package main import ( "fmt" "net/http" "os" "path/filepath" "strings" ) // handleMove moves the folder at srcFsPath (wiki URL srcURL) to dstURL. // // 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 asked to confirm it. func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, srcFsPath, dstURL string, 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 user to confirm: htmx clients get the merge dialog as // the next step; others a conflict they can retry with &merge=1. if isHX(r) { renderDialog(w, "merge", struct{ Dest string }{newPath}) return } 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: create intermediate parent folders for the destination. if parent := filepath.Dir(dstFsPath); parent != "" { if err := os.MkdirAll(parent, 0755); err != nil { http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError) return } } // Phase 2: 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 { 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 { http.Error(w, "rename failed: "+err.Error(), http.StatusInternalServerError) return } folderIndexRenameSubtree(strings.TrimPrefix(oldPath, "/"), strings.TrimPrefix(newPath, "/")) } redirectAfter(w, r, pageURL(newPath)) } // 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), "/")) redirectAfter(w, r, pageURL(parentURL(urlPath))) } // 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 } // 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 }