Skip to main content
Entwickler Themen
Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Back to homepage

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.

Einbinden

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

Nicht enthalten

Die Library deckt AppAPI, Glossar, öffnet in neuem Tab -seitige Aufrufe und ausgewählte UI-Bausteine ab. Bewusst außerhalb:

API im Detail

Credentials, AuthHeaders, 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.

UserFromRequest

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

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.

AppAPIPreferences

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.

AppAPINotifications

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.

Groups

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.

Access Gate / REQUIRED_GROUPS

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-of
  • REQUIRED_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).

Top Menu visibility

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.

App navigation

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'.

Icons

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.

Dialog

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.

Beispiele

Credentials und AuthHeaders

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()

UserFromRequest

userID, err := gonexapp.UserFromRequest(r)
if err != nil {
    http.Error(w, "unauthorized", http.StatusUnauthorized)
    return
}
cred.UserID = userID

OCSClient

ocs := gonexapp.OCSClient{Cred: cred}
raw, err := ocs.Call(http.MethodGet, "cloud/capabilities", nil)
if err != nil {
    return err
}
_ = raw

Preferences

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

Notification

err := gonexapp.NewAppAPINotifications(cred).Send(gonexapp.Notification{
    Subject: "Job finished",
    Message: "The zone was updated.",
})

Groups und WithUser

asAdmin := cred.WithUser(adminID)
members, err := gonexapp.NewGroups(asAdmin).GroupMembers("ops")
if err != nil {
    return err
}
_ = members

Access Gate

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)

Top Menu visibility

adminRequired := gonexapp.TopMenuAdminRequired(
    os.Getenv(gonexapp.EnvTopMenuAdminRequired),
    gonexapp.DefaultTopMenuAdminRequired,
)

App navigation

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())

Dialog

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>