4 Commits
Author SHA1 Message Date
Konrad NeitzelandCursor 984b0c2335 Skip /js/ in the Access Gate and set denied-page CSP so Denied UI can render.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 12:39:47 +02:00
Konrad Neitzel 198ef0e204 Merge branch 'feature/go-library-docs' 2026-08-27 22:00:10 +02:00
Konrad NeitzelandCursor cf3b396398 Document consumer README skeleton and add package Examples.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 22:00:02 +02:00
Konrad NeitzelandCursor 69af4d19c8 Serve Access Gate denied HTML as 200 for iframe display.
AppAPI sets frame-ancestors none on 403 proxy responses, which blanked the ExApp iframe; keep API denials as 403.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 18:43:01 +02:00
7 changed files with 250 additions and 10 deletions
+6 -2
View File
@@ -25,5 +25,9 @@ The Nextcloud groups configured for an ExApp (comma-separated deploy env `REQUIR
_Avoid_: AppAPI scopes, route access_level, admin-only top menu, treating the ExApp id as an implicit group name
**Access Gate**:
The Library check that enforces Required Groups for the Requesting user on ExApp HTTP traffic (403 or denied UI when not a member; 401 without a user; 503 when membership cannot be determined). Lifecycle paths stay ungated.
_Avoid_: Nextcloud middleware, HaRP ACL, admin bypass
The Library check that enforces Required Groups for the Requesting user on ExApp HTTP traffic (403 or denied UI when not a member; 401 without a user; 503 when membership cannot be determined). Lifecycle paths and top-menu script URLs under `/js/` stay ungated so Denied UI can load in the Nextcloud shell.
_Avoid_: Nextcloud middleware, HaRP ACL, admin bypass, gating the top-menu bootstrap script
**Top Menu visibility**:
Whether the ExApp app icon in the Nextcloud top menu is shown to all logged-in users or to Nextcloud admins only. Configured per deploy via env `TOP_MENU_ADMIN_REQUIRED` (`0` or `1`); the ExApp passes the value to AppAPI when registering the top-menu entry on enable. Independent of route `access_level` in info.xml and of Required Groups.
_Avoid_: route access_level, Required Groups, AppAPI group ACL
+35 -4
View File
@@ -1,8 +1,8 @@
# go-nc-exapp
Shared Go library for Nextcloud ExApp Services: AppAPI authentication, OCS JSON calls, per-user ExApp preferences, and optional Required Groups Access Gate.
Shared Go library for Nextcloud ExApp Services: AppAPI authentication, OCS JSON calls, per-user ExApp preferences, and an optional Required Groups Access Gate.
Import: `gitea.neitzel.de/konrad/go-nc-exapp`
Import: `gitea.neitzel.de/konrad/go-nc-exapp` (package `gonexapp`).
## Scope
@@ -14,7 +14,8 @@ Import: `gitea.neitzel.de/konrad/go-nc-exapp`
- **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)
- **Access Gate** — optional Required Groups enforcement (`Wrap` + `Check`), English denied HTML, positive membership cache; env helpers for `REQUIRED_GROUPS` / `REQUIRED_GROUPS_CACHE_SECONDS`
- **Access Gate** — optional Required Groups enforcement (`Wrap` + `Check`), English denied HTML for browsers (200 + `frame-ancestors 'self'`), positive membership cache; default skip for lifecycle paths and **`/js/`** top-menu scripts; env helpers for `REQUIRED_GROUPS` / `REQUIRED_GROUPS_CACHE_SECONDS`
- **Top Menu visibility** — `TopMenuAdminRequired` helper for deploy env `TOP_MENU_ADMIN_REQUIRED` (`0` / `1` for AppAPI top-menu OCS)
**Excluded**
@@ -49,9 +50,39 @@ Each ExApp chooses its own preference keys; this library does not hardcode produ
Declare `REQUIRED_GROUPS` and `REQUIRED_GROUPS_CACHE_SECONDS` in the ExApp `info.xml` so Deploy options can set them.
The Gate skips `/heartbeat`, `/enabled`, `/init`, and any path under **`/js/`** (AppAPI top-menu bootstrap). Serve the registered top-menu script under `/js/…` so a non-member still loads it and can show Denied UI in the Nextcloud shell. API routes stay gated.
Denied HTML is **200** with `Content-Security-Policy: … frame-ancestors 'self'`. Without that header AppAPI’s proxy defaults to `frame-ancestors 'none'` and a denied iframe stays blank. Non-HTML denials remain **403**.
### Top Menu visibility (`TOP_MENU_ADMIN_REQUIRED`)
Declare in `info.xml` under `<environment-variables>`. At enable time the ExApp reads the env and passes `"0"` or `"1"` to AppAPI’s top-menu OCS `adminRequired`. Only `0` and `1` are valid; anything else falls back to `DefaultTopMenuAdminRequired` (`true` → admins only).
```go
adminRequired := gonexapp.TopMenuAdminRequired(
os.Getenv(gonexapp.EnvTopMenuAdminRequired),
gonexapp.DefaultTopMenuAdminRequired,
)
// use adminRequired in POST …/ui/top-menu when registering the menu entry
```
**Applying a change:** AppAPI registers the top menu when the ExApp receives `PUT /enabled?enabled=1`. Changing the deploy env alone does not update the menu entry.
1. Set the new value in Deploy options (UI) or `occ app_api:app:register … --env TOP_MENU_ADMIN_REQUIRED=…` / update deploy config.
2. Recreate or restart the ExApp container so the new env is present.
3. Re-run lifecycle: disable then enable the ExApp (UI or `occ app_api:app:disable` / `app_api:app:enable`), or `occ app_api:app:update … -e` after an image/info update.
Route `access_level` in `info.xml` is separate and only changes when AppAPI re-reads `info.xml` on register/update — not via this env.
Runnable package examples: `go test -run Example`.
## Domain language
See [CONTEXT.md](./CONTEXT.md) for AppAPI credentials, Requesting user, ExApp preference, OCS, Required Groups, and Access Gate terminology.
See [CONTEXT.md](./CONTEXT.md) for AppAPI credentials, Requesting user, ExApp preference, OCS, Required Groups, Access Gate, and Top Menu visibility terminology.
## Testing
Unit tests use `httptest` fake OCS servers. No live Nextcloud is required for Library CI.
## Related
+35 -3
View File
@@ -32,9 +32,13 @@ type cacheEntry struct {
type CheckResult int
const (
// CheckAllowed means the request may proceed (or the gate is inactive / skipped).
CheckAllowed CheckResult = iota
// CheckDenied means the Requesting user is not in Required Groups.
CheckDenied
// CheckUnauthorized means no Requesting user could be read from the request.
CheckUnauthorized
// CheckUnavailable means group membership could not be determined (e.g. OCS error).
CheckUnavailable
)
@@ -177,8 +181,11 @@ func (g AccessGate) normalized() *AccessGate {
func (g *AccessGate) writeDenied(w http.ResponseWriter, r *http.Request) {
if acceptsHTML(r.Header.Get("Accept")) {
// 200 plus frame-ancestors 'self': AppAPI's default proxy CSP uses
// frame-ancestors 'none' unless the ExApp sets CSP, which blanks iframes.
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusForbidden)
w.Header().Set("Content-Security-Policy", deniedCSP)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(deniedHTML)
return
}
@@ -191,6 +198,9 @@ func acceptsHTML(accept string) bool {
func (g *AccessGate) shouldSkip(path string) bool {
path = normalizeGatePath(path)
if isTopMenuScriptPath(path) {
return true
}
for _, p := range defaultSkipPaths {
if path == p {
return true
@@ -204,6 +214,13 @@ func (g *AccessGate) shouldSkip(path string) bool {
return false
}
// isTopMenuScriptPath reports AppAPI top-menu bootstrap scripts under /js/.
// Those must load for a non-member so the shell can show Denied UI; gating
// them yields a blank embedded page (script Accept is not text/html → 403).
func isTopMenuScriptPath(path string) bool {
return path == "/js" || strings.HasPrefix(path, "/js/")
}
func normalizeGatePath(path string) string {
path = strings.TrimSuffix(path, "/")
if path == "" {
@@ -214,9 +231,24 @@ func normalizeGatePath(path string) string {
var defaultSkipPaths = []string{"/heartbeat", "/enabled", "/init"}
// deniedCSP lets AppAPI proxy the denied page into an ExApp iframe.
const deniedCSP = "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'; style-src 'unsafe-inline'"
var deniedHTML = []byte(`<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>Access denied</title></head>
<body><h1>Access denied</h1><p>You are not a member of a required group for this app.</p></body>
<head>
<meta charset="utf-8">
<title>Access denied</title>
<style>
:root { font-family: ui-sans-serif, system-ui, sans-serif; color: #1a1a1a; }
body { margin: 2rem; max-width: 40rem; }
h1 { font-size: 1.4rem; margin-bottom: 0.5rem; }
p { color: #444; line-height: 1.5; }
</style>
</head>
<body>
<h1>Access denied</h1>
<p>You are not a member of a required group for this app. Ask an administrator to add you to the group if you need access.</p>
</body>
</html>
`)
+26 -1
View File
@@ -159,7 +159,7 @@ func TestAccessGateDeniesNonMemberWithHTML(t *testing.T) {
req.Header.Set("Accept", "text/html,application/xhtml+xml")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
if rec.Code != http.StatusOK {
t.Fatalf("got %d", rec.Code)
}
if !strings.Contains(rec.Header().Get("Content-Type"), "text/html") {
@@ -168,6 +168,31 @@ func TestAccessGateDeniesNonMemberWithHTML(t *testing.T) {
if !strings.Contains(rec.Body.String(), "Access denied") {
t.Fatalf("body=%q", rec.Body.String())
}
csp := rec.Header().Get("Content-Security-Policy")
if !strings.Contains(csp, "frame-ancestors 'self'") {
t.Fatalf("csp=%q", csp)
}
}
func TestAccessGateSkipsTopMenuScriptPrefix(t *testing.T) {
gate := gonexapp.AccessGate{Groups: []string{"dns-ops"}}
h := gate.Wrap(okInner())
for _, path := range []string{"/js/checkdns-main.js", "/js/app.js", "/js"} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || rec.Body.String() != "ok" {
t.Fatalf("%s: got %d %q", path, rec.Code, rec.Body.String())
}
}
req := httptest.NewRequest(http.MethodGet, "/json", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("/json should stay gated, got %d", rec.Code)
}
}
func TestAccessGateLookupFailureServiceUnavailable(t *testing.T) {
+96
View File
@@ -0,0 +1,96 @@
package gonexapp_test
import (
"encoding/base64"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
gonexapp "gitea.neitzel.de/konrad/go-nc-exapp"
)
func ExampleUserFromRequest() {
token := base64.StdEncoding.EncodeToString([]byte("alice:secret"))
req := httptest.NewRequest(http.MethodGet, "/api", nil)
req.Header.Set("AUTHORIZATION-APP-API", token)
user, err := gonexapp.UserFromRequest(req)
if err != nil {
fmt.Println("err:", err)
return
}
fmt.Println(user)
// Output: alice
}
func ExampleCredentials_AuthHeaders() {
cred := gonexapp.Credentials{
BaseURL: "https://nextcloud.example", AppID: "myexapp", AppVersion: "0.1.0",
AAVersion: "1.0.0", AppSecret: "s", UserID: "alice",
}
h := cred.AuthHeaders()
fmt.Println(h.Get("EX-APP-ID"), h.Get("OCS-APIRequest") != "")
// Output: myexapp true
}
func ExampleNewAppAPIPreferences() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "get-values") {
_, _ = io.WriteString(w, `{"ocs":{"data":[{"configkey":"savedDefault","configvalue":"Zones"}]}}`)
return
}
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, `{"ocs":{"data":{}}}`)
}))
defer srv.Close()
cred := gonexapp.Credentials{
BaseURL: srv.URL, AppID: "myexapp", AppVersion: "0.1.0", AAVersion: "1.0.0",
AppSecret: "s", UserID: "alice",
}
prefs := gonexapp.NewAppAPIPreferences(cred, "myexapp", "savedDefault")
prefs.Client = srv.Client()
prefs.OCS.Client = srv.Client()
value, err := prefs.Get()
if err != nil {
fmt.Println("err:", err)
return
}
if err := prefs.Set("Zones"); err != nil {
fmt.Println("set:", err)
return
}
fmt.Println(value)
// Output: Zones
}
func ExampleAccessGate_Wrap() {
inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, "ok")
})
h := gonexapp.AccessGate{}.Wrap(inner)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api", nil))
fmt.Println(rec.Code, rec.Body.String())
// Output: 200 ok
}
func ExampleResolveRequiredGroups() {
fmt.Println(gonexapp.ResolveRequiredGroups("", false, nil))
fmt.Println(len(gonexapp.ResolveRequiredGroups("", true, []string{"ops"})))
// Output:
// []
// 0
}
func ExampleTopMenuAdminRequired() {
fmt.Println(gonexapp.TopMenuAdminRequired("0", gonexapp.DefaultTopMenuAdminRequired))
fmt.Println(gonexapp.TopMenuAdminRequired("maybe", gonexapp.DefaultTopMenuAdminRequired))
// Output:
// 0
// 1
}
+27
View File
@@ -0,0 +1,27 @@
package gonexapp
import "strings"
// EnvTopMenuAdminRequired is the conventional deploy env name for Top Menu visibility.
// Declare it in the ExApp info.xml environment-variables section.
const EnvTopMenuAdminRequired = "TOP_MENU_ADMIN_REQUIRED"
// DefaultTopMenuAdminRequired is used when TOP_MENU_ADMIN_REQUIRED is unset or invalid.
const DefaultTopMenuAdminRequired = true
// TopMenuAdminRequired returns "1" or "0" for the AppAPI top-menu OCS adminRequired field.
// Only "0" and "1" are accepted; any other value falls back to defaultAdminRequired.
// An empty envValue means unset and also uses defaultAdminRequired.
func TopMenuAdminRequired(envValue string, defaultAdminRequired bool) string {
switch strings.TrimSpace(envValue) {
case "1":
return "1"
case "0":
return "0"
default:
if defaultAdminRequired {
return "1"
}
return "0"
}
}
+25
View File
@@ -0,0 +1,25 @@
package gonexapp
import "testing"
func TestTopMenuAdminRequired(t *testing.T) {
tests := []struct {
env string
defAdmin bool
want string
}{
{"", true, "1"},
{"", false, "0"},
{"1", true, "1"},
{"0", true, "0"},
{" 1 ", true, "1"},
{"yes", true, "1"},
{"yes", false, "0"},
{"2", true, "1"},
}
for _, tc := range tests {
if got := TopMenuAdminRequired(tc.env, tc.defAdmin); got != tc.want {
t.Errorf("TopMenuAdminRequired(%q, %v) = %q, want %q", tc.env, tc.defAdmin, got, tc.want)
}
}
}