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 <cursoragent@cursor.com>
This commit is contained in:
2026-09-28 19:31:36 +02:00
co-authored by Cursor
parent 9e2005cfb3
commit 7a915aee7b
5 changed files with 377 additions and 3 deletions
+4
View File
@@ -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
+4 -2
View File
@@ -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
+2 -1
View File
@@ -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
+211
View File
@@ -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(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{.Title}}</title>
<style>
body { margin: 0; font: 16px/1.4 sans-serif; }
#app-nav { display: flex; flex-direction: column; min-height: 100vh; }
#nav-header { padding: 0.75rem 1rem; border-bottom: 1px solid #ccc; }
#app-nav-body { display: flex; flex: 1; }
nav { width: 16rem; flex: none; border-right: 1px solid #ccc; padding: 0.5rem 0; }
main { flex: 1; padding: 1rem; }
nav ul { list-style: none; margin: 0; padding-left: 0.75rem; }
nav li { margin: 0.15rem 0; }
nav a[aria-current="page"] { font-weight: 700; }
li[data-expanded="false"] > ul { display: none; }
button.fold { border: 0; background: transparent; cursor: pointer; }
#nav-missing { margin: 0 0 1rem; }
</style>
</head>
<body data-selected="{{.Selected}}" data-address="{{.Address}}">
<div id="app-nav">
<div id="nav-header">{{.Header}}</div>
<div id="app-nav-body">
<nav aria-label="App navigation">
{{template "items" .Items}}
</nav>
<main>
{{if .Missing}}<p id="nav-missing">{{.Missing}}</p>{{end}}
{{.Page}}
</main>
</div>
</div>
<script>
(function () {
var addr = document.body.getAttribute("data-address");
if (addr && addr !== location.search) {
history.replaceState(null, "", addr);
}
document.querySelectorAll("button.fold").forEach(function (btn) {
btn.addEventListener("click", function () {
var li = btn.closest("li");
var open = li.getAttribute("data-expanded") === "true";
li.setAttribute("data-expanded", open ? "false" : "true");
btn.setAttribute("aria-expanded", open ? "false" : "true");
});
});
})();
</script>
</body>
</html>
{{define "items"}}<ul>{{range .}}<li data-id="{{.ID}}"{{if .HasChildren}} data-expanded="true"{{end}}>{{if .HasChildren}}<button type="button" class="fold" aria-expanded="true" aria-label="Fold {{.Label}}"></button>{{end}}<a href="{{.Href}}"{{if .Current}} aria-current="page"{{end}}>{{.Label}}</a>{{if .HasChildren}}{{template "items" .Children}}{{end}}</li>{{end}}</ul>{{end}}
`))
+156
View File
@@ -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 "<p>domains-page</p>", true
case "keys":
return "<p>keys-page</p>", true
case "example.com":
return "<p>zone-page</p>", true
default:
return "", false
}
},
Header: func(*http.Request) string { return `<p id="visit">folder-line</p>` },
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, "<p>keys-page</p>") {
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, "<p>domains-page</p>") {
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, "<p>domains-page</p>") {
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, "<p>domains-page</p>") || !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, "<p>zone-page</p>") {
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)
}
}