dev commited on
Commit
559af37
Β·
1 Parent(s): ab1417e

Add WebDAV server files

Browse files
Files changed (4) hide show
  1. Dockerfile +12 -0
  2. app.py +298 -0
  3. requirements.txt +3 -0
  4. start.sh +9 -0
Dockerfile ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ WORKDIR /app
4
+ COPY requirements.txt .
5
+ RUN pip install --no-cache-dir -r requirements.txt
6
+
7
+ COPY app.py .
8
+ COPY start.sh .
9
+ RUN chmod +x start.sh
10
+
11
+ EXPOSE 7860
12
+ CMD ["/app/start.sh"]
app.py ADDED
@@ -0,0 +1,298 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """WebDAV server backed by Hugging Face Hub (dataset storage)."""
3
+
4
+ import os
5
+ import io
6
+ import logging
7
+ import stat
8
+ from datetime import datetime, timezone
9
+ from typing import Optional
10
+
11
+ from wsgidav.wsgidav_app import WsgiDAVApp
12
+ from wsgidav.dav_provider import DAVProvider, DAVCollection, DAVNonCollection
13
+
14
+ from huggingface_hub import HfFileSystem, hf_hub_download
15
+
16
+ logging.basicConfig(level=logging.INFO)
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # ── Configuration ──────────────────────────────────────────────
20
+ HF_REPO_ID = os.environ.get("HF_REPO_ID", "e2dew32/cloud-drive")
21
+ HF_REPO_TYPE = "dataset"
22
+
23
+ fs = HfFileSystem()
24
+ REPO_ROOT = f"{HF_REPO_TYPE}s/{HF_REPO_ID}"
25
+
26
+
27
+ def _norm(path: str) -> str:
28
+ """Normalise a WebDAV path to an HF path."""
29
+ path = path.strip("/")
30
+ return f"{REPO_ROOT}/{path}" if path else REPO_ROOT
31
+
32
+
33
+ def _stat(path: str) -> dict:
34
+ """Return info dict for a file or directory."""
35
+ try:
36
+ info = fs.info(path)
37
+ return info
38
+ except Exception:
39
+ return {}
40
+
41
+
42
+ # ── DAV Provider ───────────────────────────────────────────────
43
+ class HFDAVProvider(DAVProvider):
44
+ """Expose HF Hub storage as a WebDAV tree."""
45
+
46
+ def __init__(self):
47
+ super().__init__()
48
+
49
+ def get_resource_inst(self, path, environ):
50
+ norm = _norm(path)
51
+ info = _stat(norm)
52
+ if not info:
53
+ return None
54
+
55
+ if info["type"] == "directory":
56
+ return HFDavCollection(path, environ, info)
57
+ return HFDavNonCollection(path, environ, info)
58
+
59
+ def is_collection(self, path):
60
+ info = _stat(_norm(path))
61
+ return info.get("type") == "directory"
62
+
63
+
64
+ class HFDavCollection(DAVCollection):
65
+ def __init__(self, path, environ, info):
66
+ super().__init__(path, environ)
67
+ self._info = info
68
+
69
+ def get_display_name(self):
70
+ return self.path.rstrip("/").split("/")[-1] or "/"
71
+
72
+ def get_member_names(self):
73
+ norm = _norm(self.path)
74
+ entries = fs.ls(norm, detail=False)
75
+ names = []
76
+ for e in entries:
77
+ # e is like "datasets/e2dew32/cloud-drive/foo.txt"
78
+ name = e.rsplit("/", 1)[-1]
79
+ names.append(name)
80
+ return names
81
+
82
+ def get_member(self, name):
83
+ child_path = self.path.rstrip("/") + "/" + name
84
+ return self.provider.get_resource_inst(child_path, self.environ)
85
+
86
+ def create_empty_resource(self, name):
87
+ """Called when client does PUT with no body first."""
88
+ return HFDavNonCollection(
89
+ self.path.rstrip("/") + "/" + name, self.environ, {}
90
+ )
91
+
92
+ def create_collection(self, name):
93
+ """MKCOL – create a directory."""
94
+ new_path = self.path.rstrip("/") + "/" + name
95
+ try:
96
+ fs.mkdir(_norm(new_path), exist_ok=True)
97
+ except Exception as e:
98
+ logger.error(f"mkdir failed: {e}")
99
+
100
+ def get_creation_date(self):
101
+ return datetime.now(timezone.utc)
102
+
103
+ def get_modified_date(self):
104
+ return datetime.now(timezone.utc)
105
+
106
+ def get_content_length(self):
107
+ return 0
108
+
109
+ def get_content_type(self):
110
+ return "httpd/unix-directory"
111
+
112
+ def is_collection(self):
113
+ return True
114
+
115
+ def is_property_locked(self, name):
116
+ return False
117
+
118
+ def get_property_names(self, is_allprop):
119
+ return []
120
+
121
+ def get_property_value(self, name):
122
+ return None
123
+
124
+ def support_recursive_delete(self, path):
125
+ return True
126
+
127
+ def delete(self):
128
+ norm = _norm(self.path)
129
+ try:
130
+ # Recursively delete contents
131
+ entries = fs.ls(norm, detail=False)
132
+ for e in entries:
133
+ rel = e.replace(f"{REPO_ROOT}/", "")
134
+ if fs.isdir(e):
135
+ try:
136
+ fs.rm(e, recursive=True)
137
+ except Exception:
138
+ pass
139
+ else:
140
+ try:
141
+ fs.rm(e)
142
+ except Exception:
143
+ pass
144
+ fs.rmdir(norm)
145
+ except Exception as e:
146
+ logger.error(f"delete collection failed: {e}")
147
+
148
+
149
+ class HFDavNonCollection(DAVNonCollection):
150
+ def __init__(self, path, environ, info):
151
+ super().__init__(path, environ)
152
+ self._info = info
153
+
154
+ def get_display_name(self):
155
+ return self.path.rstrip("/").split("/")[-1]
156
+
157
+ def get_content_length(self):
158
+ return self._info.get("size", 0)
159
+
160
+ def get_content_type(self):
161
+ name = self.get_display_name()
162
+ if "." in name:
163
+ ext = name.rsplit(".", 1)[-1].lower()
164
+ types = {
165
+ "txt": "text/plain",
166
+ "md": "text/markdown",
167
+ "json": "application/json",
168
+ "html": "text/html",
169
+ "css": "text/css",
170
+ "js": "application/javascript",
171
+ "xml": "application/xml",
172
+ "csv": "text/csv",
173
+ "jpg": "image/jpeg",
174
+ "jpeg": "image/jpeg",
175
+ "png": "image/png",
176
+ "gif": "image/gif",
177
+ "svg": "image/svg+xml",
178
+ "pdf": "application/pdf",
179
+ "zip": "application/zip",
180
+ "mp3": "audio/mpeg",
181
+ "mp4": "video/mp4",
182
+ }
183
+ return types.get(ext, "application/octet-stream")
184
+ return "application/octet-stream"
185
+
186
+ def get_creation_date(self):
187
+ return datetime.now(timezone.utc)
188
+
189
+ def get_modified_date(self):
190
+ mtime = self._info.get("last_commit", {}).get("date")
191
+ if mtime:
192
+ try:
193
+ return datetime.fromisoformat(mtime.replace("Z", "+00:00"))
194
+ except Exception:
195
+ pass
196
+ return datetime.now(timezone.utc)
197
+
198
+ def get_content(self):
199
+ norm = _norm(self.path)
200
+ try:
201
+ with fs.open(norm, "rb") as f:
202
+ return io.BytesIO(f.read())
203
+ except Exception as e:
204
+ logger.error(f"read failed: {e}")
205
+ return io.BytesIO(b"")
206
+
207
+ def begin_write(self, content_type=None):
208
+ norm = _norm(self.path)
209
+ logger.info(f"begin_write: {norm}")
210
+ return HFWriteBuffer(norm)
211
+
212
+ def is_property_locked(self, name):
213
+ return False
214
+
215
+ def get_property_names(self, is_allprop):
216
+ return []
217
+
218
+ def get_property_value(self, name):
219
+ return None
220
+
221
+ def delete(self):
222
+ norm = _norm(self.path)
223
+ try:
224
+ fs.rm(norm)
225
+ except Exception as e:
226
+ logger.error(f"delete file failed: {e}")
227
+
228
+ def move_dest(self, dest_provider, dest_path, recursive, dry_run, environ):
229
+ """Handle MOVE (rename)."""
230
+ src_norm = _norm(self.path)
231
+ dst_norm = _norm(dest_path)
232
+ logger.info(f"MOVE {src_norm} -> {dst_norm}")
233
+ try:
234
+ # Download locally then upload to new path
235
+ data = fs.cat(src_norm)
236
+ fs.pipe(dst_norm, data)
237
+ fs.rm(src_norm)
238
+ except Exception as e:
239
+ logger.error(f"move failed: {e}")
240
+
241
+ def copy(self, dest_provider, dest_path, environ, depth="infinity", dry_run=False):
242
+ """Handle COPY."""
243
+ src_norm = _norm(self.path)
244
+ dst_norm = _norm(dest_path)
245
+ logger.info(f"COPY {src_norm} -> {dst_norm}")
246
+ try:
247
+ data = fs.cat(src_norm)
248
+ fs.pipe(dst_norm, data)
249
+ except Exception as e:
250
+ logger.error(f"copy failed: {e}")
251
+
252
+
253
+ class HFWriteBuffer(io.BytesIO):
254
+ """Buffer for writing file content to HF Hub."""
255
+
256
+ def __init__(self, path):
257
+ super().__init__()
258
+ self.path = path
259
+
260
+ def close(self):
261
+ data = self.getvalue()
262
+ try:
263
+ fs.pipe(self.path, data)
264
+ logger.info(f"Written {len(data)} bytes to {self.path}")
265
+ except Exception as e:
266
+ logger.error(f"Write failed: {e}")
267
+ super().close()
268
+
269
+
270
+ # ── WsgiDAVApp setup ──────────────────────────────────────────
271
+ def create_app():
272
+ user = os.environ.get("WEBDAV_USER", "user")
273
+ passwd = os.environ.get("WEBDAV_PASS", "pass")
274
+
275
+ app = WsgiDAVApp(
276
+ {
277
+ "host": "0.0.0.0",
278
+ "port": 7860,
279
+ "provider_mapping": {"/": HFDAVProvider()},
280
+ "simple_dc": {
281
+ "user_mapping": {"*": {user: {"password": passwd, "roles": ["admin"]}}}
282
+ },
283
+ "verbose": 1,
284
+ }
285
+ )
286
+ return app
287
+
288
+
289
+ if __name__ == "__main__":
290
+ from cheroot.wsgi import Server
291
+
292
+ app = create_app()
293
+ server = Server(("0.0.0.0", 7860), app)
294
+ logger.info("Starting WebDAV server on :7860")
295
+ try:
296
+ server.start()
297
+ except KeyboardInterrupt:
298
+ server.stop()
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ wsgidav
2
+ huggingface_hub
3
+ cheroot
start.sh ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+
3
+ echo "=== Starting HF WebDAV Server ==="
4
+ echo "Repo: ${HF_REPO_ID:-e2dew32/cloud-drive}"
5
+ echo "Type: ${HF_REPO_TYPE:-dataset}"
6
+ echo "WebDAV user: ${WEBDAV_USER:-user}"
7
+ echo ""
8
+
9
+ python3 /app/app.py