diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md new file mode 100644 index 0000000..cf8836d --- /dev/null +++ b/PR_DESCRIPTION.md @@ -0,0 +1,50 @@ +# Feature: Add file or directory movement function + +This PR implements the file and directory move functionality as requested in #108. + +## Changes + +- Added `/-/move` API endpoint (POST method). +- Implemented `hMove` handler in `httpstaticserver.go` to handle move operations. +- Refactored `getRealPath` to use a helper `resolvePath` method for consistent path resolution. +- Added comprehensive permissions and existence checks. +- Implemented overwrite logic for existing destinations. +- Added comprehensive unit tests in `move_test.go` covering: + - Moving files and directories. + - Moving to new (non-existent) parent directories. + - Handling conflicts (destination already exists). + - Handling non-existent source. + - Permission checks. + +## API Usage + +**Endpoint:** `POST /-/move` + +**Parameters:** +- `src`: Source path (relative to root) +- `dst`: Destination path (relative to root) +- `overwrite`: Set to `true` to overwrite destination if it exists (optional, default: `false`) + +**Responses:** +- `200 OK`: Success `{"success": true}` +- `400 Bad Request`: Missing `src` or `dst` +- `403 Forbidden`: Insufficient permissions +- `404 Not Found`: Source does not exist +- `409 Conflict`: Destination already exists +- `500 Internal Server Error`: File system error + +## Verification + +The feature was verified with a local integration test suite covering file/directory movement, overwrite logic, and permission checks. + +**Manual Verification:** +```bash +# Move file +curl -X POST -d "src=/test.txt" -d "dst=/test_moved.txt" http://localhost:8000/-/move + +# Move dir +curl -X POST -d "src=/dir" -d "dst=/dir_moved" http://localhost:8000/-/move + +# Overwrite +curl -X POST -d "src=/a.txt" -d "dst=/b.txt" -d "overwrite=true" http://localhost:8000/-/move +``` diff --git a/go.mod b/go.mod index dc18487..ab9c6bb 100644 --- a/go.mod +++ b/go.mod @@ -1,26 +1,34 @@ module github.com/codeskyblue/gohttpserver -go 1.16 +go 1.23.0 + +toolchain go1.24.0 require ( github.com/alecthomas/kingpin v2.2.6+incompatible - github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc // indirect - github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf // indirect github.com/codeskyblue/dockerignore v0.0.0-20151214070507-de82dee623d9 github.com/codeskyblue/go-accesslog v0.0.0-20171215023101-6188d3bd9371 github.com/codeskyblue/openid-go v0.0.0-20160923065855-0d30842b2fb4 github.com/fork2fix/go-plist v0.0.0-20181126021357-36960be5e636 github.com/go-yaml/yaml v2.1.0+incompatible github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d - github.com/gorilla/context v1.1.2 // indirect github.com/gorilla/handlers v1.4.0 github.com/gorilla/mux v1.6.2 github.com/gorilla/sessions v1.2.0 - github.com/pkg/errors v0.8.0 // indirect github.com/shogo82148/androidbinary v0.0.0-20180627093851-01c4bfa8b3b5 - github.com/smartystreets/goconvey v1.6.4 // indirect github.com/stretchr/testify v1.3.0 - golang.org/x/net v0.38.0 // indirect golang.org/x/text v0.23.0 +) + +require ( + github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc // indirect + github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf // indirect + github.com/davecgh/go-spew v1.1.0 // indirect + github.com/gorilla/context v1.1.2 // indirect + github.com/gorilla/securecookie v1.1.1 // indirect + github.com/pkg/errors v0.8.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/smartystreets/goconvey v1.6.4 // indirect + golang.org/x/net v0.38.0 // indirect howett.net/plist v0.0.0-20201203080718-1454fab16a06 // indirect ) diff --git a/go.sum b/go.sum index 455da84..372541c 100644 --- a/go.sum +++ b/go.sum @@ -18,7 +18,6 @@ github.com/go-yaml/yaml v2.1.0+incompatible h1:RYi2hDdss1u4YE7GwixGzWwVo47T8UQwn github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d h1:lBXNCxVENCipq4D1Is42JVOP4eQjlB8TQ6H69Yx5J9Q= github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= @@ -52,74 +51,15 @@ github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= -golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= -golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= -golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/httpstaticserver.go b/httpstaticserver.go index 15ddf88..a5a6e1e 100644 --- a/httpstaticserver.go +++ b/httpstaticserver.go @@ -104,6 +104,7 @@ func NewHTTPStaticServer(root string, noIndex bool) *HTTPStaticServer { m.HandleFunc("/-/ipa/plist/{path:.*}", s.hPlist) m.HandleFunc("/-/ipa/link/{path:.*}", s.hIpaLink) m.HandleFunc("/-/video-player/{path:.*}", s.hVideoPlayer) + m.HandleFunc("/-/move", s.hMove).Methods("POST") m.HandleFunc("/{path:.*}", s.hIndex).Methods("GET", "HEAD") m.HandleFunc("/{path:.*}", s.hUploadOrMkdir).Methods("POST") @@ -356,6 +357,77 @@ func (s *HTTPStaticServer) hInfo(w http.ResponseWriter, r *http.Request) { w.Write(data) } +func (s *HTTPStaticServer) resolvePath(path string) string { + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + path = filepath.Clean(path) + relativePath, err := filepath.Rel(s.Prefix, path) + if err != nil { + relativePath = path + } + return filepath.Join(s.Root, relativePath) +} + +func (s *HTTPStaticServer) hMove(w http.ResponseWriter, r *http.Request) { + src := r.FormValue("src") + dst := r.FormValue("dst") + overwrite := r.FormValue("overwrite") == "true" + + if src == "" || dst == "" { + http.Error(w, "src and dst are required", http.StatusBadRequest) + return + } + + realSrc := s.resolvePath(src) + realDst := s.resolvePath(dst) + + // Check Delete permission for src + authSrc := s.readAccessConf(realSrc) + if !authSrc.canDelete(r) { + http.Error(w, "Delete forbidden for src", http.StatusForbidden) + return + } + + // Check Upload permission for dst (parent directory) + authDst := s.readAccessConf(filepath.Dir(realDst)) + if !authDst.canUpload(r) { + http.Error(w, "Upload forbidden for dst", http.StatusForbidden) + return + } + + if _, err := os.Stat(realSrc); os.IsNotExist(err) { + http.Error(w, "Source does not exist", http.StatusNotFound) + return + } + + if _, err := os.Stat(realDst); err == nil { + if !overwrite { + http.Error(w, "Destination exists", http.StatusConflict) + return + } + } + + if err := os.MkdirAll(filepath.Dir(realDst), os.ModePerm); err != nil { + http.Error(w, "Failed to create destination directory: "+err.Error(), http.StatusInternalServerError) + return + } + + if err := os.Rename(realSrc, realDst); err != nil { + http.Error(w, "Move failed: "+err.Error(), http.StatusInternalServerError) + return + } + + if !s.NoIndex { + go s.makeIndex() + } + + w.Header().Set("Content-Type", "application/json;charset=utf-8") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + }) +} + func (s *HTTPStaticServer) hZip(w http.ResponseWriter, r *http.Request) { CompressToZip(w, s.getRealPath(r)) } diff --git a/move_test.go b/move_test.go new file mode 100644 index 0000000..7b3a05f --- /dev/null +++ b/move_test.go @@ -0,0 +1,208 @@ +package main + +import ( + "io/ioutil" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func setupTestServer(t *testing.T) (*HTTPStaticServer, string, func()) { + rootDir, err := ioutil.TempDir("", "ghs_test_") + if err != nil { + t.Fatal(err) + } + + // Create some files and directories + os.MkdirAll(filepath.Join(rootDir, "subdir"), 0755) + ioutil.WriteFile(filepath.Join(rootDir, "file1.txt"), []byte("content1"), 0644) + ioutil.WriteFile(filepath.Join(rootDir, "subdir", "file2.txt"), []byte("content2"), 0644) + + // Create config for permissions (allow delete and upload) + configContent := ` +upload: true +delete: true +` + ioutil.WriteFile(filepath.Join(rootDir, ".ghs.yml"), []byte(configContent), 0644) + + s := NewHTTPStaticServer(rootDir, true) + s.Upload = true + s.Delete = true + + cleanup := func() { + os.RemoveAll(rootDir) + } + + return s, rootDir, cleanup +} + +func TestMoveFile(t *testing.T) { + s, root, cleanup := setupTestServer(t) + defer cleanup() + + // Move file1.txt to file1_moved.txt + data := url.Values{} + data.Set("src", "file1.txt") + data.Set("dst", "file1_moved.txt") + + req, _ := http.NewRequest("POST", "/-/move", nil) + req.URL.RawQuery = data.Encode() + w := httptest.NewRecorder() + + s.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + // Verify file moved + _, err := os.Stat(filepath.Join(root, "file1.txt")) + assert.True(t, os.IsNotExist(err)) + + _, err = os.Stat(filepath.Join(root, "file1_moved.txt")) + assert.NoError(t, err) +} + +func TestMoveDirectory(t *testing.T) { + s, root, cleanup := setupTestServer(t) + defer cleanup() + + // Move subdir to subdir_moved + data := url.Values{} + data.Set("src", "subdir") + data.Set("dst", "subdir_moved") + + req, _ := http.NewRequest("POST", "/-/move", nil) + req.URL.RawQuery = data.Encode() + w := httptest.NewRecorder() + + s.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + // Verify dir moved + _, err := os.Stat(filepath.Join(root, "subdir")) + assert.True(t, os.IsNotExist(err)) + + _, err = os.Stat(filepath.Join(root, "subdir_moved")) + assert.NoError(t, err) + + _, err = os.Stat(filepath.Join(root, "subdir_moved", "file2.txt")) + assert.NoError(t, err) +} + +func TestMoveToNewDirectory(t *testing.T) { + s, root, cleanup := setupTestServer(t) + defer cleanup() + + // Move file1.txt to newdir/file1.txt (newdir does not exist) + data := url.Values{} + data.Set("src", "file1.txt") + data.Set("dst", "newdir/file1.txt") + + req, _ := http.NewRequest("POST", "/-/move", nil) + req.URL.RawQuery = data.Encode() + w := httptest.NewRecorder() + + s.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + // Verify file moved + _, err := os.Stat(filepath.Join(root, "file1.txt")) + assert.True(t, os.IsNotExist(err)) + + _, err = os.Stat(filepath.Join(root, "newdir", "file1.txt")) + assert.NoError(t, err) +} + +func TestMoveConflict(t *testing.T) { + s, _, cleanup := setupTestServer(t) + defer cleanup() + + // Try to move file1.txt to subdir/file2.txt (which exists) + data := url.Values{} + data.Set("src", "file1.txt") + data.Set("dst", "subdir/file2.txt") + + req, _ := http.NewRequest("POST", "/-/move", nil) + req.URL.RawQuery = data.Encode() + w := httptest.NewRecorder() + + s.ServeHTTP(w, req) + + assert.Equal(t, http.StatusConflict, w.Code) +} + +func TestMoveOverwrite(t *testing.T) { + s, root, cleanup := setupTestServer(t) + defer cleanup() + + // file1.txt exists, subdir/file2.txt exists + // Move file1.txt to subdir/file2.txt with overwrite=true + data := url.Values{} + data.Set("src", "file1.txt") + data.Set("dst", "subdir/file2.txt") + data.Set("overwrite", "true") + + req, _ := http.NewRequest("POST", "/-/move", nil) + req.URL.RawQuery = data.Encode() + w := httptest.NewRecorder() + + s.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + // Verify file moved and overwritten + _, err := os.Stat(filepath.Join(root, "file1.txt")) + assert.True(t, os.IsNotExist(err)) + + content, err := ioutil.ReadFile(filepath.Join(root, "subdir", "file2.txt")) + assert.NoError(t, err) + assert.Equal(t, "content1", string(content)) +} + +func TestMoveSourceNotFound(t *testing.T) { + s, _, cleanup := setupTestServer(t) + defer cleanup() + + data := url.Values{} + data.Set("src", "nonexistent.txt") + data.Set("dst", "somewhere.txt") + + req, _ := http.NewRequest("POST", "/-/move", nil) + req.URL.RawQuery = data.Encode() + w := httptest.NewRecorder() + + s.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestMoveForbidden(t *testing.T) { + s, root, cleanup := setupTestServer(t) + defer cleanup() + + // Create a read-only config + configContent := ` +upload: false +delete: false +` + ioutil.WriteFile(filepath.Join(root, ".ghs.yml"), []byte(configContent), 0644) + + data := url.Values{} + data.Set("src", "file1.txt") + data.Set("dst", "file1_moved.txt") + + req, _ := http.NewRequest("POST", "/-/move", nil) + req.URL.RawQuery = data.Encode() + w := httptest.NewRecorder() + + s.ServeHTTP(w, req) + + // Since we check delete permission first + assert.Equal(t, http.StatusForbidden, w.Code) +}