700 lines
23 KiB
Go
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(wikiTargetHref(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 := normalizeWikiTarget(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(wikiFileHref(target))
|
|
switch {
|
|
case isImageFile(name):
|
|
return template.HTML(`<a class="canvas-media" href="` + href + `"><img src="` +
|
|
html.EscapeString(thumbURL(wikiFileHref(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))
|
|
}
|