Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afbd76445d | ||
|
|
d7768d44d6 | ||
|
|
fad9806585 | ||
|
|
4e05fb1c6a | ||
|
|
0a387815a0 | ||
|
|
d4c18e63bb | ||
|
|
7a915aee7b | ||
|
|
9e2005cfb3 | ||
|
|
fc2f9f63c2 | ||
|
|
9c1c2f4310 | ||
|
|
984b0c2335 | ||
|
|
198ef0e204 | ||
|
|
cf3b396398 | ||
|
|
69af4d19c8 | ||
|
|
92d8efea47 | ||
|
|
3980263146 |
+34
-2
@@ -1,6 +1,6 @@
|
|||||||
# go-nc-exapp
|
# 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.
|
Shared Go Library for Nextcloud ExApp Services: AppAPI authentication, OCS calls, per-user ExApp preferences, Notifications, Users and Groups, and optional Required Groups gating. ExApps import `gitea.neitzel.de/konrad/go-nc-exapp`. File storage and folder visits live in go-nc-files.
|
||||||
|
|
||||||
## Language
|
## Language
|
||||||
|
|
||||||
@@ -9,13 +9,45 @@ The ExApp's shared secret and Nextcloud base URL, plus optional per-request user
|
|||||||
_Avoid_: API key (generic), session token
|
_Avoid_: API key (generic), session token
|
||||||
|
|
||||||
**Requesting user**:
|
**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.
|
The Nextcloud user on whose behalf the current ExApp request runs, taken from AppAPI authorization headers. WebDAV, preferences, and a Notification to that same user use this identity; there is no separate ExApp login.
|
||||||
_Avoid_: service account (for per-request identity), anonymous
|
_Avoid_: service account (for per-request identity), anonymous
|
||||||
|
|
||||||
|
**Recipient**:
|
||||||
|
The Nextcloud user a Notification is created for. May be the Requesting user or another user. AppAPI accepts one Recipient per call; sending to a group or to all admins is many Notifications.
|
||||||
|
_Avoid_: destination, target (HTTP), addressee, treating a group as a Recipient
|
||||||
|
|
||||||
**ExApp preference**:
|
**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.
|
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
|
_Avoid_: settings file in User Files, instance-wide config
|
||||||
|
|
||||||
|
**Notification**:
|
||||||
|
A Nextcloud bell-icon message that an ExApp creates for one Recipient via AppAPI. It has a Subject, optional Message, optional Link, and optional rich-object params. AppAPI’s OCS is limited: no actions and no custom icon. One AppAPI call creates one Notification; the Recipient is the user the ExApp impersonates for that call, not a field in the message body.
|
||||||
|
_Avoid_: Denied UI, email, Talk message, in-app banner, toast, treating this as a full PHP INotifier
|
||||||
|
|
||||||
**OCS**:
|
**OCS**:
|
||||||
Nextcloud's legacy HTTP API surface under `/ocs/v2.php/…`. This Library requests JSON responses (`format=json`) for machine-readable bodies.
|
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
|
_Avoid_: assuming XML responses, REST-only Nextcloud APIs for ExApp prefs
|
||||||
|
|
||||||
|
**Required Groups**:
|
||||||
|
The Nextcloud groups configured for an ExApp (comma-separated deploy env `REQUIRED_GROUPS`) such that membership in any one of them is enough to use the ExApp. Empty or unset means no group restriction. AppAPI does not enforce this; the ExApp does.
|
||||||
|
_Avoid_: AppAPI scopes, route access_level, admin-only top menu, treating the ExApp id as an implicit group name, Users and Groups (that is the OCS directory, not this ACL)
|
||||||
|
|
||||||
|
**Users and Groups**:
|
||||||
|
Nextcloud's Provisioning OCS this Library wraps as three reads: the groups of one user (as that user), the members of one group, and the instance group list. Member and instance lists run as the Requesting user and succeed only if that user is an admin or a subadmin of the group. Distinct from Required Groups.
|
||||||
|
_Avoid_: Group-API, Required Groups, AppAPI scopes, treating a group as a Recipient
|
||||||
|
|
||||||
|
**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). 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. On a screen at most 1024px wide the tree is hidden until the user opens it; choosing an item, pressing Escape, or clicking outside closes it again. An ExApp that does not use App navigation shows its own page instead.
|
||||||
|
_Avoid_: Top Menu, sidebar, NcAppNavigation
|
||||||
|
|
||||||
|
**Dialog**:
|
||||||
|
A modal window on an ExApp page, in the same Nextcloud theme as that page. It does not require App navigation. The page waits for the user's choice; that choice is not a server request. The heading, the message, and the button word are plain text. The heading may be omitted. The fixed buttons are the English words OK and Cancel. One of three kinds: a Message (short text at info, warning, or error, dismissed with one button), a Confirm (yes or no; the agreeing button may be destructive), or a Prompt (one entered value, or cancelled). A Prompt with no value cannot be agreed. A successful Prompt value has no surrounding spaces. The caller may supply a starting value. A Prompt field has no label of its own; the message is the text above it.
|
||||||
|
_Avoid_: popup, browser alert/confirm/prompt, Notification, toast, HTML message
|
||||||
|
|
||||||
|
**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
|
||||||
|
|||||||
@@ -1,51 +1,59 @@
|
|||||||
# go-nc-exapp
|
# go-nc-exapp
|
||||||
|
|
||||||
Shared Go library for Nextcloud ExApp Services: AppAPI authentication, OCS JSON calls, and per-user ExApp preferences.
|
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, an optional App navigation shell, and a Dialog.
|
||||||
|
|
||||||
Import: `gitea.neitzel.de/konrad/go-nc-exapp`
|
Import: `gitea.neitzel.de/konrad/go-nc-exapp` (package `gonexapp`).
|
||||||
|
|
||||||
## v1 scope
|
```bash
|
||||||
|
go get gitea.neitzel.de/konrad/go-nc-exapp
|
||||||
**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.
|
- [Guide](docs/guide.md) — how to call each capability
|
||||||
|
- [API](docs/api.md) — every exported symbol
|
||||||
|
- [Domain language](CONTEXT.md)
|
||||||
|
|
||||||
## Domain language
|
## Included
|
||||||
|
|
||||||
See [CONTEXT.md](./CONTEXT.md) for AppAPI credentials, requesting user, ExApp preference, and OCS terminology.
|
- [Credentials](docs/guide.md#credentials) — Nextcloud base URL, AppAPI secret, and requesting user
|
||||||
|
- [AuthHeaders](docs/guide.md#authheaders) — outbound AppAPI authorization
|
||||||
|
- [WithUser](docs/guide.md#withuser) — credentials scoped to one user
|
||||||
|
- [UserFromRequest](docs/guide.md#userfromrequest) — requesting user on an inbound request
|
||||||
|
- [OCSClient](docs/guide.md#ocsclient) — authenticated OCS calls with `format=json`
|
||||||
|
- [AppAPIPreferences](docs/guide.md#appapipreferences) — one string preference for the requesting user
|
||||||
|
- [AppAPINotifications](docs/guide.md#appapinotifications) — one bell notification
|
||||||
|
- [Groups](docs/guide.md#groups) — user groups, group members, and the group list
|
||||||
|
- [Access Gate](docs/guide.md#access-gate) — optional Required Groups check
|
||||||
|
- [Top Menu visibility](docs/guide.md#top-menu-visibility) — `TOP_MENU_ADMIN_REQUIRED`
|
||||||
|
- [App navigation](docs/guide.md#app-navigation) — optional Files-style shell
|
||||||
|
- [Dialog](docs/guide.md#dialog) — message, confirm, and prompt
|
||||||
|
|
||||||
|
## Excluded
|
||||||
|
|
||||||
|
- ExApp lifecycle HTTP routes (`/heartbeat`, `/enabled`, and `/init`). The Access Gate skips these by default and does not implement them
|
||||||
|
- 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**)
|
||||||
|
- Fan-out notifications (`SendToGroup`, `SendToAdmins`)
|
||||||
|
- A library default admin user for directory OCS (callers pass that user with `WithUser`)
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Unit tests use `httptest` fake OCS servers. No live Nextcloud is required.
|
||||||
|
|
||||||
|
`go test ./...` fails when [`docs/api.md`](docs/api.md) does not match the exported API. Regenerate it with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
UPDATE_API_DOCS=1 go test -run TestAPIDoc -count=1
|
||||||
|
```
|
||||||
|
|
||||||
|
Runnable package examples: `go test -run Example`.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
- **go-nc-files** — WebDAV, Working Folder, Saved Default, Visit resolution
|
- **go-nc-files** — WebDAV, Working Folder, Saved Default, Visit resolution
|
||||||
- Workspace ADR 0013 — extraction from CheckDNS
|
- Workspace ADR 0013 — extraction from CheckDNS
|
||||||
|
- Workspace ADR `docs/adr/go-nc-exapp/0001-required-groups-access-gate.md` — Access Gate
|
||||||
|
- Workspace ADR `docs/adr/go-nc-exapp/0002-users-and-groups-as-requesting-user.md` — directory OCS as the requesting user
|
||||||
|
- Workspace ADR `docs/adr/go-nc-exapp/0003-app-navigation-shell.md` — App navigation shell
|
||||||
|
- Workspace ADR `docs/adr/go-nc-exapp/0004-dialog-in-exapp-page.md` — Dialog
|
||||||
|
|||||||
+222
@@ -0,0 +1,222 @@
|
|||||||
|
package gonexapp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AccessGate enforces Required Groups for the Requesting user on ExApp HTTP traffic.
|
||||||
|
type AccessGate struct {
|
||||||
|
Cred Credentials
|
||||||
|
Groups []string
|
||||||
|
CacheTTL time.Duration // 0 disables cache
|
||||||
|
ExtraSkipPaths []string
|
||||||
|
Client *http.Client
|
||||||
|
OCS OCSClient
|
||||||
|
Now func() time.Time
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
cache map[string]cacheEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
type cacheEntry struct {
|
||||||
|
until time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckResult is the outcome of AccessGate.Check.
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
// Wrap returns a handler that runs Check before next.
|
||||||
|
// CheckAllowed calls next.
|
||||||
|
// CheckUnauthorized writes 401. CheckUnavailable writes 503.
|
||||||
|
// CheckDenied writes English HTML with status 200 and frame-ancestors 'self'
|
||||||
|
// when the request accepts text/html, and 403 otherwise.
|
||||||
|
// An empty Groups list allows every request. Paths /heartbeat, /enabled,
|
||||||
|
// /init, and /js/ are skipped, plus ExtraSkipPaths.
|
||||||
|
func (g AccessGate) Wrap(next http.Handler) http.Handler {
|
||||||
|
gate := g.normalized()
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch gate.Check(r) {
|
||||||
|
case CheckAllowed:
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
case CheckUnauthorized:
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
case CheckUnavailable:
|
||||||
|
http.Error(w, "service unavailable", http.StatusServiceUnavailable)
|
||||||
|
default:
|
||||||
|
gate.writeDenied(w, r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check reports whether r may proceed under Required Groups.
|
||||||
|
func (g *AccessGate) Check(r *http.Request) CheckResult {
|
||||||
|
if g.cache == nil {
|
||||||
|
g.cache = make(map[string]cacheEntry)
|
||||||
|
}
|
||||||
|
if g.Now == nil {
|
||||||
|
g.Now = time.Now
|
||||||
|
}
|
||||||
|
if g.shouldSkip(r.URL.Path) || len(g.Groups) == 0 {
|
||||||
|
return CheckAllowed
|
||||||
|
}
|
||||||
|
user, err := UserFromRequest(r)
|
||||||
|
if err != nil || user == "" {
|
||||||
|
return CheckUnauthorized
|
||||||
|
}
|
||||||
|
ok, err := g.memberOfRequired(user)
|
||||||
|
if err != nil {
|
||||||
|
return CheckUnavailable
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
return CheckAllowed
|
||||||
|
}
|
||||||
|
return CheckDenied
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *AccessGate) memberOfRequired(userID string) (bool, error) {
|
||||||
|
key := g.cacheKey(userID)
|
||||||
|
if g.CacheTTL > 0 {
|
||||||
|
if g.cachedAllowed(key) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
groups, err := g.fetchUserGroups(userID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
for _, need := range g.Groups {
|
||||||
|
if slices.Contains(groups, need) {
|
||||||
|
if g.CacheTTL > 0 {
|
||||||
|
g.storeAllowed(key)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *AccessGate) cacheKey(userID string) string {
|
||||||
|
return userID + "\x00" + strings.Join(g.Groups, "\x00")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *AccessGate) fetchUserGroups(userID string) ([]string, error) {
|
||||||
|
return Groups{Cred: g.Cred, Client: g.Client, OCS: g.OCS}.UserGroups(userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *AccessGate) cachedAllowed(key string) bool {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
ent, ok := g.cache[key]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if g.Now().After(ent.until) {
|
||||||
|
delete(g.cache, key)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *AccessGate) storeAllowed(key string) {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
g.cache[key] = cacheEntry{until: g.Now().Add(g.CacheTTL)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g AccessGate) normalized() *AccessGate {
|
||||||
|
out := g
|
||||||
|
if out.cache == nil {
|
||||||
|
out.cache = make(map[string]cacheEntry)
|
||||||
|
}
|
||||||
|
if out.Now == nil {
|
||||||
|
out.Now = time.Now
|
||||||
|
}
|
||||||
|
return &out
|
||||||
|
}
|
||||||
|
|
||||||
|
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.Header().Set("Content-Security-Policy", deniedCSP)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write(deniedHTML)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "forbidden", http.StatusForbidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
func acceptsHTML(accept string) bool {
|
||||||
|
return strings.Contains(strings.ToLower(accept), "text/html")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *AccessGate) shouldSkip(path string) bool {
|
||||||
|
path = normalizeGatePath(path)
|
||||||
|
if isTopMenuScriptPath(path) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if slices.Contains(defaultSkipPaths, path) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, p := range g.ExtraSkipPaths {
|
||||||
|
if path == normalizeGatePath(p) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 == "" {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
<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>
|
||||||
|
`)
|
||||||
@@ -0,0 +1,413 @@
|
|||||||
|
package gonexapp_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.neitzel.de/konrad/go-nc-exapp"
|
||||||
|
)
|
||||||
|
|
||||||
|
func authHeader(userID string) string {
|
||||||
|
return base64.StdEncoding.EncodeToString([]byte(userID + ":secret"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func okInner() http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = io.WriteString(w, "ok")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccessGateEmptyGroupsPassesThrough(t *testing.T) {
|
||||||
|
gate := gonexapp.AccessGate{}
|
||||||
|
h := gate.Wrap(okInner())
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/zones", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK || rec.Body.String() != "ok" {
|
||||||
|
t.Fatalf("got %d %q", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccessGateSkipsLifecyclePaths(t *testing.T) {
|
||||||
|
gate := gonexapp.AccessGate{Groups: []string{"dns-ops"}}
|
||||||
|
h := gate.Wrap(okInner())
|
||||||
|
|
||||||
|
for _, path := range []string{"/heartbeat", "/enabled", "/init"} {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("%s: got %d", path, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccessGateExtraSkipPaths(t *testing.T) {
|
||||||
|
gate := gonexapp.AccessGate{Groups: []string{"dns-ops"}, ExtraSkipPaths: []string{"/healthz"}}
|
||||||
|
h := gate.Wrap(okInner())
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccessGateMissingUserUnauthorized(t *testing.T) {
|
||||||
|
gate := gonexapp.AccessGate{Groups: []string{"dns-ops"}}
|
||||||
|
h := gate.Wrap(okInner())
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/zones", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func groupsOCSServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
srv := httptest.NewServer(handler)
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
func gateWithOCS(t *testing.T, groups []string, ttl time.Duration, srv *httptest.Server) gonexapp.AccessGate {
|
||||||
|
t.Helper()
|
||||||
|
cred := gonexapp.Credentials{
|
||||||
|
BaseURL: srv.URL, AppID: "app", AppVersion: "0.1.0", AAVersion: "1.0.0", AppSecret: "s",
|
||||||
|
}
|
||||||
|
return gonexapp.AccessGate{
|
||||||
|
Cred: cred,
|
||||||
|
Groups: groups,
|
||||||
|
CacheTTL: ttl,
|
||||||
|
Client: srv.Client(),
|
||||||
|
OCS: gonexapp.OCSClient{Cred: cred, Client: srv.Client()},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccessGateAllowsAnyOfMember(t *testing.T) {
|
||||||
|
var calls atomic.Int32
|
||||||
|
srv := groupsOCSServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls.Add(1)
|
||||||
|
if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/cloud/users/alice/groups") {
|
||||||
|
http.Error(w, "bad path "+r.URL.Path, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"ocs": map[string]any{"data": map[string]any{"groups": []string{"other", "dns-ops"}}},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
gate := gateWithOCS(t, []string{"dns-ops", "dns-admins"}, 0, srv)
|
||||||
|
h := gate.Wrap(okInner())
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/zones", nil)
|
||||||
|
req.Header.Set("AUTHORIZATION-APP-API", authHeader("alice"))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK || rec.Body.String() != "ok" {
|
||||||
|
t.Fatalf("got %d %q", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if calls.Load() != 1 {
|
||||||
|
t.Fatalf("ocs calls=%d", calls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccessGateDeniesNonMemberWith403(t *testing.T) {
|
||||||
|
srv := groupsOCSServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"ocs": map[string]any{"data": map[string]any{"groups": []string{"users"}}},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
gate := gateWithOCS(t, []string{"dns-ops"}, 0, srv)
|
||||||
|
h := gate.Wrap(okInner())
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/zones", nil)
|
||||||
|
req.Header.Set("AUTHORIZATION-APP-API", authHeader("alice"))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("got %d", rec.Code)
|
||||||
|
}
|
||||||
|
if strings.Contains(rec.Header().Get("Content-Type"), "text/html") {
|
||||||
|
t.Fatalf("unexpected html content-type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccessGateDeniesNonMemberWithHTML(t *testing.T) {
|
||||||
|
srv := groupsOCSServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"ocs": map[string]any{"data": map[string]any{"groups": []string{"users"}}},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
gate := gateWithOCS(t, []string{"dns-ops"}, 0, srv)
|
||||||
|
h := gate.Wrap(okInner())
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.Header.Set("AUTHORIZATION-APP-API", authHeader("alice"))
|
||||||
|
req.Header.Set("Accept", "text/html,application/xhtml+xml")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("got %d", rec.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Header().Get("Content-Type"), "text/html") {
|
||||||
|
t.Fatalf("content-type=%q", rec.Header().Get("Content-Type"))
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
srv := groupsOCSServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "boom", http.StatusInternalServerError)
|
||||||
|
})
|
||||||
|
gate := gateWithOCS(t, []string{"dns-ops"}, 0, srv)
|
||||||
|
h := gate.Wrap(okInner())
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/zones", nil)
|
||||||
|
req.Header.Set("AUTHORIZATION-APP-API", authHeader("alice"))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccessGateCachesPositiveMembership(t *testing.T) {
|
||||||
|
var calls atomic.Int32
|
||||||
|
srv := groupsOCSServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls.Add(1)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"ocs": map[string]any{"data": map[string]any{"groups": []string{"dns-ops"}}},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
now := time.Unix(1_700_000_000, 0)
|
||||||
|
gate := gateWithOCS(t, []string{"dns-ops"}, time.Minute, srv)
|
||||||
|
gate.Now = func() time.Time { return now }
|
||||||
|
h := gate.Wrap(okInner())
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/zones", nil)
|
||||||
|
req.Header.Set("AUTHORIZATION-APP-API", authHeader("alice"))
|
||||||
|
for i := range 2 {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("pass %d: got %d", i, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if calls.Load() != 1 {
|
||||||
|
t.Fatalf("ocs calls=%d want 1", calls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccessGateCacheKeyedByGroupSet(t *testing.T) {
|
||||||
|
var calls atomic.Int32
|
||||||
|
srv := groupsOCSServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls.Add(1)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"ocs": map[string]any{"data": map[string]any{"groups": []string{"dns-ops"}}},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
cred := gonexapp.Credentials{
|
||||||
|
BaseURL: srv.URL, AppID: "app", AppVersion: "0.1.0", AAVersion: "1.0.0", AppSecret: "s",
|
||||||
|
}
|
||||||
|
gate := &gonexapp.AccessGate{
|
||||||
|
Cred: cred, Groups: []string{"dns-ops"}, CacheTTL: time.Minute,
|
||||||
|
Client: srv.Client(), OCS: gonexapp.OCSClient{Cred: cred, Client: srv.Client()},
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/zones", nil)
|
||||||
|
req.Header.Set("AUTHORIZATION-APP-API", authHeader("alice"))
|
||||||
|
if gate.Check(req) != gonexapp.CheckAllowed {
|
||||||
|
t.Fatal("first allow")
|
||||||
|
}
|
||||||
|
gate.Groups = []string{"other-group"}
|
||||||
|
if gate.Check(req) != gonexapp.CheckDenied {
|
||||||
|
t.Fatalf("after group-set change want denied, calls=%d", calls.Load())
|
||||||
|
}
|
||||||
|
if calls.Load() != 2 {
|
||||||
|
t.Fatalf("ocs calls=%d want 2 (cache must not reuse prior group-set)", calls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccessGateDoesNotCacheDenial(t *testing.T) {
|
||||||
|
var calls atomic.Int32
|
||||||
|
srv := groupsOCSServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
n := calls.Add(1)
|
||||||
|
groups := []string{"users"}
|
||||||
|
if n >= 2 {
|
||||||
|
groups = []string{"dns-ops"}
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"ocs": map[string]any{"data": map[string]any{"groups": groups}},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
gate := gateWithOCS(t, []string{"dns-ops"}, time.Minute, srv)
|
||||||
|
h := gate.Wrap(okInner())
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/zones", nil)
|
||||||
|
req.Header.Set("AUTHORIZATION-APP-API", authHeader("alice"))
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("first: got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("second: got %d", rec.Code)
|
||||||
|
}
|
||||||
|
if calls.Load() != 2 {
|
||||||
|
t.Fatalf("ocs calls=%d want 2", calls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccessGateZeroTTLDisablesCache(t *testing.T) {
|
||||||
|
var calls atomic.Int32
|
||||||
|
srv := groupsOCSServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls.Add(1)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"ocs": map[string]any{"data": map[string]any{"groups": []string{"dns-ops"}}},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
gate := gateWithOCS(t, []string{"dns-ops"}, 0, srv)
|
||||||
|
h := gate.Wrap(okInner())
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/zones", nil)
|
||||||
|
req.Header.Set("AUTHORIZATION-APP-API", authHeader("alice"))
|
||||||
|
for i := range 2 {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("pass %d: got %d", i, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if calls.Load() != 2 {
|
||||||
|
t.Fatalf("ocs calls=%d want 2", calls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccessGateDoesNotCacheLookupErrors(t *testing.T) {
|
||||||
|
var calls atomic.Int32
|
||||||
|
srv := groupsOCSServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
n := calls.Add(1)
|
||||||
|
if n == 1 {
|
||||||
|
http.Error(w, "boom", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"ocs": map[string]any{"data": map[string]any{"groups": []string{"dns-ops"}}},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
gate := gateWithOCS(t, []string{"dns-ops"}, time.Minute, srv)
|
||||||
|
h := gate.Wrap(okInner())
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/zones", nil)
|
||||||
|
req.Header.Set("AUTHORIZATION-APP-API", authHeader("alice"))
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("first: got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("second: got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRequiredGroups(t *testing.T) {
|
||||||
|
got := gonexapp.ParseRequiredGroups(" dns-ops, dns-admins ,, ")
|
||||||
|
if len(got) != 2 || got[0] != "dns-ops" || got[1] != "dns-admins" {
|
||||||
|
t.Fatalf("got %#v", got)
|
||||||
|
}
|
||||||
|
if len(gonexapp.ParseRequiredGroups("")) != 0 {
|
||||||
|
t.Fatalf("empty should be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveRequiredGroups(t *testing.T) {
|
||||||
|
def := []string{"checkdns"}
|
||||||
|
if got := gonexapp.ResolveRequiredGroups("", false, def); len(got) != 1 || got[0] != "checkdns" {
|
||||||
|
t.Fatalf("unset: %#v", got)
|
||||||
|
}
|
||||||
|
if got := gonexapp.ResolveRequiredGroups("", true, def); len(got) != 0 {
|
||||||
|
t.Fatalf("set empty: %#v", got)
|
||||||
|
}
|
||||||
|
if got := gonexapp.ResolveRequiredGroups("ops", true, def); len(got) != 1 || got[0] != "ops" {
|
||||||
|
t.Fatalf("set: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCacheSeconds(t *testing.T) {
|
||||||
|
if d := gonexapp.ParseCacheSeconds("", 60); d != 60*time.Second {
|
||||||
|
t.Fatalf("default unset: %v", d)
|
||||||
|
}
|
||||||
|
if d := gonexapp.ParseCacheSeconds("0", 60); d != 0 {
|
||||||
|
t.Fatalf("zero: %v", d)
|
||||||
|
}
|
||||||
|
if d := gonexapp.ParseCacheSeconds("30", 60); d != 30*time.Second {
|
||||||
|
t.Fatalf("thirty: %v", d)
|
||||||
|
}
|
||||||
|
if d := gonexapp.ParseCacheSeconds("nope", 60); d != 60*time.Second {
|
||||||
|
t.Fatalf("invalid: %v", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccessGateCheckStandalone(t *testing.T) {
|
||||||
|
srv := groupsOCSServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"ocs": map[string]any{"data": map[string]any{"groups": []string{"dns-ops"}}},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
gate := gateWithOCS(t, []string{"dns-ops"}, 0, srv)
|
||||||
|
ptr := &gonexapp.AccessGate{
|
||||||
|
Cred: gate.Cred, Groups: gate.Groups, CacheTTL: gate.CacheTTL, Client: gate.Client, OCS: gate.OCS,
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/zones", nil)
|
||||||
|
req.Header.Set("AUTHORIZATION-APP-API", authHeader("alice"))
|
||||||
|
if got := ptr.Check(req); got != gonexapp.CheckAllowed {
|
||||||
|
t.Fatalf("got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+278
@@ -0,0 +1,278 @@
|
|||||||
|
package gonexapp_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"go/ast"
|
||||||
|
"go/doc"
|
||||||
|
"go/doc/comment"
|
||||||
|
"go/parser"
|
||||||
|
"go/printer"
|
||||||
|
"go/token"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const apiModulePath = "gitea.neitzel.de/konrad/go-nc-exapp"
|
||||||
|
|
||||||
|
func TestAPIDoc(t *testing.T) {
|
||||||
|
got, err := renderAPIDoc(".", apiModulePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
const path = "docs/api.md"
|
||||||
|
if os.Getenv("UPDATE_API_DOCS") == "1" {
|
||||||
|
if err := os.MkdirAll("docs", 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(got), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
want, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read %s: %v", path, err)
|
||||||
|
}
|
||||||
|
if string(want) != got {
|
||||||
|
t.Fatalf("docs/api.md is stale; regenerate with UPDATE_API_DOCS=1 go test -run TestAPIDoc -count=1\n%s", firstDiff(string(want), got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderAPIDoc(dir, modulePath string) (string, error) {
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
pkgs, err := parser.ParseDir(fset, dir, func(fi os.FileInfo) bool {
|
||||||
|
return !strings.HasSuffix(fi.Name(), "_test.go")
|
||||||
|
}, parser.ParseComments)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(pkgs) != 1 {
|
||||||
|
return "", fmt.Errorf("expected 1 package in %s, found %d", dir, len(pkgs))
|
||||||
|
}
|
||||||
|
var astPkg *ast.Package
|
||||||
|
for _, p := range pkgs {
|
||||||
|
astPkg = p
|
||||||
|
}
|
||||||
|
pkg := doc.New(astPkg, modulePath, 0)
|
||||||
|
lookup := symLookup(pkg)
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("# API\n\n")
|
||||||
|
b.WriteString("Generated from the package comment and every exported declaration. Do not edit.\n")
|
||||||
|
b.WriteString("Regenerate with `UPDATE_API_DOCS=1 go test -run TestAPIDoc -count=1`.\n\n")
|
||||||
|
fmt.Fprintf(&b, "## Package %s\n\n", pkg.Name)
|
||||||
|
writeDoc(&b, pkg.Doc, lookup)
|
||||||
|
writeValues(&b, fset, "Constants", pkg.Consts, "### ", lookup)
|
||||||
|
writeValues(&b, fset, "Variables", pkg.Vars, "### ", lookup)
|
||||||
|
writeFuncs(&b, fset, "Functions", pkg.Funcs, "### ", lookup)
|
||||||
|
if len(pkg.Types) > 0 {
|
||||||
|
b.WriteString("## Types\n\n")
|
||||||
|
for _, typ := range pkg.Types {
|
||||||
|
writeType(&b, fset, typ, lookup)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeType(b *strings.Builder, fset *token.FileSet, typ *doc.Type, lookup func(string, string) bool) {
|
||||||
|
fmt.Fprintf(b, "### %s\n\n", typ.Name)
|
||||||
|
sig, err := formatGenDecl(fset, typ.Decl)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(b, "_(signature unavailable: %v)_\n\n", err)
|
||||||
|
} else {
|
||||||
|
writeCode(b, sig)
|
||||||
|
}
|
||||||
|
writeDoc(b, typ.Doc, lookup)
|
||||||
|
writeValues(b, fset, "", typ.Consts, "#### ", lookup)
|
||||||
|
writeValues(b, fset, "", typ.Vars, "#### ", lookup)
|
||||||
|
writeFuncs(b, fset, "", typ.Funcs, "#### ", lookup)
|
||||||
|
if !isInterface(typ) {
|
||||||
|
writeFuncs(b, fset, "", typ.Methods, "#### ", lookup)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeValues(b *strings.Builder, fset *token.FileSet, title string, vals []*doc.Value, heading string, lookup func(string, string) bool) {
|
||||||
|
if len(vals) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if title != "" {
|
||||||
|
fmt.Fprintf(b, "## %s\n\n", title)
|
||||||
|
}
|
||||||
|
for _, v := range vals {
|
||||||
|
for _, name := range v.Names {
|
||||||
|
fmt.Fprintf(b, "%s%s\n\n", heading, name)
|
||||||
|
}
|
||||||
|
sig, err := formatGenDecl(fset, v.Decl)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(b, "_(signature unavailable: %v)_\n\n", err)
|
||||||
|
} else {
|
||||||
|
writeCode(b, sig)
|
||||||
|
}
|
||||||
|
writeDoc(b, v.Doc, lookup)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFuncs(b *strings.Builder, fset *token.FileSet, title string, fns []*doc.Func, heading string, lookup func(string, string) bool) {
|
||||||
|
if len(fns) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if title != "" {
|
||||||
|
fmt.Fprintf(b, "## %s\n\n", title)
|
||||||
|
}
|
||||||
|
for _, fn := range fns {
|
||||||
|
name := fn.Name
|
||||||
|
if fn.Recv != "" {
|
||||||
|
name = strings.TrimPrefix(fn.Recv, "*") + "." + fn.Name
|
||||||
|
}
|
||||||
|
fmt.Fprintf(b, "%s%s\n\n", heading, name)
|
||||||
|
sig, err := formatFunc(fset, fn.Decl)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(b, "_(signature unavailable: %v)_\n\n", err)
|
||||||
|
} else {
|
||||||
|
writeCode(b, sig)
|
||||||
|
}
|
||||||
|
writeDoc(b, fn.Doc, lookup)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeCode(b *strings.Builder, sig string) {
|
||||||
|
b.WriteString("```go\n")
|
||||||
|
b.WriteString(sig)
|
||||||
|
b.WriteString("\n```\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeDoc(b *strings.Builder, raw string, lookup func(string, string) bool) {
|
||||||
|
text := renderComment(raw, lookup)
|
||||||
|
if text == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.WriteString(text)
|
||||||
|
if !strings.HasSuffix(text, "\n") {
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
func symLookup(pkg *doc.Package) func(string, string) bool {
|
||||||
|
syms := map[string]bool{}
|
||||||
|
methods := map[string]bool{}
|
||||||
|
addNames := func(vals []*doc.Value) {
|
||||||
|
for _, v := range vals {
|
||||||
|
for _, name := range v.Names {
|
||||||
|
syms[name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addFuncs := func(fns []*doc.Func) {
|
||||||
|
for _, fn := range fns {
|
||||||
|
syms[fn.Name] = true
|
||||||
|
if fn.Recv != "" {
|
||||||
|
recv := strings.TrimPrefix(fn.Recv, "*")
|
||||||
|
methods[recv+"."+fn.Name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addNames(pkg.Consts)
|
||||||
|
addNames(pkg.Vars)
|
||||||
|
addFuncs(pkg.Funcs)
|
||||||
|
for _, typ := range pkg.Types {
|
||||||
|
syms[typ.Name] = true
|
||||||
|
addNames(typ.Consts)
|
||||||
|
addNames(typ.Vars)
|
||||||
|
addFuncs(typ.Funcs)
|
||||||
|
addFuncs(typ.Methods)
|
||||||
|
}
|
||||||
|
return func(recv, name string) bool {
|
||||||
|
if recv == "" {
|
||||||
|
return syms[name]
|
||||||
|
}
|
||||||
|
return methods[recv+"."+name]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderComment(raw string, lookup func(string, string) bool) string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var parser comment.Parser
|
||||||
|
parser.LookupSym = lookup
|
||||||
|
var printer comment.Printer
|
||||||
|
printer.HeadingLevel = 4
|
||||||
|
printer.DocLinkURL = func(link *comment.DocLink) string {
|
||||||
|
if link.ImportPath != "" {
|
||||||
|
return link.DefaultURL("https://pkg.go.dev")
|
||||||
|
}
|
||||||
|
name := link.Name
|
||||||
|
if link.Recv != "" {
|
||||||
|
name = link.Recv + "." + link.Name
|
||||||
|
}
|
||||||
|
return "#" + githubAnchor(name)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(printer.Markdown(parser.Parse(raw))))
|
||||||
|
}
|
||||||
|
|
||||||
|
// githubAnchor matches GitHub heading slugs: lowercase, drop punctuation, keep
|
||||||
|
// letters, digits, hyphens, and underscores. "Client.Chat" becomes "clientchat".
|
||||||
|
func githubAnchor(name string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range strings.ToLower(name) {
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatGenDecl(fset *token.FileSet, decl *ast.GenDecl) (string, error) {
|
||||||
|
if decl == nil {
|
||||||
|
return "", fmt.Errorf("nil decl")
|
||||||
|
}
|
||||||
|
copyDecl := *decl
|
||||||
|
copyDecl.Doc = nil
|
||||||
|
return formatNode(fset, ©Decl)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatFunc(fset *token.FileSet, decl *ast.FuncDecl) (string, error) {
|
||||||
|
if decl == nil {
|
||||||
|
return "", fmt.Errorf("nil decl")
|
||||||
|
}
|
||||||
|
copyDecl := *decl
|
||||||
|
copyDecl.Doc = nil
|
||||||
|
copyDecl.Body = nil
|
||||||
|
return formatNode(fset, ©Decl)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatNode(fset *token.FileSet, node ast.Node) (string, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := printer.Fprint(&buf, fset, node); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(buf.String()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isInterface(typ *doc.Type) bool {
|
||||||
|
if typ.Decl == nil || len(typ.Decl.Specs) != 1 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
spec, ok := typ.Decl.Specs[0].(*ast.TypeSpec)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, ok = spec.Type.(*ast.InterfaceType)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstDiff(want, got string) string {
|
||||||
|
w := strings.Split(want, "\n")
|
||||||
|
g := strings.Split(got, "\n")
|
||||||
|
n := min(len(g), len(w))
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
if w[i] != g[i] {
|
||||||
|
return fmt.Sprintf("line %d:\n got: %s\nwant: %s", i+1, g[i], w[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("length got %d lines, want %d lines", len(g), len(w))
|
||||||
|
}
|
||||||
+16
-5
@@ -7,9 +7,13 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Credentials are what an ExApp needs to call Nextcloud as a user.
|
// Credentials are what an ExApp needs to call Nextcloud as one user.
|
||||||
|
// BaseURL is the Nextcloud instance URL. A trailing slash is tolerated by
|
||||||
|
// callers in this package and is removed when they build a URL.
|
||||||
|
// AppID, AppVersion, and AAVersion are sent as EX-APP-ID, EX-APP-VERSION,
|
||||||
|
// and AA-VERSION. AppSecret and UserID form the AUTHORIZATION-APP-API token.
|
||||||
type Credentials struct {
|
type Credentials struct {
|
||||||
BaseURL string // Nextcloud instance URL, no trailing slash
|
BaseURL string
|
||||||
AppID string
|
AppID string
|
||||||
AppVersion string
|
AppVersion string
|
||||||
AAVersion string
|
AAVersion string
|
||||||
@@ -17,7 +21,10 @@ type Credentials struct {
|
|||||||
UserID string
|
UserID string
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuthHeaders returns AppAPI auth headers for requests to Nextcloud.
|
// AuthHeaders returns AppAPI headers for a request to Nextcloud.
|
||||||
|
// The set is AA-VERSION, EX-APP-ID, EX-APP-VERSION, AUTHORIZATION-APP-API,
|
||||||
|
// and OCS-APIRequest. AUTHORIZATION-APP-API is base64 of UserID, a colon,
|
||||||
|
// and AppSecret. The method does not return an error; empty fields are sent as empty.
|
||||||
func (c Credentials) AuthHeaders() http.Header {
|
func (c Credentials) AuthHeaders() http.Header {
|
||||||
h := make(http.Header)
|
h := make(http.Header)
|
||||||
h.Set("AA-VERSION", c.AAVersion)
|
h.Set("AA-VERSION", c.AAVersion)
|
||||||
@@ -29,14 +36,18 @@ func (c Credentials) AuthHeaders() http.Header {
|
|||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithUser returns a copy acting as userID.
|
// WithUser returns a copy whose UserID is userID.
|
||||||
|
// The receiver is not modified.
|
||||||
func (c Credentials) WithUser(userID string) Credentials {
|
func (c Credentials) WithUser(userID string) Credentials {
|
||||||
out := c
|
out := c
|
||||||
out.UserID = userID
|
out.UserID = userID
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserFromRequest reads the requesting user from AUTHORIZATION-APP-API on an inbound ExApp request.
|
// UserFromRequest reads the requesting user from AUTHORIZATION-APP-API
|
||||||
|
// on an inbound ExApp request.
|
||||||
|
// It returns an error when the header is missing, is not base64, or contains
|
||||||
|
// no user id before the colon.
|
||||||
func UserFromRequest(r *http.Request) (string, error) {
|
func UserFromRequest(r *http.Request) (string, error) {
|
||||||
raw := r.Header.Get("AUTHORIZATION-APP-API")
|
raw := r.Header.Get("AUTHORIZATION-APP-API")
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package gonexapp
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"html/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed dialog.js
|
||||||
|
var dialogJS string
|
||||||
|
|
||||||
|
// DialogHTML is the Dialog markup and script for an ExApp page.
|
||||||
|
// App navigation includes it. A page the ExApp renders itself inserts the
|
||||||
|
// same fragment. The page then calls exappDialog.message, exappDialog.confirm,
|
||||||
|
// or exappDialog.prompt and waits for the user's choice.
|
||||||
|
func DialogHTML() template.HTML {
|
||||||
|
return template.HTML(dialogStyle + dialogMarkup + "<script>" + dialogJS + "</script>")
|
||||||
|
}
|
||||||
|
|
||||||
|
const dialogStyle = `<style>
|
||||||
|
#exapp-dialog {
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
max-width: calc(100% - 2rem);
|
||||||
|
}
|
||||||
|
#exapp-dialog::backdrop { background: rgba(0, 0, 0, 0.45); }
|
||||||
|
#exapp-dialog-panel {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: min(28rem, 100%);
|
||||||
|
padding: 1.1rem 1.25rem 1rem;
|
||||||
|
border-radius: var(--border-radius-element, 8px);
|
||||||
|
border-top: 4px solid var(--color-primary-element, #00679e);
|
||||||
|
background: var(--color-main-background, #fff);
|
||||||
|
color: var(--color-main-text, #222);
|
||||||
|
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.28);
|
||||||
|
}
|
||||||
|
#exapp-dialog[data-severity="warning"] #exapp-dialog-panel { border-top-color: var(--color-warning, #eca700); }
|
||||||
|
#exapp-dialog[data-severity="error"] #exapp-dialog-panel { border-top-color: var(--color-error, #e9322d); }
|
||||||
|
#exapp-dialog-heading { margin: 0 0 0.6rem; font-size: 1.15rem; }
|
||||||
|
#exapp-dialog-severity {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
#exapp-dialog-message { margin: 0; white-space: pre-wrap; }
|
||||||
|
#exapp-dialog-value {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
border: 1px solid var(--color-border, #ccc);
|
||||||
|
border-radius: var(--border-radius-element, 8px);
|
||||||
|
background: var(--color-main-background, #fff);
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
#exapp-dialog-actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1.1rem; }
|
||||||
|
#exapp-dialog-actions button {
|
||||||
|
min-height: var(--default-clickable-area, 34px);
|
||||||
|
padding: 0 0.9rem;
|
||||||
|
border: 1px solid var(--color-border, #ccc);
|
||||||
|
border-radius: var(--border-radius-element, 8px);
|
||||||
|
background: var(--color-main-background, #fff);
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
#exapp-dialog-actions button#exapp-dialog-agree {
|
||||||
|
border-color: transparent;
|
||||||
|
background: var(--color-primary-element, #00679e);
|
||||||
|
color: var(--color-primary-element-text, #fff);
|
||||||
|
}
|
||||||
|
#exapp-dialog-actions button#exapp-dialog-agree.destructive {
|
||||||
|
background: var(--color-error, #ffe7e7);
|
||||||
|
color: var(--color-error-text, #8a0000);
|
||||||
|
}
|
||||||
|
#exapp-dialog-actions button#exapp-dialog-agree:disabled { opacity: 0.45; cursor: default; }
|
||||||
|
</style>`
|
||||||
|
|
||||||
|
const dialogMarkup = `<dialog id="exapp-dialog" aria-modal="true">
|
||||||
|
<div id="exapp-dialog-panel">
|
||||||
|
<h2 id="exapp-dialog-heading" hidden></h2>
|
||||||
|
<p id="exapp-dialog-severity" hidden></p>
|
||||||
|
<p id="exapp-dialog-message"></p>
|
||||||
|
<input id="exapp-dialog-value" hidden autocomplete="off">
|
||||||
|
<div id="exapp-dialog-actions">
|
||||||
|
<button type="button" id="exapp-dialog-cancel">Cancel</button>
|
||||||
|
<button type="button" id="exapp-dialog-agree">OK</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>`
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
(function () {
|
||||||
|
var dialog = document.getElementById("exapp-dialog");
|
||||||
|
if (!dialog || dialog.getAttribute("data-ready") === "1") return;
|
||||||
|
dialog.setAttribute("data-ready", "1");
|
||||||
|
|
||||||
|
var panel = document.getElementById("exapp-dialog-panel");
|
||||||
|
var heading = document.getElementById("exapp-dialog-heading");
|
||||||
|
var severity = document.getElementById("exapp-dialog-severity");
|
||||||
|
var message = document.getElementById("exapp-dialog-message");
|
||||||
|
var field = document.getElementById("exapp-dialog-value");
|
||||||
|
var cancelBtn = document.getElementById("exapp-dialog-cancel");
|
||||||
|
var agreeBtn = document.getElementById("exapp-dialog-agree");
|
||||||
|
var queue = [];
|
||||||
|
var busy = false;
|
||||||
|
var finish = null;
|
||||||
|
|
||||||
|
function textOf(value) {
|
||||||
|
return value == null ? "" : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimmed() {
|
||||||
|
return field.value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshAgree() {
|
||||||
|
agreeBtn.disabled = !field.hidden && trimmed() === "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function settle(result) {
|
||||||
|
var done = finish;
|
||||||
|
finish = null;
|
||||||
|
if (dialog.open) dialog.close();
|
||||||
|
busy = false;
|
||||||
|
if (done) done(result);
|
||||||
|
pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
function decline() {
|
||||||
|
if (!finish) return;
|
||||||
|
var kind = dialog.getAttribute("data-kind");
|
||||||
|
if (kind === "confirm") settle(false);
|
||||||
|
else if (kind === "prompt") settle(null);
|
||||||
|
else settle(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function agree() {
|
||||||
|
if (!finish || agreeBtn.disabled) return;
|
||||||
|
var kind = dialog.getAttribute("data-kind");
|
||||||
|
if (kind === "confirm") settle(true);
|
||||||
|
else if (kind === "prompt") settle(trimmed());
|
||||||
|
else settle(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelBtn.addEventListener("click", function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
decline();
|
||||||
|
});
|
||||||
|
agreeBtn.addEventListener("click", function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
agree();
|
||||||
|
});
|
||||||
|
field.addEventListener("input", refreshAgree);
|
||||||
|
dialog.addEventListener("cancel", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
decline();
|
||||||
|
});
|
||||||
|
dialog.addEventListener("click", function (e) {
|
||||||
|
if (e.target === dialog) decline();
|
||||||
|
});
|
||||||
|
dialog.addEventListener("keydown", function (e) {
|
||||||
|
if (e.key !== "Enter") return;
|
||||||
|
e.preventDefault();
|
||||||
|
agree();
|
||||||
|
});
|
||||||
|
panel.addEventListener("click", function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
|
||||||
|
function show(kind, opts, done) {
|
||||||
|
opts = opts || {};
|
||||||
|
finish = done;
|
||||||
|
dialog.setAttribute("data-kind", kind);
|
||||||
|
var head = textOf(opts.heading);
|
||||||
|
heading.textContent = head;
|
||||||
|
heading.hidden = head === "";
|
||||||
|
message.textContent = textOf(opts.text);
|
||||||
|
var sev = "";
|
||||||
|
if (kind === "message") {
|
||||||
|
sev = opts.severity === "warning" || opts.severity === "error" ? opts.severity : "info";
|
||||||
|
dialog.setAttribute("data-severity", sev);
|
||||||
|
} else {
|
||||||
|
dialog.removeAttribute("data-severity");
|
||||||
|
}
|
||||||
|
severity.textContent = sev;
|
||||||
|
severity.hidden = sev === "";
|
||||||
|
var isPrompt = kind === "prompt";
|
||||||
|
field.hidden = !isPrompt;
|
||||||
|
field.value = isPrompt ? textOf(opts.value) : "";
|
||||||
|
cancelBtn.hidden = kind === "message";
|
||||||
|
agreeBtn.textContent = kind !== "message" && opts.agree ? textOf(opts.agree) : "OK";
|
||||||
|
agreeBtn.classList.toggle("destructive", kind === "confirm" && !!opts.destructive);
|
||||||
|
refreshAgree();
|
||||||
|
if (!dialog.open) dialog.showModal();
|
||||||
|
if (isPrompt) field.focus();
|
||||||
|
else if (!agreeBtn.disabled) agreeBtn.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pump() {
|
||||||
|
if (busy || queue.length === 0) return;
|
||||||
|
busy = true;
|
||||||
|
queue.shift()();
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueue(kind, opts) {
|
||||||
|
return new Promise(function (resolve) {
|
||||||
|
queue.push(function () {
|
||||||
|
show(kind, opts, resolve);
|
||||||
|
});
|
||||||
|
pump();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.exappDialog = {
|
||||||
|
message: function (opts) { return enqueue("message", opts); },
|
||||||
|
confirm: function (opts) { return enqueue("confirm", opts); },
|
||||||
|
prompt: function (opts) { return enqueue("prompt", opts); }
|
||||||
|
};
|
||||||
|
})();
|
||||||
+519
@@ -0,0 +1,519 @@
|
|||||||
|
package gonexapp_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/chromedp/chromedp"
|
||||||
|
"github.com/chromedp/chromedp/kb"
|
||||||
|
|
||||||
|
"gitea.neitzel.de/konrad/go-nc-exapp"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
srv := httptest.NewServer(dialogFixture())
|
||||||
|
defer srv.Close()
|
||||||
|
fixtureURL = srv.URL
|
||||||
|
|
||||||
|
path := browserExecutable()
|
||||||
|
if path == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "dialog tests need Chrome, Edge, or Chromium (set EXAPP_BROWSER)")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
||||||
|
chromedp.ExecPath(path),
|
||||||
|
chromedp.WindowSize(1200, 800),
|
||||||
|
)
|
||||||
|
var cancel context.CancelFunc
|
||||||
|
allocCtx, cancel = chromedp.NewExecAllocator(context.Background(), opts...)
|
||||||
|
code := m.Run()
|
||||||
|
cancel()
|
||||||
|
os.Exit(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
fixtureURL string
|
||||||
|
allocCtx context.Context
|
||||||
|
)
|
||||||
|
|
||||||
|
func dialogFixture() http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("GET /bare", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
fmt.Fprint(w, "<!DOCTYPE html><html><head><meta charset=utf-8><title>Dialog</title></head><body>",
|
||||||
|
gonexapp.DialogHTML(), fixtureControls(), "</body></html>")
|
||||||
|
})
|
||||||
|
nav := gonexapp.AppNavigation{
|
||||||
|
DefaultID: "home",
|
||||||
|
Items: func(*http.Request) []gonexapp.Item {
|
||||||
|
return []gonexapp.Item{{ID: "home", Label: "Home"}}
|
||||||
|
},
|
||||||
|
Page: func(*http.Request, string) (string, bool) {
|
||||||
|
return fixtureControls(), true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mux.Handle("GET /shell", nav.Handler())
|
||||||
|
mux.Handle("GET /nav", sampleNavigation().Handler())
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
func fixtureControls() string {
|
||||||
|
return `<button type="button" id="open-info">Open info</button>
|
||||||
|
<button type="button" id="open-warning">Open warning</button>
|
||||||
|
<button type="button" id="open-error">Open error</button>
|
||||||
|
<button type="button" id="open-plain">Open plain</button>
|
||||||
|
<button type="button" id="open-confirm">Open confirm</button>
|
||||||
|
<button type="button" id="open-delete">Open delete</button>
|
||||||
|
<button type="button" id="open-html-button">Open html button</button>
|
||||||
|
<button type="button" id="open-prompt">Open prompt</button>
|
||||||
|
<button type="button" id="open-prefill">Open prefill</button>
|
||||||
|
<button type="button" id="open-blank">Open blank</button>
|
||||||
|
<button type="button" id="open-inner">Open inner</button>
|
||||||
|
<button type="button" id="open-three">Open three</button>
|
||||||
|
<pre id="log"></pre>
|
||||||
|
<script>
|
||||||
|
function log(line) { document.getElementById("log").textContent += line + "\n"; }
|
||||||
|
document.getElementById("open-info").onclick = function () {
|
||||||
|
exappDialog.message({ severity: "info", heading: "Notice", text: "Hello <b>x</b>", agree: "Nope" }).then(function () { log("dismissed"); });
|
||||||
|
};
|
||||||
|
document.getElementById("open-warning").onclick = function () {
|
||||||
|
exappDialog.message({ severity: "warning", text: "Careful" }).then(function () { log("dismissed"); });
|
||||||
|
};
|
||||||
|
document.getElementById("open-error").onclick = function () {
|
||||||
|
exappDialog.message({ severity: "error", heading: "<b>Nope</b>", text: "Failed" }).then(function () { log("dismissed"); });
|
||||||
|
};
|
||||||
|
document.getElementById("open-plain").onclick = function () {
|
||||||
|
exappDialog.message({ severity: "info", text: "Only the message" }).then(function () { log("dismissed"); });
|
||||||
|
};
|
||||||
|
document.getElementById("open-confirm").onclick = function () {
|
||||||
|
exappDialog.confirm({ text: "Proceed?" }).then(function (yes) { log(yes ? "yes" : "no"); });
|
||||||
|
};
|
||||||
|
document.getElementById("open-delete").onclick = function () {
|
||||||
|
exappDialog.confirm({ heading: "Remove domain", text: "Gone", agree: "Delete", destructive: true }).then(function (yes) { log(yes ? "yes" : "no"); });
|
||||||
|
};
|
||||||
|
document.getElementById("open-html-button").onclick = function () {
|
||||||
|
exappDialog.confirm({ text: "Go?", agree: "<i>Go</i>" }).then(function (yes) { log(yes ? "yes" : "no"); });
|
||||||
|
};
|
||||||
|
document.getElementById("open-prompt").onclick = function () {
|
||||||
|
exappDialog.prompt({ heading: "New folder", text: "Folder name" }).then(function (value) {
|
||||||
|
log(value === null ? "cancelled" : "value:" + JSON.stringify(value));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
document.getElementById("open-prefill").onclick = function () {
|
||||||
|
exappDialog.prompt({ text: "Name", value: " hi " }).then(function (value) {
|
||||||
|
log(value === null ? "cancelled" : "value:" + JSON.stringify(value));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
document.getElementById("open-blank").onclick = function () {
|
||||||
|
exappDialog.prompt({ text: "Name", value: " " }).then(function (value) {
|
||||||
|
log(value === null ? "cancelled" : "value:" + JSON.stringify(value));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
document.getElementById("open-inner").onclick = function () {
|
||||||
|
exappDialog.prompt({ text: "Name", value: "a b" }).then(function (value) {
|
||||||
|
log(value === null ? "cancelled" : "value:" + JSON.stringify(value));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
document.getElementById("open-three").onclick = function () {
|
||||||
|
exappDialog.message({ severity: "info", text: "First" }).then(function () { log("1"); });
|
||||||
|
exappDialog.message({ severity: "warning", text: "Second" }).then(function () { log("2"); });
|
||||||
|
exappDialog.message({ severity: "error", text: "Third" }).then(function () { log("3"); });
|
||||||
|
};
|
||||||
|
</script>`
|
||||||
|
}
|
||||||
|
|
||||||
|
func browserExecutable() string {
|
||||||
|
if p := os.Getenv("EXAPP_BROWSER"); p != "" {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
candidates := []string{
|
||||||
|
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||||
|
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
||||||
|
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||||
|
}
|
||||||
|
for _, c := range candidates {
|
||||||
|
if st, err := os.Stat(c); err == nil && !st.IsDir() {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func drive(t *testing.T, path string, actions ...chromedp.Action) {
|
||||||
|
t.Helper()
|
||||||
|
ctx, cancel := chromedp.NewContext(allocCtx)
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
ctx, cancel = context.WithTimeout(ctx, 25*time.Second)
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
steps := append([]chromedp.Action{chromedp.Navigate(fixtureURL + path)}, actions...)
|
||||||
|
if err := chromedp.Run(ctx, steps...); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clickButton(label string) chromedp.Action {
|
||||||
|
expr := fmt.Sprintf(`(() => {
|
||||||
|
const btn = [...document.querySelectorAll("#exapp-dialog button")].find((b) => b.textContent.trim() === %q && !b.hidden);
|
||||||
|
if (!btn) return "missing";
|
||||||
|
btn.click();
|
||||||
|
return "clicked";
|
||||||
|
})()`, label)
|
||||||
|
return chromedp.ActionFunc(func(ctx context.Context) error {
|
||||||
|
var result string
|
||||||
|
if err := chromedp.Evaluate(expr, &result).Do(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if result != "clicked" {
|
||||||
|
return fmt.Errorf("button %q: %s", label, result)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitJS(expr, want string, contains bool) chromedp.Action {
|
||||||
|
return chromedp.ActionFunc(func(ctx context.Context) error {
|
||||||
|
deadline, ok := ctx.Deadline()
|
||||||
|
if !ok {
|
||||||
|
deadline = time.Now().Add(5 * time.Second)
|
||||||
|
}
|
||||||
|
var last string
|
||||||
|
for {
|
||||||
|
var got string
|
||||||
|
err := chromedp.Evaluate(expr, &got).Do(ctx)
|
||||||
|
if err == nil {
|
||||||
|
last = got
|
||||||
|
if contains && strings.Contains(got, want) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !contains && strings.TrimSpace(got) == want {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return fmt.Errorf("%s = %q, want %q", expr, last, want)
|
||||||
|
}
|
||||||
|
if err := chromedp.Sleep(40 * time.Millisecond).Do(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitDialogText(id, want string) chromedp.Action {
|
||||||
|
return waitJS(fmt.Sprintf(`document.getElementById("%s").textContent`, id), want, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitLog(want string) chromedp.Action {
|
||||||
|
return waitJS(`document.getElementById("log").textContent`, want, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func openShows(t *testing.T, path, button, severity, heading, message string) {
|
||||||
|
t.Helper()
|
||||||
|
drive(t, path,
|
||||||
|
chromedp.WaitVisible("#"+button, chromedp.ByQuery),
|
||||||
|
chromedp.Click("#"+button, chromedp.ByQuery),
|
||||||
|
waitDialogText("exapp-dialog-severity", severity),
|
||||||
|
waitDialogText("exapp-dialog-message", message),
|
||||||
|
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||||
|
var head string
|
||||||
|
var hidden bool
|
||||||
|
if err := chromedp.Evaluate(`document.getElementById("exapp-dialog-heading").textContent`, &head).Do(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := chromedp.Evaluate(`document.getElementById("exapp-dialog-heading").hidden`, &hidden).Do(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if heading == "" {
|
||||||
|
if !hidden {
|
||||||
|
return fmt.Errorf("heading visible %q", head)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if hidden || strings.TrimSpace(head) != heading {
|
||||||
|
return fmt.Errorf("heading %q hidden %v, want %q", head, hidden, heading)
|
||||||
|
}
|
||||||
|
var bold int
|
||||||
|
if err := chromedp.Evaluate(`document.querySelectorAll("#exapp-dialog b, #exapp-dialog i").length`, &bold).Do(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if bold != 0 {
|
||||||
|
return fmt.Errorf("dialog interpreted HTML, %d elements", bold)
|
||||||
|
}
|
||||||
|
var label string
|
||||||
|
if err := chromedp.Evaluate(`document.getElementById("exapp-dialog-agree").textContent`, &label).Do(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(label) != "OK" {
|
||||||
|
return fmt.Errorf("message button %q, want OK", label)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageOnBarePage(t *testing.T) {
|
||||||
|
openShows(t, "/bare", "open-info", "info", "Notice", "Hello <b>x</b>")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageOnAppNavigation(t *testing.T) {
|
||||||
|
openShows(t, "/shell", "open-info", "info", "Notice", "Hello <b>x</b>")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageSeveritiesAndDismiss(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
button string
|
||||||
|
severity string
|
||||||
|
how string
|
||||||
|
}{
|
||||||
|
{"open-warning", "warning", "ok"},
|
||||||
|
{"open-error", "error", "enter"},
|
||||||
|
{"open-info", "info", "escape"},
|
||||||
|
{"open-plain", "info", "backdrop"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.how, func(t *testing.T) {
|
||||||
|
var dismiss chromedp.Action
|
||||||
|
switch tc.how {
|
||||||
|
case "ok":
|
||||||
|
dismiss = clickButton("OK")
|
||||||
|
case "enter":
|
||||||
|
dismiss = chromedp.KeyEvent(kb.Enter)
|
||||||
|
case "escape":
|
||||||
|
dismiss = chromedp.KeyEvent(kb.Escape)
|
||||||
|
case "backdrop":
|
||||||
|
dismiss = chromedp.MouseClickXY(8, 8)
|
||||||
|
}
|
||||||
|
drive(t, "/bare",
|
||||||
|
chromedp.Click("#"+tc.button, chromedp.ByQuery),
|
||||||
|
waitDialogText("exapp-dialog-severity", tc.severity),
|
||||||
|
dismiss,
|
||||||
|
waitLog("dismissed"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageWithoutHeading(t *testing.T) {
|
||||||
|
openShows(t, "/bare", "open-plain", "info", "", "Only the message")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfirmYesNoAndDestructive(t *testing.T) {
|
||||||
|
t.Run("cancel", func(t *testing.T) {
|
||||||
|
drive(t, "/bare",
|
||||||
|
chromedp.Click("#open-confirm", chromedp.ByQuery),
|
||||||
|
waitDialogText("exapp-dialog-message", "Proceed?"),
|
||||||
|
clickButton("Cancel"),
|
||||||
|
waitLog("no"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
t.Run("escape", func(t *testing.T) {
|
||||||
|
drive(t, "/bare",
|
||||||
|
chromedp.Click("#open-confirm", chromedp.ByQuery),
|
||||||
|
waitDialogText("exapp-dialog-message", "Proceed?"),
|
||||||
|
chromedp.KeyEvent(kb.Escape),
|
||||||
|
waitLog("no"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
t.Run("backdrop", func(t *testing.T) {
|
||||||
|
drive(t, "/bare",
|
||||||
|
chromedp.Click("#open-confirm", chromedp.ByQuery),
|
||||||
|
waitDialogText("exapp-dialog-message", "Proceed?"),
|
||||||
|
chromedp.MouseClickXY(8, 8),
|
||||||
|
waitLog("no"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
t.Run("enter", func(t *testing.T) {
|
||||||
|
drive(t, "/bare",
|
||||||
|
chromedp.Click("#open-confirm", chromedp.ByQuery),
|
||||||
|
waitDialogText("exapp-dialog-message", "Proceed?"),
|
||||||
|
chromedp.KeyEvent(kb.Enter),
|
||||||
|
waitLog("yes"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
t.Run("delete", func(t *testing.T) {
|
||||||
|
drive(t, "/bare",
|
||||||
|
chromedp.Click("#open-delete", chromedp.ByQuery),
|
||||||
|
waitDialogText("exapp-dialog-heading", "Remove domain"),
|
||||||
|
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||||
|
var plain, destructive string
|
||||||
|
if err := chromedp.Evaluate(`getComputedStyle(document.getElementById("exapp-dialog-cancel")).backgroundColor`, &plain).Do(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := chromedp.Evaluate(`getComputedStyle(document.getElementById("exapp-dialog-agree")).backgroundColor`, &destructive).Do(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if plain == destructive {
|
||||||
|
return fmt.Errorf("destructive button color %q matches Cancel", destructive)
|
||||||
|
}
|
||||||
|
var text string
|
||||||
|
if err := chromedp.Evaluate(`getComputedStyle(document.getElementById("exapp-dialog-agree")).color`, &text).Do(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if text == "rgb(255, 255, 255)" || text == destructive {
|
||||||
|
return fmt.Errorf("destructive label color %q on background %q", text, destructive)
|
||||||
|
}
|
||||||
|
var label string
|
||||||
|
if err := chromedp.Evaluate(`document.getElementById("exapp-dialog-agree").textContent`, &label).Do(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(label) != "Delete" {
|
||||||
|
return fmt.Errorf("agree label %q", label)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
clickButton("Delete"),
|
||||||
|
waitLog("yes"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
t.Run("button word is plain text", func(t *testing.T) {
|
||||||
|
drive(t, "/bare",
|
||||||
|
chromedp.Click("#open-html-button", chromedp.ByQuery),
|
||||||
|
waitDialogText("exapp-dialog-message", "Go?"),
|
||||||
|
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||||
|
var n int
|
||||||
|
var label string
|
||||||
|
if err := chromedp.Evaluate(`document.querySelectorAll("#exapp-dialog i").length`, &n).Do(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n != 0 {
|
||||||
|
return fmt.Errorf("button word was interpreted as HTML")
|
||||||
|
}
|
||||||
|
if err := chromedp.Evaluate(`document.getElementById("exapp-dialog-agree").textContent`, &label).Do(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(label) != "<i>Go</i>" {
|
||||||
|
return fmt.Errorf("agree label %q", label)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromptValueAndCancel(t *testing.T) {
|
||||||
|
t.Run("empty stays inactive", func(t *testing.T) {
|
||||||
|
drive(t, "/bare",
|
||||||
|
chromedp.Click("#open-prompt", chromedp.ByQuery),
|
||||||
|
waitDialogText("exapp-dialog-message", "Folder name"),
|
||||||
|
waitDialogText("exapp-dialog-heading", "New folder"),
|
||||||
|
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||||
|
var disabled bool
|
||||||
|
if err := chromedp.Evaluate(`document.getElementById("exapp-dialog-agree").disabled`, &disabled).Do(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !disabled {
|
||||||
|
return fmt.Errorf("agree button active on an empty Prompt")
|
||||||
|
}
|
||||||
|
return chromedp.KeyEvent(kb.Enter).Do(ctx)
|
||||||
|
}),
|
||||||
|
chromedp.Sleep(200*time.Millisecond),
|
||||||
|
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||||
|
var open bool
|
||||||
|
if err := chromedp.Evaluate(`document.getElementById("exapp-dialog").open`, &open).Do(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !open {
|
||||||
|
return fmt.Errorf("Enter closed an empty Prompt")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
clickButton("Cancel"),
|
||||||
|
waitLog("cancelled"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
t.Run("spaces only stay inactive", func(t *testing.T) {
|
||||||
|
drive(t, "/bare",
|
||||||
|
chromedp.Click("#open-blank", chromedp.ByQuery),
|
||||||
|
waitDialogText("exapp-dialog-message", "Name"),
|
||||||
|
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||||
|
var disabled bool
|
||||||
|
var raw string
|
||||||
|
if err := chromedp.Evaluate(`document.getElementById("exapp-dialog-agree").disabled`, &disabled).Do(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := chromedp.Evaluate(`document.getElementById("exapp-dialog-value").value`, &raw).Do(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if raw != " " {
|
||||||
|
return fmt.Errorf("starting value %q", raw)
|
||||||
|
}
|
||||||
|
if !disabled {
|
||||||
|
return fmt.Errorf("agree button active for spaces")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
t.Run("prefill trims on agree", func(t *testing.T) {
|
||||||
|
drive(t, "/bare",
|
||||||
|
chromedp.Click("#open-prefill", chromedp.ByQuery),
|
||||||
|
waitDialogText("exapp-dialog-message", "Name"),
|
||||||
|
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||||
|
var raw string
|
||||||
|
if err := chromedp.Evaluate(`document.getElementById("exapp-dialog-value").value`, &raw).Do(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if raw != " hi " {
|
||||||
|
return fmt.Errorf("starting value %q", raw)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
clickButton("OK"),
|
||||||
|
waitLog(`value:"hi"`),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
t.Run("inner spaces stay", func(t *testing.T) {
|
||||||
|
drive(t, "/bare",
|
||||||
|
chromedp.Click("#open-inner", chromedp.ByQuery),
|
||||||
|
waitDialogText("exapp-dialog-message", "Name"),
|
||||||
|
clickButton("OK"),
|
||||||
|
waitLog(`value:"a b"`),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
t.Run("escape cancels", func(t *testing.T) {
|
||||||
|
drive(t, "/bare",
|
||||||
|
chromedp.Click("#open-prompt", chromedp.ByQuery),
|
||||||
|
waitDialogText("exapp-dialog-message", "Folder name"),
|
||||||
|
chromedp.KeyEvent(kb.Escape),
|
||||||
|
waitLog("cancelled"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
t.Run("typed value", func(t *testing.T) {
|
||||||
|
drive(t, "/bare",
|
||||||
|
chromedp.Click("#open-prompt", chromedp.ByQuery),
|
||||||
|
waitDialogText("exapp-dialog-message", "Folder name"),
|
||||||
|
chromedp.SendKeys("#exapp-dialog-value", " mail ", chromedp.ByQuery),
|
||||||
|
chromedp.KeyEvent(kb.Enter),
|
||||||
|
waitLog(`value:"mail"`),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOneDialogAtATime(t *testing.T) {
|
||||||
|
drive(t, "/bare",
|
||||||
|
chromedp.Click("#open-three", chromedp.ByQuery),
|
||||||
|
waitDialogText("exapp-dialog-message", "First"),
|
||||||
|
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||||
|
var n int
|
||||||
|
if err := chromedp.Evaluate(`document.querySelectorAll("dialog[open]").length`, &n).Do(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
return fmt.Errorf("open dialogs %d, want 1", n)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
clickButton("OK"),
|
||||||
|
waitDialogText("exapp-dialog-message", "Second"),
|
||||||
|
clickButton("OK"),
|
||||||
|
waitDialogText("exapp-dialog-message", "Third"),
|
||||||
|
clickButton("OK"),
|
||||||
|
waitLog("1\n2\n3"),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
// Package gonexapp provides AppAPI credentials, OCS JSON calls, and ExApp user
|
// Package gonexapp provides AppAPI credentials, OCS JSON calls, ExApp user
|
||||||
// preferences for Nextcloud ExApp Services.
|
// preferences, Notifications, Users and Groups reads, an optional Required
|
||||||
|
// Groups Access Gate, an optional App navigation shell, and a Dialog for
|
||||||
|
// Nextcloud ExApp Services.
|
||||||
package gonexapp
|
package gonexapp
|
||||||
|
|||||||
+448
@@ -0,0 +1,448 @@
|
|||||||
|
# API
|
||||||
|
|
||||||
|
Generated from the package comment and every exported declaration. Do not edit.
|
||||||
|
Regenerate with `UPDATE_API_DOCS=1 go test -run TestAPIDoc -count=1`.
|
||||||
|
|
||||||
|
## Package gonexapp
|
||||||
|
|
||||||
|
Package gonexapp provides AppAPI credentials, OCS JSON calls, ExApp user preferences, Notifications, Users and Groups reads, an optional Required Groups Access Gate, an optional App navigation shell, and a Dialog for Nextcloud ExApp Services.
|
||||||
|
|
||||||
|
## Constants
|
||||||
|
|
||||||
|
### DefaultCacheSeconds
|
||||||
|
|
||||||
|
```go
|
||||||
|
const DefaultCacheSeconds = 60
|
||||||
|
```
|
||||||
|
|
||||||
|
DefaultCacheSeconds is used when REQUIRED\_GROUPS\_CACHE\_SECONDS is unset or invalid.
|
||||||
|
|
||||||
|
### DefaultTopMenuAdminRequired
|
||||||
|
|
||||||
|
```go
|
||||||
|
const DefaultTopMenuAdminRequired = true
|
||||||
|
```
|
||||||
|
|
||||||
|
DefaultTopMenuAdminRequired is used when TOP\_MENU\_ADMIN\_REQUIRED is unset or invalid.
|
||||||
|
|
||||||
|
### EnvRequiredGroups
|
||||||
|
|
||||||
|
```go
|
||||||
|
const EnvRequiredGroups = "REQUIRED_GROUPS"
|
||||||
|
```
|
||||||
|
|
||||||
|
EnvRequiredGroups is the conventional deploy env name for Required Groups.
|
||||||
|
|
||||||
|
### EnvRequiredGroupsCacheSeconds
|
||||||
|
|
||||||
|
```go
|
||||||
|
const EnvRequiredGroupsCacheSeconds = "REQUIRED_GROUPS_CACHE_SECONDS"
|
||||||
|
```
|
||||||
|
|
||||||
|
EnvRequiredGroupsCacheSeconds is the conventional deploy env name for Access Gate cache TTL.
|
||||||
|
|
||||||
|
### EnvTopMenuAdminRequired
|
||||||
|
|
||||||
|
```go
|
||||||
|
const EnvTopMenuAdminRequired = "TOP_MENU_ADMIN_REQUIRED"
|
||||||
|
```
|
||||||
|
|
||||||
|
EnvTopMenuAdminRequired is the conventional deploy env name for Top Menu visibility. Declare it in the ExApp info.xml environment-variables section.
|
||||||
|
|
||||||
|
## Variables
|
||||||
|
|
||||||
|
### NextcloudIcons
|
||||||
|
|
||||||
|
```go
|
||||||
|
var NextcloudIcons = nextcloudIcons{
|
||||||
|
Folder: "/core/img/filetypes/folder.svg",
|
||||||
|
FolderShared: "/core/img/filetypes/folder-shared.svg",
|
||||||
|
FolderPublic: "/core/img/filetypes/folder-public.svg",
|
||||||
|
FolderStarred: "/core/img/filetypes/folder-starred.svg",
|
||||||
|
FolderEncrypted: "/core/img/filetypes/folder-encrypted.svg",
|
||||||
|
File: "/core/img/filetypes/file.svg",
|
||||||
|
Text: "/core/img/filetypes/text.svg",
|
||||||
|
Image: "/core/img/filetypes/image.svg",
|
||||||
|
Audio: "/core/img/filetypes/audio.svg",
|
||||||
|
Video: "/core/img/filetypes/video.svg",
|
||||||
|
PDF: "/core/img/filetypes/application-pdf.svg",
|
||||||
|
Document: "/core/img/filetypes/x-office-document.svg",
|
||||||
|
Spreadsheet: "/core/img/filetypes/x-office-spreadsheet.svg",
|
||||||
|
Presentation: "/core/img/filetypes/x-office-presentation.svg",
|
||||||
|
Files: "/core/img/places/files.svg",
|
||||||
|
Home: "/core/img/places/home.svg",
|
||||||
|
Calendar: "/core/img/places/calendar.svg",
|
||||||
|
Contacts: "/core/img/places/contacts.svg",
|
||||||
|
Add: "/core/img/actions/add.svg",
|
||||||
|
Delete: "/core/img/actions/delete.svg",
|
||||||
|
Edit: "/core/img/actions/edit.svg",
|
||||||
|
Rename: "/core/img/actions/rename.svg",
|
||||||
|
Download: "/core/img/actions/download.svg",
|
||||||
|
Upload: "/core/img/actions/upload.svg",
|
||||||
|
Share: "/core/img/actions/share.svg",
|
||||||
|
Search: "/core/img/actions/search.svg",
|
||||||
|
Settings: "/core/img/actions/settings.svg",
|
||||||
|
Info: "/core/img/actions/info.svg",
|
||||||
|
History: "/core/img/actions/history.svg",
|
||||||
|
Password: "/core/img/actions/password.svg",
|
||||||
|
Confirm: "/core/img/actions/confirm.svg",
|
||||||
|
Close: "/core/img/actions/close.svg",
|
||||||
|
Star: "/core/img/actions/star.svg",
|
||||||
|
User: "/core/img/actions/user.svg",
|
||||||
|
Group: "/core/img/actions/group.svg",
|
||||||
|
Mail: "/core/img/actions/mail.svg",
|
||||||
|
Menu: "/core/img/actions/menu.svg",
|
||||||
|
External: "/core/img/actions/external.svg",
|
||||||
|
Filter: "/core/img/actions/filter.svg",
|
||||||
|
Recent: "/core/img/actions/recent.svg",
|
||||||
|
Tag: "/core/img/actions/tag.svg",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
NextcloudIcons are SVG paths served by Nextcloud core under /core/img/. The library does not ship the files. Set Item.Icon to one of these. Dark mode inverts them with Nextcloud’s --background-invert-if-dark filter.
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### DialogHTML
|
||||||
|
|
||||||
|
```go
|
||||||
|
func DialogHTML() template.HTML
|
||||||
|
```
|
||||||
|
|
||||||
|
DialogHTML is the Dialog markup and script for an ExApp page. App navigation includes it. A page the ExApp renders itself inserts the same fragment. The page then calls exappDialog.message, exappDialog.confirm, or exappDialog.prompt and waits for the user's choice.
|
||||||
|
|
||||||
|
### ParseCacheSeconds
|
||||||
|
|
||||||
|
```go
|
||||||
|
func ParseCacheSeconds(s string, defaultSec int) time.Duration
|
||||||
|
```
|
||||||
|
|
||||||
|
ParseCacheSeconds parses REQUIRED\_GROUPS\_CACHE\_SECONDS. Unset or invalid → defaultSec seconds; "0" → no cache.
|
||||||
|
|
||||||
|
### ParseRequiredGroups
|
||||||
|
|
||||||
|
```go
|
||||||
|
func ParseRequiredGroups(s string) []string
|
||||||
|
```
|
||||||
|
|
||||||
|
ParseRequiredGroups splits a comma-separated Required Groups env value.
|
||||||
|
|
||||||
|
### ResolveRequiredGroups
|
||||||
|
|
||||||
|
```go
|
||||||
|
func ResolveRequiredGroups(envValue string, envSet bool, codeDefault []string) []string
|
||||||
|
```
|
||||||
|
|
||||||
|
ResolveRequiredGroups applies env override rules: unset uses codeDefault; set (including empty) replaces the default.
|
||||||
|
|
||||||
|
### TopMenuAdminRequired
|
||||||
|
|
||||||
|
```go
|
||||||
|
func TopMenuAdminRequired(envValue string, defaultAdminRequired bool) string
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### UserFromRequest
|
||||||
|
|
||||||
|
```go
|
||||||
|
func UserFromRequest(r *http.Request) (string, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
UserFromRequest reads the requesting user from AUTHORIZATION-APP-API on an inbound ExApp request. It returns an error when the header is missing, is not base64, or contains no user id before the colon.
|
||||||
|
|
||||||
|
## Types
|
||||||
|
|
||||||
|
### AccessGate
|
||||||
|
|
||||||
|
```go
|
||||||
|
type AccessGate struct {
|
||||||
|
Cred Credentials
|
||||||
|
Groups []string
|
||||||
|
CacheTTL time.Duration // 0 disables cache
|
||||||
|
ExtraSkipPaths []string
|
||||||
|
Client *http.Client
|
||||||
|
OCS OCSClient
|
||||||
|
Now func() time.Time
|
||||||
|
// contains filtered or unexported fields
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
AccessGate enforces Required Groups for the Requesting user on ExApp HTTP traffic.
|
||||||
|
|
||||||
|
#### AccessGate.Check
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (g *AccessGate) Check(r *http.Request) CheckResult
|
||||||
|
```
|
||||||
|
|
||||||
|
Check reports whether r may proceed under Required Groups.
|
||||||
|
|
||||||
|
#### AccessGate.Wrap
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (g AccessGate) Wrap(next http.Handler) http.Handler
|
||||||
|
```
|
||||||
|
|
||||||
|
Wrap returns a handler that runs Check before next. CheckAllowed calls next. CheckUnauthorized writes 401. CheckUnavailable writes 503. CheckDenied writes English HTML with status 200 and frame-ancestors 'self' when the request accepts text/html, and 403 otherwise. An empty Groups list allows every request. Paths /heartbeat, /enabled, /init, and /js/ are skipped, plus ExtraSkipPaths.
|
||||||
|
|
||||||
|
### AppAPINotifications
|
||||||
|
|
||||||
|
```go
|
||||||
|
type AppAPINotifications struct {
|
||||||
|
Cred Credentials
|
||||||
|
Client *http.Client
|
||||||
|
OCS OCSClient
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
AppAPINotifications creates Notifications via AppAPI OCS.
|
||||||
|
|
||||||
|
#### NewAppAPINotifications
|
||||||
|
|
||||||
|
```go
|
||||||
|
func NewAppAPINotifications(cred Credentials) AppAPINotifications
|
||||||
|
```
|
||||||
|
|
||||||
|
NewAppAPINotifications returns a sender using cred for AppAPI auth.
|
||||||
|
|
||||||
|
#### AppAPINotifications.Send
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (n AppAPINotifications) Send(notif Notification) error
|
||||||
|
```
|
||||||
|
|
||||||
|
Send creates a Notification for Cred.UserID. It returns an error when UserID or Subject is empty, or when the OCS call fails. AppAPI notification OCS accepts Subject, Message, Link, and rich-object parameters. It does not accept actions or a custom icon.
|
||||||
|
|
||||||
|
#### AppAPINotifications.SendTo
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (n AppAPINotifications) SendTo(userID string, notif Notification) error
|
||||||
|
```
|
||||||
|
|
||||||
|
SendTo creates a Notification for userID. The OCS call is authenticated as that user via WithUser. It returns an error when userID or Subject is empty, or when the OCS call fails.
|
||||||
|
|
||||||
|
### AppAPIPreferences
|
||||||
|
|
||||||
|
```go
|
||||||
|
type AppAPIPreferences struct {
|
||||||
|
Cred Credentials
|
||||||
|
AppID string
|
||||||
|
Key string
|
||||||
|
Client *http.Client
|
||||||
|
OCS OCSClient
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
AppAPIPreferences reads and writes one string ExApp preference for the requesting user.
|
||||||
|
|
||||||
|
#### NewAppAPIPreferences
|
||||||
|
|
||||||
|
```go
|
||||||
|
func NewAppAPIPreferences(cred Credentials, appID, key string) AppAPIPreferences
|
||||||
|
```
|
||||||
|
|
||||||
|
NewAppAPIPreferences returns a preference store for appID and key using cred for auth.
|
||||||
|
|
||||||
|
#### AppAPIPreferences.Get
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (p AppAPIPreferences) Get() (string, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
Get loads the configured preference key for the requesting user. A missing key returns an empty string and a nil error. It returns an error when the OCS call fails or the body cannot be decoded.
|
||||||
|
|
||||||
|
#### AppAPIPreferences.Set
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (p AppAPIPreferences) Set(value string) error
|
||||||
|
```
|
||||||
|
|
||||||
|
Set stores value for the configured preference key. The value is stored as non-sensitive. It returns an error when the OCS call fails.
|
||||||
|
|
||||||
|
### AppNavigation
|
||||||
|
|
||||||
|
```go
|
||||||
|
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
|
||||||
|
// DisableTheme skips the Nextcloud theme stylesheets. The zero value
|
||||||
|
// loads the active theme.
|
||||||
|
DisableTheme bool
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
#### AppNavigation.Handler
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (n AppNavigation) Handler() http.Handler
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### CheckResult
|
||||||
|
|
||||||
|
```go
|
||||||
|
type CheckResult int
|
||||||
|
```
|
||||||
|
|
||||||
|
CheckResult is the outcome of AccessGate.Check.
|
||||||
|
|
||||||
|
#### CheckAllowed
|
||||||
|
|
||||||
|
#### CheckDenied
|
||||||
|
|
||||||
|
#### CheckUnauthorized
|
||||||
|
|
||||||
|
#### CheckUnavailable
|
||||||
|
|
||||||
|
```go
|
||||||
|
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
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Credentials
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Credentials struct {
|
||||||
|
BaseURL string
|
||||||
|
AppID string
|
||||||
|
AppVersion string
|
||||||
|
AAVersion string
|
||||||
|
AppSecret string
|
||||||
|
UserID string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Credentials are what an ExApp needs to call Nextcloud as one user. BaseURL is the Nextcloud instance URL. A trailing slash is tolerated by callers in this package and is removed when they build a URL. AppID, AppVersion, and AAVersion are sent as EX-APP-ID, EX-APP-VERSION, and AA-VERSION. AppSecret and UserID form the AUTHORIZATION-APP-API token.
|
||||||
|
|
||||||
|
#### Credentials.AuthHeaders
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (c Credentials) AuthHeaders() http.Header
|
||||||
|
```
|
||||||
|
|
||||||
|
AuthHeaders returns AppAPI headers for a request to Nextcloud. The set is AA-VERSION, EX-APP-ID, EX-APP-VERSION, AUTHORIZATION-APP-API, and OCS-APIRequest. AUTHORIZATION-APP-API is base64 of UserID, a colon, and AppSecret. The method does not return an error; empty fields are sent as empty.
|
||||||
|
|
||||||
|
#### Credentials.WithUser
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (c Credentials) WithUser(userID string) Credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
WithUser returns a copy whose UserID is userID. The receiver is not modified.
|
||||||
|
|
||||||
|
### Groups
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Groups struct {
|
||||||
|
Cred Credentials
|
||||||
|
Client *http.Client
|
||||||
|
OCS OCSClient
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Groups reads Users and Groups from Provisioning OCS.
|
||||||
|
|
||||||
|
#### NewGroups
|
||||||
|
|
||||||
|
```go
|
||||||
|
func NewGroups(cred Credentials) Groups
|
||||||
|
```
|
||||||
|
|
||||||
|
NewGroups returns a directory reader using cred for AppAPI auth.
|
||||||
|
|
||||||
|
#### Groups.GroupMembers
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (g Groups) GroupMembers(groupID string) ([]string, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
GroupMembers returns the user ids in groupID. The OCS call uses Cred as-is. Nextcloud requires that user to be an admin or a subadmin of the group. It returns an error when the OCS call fails or the body cannot be decoded. There is no search or paging.
|
||||||
|
|
||||||
|
#### Groups.ListGroups
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (g Groups) ListGroups() ([]string, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
ListGroups returns instance group ids. The OCS call uses Cred as-is and needs an admin or subadmin. It returns an error when the OCS call fails or the body cannot be decoded. There is no search or paging.
|
||||||
|
|
||||||
|
#### Groups.UserGroups
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (g Groups) UserGroups(userID string) ([]string, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
UserGroups returns the Nextcloud group ids of userID. The OCS call is authenticated as userID. It returns an error when the OCS call fails or the body cannot be decoded.
|
||||||
|
|
||||||
|
### Item
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Item struct {
|
||||||
|
ID string
|
||||||
|
Label string
|
||||||
|
Icon string
|
||||||
|
Children []Item
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Item is one App navigation entry. The ExApp chooses the ids and labels. Children may nest. The shell does not interpret the ids. Icon is an optional same-origin image URL. NextcloudIcons names the core SVGs.
|
||||||
|
|
||||||
|
### Notification
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Notification struct {
|
||||||
|
Subject string
|
||||||
|
Message string
|
||||||
|
Link string
|
||||||
|
SubjectParams map[string]any
|
||||||
|
MessageParams map[string]any
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notification is one Nextcloud bell for a single Recipient.
|
||||||
|
|
||||||
|
### OCSClient
|
||||||
|
|
||||||
|
```go
|
||||||
|
type OCSClient struct {
|
||||||
|
Cred Credentials
|
||||||
|
Client *http.Client
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
OCSClient performs AppAPI-authenticated OCS requests with format=json.
|
||||||
|
|
||||||
|
#### OCSClient.Call
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (c OCSClient) Call(method, path string, body []byte) ([]byte, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
#### OCSClient.URL
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (c OCSClient) URL(path string) string
|
||||||
|
```
|
||||||
|
|
||||||
|
URL builds an OCS URL under /ocs/v2.php with format=json.
|
||||||
|
|
||||||
+404
@@ -0,0 +1,404 @@
|
|||||||
|
# go-nc-exapp guide
|
||||||
|
|
||||||
|
How to use the library. Symbol signatures and doc comments are in [API](api.md). Words for the domain are in [CONTEXT.md](../CONTEXT.md).
|
||||||
|
|
||||||
|
## Credentials
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
`Credentials` is the set an ExApp needs to call Nextcloud as one user: the instance URL, the ExApp identity, the AppAPI secret, and the user id.
|
||||||
|
|
||||||
|
### When to use it
|
||||||
|
|
||||||
|
Fill one value at the edge of the service, from the environment HaRP injects, and pass it into preferences, notifications, groups, the Access Gate, and go-nc-files.
|
||||||
|
|
||||||
|
### Call sequence
|
||||||
|
|
||||||
|
1. Set `BaseURL`, `AppID`, `AppVersion`, `AAVersion`, `AppSecret`, and `UserID`.
|
||||||
|
2. Pass the value to `AuthHeaders`, `WithUser`, or a constructor such as `NewAppAPIPreferences`.
|
||||||
|
|
||||||
|
`BaseURL` may have a trailing slash. Callers in this package remove it when they build a URL.
|
||||||
|
|
||||||
|
### Errors
|
||||||
|
|
||||||
|
Constructing a `Credentials` value does not return an error. Empty fields are sent as empty headers. The Nextcloud call then fails.
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```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",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## AuthHeaders
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
`AuthHeaders` builds the AppAPI headers for a request from the ExApp to Nextcloud.
|
||||||
|
|
||||||
|
### When to use it
|
||||||
|
|
||||||
|
Use it for any Nextcloud HTTP call that is not going through `OCSClient`. `OCSClient` calls it for you. go-nc-files uses it on WebDAV.
|
||||||
|
|
||||||
|
### Call sequence
|
||||||
|
|
||||||
|
1. Fill `Credentials`.
|
||||||
|
2. Call `AuthHeaders`.
|
||||||
|
3. Copy the header onto the outbound request.
|
||||||
|
|
||||||
|
The set is `AA-VERSION`, `EX-APP-ID`, `EX-APP-VERSION`, `AUTHORIZATION-APP-API`, and `OCS-APIRequest`. `AUTHORIZATION-APP-API` is base64 of `UserID`, a colon, and `AppSecret`.
|
||||||
|
|
||||||
|
### Errors
|
||||||
|
|
||||||
|
`AuthHeaders` does not return an error.
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```go
|
||||||
|
req.Header = cred.AuthHeaders()
|
||||||
|
```
|
||||||
|
|
||||||
|
## WithUser
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
`WithUser` returns a copy of the credentials whose `UserID` is the given user. The original value is unchanged.
|
||||||
|
|
||||||
|
### When to use it
|
||||||
|
|
||||||
|
Use it when one OCS call must run as a different user than the one on the service's credentials. `SendTo` and `UserGroups` do this themselves. Directory calls that must run as an admin use `WithUser` at the call site. This library does not pick an admin user.
|
||||||
|
|
||||||
|
### Call sequence
|
||||||
|
|
||||||
|
1. Start from the service credentials.
|
||||||
|
2. Call `WithUser` with the target user id.
|
||||||
|
3. Pass the copy to the client that should act as that user.
|
||||||
|
|
||||||
|
### Errors
|
||||||
|
|
||||||
|
`WithUser` does not return an error. An empty user id is stored as empty and fails later, when a call requires a recipient.
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```go
|
||||||
|
asAdmin := cred.WithUser(adminID)
|
||||||
|
groups := gonexapp.NewGroups(asAdmin)
|
||||||
|
```
|
||||||
|
|
||||||
|
## UserFromRequest
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
`UserFromRequest` reads the requesting user from `AUTHORIZATION-APP-API` on a request HaRP proxied into the ExApp.
|
||||||
|
|
||||||
|
### When to use it
|
||||||
|
|
||||||
|
Use it on inbound ExApp routes when the handler needs the user id and does not already have it from the Access Gate. The Access Gate calls it before checking groups.
|
||||||
|
|
||||||
|
### Call sequence
|
||||||
|
|
||||||
|
1. Take the `*http.Request` the ExApp received.
|
||||||
|
2. Call `UserFromRequest`.
|
||||||
|
3. Use the returned user id as `Credentials.UserID` for outbound calls on behalf of that user.
|
||||||
|
|
||||||
|
### Errors
|
||||||
|
|
||||||
|
The function returns an error when the header is missing, is not base64, or has no user id before the colon.
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```go
|
||||||
|
userID, err := gonexapp.UserFromRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cred.UserID = userID
|
||||||
|
```
|
||||||
|
|
||||||
|
## OCSClient
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
`OCSClient` performs an AppAPI-authenticated OCS request and requires a JSON body. Every URL gets `format=json`.
|
||||||
|
|
||||||
|
### When to use it
|
||||||
|
|
||||||
|
Use it for OCS routes that the typed helpers do not cover. Preferences, notifications, and groups are built on it. A nil `Client` uses `http.DefaultClient`.
|
||||||
|
|
||||||
|
### Call sequence
|
||||||
|
|
||||||
|
1. Set `OCSClient.Cred`.
|
||||||
|
2. Call `URL` when the caller only needs the address, or `Call` to perform the request.
|
||||||
|
3. `Call` takes an HTTP method, a path under `/ocs/v2.php/`, and an optional JSON body.
|
||||||
|
4. On success, decode the returned bytes. The client checks that the bytes are JSON and does not decode a particular OCS shape.
|
||||||
|
|
||||||
|
`URL` joins `BaseURL`, `/ocs/v2.php/`, the path, and `?format=json`.
|
||||||
|
|
||||||
|
### Errors
|
||||||
|
|
||||||
|
`Call` returns an error when the request cannot be built, the transport fails, the status is outside 2xx, or the body is not JSON. A non-2xx error includes the method, path, and HTTP status.
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```go
|
||||||
|
ocs := gonexapp.OCSClient{Cred: cred}
|
||||||
|
raw, err := ocs.Call(http.MethodGet, "cloud/capabilities", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = raw
|
||||||
|
```
|
||||||
|
|
||||||
|
## AppAPIPreferences
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
`AppAPIPreferences` reads and writes one string ExApp preference for the requesting user. The caller chooses the app id and the key. The library does not reserve product key names.
|
||||||
|
|
||||||
|
### When to use it
|
||||||
|
|
||||||
|
Use it for a per-user string such as a Saved Default path. Pair it with a go-nc-files `DefaultPathStore` in the ExApp if the product stores that path in preferences.
|
||||||
|
|
||||||
|
### Call sequence
|
||||||
|
|
||||||
|
1. Call `NewAppAPIPreferences` with credentials, the app id, and the key.
|
||||||
|
2. `Get` loads the value. A missing key returns an empty string.
|
||||||
|
3. `Set` stores a new string. The value is stored as non-sensitive.
|
||||||
|
|
||||||
|
Declare nothing extra for the preference itself. The AppAPI preference routes are fixed.
|
||||||
|
|
||||||
|
### Errors
|
||||||
|
|
||||||
|
`Get` and `Set` return the error from the OCS call. `Get` also returns an error when the body cannot be decoded. A missing key is an empty string and a nil error.
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```go
|
||||||
|
prefs := gonexapp.NewAppAPIPreferences(cred, "myexapp", "savedDefault")
|
||||||
|
value, err := prefs.Get()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := prefs.Set("Zones"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = value
|
||||||
|
```
|
||||||
|
|
||||||
|
## AppAPINotifications
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
`AppAPINotifications` creates one Nextcloud bell notification for one recipient. `Notification` carries a subject and, optionally, a message, a link, and rich-object parameter maps.
|
||||||
|
|
||||||
|
### When to use it
|
||||||
|
|
||||||
|
Use `Send` for the user on `Credentials`. Use `SendTo` for a different user. The library does not fan out to a group or to all admins.
|
||||||
|
|
||||||
|
### Call sequence
|
||||||
|
|
||||||
|
1. Call `NewAppAPINotifications` with credentials.
|
||||||
|
2. Fill a `Notification`. `Subject` is required. `Message`, `Link`, `SubjectParams`, and `MessageParams` are optional.
|
||||||
|
3. Call `Send` or `SendTo`.
|
||||||
|
|
||||||
|
`SendTo` authenticates the OCS call as that user via `WithUser`. AppAPI's notification route accepts this shape and does not accept actions or a custom icon.
|
||||||
|
|
||||||
|
### Errors
|
||||||
|
|
||||||
|
`Send` returns an error when `Cred.UserID` is empty. Both methods return an error when the subject is empty, the recipient user id is empty, or the OCS call fails.
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```go
|
||||||
|
err := gonexapp.NewAppAPINotifications(cred).Send(gonexapp.Notification{
|
||||||
|
Subject: "Job finished",
|
||||||
|
Message: "The zone was updated.",
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Groups
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
`Groups` reads Users and Groups from the Provisioning API: the groups of one user, the members of one group, and the instance group list. There is no search and no paging.
|
||||||
|
|
||||||
|
### When to use it
|
||||||
|
|
||||||
|
Use `UserGroups` for the Access Gate's membership check, or whenever the product needs one user's groups. Use `GroupMembers` and `ListGroups` for directory listings. Those two call OCS as `Cred`'s user, who must be an admin or a subadmin.
|
||||||
|
|
||||||
|
### Call sequence
|
||||||
|
|
||||||
|
1. Call `NewGroups` with credentials.
|
||||||
|
2. `UserGroups(userID)` lists that user's group ids and authenticates as `userID`.
|
||||||
|
3. `GroupMembers(groupID)` lists user ids in the group, as the credentials user.
|
||||||
|
4. `ListGroups` lists instance group ids, as the credentials user.
|
||||||
|
|
||||||
|
### Errors
|
||||||
|
|
||||||
|
Each method returns an error when the OCS call fails or the body cannot be decoded. A forbidden directory call is the OCS error from `Call` (HTTP status in the message). This package does not wrap those as a sentinel.
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```go
|
||||||
|
members, err := gonexapp.NewGroups(cred).GroupMembers("ops")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = members
|
||||||
|
```
|
||||||
|
|
||||||
|
## Access Gate
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
The Access Gate allows a request only when the requesting user is in one of the Required Groups. An empty group list turns the gate off.
|
||||||
|
|
||||||
|
### When to use it
|
||||||
|
|
||||||
|
Wrap the ExApp's HTTP handler when the product restricts who may open it. Leave `Groups` empty to allow everyone. Lifecycle routes stay reachable either way.
|
||||||
|
|
||||||
|
### Call sequence
|
||||||
|
|
||||||
|
1. Read the deploy environment. `EnvRequiredGroups` is `REQUIRED_GROUPS`. `EnvRequiredGroupsCacheSeconds` is `REQUIRED_GROUPS_CACHE_SECONDS`.
|
||||||
|
2. `ResolveRequiredGroups` uses the code default when the variable is unset, and replaces it when the variable is set, including set to empty.
|
||||||
|
3. `ParseCacheSeconds` turns the cache variable into a duration. Unset or invalid uses `DefaultCacheSeconds` (60). `"0"` disables the cache.
|
||||||
|
4. Put the credentials, the group list, and the cache TTL on an `AccessGate`.
|
||||||
|
5. `Wrap` the handler. Or call `Check` and branch on `CheckResult`.
|
||||||
|
|
||||||
|
`Wrap` calls `next` on `CheckAllowed`. `CheckUnauthorized` writes 401. `CheckUnavailable` writes 503. `CheckDenied` writes English HTML with status 200 and `frame-ancestors 'self'` when the request accepts `text/html`, and 403 otherwise. The 200 status and the CSP keep AppAPI from blanking the iframe.
|
||||||
|
|
||||||
|
Skipped paths are `/heartbeat`, `/enabled`, `/init`, anything under `/js/`, and `ExtraSkipPaths`. Top-menu scripts live under `/js/` so a non-member can still load them.
|
||||||
|
|
||||||
|
Positive membership is cached for `CacheTTL`. A denial is not cached. `CacheTTL` of zero disables the cache.
|
||||||
|
|
||||||
|
### Errors
|
||||||
|
|
||||||
|
`Check` does not return an error. It returns `CheckUnauthorized` when the requesting user cannot be read, and `CheckUnavailable` when the group lookup fails. `ParseRequiredGroups` and `ResolveRequiredGroups` do not return errors. They drop empty comma-separated fields.
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```go
|
||||||
|
groupsEnv, groupsSet := os.LookupEnv(gonexapp.EnvRequiredGroups)
|
||||||
|
groups := gonexapp.ResolveRequiredGroups(groupsEnv, groupsSet, nil)
|
||||||
|
ttl := gonexapp.ParseCacheSeconds(os.Getenv(gonexapp.EnvRequiredGroupsCacheSeconds), gonexapp.DefaultCacheSeconds)
|
||||||
|
handler = gonexapp.AccessGate{Cred: cred, Groups: groups, CacheTTL: ttl}.Wrap(handler)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Top Menu visibility
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
`TopMenuAdminRequired` turns the deploy environment into the `"0"` or `"1"` string AppAPI's top-menu OCS field `adminRequired` expects.
|
||||||
|
|
||||||
|
### When to use it
|
||||||
|
|
||||||
|
Use it when the ExApp registers its top-menu entry at enable time. The library does not register the menu. `"1"` means admins only. `"0"` means every user.
|
||||||
|
|
||||||
|
### Call sequence
|
||||||
|
|
||||||
|
1. Declare `TOP_MENU_ADMIN_REQUIRED` (`EnvTopMenuAdminRequired`) in `info.xml` under environment variables.
|
||||||
|
2. At enable time, call `TopMenuAdminRequired` with the env value and `DefaultTopMenuAdminRequired` (`true`).
|
||||||
|
3. Send the returned `"0"` or `"1"` in the top-menu registration request.
|
||||||
|
|
||||||
|
Only `"0"` and `"1"` are accepted. Any other value, including empty, uses the default. Changing the deploy env does not update an entry AppAPI already registered. Set the new value, recreate or restart the container so the env is present, then disable and enable the ExApp (or update it) so `PUT /enabled?enabled=1` runs again. Route `access_level` in `info.xml` is separate and changes only when AppAPI re-reads `info.xml`.
|
||||||
|
|
||||||
|
### Errors
|
||||||
|
|
||||||
|
`TopMenuAdminRequired` does not return an error. An invalid value falls back to the default.
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```go
|
||||||
|
adminRequired := gonexapp.TopMenuAdminRequired(
|
||||||
|
os.Getenv(gonexapp.EnvTopMenuAdminRequired),
|
||||||
|
gonexapp.DefaultTopMenuAdminRequired,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## App navigation
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
App navigation is an optional Files-style shell. The ExApp supplies the tree and the page for each item. Mounting `Handler` replaces the ExApp's own page. Not mounting it leaves that page alone.
|
||||||
|
|
||||||
|
### When to use it
|
||||||
|
|
||||||
|
Use it when the ExApp wants Nextcloud's navigation column, theme background, and a selected item in the query string. Lifecycle routes, HaRP startup, and top-menu registration stay in the ExApp.
|
||||||
|
|
||||||
|
### Call sequence
|
||||||
|
|
||||||
|
1. Fill `AppNavigation`. `Items`, `Page`, and `DefaultID` are required. `Header` and `MissingMessage` are optional.
|
||||||
|
2. `Items` returns the tree for this request. An `Item` has an id, a label, optional children, and an optional `Icon`.
|
||||||
|
3. Set `Icon` to a same-origin image URL, or to a field of `NextcloudIcons` (core SVGs Nextcloud already serves, such as `NextcloudIcons.Folder`).
|
||||||
|
4. `Page` returns the HTML body for the selected id and whether that id is known.
|
||||||
|
5. `SelectKey` defaults to `item`. Other query parameters, including a Visit folder, stay on the address.
|
||||||
|
6. Mount `Handler` on the ExApp's page route.
|
||||||
|
|
||||||
|
A missing item renders `DefaultID`, shows `MissingMessage`, and publishes the corrected query on `data-address`. Every parent starts expanded. At 1024px and below, the tree stays hidden until the user opens it. Choosing an item, pressing Escape, or clicking outside closes it.
|
||||||
|
|
||||||
|
The zero value of `DisableTheme` loads the Nextcloud theme stylesheets, paints `--image-background`, and copies the surrounding page's `data-theme-*` markers. Set `DisableTheme` to skip the stylesheets. The shell includes the Dialog. The response sets `frame-ancestors 'self'` so AppAPI can show the iframe.
|
||||||
|
|
||||||
|
### Errors
|
||||||
|
|
||||||
|
`Handler` does not return an error to the caller. It writes HTML with status 200. A nil `Items` or `Page` yields an empty tree or an empty page.
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```go
|
||||||
|
nav := gonexapp.AppNavigation{
|
||||||
|
DefaultID: "home",
|
||||||
|
Items: func(*http.Request) []gonexapp.Item {
|
||||||
|
return []gonexapp.Item{{
|
||||||
|
ID: "home", Label: "Home", Icon: gonexapp.NextcloudIcons.Home,
|
||||||
|
}}
|
||||||
|
},
|
||||||
|
Page: func(_ *http.Request, id string) (string, bool) {
|
||||||
|
if id != "home" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return "<p>Home</p>", true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
http.Handle("/page", nav.Handler())
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dialog
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
The Dialog is a modal for a message, a confirm, or a prompt. The page calls it from JavaScript and waits for the user's choice.
|
||||||
|
|
||||||
|
### When to use it
|
||||||
|
|
||||||
|
App navigation already includes the dialog. Insert `DialogHTML` on a page the ExApp renders itself. The buttons say OK and Cancel. The agreeing button's word may be replaced. The choice stays in the page.
|
||||||
|
|
||||||
|
### Call sequence
|
||||||
|
|
||||||
|
1. Insert `DialogHTML()` into the page HTML once.
|
||||||
|
2. From the page script, call one of `exappDialog.message`, `exappDialog.confirm`, or `exappDialog.prompt`, and await the promise.
|
||||||
|
|
||||||
|
`message` takes `heading`, `text`, and `severity` (`info`, `warning`, or `error`). It shows no Cancel button. `confirm` takes `heading`, `text`, optional `agree`, and optional `destructive`. It resolves `true` or `false`. `prompt` takes `heading`, `text`, optional `value`, and optional `agree`. It resolves the trimmed string, or `null` when the user cancels. The agreeing button stays disabled until the field is non-empty. Calls are queued so only one dialog is open.
|
||||||
|
|
||||||
|
### Errors
|
||||||
|
|
||||||
|
`DialogHTML` does not return an error. It returns HTML that is safe to drop into a `template.HTML` context because the function's result type is already `template.HTML`. The page script is responsible for handling a dismissed dialog (`false`, `null`, or a resolved message).
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```go
|
||||||
|
fmt.Fprint(w, gonexapp.DialogHTML())
|
||||||
|
```
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script>
|
||||||
|
const ok = await exappDialog.confirm({heading: "Delete", text: "Delete this file?", agree: "Delete", destructive: true});
|
||||||
|
const name = await exappDialog.prompt({heading: "Name", text: "Folder name", value: "Zones"});
|
||||||
|
</script>
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,3 +1,14 @@
|
|||||||
module gitea.neitzel.de/konrad/go-nc-exapp
|
module gitea.neitzel.de/konrad/go-nc-exapp
|
||||||
|
|
||||||
go 1.26.4
|
go 1.27.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f // indirect
|
||||||
|
github.com/chromedp/chromedp v0.16.0 // indirect
|
||||||
|
github.com/chromedp/sysutil v1.1.0 // indirect
|
||||||
|
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect
|
||||||
|
github.com/gobwas/httphead v0.1.0 // indirect
|
||||||
|
github.com/gobwas/pool v0.2.1 // indirect
|
||||||
|
github.com/gobwas/ws v1.4.0 // indirect
|
||||||
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f h1:0Z1zcSLEmnj2c2CmJYBqewtS6pxhB39bNWUSEUAWjgk=
|
||||||
|
github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f/go.mod h1:RwFsSODCtFExll+GhHM6R92SARHR3Z3oipaxLHj46C0=
|
||||||
|
github.com/chromedp/chromedp v0.16.0 h1:rOO4deOm4CbZgBCa8mD9g2rDyIoNs0BkgvNrlbp5ouk=
|
||||||
|
github.com/chromedp/chromedp v0.16.0/go.mod h1:rbuGKFT1vMcFcFqKfPIO1GpX/N+2s8onm2qMxZLbU5U=
|
||||||
|
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
|
||||||
|
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
|
||||||
|
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 h1:KZaTBSyshWX3MP5jukJcNSuXDQTO+rNpt0J564dX/eg=
|
||||||
|
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||||
|
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||||
|
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||||
|
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||||
|
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||||
|
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
||||||
|
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package gonexapp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Groups reads Users and Groups from Provisioning OCS.
|
||||||
|
type Groups struct {
|
||||||
|
Cred Credentials
|
||||||
|
Client *http.Client
|
||||||
|
OCS OCSClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGroups returns a directory reader using cred for AppAPI auth.
|
||||||
|
func NewGroups(cred Credentials) Groups {
|
||||||
|
return Groups{
|
||||||
|
Cred: cred,
|
||||||
|
OCS: OCSClient{Cred: cred},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g Groups) ocsClient() OCSClient {
|
||||||
|
c := g.OCS
|
||||||
|
if c.Cred.BaseURL == "" {
|
||||||
|
c.Cred = g.Cred
|
||||||
|
}
|
||||||
|
if c.Client == nil {
|
||||||
|
c.Client = g.Client
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserGroups returns the Nextcloud group ids of userID.
|
||||||
|
// The OCS call is authenticated as userID.
|
||||||
|
// It returns an error when the OCS call fails or the body cannot be decoded.
|
||||||
|
func (g Groups) UserGroups(userID string) ([]string, error) {
|
||||||
|
ocs := g.ocsClient()
|
||||||
|
ocs.Cred = ocs.Cred.WithUser(userID)
|
||||||
|
path := "cloud/users/" + url.PathEscape(userID) + "/groups"
|
||||||
|
raw, err := ocs.Call(http.MethodGet, path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return decodeUserGroups(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupMembers returns the user ids in groupID.
|
||||||
|
// The OCS call uses Cred as-is. Nextcloud requires that user to be an admin
|
||||||
|
// or a subadmin of the group.
|
||||||
|
// It returns an error when the OCS call fails or the body cannot be decoded.
|
||||||
|
// There is no search or paging.
|
||||||
|
func (g Groups) GroupMembers(groupID string) ([]string, error) {
|
||||||
|
ocs := g.ocsClient()
|
||||||
|
path := "cloud/groups/" + url.PathEscape(groupID)
|
||||||
|
raw, err := ocs.Call(http.MethodGet, path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return decodeGroupMembers(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeGroupMembers(raw []byte) ([]string, error) {
|
||||||
|
var parsed struct {
|
||||||
|
OCS struct {
|
||||||
|
Data struct {
|
||||||
|
Users []string `json:"users"`
|
||||||
|
} `json:"data"`
|
||||||
|
} `json:"ocs"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||||
|
return nil, fmt.Errorf("group members decode: %w", err)
|
||||||
|
}
|
||||||
|
return parsed.OCS.Data.Users, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListGroups returns instance group ids.
|
||||||
|
// The OCS call uses Cred as-is and needs an admin or subadmin.
|
||||||
|
// It returns an error when the OCS call fails or the body cannot be decoded.
|
||||||
|
// There is no search or paging.
|
||||||
|
func (g Groups) ListGroups() ([]string, error) {
|
||||||
|
ocs := g.ocsClient()
|
||||||
|
raw, err := ocs.Call(http.MethodGet, "cloud/groups", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return decodeUserGroups(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeUserGroups(raw []byte) ([]string, error) {
|
||||||
|
var parsed struct {
|
||||||
|
OCS struct {
|
||||||
|
Data struct {
|
||||||
|
Groups []string `json:"groups"`
|
||||||
|
} `json:"data"`
|
||||||
|
} `json:"ocs"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||||
|
return nil, fmt.Errorf("user groups decode: %w", err)
|
||||||
|
}
|
||||||
|
return parsed.OCS.Data.Groups, nil
|
||||||
|
}
|
||||||
+187
@@ -0,0 +1,187 @@
|
|||||||
|
package gonexapp_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.neitzel.de/konrad/go-nc-exapp"
|
||||||
|
)
|
||||||
|
|
||||||
|
func groupsAuthUser(r *http.Request) string {
|
||||||
|
raw := r.Header.Get("AUTHORIZATION-APP-API")
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(raw)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(string(decoded), ":", 2)
|
||||||
|
if len(parts) < 1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return parts[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGroupsUserGroups(t *testing.T) {
|
||||||
|
var gotMethod, gotPath, gotUser string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotMethod = r.Method
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
gotUser = groupsAuthUser(r)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"ocs": map[string]any{"data": map[string]any{"groups": []string{"dns-ops", "users"}}},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
cred := gonexapp.Credentials{
|
||||||
|
BaseURL: srv.URL, AppID: "app", AppVersion: "0.1.0", AAVersion: "1.0.0", AppSecret: "s", UserID: "admin",
|
||||||
|
}
|
||||||
|
g := gonexapp.NewGroups(cred)
|
||||||
|
g.Client = srv.Client()
|
||||||
|
g.OCS.Client = srv.Client()
|
||||||
|
|
||||||
|
got, err := g.UserGroups("alice")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if gotMethod != http.MethodGet {
|
||||||
|
t.Fatalf("method=%q", gotMethod)
|
||||||
|
}
|
||||||
|
if !strings.Contains(gotPath, "/cloud/users/alice/groups") {
|
||||||
|
t.Fatalf("path=%q", gotPath)
|
||||||
|
}
|
||||||
|
if gotUser != "alice" {
|
||||||
|
t.Fatalf("auth user=%q want alice", gotUser)
|
||||||
|
}
|
||||||
|
if len(got) != 2 || got[0] != "dns-ops" || got[1] != "users" {
|
||||||
|
t.Fatalf("got %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGroupsGroupMembers(t *testing.T) {
|
||||||
|
var gotMethod, gotPath, gotUser string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotMethod = r.Method
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
gotUser = groupsAuthUser(r)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"ocs": map[string]any{"data": map[string]any{"users": []string{"alice", "bob"}}},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
cred := gonexapp.Credentials{
|
||||||
|
BaseURL: srv.URL, AppID: "app", AppVersion: "0.1.0", AAVersion: "1.0.0", AppSecret: "s", UserID: "admin",
|
||||||
|
}
|
||||||
|
g := gonexapp.NewGroups(cred)
|
||||||
|
g.Client = srv.Client()
|
||||||
|
g.OCS.Client = srv.Client()
|
||||||
|
|
||||||
|
got, err := g.GroupMembers("CheckDNS")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if gotMethod != http.MethodGet {
|
||||||
|
t.Fatalf("method=%q", gotMethod)
|
||||||
|
}
|
||||||
|
if !strings.Contains(gotPath, "/cloud/groups/CheckDNS") {
|
||||||
|
t.Fatalf("path=%q", gotPath)
|
||||||
|
}
|
||||||
|
if gotUser != "admin" {
|
||||||
|
t.Fatalf("auth user=%q want admin", gotUser)
|
||||||
|
}
|
||||||
|
if len(got) != 2 || got[0] != "alice" || got[1] != "bob" {
|
||||||
|
t.Fatalf("got %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGroupsListGroups(t *testing.T) {
|
||||||
|
var gotMethod, gotPath, gotUser string
|
||||||
|
var gotQuery map[string][]string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotMethod = r.Method
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
gotUser = groupsAuthUser(r)
|
||||||
|
gotQuery = r.URL.Query()
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"ocs": map[string]any{"data": map[string]any{"groups": []string{"CheckDNS", "users"}}},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
cred := gonexapp.Credentials{
|
||||||
|
BaseURL: srv.URL, AppID: "app", AppVersion: "0.1.0", AAVersion: "1.0.0", AppSecret: "s", UserID: "admin",
|
||||||
|
}
|
||||||
|
g := gonexapp.NewGroups(cred)
|
||||||
|
g.Client = srv.Client()
|
||||||
|
g.OCS.Client = srv.Client()
|
||||||
|
|
||||||
|
got, err := g.ListGroups()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if gotMethod != http.MethodGet {
|
||||||
|
t.Fatalf("method=%q", gotMethod)
|
||||||
|
}
|
||||||
|
if gotPath != "/ocs/v2.php/cloud/groups" {
|
||||||
|
if !strings.Contains(gotPath, "/cloud/groups") || strings.Contains(gotPath, "/cloud/groups/") {
|
||||||
|
t.Fatalf("path=%q", gotPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if gotUser != "admin" {
|
||||||
|
t.Fatalf("auth user=%q want admin", gotUser)
|
||||||
|
}
|
||||||
|
if _, ok := gotQuery["search"]; ok {
|
||||||
|
t.Fatalf("unexpected search query: %v", gotQuery["search"])
|
||||||
|
}
|
||||||
|
if _, ok := gotQuery["limit"]; ok {
|
||||||
|
t.Fatalf("unexpected limit query: %v", gotQuery["limit"])
|
||||||
|
}
|
||||||
|
if _, ok := gotQuery["offset"]; ok {
|
||||||
|
t.Fatalf("unexpected offset query: %v", gotQuery["offset"])
|
||||||
|
}
|
||||||
|
if len(got) != 2 || got[0] != "CheckDNS" || got[1] != "users" {
|
||||||
|
t.Fatalf("got %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGroupsGroupMembersForbidden(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "forbidden", http.StatusForbidden)
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
cred := gonexapp.Credentials{
|
||||||
|
BaseURL: srv.URL, AppID: "app", AppVersion: "0.1.0", AAVersion: "1.0.0", AppSecret: "s", UserID: "alice",
|
||||||
|
}
|
||||||
|
g := gonexapp.NewGroups(cred)
|
||||||
|
g.Client = srv.Client()
|
||||||
|
g.OCS.Client = srv.Client()
|
||||||
|
|
||||||
|
_, err := g.GroupMembers("CheckDNS")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "403") {
|
||||||
|
t.Fatalf("got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGroupsListGroupsForbidden(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "forbidden", http.StatusForbidden)
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
cred := gonexapp.Credentials{
|
||||||
|
BaseURL: srv.URL, AppID: "app", AppVersion: "0.1.0", AAVersion: "1.0.0", AppSecret: "s", UserID: "alice",
|
||||||
|
}
|
||||||
|
g := gonexapp.NewGroups(cred)
|
||||||
|
g.Client = srv.Client()
|
||||||
|
g.OCS.Client = srv.Client()
|
||||||
|
|
||||||
|
_, err := g.ListGroups()
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "403") {
|
||||||
|
t.Fatalf("got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package gonexapp
|
||||||
|
|
||||||
|
// NextcloudIcons are SVG paths served by Nextcloud core under /core/img/.
|
||||||
|
// The library does not ship the files. Set Item.Icon to one of these.
|
||||||
|
// Dark mode inverts them with Nextcloud’s --background-invert-if-dark filter.
|
||||||
|
var NextcloudIcons = nextcloudIcons{
|
||||||
|
Folder: "/core/img/filetypes/folder.svg",
|
||||||
|
FolderShared: "/core/img/filetypes/folder-shared.svg",
|
||||||
|
FolderPublic: "/core/img/filetypes/folder-public.svg",
|
||||||
|
FolderStarred: "/core/img/filetypes/folder-starred.svg",
|
||||||
|
FolderEncrypted: "/core/img/filetypes/folder-encrypted.svg",
|
||||||
|
File: "/core/img/filetypes/file.svg",
|
||||||
|
Text: "/core/img/filetypes/text.svg",
|
||||||
|
Image: "/core/img/filetypes/image.svg",
|
||||||
|
Audio: "/core/img/filetypes/audio.svg",
|
||||||
|
Video: "/core/img/filetypes/video.svg",
|
||||||
|
PDF: "/core/img/filetypes/application-pdf.svg",
|
||||||
|
Document: "/core/img/filetypes/x-office-document.svg",
|
||||||
|
Spreadsheet: "/core/img/filetypes/x-office-spreadsheet.svg",
|
||||||
|
Presentation: "/core/img/filetypes/x-office-presentation.svg",
|
||||||
|
Files: "/core/img/places/files.svg",
|
||||||
|
Home: "/core/img/places/home.svg",
|
||||||
|
Calendar: "/core/img/places/calendar.svg",
|
||||||
|
Contacts: "/core/img/places/contacts.svg",
|
||||||
|
Add: "/core/img/actions/add.svg",
|
||||||
|
Delete: "/core/img/actions/delete.svg",
|
||||||
|
Edit: "/core/img/actions/edit.svg",
|
||||||
|
Rename: "/core/img/actions/rename.svg",
|
||||||
|
Download: "/core/img/actions/download.svg",
|
||||||
|
Upload: "/core/img/actions/upload.svg",
|
||||||
|
Share: "/core/img/actions/share.svg",
|
||||||
|
Search: "/core/img/actions/search.svg",
|
||||||
|
Settings: "/core/img/actions/settings.svg",
|
||||||
|
Info: "/core/img/actions/info.svg",
|
||||||
|
History: "/core/img/actions/history.svg",
|
||||||
|
Password: "/core/img/actions/password.svg",
|
||||||
|
Confirm: "/core/img/actions/confirm.svg",
|
||||||
|
Close: "/core/img/actions/close.svg",
|
||||||
|
Star: "/core/img/actions/star.svg",
|
||||||
|
User: "/core/img/actions/user.svg",
|
||||||
|
Group: "/core/img/actions/group.svg",
|
||||||
|
Mail: "/core/img/actions/mail.svg",
|
||||||
|
Menu: "/core/img/actions/menu.svg",
|
||||||
|
External: "/core/img/actions/external.svg",
|
||||||
|
Filter: "/core/img/actions/filter.svg",
|
||||||
|
Recent: "/core/img/actions/recent.svg",
|
||||||
|
Tag: "/core/img/actions/tag.svg",
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextcloudIcons is the named set of core SVGs. Fields are paths, not image bytes.
|
||||||
|
type nextcloudIcons struct {
|
||||||
|
Folder string
|
||||||
|
FolderShared string
|
||||||
|
FolderPublic string
|
||||||
|
FolderStarred string
|
||||||
|
FolderEncrypted string
|
||||||
|
File string
|
||||||
|
Text string
|
||||||
|
Image string
|
||||||
|
Audio string
|
||||||
|
Video string
|
||||||
|
PDF string
|
||||||
|
Document string
|
||||||
|
Spreadsheet string
|
||||||
|
Presentation string
|
||||||
|
Files string
|
||||||
|
Home string
|
||||||
|
Calendar string
|
||||||
|
Contacts string
|
||||||
|
Add string
|
||||||
|
Delete string
|
||||||
|
Edit string
|
||||||
|
Rename string
|
||||||
|
Download string
|
||||||
|
Upload string
|
||||||
|
Share string
|
||||||
|
Search string
|
||||||
|
Settings string
|
||||||
|
Info string
|
||||||
|
History string
|
||||||
|
Password string
|
||||||
|
Confirm string
|
||||||
|
Close string
|
||||||
|
Star string
|
||||||
|
User string
|
||||||
|
Group string
|
||||||
|
Mail string
|
||||||
|
Menu string
|
||||||
|
External string
|
||||||
|
Filter string
|
||||||
|
Recent string
|
||||||
|
Tag string
|
||||||
|
}
|
||||||
+423
@@ -0,0 +1,423 @@
|
|||||||
|
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:; font-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.
|
||||||
|
// Icon is an optional same-origin image URL. NextcloudIcons names the core SVGs.
|
||||||
|
type Item struct {
|
||||||
|
ID string
|
||||||
|
Label string
|
||||||
|
Icon 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
|
||||||
|
// DisableTheme skips the Nextcloud theme stylesheets. The zero value
|
||||||
|
// loads the active theme.
|
||||||
|
DisableTheme bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
Theme: !n.DisableTheme,
|
||||||
|
Dialog: DialogHTML(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
Theme bool
|
||||||
|
Dialog template.HTML
|
||||||
|
}
|
||||||
|
|
||||||
|
type itemView struct {
|
||||||
|
ID string
|
||||||
|
Label string
|
||||||
|
Icon 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,
|
||||||
|
Icon: it.Icon,
|
||||||
|
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">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{.Title}}</title>
|
||||||
|
{{if .Theme}}
|
||||||
|
<link rel="stylesheet" href="/apps/theming/css/default.css">
|
||||||
|
<link rel="stylesheet" href="/index.php/apps/theming/theme/default.css">
|
||||||
|
<link rel="stylesheet" href="/index.php/apps/theming/theme/dark.css">
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var root = document.documentElement;
|
||||||
|
try {
|
||||||
|
var parent = window.parent && window.parent !== window ? window.parent.document.documentElement : null;
|
||||||
|
if (parent) {
|
||||||
|
for (var i = 0; i < parent.attributes.length; i++) {
|
||||||
|
var attr = parent.attributes[i];
|
||||||
|
if (attr.name.indexOf("data-theme") === 0) {
|
||||||
|
root.setAttribute(attr.name, attr.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
if (!root.hasAttribute("data-theme-default") && !root.hasAttribute("data-theme-dark") && !root.hasAttribute("data-theme-light")) {
|
||||||
|
var dark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||||
|
root.setAttribute(dark ? "data-theme-dark" : "data-theme-default", "");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--color-main-background: #ffffff;
|
||||||
|
--color-main-text: #222222;
|
||||||
|
--color-background-hover: #f5f5f5;
|
||||||
|
--color-background-dark: #ededed;
|
||||||
|
--color-primary-element: #00679e;
|
||||||
|
--color-primary-element-light: #e5f0f8;
|
||||||
|
--color-border: #ededed;
|
||||||
|
--default-clickable-area: 34px;
|
||||||
|
--border-radius-element: 8px;
|
||||||
|
}
|
||||||
|
html, body {
|
||||||
|
min-height: 100%;
|
||||||
|
color: var(--color-main-text);
|
||||||
|
background-color: var(--color-background-plain, var(--color-main-background));
|
||||||
|
background-image: var(--image-background);
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
body { margin: 0; font: 15px/1.4 var(--font-face, system-ui, sans-serif); }
|
||||||
|
#app-nav { display: flex; flex-direction: column; min-height: 100vh; background: transparent; }
|
||||||
|
#nav-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
#nav-header-text { flex: 1; min-width: 0; }
|
||||||
|
#nav-toggle {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
flex: none;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--border-radius-element);
|
||||||
|
}
|
||||||
|
#nav-toggle:hover { background: var(--color-background-hover); }
|
||||||
|
#nav-toggle svg { width: 20px; height: 20px; }
|
||||||
|
#nav-backdrop { display: none; }
|
||||||
|
#app-nav-body { display: flex; flex: 1; min-height: 0; }
|
||||||
|
nav.app-navigation {
|
||||||
|
width: var(--navigation-width, 300px);
|
||||||
|
flex: none;
|
||||||
|
background-color: var(--color-main-background-translucent, var(--color-main-background));
|
||||||
|
backdrop-filter: var(--filter-background-blur, none);
|
||||||
|
-webkit-backdrop-filter: var(--filter-background-blur, none);
|
||||||
|
border-inline-end: 1px solid var(--color-border);
|
||||||
|
padding: 8px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
main { flex: 1; padding: 16px; min-width: 0; background-color: var(--color-main-background); }
|
||||||
|
.app-navigation-list, .app-navigation-entry__children { list-style: none; margin: 0; padding: 0; }
|
||||||
|
.app-navigation-entry__children { padding-inline-start: 10px; }
|
||||||
|
.app-navigation-entry-wrapper { display: flex; flex-wrap: wrap; width: 100%; }
|
||||||
|
.app-navigation-entry {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
width: 100%;
|
||||||
|
min-height: var(--default-clickable-area);
|
||||||
|
border-radius: var(--border-radius-element);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.app-navigation-entry:hover { background: var(--color-background-hover); }
|
||||||
|
.app-navigation-entry.active { background: var(--color-primary-element-light); }
|
||||||
|
.app-navigation-entry.active::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset-block: 8px;
|
||||||
|
inset-inline-start: 0;
|
||||||
|
width: 3px;
|
||||||
|
background: var(--color-primary-element);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.app-navigation-entry-link {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: var(--default-clickable-area);
|
||||||
|
padding: 0 8px 0 12px;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.app-navigation-entry-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-inline-end: 8px;
|
||||||
|
flex: none;
|
||||||
|
filter: var(--background-invert-if-dark, none);
|
||||||
|
}
|
||||||
|
.app-navigation-entry__name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.app-navigation-entry.active .app-navigation-entry__name { font-weight: 700; }
|
||||||
|
.app-navigation-entry__utils { display: flex; align-items: center; }
|
||||||
|
button.fold {
|
||||||
|
width: var(--default-clickable-area);
|
||||||
|
height: var(--default-clickable-area);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--border-radius-element);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
button.fold:hover { background: var(--color-background-dark); }
|
||||||
|
button.fold svg { width: 16px; height: 16px; transition: transform .15s ease; }
|
||||||
|
li[data-expanded="false"] > .app-navigation-entry__children { display: none; }
|
||||||
|
li[data-expanded="false"] button.fold svg { transform: rotate(-90deg); }
|
||||||
|
#nav-missing { margin: 0 0 1rem; }
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
#nav-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2100;
|
||||||
|
background-color: var(--color-main-background);
|
||||||
|
}
|
||||||
|
#nav-toggle { display: flex; }
|
||||||
|
nav.app-navigation {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 2000;
|
||||||
|
top: var(--nav-header-offset, 0px);
|
||||||
|
bottom: 0;
|
||||||
|
inset-inline-start: 0;
|
||||||
|
width: min(var(--navigation-width, 300px), 85vw);
|
||||||
|
}
|
||||||
|
body.nav-open nav.app-navigation { display: block; }
|
||||||
|
body.nav-open #nav-backdrop {
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1990;
|
||||||
|
top: var(--nav-header-offset, 0px);
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body data-selected="{{.Selected}}" data-address="{{.Address}}">
|
||||||
|
{{.Dialog}}
|
||||||
|
<div id="app-nav">
|
||||||
|
<div id="nav-header">
|
||||||
|
<button type="button" id="nav-toggle" aria-controls="app-navigation" aria-expanded="false" aria-label="Open navigation"><svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M3 6h18v2H3V6m0 5h18v2H3v-2m0 5h18v2H3v-2z"/></svg></button>
|
||||||
|
<div id="nav-header-text">{{.Header}}</div>
|
||||||
|
</div>
|
||||||
|
<div id="nav-backdrop"></div>
|
||||||
|
<div id="app-nav-body">
|
||||||
|
<nav id="app-navigation" class="app-navigation" aria-label="App navigation">
|
||||||
|
<ul class="app-navigation-list">{{template "items" .Items}}</ul>
|
||||||
|
</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");
|
||||||
|
li.classList.toggle("app-navigation-entry--opened", !open);
|
||||||
|
btn.setAttribute("aria-expanded", open ? "false" : "true");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
var toggle = document.getElementById("nav-toggle");
|
||||||
|
var backdrop = document.getElementById("nav-backdrop");
|
||||||
|
function placeNav() {
|
||||||
|
var header = document.getElementById("nav-header");
|
||||||
|
if (!header) return;
|
||||||
|
document.documentElement.style.setProperty("--nav-header-offset", header.offsetHeight + "px");
|
||||||
|
}
|
||||||
|
function setNavOpen(open) {
|
||||||
|
placeNav();
|
||||||
|
document.body.classList.toggle("nav-open", open);
|
||||||
|
if (!toggle) return;
|
||||||
|
toggle.setAttribute("aria-expanded", open ? "true" : "false");
|
||||||
|
toggle.setAttribute("aria-label", open ? "Close navigation" : "Open navigation");
|
||||||
|
}
|
||||||
|
placeNav();
|
||||||
|
window.addEventListener("resize", placeNav);
|
||||||
|
var narrow = window.matchMedia("(max-width: 1024px)");
|
||||||
|
if (narrow.addEventListener) narrow.addEventListener("change", placeNav);
|
||||||
|
if (toggle) {
|
||||||
|
toggle.addEventListener("click", function () {
|
||||||
|
setNavOpen(!document.body.classList.contains("nav-open"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (backdrop) backdrop.addEventListener("click", function () { setNavOpen(false); });
|
||||||
|
document.addEventListener("keydown", function (e) {
|
||||||
|
if (e.key !== "Escape") return;
|
||||||
|
var dialog = document.getElementById("exapp-dialog");
|
||||||
|
if (dialog && dialog.open) return;
|
||||||
|
setNavOpen(false);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
|
{{define "items"}}{{range .}}<li class="app-navigation-entry-wrapper{{if .HasChildren}} app-navigation-entry--collapsible app-navigation-entry--opened{{end}}" data-id="{{.ID}}"{{if .HasChildren}} data-expanded="true"{{end}}><div class="app-navigation-entry{{if .Current}} active{{end}}"><a class="app-navigation-entry-link" href="{{.Href}}"{{if .Current}} aria-current="page"{{end}}>{{if .Icon}}<img class="app-navigation-entry-icon" src="{{.Icon}}" alt="">{{end}}<span class="app-navigation-entry__name">{{.Label}}</span></a>{{if .HasChildren}}<div class="app-navigation-entry__utils"><button type="button" class="fold" aria-expanded="true" aria-label="Fold {{.Label}}"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4.5 6.5 8 10l3.5-3.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg></button></div>{{end}}</div>{{if .HasChildren}}<ul class="app-navigation-entry__children">{{template "items" .Children}}</ul>{{end}}</li>{{end}}{{end}}
|
||||||
|
`))
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
package gonexapp_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/chromedp/chromedp"
|
||||||
|
"github.com/chromedp/chromedp/kb"
|
||||||
|
|
||||||
|
"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 TestAppNavigationLoadsNextcloudTheme(t *testing.T) {
|
||||||
|
body := navBody(t, "/")
|
||||||
|
for _, need := range []string{
|
||||||
|
"/apps/theming/css/default.css",
|
||||||
|
"/index.php/apps/theming/theme/default.css",
|
||||||
|
"/index.php/apps/theming/theme/dark.css",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, need) {
|
||||||
|
t.Fatalf("missing %s in %s", need, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppNavigationThemeCanBeDisabled(t *testing.T) {
|
||||||
|
nav := sampleNavigation()
|
||||||
|
nav.DisableTheme = true
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
nav.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||||
|
if strings.Contains(rec.Body.String(), "/apps/theming/css/default.css") {
|
||||||
|
t.Fatalf("theme stylesheet still present: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppNavigationFoldControlFollowsLabel(t *testing.T) {
|
||||||
|
body := navBody(t, "/?item=domains")
|
||||||
|
name := strings.Index(body, ">Domains<")
|
||||||
|
fold := strings.Index(body, `aria-label="Fold Domains"`)
|
||||||
|
if name < 0 || fold < 0 || fold < name {
|
||||||
|
t.Fatalf("name %d fold %d", name, fold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppNavigationRendersIconBeforeLabel(t *testing.T) {
|
||||||
|
nav := sampleNavigation()
|
||||||
|
nav.Items = func(*http.Request) []gonexapp.Item {
|
||||||
|
return []gonexapp.Item{{ID: "domains", Label: "Domains", Icon: gonexapp.NextcloudIcons.Folder}}
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
nav.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/?item=domains", nil))
|
||||||
|
body := rec.Body.String()
|
||||||
|
icon := strings.Index(body, `src="/core/img/filetypes/folder.svg"`)
|
||||||
|
name := strings.Index(body, ">Domains<")
|
||||||
|
if icon < 0 || name < 0 || icon > name {
|
||||||
|
t.Fatalf("icon %d name %d", icon, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppNavigationOffersNarrowScreenToggle(t *testing.T) {
|
||||||
|
body := navBody(t, "/")
|
||||||
|
for _, need := range []string{
|
||||||
|
`<meta name="viewport" content="width=device-width, initial-scale=1">`,
|
||||||
|
`id="nav-toggle"`,
|
||||||
|
`aria-controls="app-navigation"`,
|
||||||
|
`aria-expanded="false"`,
|
||||||
|
`aria-label="Open navigation"`,
|
||||||
|
`id="nav-backdrop"`,
|
||||||
|
`id="app-navigation"`,
|
||||||
|
`max-width: 1024px`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, need) {
|
||||||
|
t.Errorf("missing %s", need)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// navLayoutJS reports how App navigation is laid out: "wide", "closed", or "open".
|
||||||
|
const navLayoutJS = `(() => {
|
||||||
|
const nav = document.querySelector("nav.app-navigation");
|
||||||
|
const btn = document.getElementById("nav-toggle");
|
||||||
|
const narrow = window.matchMedia("(max-width: 1024px)").matches;
|
||||||
|
const box = nav.getBoundingClientRect();
|
||||||
|
const navShown = getComputedStyle(nav).display !== "none" && box.width > 0 && box.left < window.innerWidth && box.right > 0;
|
||||||
|
const btnShown = getComputedStyle(btn).display !== "none";
|
||||||
|
const expanded = btn.getAttribute("aria-expanded") === "true";
|
||||||
|
if (!narrow && navShown && !btnShown && !expanded) return "wide";
|
||||||
|
if (narrow && !navShown && btnShown && !expanded) return "closed";
|
||||||
|
if (narrow && navShown && btnShown && expanded) return "open";
|
||||||
|
return "narrow=" + narrow + " nav=" + navShown + " btn=" + btnShown + " expanded=" + expanded + " width=" + window.innerWidth;
|
||||||
|
})()`
|
||||||
|
|
||||||
|
func TestAppNavigationWideScreenKeepsTheTreeVisible(t *testing.T) {
|
||||||
|
drive(t, "/nav",
|
||||||
|
chromedp.EmulateViewport(1280, 800),
|
||||||
|
waitJS(navLayoutJS, "wide", false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppNavigationNarrowScreenOpensTheTreeOnDemand(t *testing.T) {
|
||||||
|
drive(t, "/nav",
|
||||||
|
chromedp.EmulateViewport(390, 800),
|
||||||
|
waitJS(navLayoutJS, "closed", false),
|
||||||
|
chromedp.Click("#nav-toggle", chromedp.ByQuery),
|
||||||
|
waitJS(navLayoutJS, "open", false),
|
||||||
|
chromedp.Click("#nav-toggle", chromedp.ByQuery),
|
||||||
|
waitJS(navLayoutJS, "closed", false),
|
||||||
|
chromedp.Click("#nav-toggle", chromedp.ByQuery),
|
||||||
|
waitJS(navLayoutJS, "open", false),
|
||||||
|
chromedp.MouseClickXY(370, 400),
|
||||||
|
waitJS(navLayoutJS, "closed", false),
|
||||||
|
chromedp.Click("#nav-toggle", chromedp.ByQuery),
|
||||||
|
waitJS(navLayoutJS, "open", false),
|
||||||
|
chromedp.KeyEvent(kb.Escape),
|
||||||
|
waitJS(navLayoutJS, "closed", false),
|
||||||
|
chromedp.Click("#nav-toggle", chromedp.ByQuery),
|
||||||
|
waitJS(navLayoutJS, "open", false),
|
||||||
|
chromedp.Click(`a.app-navigation-entry-link[href*="item=keys"]`, chromedp.ByQuery),
|
||||||
|
waitJS(`document.querySelector("main").textContent`, "keys-page", true),
|
||||||
|
waitJS(navLayoutJS, "closed", false),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package gonexapp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Notification is one Nextcloud bell for a single Recipient.
|
||||||
|
type Notification struct {
|
||||||
|
Subject string
|
||||||
|
Message string
|
||||||
|
Link string
|
||||||
|
SubjectParams map[string]any
|
||||||
|
MessageParams map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppAPINotifications creates Notifications via AppAPI OCS.
|
||||||
|
type AppAPINotifications struct {
|
||||||
|
Cred Credentials
|
||||||
|
Client *http.Client
|
||||||
|
OCS OCSClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAppAPINotifications returns a sender using cred for AppAPI auth.
|
||||||
|
func NewAppAPINotifications(cred Credentials) AppAPINotifications {
|
||||||
|
return AppAPINotifications{
|
||||||
|
Cred: cred,
|
||||||
|
OCS: OCSClient{Cred: cred},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n AppAPINotifications) ocsClient() OCSClient {
|
||||||
|
c := n.OCS
|
||||||
|
if c.Cred.BaseURL == "" {
|
||||||
|
c.Cred = n.Cred
|
||||||
|
}
|
||||||
|
if c.Client == nil {
|
||||||
|
c.Client = n.Client
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send creates a Notification for Cred.UserID.
|
||||||
|
// It returns an error when UserID or Subject is empty, or when the OCS call fails.
|
||||||
|
// AppAPI notification OCS accepts Subject, Message, Link, and rich-object
|
||||||
|
// parameters. It does not accept actions or a custom icon.
|
||||||
|
func (n AppAPINotifications) Send(notif Notification) error {
|
||||||
|
if n.Cred.UserID == "" {
|
||||||
|
return fmt.Errorf("notification recipient user id is required")
|
||||||
|
}
|
||||||
|
return n.send(n.Cred.UserID, notif)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendTo creates a Notification for userID.
|
||||||
|
// The OCS call is authenticated as that user via WithUser.
|
||||||
|
// It returns an error when userID or Subject is empty, or when the OCS call fails.
|
||||||
|
func (n AppAPINotifications) SendTo(userID string, notif Notification) error {
|
||||||
|
return n.send(userID, notif)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n AppAPINotifications) send(userID string, notif Notification) error {
|
||||||
|
if userID == "" {
|
||||||
|
return fmt.Errorf("notification recipient user id is required")
|
||||||
|
}
|
||||||
|
if notif.Subject == "" {
|
||||||
|
return fmt.Errorf("notification subject is required")
|
||||||
|
}
|
||||||
|
ocs := n.ocsClient()
|
||||||
|
ocs.Cred = ocs.Cred.WithUser(userID)
|
||||||
|
richSubjectParams := notif.SubjectParams
|
||||||
|
if richSubjectParams == nil {
|
||||||
|
richSubjectParams = map[string]any{}
|
||||||
|
}
|
||||||
|
subjectParams := map[string]any{
|
||||||
|
"rich_subject": notif.Subject,
|
||||||
|
"rich_subject_params": richSubjectParams,
|
||||||
|
}
|
||||||
|
if notif.Message != "" {
|
||||||
|
richMessageParams := notif.MessageParams
|
||||||
|
if richMessageParams == nil {
|
||||||
|
richMessageParams = map[string]any{}
|
||||||
|
}
|
||||||
|
subjectParams["rich_message"] = notif.Message
|
||||||
|
subjectParams["rich_message_params"] = richMessageParams
|
||||||
|
}
|
||||||
|
if notif.Link != "" {
|
||||||
|
subjectParams["link"] = notif.Link
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"params": map[string]any{
|
||||||
|
"object": "app_api",
|
||||||
|
"object_id": "app_api_id",
|
||||||
|
"subject_type": "app_api_ex_app",
|
||||||
|
"subject_params": subjectParams,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
_, err := ocs.Call(http.MethodPost, "apps/app_api/api/v1/notification", body)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
package gonexapp_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.neitzel.de/konrad/go-nc-exapp"
|
||||||
|
)
|
||||||
|
|
||||||
|
func authUserFromRequest(r *http.Request) string {
|
||||||
|
raw := r.Header.Get("AUTHORIZATION-APP-API")
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(raw)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(string(decoded), ":", 2)
|
||||||
|
if len(parts) < 1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return parts[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppAPINotificationsSendPostsForCredentialsUser(t *testing.T) {
|
||||||
|
var (
|
||||||
|
gotMethod string
|
||||||
|
gotPath string
|
||||||
|
gotUser string
|
||||||
|
gotBody []byte
|
||||||
|
)
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotMethod = r.Method
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
gotUser = authUserFromRequest(r)
|
||||||
|
gotBody, _ = io.ReadAll(r.Body)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"ocs": map[string]any{"data": map[string]any{}}})
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
cred := gonexapp.Credentials{
|
||||||
|
BaseURL: srv.URL, AppID: "app", AppVersion: "0.1.0", AAVersion: "1.0.0", AppSecret: "s", UserID: "alice",
|
||||||
|
}
|
||||||
|
api := gonexapp.NewAppAPINotifications(cred)
|
||||||
|
api.Client = srv.Client()
|
||||||
|
api.OCS.Client = srv.Client()
|
||||||
|
|
||||||
|
err := api.Send(gonexapp.Notification{Subject: "Hello"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if gotMethod != http.MethodPost {
|
||||||
|
t.Fatalf("method=%q", gotMethod)
|
||||||
|
}
|
||||||
|
if !strings.Contains(gotPath, "apps/app_api/api/v1/notification") {
|
||||||
|
t.Fatalf("path=%q", gotPath)
|
||||||
|
}
|
||||||
|
if gotUser != "alice" {
|
||||||
|
t.Fatalf("auth user=%q", gotUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Params struct {
|
||||||
|
Object string `json:"object"`
|
||||||
|
ObjectID string `json:"object_id"`
|
||||||
|
SubjectType string `json:"subject_type"`
|
||||||
|
SubjectParams struct {
|
||||||
|
RichSubject string `json:"rich_subject"`
|
||||||
|
RichSubjectParams map[string]any `json:"rich_subject_params"`
|
||||||
|
} `json:"subject_params"`
|
||||||
|
} `json:"params"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(gotBody, &payload); err != nil {
|
||||||
|
t.Fatalf("body: %v\n%s", err, gotBody)
|
||||||
|
}
|
||||||
|
if payload.Params.Object != "app_api" {
|
||||||
|
t.Fatalf("object=%q", payload.Params.Object)
|
||||||
|
}
|
||||||
|
if payload.Params.ObjectID != "app_api_id" {
|
||||||
|
t.Fatalf("object_id=%q", payload.Params.ObjectID)
|
||||||
|
}
|
||||||
|
if payload.Params.SubjectType != "app_api_ex_app" {
|
||||||
|
t.Fatalf("subject_type=%q", payload.Params.SubjectType)
|
||||||
|
}
|
||||||
|
if payload.Params.SubjectParams.RichSubject != "Hello" {
|
||||||
|
t.Fatalf("rich_subject=%q", payload.Params.SubjectParams.RichSubject)
|
||||||
|
}
|
||||||
|
if payload.Params.SubjectParams.RichSubjectParams == nil {
|
||||||
|
t.Fatal("rich_subject_params is null")
|
||||||
|
}
|
||||||
|
if len(payload.Params.SubjectParams.RichSubjectParams) != 0 {
|
||||||
|
t.Fatalf("rich_subject_params=%#v", payload.Params.SubjectParams.RichSubjectParams)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppAPINotificationsSendToImpersonatesGivenUser(t *testing.T) {
|
||||||
|
var gotUser string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotUser = authUserFromRequest(r)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"ocs": map[string]any{"data": map[string]any{}}})
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
cred := gonexapp.Credentials{
|
||||||
|
BaseURL: srv.URL, AppID: "app", AppVersion: "0.1.0", AAVersion: "1.0.0", AppSecret: "s", UserID: "alice",
|
||||||
|
}
|
||||||
|
api := gonexapp.NewAppAPINotifications(cred)
|
||||||
|
api.Client = srv.Client()
|
||||||
|
api.OCS.Client = srv.Client()
|
||||||
|
|
||||||
|
err := api.SendTo("bob", gonexapp.Notification{Subject: "Hello"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if gotUser != "bob" {
|
||||||
|
t.Fatalf("auth user=%q, want bob", gotUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppAPINotificationsRejectsEmptySubject(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
called = true
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"ocs": map[string]any{"data": map[string]any{}}})
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
cred := gonexapp.Credentials{
|
||||||
|
BaseURL: srv.URL, AppID: "app", AppVersion: "0.1.0", AAVersion: "1.0.0", AppSecret: "s", UserID: "alice",
|
||||||
|
}
|
||||||
|
api := gonexapp.NewAppAPINotifications(cred)
|
||||||
|
api.Client = srv.Client()
|
||||||
|
api.OCS.Client = srv.Client()
|
||||||
|
|
||||||
|
if err := api.Send(gonexapp.Notification{}); err == nil {
|
||||||
|
t.Fatal("Send: expected error for empty Subject")
|
||||||
|
}
|
||||||
|
if err := api.SendTo("bob", gonexapp.Notification{Message: "no subject"}); err == nil {
|
||||||
|
t.Fatal("SendTo: expected error for empty Subject")
|
||||||
|
}
|
||||||
|
if called {
|
||||||
|
t.Fatal("OCS was called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppAPINotificationsSendRejectsEmptyUserID(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
called = true
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"ocs": map[string]any{"data": map[string]any{}}})
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
cred := gonexapp.Credentials{
|
||||||
|
BaseURL: srv.URL, AppID: "app", AppVersion: "0.1.0", AAVersion: "1.0.0", AppSecret: "s",
|
||||||
|
}
|
||||||
|
api := gonexapp.NewAppAPINotifications(cred)
|
||||||
|
api.Client = srv.Client()
|
||||||
|
api.OCS.Client = srv.Client()
|
||||||
|
|
||||||
|
if err := api.Send(gonexapp.Notification{Subject: "Hello"}); err == nil {
|
||||||
|
t.Fatal("expected error for empty UserID")
|
||||||
|
}
|
||||||
|
if called {
|
||||||
|
t.Fatal("OCS was called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppAPINotificationsSendToRejectsEmptyUserID(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
called = true
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"ocs": map[string]any{"data": map[string]any{}}})
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
cred := gonexapp.Credentials{
|
||||||
|
BaseURL: srv.URL, AppID: "app", AppVersion: "0.1.0", AAVersion: "1.0.0", AppSecret: "s", UserID: "alice",
|
||||||
|
}
|
||||||
|
api := gonexapp.NewAppAPINotifications(cred)
|
||||||
|
api.Client = srv.Client()
|
||||||
|
api.OCS.Client = srv.Client()
|
||||||
|
|
||||||
|
if err := api.SendTo("", gonexapp.Notification{Subject: "Hello"}); err == nil {
|
||||||
|
t.Fatal("expected error for empty userID")
|
||||||
|
}
|
||||||
|
if called {
|
||||||
|
t.Fatal("OCS was called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppAPINotificationsSendIncludesOptionalMessageAndLink(t *testing.T) {
|
||||||
|
var gotBody []byte
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotBody, _ = io.ReadAll(r.Body)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"ocs": map[string]any{"data": map[string]any{}}})
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
cred := gonexapp.Credentials{
|
||||||
|
BaseURL: srv.URL, AppID: "app", AppVersion: "0.1.0", AAVersion: "1.0.0", AppSecret: "s", UserID: "alice",
|
||||||
|
}
|
||||||
|
api := gonexapp.NewAppAPINotifications(cred)
|
||||||
|
api.Client = srv.Client()
|
||||||
|
api.OCS.Client = srv.Client()
|
||||||
|
|
||||||
|
err := api.Send(gonexapp.Notification{
|
||||||
|
Subject: "Hello",
|
||||||
|
Message: "Details here",
|
||||||
|
Link: "https://cloud.example/apps/checkdns",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Params struct {
|
||||||
|
SubjectParams struct {
|
||||||
|
RichMessage string `json:"rich_message"`
|
||||||
|
RichMessageParams map[string]any `json:"rich_message_params"`
|
||||||
|
Link string `json:"link"`
|
||||||
|
} `json:"subject_params"`
|
||||||
|
} `json:"params"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(gotBody, &payload); err != nil {
|
||||||
|
t.Fatalf("body: %v\n%s", err, gotBody)
|
||||||
|
}
|
||||||
|
if payload.Params.SubjectParams.RichMessage != "Details here" {
|
||||||
|
t.Fatalf("rich_message=%q", payload.Params.SubjectParams.RichMessage)
|
||||||
|
}
|
||||||
|
if payload.Params.SubjectParams.RichMessageParams == nil {
|
||||||
|
t.Fatal("rich_message_params is null")
|
||||||
|
}
|
||||||
|
if payload.Params.SubjectParams.Link != "https://cloud.example/apps/checkdns" {
|
||||||
|
t.Fatalf("link=%q", payload.Params.SubjectParams.Link)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppAPINotificationsSendSurfacesOCSStatus(t *testing.T) {
|
||||||
|
for _, code := range []int{http.StatusBadRequest, http.StatusForbidden} {
|
||||||
|
t.Run(http.StatusText(code), func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "nope", code)
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
cred := gonexapp.Credentials{
|
||||||
|
BaseURL: srv.URL, AppID: "app", AppVersion: "0.1.0", AAVersion: "1.0.0", AppSecret: "s", UserID: "alice",
|
||||||
|
}
|
||||||
|
api := gonexapp.NewAppAPINotifications(cred)
|
||||||
|
api.Client = srv.Client()
|
||||||
|
api.OCS.Client = srv.Client()
|
||||||
|
|
||||||
|
err := api.Send(gonexapp.Notification{Subject: "Hello"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
want := fmt.Sprintf("%d", code)
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Fatalf("error %q missing status %s", err, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,8 @@ func (p AppAPIPreferences) ocsClient() OCSClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get loads the configured preference key for the requesting user.
|
// Get loads the configured preference key for the requesting user.
|
||||||
|
// A missing key returns an empty string and a nil error.
|
||||||
|
// It returns an error when the OCS call fails or the body cannot be decoded.
|
||||||
func (p AppAPIPreferences) Get() (string, error) {
|
func (p AppAPIPreferences) Get() (string, error) {
|
||||||
ocs := p.ocsClient()
|
ocs := p.ocsClient()
|
||||||
body, _ := json.Marshal(map[string]any{"configKeys": []string{p.Key}})
|
body, _ := json.Marshal(map[string]any{"configKeys": []string{p.Key}})
|
||||||
@@ -49,6 +51,8 @@ func (p AppAPIPreferences) Get() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set stores value for the configured preference key.
|
// Set stores value for the configured preference key.
|
||||||
|
// The value is stored as non-sensitive.
|
||||||
|
// It returns an error when the OCS call fails.
|
||||||
func (p AppAPIPreferences) Set(value string) error {
|
func (p AppAPIPreferences) Set(value string) error {
|
||||||
ocs := p.ocsClient()
|
ocs := p.ocsClient()
|
||||||
body, _ := json.Marshal(map[string]any{
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package gonexapp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EnvRequiredGroups is the conventional deploy env name for Required Groups.
|
||||||
|
const EnvRequiredGroups = "REQUIRED_GROUPS"
|
||||||
|
|
||||||
|
// EnvRequiredGroupsCacheSeconds is the conventional deploy env name for Access Gate cache TTL.
|
||||||
|
const EnvRequiredGroupsCacheSeconds = "REQUIRED_GROUPS_CACHE_SECONDS"
|
||||||
|
|
||||||
|
// DefaultCacheSeconds is used when REQUIRED_GROUPS_CACHE_SECONDS is unset or invalid.
|
||||||
|
const DefaultCacheSeconds = 60
|
||||||
|
|
||||||
|
// ParseRequiredGroups splits a comma-separated Required Groups env value.
|
||||||
|
func ParseRequiredGroups(s string) []string {
|
||||||
|
parts := strings.Split(s, ",")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveRequiredGroups applies env override rules: unset uses codeDefault;
|
||||||
|
// set (including empty) replaces the default.
|
||||||
|
func ResolveRequiredGroups(envValue string, envSet bool, codeDefault []string) []string {
|
||||||
|
if !envSet {
|
||||||
|
return append([]string(nil), codeDefault...)
|
||||||
|
}
|
||||||
|
return ParseRequiredGroups(envValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseCacheSeconds parses REQUIRED_GROUPS_CACHE_SECONDS. Unset or invalid → defaultSec seconds; "0" → no cache.
|
||||||
|
func ParseCacheSeconds(s string, defaultSec int) time.Duration {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return time.Duration(defaultSec) * time.Second
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(s))
|
||||||
|
if err != nil || n < 0 {
|
||||||
|
return time.Duration(defaultSec) * time.Second
|
||||||
|
}
|
||||||
|
return time.Duration(n) * time.Second
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user