package main import ( "bytes" "encoding/json" "fmt" "log" "net/http" "os" "path/filepath" "strings" ) // handleMove moves the folder at srcFsPath (wiki URL srcURL) to dstURL. When // updateLinks is true it also rewrites every [[...]] wiki link across the // tree that targets the old path or any descendant; rewritten files are held // in memory for rollback. // // If the destination folder already exists it is normally a hard conflict. // The one exception is a merge: when the source carries a page (index.md) and // the destination folder has none, the source's contents can fill the empty // container without clobbering anything. That merge only proceeds when merge // is true; otherwise the client is told a merge is available so it can ask the // user to confirm. func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, srcFsPath, dstURL string, updateLinks, merge bool) { oldPath := normalizeMovePath(srcURL) if oldPath == "/" { http.Error(w, "cannot move wiki root", http.StatusBadRequest) return } newPath, err := validateAndNormalizeNewPath(dstURL) if err != nil { http.Error(w, "invalid destination: "+err.Error(), http.StatusBadRequest) return } if newPath == oldPath { http.Error(w, "destination equals source", http.StatusBadRequest) return } if strings.HasPrefix(newPath, oldPath+"/") { http.Error(w, "destination is inside source", http.StatusBadRequest) return } if info, err := os.Stat(srcFsPath); err != nil || !info.IsDir() { http.NotFound(w, r) return } dstFsPath := filepath.Join(h.root, filepath.FromSlash(strings.TrimPrefix(newPath, "/"))) // Decide between a plain rename and a merge into an existing destination. merging := false if dstInfo, err := os.Stat(dstFsPath); err == nil { if !canMergeMove(srcFsPath, dstFsPath, dstInfo) { http.Error(w, "destination already exists", http.StatusConflict) return } if !merge { // Ask the client to confirm the merge. The header lets it tell // this apart from an ordinary conflict. w.Header().Set("X-Merge-Available", "1") http.Error(w, "destination already exists — merge folders?", http.StatusConflict) return } conflict, err := firstMergeConflict(srcFsPath, dstFsPath) if err != nil { http.Error(w, "merge check failed: "+err.Error(), http.StatusInternalServerError) return } if conflict != "" { http.Error(w, "cannot merge: "+conflict+" exists in both folders", http.StatusConflict) return } merging = true } // Phase 1: optionally walk the tree and rewrite every index.md that // references the moved path. Keep the pre-rewrite bytes in memory so we // can revert on failure. The walker only reads directory listings and // files literally named index.md; hidden directories are pruned. A cheap // substring check skips parsing files that cannot contain a relevant // link. rewritten := map[string][]byte{} if updateLinks { // rewriteInto reads a file, hands it to rewrite, and writes the result // back atomically — recording the original bytes so every file touched // in this phase can be rolled back together. rewriteInto := func(fsPath string, needle []byte, rewrite func([]byte) ([]byte, bool)) error { orig, err := os.ReadFile(fsPath) if err != nil { return err } // Cheap reject before parsing: a file that never mentions the old // path cannot contain a reference to it. if !bytes.Contains(orig, needle) { return nil } updated, changed := rewrite(orig) if !changed { return nil } if err := writeFileAtomic(fsPath, updated, 0644); err != nil { return fmt.Errorf("write %s: %w", fsPath, err) } rewritten[fsPath] = orig return nil } linkNeedle := []byte("[[" + oldPath) walkErr := walkIndexFiles(h.root, func(fsPath string) error { return rewriteInto(fsPath, linkNeedle, func(b []byte) ([]byte, bool) { return rewriteWikiLinks(b, oldPath, newPath) }) }) if walkErr == nil { // Canvases reference pages too: as [[wikilinks]] inside text nodes // and as the path of a file node. Skipping them would leave a move // silently breaking every canvas that pointed at the moved page — // the failure mode an auto-rewriting link system exists to prevent. // The needle is the bare path because a canvas holds it unbracketed. walkErr = walkCanvasFiles(h.root, func(fsPath string) error { return rewriteInto(fsPath, []byte(oldPath), func(b []byte) ([]byte, bool) { return rewriteCanvasLinks(b, oldPath, newPath) }) }) } if walkErr != nil { rollbackRewrites(rewritten) http.Error(w, "rewrite failed: "+walkErr.Error(), http.StatusInternalServerError) return } } // Phase 2: create intermediate parent folders for the destination. if parent := filepath.Dir(dstFsPath); parent != "" { if err := os.MkdirAll(parent, 0755); err != nil { rollbackRewrites(rewritten) http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError) return } } // Phase 3: move the source into place. A plain move renames the whole // folder; a merge moves the source's entries into the existing // destination and drops the emptied source. if merging { if err := mergeFolder(srcFsPath, dstFsPath); err != nil { rollbackRewrites(rewritten) http.Error(w, "merge failed: "+err.Error(), http.StatusInternalServerError) return } folderIndexMergeSubtree(strings.TrimPrefix(oldPath, "/"), strings.TrimPrefix(newPath, "/")) } else { if err := os.Rename(srcFsPath, dstFsPath); err != nil { rollbackRewrites(rewritten) http.Error(w, "rename failed: "+err.Error(), http.StatusInternalServerError) return } folderIndexRenameSubtree(strings.TrimPrefix(oldPath, "/"), strings.TrimPrefix(newPath, "/")) } http.Redirect(w, r, wikiTargetHref(newPath), http.StatusSeeOther) } // canMergeMove reports whether moving onto an existing destination should // merge rather than conflict. Merging is allowed only when the destination is // a folder with no page of its own (no index.md) and the source has one — so // the source's page fills the empty destination without overwriting content. func canMergeMove(srcFsPath, dstFsPath string, dstInfo os.FileInfo) bool { if !dstInfo.IsDir() { return false } return hasIndexFile(srcFsPath) && !hasIndexFile(dstFsPath) } // hasIndexFile reports whether dir contains a regular index.md. func hasIndexFile(dir string) bool { info, err := os.Stat(filepath.Join(dir, "index.md")) return err == nil && info.Mode().IsRegular() } // firstMergeConflict returns the name of the first source entry that already // exists in the destination, or "" when the merge can proceed without // overwriting anything. index.md cannot collide here: canMergeMove already // established the destination has none. func firstMergeConflict(srcFsPath, dstFsPath string) (string, error) { entries, err := os.ReadDir(srcFsPath) if err != nil { return "", err } for _, e := range entries { _, err := os.Lstat(filepath.Join(dstFsPath, e.Name())) if err == nil { return e.Name(), nil } if !os.IsNotExist(err) { return "", err } } return "", nil } // mergeFolder moves every entry from srcFsPath into dstFsPath, then removes // the now-empty source. Callers must run firstMergeConflict beforehand so no // rename overwrites an existing destination entry. func mergeFolder(srcFsPath, dstFsPath string) error { entries, err := os.ReadDir(srcFsPath) if err != nil { return err } for _, e := range entries { from := filepath.Join(srcFsPath, e.Name()) to := filepath.Join(dstFsPath, e.Name()) if err := os.Rename(from, to); err != nil { return fmt.Errorf("move %s: %w", e.Name(), err) } } return os.Remove(srcFsPath) } // handleDelete removes the folder at fsPath (URL urlPath) and redirects to // the parent. Refuses to touch the wiki root. func (h *handler) handleDelete(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) { if normalizeMovePath(urlPath) == "/" { http.Error(w, "cannot delete wiki root", http.StatusBadRequest) return } if info, err := os.Stat(fsPath); err != nil || !info.IsDir() { http.NotFound(w, r) return } if err := os.RemoveAll(fsPath); err != nil { http.Error(w, "delete failed: "+err.Error(), http.StatusInternalServerError) return } folderIndexRemoveSubtree(strings.TrimPrefix(normalizeMovePath(urlPath), "/")) http.Redirect(w, r, parentURL(urlPath), http.StatusSeeOther) } // normalizeMovePath returns the absolute path with any trailing slash removed, // except for the wiki root which is always "/". func normalizeMovePath(p string) string { if p == "" || p == "/" { return "/" } return "/" + strings.Trim(p, "/") } // validateAndNormalizeNewPath returns the cleaned absolute path or an error // describing why the input was rejected. Empty/root paths, relative paths, // and paths with bad segments are all invalid. func validateAndNormalizeNewPath(raw string) (string, error) { if !strings.HasPrefix(raw, "/") { return "", fmt.Errorf("must start with /") } trimmed := strings.Trim(raw, "/") if trimmed == "" { return "", fmt.Errorf("cannot target the wiki root") } for _, seg := range strings.Split(trimmed, "/") { if seg == "" { return "", fmt.Errorf("empty segment") } if seg == "." || seg == ".." { return "", fmt.Errorf("segment %q is not allowed", seg) } if strings.ContainsAny(seg, "\\\x00") { return "", fmt.Errorf("segment contains an invalid character") } } return "/" + trimmed, nil } // rewriteWikiLinks returns (newContent, changed). Any [[target]] or // [[target::display]] whose target equals oldPath or begins with oldPath+"/" // has its target rewritten to the corresponding position under newPath. func rewriteWikiLinks(content []byte, oldPath, newPath string) ([]byte, bool) { changed := false out := wikiLinkPattern.ReplaceAllFunc(content, func(match []byte) []byte { parts := wikiLinkPattern.FindSubmatch(match) if parts == nil { return match } newTarget, ok := rewriteMovedPath(strings.TrimSpace(string(parts[1])), oldPath, newPath) if !ok { return match } changed = true suffix := "" if len(parts[2]) > 0 { suffix = "::" + string(parts[2]) } return []byte("[[" + newTarget + suffix + "]]") }) return out, changed } // rewriteMovedPath maps a wiki path onto its position after a move, matching // either the moved path itself or anything beneath it. ok=false means the path // is unaffected. Shared by the wiki-link rewriter and the canvas rewriter so // both agree on what "under the moved page" means. func rewriteMovedPath(target, oldPath, newPath string) (string, bool) { norm := normalizeMovePath(target) switch { case norm == oldPath: return newPath, true case strings.HasPrefix(norm, oldPath+"/"): return newPath + strings.TrimPrefix(norm, oldPath), true } return "", false } // rewriteCanvasLinks updates every reference to a moved page inside a canvas: // the [[wikilinks]] in text nodes, the target of a file node, and a group's // background image. An unparseable canvas is left exactly as it is rather than // half-rewritten — a move is not the moment to start editing a broken file. // // The result is re-marshalled from the parsed document, so it is normalised the // same way a save through the editor would normalise it. func rewriteCanvasLinks(content []byte, oldPath, newPath string) ([]byte, bool) { doc, err := parseCanvas(content) if err != nil { return content, false } changed := false for i := range doc.Nodes { n := &doc.Nodes[i] switch n.Type { case canvasNodeText: if updated, ok := rewriteWikiLinks([]byte(n.Text), oldPath, newPath); ok { n.Text = string(updated) changed = true } case canvasNodeFile: if target, ok := rewriteMovedPath(n.File, oldPath, newPath); ok { n.File = target changed = true } case canvasNodeGroup: if n.Background == "" { continue } if target, ok := rewriteMovedPath(n.Background, oldPath, newPath); ok { n.Background = target changed = true } } } if !changed { return content, false } out, err := json.MarshalIndent(doc, "", "\t") if err != nil { return content, false } return append(out, '\n'), true } // rollbackRewrites restores the given files to their pre-rewrite contents. // Errors are logged; best-effort since we're already in a failure path. func rollbackRewrites(rewritten map[string][]byte) { for path, orig := range rewritten { if err := writeFileAtomic(path, orig, 0644); err != nil { log.Printf("rollback %s: %v", path, err) } } } // walkIndexFiles visits every `index.md` under root, skipping hidden // directories (names beginning with `.`). Unlike filepath.WalkDir this does // not stat each regular file — on spinning disks that saves the bulk of the // traversal cost when folders contain many non-page files (photos, archives). func walkIndexFiles(root string, visit func(fsPath string) error) error { entries, err := os.ReadDir(root) if err != nil { return err } for _, e := range entries { name := e.Name() if strings.HasPrefix(name, ".") { continue } full := filepath.Join(root, name) if e.IsDir() { if err := walkIndexFiles(full, visit); err != nil { return err } continue } if name == "index.md" { if err := visit(full); err != nil { return err } } } return nil } // walkCanvasFiles visits every `*.canvas` under root, skipping hidden entries // (which excludes the `..canvas.bak` backups). Same shape and same // reasoning as walkIndexFiles: no stat per regular file. func walkCanvasFiles(root string, visit func(fsPath string) error) error { entries, err := os.ReadDir(root) if err != nil { return err } for _, e := range entries { name := e.Name() if strings.HasPrefix(name, ".") { continue } full := filepath.Join(root, name) if e.IsDir() { if err := walkCanvasFiles(full, visit); err != nil { return err } continue } if isCanvasFile(name) { if err := visit(full); err != nil { return err } } } return nil } // writeFileAtomic writes data to a temp file in the same directory as path // and renames it into place so readers never observe a partial file. func writeFileAtomic(path string, data []byte, perm os.FileMode) error { dir := filepath.Dir(path) tmp, err := os.CreateTemp(dir, ".tmp-*") if err != nil { return err } tmpPath := tmp.Name() cleanup := true defer func() { if cleanup { _ = os.Remove(tmpPath) } }() if _, err := tmp.Write(data); err != nil { tmp.Close() return err } if err := tmp.Close(); err != nil { return err } if err := os.Chmod(tmpPath, perm); err != nil { return err } if err := os.Rename(tmpPath, path); err != nil { return err } cleanup = false return nil }