Files
datascape/canvas.go
T
2026-09-22 21:28:28 +02:00

700 lines
23 KiB
Go

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 `<Name>.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
// <img> 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(`<a class="canvas-media" href="` + href + `"><img src="` +
html.EscapeString(thumbURL(fileURL(strings.TrimPrefix(target, "/")), 800)) + `" alt="" loading="lazy"></a>`)
case isVideoFile(name):
return template.HTML(`<video class="canvas-media" controls preload="metadata" src="` + href + `"></video>`)
case strings.EqualFold(path.Ext(name), ".pdf"):
return template.HTML(`<embed class="canvas-media" type="application/pdf" src="` + href + `">`)
case strings.EqualFold(path.Ext(name), ".md"):
return h.renderMarkdownFile(fsPath, file, subpath)
default:
return template.HTML(`<p class="canvas-file-fallback"><a href="` + href + `">` +
html.EscapeString(name) + `</a></p>`)
}
}
// 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(`<p class="canvas-broken">` + html.EscapeString(target) +
` <span class="muted">(` + html.EscapeString(reason) + `)</span></p>`)
}
// --- request handlers ---
// serveCanvas renders the canvas view of a page. Reached from serveDir when
// the request carries ?canvas=<name>.
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 <script> block.
blob, err := json.Marshal(payload)
if err != nil {
http.Error(w, "encode failed: "+err.Error(), http.StatusInternalServerError)
return
}
data.CanvasJSON = template.JS(blob)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
data.RenderMS = elapsedMS(r)
if err := canvasTmpl.ExecuteTemplate(w, "layout", data); err != nil {
log.Printf("template error: %v", err)
}
}
// handleCanvasPost dispatches every POST carrying ?canvas=. It is checked
// before the page-level delete/move actions in handlePost so `?canvas=X&delete`
// removes the canvas rather than the whole page.
func (h *handler) handleCanvasPost(w http.ResponseWriter, r *http.Request, urlPath, fsPath, name string) {
if !canvasNameRe.MatchString(name) {
http.Error(w, "bad canvas name", http.StatusBadRequest)
return
}
query := r.URL.Query()
switch {
case query.Has("create"):
h.handleCanvasCreate(w, r, urlPath, fsPath, name)
case query.Has("delete"):
h.handleCanvasDelete(w, r, urlPath, fsPath, name)
case query.Has("rename"):
h.handleCanvasRename(w, r, urlPath, fsPath, name, query.Get("rename"))
default:
h.handleCanvasSave(w, r, urlPath, fsPath, name)
}
}
// handleCanvasSave replaces a canvas with the posted document. The write is
// gated on four checks in order — parse, validate, version, non-empty — so a
// malformed or stale payload never reaches the file.
func (h *handler) handleCanvasSave(w http.ResponseWriter, r *http.Request, urlPath, fsPath, name string) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, canvasMaxBytes))
if err != nil {
http.Error(w, "request too large or truncated", http.StatusBadRequest)
return
}
doc, err := parseCanvas(body)
if err != nil {
http.Error(w, "rejected: "+err.Error(), http.StatusBadRequest)
return
}
p := canvasPath(fsPath, name)
current, readErr := os.ReadFile(p)
if readErr != nil && !os.IsNotExist(readErr) {
http.Error(w, "read failed: "+readErr.Error(), http.StatusInternalServerError)
return
}
if want := r.Header.Get("X-Canvas-Version"); want != "" && want != canvasHash(current) {
http.Error(w, "the canvas changed since you opened it — reload to get the current version",
http.StatusConflict)
return
}
// A save must never blank a canvas. An empty document is indistinguishable
// from a client that lost its state, so emptying is only allowed when the
// caller says so explicitly — the same reasoning as the empty-content guard
// on page saves.
if len(doc.Nodes) == 0 && !r.URL.Query().Has("empty") {
if cur, err := parseCanvas(current); err == nil && len(cur.Nodes) > 0 {
http.Error(w, fmt.Sprintf("refusing to replace %d nodes with an empty canvas", len(cur.Nodes)),
http.StatusBadRequest)
return
}
}
out, err := json.MarshalIndent(doc, "", "\t")
if err != nil {
http.Error(w, "encode failed: "+err.Error(), http.StatusInternalServerError)
return
}
out = append(out, '\n')
if len(current) > 0 {
if err := writeFileAtomic(canvasBackupPath(fsPath, name), current, 0644); err != nil {
log.Printf("canvas backup %s: %v", p, err)
}
}
if err := writeFileAtomic(p, out, 0644); err != nil {
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
return
}
// Hand back the new token so the client can keep editing without reloading.
w.Header().Set("X-Canvas-Version", canvasHash(out))
w.WriteHeader(http.StatusNoContent)
}
func (h *handler) handleCanvasCreate(w http.ResponseWriter, r *http.Request, urlPath, fsPath, name string) {
if err := os.MkdirAll(fsPath, 0755); err != nil {
http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError)
return
}
p := canvasPath(fsPath, name)
if _, err := os.Stat(p); err == nil {
http.Error(w, "a canvas with that name already exists", http.StatusConflict)
return
}
if err := writeFileAtomic(p, []byte("{}\n"), 0644); err != nil {
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, canvasURL(urlPath, name), http.StatusSeeOther)
}
func (h *handler) handleCanvasDelete(w http.ResponseWriter, r *http.Request, urlPath, fsPath, name string) {
if err := os.Remove(canvasPath(fsPath, name)); err != nil {
if os.IsNotExist(err) {
http.NotFound(w, r)
return
}
http.Error(w, "delete failed: "+err.Error(), http.StatusInternalServerError)
return
}
_ = os.Remove(canvasBackupPath(fsPath, name))
http.Redirect(w, r, urlPath, http.StatusSeeOther)
}
func (h *handler) handleCanvasRename(w http.ResponseWriter, r *http.Request, urlPath, fsPath, name, newName string) {
if !canvasNameRe.MatchString(newName) {
http.Error(w, "bad canvas name", http.StatusBadRequest)
return
}
if newName == name {
http.Redirect(w, r, canvasURL(urlPath, name), http.StatusSeeOther)
return
}
dst := canvasPath(fsPath, newName)
if _, err := os.Stat(dst); err == nil {
http.Error(w, "a canvas with that name already exists", http.StatusConflict)
return
}
if err := os.Rename(canvasPath(fsPath, name), dst); err != nil {
http.Error(w, "rename failed: "+err.Error(), http.StatusInternalServerError)
return
}
_ = os.Remove(canvasBackupPath(fsPath, name))
http.Redirect(w, r, canvasURL(urlPath, newName), http.StatusSeeOther)
}
// handleCanvasRender renders one node's content on demand. It is stateless and
// takes the content in the request rather than reading it from the canvas file:
// the node being re-rendered has just been edited in the browser and is not on
// disk yet. Everything already on disk arrives pre-rendered with the page.
func (h *handler) handleCanvasRender(w http.ResponseWriter, r *http.Request) {
if !h.checkAuth(w, r) {
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
r.Body = http.MaxBytesReader(w, r.Body, canvasRenderMaxBytes)
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
var out template.HTML
switch r.FormValue("type") {
case canvasNodeFile:
out = h.renderFileNode(r.FormValue("file"), r.FormValue("subpath"))
case canvasNodeText:
out = renderMarkdown([]byte(r.FormValue("text")))
default:
http.Error(w, "bad node type", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = io.WriteString(w, string(out))
}