package main import ( "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "html" "html/template" "io" "log" "net/http" "net/url" "os" "path" "path/filepath" "regexp" "sort" "strings" ) // JSON Canvas (https://jsoncanvas.org/spec/1.0/) support. // // A canvas is a `.canvas` file sitting in a page folder, addressed as an // alternative *view* of that page rather than as a standalone file: // // /Topics/Ideas/?canvas=Architecture the canvas view // /Topics/Ideas/Architecture.canvas 302 -> the line above // /Topics/Ideas/Architecture.canvas?raw the file bytes // // Routing through the folder keeps the page's identity (breadcrumb, tree, // header actions) and lets every canvas in a folder render as a tab strip above // the page content. The redirect mirrors how diary.go collapses its virtual // month/day URLs onto one canonical form. // // The server owns the format: it parses and validates on every read and write, // re-marshals from its own structs on save, and never writes bytes it has not // round-tripped through canvasDoc. Keys it does not model are dropped rather // than preserved — this wiki is the only writer. const canvasExt = ".canvas" // canvasMaxBytes caps a save payload. A canvas is coordinates and prose; a // megabyte is already far past any hand-built diagram and well short of // anything that could pressure the NAS. const canvasMaxBytes = 4 << 20 // canvasRenderMaxBytes caps a /_canvas/render body. Only one node's content // travels at a time. const canvasRenderMaxBytes = 1 << 20 // Structural caps. These exist so a looping client cannot grow a file until it // stops loading, not because the format has limits. const ( canvasMaxNodes = 5000 canvasMaxEdges = 10000 canvasMaxCoord = 1 << 20 ) // canvasNameRe restricts a ?canvas= value to a single safe path component: the // name is joined onto the folder path, so separators, dot-segments, control // characters, and leading dots (which would create a hidden file the listing // filters away) must all be rejected before the filesystem sees it. var canvasNameRe = regexp.MustCompile(`^[^./\\\x00-\x1f][^/\\\x00-\x1f]{0,99}$`) // canvasHexRe matches the hex form of a canvasColor. The spec also allows the // preset digits "1".."6"; see validCanvasColor. var canvasHexRe = regexp.MustCompile(`^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`) // Node types defined by the spec. const ( canvasNodeText = "text" canvasNodeFile = "file" canvasNodeLink = "link" canvasNodeGroup = "group" ) // canvasDoc is a whole `.canvas` file. Both arrays are optional per the spec, // so an empty document marshals to `{}`. type canvasDoc struct { Nodes []canvasNode `json:"nodes,omitempty"` Edges []canvasEdge `json:"edges,omitempty"` } // canvasNode carries every node variant in one struct. The spec's four types // share six required attributes and add one to three of their own, so a flat // struct with omitempty round-trips all of them without the ceremony of a // custom unmarshaller; validate() enforces which extras a given Type may use. type canvasNode struct { ID string `json:"id"` Type string `json:"type"` X int `json:"x"` Y int `json:"y"` Width int `json:"width"` Height int `json:"height"` Color string `json:"color,omitempty"` Text string `json:"text,omitempty"` // type=text File string `json:"file,omitempty"` // type=file Subpath string `json:"subpath,omitempty"` // type=file URL string `json:"url,omitempty"` // type=link Label string `json:"label,omitempty"` // type=group Background string `json:"background,omitempty"` // type=group BackgroundStyle string `json:"backgroundStyle,omitempty"` // type=group } type canvasEdge struct { ID string `json:"id"` FromNode string `json:"fromNode"` FromSide string `json:"fromSide,omitempty"` FromEnd string `json:"fromEnd,omitempty"` // defaults to "none" ToNode string `json:"toNode"` ToSide string `json:"toSide,omitempty"` ToEnd string `json:"toEnd,omitempty"` // defaults to "arrow" Color string `json:"color,omitempty"` Label string `json:"label,omitempty"` } // canvasRef is one entry in the page's view switcher. type canvasRef struct { Name string URL string Active bool } // canvasPayload is the single blob inlined into the canvas page. Shipping the // document *and* its server-rendered node HTML together means opening a canvas // costs exactly one request no matter how many nodes it holds — the obvious // alternative (one content request per node on load) fans out badly over the // mobile/VPN path. type canvasPayload struct { Name string `json:"name"` PostURL string `json:"postUrl"` Hash string `json:"hash"` Doc *canvasDoc `json:"doc"` Rendered map[string]template.HTML `json:"rendered"` } // --- parsing and validation --- // parseCanvas unmarshals and validates raw canvas bytes. Unknown keys are // ignored rather than rejected so a hand-edited file with a stray attribute // still opens; they are dropped on the next save. func parseCanvas(raw []byte) (*canvasDoc, error) { doc := &canvasDoc{} if len(strings.TrimSpace(string(raw))) == 0 { return doc, nil } if err := json.Unmarshal(raw, doc); err != nil { return nil, fmt.Errorf("invalid JSON: %w", err) } if err := doc.validate(); err != nil { return nil, err } return doc, nil } // validate rejects anything that would render as a broken or invisible canvas. // It runs on read as well as write: a file edited by hand outside the app gets // the same diagnosis the editor would give, naming the offending node. func (c *canvasDoc) validate() error { if len(c.Nodes) > canvasMaxNodes { return fmt.Errorf("too many nodes (%d, max %d)", len(c.Nodes), canvasMaxNodes) } if len(c.Edges) > canvasMaxEdges { return fmt.Errorf("too many edges (%d, max %d)", len(c.Edges), canvasMaxEdges) } ids := make(map[string]bool, len(c.Nodes)) for i := range c.Nodes { n := &c.Nodes[i] if n.ID == "" { return fmt.Errorf("node %d: missing id", i) } if ids[n.ID] { return fmt.Errorf("duplicate node id %q", n.ID) } ids[n.ID] = true switch n.Type { case canvasNodeText: // An empty text node is a node the user just created and has not // typed into yet, so emptiness is allowed here even though the spec // calls `text` required. A missing *file* or *url*, by contrast, is // a node that can never render anything. case canvasNodeFile: if n.File == "" { return fmt.Errorf("node %q: file node has no file", n.ID) } if n.Subpath != "" && !strings.HasPrefix(n.Subpath, "#") { return fmt.Errorf("node %q: subpath must start with #", n.ID) } case canvasNodeLink: if n.URL == "" { return fmt.Errorf("node %q: link node has no url", n.ID) } case canvasNodeGroup: if n.BackgroundStyle != "" { switch n.BackgroundStyle { case "cover", "ratio", "repeat": default: return fmt.Errorf("node %q: bad backgroundStyle %q", n.ID, n.BackgroundStyle) } } case "": return fmt.Errorf("node %q: missing type", n.ID) default: return fmt.Errorf("node %q: unknown type %q", n.ID, n.Type) } if n.Width <= 0 || n.Height <= 0 { return fmt.Errorf("node %q: width and height must be positive", n.ID) } if n.Width > canvasMaxCoord || n.Height > canvasMaxCoord || abs(n.X) > canvasMaxCoord || abs(n.Y) > canvasMaxCoord { return fmt.Errorf("node %q: coordinates out of range", n.ID) } if !validCanvasColor(n.Color) { return fmt.Errorf("node %q: bad color %q", n.ID, n.Color) } } edgeIDs := make(map[string]bool, len(c.Edges)) for i := range c.Edges { e := &c.Edges[i] if e.ID == "" { return fmt.Errorf("edge %d: missing id", i) } if edgeIDs[e.ID] { return fmt.Errorf("duplicate edge id %q", e.ID) } edgeIDs[e.ID] = true if !ids[e.FromNode] { return fmt.Errorf("edge %q: fromNode %q does not exist", e.ID, e.FromNode) } if !ids[e.ToNode] { return fmt.Errorf("edge %q: toNode %q does not exist", e.ID, e.ToNode) } if !validCanvasSide(e.FromSide) { return fmt.Errorf("edge %q: bad fromSide %q", e.ID, e.FromSide) } if !validCanvasSide(e.ToSide) { return fmt.Errorf("edge %q: bad toSide %q", e.ID, e.ToSide) } if !validCanvasEnd(e.FromEnd) { return fmt.Errorf("edge %q: bad fromEnd %q", e.ID, e.FromEnd) } if !validCanvasEnd(e.ToEnd) { return fmt.Errorf("edge %q: bad toEnd %q", e.ID, e.ToEnd) } if !validCanvasColor(e.Color) { return fmt.Errorf("edge %q: bad color %q", e.ID, e.Color) } } return nil } func abs(n int) int { if n < 0 { return -n } return n } // validCanvasColor accepts an absent color, a preset digit "1".."6" (red, // orange, yellow, green, cyan, purple), or a hex string. func validCanvasColor(c string) bool { switch c { case "", "1", "2", "3", "4", "5", "6": return true } return canvasHexRe.MatchString(c) } func validCanvasSide(s string) bool { switch s { case "", "top", "right", "bottom", "left": return true } return false } func validCanvasEnd(e string) bool { switch e { case "", "none", "arrow": return true } return false } // canvasHash is the conflict token exchanged with the client. It covers the // exact bytes on disk, so any edit made outside this editor invalidates it. func canvasHash(raw []byte) string { sum := sha256.Sum256(raw) return hex.EncodeToString(sum[:])[:16] } // --- paths and discovery --- func canvasPath(fsPath, name string) string { return filepath.Join(fsPath, name+canvasExt) } // canvasBackupPath is the previous-contents copy kept beside a canvas. The dot // prefix keeps it out of the file listing and the search index. func canvasBackupPath(fsPath, name string) string { return filepath.Join(fsPath, "."+name+canvasExt+".bak") } // canvasURL builds the canonical view URL for a canvas in the folder at urlPath. func canvasURL(urlPath, name string) string { if !strings.HasSuffix(urlPath, "/") { urlPath += "/" } return urlPath + "?canvas=" + url.QueryEscape(name) } // canvasViewURL maps a direct `/A/B/Name.canvas` request onto the canonical // `/A/B/?canvas=Name` form. Segments are re-encoded because urlPath arrives // already percent-decoded. func canvasViewURL(urlPath string) (string, bool) { base := path.Base(urlPath) name := strings.TrimSuffix(base, canvasExt) if name == "" || name == base || !canvasNameRe.MatchString(name) { return "", false } return canvasURL(fileURL(strings.Trim(path.Dir(urlPath), "/")), name), true } // listCanvases returns the canvases in a folder as view-switcher tabs, sorted // case-insensitively by name. active marks the one currently being viewed. func listCanvases(fsPath, urlPath, active string) []canvasRef { entries, err := os.ReadDir(fsPath) if err != nil { return nil } var refs []canvasRef for _, e := range entries { name := e.Name() if e.IsDir() || strings.HasPrefix(name, ".") || !strings.HasSuffix(name, canvasExt) { continue } base := strings.TrimSuffix(name, canvasExt) if !canvasNameRe.MatchString(base) { continue } refs = append(refs, canvasRef{ Name: base, URL: canvasURL(urlPath, base), Active: base == active, }) } sort.Slice(refs, func(i, j int) bool { return strings.ToLower(refs[i].Name) < strings.ToLower(refs[j].Name) }) return refs } // isCanvasFile reports whether a listing entry is a canvas. Used to keep // canvases out of the Files listing — they are surfaced as tabs above the // content instead, the same reasoning that hides index.md. func isCanvasFile(name string) bool { return strings.HasSuffix(name, canvasExt) } // --- server-side node rendering --- // prerenderNodes renders every node whose content the server owns, keyed by // node id. Link and group nodes are built entirely in the browser and are // absent from the map. func (h *handler) prerenderNodes(c *canvasDoc) map[string]template.HTML { out := make(map[string]template.HTML, len(c.Nodes)) for i := range c.Nodes { n := &c.Nodes[i] switch n.Type { case canvasNodeText: out[n.ID] = renderMarkdown([]byte(n.Text)) case canvasNodeFile: out[n.ID] = h.renderFileNode(n.File, n.Subpath) } } return out } // renderFileNode renders a file node's transclusion. Markdown is rendered // through the shared goldmark instance so wikilinks, embeds, tables, and task // checkboxes behave exactly as they do on a page; media gets a player or an // pointed at the existing thumbnail endpoint; anything else degrades to // a link. A missing target renders visibly broken rather than blank. func (h *handler) renderFileNode(file, subpath string) template.HTML { target := "/" + strings.Trim(file, "/") fsPath := filepath.Join(h.root, filepath.FromSlash(strings.TrimPrefix(target, "/"))) if rel, err := filepath.Rel(h.root, fsPath); err != nil || strings.HasPrefix(rel, "..") { return canvasBrokenRef(file, "outside the wiki") } info, err := os.Stat(fsPath) if err != nil { return canvasBrokenRef(file, "not found") } if info.IsDir() { // A folder is a page; transclude its markdown. fsPath = filepath.Join(fsPath, "index.md") if _, err := os.Stat(fsPath); err != nil { return canvasBrokenRef(file, "page has no content") } return h.renderMarkdownFile(fsPath, file, subpath) } name := path.Base(target) href := html.EscapeString(fileURL(strings.TrimPrefix(target, "/"))) switch { case isImageFile(name): return template.HTML(``) case isVideoFile(name): return template.HTML(``) case strings.EqualFold(path.Ext(name), ".pdf"): return template.HTML(``) case strings.EqualFold(path.Ext(name), ".md"): return h.renderMarkdownFile(fsPath, file, subpath) default: return template.HTML(`

` + html.EscapeString(name) + `

`) } } // renderMarkdownFile renders a markdown file, optionally narrowed to the // section named by subpath (`#Heading`). The narrowing reuses the same section // machinery the page editor uses, so a heading spans its subsections. func (h *handler) renderMarkdownFile(fsPath, file, subpath string) template.HTML { raw, err := os.ReadFile(fsPath) if err != nil { return canvasBrokenRef(file, "unreadable") } if subpath != "" { section, ok := canvasSubpathSection(raw, subpath) if !ok { return canvasBrokenRef(file+subpath, "no such heading") } raw = section } return renderMarkdown(raw) } // canvasSubpathSection narrows raw markdown to the section whose heading // matches subpath. The match accepts either the heading text (what a human // types) or goldmark's generated anchor id (what a copied link contains). func canvasSubpathSection(raw []byte, subpath string) ([]byte, bool) { want := strings.TrimSpace(strings.TrimPrefix(subpath, "#")) if want == "" { return raw, true } sections := splitSections(raw) ids := headingIDs(raw) for i := 1; i < len(sections); i++ { _, text := sectionHeading(sections[i]) id := "" if i-1 < len(ids) { id = ids[i-1] } if strings.EqualFold(text, want) || id == want { return joinSections(sections[i:secionSpanEnd(sections, i)]), true } } return nil, false } func canvasBrokenRef(target, reason string) template.HTML { return template.HTML(`

` + html.EscapeString(target) + ` (` + html.EscapeString(reason) + `)

`) } // --- request handlers --- // serveCanvas renders the canvas view of a page. Reached from serveDir when // the request carries ?canvas=. func (h *handler) serveCanvas(w http.ResponseWriter, r *http.Request, urlPath, fsPath, name string) { if !canvasNameRe.MatchString(name) { http.Error(w, "bad canvas name", http.StatusBadRequest) return } raw, err := os.ReadFile(canvasPath(fsPath, name)) if err != nil { if os.IsNotExist(err) { http.NotFound(w, r) return } http.Error(w, "read failed: "+err.Error(), http.StatusInternalServerError) return } data := pageData{ Title: name + " — " + pageTitle(urlPath), CanEdit: true, IsRoot: urlPath == "/", SectionIndex: -1, InsertBefore: -1, PostURL: urlPath, ActiveCanvas: name, Canvases: listCanvases(fsPath, urlPath, name), } // A parse failure is reported in-page rather than as a bare error: the file // is hand-editable, so the user needs to see which node is wrong and still // reach the raw bytes. doc, parseErr := parseCanvas(raw) if parseErr != nil { data.CanvasError = parseErr.Error() } else { payload := canvasPayload{ Name: name, PostURL: urlPath, Hash: canvasHash(raw), Doc: doc, Rendered: h.prerenderNodes(doc), } // encoding/json escapes <, >, & and the U+2028/U+2029 line separators, // so the result is safe to inline verbatim in a