commit 792169478201cf67fcb746f0a8716052f7e09ead Author: Konrad Neitzel Date: Wed Aug 26 19:14:22 2026 +0200 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 diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..1e2fa0c --- /dev/null +++ b/CONTEXT.md @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..00225ec --- /dev/null +++ b/README.md @@ -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 diff --git a/credentials.go b/credentials.go new file mode 100644 index 0000000..6e56114 --- /dev/null +++ b/credentials.go @@ -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 +} diff --git a/credentials_test.go b/credentials_test.go new file mode 100644 index 0000000..f301a05 --- /dev/null +++ b/credentials_test.go @@ -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) + } +} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..f20fce2 --- /dev/null +++ b/doc.go @@ -0,0 +1,3 @@ +// Package gonexapp provides AppAPI credentials, OCS JSON calls, and ExApp user +// preferences for Nextcloud ExApp Services. +package gonexapp diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..05dec21 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module gitea.neitzel.de/konrad/go-nc-exapp + +go 1.26.4 diff --git a/ocs.go b/ocs.go new file mode 100644 index 0000000..e36e199 --- /dev/null +++ b/ocs.go @@ -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 +} diff --git a/preferences.go b/preferences.go new file mode 100644 index 0000000..44c265d --- /dev/null +++ b/preferences.go @@ -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 +} diff --git a/preferences_test.go b/preferences_test.go new file mode 100644 index 0000000..630358a --- /dev/null +++ b/preferences_test.go @@ -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, "") + })) + 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) + } +}