package main import ( "bytes" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "html/template" "io" "log" "math" "net/http" "net/url" "os" "path" "path/filepath" "regexp" "strconv" "strings" "sync" ) // Canvases are JSON Canvas 1.0 files (https://jsoncanvas.org) stored as // .canvas inside a page's folder, so a page can hold any number of them. // // GET /Page/Board.canvas the canvas view (canvas/main.html) // GET /Page/Board.canvas?raw the file itself // POST /Page/Board.canvas?save the whole document (autosave, canvas.js) // POST /Page/Board.canvas?render one node → its rendered element // POST /Page/?canvas create a canvas (dialog "canvas") // // The canvas URL sits next to the page, so relative links and embeds in text // nodes resolve against the page folder exactly like they do in index.md. // // The server only reads the fields it renders. Saves write the client's JSON // back as sent (reindented), so fields this code does not know survive. const canvasExt = ".canvas" var canvasTmpl = template.Must(template.New("canvas").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/editor/toolbar.html", "assets/canvas/main.html", "assets/canvas/node.html")) func isCanvasFile(name string) bool { return strings.EqualFold(path.Ext(name), canvasExt) } type canvasNode struct { ID string `json:"id"` Type string `json:"type"` X float64 `json:"x"` Y float64 `json:"y"` Width float64 `json:"width"` Height float64 `json:"height"` Color string `json:"color,omitempty"` Text string `json:"text,omitempty"` File string `json:"file,omitempty"` Subpath string `json:"subpath,omitempty"` URL string `json:"url,omitempty"` Label string `json:"label,omitempty"` Background string `json:"background,omitempty"` BackgroundStyle string `json:"backgroundStyle,omitempty"` } type canvasEdge struct { ID string `json:"id"` FromNode string `json:"fromNode"` FromSide string `json:"fromSide,omitempty"` FromEnd string `json:"fromEnd,omitempty"` ToNode string `json:"toNode"` ToSide string `json:"toSide,omitempty"` ToEnd string `json:"toEnd,omitempty"` Color string `json:"color,omitempty"` Label string `json:"label,omitempty"` } type canvasDoc struct { Nodes []canvasNode `json:"nodes"` Edges []canvasEdge `json:"edges"` } // emptyCanvas is what a new canvas file holds, and what an empty file reads as. var emptyCanvas = []byte("{\n\t\"nodes\": [],\n\t\"edges\": []\n}\n") // parseCanvas decodes and validates a canvas document. An empty file is an // empty canvas. func parseCanvas(raw []byte) (canvasDoc, error) { var doc canvasDoc if len(bytes.TrimSpace(raw)) == 0 { return doc, nil } // The top level must be an object; Unmarshal into a struct would accept // `null` silently. var top map[string]json.RawMessage if err := json.Unmarshal(raw, &top); err != nil { return doc, fmt.Errorf("not a JSON Canvas object: %v", err) } if err := json.Unmarshal(raw, &doc); err != nil { return doc, fmt.Errorf("invalid canvas: %v", err) } return doc, validateCanvas(doc) } var canvasHexColorRe = regexp.MustCompile(`^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`) // canvasColor splits a canvas color into a preset ("1"–"6") or a hex value. // Anything else is no color. func canvasColor(c string) (preset string, hex template.CSS) { if len(c) == 1 && c[0] >= '1' && c[0] <= '6' { return c, "" } if canvasHexColorRe.MatchString(c) { return "", template.CSS(c) } return "", "" } func validCanvasColor(c string) bool { p, h := canvasColor(c) return c == "" || p != "" || h != "" } func validCanvasSide(s string) bool { switch s { case "", "top", "right", "bottom", "left": return true } return false } func validCanvasEnd(s string) bool { return s == "" || s == "none" || s == "arrow" } // validateCanvas checks what the renderer and editor rely on: unique ids, // known node types with their required field, finite geometry, and edges // between existing nodes. func validateCanvas(doc canvasDoc) error { ids := map[string]bool{} for i, n := range doc.Nodes { if n.ID == "" { return fmt.Errorf("node %d has no id", i) } if ids[n.ID] { return fmt.Errorf("duplicate id %q", n.ID) } ids[n.ID] = true for _, v := range []float64{n.X, n.Y, n.Width, n.Height} { if math.IsNaN(v) || math.IsInf(v, 0) { return fmt.Errorf("node %q has invalid geometry", n.ID) } } switch n.Type { case "text", "group": case "file": if n.File == "" { return fmt.Errorf("file node %q has no file", n.ID) } case "link": if n.URL == "" { return fmt.Errorf("link node %q has no url", n.ID) } default: return fmt.Errorf("node %q has unknown type %q", n.ID, n.Type) } if !validCanvasColor(n.Color) { return fmt.Errorf("node %q has invalid color %q", n.ID, n.Color) } } edgeIDs := map[string]bool{} for i, e := range doc.Edges { if e.ID == "" { return fmt.Errorf("edge %d has no id", i) } if edgeIDs[e.ID] { return fmt.Errorf("duplicate edge id %q", e.ID) } edgeIDs[e.ID] = true if !ids[e.FromNode] || !ids[e.ToNode] { return fmt.Errorf("edge %q connects a missing node", e.ID) } if !validCanvasSide(e.FromSide) || !validCanvasSide(e.ToSide) { return fmt.Errorf("edge %q has an invalid side", e.ID) } if !validCanvasEnd(e.FromEnd) || !validCanvasEnd(e.ToEnd) { return fmt.Errorf("edge %q has an invalid end", e.ID) } if !validCanvasColor(e.Color) { return fmt.Errorf("edge %q has invalid color %q", e.ID, e.Color) } } return nil } // canvasVersion identifies one exact file content. Saves carry the version // they were based on; a mismatch means someone else changed the file. func canvasVersion(raw []byte) string { sum := sha256.Sum256(raw) return hex.EncodeToString(sum[:8]) } // canvasMu serializes the version check and write of canvas saves. var canvasMu sync.Mutex // === Rendering === // canvasNodeView is one node element (canvas/node.html). Geometry is in // canvas units; the client positions the world, not the nodes. type canvasNodeView struct { ID, Type string X, Y, W, H int Preset string Hex template.CSS Label string LabelURL string Markdown bool // body is rendered markdown (.content styling) Body template.HTML } // canvasBody* feed the node body templates in canvas/node.html. type canvasMediaBody struct { URL, Thumb, Kind string } type canvasCardBody struct { Icon template.HTML Name, URL string Host string Label string // a link's display text, shown instead of its URL } type canvasGroupBody struct { URL string Style string } func (h *handler) renderCanvasNode(n canvasNode) canvasNodeView { v := canvasNodeView{ ID: n.ID, Type: n.Type, X: int(math.Round(n.X)), Y: int(math.Round(n.Y)), W: int(math.Round(n.Width)), H: int(math.Round(n.Height)), } v.Preset, v.Hex = canvasColor(n.Color) switch n.Type { case "text": v.Markdown = true v.Body = template.HTML(canvasTaskCheckboxes(convertMarkdown([]byte(n.Text), ""))) case "file": h.renderCanvasFile(&v, n) case "link": host := n.URL if u, err := url.Parse(n.URL); err == nil && u.Host != "" { host = u.Host } v.Body = execNodeTemplate("canvas-link", canvasCardBody{Name: n.URL, URL: n.URL, Host: host, Label: strings.TrimSpace(n.Label)}) case "group": v.Label = n.Label if n.Background != "" { if rel, _, ok := h.canvasFilePath(n.Background); ok { style := n.BackgroundStyle if style != "ratio" && style != "repeat" { style = "cover" } v.Body = execNodeTemplate("canvas-group-bg", canvasGroupBody{URL: fileURL(rel), Style: style}) } } } return v } // canvasFilePath resolves a file node's path. Paths are relative to the wiki // root (the spec's "vault"); a leading slash is accepted. Dot segments are // refused like everywhere else. func (h *handler) canvasFilePath(p string) (rel, fsPath string, ok bool) { rel = strings.Trim(path.Clean("/"+strings.ReplaceAll(p, "\\", "/")), "/") if hasDotSegment(rel) { return "", "", false } return rel, filepath.Join(h.root, filepath.FromSlash(rel)), true } func (h *handler) renderCanvasFile(v *canvasNodeView, n canvasNode) { rel, fsPath, ok := h.canvasFilePath(n.File) v.Label = path.Base("/" + rel) info, err := os.Stat(fsPath) if !ok || err != nil { v.Body = execNodeTemplate("canvas-missing", n.File) return } if info.IsDir() { // A folder is a page: show its index.md. v.LabelURL = pageURL(rel) raw, _ := os.ReadFile(filepath.Join(fsPath, "index.md")) if heading := extractFirstHeading(raw); heading != "" { v.Label = heading } v.Markdown = true v.Body = template.HTML(convertMarkdown(canvasSubpath(raw, n.Subpath), dirBase(rel))) return } u := fileURL(rel) v.LabelURL = u name := info.Name() ext := strings.ToLower(path.Ext(name)) switch { case ext == ".md": raw, _ := os.ReadFile(fsPath) v.Markdown = true v.Body = template.HTML(convertMarkdown(canvasSubpath(raw, n.Subpath), dirBase(path.Dir("/"+rel)))) case isImageFile(name) || ext == ".svg" || ext == ".webp": thumb := u if hasThumbnail(name) { thumb = u + "?w=800" } v.Body = execNodeTemplate("canvas-media", canvasMediaBody{URL: u, Thumb: thumb, Kind: "image"}) case isVideoFile(name): v.Body = execNodeTemplate("canvas-media", canvasMediaBody{URL: u, Kind: "video"}) case ext == ".mp3" || ext == ".flac" || ext == ".ogg" || ext == ".wav": v.Body = execNodeTemplate("canvas-media", canvasMediaBody{URL: u, Kind: "audio"}) default: v.Body = execNodeTemplate("canvas-file", canvasCardBody{Icon: fileIcon(name), Name: name, URL: u}) } } // dirBase is the base URL path for markdown that lives in the folder rel // (wiki-relative, "" or "/" for the root). func dirBase(rel string) string { rel = strings.Trim(rel, "/") if rel == "" { return "/" } return "/" + rel + "/" } // canvasSubpath narrows markdown to a file node's "#Heading" subpath: that // heading's section including its subsections. Unknown subpaths (or block // references, "#^id") show the whole file. func canvasSubpath(raw []byte, subpath string) []byte { want := strings.TrimSpace(strings.TrimPrefix(subpath, "#")) if want == "" || strings.HasPrefix(want, "^") { return raw } sections := splitSections(raw) for i, s := range sections { if _, text := sectionHeading(s); i > 0 && strings.EqualFold(text, want) { return joinSections(sections[i:secionSpanEnd(sections, i)]) } } return raw } // canvasTaskCheckboxes makes a text node's task checkboxes clickable. The // canvas editor (canvas.js) flips the Nth task line of the node's text and // saves the whole canvas, so they carry only their index. func canvasTaskCheckboxes(in []byte) []byte { idx := 0 return taskCheckboxRe.ReplaceAllFunc(in, func(match []byte) []byte { out := `") }) } func execNodeTemplate(name string, data any) template.HTML { var buf bytes.Buffer if err := canvasTmpl.ExecuteTemplate(&buf, name, data); err != nil { log.Printf("canvas template %s: %v", name, err) } return template.HTML(buf.String()) } // === Handlers === type canvasPageData struct { Title string EditMode bool CanEdit bool IsRoot bool PageURL string // the page the canvas belongs to RawURL string Name string Nodes []canvasNodeView Doc json.RawMessage Version string Error string renderTimer } // serveCanvas handles every request to an existing .canvas file. func (h *handler) serveCanvas(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) { q := r.URL.Query() switch { case r.Method == http.MethodGet && q.Has("raw"): w.Header().Set("Content-Type", "application/json; charset=utf-8") http.ServeFile(w, r, fsPath) case r.Method == http.MethodGet: h.serveCanvasPage(w, r, urlPath, fsPath) case r.Method == http.MethodPost && q.Has("save"): h.handleCanvasSave(w, r, fsPath) case r.Method == http.MethodPost && q.Has("render"): h.handleCanvasRender(w, r) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func (h *handler) serveCanvasPage(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) { raw, err := os.ReadFile(fsPath) if err != nil { http.Error(w, "read failed: "+err.Error(), http.StatusInternalServerError) return } name := path.Base(urlPath) data := canvasPageData{ Title: strings.TrimSuffix(name, path.Ext(name)), CanEdit: true, PageURL: pageURL(path.Dir(urlPath)), RawURL: fileURL(strings.TrimPrefix(urlPath, "/")) + "?raw", Name: name, Version: canvasVersion(raw), Doc: json.RawMessage(emptyCanvas), } doc, err := parseCanvas(raw) if err != nil { data.Error = err.Error() } else { if len(bytes.TrimSpace(raw)) > 0 { data.Doc = json.RawMessage(raw) } for _, n := range doc.Nodes { data.Nodes = append(data.Nodes, h.renderCanvasNode(n)) } } data.renderTimer = renderTimer{requestStart(r)} w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := canvasTmpl.ExecuteTemplate(w, "layout", data); err != nil { log.Printf("canvas template error: %v", err) } } // maxCanvasBytes caps a saved canvas; text nodes hold markdown, not files. const maxCanvasBytes = 10 << 20 // handleCanvasSave replaces the canvas with the posted document. The client // sends the version it last loaded or saved (X-Canvas-Version); when the file // has changed since, the save is refused with 409 so nothing is overwritten. func (h *handler) handleCanvasSave(w http.ResponseWriter, r *http.Request, fsPath string) { body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxCanvasBytes)) if err != nil { http.Error(w, "canvas too large", http.StatusRequestEntityTooLarge) return } if len(bytes.TrimSpace(body)) == 0 { http.Error(w, "refusing to save an empty canvas body", http.StatusBadRequest) return } if _, err := parseCanvas(body); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } var out bytes.Buffer if err := json.Indent(&out, body, "", "\t"); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } out.WriteByte('\n') canvasMu.Lock() defer canvasMu.Unlock() current, err := os.ReadFile(fsPath) if err != nil { if errors.Is(err, os.ErrNotExist) { http.Error(w, "this canvas no longer exists", http.StatusNotFound) return } http.Error(w, "read failed: "+err.Error(), http.StatusInternalServerError) return } if r.Header.Get("X-Canvas-Version") != canvasVersion(current) { http.Error(w, "the canvas changed since it was opened — reload to see the current version", http.StatusConflict) return } if err := writeFileAtomic(fsPath, out.Bytes(), 0644); err != nil { http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError) return } w.Header().Set("X-Canvas-Version", canvasVersion(out.Bytes())) w.WriteHeader(http.StatusNoContent) } // handleCanvasRender renders one posted node as its element, so the editor // shows nodes exactly as the server renders them on load. func (h *handler) handleCanvasRender(w http.ResponseWriter, r *http.Request) { var n canvasNode if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxCanvasBytes)).Decode(&n); err != nil { http.Error(w, "bad node: "+err.Error(), http.StatusBadRequest) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := canvasTmpl.ExecuteTemplate(w, "canvas-node", h.renderCanvasNode(n)); err != nil { log.Printf("canvas template error: %v", err) } } // canvasFileName validates a new canvas's name from the create dialog and // returns the file name, with the extension added when missing. func canvasFileName(raw string) (string, error) { name := strings.TrimSpace(raw) if isCanvasFile(name) { name = strings.TrimSpace(name[:len(name)-len(canvasExt)]) } if name == "" { return "", fmt.Errorf("name is empty") } if strings.HasPrefix(name, ".") || strings.ContainsAny(name, "/\\\x00") { return "", fmt.Errorf("name must not start with a dot or contain slashes") } return name + canvasExt, nil } // handleCanvasCreate creates an empty canvas in the page's folder (creating // the folder for a page that does not exist yet) and opens it. func (h *handler) handleCanvasCreate(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) { if err := r.ParseForm(); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } name, err := canvasFileName(r.FormValue("name")) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } _, statErr := os.Stat(fsPath) newlyCreated := os.IsNotExist(statErr) if err := os.MkdirAll(fsPath, 0755); err != nil { http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError) return } f, err := os.OpenFile(filepath.Join(fsPath, name), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644) if err != nil { if errors.Is(err, os.ErrExist) { http.Error(w, name+" already exists", http.StatusConflict) return } http.Error(w, "create failed: "+err.Error(), http.StatusInternalServerError) return } _, werr := f.Write(emptyCanvas) if cerr := f.Close(); werr == nil { werr = cerr } if werr != nil { http.Error(w, "write failed: "+werr.Error(), http.StatusInternalServerError) return } if newlyCreated { if rel, err := filepath.Rel(h.root, fsPath); err == nil { folderIndexAdd(filepath.ToSlash(rel)) } } rel := strings.Trim(urlPath, "/") if rel != "" { rel += "/" } redirectAfter(w, r, fileURL(rel+name)) }