257 lines
7.5 KiB
Go
257 lines
7.5 KiB
Go
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
|
|
}
|