Canvas support V1
This commit is contained in:
@@ -0,0 +1,572 @@
|
||||
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))
|
||||
}
|
||||
Reference in New Issue
Block a user