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. // Phrase names the ExApp Phrase for Label. The English label stays in the // page until the script replaces it. Empty leaves the label as written. type Item struct { ID string Label string Icon string Phrase 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 // Cred reads the UI language for this page from Nextcloud. // The zero value leaves the page in English. // The caller does not pass a language. Cred Credentials // Phrases is the ExApp's set. The page script receives it. // Library chrome is not included. The zero value leaves the page in English. Phrases Phrases // MissingPhrase names the ExApp Phrase for MissingMessage. MissingPhrase string } // Handler serves the shell. The selected item is the SelectKey query parameter. // A missing item renders DefaultID, includes MissingMessage, and publishes the // corrected query on data-address so the page can replace the address. // Every parent starts expanded. Folding is client state for this document only. func (n AppNavigation) Handler() http.Handler { return http.HandlerFunc(n.serve) } func (n AppNavigation) selectKey() string { if n.SelectKey != "" { return n.SelectKey } return "item" } func (n AppNavigation) serve(w http.ResponseWriter, r *http.Request) { lang := n.Cred.UILanguage(r) r = rememberUILanguage(r, lang) 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, lang), Page: template.HTML(page), Missing: msg, Title: title, Theme: !n.DisableTheme, Dialog: n.Cred.DialogHTML(r), Lang: lang, OpenLabel: chromePhrase(lang, "nav.open", nil), CloseLabel: chromePhrase(lang, "nav.close", nil), PhraseScript: n.Phrases.Script(lang), PhraseFill: FillScript(), MissingPhrase: n.MissingPhrase, }) } 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 Lang string OpenLabel string CloseLabel string PhraseScript template.HTML PhraseFill template.HTML MissingPhrase string } type itemView struct { ID string Label string Icon string Href string Current bool HasChildren bool Children []itemView Fold string FoldPattern string Phrase string } func viewItems(r *http.Request, items []Item, selected string, href func(*http.Request, string) string, lang string) []itemView { out := make([]itemView, 0, len(items)) for _, it := range items { children := viewItems(r, it.Children, selected, href, lang) fold := "" pattern := "" if len(children) > 0 { fold = chromePhrase(lang, "nav.fold", map[string]string{"label": it.Label}) pattern = chromePhrase(lang, "nav.fold", nil) } 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, Fold: fold, FoldPattern: pattern, Phrase: it.Phrase, }) } return out } var shellTmpl = template.Must(template.New("shell").Parse(`