package main import ( "bytes" "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. func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, srcFsPath, dstURL string, updateLinks 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, "/"))) if _, err := os.Stat(dstFsPath); err == nil { http.Error(w, "destination already exists", http.StatusConflict) return } // 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 { needle := []byte("[[" + oldPath) walkErr := walkIndexFiles(h.root, func(fsPath string) error { orig, err := os.ReadFile(fsPath) if err != nil { return err } if !bytes.Contains(orig, needle) { return nil } updated, changed := rewriteWikiLinks(orig, oldPath, newPath) 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 }) 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: rename the source folder into place. 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) } // 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 } target := strings.TrimSpace(string(parts[1])) normTarget := normalizeMovePath(target) var newTarget string switch { case normTarget == oldPath: newTarget = newPath case strings.HasPrefix(normTarget, oldPath+"/"): newTarget = newPath + strings.TrimPrefix(normTarget, oldPath) default: return match } changed = true suffix := "" if len(parts[2]) > 0 { suffix = "::" + string(parts[2]) } return []byte("[[" + newTarget + suffix + "]]") }) return out, changed } // 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 } // 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 }