Extract AppAPI auth and OCS preferences from CheckDNS.

Publish gonexapp v1 with Credentials, OCSClient, and parameterized
AppAPIPreferences so ExApps share Nextcloud ExApp plumbing per ADR 0013.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Konrad Neitzel
2026-08-26 19:14:22 +02:00
co-authored by Cursor
commit 7921694782
9 changed files with 501 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
# go-nc-exapp
Shared Go Library for Nextcloud ExApp Services: AppAPI authentication, OCS calls, and per-user ExApp preferences. ExApps import `gitea.neitzel.de/konrad/go-nc-exapp`. File storage and folder visits live in go-nc-files.
## Language
**AppAPI credentials**:
The ExApp's shared secret and Nextcloud base URL, plus optional per-request user identity. Used to sign outbound calls to Nextcloud and to read the requesting user from inbound AppAPI-proxied requests.
_Avoid_: API key (generic), session token
**Requesting user**:
The Nextcloud user on whose behalf the current ExApp request runs, taken from AppAPI authorization headers. WebDAV and preferences use this user; there is no separate ExApp login.
_Avoid_: service account (for per-request identity), anonymous
**ExApp preference**:
A string value stored in Nextcloud for one user and one ExApp, keyed by the ExApp (not admin AppConfig). Libraries expose a parameterized key; each ExApp chooses its own key names.
_Avoid_: settings file in User Files, instance-wide config
**OCS**:
Nextcloud's legacy HTTP API surface under `/ocs/v2.php/…`. This Library requests JSON responses (`format=json`) for machine-readable bodies.
_Avoid_: assuming XML responses, REST-only Nextcloud APIs for ExApp prefs
+51
View File
@@ -0,0 +1,51 @@
# go-nc-exapp
Shared Go library for Nextcloud ExApp Services: AppAPI authentication, OCS JSON calls, and per-user ExApp preferences.
Import: `gitea.neitzel.de/konrad/go-nc-exapp`
## v1 scope
**Included**
- **Credentials** — Nextcloud base URL, AppAPI secret, and requesting user identity
- **AuthHeaders** — outbound AppAPI authorization for OCS and other Nextcloud calls
- **WithUser** — credentials scoped to a specific requesting user
- **UserFromRequest** — extract the requesting user from inbound AppAPI-proxied requests
- **OCSClient** — authenticated OCS calls that always append `format=json`
- **AppAPIPreferences** — parameterized get/set of a string ExApp preference (caller supplies app id and key)
**Excluded from v1**
- ExApp lifecycle HTTP routes (`/heartbeat`, `/enabled`, …)
- HaRP listen / `serve()` and unix-socket bootstrap
- Top-menu, script, and iframe UI registration
- WebDAV and file storage (see **go-nc-files**)
- **Visit** folder resolution (see **go-nc-files**)
## Usage
```go
cred := gonexapp.Credentials{
BaseURL: "https://nextcloud.example",
AppID: "myexapp",
AppVersion: "0.1.0",
AAVersion: "1.0.0",
AppSecret: os.Getenv("APP_SECRET"),
UserID: "alice",
}
prefs := gonexapp.NewAppAPIPreferences(cred, "myexapp", "savedDefault")
value, err := prefs.Get()
```
Each ExApp chooses its own preference keys; this library does not hardcode product-specific names.
## Domain language
See [CONTEXT.md](./CONTEXT.md) for AppAPI credentials, requesting user, ExApp preference, and OCS terminology.
## Related
- **go-nc-files** — WebDAV, Working Folder, Saved Default, Visit resolution
- Workspace ADR 0013 — extraction from CheckDNS
+54
View File
@@ -0,0 +1,54 @@
package gonexapp
import (
"encoding/base64"
"fmt"
"net/http"
"strings"
)
// Credentials are what an ExApp needs to call Nextcloud as a user.
type Credentials struct {
BaseURL string // Nextcloud instance URL, no trailing slash
AppID string
AppVersion string
AAVersion string
AppSecret string
UserID string
}
// AuthHeaders returns AppAPI auth headers for requests to Nextcloud.
func (c Credentials) AuthHeaders() http.Header {
h := make(http.Header)
h.Set("AA-VERSION", c.AAVersion)
h.Set("EX-APP-ID", c.AppID)
h.Set("EX-APP-VERSION", c.AppVersion)
token := base64.StdEncoding.EncodeToString([]byte(c.UserID + ":" + c.AppSecret))
h.Set("AUTHORIZATION-APP-API", token)
h.Set("OCS-APIRequest", "true")
return h
}
// WithUser returns a copy acting as userID.
func (c Credentials) WithUser(userID string) Credentials {
out := c
out.UserID = userID
return out
}
// UserFromRequest reads the requesting user from AUTHORIZATION-APP-API on an inbound ExApp request.
func UserFromRequest(r *http.Request) (string, error) {
raw := r.Header.Get("AUTHORIZATION-APP-API")
if raw == "" {
return "", fmt.Errorf("missing AUTHORIZATION-APP-API")
}
decoded, err := base64.StdEncoding.DecodeString(raw)
if err != nil {
return "", fmt.Errorf("invalid AUTHORIZATION-APP-API: %w", err)
}
parts := strings.SplitN(string(decoded), ":", 2)
if len(parts) < 1 || strings.TrimSpace(parts[0]) == "" {
return "", fmt.Errorf("AUTHORIZATION-APP-API has no user id")
}
return parts[0], nil
}
+83
View File
@@ -0,0 +1,83 @@
package gonexapp_test
import (
"encoding/base64"
"net/http"
"net/http/httptest"
"testing"
"gitea.neitzel.de/konrad/go-nc-exapp"
)
func TestUserFromRequestRoundTrip(t *testing.T) {
token := base64.StdEncoding.EncodeToString([]byte("alice:secret"))
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.Header.Set("AUTHORIZATION-APP-API", token)
user, err := gonexapp.UserFromRequest(r)
if err != nil || user != "alice" {
t.Fatalf("user=%q err=%v", user, err)
}
}
func TestUserFromRequestMissing(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/", nil)
_, err := gonexapp.UserFromRequest(r)
if err == nil {
t.Fatal("expected error for missing header")
}
}
func TestUserFromRequestInvalidBase64(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.Header.Set("AUTHORIZATION-APP-API", "%%%")
_, err := gonexapp.UserFromRequest(r)
if err == nil {
t.Fatal("expected error for invalid base64")
}
}
func TestUserFromRequestNoUserID(t *testing.T) {
token := base64.StdEncoding.EncodeToString([]byte(":secret"))
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.Header.Set("AUTHORIZATION-APP-API", token)
_, err := gonexapp.UserFromRequest(r)
if err == nil {
t.Fatal("expected error for empty user id")
}
}
func TestWithUser(t *testing.T) {
cred := gonexapp.Credentials{UserID: "alice"}
asBob := cred.WithUser("bob")
if asBob.UserID != "bob" {
t.Fatalf("user=%q", asBob.UserID)
}
if cred.UserID != "alice" {
t.Fatalf("original mutated: %q", cred.UserID)
}
}
func TestAuthHeaders(t *testing.T) {
cred := gonexapp.Credentials{
BaseURL: "https://nc.example",
AppID: "myapp",
AppVersion: "1.0.0",
AAVersion: "2.0.0",
AppSecret: "secret",
UserID: "alice",
}
h := cred.AuthHeaders()
if got := h.Get("EX-APP-ID"); got != "myapp" {
t.Fatalf("EX-APP-ID=%q", got)
}
if got := h.Get("AA-VERSION"); got != "2.0.0" {
t.Fatalf("AA-VERSION=%q", got)
}
wantToken := base64.StdEncoding.EncodeToString([]byte("alice:secret"))
if got := h.Get("AUTHORIZATION-APP-API"); got != wantToken {
t.Fatalf("AUTHORIZATION-APP-API=%q want %q", got, wantToken)
}
if got := h.Get("OCS-APIRequest"); got != "true" {
t.Fatalf("OCS-APIRequest=%q", got)
}
}
+3
View File
@@ -0,0 +1,3 @@
// Package gonexapp provides AppAPI credentials, OCS JSON calls, and ExApp user
// preferences for Nextcloud ExApp Services.
package gonexapp
+3
View File
@@ -0,0 +1,3 @@
module gitea.neitzel.de/konrad/go-nc-exapp
go 1.26.4
+67
View File
@@ -0,0 +1,67 @@
package gonexapp
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
// OCSClient performs AppAPI-authenticated OCS requests with format=json.
type OCSClient struct {
Cred Credentials
Client *http.Client
}
func (c OCSClient) httpClient() *http.Client {
if c.Client != nil {
return c.Client
}
return http.DefaultClient
}
// URL builds an OCS URL under /ocs/v2.php with format=json.
func (c OCSClient) URL(path string) string {
base := strings.TrimRight(c.Cred.BaseURL, "/")
path = strings.TrimPrefix(path, "/")
return base + "/ocs/v2.php/" + path + "?format=json"
}
// Call performs method against path with an optional JSON body and returns the response body
// after verifying it is valid JSON. Non-2xx responses return an error including the status.
func (c OCSClient) Call(method, path string, body []byte) ([]byte, error) {
var reader io.Reader
if len(body) > 0 {
reader = bytes.NewReader(body)
}
req, err := http.NewRequest(method, c.URL(path), reader)
if err != nil {
return nil, err
}
if len(body) > 0 {
req.Header.Set("Content-Type", "application/json")
}
for k, vs := range c.Cred.AuthHeaders() {
for _, v := range vs {
req.Header.Set(k, v)
}
}
res, err := c.httpClient().Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
raw, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
if res.StatusCode >= 300 {
return nil, fmt.Errorf("ocs %s %s: %s", method, path, res.Status)
}
if !json.Valid(raw) {
return nil, fmt.Errorf("ocs %s %s: response is not JSON", method, path)
}
return raw, nil
}
+93
View File
@@ -0,0 +1,93 @@
package gonexapp
import (
"encoding/json"
"fmt"
"net/http"
"strings"
)
// AppAPIPreferences reads and writes one string ExApp preference for the requesting user.
type AppAPIPreferences struct {
Cred Credentials
AppID string
Key string
Client *http.Client
OCS OCSClient
}
// NewAppAPIPreferences returns a preference store for appID and key using cred for auth.
func NewAppAPIPreferences(cred Credentials, appID, key string) AppAPIPreferences {
return AppAPIPreferences{
Cred: cred,
AppID: appID,
Key: key,
OCS: OCSClient{Cred: cred},
}
}
func (p AppAPIPreferences) ocsClient() OCSClient {
c := p.OCS
if c.Cred.BaseURL == "" {
c.Cred = p.Cred
}
if c.Client == nil {
c.Client = p.Client
}
return c
}
// Get loads the configured preference key for the requesting user.
func (p AppAPIPreferences) Get() (string, error) {
ocs := p.ocsClient()
body, _ := json.Marshal(map[string]any{"configKeys": []string{p.Key}})
raw, err := ocs.Call(http.MethodPost, "apps/app_api/api/v1/ex-app/preference/get-values", body)
if err != nil {
return "", err
}
return decodePreferenceValue(raw, p.Key)
}
// Set stores value for the configured preference key.
func (p AppAPIPreferences) Set(value string) error {
ocs := p.ocsClient()
body, _ := json.Marshal(map[string]any{
"configKey": p.Key,
"configValue": value,
"sensitive": 0,
})
_, err := ocs.Call(http.MethodPost, "apps/app_api/api/v1/ex-app/preference", body)
return err
}
func decodePreferenceValue(raw []byte, key string) (string, error) {
var parsed struct {
OCS struct {
Data []struct {
ConfigKey string `json:"configkey"`
ConfigValue string `json:"configvalue"`
} `json:"data"`
} `json:"ocs"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
var items []struct {
ConfigKey string `json:"configkey"`
ConfigValue string `json:"configvalue"`
}
if err2 := json.Unmarshal(raw, &items); err2 != nil {
return "", fmt.Errorf("preferences decode: %w", err)
}
for _, it := range items {
if it.ConfigKey == key {
return strings.TrimSpace(it.ConfigValue), nil
}
}
return "", nil
}
for _, it := range parsed.OCS.Data {
if it.ConfigKey == key {
return strings.TrimSpace(it.ConfigValue), nil
}
}
return "", nil
}
+126
View File
@@ -0,0 +1,126 @@
package gonexapp_test
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitea.neitzel.de/konrad/go-nc-exapp"
)
func TestOCSCallIncludesFormatJSON(t *testing.T) {
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.String()
_ = json.NewEncoder(w).Encode(map[string]string{"ok": "true"})
}))
t.Cleanup(srv.Close)
cred := gonexapp.Credentials{
BaseURL: srv.URL, AppID: "app", AppVersion: "0.1.0", AAVersion: "1.0.0", AppSecret: "s", UserID: "alice",
}
client := gonexapp.OCSClient{Cred: cred, Client: srv.Client()}
raw, err := client.Call(http.MethodGet, "apps/app_api/api/v1/ex-app/preference/get-values", nil)
if err != nil {
t.Fatal(err)
}
if !json.Valid(raw) {
t.Fatalf("not json: %s", raw)
}
if !strings.Contains(gotPath, "format=json") {
t.Fatalf("path missing format=json: %s", gotPath)
}
}
func TestOCSCallRejectsNonJSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, "<xml/>")
}))
t.Cleanup(srv.Close)
cred := gonexapp.Credentials{BaseURL: srv.URL, AppID: "app", AppVersion: "0.1.0", AAVersion: "1.0.0", AppSecret: "s", UserID: "alice"}
client := gonexapp.OCSClient{Cred: cred, Client: srv.Client()}
_, err := client.Call(http.MethodGet, "apps/example", nil)
if err == nil || !strings.Contains(err.Error(), "not JSON") {
t.Fatalf("got %v", err)
}
}
func TestOCSCallSurfacesHTTPError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "nope", http.StatusBadRequest)
}))
t.Cleanup(srv.Close)
cred := gonexapp.Credentials{BaseURL: srv.URL, AppID: "app", AppVersion: "0.1.0", AAVersion: "1.0.0", AppSecret: "s", UserID: "alice"}
client := gonexapp.OCSClient{Cred: cred, Client: srv.Client()}
_, err := client.Call(http.MethodPost, "apps/example", []byte(`{}`))
if err == nil || !strings.Contains(err.Error(), "400") {
t.Fatalf("got %v", err)
}
}
func TestAppAPIPreferencesGetSet(t *testing.T) {
const prefKey = "savedDefault"
prefsVals := map[string]string{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.Contains(r.URL.Path, "ex-app/preference/get-values"):
_ = json.NewEncoder(w).Encode(map[string]any{
"ocs": map[string]any{
"data": []map[string]string{{
"configkey": prefKey,
"configvalue": prefsVals[prefKey],
}},
},
})
case strings.HasSuffix(r.URL.Path, "ex-app/preference") && r.Method == http.MethodPost:
var body struct {
ConfigKey string `json:"configKey"`
ConfigValue string `json:"configValue"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
prefsVals[body.ConfigKey] = body.ConfigValue
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
default:
http.Error(w, "unhandled "+r.Method+" "+r.URL.Path, http.StatusInternalServerError)
}
}))
t.Cleanup(srv.Close)
cred := gonexapp.Credentials{
BaseURL: srv.URL, AppID: "myexapp", AppVersion: "0.1.0", AAVersion: "1.0.0", AppSecret: "s", UserID: "alice",
}
store := gonexapp.NewAppAPIPreferences(cred, "myexapp", prefKey)
store.Client = srv.Client()
store.OCS.Client = srv.Client()
got, err := store.Get()
if err != nil {
t.Fatal(err)
}
if got != "" {
t.Fatalf("initial value=%q", got)
}
if err := store.Set("Zones"); err != nil {
t.Fatal(err)
}
if prefsVals[prefKey] != "Zones" {
t.Fatalf("prefs not written: %#v", prefsVals)
}
got, err = store.Get()
if err != nil {
t.Fatal(err)
}
if got != "Zones" {
t.Fatalf("got %q", got)
}
}