From 7a915aee7bfe45d8515bd75579b7bc70bb36ea8c Mon Sep 17 00:00:00 2001 From: Konrad Neitzel Date: Mon, 28 Sep 2026 19:31:36 +0200 Subject: [PATCH] Add an optional App navigation shell for ExApp pages. ExApps that mount it share one Files-style tree; an ExApp that never mounts it keeps its own page. Co-authored-by: Cursor --- CONTEXT.md | 4 + README.md | 6 +- doc.go | 3 +- navigation.go | 211 +++++++++++++++++++++++++++++++++++++++++++++ navigation_test.go | 156 +++++++++++++++++++++++++++++++++ 5 files changed, 377 insertions(+), 3 deletions(-) create mode 100644 navigation.go create mode 100644 navigation_test.go diff --git a/CONTEXT.md b/CONTEXT.md index b1c7e76..6cffd9e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -40,6 +40,10 @@ _Avoid_: Group-API, Required Groups, AppAPI scopes, treating a group as a Recipi 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). It reads the user's groups through Users and Groups. 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 +**App navigation**: +The tree of named items along the left of an ExApp page. The ExApp supplies the tree. The selected item is the page on display. It plays the same role as the navigation in Nextcloud Files. An ExApp that does not use App navigation shows its own page instead. +_Avoid_: Top Menu, sidebar, NcAppNavigation + **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 diff --git a/README.md b/README.md index b54207b..262858f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # go-nc-exapp -Shared Go library for Nextcloud ExApp Services: AppAPI authentication, OCS JSON calls, per-user ExApp preferences, Notifications, Users and Groups, and an optional Required Groups Access Gate. +Shared Go library for Nextcloud ExApp Services: AppAPI authentication, OCS JSON calls, per-user ExApp preferences, Notifications, Users and Groups, an optional Required Groups Access Gate, and an optional App navigation shell. Import: `gitea.neitzel.de/konrad/go-nc-exapp` (package `gonexapp`). @@ -18,6 +18,7 @@ Import: `gitea.neitzel.de/konrad/go-nc-exapp` (package `gonexapp`). - **Groups** — Users and Groups reads: `UserGroups`, `GroupMembers`, `ListGroups` (no search/paging). Directory calls (`GroupMembers` / `ListGroups`) run as the Credentials user and need an admin or subadmin - **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) +- **App navigation** — optional Files-style shell (`AppNavigation.Handler`). The ExApp supplies the tree, the page for each item, and an optional header. The selected item is the `item` query parameter (override with `SelectKey`). Other query parameters, including a Visit folder, are left in place. Not mounting the handler keeps the ExApp’s own page. Lifecycle routes, HaRP startup, and Top Menu registration stay in the ExApp. **Excluded** @@ -86,7 +87,7 @@ Runnable package examples: `go test -run Example`. ## Domain language -See [CONTEXT.md](./CONTEXT.md) for AppAPI credentials, Requesting user, Recipient, ExApp preference, Notification, OCS, Required Groups, Users and Groups, Access Gate, and Top Menu visibility terminology. +See [CONTEXT.md](./CONTEXT.md) for AppAPI credentials, Requesting user, Recipient, ExApp preference, Notification, OCS, Required Groups, Users and Groups, Access Gate, App navigation, and Top Menu visibility terminology. ## Testing @@ -98,3 +99,4 @@ Unit tests use `httptest` fake OCS servers. No live Nextcloud is required for Li - Workspace ADR 0013 — extraction from CheckDNS - Workspace ADR `docs/adr/go-nc-exapp/0001-required-groups-access-gate.md` — Access Gate decisions - Workspace ADR `docs/adr/go-nc-exapp/0002-users-and-groups-as-requesting-user.md` — directory OCS as Requesting user +- Workspace ADR `docs/adr/go-nc-exapp/0003-app-navigation-shell.md` — App navigation shell diff --git a/doc.go b/doc.go index 767f281..18c71eb 100644 --- a/doc.go +++ b/doc.go @@ -1,3 +1,4 @@ // Package gonexapp provides AppAPI credentials, OCS JSON calls, ExApp user -// preferences, and an optional Required Groups Access Gate for Nextcloud ExApp Services. +// preferences, an optional Required Groups Access Gate, and an optional +// App navigation shell for Nextcloud ExApp Services. package gonexapp diff --git a/navigation.go b/navigation.go new file mode 100644 index 0000000..f37c0f6 --- /dev/null +++ b/navigation.go @@ -0,0 +1,211 @@ +package gonexapp + +import ( + "html/template" + "net/http" +) + +// shellCSP lets the ExApp iframe render this document. AppAPI blanks a frame +// whose response omits frame-ancestors. +const shellCSP = "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:" + +// Item is one App navigation entry. The ExApp chooses the ids and labels. +// Children may nest. The shell does not interpret the ids. +type Item struct { + ID string + Label string + Children []Item +} + +// AppNavigation is the Files-style shell an ExApp mounts. +// Items, Page, and DefaultID are required. Header and MissingMessage are +// optional. SelectKey defaults to "item". Every other query parameter, +// including the Visit folder, is copied onto the corrected address. +// An ExApp that never calls Handler keeps its own page. +type AppNavigation struct { + Items func(*http.Request) []Item + Page func(*http.Request, string) (string, bool) + Header func(*http.Request) string + DefaultID string + MissingMessage string + SelectKey string + // Title is the document title. Empty means "App navigation". + Title string +} + +// Handler serves the shell. The selected item is the SelectKey query parameter. +// A missing item renders DefaultID, includes MissingMessage, and publishes the +// corrected query on data-address so the page can replace the address. +// Every parent starts expanded. Folding is client state for this document only. +func (n AppNavigation) Handler() http.Handler { + return http.HandlerFunc(n.serve) +} + +func (n AppNavigation) selectKey() string { + if n.SelectKey != "" { + return n.SelectKey + } + return "item" +} + +func (n AppNavigation) serve(w http.ResponseWriter, r *http.Request) { + var items []Item + if n.Items != nil { + items = n.Items(r) + } + asked := r.URL.Query().Get(n.selectKey()) + selected := n.DefaultID + missing := false + if asked != "" { + if _, ok := findItem(items, asked); ok { + selected = asked + } else { + missing = true + } + } + page := "" + if n.Page != nil { + body, ok := n.Page(r, selected) + if !ok && selected != n.DefaultID { + missing = true + selected = n.DefaultID + body, ok = n.Page(r, selected) + } + if ok { + page = body + } + } + header := "" + if n.Header != nil { + header = n.Header(r) + } + msg := "" + if missing { + msg = n.MissingMessage + } + title := n.Title + if title == "" { + title = "App navigation" + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Content-Security-Policy", shellCSP) + w.WriteHeader(http.StatusOK) + // Status is already committed; a template error cannot change the response. + _ = shellTmpl.Execute(w, shellView{ + Selected: selected, + Address: n.address(r, selected), + Header: template.HTML(header), + Items: viewItems(r, items, selected, n.address), + Page: template.HTML(page), + Missing: msg, + Title: title, + }) +} + +func (n AppNavigation) address(r *http.Request, id string) string { + q := r.URL.Query() + q.Set(n.selectKey(), id) + return "?" + q.Encode() +} + +func findItem(items []Item, id string) (Item, bool) { + for _, it := range items { + if it.ID == id { + return it, true + } + if child, ok := findItem(it.Children, id); ok { + return child, true + } + } + return Item{}, false +} + +type shellView struct { + Selected string + Address string + Header template.HTML + Items []itemView + Page template.HTML + Missing string + Title string +} + +type itemView struct { + ID string + Label string + Href string + Current bool + HasChildren bool + Children []itemView +} + +func viewItems(r *http.Request, items []Item, selected string, href func(*http.Request, string) string) []itemView { + out := make([]itemView, 0, len(items)) + for _, it := range items { + children := viewItems(r, it.Children, selected, href) + out = append(out, itemView{ + ID: it.ID, + Label: it.Label, + Href: href(r, it.ID), + Current: it.ID == selected, + HasChildren: len(children) > 0, + Children: children, + }) + } + return out +} + +var shellTmpl = template.Must(template.New("shell").Parse(` + + + +{{.Title}} + + + +
+ +
+ +
+ {{if .Missing}}{{end}} + {{.Page}} +
+
+
+ + + + +{{define "items"}}{{end}} +`)) diff --git a/navigation_test.go b/navigation_test.go new file mode 100644 index 0000000..2acea2c --- /dev/null +++ b/navigation_test.go @@ -0,0 +1,156 @@ +package gonexapp_test + +import ( + "html" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "gitea.neitzel.de/konrad/go-nc-exapp" +) + +func sampleNavigation() gonexapp.AppNavigation { + items := []gonexapp.Item{ + { + ID: "domains", Label: "Domains", + Children: []gonexapp.Item{{ID: "example.com", Label: "example.com"}}, + }, + {ID: "keys", Label: "Keys"}, + } + return gonexapp.AppNavigation{ + Items: func(*http.Request) []gonexapp.Item { return items }, + Page: func(_ *http.Request, id string) (string, bool) { + switch id { + case "domains": + return "

domains-page

", true + case "keys": + return "

keys-page

", true + case "example.com": + return "

zone-page

", true + default: + return "", false + } + }, + Header: func(*http.Request) string { return `

folder-line

` }, + DefaultID: "domains", + MissingMessage: "that item is not in this Visit", + } +} + +func navBody(t *testing.T, rawURL string) string { + t.Helper() + rec := httptest.NewRecorder() + sampleNavigation().Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, rawURL, nil)) + if rec.Code != http.StatusOK { + t.Fatalf("GET %s: got %d", rawURL, rec.Code) + } + return rec.Body.String() +} + +func navAddress(t *testing.T, body string) url.Values { + t.Helper() + const key = `data-address="` + _, after, ok := strings.Cut(body, key) + if !ok { + t.Fatalf("no data-address in %s", body) + } + rest := after + end := strings.Index(rest, `"`) + if end < 0 { + t.Fatalf("unclosed data-address") + } + raw, err := url.QueryUnescape(html.UnescapeString(rest[:end])) + if err != nil { + t.Fatal(err) + } + q, err := url.ParseQuery(strings.TrimPrefix(raw, "?")) + if err != nil { + t.Fatal(err) + } + return q +} + +func TestAppNavigationShowsSelectedPage(t *testing.T) { + body := navBody(t, "/?item=keys") + if !strings.Contains(body, "

keys-page

") { + t.Fatalf("got %s", body) + } + if !strings.Contains(body, `data-selected="keys"`) { + t.Fatalf("got %s", body) + } + if navAddress(t, body).Get("item") != "keys" { + t.Fatalf("address: %s", navAddress(t, body).Encode()) + } +} + +func TestAppNavigationDefaultsWhenItemMissingFromAddress(t *testing.T) { + body := navBody(t, "/") + if !strings.Contains(body, "

domains-page

") { + t.Fatalf("got %s", body) + } + if navAddress(t, body).Get("item") != "domains" { + t.Fatalf("address item = %q", navAddress(t, body).Get("item")) + } + if strings.Contains(body, "that item is not in this Visit") { + t.Fatalf("default open should not show the missing-item message: %s", body) + } +} + +func TestAppNavigationMissingItemSelectsDefault(t *testing.T) { + body := navBody(t, "/?folder=share&item=gone") + if !strings.Contains(body, "

domains-page

") { + t.Fatalf("got %s", body) + } + if !strings.Contains(body, "that item is not in this Visit") { + t.Fatalf("got %s", body) + } + q := navAddress(t, body) + if q.Get("item") != "domains" || q.Get("folder") != "share" { + t.Fatalf("address = %s", q.Encode()) + } +} + +func TestAppNavigationPageRefusalSelectsDefault(t *testing.T) { + nav := sampleNavigation() + inner := nav.Page + nav.Page = func(r *http.Request, id string) (string, bool) { + if id == "keys" { + return "", false + } + return inner(r, id) + } + rec := httptest.NewRecorder() + nav.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/?folder=share&item=keys", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("got %d", rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, "

domains-page

") || !strings.Contains(body, "that item is not in this Visit") { + t.Fatalf("got %s", body) + } + q := navAddress(t, body) + if q.Get("item") != "domains" || q.Get("folder") != "share" { + t.Fatalf("address = %s", q.Encode()) + } +} + +func TestAppNavigationChildExpandsParents(t *testing.T) { + body := navBody(t, "/?item=example.com") + if !strings.Contains(body, "

zone-page

") { + t.Fatalf("got %s", body) + } + if !strings.Contains(body, `data-id="domains" data-expanded="true"`) { + t.Fatalf("got %s", body) + } +} + +func TestAppNavigationHeaderPrecedesTree(t *testing.T) { + body := navBody(t, "/?item=domains") + header := strings.Index(body, "folder-line") + tree := strings.Index(body, ">Domains<") + if header < 0 || tree < 0 || header > tree { + t.Fatalf("header %d tree %d in %s", header, tree, body) + } +}