@@ -2720,6 +2810,7 @@ function app() {
newRepo: { name:'', url:'', branch:'main', subdir:'apps' },
repoEdit: { id:'', name:'', url:'', branch:'', subdir:'' },
+ devices: [],
install: {
stack: '', repo_id: '', instance: '',
format: 'compose', loading: false,
@@ -3356,6 +3447,37 @@ function app() {
this.appNets = d.networks || [];
if (!this.newNetName) this.newNetName = d.suggested || '';
},
+ // ── Eigen mappen en apparaten ────────────────────────────────────────────
+ async loadDevices() {
+ const d = await this.rpc('/api/devices');
+ this.devices = (d && d.devices) || [];
+ },
+ _eersteDienst() {
+ const info = this.install.service_info || [];
+ const echte = info.find(s => !s.helper) || info[0];
+ return (echte || {}).name || '';
+ },
+ voegMapToe() {
+ if (!Array.isArray(this.install.extras.volumes)) this.install.extras.volumes = [];
+ this.install.extras.volumes.push(
+ { service: this._eersteDienst(), host: '', container: '', ro: false });
+ },
+ voegApparaatToe(pad = '') {
+ if (!Array.isArray(this.install.extras.devices)) this.install.extras.devices = [];
+ if (pad && this.install.extras.devices.some(a => a.host === pad)) return;
+ this.install.extras.devices.push(
+ { service: this._eersteDienst(), host: pad, container: '' });
+ },
+ // Bestaat dit pad op de host, en komt Server Up er überhaupt bij? Een map
+ // die alleen binnen de container bestaat levert een lege bind-mount op.
+ async checkExtraPad(v) {
+ v.fout = '';
+ if (!v.host) return;
+ if (!v.container) v.container = v.host;
+ const d = await this.rpc('/api/paths/check?path=' + encodeURIComponent(v.host));
+ if (d && !d.ok) v.fout = d.msg || this.t('path_unreachable');
+ },
+
// Zonder eigen keuze bepaalt de server het: alles behalve databases en
// zoekindexen. Dat spiegelen we hier, zodat de vinkjes kloppen met wat er
// straks in het compose-bestand belandt.
@@ -3868,6 +3990,7 @@ function app() {
step: 0, tab: 'compose', editing: false, composeText: '', envText: '',
network: '', ip: '', ips: {}, services: [], service_info: [],
app_services: [], depends_on: [],
+ extras: { volumes: [], devices: [] },
compatibility: { known: false, supported: true, architecture: '', reason: '' },
architecture_note: ''
};
@@ -3951,6 +4074,7 @@ function app() {
verbinden: { icon: 'mdi-lan-connect', key: 'step_network' },
instellingen: { icon: 'mdi-cog-outline', key: 'step_settings' },
geheimen: { icon: 'mdi-key-variant', key: 'step_secrets' },
+ extras: { icon: 'mdi-usb-flash-drive-outline', key: 'step_extras' },
controleren: { icon: 'mdi-file-check-outline', key: 'step_review' },
},
wizardActief() {
@@ -3972,6 +4096,7 @@ function app() {
// 'basis' toont ook de instantienaam, dus die stap is nooit leeg.
if (id === 'basis' || this.stepFields(id).length) uit.push(this._stap(id));
}
+ uit.push(this._stap('extras'));
uit.push(this._stap('controleren'));
return uit;
},
@@ -4025,7 +4150,7 @@ function app() {
stepMissing() {
if (!this.wizardActief()) return [];
const s = this.wizardSteps()[this.install.step];
- if (!s || s.id === 'controleren' || s.id === 'verbinden') return [];
+ if (!s || ['controleren', 'verbinden', 'extras'].includes(s.id)) return [];
return this.stepFields(s.id).filter(f => {
if (!f.required || f.is_group_toggle) return false;
if (f.group_toggle && !this.install.values[f.group_toggle]) return false;
@@ -4044,7 +4169,8 @@ function app() {
const stapId = stappen[this.install.step].id;
// Ook bij Verbinden: daar staat de lijst containers die uit de
// voorvertoning komt, en die verandert met elk onderdeel dat je aanzet.
- if (['controleren', 'verbinden'].includes(stapId)) await this.refreshPreview();
+ if (['controleren', 'verbinden', 'extras'].includes(stapId)) await this.refreshPreview();
+ if (stapId === 'extras' && !this.devices.length) await this.loadDevices();
// Meteen laten zien of de poorten van deze stap vrij zijn.
this.checkPoortenVanStap(stapId);
},
@@ -4120,6 +4246,8 @@ function app() {
app_networks: d.app_networks || (d.connected ? [this.sharedNetwork] : []),
app_services: d.app_services || [], service_info: [],
source_repo: d.source_repo || {},
+ extras: { volumes: (d.extras||{}).volumes || [],
+ devices: (d.extras||{}).devices || [] },
};
await this.loadAppNets(stack);
this.fieldInfo = null;
@@ -4142,6 +4270,7 @@ function app() {
connect_apps: this.install.connect_apps !== false,
app_networks: this.install.app_networks || [],
app_services: this.install.app_services || [],
+ extras: this.install.extras || {},
});
if (!d.job_id) { this.toast(d.msg || this.t('error'), 'er'); return; }
this.modal = null;
@@ -4176,6 +4305,7 @@ function app() {
body.connect_apps = this.install.connect_apps !== false;
body.app_networks = this.install.app_networks || [];
body.app_services = this.install.app_services || [];
+ body.extras = this.install.extras || {};
if (this.install.format === 'boilerplate') body.values = this.install.values;
else { body.env_overrides = this.install.envOverrides; body.image_tags = this.install.imageTags; }
// Heb je het resultaat in de laatste stap zelf bewerkt, dan telt jouw
diff --git a/server-up/translations/en.json b/server-up/translations/en.json
index 0630f0b..c522f32 100644
--- a/server-up/translations/en.json
+++ b/server-up/translations/en.json
@@ -57,6 +57,18 @@
"cache_outdated": "This repo is on {version} while Server Up runs {current}. Sync it or pick another branch — otherwise saving renders an older template over your app.",
"cache_outdated_hint": "The templates in this repo lag behind the running version of Server Up.",
"just_now": "just now",
+ "step_extras": "Extras",
+ "extra_volumes": "Folders from this host",
+ "extra_volumes_hint": "Make a folder from the server available inside the app — your media library, a disk with photos. Survives later settings changes.",
+ "extra_devices": "Devices",
+ "extra_devices_hint": "Pass through a USB stick or graphics card, for example a Zigbee stick or a GPU for transcoding.",
+ "add_volume": "Add folder",
+ "add_device": "Add device",
+ "host_path": "/srv/media",
+ "read_only": "read only",
+ "device_stable": "stable",
+ "no_devices": "No devices found on this host. You can type the path yourself, for example /dev/ttyUSB0.",
+ "path_unreachable": "Server Up cannot reach this path; check that the folder is mounted.",
"add_repo": "Add repository",
"repo_name": "Name",
"repo_url": "Git URL",
diff --git a/server-up/translations/nl.json b/server-up/translations/nl.json
index 4b097de..214b326 100644
--- a/server-up/translations/nl.json
+++ b/server-up/translations/nl.json
@@ -57,6 +57,18 @@
"cache_outdated": "Deze repo staat op {version} terwijl Server Up {current} draait. Synchroniseer hem of kies een andere branch — anders rendert opslaan een ouder sjabloon over je app heen.",
"cache_outdated_hint": "De sjablonen in deze repo lopen achter op de draaiende versie van Server Up.",
"just_now": "zojuist",
+ "step_extras": "Extra's",
+ "extra_volumes": "Mappen van deze host",
+ "extra_volumes_hint": "Een map van de server binnen de app beschikbaar maken — je mediabibliotheek, een schijf met foto's. Blijft staan als je later instellingen wijzigt.",
+ "extra_devices": "Apparaten",
+ "extra_devices_hint": "Een USB-stick of videokaart doorgeven, bijvoorbeeld een Zigbee-stick of een GPU voor transcoderen.",
+ "add_volume": "Map toevoegen",
+ "add_device": "Apparaat toevoegen",
+ "host_path": "/srv/media",
+ "read_only": "alleen lezen",
+ "device_stable": "vast",
+ "no_devices": "Geen apparaten gevonden op deze host. Je kunt het pad zelf intikken, bijvoorbeeld /dev/ttyUSB0.",
+ "path_unreachable": "Server Up kan hier niet bij; controleer of deze map gemount is.",
"add_repo": "Repository toevoegen",
"repo_name": "Naam",
"repo_url": "Git URL",
diff --git a/tests/js/menu.test.js b/tests/js/menu.test.js
index dba843a..e2c87c1 100644
--- a/tests/js/menu.test.js
+++ b/tests/js/menu.test.js
@@ -40,8 +40,8 @@ c.install = {format:'boilerplate', loading:false, fields:[
], values:{}, step:0};
c.onePage = false; c.showAdvanced = false;
let s = c.wizardSteps().map(x => x.id);
-check('eenvoudige app: basis, verbinden, controleren',
- JSON.stringify(s) === '["basis","verbinden","controleren"]');
+check('eenvoudige app: basis, verbinden, extra\'s, controleren',
+ JSON.stringify(s) === '["basis","verbinden","extras","controleren"]');
// ── App met geheimen ────────────────────────────────────────────────────────
c.install.fields.push(veld('db_password','geheimen',{required:true, secret:true}));
diff --git a/tests/test_compose_transform.py b/tests/test_compose_transform.py
index c1c997c..6f37093 100644
--- a/tests/test_compose_transform.py
+++ b/tests/test_compose_transform.py
@@ -158,3 +158,97 @@ def test_onbekende_service_geeft_fout():
def test_published_ports():
assert ct.published_ports(BASIS) == ["8222:80"]
assert ct.published_ports("services:\n a:\n image: x\n") == []
+
+
+# ── Eigen mappen en apparaten ────────────────────────────────────────────────
+
+EXTRA_BASIS = """\
+services:
+ app:
+ image: nginx
+ volumes:
+ - /opt/serverup/appdata/app:/config
+ db:
+ image: postgres:16-alpine
+"""
+
+
+def test_datamap_wordt_toegevoegd():
+ """Een mediamap die ergens anders staat kon alleen via het compose-bestand,
+ en dat wordt bij de eerstvolgende wijziging overschreven."""
+ uit = ct.add_extras(EXTRA_BASIS, {"volumes": [
+ {"service": "app", "host": "/srv/media", "container": "/media"}]})
+ doc = yaml.safe_load(uit)
+ assert "/srv/media:/media" in doc["services"]["app"]["volumes"]
+ # Wat er al stond blijft staan.
+ assert "/opt/serverup/appdata/app:/config" in doc["services"]["app"]["volumes"]
+
+
+def test_datamap_alleen_lezen():
+ uit = ct.add_extras(EXTRA_BASIS, {"volumes": [
+ {"service": "app", "host": "/srv/foto", "container": "/foto", "ro": True}]})
+ assert "/srv/foto:/foto:ro" in yaml.safe_load(uit)["services"]["app"]["volumes"]
+
+
+def test_apparaat_wordt_toegevoegd():
+ uit = ct.add_extras(EXTRA_BASIS, {"devices": [
+ {"service": "app", "host": "/dev/ttyUSB0", "container": "/dev/ttyACM0"}]})
+ assert yaml.safe_load(uit)["services"]["app"]["devices"] == \
+ ["/dev/ttyUSB0:/dev/ttyACM0"]
+
+
+def test_apparaat_zonder_pad_binnen_de_container():
+ uit = ct.add_extras(EXTRA_BASIS, {"devices": [
+ {"service": "app", "host": "/dev/ttyUSB0"}]})
+ assert yaml.safe_load(uit)["services"]["app"]["devices"] == \
+ ["/dev/ttyUSB0:/dev/ttyUSB0"]
+
+
+def test_extras_zijn_idempotent():
+ """Elke wijziging rendert opnieuw en past dit opnieuw toe; twee mounts op
+ hetzelfde bronpad laat compose stuklopen."""
+ extras = {"volumes": [{"service": "app", "host": "/srv/media",
+ "container": "/media"}]}
+ een = ct.add_extras(EXTRA_BASIS, extras)
+ twee = ct.add_extras(een, extras)
+ volumes = yaml.safe_load(twee)["services"]["app"]["volumes"]
+ assert volumes.count("/srv/media:/media") == 1
+
+
+def test_onbekende_service_wordt_overgeslagen():
+ """Een hernoemd onderdeel mag geen kapot compose-bestand opleveren."""
+ uit = ct.add_extras(EXTRA_BASIS, {"volumes": [
+ {"service": "bestaatniet", "host": "/srv/x", "container": "/x"}]})
+ assert yaml.safe_load(uit)["services"].keys() == {"app", "db"}
+
+
+def test_gevaarlijke_mounts_worden_geweigerd():
+ """Een container die /etc of de Docker-socket mag lezen, is de host."""
+ for pad in ("/", "/etc", "/etc/ssh", "/var/run/docker.sock", "/proc/self",
+ "/boot", "relatief/pad", "/srv/../etc"):
+ assert ct.mount_bezwaar(pad), pad
+ for pad in ("/srv/media", "/mnt/schijf/films", "/opt/data"):
+ assert ct.mount_bezwaar(pad) == "", pad
+
+
+def test_alleen_echte_apparaten():
+ for pad in ("/dev", "/srv/media", "/dev/../etc", "ttyUSB0", ""):
+ assert ct.apparaat_bezwaar(pad), pad
+ for pad in ("/dev/ttyUSB0", "/dev/serial/by-id/usb-abc", "/dev/dri/renderD128"):
+ assert ct.apparaat_bezwaar(pad) == "", pad
+
+
+def test_geweigerd_pad_belandt_niet_in_compose():
+ """De controle in de API is de eerste zeef; deze is de laatste."""
+ uit = ct.add_extras(EXTRA_BASIS, {
+ "volumes": [{"service": "app", "host": "/var/run/docker.sock",
+ "container": "/var/run/docker.sock"}],
+ "devices": [{"service": "app", "host": "/etc/shadow"}]})
+ doc = yaml.safe_load(uit)
+ assert "docker.sock" not in str(doc["services"]["app"].get("volumes"))
+ assert "devices" not in doc["services"]["app"]
+
+
+def test_zonder_extras_verandert_er_niets():
+ assert ct.add_extras(EXTRA_BASIS, {}) == EXTRA_BASIS
+ assert ct.add_extras(EXTRA_BASIS, {"volumes": [], "devices": []}) == EXTRA_BASIS
diff --git a/tests/test_reconfigure.py b/tests/test_reconfigure.py
index 681d8da..0a813fb 100644
--- a/tests/test_reconfigure.py
+++ b/tests/test_reconfigure.py
@@ -290,3 +290,96 @@ def test_audit_endpoint_limiet_wordt_begrensd(client, env, gevuld):
login(client)
d = client.get("/api/audit?limit=99999").get_json()
assert d["limit"] <= 500
+
+
+# ── Eigen mappen en apparaten ────────────────────────────────────────────────
+
+def _wijzig(client, csrf, extras, poort=9200):
+ return client.post("/api/stacks/kz/reconfigure",
+ json={"values": {"service_name": "keuzeapp", "port": poort},
+ "connect_apps": False, "extras": extras},
+ headers={"X-CSRF-Token": csrf})
+
+
+def test_datamap_en_apparaat_belanden_in_de_compose(client, env, monkeypatch):
+ """Een mediamap of USB-stick toevoegen kon alleen via 'compose bewerken',
+ en dat werd bij de eerstvolgende wijziging overschreven."""
+ import yaml as _yaml
+ import app as app_module
+ csrf = login(client)
+ _installeer_keuzeapp(client, csrf)
+ monkeypatch.setattr(app_module.docker, "validate_compose", lambda *a, **k: (True, ""))
+ monkeypatch.setattr(app_module.docker, "compose_down", lambda *a, **k: 0)
+ monkeypatch.setattr(app_module.docker, "compose_up", lambda *a, **k: 0)
+ monkeypatch.setattr(app_module.backups, "create", lambda *a, **k: {"file": "x.tar.gz"})
+
+ r = _wijzig(client, csrf, {
+ "volumes": [{"service": "keuzeapp", "host": "/srv/media",
+ "container": "/media", "ro": True}],
+ "devices": [{"service": "keuzeapp", "host": "/dev/ttyUSB0"}]})
+ assert r.status_code == 200, r.get_json()
+ d = _wacht_op_job(client, r.get_json()["job_id"])
+ assert d.get("status") == "done", d.get("lines")
+
+ compose = next(p for p in (env["lib"] / "kz").iterdir()
+ if p.name.startswith(("compose", "docker-compose")))
+ svc = _yaml.safe_load(compose.read_text())["services"]["keuzeapp"]
+ assert "/srv/media:/media:ro" in svc["volumes"]
+ assert "/dev/ttyUSB0:/dev/ttyUSB0" in svc["devices"]
+
+ # En ze staan in de metadata, dus een volgende wijziging behoudt ze.
+ meta = json.loads((env["lib"] / "kz" / ".serverup.json").read_text())
+ assert meta["extras"]["volumes"][0]["host"] == "/srv/media"
+
+
+def test_extras_overleven_een_volgende_wijziging(client, env, monkeypatch):
+ """Het renderen begint elke keer opnieuw uit het sjabloon; zonder bewaarde
+ extra's zou je map er na één instellingswijziging weer af zijn."""
+ import yaml as _yaml
+ import app as app_module
+ csrf = login(client)
+ _installeer_keuzeapp(client, csrf)
+ for naam in ("validate_compose",):
+ monkeypatch.setattr(app_module.docker, naam, lambda *a, **k: (True, ""))
+ monkeypatch.setattr(app_module.docker, "compose_down", lambda *a, **k: 0)
+ monkeypatch.setattr(app_module.docker, "compose_up", lambda *a, **k: 0)
+ monkeypatch.setattr(app_module.backups, "create", lambda *a, **k: {"file": "x.tar.gz"})
+
+ _wacht_op_job(client, _wijzig(client, csrf, {
+ "volumes": [{"service": "keuzeapp", "host": "/srv/media",
+ "container": "/media"}]}).get_json()["job_id"])
+ # Nu een wijziging zonder extras mee te sturen: de bewaarde keuze telt.
+ r = client.post("/api/stacks/kz/reconfigure",
+ json={"values": {"service_name": "keuzeapp", "port": 9201},
+ "connect_apps": False},
+ headers={"X-CSRF-Token": csrf})
+ _wacht_op_job(client, r.get_json()["job_id"])
+
+ compose = next(p for p in (env["lib"] / "kz").iterdir()
+ if p.name.startswith(("compose", "docker-compose")))
+ svc = _yaml.safe_load(compose.read_text())["services"]["keuzeapp"]
+ assert "/srv/media:/media" in svc["volumes"]
+
+
+def test_gevaarlijk_pad_wordt_geweigerd_met_uitleg(client, env):
+ """Niet stil corrigeren: wie /etc intikt hoort te horen waarom dat niet gaat."""
+ csrf = login(client)
+ _installeer_keuzeapp(client, csrf)
+ r = _wijzig(client, csrf, {
+ "volumes": [{"service": "keuzeapp", "host": "/var/run/docker.sock",
+ "container": "/var/run/docker.sock"}]})
+ assert r.status_code == 400
+ assert "Docker-socket" in r.get_json()["msg"]
+
+ r = _wijzig(client, csrf, {
+ "devices": [{"service": "keuzeapp", "host": "/etc/shadow"}]})
+ assert r.status_code == 400
+ assert "/dev/" in r.get_json()["msg"]
+
+
+def test_apparatenlijst_is_op_te_vragen(client, env):
+ login(client)
+ d = client.get("/api/devices").get_json()
+ assert "devices" in d and isinstance(d["devices"], list)
+ for dev in d["devices"]:
+ assert dev["path"].startswith("/dev/")