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 _Avoid_: AppAPI scopes, route access_level, admin-only top menu, treating the ExApp id as an implicit group name
**Access Gate**: **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. 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 _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 # 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 ## Scope
@@ -14,7 +14,8 @@ Import: `gitea.neitzel.de/konrad/go-nc-exapp`
- **UserFromRequest** — extract the requesting user from inbound AppAPI-proxied requests - **UserFromRequest** — extract the requesting user from inbound AppAPI-proxied requests
- **OCSClient** — authenticated OCS calls that always append `format=json` - **OCSClient** — authenticated OCS calls that always append `format=json`
- **AppAPIPreferences** — parameterized get/set of a string ExApp preference (caller supplies app id and key) - **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** **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. 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 ## 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 ## Related
+35 -3
View File
@@ -32,9 +32,13 @@ type cacheEntry struct {
type CheckResult int type CheckResult int
const ( const (
// CheckAllowed means the request may proceed (or the gate is inactive / skipped).
CheckAllowed CheckResult = iota CheckAllowed CheckResult = iota
// CheckDenied means the Requesting user is not in Required Groups.
CheckDenied CheckDenied
// CheckUnauthorized means no Requesting user could be read from the request.
CheckUnauthorized CheckUnauthorized
// CheckUnavailable means group membership could not be determined (e.g. OCS error).
CheckUnavailable CheckUnavailable
) )
@@ -177,8 +181,11 @@ func (g AccessGate) normalized() *AccessGate {
func (g *AccessGate) writeDenied(w http.ResponseWriter, r *http.Request) { func (g *AccessGate) writeDenied(w http.ResponseWriter, r *http.Request) {
if acceptsHTML(r.Header.Get("Accept")) { 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.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) _, _ = w.Write(deniedHTML)
return return
} }
@@ -191,6 +198,9 @@ func acceptsHTML(accept string) bool {
func (g *AccessGate) shouldSkip(path string) bool { func (g *AccessGate) shouldSkip(path string) bool {
path = normalizeGatePath(path) path = normalizeGatePath(path)
if isTopMenuScriptPath(path) {
return true
}
for _, p := range defaultSkipPaths { for _, p := range defaultSkipPaths {
if path == p { if path == p {
return true return true
@@ -204,6 +214,13 @@ func (g *AccessGate) shouldSkip(path string) bool {
return false 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 { func normalizeGatePath(path string) string {
path = strings.TrimSuffix(path, "/") path = strings.TrimSuffix(path, "/")
if path == "" { if path == "" {
@@ -214,9 +231,24 @@ func normalizeGatePath(path string) string {
var defaultSkipPaths = []string{"/heartbeat", "/enabled", "/init"} 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> var deniedHTML = []byte(`<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head><meta charset="utf-8"><title>Access denied</title></head> <head>
<body><h1>Access denied</h1><p>You are not a member of a required group for this app.</p></body> <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> </html>
`) `)
+26 -1
View File
@@ -159,7 +159,7 @@ func TestAccessGateDeniesNonMemberWithHTML(t *testing.T) {
req.Header.Set("Accept", "text/html,application/xhtml+xml") req.Header.Set("Accept", "text/html,application/xhtml+xml")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
h.ServeHTTP(rec, req) h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden { if rec.Code != http.StatusOK {
t.Fatalf("got %d", rec.Code) t.Fatalf("got %d", rec.Code)
} }
if !strings.Contains(rec.Header().Get("Content-Type"), "text/html") { 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") { if !strings.Contains(rec.Body.String(), "Access denied") {
t.Fatalf("body=%q", rec.Body.String()) 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) { 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)
}
}
}