240 lines
7.6 KiB
Go
240 lines
7.6 KiB
Go
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 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, 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: 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, "/"))
|
|
}
|
|
|
|
http.Redirect(w, r, fileURL(strings.TrimPrefix(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
|
|
}
|
|
|
|
// 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
|
|
}
|