7.3.1.1. go-nc-exapp
go-nc-exapp ist die gemeinsame Go-Library für Nextcloud-ExApp, Glossar, öffnet in neuem Tab
-Services: AppAPI Credentials, Glossar, öffnet in neuem Tab
, authentifizierte OCS, Glossar, öffnet in neuem Tab
-Aufrufe, ExApp Preferences, Glossar, öffnet in neuem Tab
und Notifications, Glossar, öffnet in neuem Tab
pro User, Users and Groups, Glossar, öffnet in neuem Tab
-Reads, optionaler Required Groups, Glossar, öffnet in neuem Tab
-Access Gate, Glossar, öffnet in neuem Tab
, optionale App Navigation, Glossar, öffnet in neuem Tab
-Shell und Dialog, Glossar, öffnet in neuem Tab
.
Import-Pfad: gitea.neitzel.de/konrad/go-nc-exapp, Package-Name: gonexapp. Repo: gitea.neitzel.de/konrad/go-nc-exapp.
Diese Seite gehört zu 7.3.1. Nextcloud ExApps. Englische Fachbegriffe sind im Glossar erklärt.
go get gitea.neitzel.de/konrad/go-nc-exapp
Der Modulpfad ist die Library-Identität auf Gitea. Klonen und Fetch laufen über SSH auf Port 2222 (ssh://git@gitea.neitzel.de:2222/konrad/go-nc-exapp.git), nicht über den HTTPS-Pfad, den der Modulname nahelegt. Private Module brauchen GOPRIVATE=gitea.neitzel.de, damit go get nicht den öffentlichen Proxy trifft.
import gonexapp "gitea.neitzel.de/konrad/go-nc-exapp"
flowchart LR HaRP -->|"AUTHORIZATION-APP-API"| UserFromRequest UserFromRequest --> AccessGate AccessGate -->|"Allowed"| Handler Handler -->|"AuthHeaders / WithUser"| Nextcloud
Die Library deckt AppAPI, Glossar, öffnet in neuem Tab -seitige Aufrufe und ausgewählte UI-Bausteine ab. Bewusst außerhalb:
- Lifecycle-HTTP-Routen (
/heartbeat,/enabled,/init) – der Access Gate überspringt sie, implementiert sie aber nicht - HaRP, Glossar, öffnet in neuem Tab
-Listen,
serve()und Unix-Socket-Bootstrap - Registrierung von Top-Menu, Script und Iframe in Nextcloud
- WebDAV und Dateispeicher → 7.3.1.2. go-nc-files
- User Tokens, Glossar, öffnet in neuem Tab ExApp → Microservice, Glossar, öffnet in neuem Tab → 7.3.2. go-usertoken
- Fan-out-Notifications (
SendToGroup,SendToAdmins) - ein Library-Default-Admin für Directory-OCS (Aufrufer setzen den Admin mit
WithUser)
Credentials bündelt, was ein ExApp braucht, um Nextcloud als ein User anzusprechen: BaseURL, AppID, AppVersion, AAVersion, AppSecret, UserID. Die Werte kommen typischerweise aus der Deploy-Umgebung, die HaRP injiziert. Ein trailing Slash an BaseURL ist erlaubt; Aufrufer in der Library entfernen ihn beim URL-Bau. Leere Felder erzeugen keine Konstruktorfehler – die Nextcloud-Antwort scheitert später.
AuthHeaders setzt die AppAPI-Header für ausgehende Requests: AA-VERSION, EX-APP-ID, EX-APP-VERSION, AUTHORIZATION-APP-API, OCS-APIRequest. AUTHORIZATION-APP-API ist Base64 von UserID:AppSecret. OCSClient und go-nc-files rufen das intern; für eigene HTTP-Calls reicht req.Header = cred.AuthHeaders().
WithUser liefert eine Kopie mit anderem UserID. Das Original bleibt unverändert. Braucht ein Directory-Call einen Admin, setzt der Aufrufer WithUser am Call-Site – die Library wählt keinen Admin.
Auf eingehenden, von HaRP proxied Requests liest UserFromRequest den Requesting User, Glossar, öffnet in neuem Tab
aus AUTHORIZATION-APP-API. Fehlender Header, ungültiges Base64 oder fehlende User-Id vor dem Doppelpunkt → Fehler. Der Access Gate ruft das vor dem Gruppen-Check. Die Funktion vergleicht das Secret nicht; die ExApp muss den Secret-Check auf User-Routen selbst machen.
OCSClient macht AppAPI-authentifizierte OCS-Requests und verlangt JSON-Bodies. Jede URL bekommt format=json. URL baut die Adresse unter /ocs/v2.php/…; Call führt Methode, Pfad und optionalen Body aus und prüft nur, dass die Antwort gültiges JSON ist – kein festes OCS-Schema. nil Client → http.DefaultClient. Preferences, Notifications und Groups bauen darauf auf. Nicht-2xx und Transportfehler kommen als Fehler inkl. Status zurück.
NewAppAPIPreferences(cred, appID, key) liest und schreibt eine String-Preference für den Requesting User. App-Id und Key wählt der Aufrufer; die Library reserviert keine Produkt-Keys. Get liefert bei fehlendem Key leeren String und nil Fehler. Set speichert non-sensitive. Passt zu einem DefaultPathStore in go-nc-files, wenn der Saved-Default-Pfad in Preferences liegt.
NewAppAPINotifications(cred) erzeugt eine Bell-Notification für einen Recipient, Glossar, öffnet in neuem Tab
. Notification hat Pflichtfeld Subject; optional Message, Link, SubjectParams, MessageParams. Send geht an Cred.UserID, SendTo an einen anderen User (Auth über WithUser). Kein Fan-out an Gruppen oder Admins. AppAPI akzeptiert keine Actions und kein Custom-Icon.
NewGroups(cred) wrappt Provisioning-OCS ohne Suche und ohne Paging:
| Methode | Was | Auth |
|---|---|---|
UserGroups(userID) |
Gruppen-Ids eines Users | als userID |
GroupMembers(groupID) |
User-Ids in der Gruppe | als Cred-User (Admin/Subadmin) |
ListGroups() |
Gruppen-Ids der Instanz | als Cred-User (Admin/Subadmin) |
Distinct von Required Groups: das ist Directory, nicht ACL.
Der Access Gate lässt Requests nur durch, wenn der Requesting User in einer der Required Groups ist. Leere Gruppenliste → Gate aus. AppAPI erzwingt das nicht; die ExApp schon.
Deploy-Env:
REQUIRED_GROUPS(EnvRequiredGroups) – komma-separiert, any-ofREQUIRED_GROUPS_CACHE_SECONDS(EnvRequiredGroupsCacheSeconds) – Default 60;"0"schaltet den Cache aus
ResolveRequiredGroups: Variable unset → Code-Default; gesetzt (auch leer) ersetzt den Default. ParseCacheSeconds mappt die Cache-Env auf eine Duration.
AccessGate{Cred, Groups, CacheTTL}.Wrap(handler) prüft vor next. Ergebnisse von Check:
| Ergebnis | HTTP |
|---|---|
CheckAllowed |
weiter zu next |
CheckUnauthorized |
401 |
CheckUnavailable |
503 |
CheckDenied |
HTML mit 200 + frame-ancestors 'self' (Accept text/html), sonst 403 |
200 + CSP halten AppAPI davon ab, das Iframe zu blanken. Positive Membership wird für CacheTTL gecacht; Denials nicht. Skip-Pfade: /heartbeat, /enabled, /init, alles unter /js/, plus ExtraSkipPaths (Top-Menu-Scripts unter /js/ müssen für Nicht-Mitglieder ladbar bleiben).
TopMenuAdminRequired(env, default) liefert "0" oder "1" für AppAPI’s Top-Menu-Feld adminRequired (Top Menu Visibility, Glossar, öffnet in neuem Tab
). Env-Name: TOP_MENU_ADMIN_REQUIRED (EnvTopMenuAdminRequired), in info.xml deklarieren. Default der Library: DefaultTopMenuAdminRequired (true → nur Admins). Nur "0" und "1" gelten; alles andere inkl. leer → Default. Die Library registriert das Menü nicht. Unabhängig von access_level in info.xml und von Required Groups. Nach Env-Änderung: Container neu, dann ExApp disable/enable (oder Update), damit PUT /enabled?enabled=1 erneut läuft.
Optionale Files-artige Shell. AppNavigation braucht Items, Page, DefaultID; optional Header, MissingMessage, Title, SelectKey (Default item), DisableTheme. Handler() auf die Page-Route mounten ersetzt die eigene Page; ohne Mount bleibt die ExApp-Page unverändert.
Items liefert den Baum (Item: ID, Label, optionale Children, optionales Icon). Page liefert HTML-Body und ob die Id bekannt ist. Fehlende Id → DefaultID, MissingMessage, korrigierte Query auf data-address. Andere Query-Parameter (z. B. Visit-Folder) bleiben erhalten. Unter 1024px bleibt der Baum zugeklappt, bis der User ihn öffnet. Zero-Value von DisableTheme lädt Nextcloud-Theme und Hintergrund; Dialog ist eingebaut; Response setzt frame-ancestors 'self'.
NextcloudIcons sind Pfade zu Core-SVGs unter /core/img/ (Folder, File, Home, Actions, …). Die Library liefert die Dateien nicht mit. Item.Icon setzt man auf einen same-origin Image-URL oder auf ein Feld von NextcloudIcons (z. B. NextcloudIcons.Folder). Dark Mode invertiert über Nextclouds --background-invert-if-dark.
Modal für Message, Confirm und Prompt. App navigation bindet ihn ein; eigene Pages fügen DialogHTML() einmal ein. Buttons: OK / Cancel (Agree-Text austauschbar). Aufruf aus JS: exappDialog.message, .confirm, .prompt – Promise wartet auf die Wahl; keine Server-Roundtrip. Message: heading, text, severity (info/warning/error), kein Cancel. Confirm: optional agree, destructive → true/false. Prompt: optional value, agree → getrimmter String oder null; Agree bleibt disabled, solange das Feld leer ist. Aufrufe sind gequeued.
cred := gonexapp.Credentials{
BaseURL: "https://cloud.example",
AppID: "myexapp",
AppVersion: "0.1.0",
AAVersion: "1.0.0",
AppSecret: os.Getenv("APP_SECRET"),
UserID: "alice",
}
req.Header = cred.AuthHeaders()
userID, err := gonexapp.UserFromRequest(r)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
cred.UserID = userID
ocs := gonexapp.OCSClient{Cred: cred}
raw, err := ocs.Call(http.MethodGet, "cloud/capabilities", nil)
if err != nil {
return err
}
_ = raw
prefs := gonexapp.NewAppAPIPreferences(cred, "myexapp", "savedDefault")
value, err := prefs.Get()
if err != nil {
return err
}
if err := prefs.Set("Zones"); err != nil {
return err
}
_ = value
err := gonexapp.NewAppAPINotifications(cred).Send(gonexapp.Notification{
Subject: "Job finished",
Message: "The zone was updated.",
})
asAdmin := cred.WithUser(adminID)
members, err := gonexapp.NewGroups(asAdmin).GroupMembers("ops")
if err != nil {
return err
}
_ = members
groupsEnv, groupsSet := os.LookupEnv(gonexapp.EnvRequiredGroups)
groups := gonexapp.ResolveRequiredGroups(groupsEnv, groupsSet, nil)
ttl := gonexapp.ParseCacheSeconds(
os.Getenv(gonexapp.EnvRequiredGroupsCacheSeconds),
gonexapp.DefaultCacheSeconds,
)
handler = gonexapp.AccessGate{Cred: cred, Groups: groups, CacheTTL: ttl}.Wrap(handler)
Leere AccessGate{} lässt alles durch (keine Required Groups):
h := gonexapp.AccessGate{}.Wrap(inner)
adminRequired := gonexapp.TopMenuAdminRequired(
os.Getenv(gonexapp.EnvTopMenuAdminRequired),
gonexapp.DefaultTopMenuAdminRequired,
)
nav := gonexapp.AppNavigation{
DefaultID: "home",
Items: func(*http.Request) []gonexapp.Item {
return []gonexapp.Item{{
ID: "home", Label: "Home", Icon: gonexapp.NextcloudIcons.Home,
}}
},
Page: func(_ *http.Request, id string) (string, bool) {
if id != "home" {
return "", false
}
return "<p>Home</p>", true
},
}
http.Handle("/page", nav.Handler())
fmt.Fprint(w, gonexapp.DialogHTML())
<script>
const ok = await exappDialog.confirm({
heading: "Delete",
text: "Delete this file?",
agree: "Delete",
destructive: true,
});
const name = await exappDialog.prompt({
heading: "Name",
text: "Folder name",
value: "Zones",
});
</script>