feat(repos): branch van een app-repo tonen en wijzigen
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 6m45s

Sjablonen komen uit de git-cache van een app-repo, niet uit het image. Welke
branch die volgt was nergens te zien en alleen te wijzigen door de repo te
verwijderen en opnieuw toe te voegen — waarbij de id een volgnummer krijgt en
het bronsjabloon van bestaande apps onvindbaar wordt.

- PUT /api/repos/<id> past naam, branch, submap en optioneel de URL aan; de id
  blijft staan zodat .serverup.json van bestaande stacks blijft kloppen.
- Een leeg tokenveld laat het bestaande token staan.
- De repolijst toont de branch en heeft een bewerkknop; na opslaan wordt de
  repo meteen opnieuw opgehaald.
- Nieuwe auditactie 'edit' vertaald in nl en en.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9Vcwu7owJZ3nj5dCf4VFT
This commit is contained in:
Ramon 2026-08-06 22:10:42 +02:00
parent dce4e34894
commit 483f80e032
7 changed files with 187 additions and 1 deletions

View file

@ -1,3 +1,19 @@
# v0.8.23-beta — De branch van een repo is te zien en te wijzigen
Sjablonen komen uit de git-cache van een app-repo, niet uit het image van
Server Up. Welke branch die repo volgt was nergens te zien, en alleen te
veranderen door de repo te verwijderen en opnieuw toe te voegen. De id wordt
dan uit de URL afgeleid en krijgt er een volgnummer bij zodra hij al bestaat —
en juist die id staat in de metadata van elke app uit die repo. Overstappen
maakte zo stilzwijgend het bronsjabloon van je apps onvindbaar.
- **De branch staat in de lijst.** Zo is te zien waarom een app een oudere
versie van zijn sjabloon blijft renderen.
- **Bewerken zonder de id te raken.** Naam, branch en submap zijn aan te passen;
na opslaan wordt de repo meteen opnieuw opgehaald.
- **Een leeg tokenveld wist het token niet.** De interface krijgt het token
nooit te zien, dus zou opslaan hem anders weggooien.
# v0.8.22-beta — UniFi leest zijn databasegegevens opnieuw in # v0.8.22-beta — UniFi leest zijn databasegegevens opnieuw in
Het linuxserver-image schrijft `/config/data/system.properties` alleen bij de Het linuxserver-image schrijft `/config/data/system.properties` alleen bij de

View file

@ -1 +1 @@
0.8.22-beta 0.8.23-beta

View file

@ -1445,6 +1445,49 @@ def api_repos_add():
return jsonify(ok=True) return jsonify(ok=True)
@app.route("/api/repos/<rid>", methods=["PUT"])
def api_repos_edit(rid):
"""Naam, branch of submap van een bestaande repo aanpassen.
Zonder dit kon je alleen verwijderen en opnieuw toevoegen. De id wordt
daarbij uit de URL afgeleid en krijgt er een volgnummer bij zodra hij al
bestaat en juist die id staat in de `.serverup.json` van elke stack die
uit deze repo komt. Overstappen van de ene branch naar de andere maakte zo
stilletjes het bronsjabloon van je apps onvindbaar.
"""
d = request.json or {}
repos = list(cfg.load().get("APP_REPOS", []))
repo = next((r for r in repos
if isinstance(r, dict) and r.get("id") == rid), None)
if not repo:
return jsonify(ok=False, msg="niet gevonden"), 404
if "url" in d:
url = str(d.get("url") or "").strip()
if not url:
return jsonify(ok=False, msg="URL vereist"), 400
if not cfg.valid_repo_url(url):
return jsonify(ok=False, msg=_BAD_URL_MSG), 400
repo["url"] = url
for veld in ("name", "branch", "subdir"):
if veld in d:
repo[veld] = str(d.get(veld) or "").strip()
# Een leeg tokenveld betekent "ongewijzigd": de interface krijgt het token
# nooit te zien, dus zou opslaan hem anders wissen.
if str(d.get("token") or "").strip():
repo["token"] = d["token"].strip()
if not repo.get("branch"):
repo["branch"] = "main"
cfg.patch({"APP_REPOS": repos})
audit.log("repos", "edit", "ok", ref=rid, detail=repo.get("branch", ""),
ip=request.remote_addr)
# De cache staat nog op de oude branch; die haalt de eerstvolgende sync op.
veilig = {k: v for k, v in repo.items() if k != "token"}
veilig["has_token"] = bool((repo.get("token") or "").strip())
return jsonify(ok=True, repo=veilig)
@app.route("/api/repos/<rid>", methods=["DELETE"]) @app.route("/api/repos/<rid>", methods=["DELETE"])
def api_repos_del(rid): def api_repos_del(rid):
# `r["id"]` op een regel zonder id gaf hier een KeyError, dus een 500 — # `r["id"]` op een regel zonder id gaf hier een KeyError, dus een 500 —

View file

@ -1395,7 +1395,12 @@ tailwind.config = {
<div class="flex items-center gap-2 py-2 border-b border-bd dark:border-bd-dark last:border-0"> <div class="flex items-center gap-2 py-2 border-b border-bd dark:border-bd-dark last:border-0">
<span class="mdi mdi-source-branch text-dm dark:text-dm-dark"></span> <span class="mdi mdi-source-branch text-dm dark:text-dm-dark"></span>
<span class="font-mono text-xs flex-1 truncate" x-text="r.name"></span> <span class="font-mono text-xs flex-1 truncate" x-text="r.name"></span>
<!-- De branch bepaalt welke sjablonen je krijgt; die stilzwijgend
weglaten maakte het onmogelijk te zien waarom een app een
oude versie bleef renderen. -->
<span class="badge badge-dim font-mono !text-[10px]" x-text="r.branch || 'main'"></span>
<span class="text-[10px] text-t2 dark:text-t2-dark" x-text="r.subdir||''"></span> <span class="text-[10px] text-t2 dark:text-t2-dark" x-text="r.subdir||''"></span>
<button @click="openRepoEdit(r)" class="btn-icon w-9 h-9" :title="t('edit')"><span class="mdi mdi-pencil-outline"></span></button>
<button @click="syncRepo(r.id)" class="btn-icon w-9 h-9"><span class="mdi mdi-refresh"></span></button> <button @click="syncRepo(r.id)" class="btn-icon w-9 h-9"><span class="mdi mdi-refresh"></span></button>
<button @click="deleteRepo(r.id)" class="btn-icon w-9 h-9 !text-er hover:!border-er/40"><span class="mdi mdi-delete-outline"></span></button> <button @click="deleteRepo(r.id)" class="btn-icon w-9 h-9 !text-er hover:!border-er/40"><span class="mdi mdi-delete-outline"></span></button>
</div> </div>
@ -1462,6 +1467,29 @@ tailwind.config = {
<!-- ░░ Modals ░░ --> <!-- ░░ Modals ░░ -->
<div x-show="modal" @click.self="modal=null" x-cloak class="fixed inset-0 bg-slate-900/50 backdrop-blur-sm z-[60] grid place-items-center p-0 sm:p-4 overflow-hidden"> <div x-show="modal" @click.self="modal=null" x-cloak class="fixed inset-0 bg-slate-900/50 backdrop-blur-sm z-[60] grid place-items-center p-0 sm:p-4 overflow-hidden">
<!-- Repo bewerken: alleen branch, submap en naam. De URL en de id blijven
staan, want die id staat in de metadata van elke stack uit deze repo. -->
<div x-show="modal==='edit-repo'" class="modal max-w-md">
<div class="modal-head">
<span class="mdi mdi-source-branch text-ac dark:text-ac-dark"></span>
<span x-text="t('edit_repo')">Repository bewerken</span>
<span class="font-mono text-xs text-t2 dark:text-t2-dark" x-text="repoEdit.id"></span>
</div>
<div class="modal-body space-y-3">
<div><label class="form-label" x-text="t('repo_name')">Naam</label><input class="form-input" x-model="repoEdit.name"></div>
<div class="grid grid-cols-2 gap-3">
<div><label class="form-label" x-text="t('repo_branch')">Branch</label><input class="form-input font-mono" x-model="repoEdit.branch"></div>
<div><label class="form-label" x-text="t('repo_subdir')">Submap</label><input class="form-input" x-model="repoEdit.subdir" placeholder="apps"></div>
</div>
<p class="text-[11px] text-t2 dark:text-t2-dark font-mono truncate" x-text="repoEdit.url"></p>
<p class="text-[11px] text-t2 dark:text-t2-dark" x-text="t('repo_branch_hint')"></p>
</div>
<div class="modal-foot">
<button class="btn" @click="modal=null" x-text="t('cancel')">Annuleren</button>
<button class="btn btn-primary" @click="saveRepo()"><span class="mdi mdi-content-save-outline"></span> <span x-text="t('save')">Opslaan</span></button>
</div>
</div>
<!-- Add repo --> <!-- Add repo -->
<div x-show="modal==='add-repo'" class="modal max-w-md"> <div x-show="modal==='add-repo'" class="modal max-w-md">
<div class="modal-head"><span class="mdi mdi-store-plus-outline text-ac dark:text-ac-dark"></span> <span x-text="t('add_repo')">Repository toevoegen</span></div> <div class="modal-head"><span class="mdi mdi-store-plus-outline text-ac dark:text-ac-dark"></span> <span x-text="t('add_repo')">Repository toevoegen</span></div>
@ -2638,6 +2666,7 @@ function app() {
job: { id: null, lines: [], status: 'idle', timer: null }, job: { id: null, lines: [], status: 'idle', timer: null },
newRepo: { name:'', url:'', branch:'main', subdir:'apps' }, newRepo: { name:'', url:'', branch:'main', subdir:'apps' },
repoEdit: { id:'', name:'', url:'', branch:'', subdir:'' },
install: { install: {
stack: '', repo_id: '', instance: '', stack: '', repo_id: '', instance: '',
format: 'compose', loading: false, format: 'compose', loading: false,
@ -3732,6 +3761,21 @@ function app() {
this.toast('Verwijderd', 'ok'); this.toast('Verwijderd', 'ok');
this.loadAppStore(); this.loadAppStore();
}, },
openRepoEdit(repo) {
this.repoEdit = { id: repo.id, name: repo.name || '', url: repo.url || '',
branch: repo.branch || 'main', subdir: repo.subdir || '' };
this.modal = 'edit-repo';
},
async saveRepo() {
const d = await this.req(`/api/repos/${encodeURIComponent(this.repoEdit.id)}`,
{ method:'PUT', json: this.repoEdit });
if (!d.ok) { this.toast(d.msg || this.t('error'), 'er'); return; }
this.modal = null;
// Meteen ophalen: de cache staat nog op de vorige branch, en zonder deze
// stap zie je in de app store nog steeds de oude sjablonen.
await this.syncRepo(this.repoEdit.id);
await this.loadAppStore();
},
async addRepo() { async addRepo() {
const r = await this.rpc('/api/repos', this.newRepo); const r = await this.rpc('/api/repos', this.newRepo);
if (r.ok) { this.toast('Toegevoegd', 'ok'); this.modal=null; this.newRepo = { name:'', url:'', branch:'main', subdir:'apps' }; this.loadAppStore(); } if (r.ok) { this.toast('Toegevoegd', 'ok'); this.modal=null; this.newRepo = { name:'', url:'', branch:'main', subdir:'apps' }; this.loadAppStore(); }

View file

@ -50,6 +50,8 @@
"no_stacks": "No apps found", "no_stacks": "No apps found",
"no_images": "No images found", "no_images": "No images found",
"no_repos": "No repository added yet — add one to see apps.", "no_repos": "No repository added yet — add one to see apps.",
"edit_repo": "Edit repository",
"repo_branch_hint": "The branch decides which version of the templates you get. Saving re-fetches the repo right away; existing apps only change once you save their settings.",
"add_repo": "Add repository", "add_repo": "Add repository",
"repo_name": "Name", "repo_name": "Name",
"repo_url": "Git URL", "repo_url": "Git URL",
@ -377,6 +379,7 @@
"audit_act_add": "Added", "audit_act_add": "Added",
"audit_act_app_create": "Network created", "audit_act_app_create": "Network created",
"audit_act_app_delete": "Network deleted", "audit_act_app_delete": "Network deleted",
"audit_act_edit": "Repo edited",
"audit_act_apply": "Applied", "audit_act_apply": "Applied",
"audit_act_backup": "Backed up", "audit_act_backup": "Backed up",
"audit_act_boot_repo": "Repo at startup", "audit_act_boot_repo": "Repo at startup",

View file

@ -50,6 +50,8 @@
"no_stacks": "Geen apps gevonden", "no_stacks": "Geen apps gevonden",
"no_images": "Geen images gevonden", "no_images": "Geen images gevonden",
"no_repos": "Nog geen repository toegevoegd — voeg er een toe om apps te zien.", "no_repos": "Nog geen repository toegevoegd — voeg er een toe om apps te zien.",
"edit_repo": "Repository bewerken",
"repo_branch_hint": "De branch bepaalt welke versie van de sjablonen je krijgt. Na opslaan wordt de repo meteen opnieuw opgehaald; bestaande apps veranderen pas als je hun instellingen opslaat.",
"add_repo": "Repository toevoegen", "add_repo": "Repository toevoegen",
"repo_name": "Naam", "repo_name": "Naam",
"repo_url": "Git URL", "repo_url": "Git URL",
@ -377,6 +379,7 @@
"audit_act_add": "Toegevoegd", "audit_act_add": "Toegevoegd",
"audit_act_app_create": "Netwerk gemaakt", "audit_act_app_create": "Netwerk gemaakt",
"audit_act_app_delete": "Netwerk verwijderd", "audit_act_app_delete": "Netwerk verwijderd",
"audit_act_edit": "Repo bewerkt",
"audit_act_apply": "Toegepast", "audit_act_apply": "Toegepast",
"audit_act_backup": "Backup gemaakt", "audit_act_backup": "Backup gemaakt",
"audit_act_boot_repo": "Repo bij opstarten", "audit_act_boot_repo": "Repo bij opstarten",

View file

@ -280,3 +280,80 @@ def test_verwijderen_haalt_alle_regels_met_die_id_weg(client, env):
assert r.status_code == 200, r.get_json() assert r.status_code == 200, r.get_json()
over = core.load()["APP_REPOS"] over = core.load()["APP_REPOS"]
assert [x.get("id") for x in over] == ["blijft", None], over assert [x.get("id") for x in over] == ["blijft", None], over
# ── Een bestaande repo aanpassen ─────────────────────────────────────────────
def test_branch_van_bestaande_repo_wijzigen(client, env):
"""De id van een repo staat in de metadata van elke stack die eruit komt.
Wie van branch wilde wisselen kon alleen verwijderen en opnieuw toevoegen;
de id kreeg er dan een volgnummer bij en het bronsjabloon van bestaande
apps was ineens onvindbaar.
"""
csrf = login(client)
client.post("/api/repos",
json={"url": "https://example.com/eigen-apps.git", "branch": "main",
"subdir": "apps"},
headers={"X-CSRF-Token": csrf})
r = client.put("/api/repos/eigen-apps", json={"branch": "dev"},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 200, r.get_json()
repo = next(x for x in client.get("/api/repos").get_json()
if x["id"] == "eigen-apps")
assert repo["branch"] == "dev"
# De rest blijft staan; alleen wat je meestuurt verandert.
assert repo["subdir"] == "apps"
assert repo["url"] == "https://example.com/eigen-apps.git"
def test_bewerken_van_onbekende_repo(client, env):
csrf = login(client)
r = client.put("/api/repos/bestaatniet", json={"branch": "dev"},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 404
def test_bewerken_wist_een_bestaand_token_niet(client, env):
"""De interface krijgt het token nooit te zien, dus stuurt hij het leeg
terug. Dat mag geen repo zonder toegang opleveren."""
csrf = login(client)
client.post("/api/repos",
json={"url": "https://example.com/prive.git", "token": "s3cr3t-token"},
headers={"X-CSRF-Token": csrf})
r = client.put("/api/repos/prive", json={"branch": "dev", "token": ""},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 200
assert "s3cr3t-token" not in r.get_data(as_text=True)
from core import config as _cfg # noqa: F401
import core
repo = next(x for x in core.load()["APP_REPOS"] if x["id"] == "prive")
assert repo["token"] == "s3cr3t-token"
assert repo["branch"] == "dev"
def test_bewerken_weigert_een_ongeldige_url(client, env):
csrf = login(client)
client.post("/api/repos", json={"url": "https://example.com/proef.git"},
headers={"X-CSRF-Token": csrf})
r = client.put("/api/repos/proef", json={"url": 'ext::sh -c "id"'},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 400
def test_viewer_mag_geen_repo_bewerken(client, env):
csrf = login(client)
client.post("/api/repos", json={"url": "https://example.com/proef.git"},
headers={"X-CSRF-Token": csrf})
client.post("/api/auth/users",
json={"username": "kijker", "password": "eenlangwachtwoord",
"role": "viewer"}, headers={"X-CSRF-Token": csrf})
kijker = client.post("/api/auth/login",
json={"username": "kijker", "password": "eenlangwachtwoord"}
).get_json()["csrf_token"]
assert client.put("/api/repos/proef", json={"branch": "dev"},
headers={"X-CSRF-Token": kijker}).status_code == 403