Files
datascape/canvas.go
T
2026-10-04 15:28:06 +02:00

573 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
// <name>.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
}
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})
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 := `<input type="checkbox" class="task-checkbox" data-task="` + strconv.Itoa(idx) + `"`
if bytes.Contains(match, []byte("checked")) {
out += ` checked=""`
}
idx++
return []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))
}