xwing 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- xwing-0.1.0/PKG-INFO +158 -0
- xwing-0.1.0/README.md +126 -0
- xwing-0.1.0/pyproject.toml +57 -0
- xwing-0.1.0/setup.cfg +4 -0
- xwing-0.1.0/tests/test_app.py +305 -0
- xwing-0.1.0/tests/test_files.py +95 -0
- xwing-0.1.0/tests/test_upload.py +244 -0
- xwing-0.1.0/xwing/__init__.py +1 -0
- xwing-0.1.0/xwing/app.py +316 -0
- xwing-0.1.0/xwing/cli.py +138 -0
- xwing-0.1.0/xwing/config.py +28 -0
- xwing-0.1.0/xwing/files.py +91 -0
- xwing-0.1.0/xwing/static/app.js +300 -0
- xwing-0.1.0/xwing/static/codemirror-bundle.js +34 -0
- xwing-0.1.0/xwing/static/editor.js +107 -0
- xwing-0.1.0/xwing/static/favicon.svg +35 -0
- xwing-0.1.0/xwing/static/style.css +271 -0
- xwing-0.1.0/xwing/templates/editor.html +78 -0
- xwing-0.1.0/xwing/templates/index.html +156 -0
- xwing-0.1.0/xwing/upload.py +153 -0
- xwing-0.1.0/xwing/webdav.py +135 -0
- xwing-0.1.0/xwing.egg-info/PKG-INFO +158 -0
- xwing-0.1.0/xwing.egg-info/SOURCES.txt +25 -0
- xwing-0.1.0/xwing.egg-info/dependency_links.txt +1 -0
- xwing-0.1.0/xwing.egg-info/entry_points.txt +2 -0
- xwing-0.1.0/xwing.egg-info/requires.txt +14 -0
- xwing-0.1.0/xwing.egg-info/top_level.txt +1 -0
xwing-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xwing
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: File sharing server with WebDAV support
|
|
5
|
+
Author-email: Anudeep Dhavaleswarapu <anudeepd2@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/anudeepd/xwing
|
|
8
|
+
Project-URL: Repository, https://github.com/anudeepd/xwing
|
|
9
|
+
Project-URL: Issues, https://github.com/anudeepd/xwing/issues
|
|
10
|
+
Keywords: webdav,fileserver,upload,sftp
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Web Environment
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Requires-Dist: fastapi>=0.111.0
|
|
21
|
+
Requires-Dist: uvicorn[standard]>=0.29.0
|
|
22
|
+
Requires-Dist: anyio>=4.0
|
|
23
|
+
Requires-Dist: click>=8.1.0
|
|
24
|
+
Requires-Dist: jinja2>=3.1.0
|
|
25
|
+
Requires-Dist: python-multipart>=0.0.9
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
29
|
+
Requires-Dist: httpx>=0.28.1; extra == "dev"
|
|
30
|
+
Provides-Extra: ldap
|
|
31
|
+
Requires-Dist: ldapgate; extra == "ldap"
|
|
32
|
+
|
|
33
|
+
<p align="center">
|
|
34
|
+
<img src="https://raw.githubusercontent.com/anudeepd/xwing/main/assets/logo.svg" alt="Xwing" width="120"/>
|
|
35
|
+
</p>
|
|
36
|
+
|
|
37
|
+
<h1 align="center">Xwing</h1>
|
|
38
|
+
|
|
39
|
+
<p align="center">A self-contained file sharing server with WebDAV support. Works out of the box or integrates with LDAPGate for corporate LDAP/AD authentication.</p>
|
|
40
|
+
|
|
41
|
+
## Features
|
|
42
|
+
|
|
43
|
+
- **WebDAV server** — mount as a drive on Windows, macOS, and Linux using native WebDAV clients
|
|
44
|
+
- **Resumable uploads** — chunked uploads with session recovery; supports large files (up to 10 GB)
|
|
45
|
+
- **Browser-based file browser** — drag-and-drop upload, directory creation, zip download, file delete
|
|
46
|
+
- **In-browser text editor** — CodeMirror-powered editor for text files (configurable via `_EDITABLE_EXTS`); files over 2 MB are not editable
|
|
47
|
+
- **WebDAV COPY / MOVE** — server-side file and directory copy/move via `Destination` header
|
|
48
|
+
- **Optional LDAP / AD authentication** — via [LDAPGate](https://github.com/anudeepd/ldapgate)
|
|
49
|
+
- **Single self-contained wheel** — no external CDN dependencies; fonts embedded as base64 WOFF2
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install xwing
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
For LDAP/AD authentication:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install 'xwing[ldap]'
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Usage
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
xwing serve --root /path/to/serve
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Opens the file browser at `http://127.0.0.1:8989` and launches your default browser.
|
|
70
|
+
|
|
71
|
+
Options:
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
--root PATH Root directory to serve. [required]
|
|
75
|
+
--host TEXT Bind host. [default: 127.0.0.1]
|
|
76
|
+
--port INTEGER Bind port. [default: 8989]
|
|
77
|
+
--no-open Don't open the browser automatically.
|
|
78
|
+
--max-upload-gb NUM Max upload size in GB. [default: 10]
|
|
79
|
+
--require-auth Require X-Forwarded-User header (403 if missing).
|
|
80
|
+
--user-header TEXT Header to read username from. [default: X-Forwarded-User]
|
|
81
|
+
--reload Auto-reload on code changes (dev only).
|
|
82
|
+
--ldap-config PATH Path to LDAPGate YAML config to enable LDAP authentication.
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### WebDAV Mount Examples
|
|
86
|
+
|
|
87
|
+
**Linux (DAVfs2):**
|
|
88
|
+
```bash
|
|
89
|
+
sudo mount.davfs http://localhost:8989 /mnt/xwing -o username=<user>
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**macOS:**
|
|
93
|
+
```bash
|
|
94
|
+
open http://localhost:8989
|
|
95
|
+
# Or mount: Finder → Go → Connect to Server → http://localhost:8989
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
**Windows (native WebDAV):**
|
|
99
|
+
```
|
|
100
|
+
net use Z: \\localhost@8989\DavWWWRoot /persistent:yes
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Resumable Upload (Chunked)
|
|
104
|
+
|
|
105
|
+
For large files, use the chunked upload API:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
# 1. Init session
|
|
109
|
+
curl -X POST http://localhost:8989/_upload/init \
|
|
110
|
+
-H "Content-Type: application/json" \
|
|
111
|
+
-d '{"filename": "big.iso", "total_chunks": 100, "dir": "/"}'
|
|
112
|
+
|
|
113
|
+
# 2. Upload each chunk
|
|
114
|
+
curl -X PUT http://localhost:8989/_upload/<session_id>/<chunk_index> \
|
|
115
|
+
--data-binary @chunk.part
|
|
116
|
+
|
|
117
|
+
# 3. Complete
|
|
118
|
+
curl -X POST http://localhost:8989/_upload/<session_id>/complete
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## LDAP / Active Directory Authentication
|
|
122
|
+
|
|
123
|
+
Xwing supports two modes for LDAP/AD auth:
|
|
124
|
+
|
|
125
|
+
**Mode 1 — Standalone proxy:** Run LDAPGate as a reverse proxy in front of xwing. Authenticated requests get an `X-Forwarded-User` header that xwing reads.
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
Browser → LDAPGate → xwing
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
ldapgate serve --config ldapgate.yaml
|
|
133
|
+
xwing serve --root /data --require-auth
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
**Mode 2 — Built-in middleware:** Inject LDAPGate directly into xwing as FastAPI middleware:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
pip install 'xwing[ldap]'
|
|
140
|
+
xwing serve --root /data --ldap-config /path/to/ldapgate.yaml
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
See the [LDAPGate README](https://github.com/anudeepd/ldapgate) for config file documentation.
|
|
144
|
+
|
|
145
|
+
## Development
|
|
146
|
+
|
|
147
|
+
Requires [uv](https://github.com/astral-sh/uv).
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
git clone https://github.com/anudeepd/xwing
|
|
151
|
+
cd xwing
|
|
152
|
+
uv sync
|
|
153
|
+
uv run xwing serve --root .
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## License
|
|
157
|
+
|
|
158
|
+
MIT
|
xwing-0.1.0/README.md
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/anudeepd/xwing/main/assets/logo.svg" alt="Xwing" width="120"/>
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">Xwing</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center">A self-contained file sharing server with WebDAV support. Works out of the box or integrates with LDAPGate for corporate LDAP/AD authentication.</p>
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- **WebDAV server** — mount as a drive on Windows, macOS, and Linux using native WebDAV clients
|
|
12
|
+
- **Resumable uploads** — chunked uploads with session recovery; supports large files (up to 10 GB)
|
|
13
|
+
- **Browser-based file browser** — drag-and-drop upload, directory creation, zip download, file delete
|
|
14
|
+
- **In-browser text editor** — CodeMirror-powered editor for text files (configurable via `_EDITABLE_EXTS`); files over 2 MB are not editable
|
|
15
|
+
- **WebDAV COPY / MOVE** — server-side file and directory copy/move via `Destination` header
|
|
16
|
+
- **Optional LDAP / AD authentication** — via [LDAPGate](https://github.com/anudeepd/ldapgate)
|
|
17
|
+
- **Single self-contained wheel** — no external CDN dependencies; fonts embedded as base64 WOFF2
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install xwing
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
For LDAP/AD authentication:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install 'xwing[ldap]'
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
xwing serve --root /path/to/serve
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Opens the file browser at `http://127.0.0.1:8989` and launches your default browser.
|
|
38
|
+
|
|
39
|
+
Options:
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
--root PATH Root directory to serve. [required]
|
|
43
|
+
--host TEXT Bind host. [default: 127.0.0.1]
|
|
44
|
+
--port INTEGER Bind port. [default: 8989]
|
|
45
|
+
--no-open Don't open the browser automatically.
|
|
46
|
+
--max-upload-gb NUM Max upload size in GB. [default: 10]
|
|
47
|
+
--require-auth Require X-Forwarded-User header (403 if missing).
|
|
48
|
+
--user-header TEXT Header to read username from. [default: X-Forwarded-User]
|
|
49
|
+
--reload Auto-reload on code changes (dev only).
|
|
50
|
+
--ldap-config PATH Path to LDAPGate YAML config to enable LDAP authentication.
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### WebDAV Mount Examples
|
|
54
|
+
|
|
55
|
+
**Linux (DAVfs2):**
|
|
56
|
+
```bash
|
|
57
|
+
sudo mount.davfs http://localhost:8989 /mnt/xwing -o username=<user>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**macOS:**
|
|
61
|
+
```bash
|
|
62
|
+
open http://localhost:8989
|
|
63
|
+
# Or mount: Finder → Go → Connect to Server → http://localhost:8989
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**Windows (native WebDAV):**
|
|
67
|
+
```
|
|
68
|
+
net use Z: \\localhost@8989\DavWWWRoot /persistent:yes
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Resumable Upload (Chunked)
|
|
72
|
+
|
|
73
|
+
For large files, use the chunked upload API:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
# 1. Init session
|
|
77
|
+
curl -X POST http://localhost:8989/_upload/init \
|
|
78
|
+
-H "Content-Type: application/json" \
|
|
79
|
+
-d '{"filename": "big.iso", "total_chunks": 100, "dir": "/"}'
|
|
80
|
+
|
|
81
|
+
# 2. Upload each chunk
|
|
82
|
+
curl -X PUT http://localhost:8989/_upload/<session_id>/<chunk_index> \
|
|
83
|
+
--data-binary @chunk.part
|
|
84
|
+
|
|
85
|
+
# 3. Complete
|
|
86
|
+
curl -X POST http://localhost:8989/_upload/<session_id>/complete
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## LDAP / Active Directory Authentication
|
|
90
|
+
|
|
91
|
+
Xwing supports two modes for LDAP/AD auth:
|
|
92
|
+
|
|
93
|
+
**Mode 1 — Standalone proxy:** Run LDAPGate as a reverse proxy in front of xwing. Authenticated requests get an `X-Forwarded-User` header that xwing reads.
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
Browser → LDAPGate → xwing
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
ldapgate serve --config ldapgate.yaml
|
|
101
|
+
xwing serve --root /data --require-auth
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
**Mode 2 — Built-in middleware:** Inject LDAPGate directly into xwing as FastAPI middleware:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
pip install 'xwing[ldap]'
|
|
108
|
+
xwing serve --root /data --ldap-config /path/to/ldapgate.yaml
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
See the [LDAPGate README](https://github.com/anudeepd/ldapgate) for config file documentation.
|
|
112
|
+
|
|
113
|
+
## Development
|
|
114
|
+
|
|
115
|
+
Requires [uv](https://github.com/astral-sh/uv).
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
git clone https://github.com/anudeepd/xwing
|
|
119
|
+
cd xwing
|
|
120
|
+
uv sync
|
|
121
|
+
uv run xwing serve --root .
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## License
|
|
125
|
+
|
|
126
|
+
MIT
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "xwing"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "File sharing server with WebDAV support"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{name = "Anudeep Dhavaleswarapu", email = "anudeepd2@gmail.com"}]
|
|
13
|
+
keywords = ["webdav", "fileserver", "upload", "sftp"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Environment :: Web Environment",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
dependencies = [
|
|
25
|
+
"fastapi>=0.111.0",
|
|
26
|
+
"uvicorn[standard]>=0.29.0",
|
|
27
|
+
"anyio>=4.0",
|
|
28
|
+
"click>=8.1.0",
|
|
29
|
+
"jinja2>=3.1.0",
|
|
30
|
+
"python-multipart>=0.0.9",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
dev = [
|
|
35
|
+
"pytest>=7.0",
|
|
36
|
+
"pytest-asyncio>=0.21",
|
|
37
|
+
"httpx>=0.28.1",
|
|
38
|
+
]
|
|
39
|
+
ldap = ["ldapgate"]
|
|
40
|
+
|
|
41
|
+
[project.urls]
|
|
42
|
+
Homepage = "https://github.com/anudeepd/xwing"
|
|
43
|
+
Repository = "https://github.com/anudeepd/xwing"
|
|
44
|
+
Issues = "https://github.com/anudeepd/xwing/issues"
|
|
45
|
+
|
|
46
|
+
[project.scripts]
|
|
47
|
+
xwing = "xwing.cli:main"
|
|
48
|
+
|
|
49
|
+
[tool.setuptools.packages.find]
|
|
50
|
+
where = ["."]
|
|
51
|
+
include = ["xwing*"]
|
|
52
|
+
|
|
53
|
+
[tool.setuptools.package-data]
|
|
54
|
+
xwing = ["static/**/*", "static/*", "templates/*"]
|
|
55
|
+
|
|
56
|
+
[tool.pytest.ini_options]
|
|
57
|
+
asyncio_mode = "auto"
|
xwing-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import zipfile
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from fastapi.testclient import TestClient
|
|
7
|
+
|
|
8
|
+
from xwing.app import create_app
|
|
9
|
+
from xwing.config import Settings
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
HTML = {"Accept": "text/html"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TestDirectoryListing:
|
|
16
|
+
def test_root_listing(self, client, root):
|
|
17
|
+
(root / "hello.txt").write_text("hi")
|
|
18
|
+
(root / "subdir").mkdir()
|
|
19
|
+
r = client.get("/", headers=HTML)
|
|
20
|
+
assert r.status_code == 200
|
|
21
|
+
assert "hello.txt" in r.text
|
|
22
|
+
assert "subdir" in r.text
|
|
23
|
+
|
|
24
|
+
def test_subdir_listing(self, client, root):
|
|
25
|
+
d = root / "docs"
|
|
26
|
+
d.mkdir()
|
|
27
|
+
(d / "readme.md").write_text("docs")
|
|
28
|
+
r = client.get("/docs/", headers=HTML)
|
|
29
|
+
assert r.status_code == 200
|
|
30
|
+
assert "readme.md" in r.text
|
|
31
|
+
|
|
32
|
+
def test_missing_dir_returns_404(self, client):
|
|
33
|
+
r = client.get("/nonexistent/", headers=HTML)
|
|
34
|
+
assert r.status_code == 404
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TestFileDownload:
|
|
38
|
+
def test_download_file(self, client, root):
|
|
39
|
+
(root / "data.txt").write_text("content here")
|
|
40
|
+
r = client.get("/data.txt")
|
|
41
|
+
assert r.status_code == 200
|
|
42
|
+
assert r.text == "content here"
|
|
43
|
+
|
|
44
|
+
def test_missing_file_returns_404(self, client):
|
|
45
|
+
r = client.get("/nope.txt")
|
|
46
|
+
assert r.status_code == 404
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TestAuth:
|
|
50
|
+
def test_require_auth_blocks_anonymous(self, root, tmp_dir):
|
|
51
|
+
s = Settings(root_dir=root, tmp_dir=tmp_dir, require_auth=True)
|
|
52
|
+
with TestClient(create_app(s)) as c:
|
|
53
|
+
r = c.get("/")
|
|
54
|
+
assert r.status_code == 403
|
|
55
|
+
|
|
56
|
+
def test_require_auth_passes_with_header(self, root, tmp_dir):
|
|
57
|
+
s = Settings(root_dir=root, tmp_dir=tmp_dir, require_auth=True)
|
|
58
|
+
with TestClient(create_app(s)) as c:
|
|
59
|
+
r = c.get("/", headers={**HTML, "X-Forwarded-User": "alice"})
|
|
60
|
+
assert r.status_code == 200
|
|
61
|
+
|
|
62
|
+
def test_no_auth_returns_anonymous(self, client, root):
|
|
63
|
+
r = client.get("/", headers=HTML)
|
|
64
|
+
assert r.status_code == 200
|
|
65
|
+
assert "anonymous" in r.text
|
|
66
|
+
|
|
67
|
+
def test_custom_user_header(self, root, tmp_dir):
|
|
68
|
+
s = Settings(root_dir=root, tmp_dir=tmp_dir, user_header="X-Remote-User")
|
|
69
|
+
with TestClient(create_app(s)) as c:
|
|
70
|
+
r = c.get("/", headers={**HTML, "X-Remote-User": "bob"})
|
|
71
|
+
assert r.status_code == 200
|
|
72
|
+
assert "bob" in r.text
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class TestPut:
|
|
76
|
+
def test_put_creates_file(self, client, root):
|
|
77
|
+
r = client.put("/newfile.txt", content=b"hello")
|
|
78
|
+
assert r.status_code == 204
|
|
79
|
+
assert (root / "newfile.txt").read_bytes() == b"hello"
|
|
80
|
+
|
|
81
|
+
def test_put_overwrites_file(self, client, root):
|
|
82
|
+
(root / "existing.txt").write_text("old")
|
|
83
|
+
r = client.put("/existing.txt", content=b"new")
|
|
84
|
+
assert r.status_code == 204
|
|
85
|
+
assert (root / "existing.txt").read_bytes() == b"new"
|
|
86
|
+
|
|
87
|
+
def test_put_on_directory_returns_409(self, client, root):
|
|
88
|
+
(root / "adir").mkdir()
|
|
89
|
+
r = client.put("/adir", content=b"data")
|
|
90
|
+
assert r.status_code == 409
|
|
91
|
+
|
|
92
|
+
def test_put_exceeds_max_upload_bytes_returns_413(self, root, tmp_dir):
|
|
93
|
+
s = Settings(root_dir=root, tmp_dir=tmp_dir, max_upload_bytes=10)
|
|
94
|
+
with TestClient(create_app(s)) as c:
|
|
95
|
+
r = c.put("/large.txt", content=b"x" * 100)
|
|
96
|
+
assert r.status_code == 413
|
|
97
|
+
assert not (root / "large.txt").exists()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class TestHead:
|
|
101
|
+
def test_head_on_file_returns_headers(self, client, root):
|
|
102
|
+
(root / "data.txt").write_text("content")
|
|
103
|
+
r = client.head("/data.txt")
|
|
104
|
+
assert r.status_code == 200
|
|
105
|
+
|
|
106
|
+
def test_head_on_dir_returns_200(self, client, root):
|
|
107
|
+
(root / "subdir").mkdir()
|
|
108
|
+
r = client.head("/")
|
|
109
|
+
assert r.status_code == 200
|
|
110
|
+
|
|
111
|
+
def test_head_does_not_return_body(self, client, root):
|
|
112
|
+
(root / "data.txt").write_text("content")
|
|
113
|
+
r = client.head("/data.txt")
|
|
114
|
+
assert r.content == b""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class TestZip:
|
|
118
|
+
def test_zip_directory(self, client, root):
|
|
119
|
+
(root / "a.txt").write_text("hello")
|
|
120
|
+
(root / "sub").mkdir()
|
|
121
|
+
(root / "sub" / "b.txt").write_text("world")
|
|
122
|
+
r = client.get("/?zip")
|
|
123
|
+
assert r.status_code == 200
|
|
124
|
+
assert r.headers["content-type"] == "application/zip"
|
|
125
|
+
zf = zipfile.ZipFile(io.BytesIO(r.content))
|
|
126
|
+
names = zf.namelist()
|
|
127
|
+
assert "a.txt" in names
|
|
128
|
+
assert "sub/b.txt" in names
|
|
129
|
+
|
|
130
|
+
def test_zip_nested_files(self, client, root):
|
|
131
|
+
(root / "deep").mkdir()
|
|
132
|
+
(root / "deep" / "nested.txt").write_text("deep content")
|
|
133
|
+
r = client.get("/deep/?zip")
|
|
134
|
+
assert r.status_code == 200
|
|
135
|
+
zf = zipfile.ZipFile(io.BytesIO(r.content))
|
|
136
|
+
assert "nested.txt" in zf.namelist()
|
|
137
|
+
|
|
138
|
+
def test_zip_empty_directory(self, client, root):
|
|
139
|
+
(root / "empty").mkdir()
|
|
140
|
+
r = client.get("/empty/?zip")
|
|
141
|
+
assert r.status_code == 200
|
|
142
|
+
assert r.headers["content-type"] == "application/zip"
|
|
143
|
+
zf = zipfile.ZipFile(io.BytesIO(r.content))
|
|
144
|
+
assert len(zf.namelist()) == 0
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class TestCopy:
|
|
148
|
+
def test_copy_file(self, client, root):
|
|
149
|
+
(root / "src.txt").write_text("source")
|
|
150
|
+
r = client.request("COPY", "/src.txt", headers={"Destination": "/dst.txt"})
|
|
151
|
+
assert r.status_code == 201
|
|
152
|
+
assert (root / "src.txt").read_text() == "source"
|
|
153
|
+
assert (root / "dst.txt").read_text() == "source"
|
|
154
|
+
|
|
155
|
+
def test_copy_file_overwrite_t(self, client, root):
|
|
156
|
+
(root / "src.txt").write_text("source")
|
|
157
|
+
(root / "dst.txt").write_text("old")
|
|
158
|
+
r = client.request(
|
|
159
|
+
"COPY", "/src.txt", headers={"Destination": "/dst.txt", "Overwrite": "T"}
|
|
160
|
+
)
|
|
161
|
+
assert r.status_code == 201
|
|
162
|
+
assert (root / "dst.txt").read_text() == "source"
|
|
163
|
+
|
|
164
|
+
def test_copy_file_overwrite_f_returns_412(self, client, root):
|
|
165
|
+
(root / "src.txt").write_text("source")
|
|
166
|
+
(root / "dst.txt").write_text("old")
|
|
167
|
+
r = client.request(
|
|
168
|
+
"COPY", "/src.txt", headers={"Destination": "/dst.txt", "Overwrite": "F"}
|
|
169
|
+
)
|
|
170
|
+
assert r.status_code == 412
|
|
171
|
+
|
|
172
|
+
def test_copy_missing_source_returns_404(self, client):
|
|
173
|
+
r = client.request("COPY", "/ghost.txt", headers={"Destination": "/dst.txt"})
|
|
174
|
+
assert r.status_code == 404
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class TestMove:
|
|
178
|
+
def test_move_file(self, client, root):
|
|
179
|
+
(root / "src.txt").write_text("source")
|
|
180
|
+
r = client.request("MOVE", "/src.txt", headers={"Destination": "/dst.txt"})
|
|
181
|
+
assert r.status_code == 201
|
|
182
|
+
assert not (root / "src.txt").exists()
|
|
183
|
+
assert (root / "dst.txt").read_text() == "source"
|
|
184
|
+
|
|
185
|
+
def test_move_file_overwrite_f_returns_412(self, client, root):
|
|
186
|
+
(root / "src.txt").write_text("source")
|
|
187
|
+
(root / "dst.txt").write_text("old")
|
|
188
|
+
r = client.request(
|
|
189
|
+
"MOVE", "/src.txt", headers={"Destination": "/dst.txt", "Overwrite": "F"}
|
|
190
|
+
)
|
|
191
|
+
assert r.status_code == 412
|
|
192
|
+
assert (root / "src.txt").read_text() == "source"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class TestEnvFile:
|
|
196
|
+
def test_env_file_not_downloadable(self, client, root):
|
|
197
|
+
(root / ".env").write_text("SECRET=hunter2")
|
|
198
|
+
r = client.get("/.env")
|
|
199
|
+
assert r.status_code == 403
|
|
200
|
+
|
|
201
|
+
def test_env_file_variant_not_downloadable(self, client, root):
|
|
202
|
+
(root / ".env.local").write_text("SECRET=local")
|
|
203
|
+
r = client.get("/.env.local")
|
|
204
|
+
assert r.status_code == 403
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class TestSymlinkInsideRoot:
|
|
208
|
+
def test_symlink_inside_root_allowed(self, root):
|
|
209
|
+
target = root / "actual.txt"
|
|
210
|
+
target.write_text("hello")
|
|
211
|
+
link = root / "link.txt"
|
|
212
|
+
link.symlink_to(target)
|
|
213
|
+
from xwing.files import safe_path
|
|
214
|
+
|
|
215
|
+
result = safe_path(root, "link.txt")
|
|
216
|
+
assert result.resolve() == target.resolve()
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class TestDelete:
|
|
220
|
+
def test_delete_file(self, client, root):
|
|
221
|
+
f = root / "todelete.txt"
|
|
222
|
+
f.write_text("bye")
|
|
223
|
+
r = client.delete("/todelete.txt")
|
|
224
|
+
assert r.status_code == 204
|
|
225
|
+
assert not f.exists()
|
|
226
|
+
|
|
227
|
+
def test_delete_directory(self, client, root):
|
|
228
|
+
d = root / "rmdir"
|
|
229
|
+
d.mkdir()
|
|
230
|
+
(d / "child.txt").write_text("x")
|
|
231
|
+
r = client.delete("/rmdir/")
|
|
232
|
+
assert r.status_code == 204
|
|
233
|
+
assert not d.exists()
|
|
234
|
+
|
|
235
|
+
def test_delete_missing_returns_404(self, client):
|
|
236
|
+
r = client.delete("/ghost.txt")
|
|
237
|
+
assert r.status_code == 404
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class TestOptions:
|
|
241
|
+
def test_options_returns_dav_headers(self, client):
|
|
242
|
+
r = client.options("/")
|
|
243
|
+
assert r.status_code == 200
|
|
244
|
+
assert "DAV" in r.headers
|
|
245
|
+
assert "PROPFIND" in r.headers.get("Allow", "")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class TestMkcol:
|
|
249
|
+
def test_mkcol_creates_directory(self, client, root):
|
|
250
|
+
r = client.request("MKCOL", "/newdir")
|
|
251
|
+
assert r.status_code == 201
|
|
252
|
+
assert (root / "newdir").is_dir()
|
|
253
|
+
|
|
254
|
+
def test_mkcol_existing_returns_405(self, client, root):
|
|
255
|
+
(root / "exists").mkdir()
|
|
256
|
+
r = client.request("MKCOL", "/exists")
|
|
257
|
+
assert r.status_code == 405
|
|
258
|
+
|
|
259
|
+
def test_mkcol_missing_parent_returns_409(self, client, root):
|
|
260
|
+
r = client.request("MKCOL", "/parent/child")
|
|
261
|
+
assert r.status_code == 409
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class TestPropfind:
|
|
265
|
+
def test_propfind_root(self, client, root):
|
|
266
|
+
(root / "a.txt").write_text("a")
|
|
267
|
+
r = client.request("PROPFIND", "/", headers={"Depth": "1"})
|
|
268
|
+
assert r.status_code == 207
|
|
269
|
+
assert "a.txt" in r.text
|
|
270
|
+
|
|
271
|
+
def test_propfind_depth0(self, client, root):
|
|
272
|
+
r = client.request("PROPFIND", "/", headers={"Depth": "0"})
|
|
273
|
+
assert r.status_code == 207
|
|
274
|
+
|
|
275
|
+
def test_propfind_infinity_rejected(self, client):
|
|
276
|
+
r = client.request("PROPFIND", "/", headers={"Depth": "infinity"})
|
|
277
|
+
assert r.status_code == 403
|
|
278
|
+
|
|
279
|
+
def test_propfind_missing_returns_404(self, client):
|
|
280
|
+
r = client.request("PROPFIND", "/ghost")
|
|
281
|
+
assert r.status_code == 404
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class TestLock:
|
|
285
|
+
def test_lock_returns_501(self, client, root):
|
|
286
|
+
(root / "file.txt").write_text("x")
|
|
287
|
+
r = client.request("LOCK", "/file.txt")
|
|
288
|
+
assert r.status_code == 501
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class TestPathTraversal:
|
|
292
|
+
def test_traversal_in_url_path_normalized_by_http(self, client):
|
|
293
|
+
# HTTP normalises ../../ in URLs before routing — the path arrives
|
|
294
|
+
# as the normalised form, which simply won't exist under root → 404.
|
|
295
|
+
r = client.get("/../../etc/passwd")
|
|
296
|
+
assert r.status_code == 404
|
|
297
|
+
|
|
298
|
+
def test_traversal_in_destination_rejected(self, client, root):
|
|
299
|
+
(root / "src.txt").write_text("src")
|
|
300
|
+
r = client.request(
|
|
301
|
+
"COPY",
|
|
302
|
+
"/src.txt",
|
|
303
|
+
headers={"Destination": "http://localhost/../../etc/passwd"},
|
|
304
|
+
)
|
|
305
|
+
assert r.status_code == 403
|