diff --git a/docs/frontends/webapi.rst b/docs/frontends/webapi.rst index 47ab754..a715a50 100644 --- a/docs/frontends/webapi.rst +++ b/docs/frontends/webapi.rst @@ -29,8 +29,9 @@ The Tahoe REST-ful Web API 6. `Attaching An Existing File Or Directory (by URI)`_ 7. `Unlinking A Child`_ 8. `Renaming A Child`_ - 9. `Other Utilities`_ - 10. `Debugging and Testing Features`_ + 9. `Moving A Child`_ + 10. `Other Utilities`_ + 11. `Debugging and Testing Features`_ 7. `Other Useful Pages`_ 8. `Static Files in /public_html`_ @@ -1273,6 +1274,21 @@ Renaming A Child This operation will replace any existing child of the new name, making it behave like the UNIX "``mv -f``" command. +Moving A Child +---------------- + +``POST /uri/$DIRCAP/[SUBDIRS../]?t=rename&from_name=OLD&to_dir=TARGET[&to_name=NEW]`` + + This instructs the node to move a child of the given directory to a + different directory, both of which must be mutable. The child can also be + renamed in the process. The to_dir parameter can be either the name of a + subdirectory of the dircap from which the child is being moved (multiple + levels of descent are supported) or the writecap of an unrelated directory. + + This operation will replace any existing child of the new name, making it + behave like the UNIX "``mv -f``" command. The original child is not + unlinked until it is linked into the target directory. + Other Utilities --------------- @@ -1294,6 +1310,8 @@ Other Utilities functionality described above, with the provided $CHILDNAME present in the 'from_name' field of that form. I.e. this presents a form offering to rename $CHILDNAME, requesting the new name, and submitting POST rename. + This same URL format can also be used with "move-form" with the expected + results. ``GET /uri/$DIRCAP/[SUBDIRS../]CHILDNAME?t=uri`` diff --git a/src/allmydata/test/test_web.py b/src/allmydata/test/test_web.py index dc7ab9e..0913f0d 100644 --- a/src/allmydata/test/test_web.py +++ b/src/allmydata/test/test_web.py @@ -236,6 +236,7 @@ class WebMixin(object): self._sub_uri = sub_uri foo.set_uri(u"sub", sub_uri, sub_uri) sub = self.s.create_node_from_uri(sub_uri) + self._sub_node = sub _ign, n, blocking_uri = self.makefile(1) foo.set_uri(u"blockingfile", blocking_uri, blocking_uri) @@ -245,7 +246,7 @@ class WebMixin(object): # still think of it as an umlaut foo.set_uri(unicode_filename, self._bar_txt_uri, self._bar_txt_uri) - _ign, n, baz_file = self.makefile(2) + self.SUBBAZ_CONTENTS, n, baz_file = self.makefile(2) self._baz_file_uri = baz_file sub.set_uri(u"baz.txt", baz_file, baz_file) @@ -300,6 +301,9 @@ class WebMixin(object): def failUnlessIsBazDotTxt(self, res): self.failUnlessReallyEqual(res, self.BAZ_CONTENTS, res) + def failUnlessIsSubBazDotTxt(self, res): + self.failUnlessReallyEqual(res, self.SUBBAZ_CONTENTS, res) + def failUnlessIsBarJSON(self, res): data = simplejson.loads(res) self.failUnless(isinstance(data, list)) @@ -1335,7 +1339,7 @@ class Web(WebMixin, WebErrorMixin, testutil.StallMixin, testutil.ReallyEqualMixi r'\s+%d' % len(self.BAR_CONTENTS), ]) self.failUnless(re.search(get_bar, res), res) - for label in ['unlink', 'rename']: + for label in ['unlink', 'rename', 'move']: for line in res.split("\n"): # find the line that contains the relevant button for bar.txt if ("form action" in line and @@ -3316,6 +3320,151 @@ class Web(WebMixin, WebErrorMixin, testutil.StallMixin, testutil.ReallyEqualMixi d.addCallback(self.failUnlessIsFooJSON) return d + def test_POST_move_file(self): + """""" + d = self.POST(self.public_url + "/foo", t="move", + from_name="bar.txt", to_dir="sub") + d.addCallback(lambda res: + self.failIfNodeHasChild(self._foo_node, u"bar.txt")) + d.addCallback(lambda res: + self.failUnlessNodeHasChild(self._sub_node, u"bar.txt")) + d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/bar.txt")) + d.addCallback(self.failUnlessIsBarDotTxt) + d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/bar.txt?t=json")) + d.addCallback(self.failUnlessIsBarJSON) + return d + + def test_POST_move_file_new_name(self): + d = self.POST(self.public_url + "/foo", t="move", + from_name="bar.txt", to_name="wibble.txt", to_dir="sub") + d.addCallback(lambda res: + self.failIfNodeHasChild(self._foo_node, u"bar.txt")) + d.addCallback(lambda res: + self.failIfNodeHasChild(self._sub_node, u"bar.txt")) + d.addCallback(lambda res: + self.failUnlessNodeHasChild(self._sub_node, u"wibble.txt")) + d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/wibble.txt")) + d.addCallback(self.failUnlessIsBarDotTxt) + d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/wibble.txt?t=json")) + d.addCallback(self.failUnlessIsBarJSON) + return d + + def test_POST_move_file_replace(self): + d = self.POST(self.public_url + "/foo", t="move", + from_name="bar.txt", to_name="baz.txt", to_dir="sub") + d.addCallback(lambda res: + self.failIfNodeHasChild(self._foo_node, u"bar.txt")) + d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/baz.txt")) + d.addCallback(self.failUnlessIsBarDotTxt) + d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/baz.txt?t=json")) + d.addCallback(self.failUnlessIsBarJSON) + return d + + def test_POST_move_file_no_replace(self): + d = self.POST(self.public_url + "/foo", t="move", replace="false", + from_name="bar.txt", to_name="baz.txt", to_dir="sub") + d.addBoth(self.shouldFail, error.Error, + "POST_move_file_no_replace", + "409 Conflict", + "There was already a child by that name, and you asked me " + "to not replace it") + d.addCallback(lambda res: self.GET(self.public_url + "/foo/bar.txt")) + d.addCallback(self.failUnlessIsBarDotTxt) + d.addCallback(lambda res: self.GET(self.public_url + "/foo/bar.txt?t=json")) + d.addCallback(self.failUnlessIsBarJSON) + d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/baz.txt")) + d.addCallback(self.failUnlessIsSubBazDotTxt) + return d + + def test_POST_move_file_slash_fail(self): + d = self.POST(self.public_url + "/foo", t="move", + from_name="bar.txt", to_name="slash/fail.txt", to_dir="sub") + d.addBoth(self.shouldFail, error.Error, + "test_POST_rename_file_slash_fail", + "400 Bad Request", + "to_name= may not contain a slash", + ) + d.addCallback(lambda res: + self.failUnlessNodeHasChild(self._foo_node, u"bar.txt")) + d.addCallback(lambda res: + self.failIfNodeHasChild(self._sub_node, u"slash/fail.txt")) + return d + + def test_POST_move_file_no_target(self): + d = self.POST(self.public_url + "/foo", t="move", + from_name="bar.txt", to_name="baz.txt") + d.addBoth(self.shouldFail, error.Error, + "POST_move_file_no_target", + "400 Bad Request", + "move requires from_name and to_dir") + d.addCallback(lambda res: self.GET(self.public_url + "/foo/bar.txt")) + d.addCallback(self.failUnlessIsBarDotTxt) + d.addCallback(lambda res: self.GET(self.public_url + "/foo/bar.txt?t=json")) + d.addCallback(self.failUnlessIsBarJSON) + d.addCallback(lambda res: self.GET(self.public_url + "/foo/baz.txt")) + d.addCallback(self.failUnlessIsBazDotTxt) + return d + + def test_POST_move_file_multi_level(self): + d = self.POST(self.public_url + "/foo/sub/level2?t=mkdir", "") + d.addCallback(lambda res: self.POST(self.public_url + "/foo", t="move", + from_name="bar.txt", to_dir="sub/level2")) + d.addCallback(lambda res: self.failIfNodeHasChild(self._foo_node, u"bar.txt")) + d.addCallback(lambda res: self.failIfNodeHasChild(self._sub_node, u"bar.txt")) + d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/level2/bar.txt")) + d.addCallback(self.failUnlessIsBarDotTxt) + d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/level2/bar.txt?t=json")) + d.addCallback(self.failUnlessIsBarJSON) + return d + + def test_POST_move_file_to_uri(self): + d = self.POST(self.public_url + "/foo", t="move", + from_name="bar.txt", to_dir=self._sub_uri) + d.addCallback(lambda res: + self.failIfNodeHasChild(self._foo_node, u"bar.txt")) + d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/bar.txt")) + d.addCallback(self.failUnlessIsBarDotTxt) + d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/bar.txt?t=json")) + d.addCallback(self.failUnlessIsBarJSON) + return d + + def test_POST_move_file_to_nonexist_dir(self): + d = self.POST(self.public_url + "/foo", t="move", + from_name="bar.txt", to_dir="notchucktesta") + d.addBoth(self.shouldFail, error.Error, + "POST_move_file_to_nonexist_dir", + "404 Not Found", + "No such child: notchucktesta") + return d + + def test_POST_move_file_into_file(self): + d = self.POST(self.public_url + "/foo", t="move", + from_name="bar.txt", to_dir="baz.txt") + d.addBoth(self.shouldFail, error.Error, + "POST_move_file_into_file", + "410 Gone", + "to_dir is not a usable directory") + d.addCallback(lambda res: self.GET(self.public_url + "/foo/baz.txt")) + d.addCallback(self.failUnlessIsBazDotTxt) + d.addCallback(lambda res: self.GET(self.public_url + "/foo/bar.txt")) + d.addCallback(self.failUnlessIsBarDotTxt) + d.addCallback(lambda res: self.GET(self.public_url + "/foo/bar.txt?t=json")) + d.addCallback(self.failUnlessIsBarJSON) + return d + + def test_POST_move_file_to_bad_uri(self): + d = self.POST(self.public_url + "/foo", t="move", from_name="bar.txt", + to_dir="URI:DIR2:mn5jlyjnrjeuydyswlzyui72i:rmneifcj6k6sycjljjhj3f6majsq2zqffydnnul5hfa4j577arma") + d.addBoth(self.shouldFail, error.Error, + "POST_move_file_to_bad_uri", + "410 Gone", + "to_dir is not a usable directory") + d.addCallback(lambda res: self.GET(self.public_url + "/foo/bar.txt")) + d.addCallback(self.failUnlessIsBarDotTxt) + d.addCallback(lambda res: self.GET(self.public_url + "/foo/bar.txt?t=json")) + d.addCallback(self.failUnlessIsBarJSON) + return d + def shouldRedirect(self, res, target=None, statuscode=None, which=""): """ If target is not None then the redirection has to go to target. If statuscode is not None then the redirection has to be accomplished with @@ -3372,6 +3521,15 @@ class Web(WebMixin, WebErrorMixin, testutil.StallMixin, testutil.ReallyEqualMixi d.addCallback(_check) return d + def test_GET_move_form(self): + d = self.GET(self.public_url + "/foo?t=move-form&name=bar.txt", + followRedirect=True) + def _check(res): + self.failUnless('name="when_done" value="."' in res, res) + self.failUnless(re.search(r'name="from_name" value="bar\.txt"', res)) + d.addCallback(_check) + return d + def log(self, res, msg): #print "MSG: %s RES: %s" % (msg, res) log.msg(msg) diff --git a/src/allmydata/uri.py b/src/allmydata/uri.py index 8cb9d10..e2b78a2 100644 --- a/src/allmydata/uri.py +++ b/src/allmydata/uri.py @@ -934,6 +934,13 @@ def is_literal_file_uri(s): s.startswith(ALLEGED_READONLY_PREFIX + 'URI:LIT:') or s.startswith(ALLEGED_IMMUTABLE_PREFIX + 'URI:LIT:')) +def is_writeable_directory_uri(s): + if not isinstance(s, str): + return False + return (s.startswith('URI:DIR2:') or + s.startswith(ALLEGED_READONLY_PREFIX + 'URI:DIR2:') or + s.startswith(ALLEGED_IMMUTABLE_PREFIX + 'URI:DIR2:')) + def has_uri_prefix(s): if not isinstance(s, str): return False diff --git a/src/allmydata/web/directory.py b/src/allmydata/web/directory.py index 58fe781..90ef36a 100644 --- a/src/allmydata/web/directory.py +++ b/src/allmydata/web/directory.py @@ -13,7 +13,7 @@ from nevow.inevow import IRequest from foolscap.api import fireEventually from allmydata.util import base32, time_format -from allmydata.uri import from_string_dirnode +from allmydata.uri import from_string_dirnode, is_writeable_directory_uri from allmydata.interfaces import IDirectoryNode, IFileNode, IFilesystemNode, \ IImmutableFileNode, IMutableFileNode, ExistingChildError, \ NoSuchChildError, EmptyPathnameComponentError, SDMF_VERSION, MDMF_VERSION @@ -169,6 +169,8 @@ class DirectoryNodeHandler(RenderMixin, rend.Page, ReplaceMeMixin): return DirectoryReadonlyURI(ctx, self.node) if t == 'rename-form': return RenameForm(self.node) + if t == 'move-form': + return MoveForm(self.node) raise WebError("GET directory: bad t=%s" % t) @@ -213,6 +215,8 @@ class DirectoryNodeHandler(RenderMixin, rend.Page, ReplaceMeMixin): d = self._POST_unlink(req) elif t == "rename": d = self._POST_rename(req) + elif t == "move": + d = self._POST_move(req) elif t == "check": d = self._POST_check(req) elif t == "start-deep-check": @@ -418,6 +422,52 @@ class DirectoryNodeHandler(RenderMixin, rend.Page, ReplaceMeMixin): d.addCallback(lambda res: "thing renamed") return d + def _POST_move(self, req): + charset = get_arg(req, "_charset", "utf-8") + from_name = get_arg(req, "from_name") + if from_name is not None: + from_name = from_name.strip() + from_name = from_name.decode(charset) + assert isinstance(from_name, unicode) + to_name = get_arg(req, "to_name") + if to_name is not None: + to_name = to_name.strip() + to_name = to_name.decode(charset) + assert isinstance(to_name, unicode) + if not to_name: + to_name = from_name + to_dir = get_arg(req, "to_dir") + if to_dir is not None: + to_dir = to_dir.strip() + to_dir = to_dir.decode(charset) + assert isinstance(to_dir, unicode) + if not from_name or not to_dir: + raise WebError("move requires from_name and to_dir") + replace = boolean_of_arg(get_arg(req, "replace", "true")) + + # allow from_name to contain slashes, so they can fix names that + # were accidentally created with them. But disallow them in to_name + # (if it's specified), to discourage the practice. + if to_name and "/" in to_name: + raise WebError("to_name= may not contain a slash", http.BAD_REQUEST) + + d = self.node.has_child(to_dir.split('/')[0]) + def get_target_node(isname): + if isname or not is_writeable_directory_uri(str(to_dir)): + return self.node.get_child_at_path(to_dir) + else: + return self.client.create_node_from_uri(str(to_dir)) + d.addCallback(get_target_node) + def is_target_node_usable(target_node): + if not IDirectoryNode.providedBy(target_node): + raise WebError("to_dir is not a usable directory", http.GONE) + return target_node + d.addCallback(is_target_node_usable) + d.addCallback(lambda new_parent: self.node.move_child_to( + from_name, new_parent, to_name, replace)) + d.addCallback(lambda res: "thing moved") + return d + def _maybe_literal(self, res, Results_Class): if res: return Results_Class(self.client, res) @@ -662,6 +712,7 @@ class DirectoryAsHTML(rend.Page): if self.node.is_unknown() or self.node.is_readonly(): unlink = "-" rename = "-" + move = "-" else: # this creates a button which will cause our _POST_unlink method # to be invoked, which unlinks the file and then redirects the @@ -680,8 +731,16 @@ class DirectoryAsHTML(rend.Page): T.input(type='submit', value='rename', name="rename"), ] + move = T.form(action=here, method="get")[ + T.input(type='hidden', name='t', value='move-form'), + T.input(type='hidden', name='name', value=name), + T.input(type='hidden', name='when_done', value="."), + T.input(type='submit', value='move', name="move"), + ] + ctx.fillSlots("unlink", unlink) ctx.fillSlots("rename", rename) + ctx.fillSlots("move", move) times = [] linkcrtime = metadata.get('tahoe', {}).get("linkcrtime") @@ -943,6 +1002,32 @@ class RenameForm(rend.Page): ctx.tag.attributes['value'] = name return ctx.tag +class MoveForm(rend.Page): + addSlash = True + docFactory = getxmlfile("move-form.xhtml") + + def render_title(self, ctx, data): + return ctx.tag["Directory SI=%s" % abbreviated_dirnode(self.original)] + + def render_header(self, ctx, data): + header = ["Move " + "from directory SI=%s" % abbreviated_dirnode(self.original), + ] + + if self.original.is_readonly(): + header.append(" (readonly!)") + header.append(":") + return ctx.tag[header] + + def render_when_done(self, ctx, data): + return T.input(type="hidden", name="when_done", value=".") + + def render_get_name(self, ctx, data): + req = IRequest(ctx) + name = get_arg(req, "name", "") + ctx.tag.attributes['value'] = name + return ctx.tag + class ManifestResults(rend.Page, ReloadMixin): docFactory = getxmlfile("manifest.xhtml") diff --git a/src/allmydata/web/directory.xhtml b/src/allmydata/web/directory.xhtml index 4875738..1df38fe 100644 --- a/src/allmydata/web/directory.xhtml +++ b/src/allmydata/web/directory.xhtml @@ -33,6 +33,7 @@ + diff --git a/src/allmydata/web/move-form.xhtml b/src/allmydata/web/move-form.xhtml new file mode 100644 index 0000000..0460add --- /dev/null +++ b/src/allmydata/web/move-form.xhtml @@ -0,0 +1,31 @@ + + + + + + + + + + +

+ +
+
+
+ Rename child + + + + Move child: + + to +
+ New name? + + +
+
+
+ +