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:
+21
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Package gonexapp provides AppAPI credentials, OCS JSON calls, and ExApp user
|
||||
// preferences for Nextcloud ExApp Services.
|
||||
package gonexapp
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user