mirror of
				https://github.com/9001/copyparty.git
				synced 2025-10-30 20:22:20 -06:00 
			
		
		
		
	* allow depth:0 at top of unmapped root * cannot use the Referer header to identify graphical browsers since rclone sends it
		
			
				
	
	
		
			265 lines
		
	
	
		
			7.9 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			265 lines
		
	
	
		
			7.9 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
| #!/usr/bin/env python3
 | |
| # coding: utf-8
 | |
| from __future__ import print_function, unicode_literals
 | |
| 
 | |
| import os
 | |
| import shutil
 | |
| import tempfile
 | |
| import time
 | |
| import unittest
 | |
| 
 | |
| from copyparty.authsrv import AuthSrv
 | |
| from copyparty.httpcli import HttpCli
 | |
| from tests import util as tu
 | |
| from tests.util import TC, Cfg, pfind2ls
 | |
| 
 | |
| # tcpdump of `rclone ls dav:`
 | |
| RCLONE_PROPFIND = """PROPFIND /%s HTTP/1.1
 | |
| Host: 127.0.0.1:3923
 | |
| User-Agent: rclone/v1.67.0
 | |
| Content-Length: 308
 | |
| Authorization: Basic azp1
 | |
| Depth: 1
 | |
| Referer: http://127.0.0.1:3923/
 | |
| Accept-Encoding: gzip
 | |
| 
 | |
| <?xml version="1.0"?>
 | |
| <d:propfind  xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
 | |
|  <d:prop>
 | |
|   <d:displayname />
 | |
|   <d:getlastmodified />
 | |
|   <d:getcontentlength />
 | |
|   <d:resourcetype />
 | |
|   <d:getcontenttype />
 | |
|   <oc:checksums />
 | |
|   <oc:permissions />
 | |
|  </d:prop>
 | |
| </d:propfind>
 | |
| """
 | |
| 
 | |
| 
 | |
| # tcpdump of `rclone copy fa dav:/a/`  (it does a mkcol first)
 | |
| RCLONE_MKCOL = """MKCOL /%s HTTP/1.1
 | |
| Host: 127.0.0.1:3923
 | |
| User-Agent: rclone/v1.67.0
 | |
| Authorization: Basic azp1
 | |
| Referer: http://127.0.0.1:3923/
 | |
| Accept-Encoding: gzip
 | |
| \n"""
 | |
| 
 | |
| 
 | |
| # tcpdump of `rclone copy fa dav:/a/`  (the actual upload)
 | |
| RCLONE_PUT = """PUT /%s HTTP/1.1
 | |
| Host: 127.0.0.1:3923
 | |
| User-Agent: rclone/v1.67.0
 | |
| Content-Length: 6
 | |
| Authorization: Basic azp1
 | |
| Content-Type: application/octet-stream
 | |
| Oc-Checksum: SHA1:f5e3dc3fb27af53cd0005a1184e2df06481199e8
 | |
| Referer: http://127.0.0.1:3923/
 | |
| X-Oc-Mtime: 1689453578
 | |
| Accept-Encoding: gzip
 | |
| 
 | |
| fgsfds"""
 | |
| 
 | |
| 
 | |
| # tcpdump of `rclone delete dav:/a/d1/`  (it does propfind recursively and then this on each file)
 | |
| # (note: `rclone rmdirs dav:/a/d1/` does the same thing but just each folder after asserting they're empty)
 | |
| RCLONE_DELETE = """DELETE /%s HTTP/1.1
 | |
| Host: 127.0.0.1:3923
 | |
| User-Agent: rclone/v1.67.0
 | |
| Authorization: Basic azp1
 | |
| Referer: http://127.0.0.1:3923/
 | |
| Accept-Encoding: gzip
 | |
| \n"""
 | |
| 
 | |
| 
 | |
| # tcpdump of `rclone move dav:/a/d1/d2 /a/d1/d3`  (it does a lot of boilerplate propfinds/mkcols before)
 | |
| RCLONE_MOVE = """MOVE /%s HTTP/1.1
 | |
| Host: 127.0.0.1:3923
 | |
| User-Agent: rclone/v1.67.0
 | |
| Authorization: Basic azp1
 | |
| Destination: http://127.0.0.1:3923/%s
 | |
| Overwrite: T
 | |
| Referer: http://127.0.0.1:3923/
 | |
| Accept-Encoding: gzip
 | |
| \n"""
 | |
| 
 | |
| 
 | |
| class TestHttpCli(TC):
 | |
|     def setUp(self):
 | |
|         self.td = tu.get_ramdisk()
 | |
|         self.maxDiff = 99999
 | |
| 
 | |
|     def tearDown(self):
 | |
|         self.conn.shutdown()
 | |
|         os.chdir(tempfile.gettempdir())
 | |
|         shutil.rmtree(self.td)
 | |
| 
 | |
|     def test(self):
 | |
|         td = os.path.join(self.td, "vfs")
 | |
|         os.mkdir(td)
 | |
|         os.chdir(td)
 | |
| 
 | |
|         self.fn = "g{:x}g".format(int(time.time() * 3))
 | |
|         vcfg = [
 | |
|             "r:r:r,u",
 | |
|             "w:w:w,u",
 | |
|             "a:a:A,u",
 | |
|             "x:x:r,u2",
 | |
|             "x/r:x/r:r,u",
 | |
|             "x/x:x/x:r,u2",
 | |
|         ]
 | |
|         self.args = Cfg(v=vcfg, a=["u:u", "u2:u2"])
 | |
|         self.asrv = AuthSrv(self.args, self.log)
 | |
|         self.conn = tu.VHttpConn(self.args, self.asrv, self.log, b"", True)
 | |
| 
 | |
|         self.fns = ["%s/%s" % (zs.split(":")[0], self.fn) for zs in vcfg]
 | |
|         for fp in self.fns:
 | |
|             try:
 | |
|                 os.makedirs(os.path.dirname(fp))
 | |
|             except:
 | |
|                 pass
 | |
|             with open(fp, "wb") as f:
 | |
|                 f.write(("ok %s\n" % (fp,)).encode("utf-8"))
 | |
| 
 | |
|         ##
 | |
|         ## depth:1 (regular listing)
 | |
| 
 | |
|         # unmapped root; should return list of volumes
 | |
|         h, b = self.req(RCLONE_PROPFIND % ("",))
 | |
|         fns = pfind2ls(b)
 | |
|         self.assertStart("HTTP/1.1 207 Multi-Status\r", h)
 | |
|         self.assertListEqual(fns, ["/", "/a/", "/r/"])
 | |
| 
 | |
|         # toplevel of a volume; has one file
 | |
|         h, b = self.req(RCLONE_PROPFIND % ("a",))
 | |
|         fns = pfind2ls(b)
 | |
|         self.assertStart("HTTP/1.1 207 Multi-Status\r", h)
 | |
|         self.assertListEqual(fns, ["/a/", "/a/" + self.fn])
 | |
| 
 | |
|         # toplevel of a volume; has one file
 | |
|         h, b = self.req(RCLONE_PROPFIND % ("r",))
 | |
|         fns = pfind2ls(b)
 | |
|         self.assertStart("HTTP/1.1 207 Multi-Status\r", h)
 | |
|         self.assertListEqual(fns, ["/r/", "/r/" + self.fn])
 | |
| 
 | |
|         # toplevel of write-only volume; has one file, will not list
 | |
|         h, b = self.req(RCLONE_PROPFIND % ("w",))
 | |
|         fns = pfind2ls(b)
 | |
|         self.assertStart("HTTP/1.1 207 Multi-Status\r", h)
 | |
|         self.assertListEqual(fns, ["/w/"])
 | |
| 
 | |
|         ##
 | |
|         ## auth challenge
 | |
| 
 | |
|         bad_pfind = RCLONE_PROPFIND.replace("Authorization: Basic azp1\n", "")
 | |
|         bad_put = RCLONE_PUT.replace("Authorization: Basic azp1\n", "")
 | |
|         urls = ["", "r", "w", "a"]
 | |
|         urls += [x + "/" + self.fn for x in urls[1:]]
 | |
|         for url in urls:
 | |
|             for q in (bad_pfind, bad_put):
 | |
|                 h, b = self.req(q % (url,))
 | |
|                 self.assertStart("HTTP/1.1 401 Unauthorized\r", h)
 | |
|                 self.assertIn('\nWWW-Authenticate: Basic realm="a"\r', h)
 | |
| 
 | |
|         ##
 | |
|         ## depth:0 (recursion)
 | |
| 
 | |
|         # depth:0 from unmapped root should work;
 | |
|         # will NOT list contents of /x/r/ due to current limitations
 | |
|         # (stops descending at first non-accessible volume)
 | |
|         recursive = RCLONE_PROPFIND.replace("Depth: 1\n", "")
 | |
|         h, b = self.req(recursive % ("",))
 | |
|         fns = pfind2ls(b)
 | |
|         expect = ["/", "/a/", "/r/"]
 | |
|         expect += [x + self.fn for x in expect[1:]]
 | |
|         self.assertListEqual(fns, expect)
 | |
| 
 | |
|         # same thing here...
 | |
|         h, b = self.req(recursive % ("/x",))
 | |
|         fns = pfind2ls(b)
 | |
|         self.assertListEqual(fns, [])
 | |
| 
 | |
|         # but this obviously works
 | |
|         h, b = self.req(recursive % ("/x/r",))
 | |
|         fns = pfind2ls(b)
 | |
|         self.assertListEqual(fns, ["/x/r/", "/x/r/" + self.fn])
 | |
| 
 | |
|         ##
 | |
|         ## uploading
 | |
| 
 | |
|         # rclone does a propfind on the target file first; expects 404
 | |
|         h, b = self.req(RCLONE_PROPFIND % ("a/fa",))
 | |
|         self.assertStart("HTTP/1.1 404 Not Found\r", h)
 | |
| 
 | |
|         # then it does a mkcol (mkdir), expecting 405 (exists)
 | |
|         h, b = self.req(RCLONE_MKCOL % ("a",))
 | |
|         self.assertStart("HTTP/1.1 405 Method Not Allowed\r", h)
 | |
| 
 | |
|         # then it uploads the file
 | |
|         h, b = self.req(RCLONE_PUT % ("a/fa",))
 | |
|         self.assertStart("HTTP/1.1 201 Created\r", h)
 | |
| 
 | |
|         # then it does a propfind to confirm
 | |
|         h, b = self.req(RCLONE_PROPFIND % ("a/fa",))
 | |
|         fns = pfind2ls(b)
 | |
|         self.assertStart("HTTP/1.1 207 Multi-Status\r", h)
 | |
|         self.assertListEqual(fns, ["/a/fa"])
 | |
| 
 | |
|         ##
 | |
|         ## upload into set of subfolders that don't exist yet
 | |
| 
 | |
|         # rclone does this:
 | |
|         # propfind /a/d1/d2/fa => 404
 | |
|         # mkcol /a/d1/d2/ => 409
 | |
|         # propfind /a/d1/d2/ => 404
 | |
|         # mkcol /a/d1/ => 201
 | |
|         # mkcol /a/d1/d2/ => 201
 | |
|         # put /a/d1/d2/fa => 201
 | |
|         # propfind /a/d1/d2/fa => 207
 | |
|         # ...some of which already tested above;
 | |
| 
 | |
|         h, b = self.req(RCLONE_PROPFIND % ("/a/d1/d2/",))
 | |
|         self.assertStart("HTTP/1.1 404 Not Found\r", h)
 | |
| 
 | |
|         h, b = self.req(RCLONE_PROPFIND % ("/a/d1/",))
 | |
|         self.assertStart("HTTP/1.1 404 Not Found\r", h)
 | |
| 
 | |
|         h, b = self.req(RCLONE_MKCOL % ("/a/d1/d2/",))
 | |
|         self.assertStart("HTTP/1.1 409 Conflict\r", h)
 | |
| 
 | |
|         h, b = self.req(RCLONE_MKCOL % ("/a/d1/",))
 | |
|         self.assertStart("HTTP/1.1 201 Created\r", h)
 | |
| 
 | |
|         h, b = self.req(RCLONE_MKCOL % ("/a/d1/d2/",))
 | |
|         self.assertStart("HTTP/1.1 201 Created\r", h)
 | |
| 
 | |
|         h, b = self.req(RCLONE_PUT % ("a/d1/d2/fa",))
 | |
|         self.assertStart("HTTP/1.1 201 Created\r", h)
 | |
| 
 | |
|         ##
 | |
|         ## rename
 | |
| 
 | |
|         h, b = self.req(RCLONE_MOVE % ("a/d1/d2/", "a/d1/d3/"))
 | |
|         self.assertStart("HTTP/1.1 201 Created\r", h)
 | |
|         self.assertListEqual(os.listdir("a/d1"), ["d3"])
 | |
| 
 | |
|         ##
 | |
|         ## delete
 | |
| 
 | |
|         h, b = self.req(RCLONE_DELETE % ("a/d1",))
 | |
|         self.assertStart("HTTP/1.1 200 OK\r", h)
 | |
|         if os.path.exists("a/d1"):
 | |
|             self.fail("a/d1 still exists")
 | |
| 
 | |
|     def req(self, q):
 | |
|         h, b = q.split("\n\n", 1)
 | |
|         q = h.replace("\n", "\r\n") + "\r\n\r\n" + b
 | |
|         conn = self.conn.setbuf(q.encode("utf-8"))
 | |
|         HttpCli(conn).run()
 | |
|         return conn.s._reply.decode("utf-8").split("\r\n\r\n", 1)
 | |
| 
 | |
|     def log(self, src, msg, c=0):
 | |
|         print(msg)
 |