package web import ( "bytes" "html/template" "net/http" "net/url" "path" "strings" "sync" "github.com/yuin/goldmark" "github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/text" "github.com/yuin/goldmark/util" llmgw "gitea.mynodes.xyz/ilker/llmgw" ) // The docs portal: the manual the binary carries, the pages `llmgw docs` and // the console show, rendered as a website at /docs on the client port. // // Public by default, like the landing page and for the same reason: the // person who needs the client guide is the one who does not have an account // yet. The operator documents describe how llmgw is deployed in general, not // this deployment — no address, no model, no key — so publishing them tells a // stranger what the project's own repository already does. A site that would // rather not say even that sets landing.docs_sign_in. // // Only the embedded documents are rendered, never anything a visitor sent, and // in goldmark's safe mode all the same: raw HTML in a document is dropped // rather than trusted, so a page here can never carry a script for the // Content-Security-Policy to have to refuse. // docLink is one entry of the portal's sidebar. type docLink struct{ Key, Title string } // docSection is a second-level heading, offered under the open document. type docSection struct{ ID, Title string } // renderedDoc is a document turned into HTML once, when first asked for. type renderedDoc struct { docLink Sections []docSection Body template.HTML } // docsData is what the docs template is rendered with. type docsData struct { Version string // Doc is the open document; nil on the index. Doc *renderedDoc Docs []docLink // Clients is the client developer's guide, which the index leads with. Clients *docLink // Nav is the menu of whoever is signed in, and SignedIn whether anyone is. Nav Nav SignedIn bool // SignIn offers a sign-in link to someone who is not. SignIn bool } // manualOnce renders every document the first time the portal is opened: // the configuration reference alone is two thousand lines, and rendering it // again on every request would be work for nothing — the documents change // only when the binary does. var ( manualOnce sync.Once manualDocs []renderedDoc ) func manual() []renderedDoc { manualOnce.Do(func() { docs := llmgw.Manual() paths := map[string]string{} for _, d := range docs { paths[d.Path] = d.Key } for _, d := range docs { body, sections := renderDoc(d.Source(), d.Path, paths) manualDocs = append(manualDocs, renderedDoc{ docLink: docLink{Key: d.Key, Title: d.Title}, Sections: sections, Body: body, }) } }) return manualDocs } // renderDoc turns one embedded document into HTML. from is its path in the // embed, which its relative links are resolved against; paths are the paths // of every document the portal has, by key. func renderDoc(src, from string, paths map[string]string) (template.HTML, []docSection) { t := &docTransform{from: from, paths: paths} md := goldmark.New( goldmark.WithExtensions(extension.GFM), goldmark.WithParserOptions( // The same ids GitHub gives headings, which is what the documents' // own #links were written against. parser.WithAutoHeadingID(), parser.WithASTTransformers(util.Prioritized(t, 100))), ) var out bytes.Buffer if err := md.Convert([]byte(src), &out); err != nil { return template.HTML("

This document could not be rendered.

"), nil } // goldmark in its default, safe mode over documents compiled into the // binary, with links rewritten below: not something to escape again. return template.HTML(out.String()), t.sections //nolint:gosec } // docTransform rewrites a document's links for the portal, gives every // heading a link to itself, and collects the sections for the sidebar. type docTransform struct { from string paths map[string]string sections []docSection } func (t *docTransform) Transform(doc *ast.Document, reader text.Reader, _ parser.Context) { src := reader.Source() var headings []*ast.Heading _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { if !entering { return ast.WalkContinue, nil } switch n := n.(type) { case *ast.Link: n.Destination = []byte(t.rewrite(string(n.Destination))) case *ast.Heading: headings = append(headings, n) } return ast.WalkContinue, nil }) // After the walk, so the links added here are not themselves rewritten. for _, h := range headings { id, ok := h.AttributeString("id") if !ok { continue } idStr := string(id.([]byte)) if h.Level == 2 { t.sections = append(t.sections, docSection{ID: idStr, Title: plainText(h, src)}) } a := ast.NewLink() a.Destination = []byte("#" + idStr) a.Title = []byte("Link to this section") a.SetAttributeString("class", []byte("anchor")) a.AppendChild(a, ast.NewString([]byte("#"))) h.AppendChild(h, a) } } // rewrite points a link at where its target is served from. // // A link to another document becomes its page under /docs, keeping the // section it names. A link to anything else in the repository — deploy/, // the backlog, the design plan — is not in the binary, so it goes to the // repository itself rather than to a 404 here. Absolute links and links // within the page are left alone. func (t *docTransform) rewrite(dest string) string { u, err := url.Parse(dest) if err != nil || u.Scheme != "" || u.Host != "" || u.Path == "" || strings.HasPrefix(u.Path, "/") { return dest } target := path.Clean(path.Join(path.Dir(t.from), u.Path)) frag := "" if u.Fragment != "" { frag = "#" + u.EscapedFragment() } if key, ok := t.paths[target]; ok { return "/docs/" + key + frag } if strings.HasPrefix(target, "../") { return dest // outside the repository: nothing better to point it at } if strings.HasSuffix(u.Path, "/") { target += "/" } return repoURL + "/src/branch/main/" + target + frag } // plainText is a heading's words without their markup: "`name`" is "name". func plainText(n ast.Node, src []byte) string { var b strings.Builder _ = ast.Walk(n, func(c ast.Node, entering bool) (ast.WalkStatus, error) { if !entering { return ast.WalkContinue, nil } switch c := c.(type) { case *ast.Text: b.Write(c.Segment.Value(src)) if c.SoftLineBreak() { b.WriteByte(' ') } case *ast.String: b.Write(c.Value) } return ast.WalkContinue, nil }) return b.String() } // Docs serves the portal: the index at /docs and a document at // /docs/{doc}. signIn is whether this gateway has accounts to sign in to, // which decides whether the page offers it. func Docs(version string, signIn bool) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { docs := manual() d := docsData{Version: version, SignIn: signIn} if n, ok := r.Context().Value(navKey{}).(Nav); ok { n.Current = "docs" d.Nav, d.SignedIn = n, true } for i := range docs { d.Docs = append(d.Docs, docs[i].docLink) if docs[i].Key == "clients" { d.Clients = &docs[i].docLink } } key := strings.TrimSuffix(strings.ToLower(r.PathValue("doc")), ".md") if key == "" { render(w, r, "docs", d) return } for i := range docs { if docs[i].Key == key { d.Doc = &docs[i] render(w, r, "docs", d) return } } http.NotFound(w, r) } } // DocsPage serves the docs portal with the menu of whoever is signed in and, // when required is set, to nobody else: someone not signed in is sent to sign // in, as every other page that needs a session does. func (p *AccountPages) DocsPage(docs http.HandlerFunc, required bool) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { _, user, ok := p.session(r) if !ok { if required { noStore(w) http.Redirect(w, r, "/login", http.StatusSeeOther) return } docs(w, r) return } // The page carries this person's menu, so no cache may keep it for // the next. noStore(w) docs(w, p.withNav(r, user)) } }