remote-fileshare 1.1.0__py3-none-any.whl
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.
- remote_fileshare/__init__.py +3 -0
- remote_fileshare/__main__.py +3 -0
- remote_fileshare/server.py +712 -0
- remote_fileshare/ui.py +12 -0
- remote_fileshare-1.1.0.dist-info/METADATA +208 -0
- remote_fileshare-1.1.0.dist-info/RECORD +9 -0
- remote_fileshare-1.1.0.dist-info/WHEEL +4 -0
- remote_fileshare-1.1.0.dist-info/entry_points.txt +2 -0
- remote_fileshare-1.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
"""remote-fileshare 的 Python 实现,只用标准库。
|
|
2
|
+
|
|
3
|
+
与 Node 版 remote-fileshare.js 功能、接口和网页完全一致(同一套测试)。网页界面来自 ui.py,
|
|
4
|
+
由 scripts/sync-ui.js 从 Node 版生成。用法:uvx remote-fileshare [共享目录]
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
import errno
|
|
9
|
+
import hashlib
|
|
10
|
+
import hmac
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import secrets
|
|
15
|
+
import shutil
|
|
16
|
+
import signal
|
|
17
|
+
import socket
|
|
18
|
+
import stat as statmod
|
|
19
|
+
import sys
|
|
20
|
+
import tarfile
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
import urllib.parse
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
from decimal import ROUND_HALF_UP, Decimal
|
|
26
|
+
from email.utils import formatdate
|
|
27
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
28
|
+
|
|
29
|
+
from . import __version__, ui
|
|
30
|
+
|
|
31
|
+
HELP = """remote-fileshare {version} — 远程文件共享
|
|
32
|
+
|
|
33
|
+
用法:remote-fileshare [共享目录]
|
|
34
|
+
|
|
35
|
+
环境变量:
|
|
36
|
+
PORT 监听端口(默认 8001)
|
|
37
|
+
HOST 监听地址(默认 0.0.0.0)
|
|
38
|
+
FS_USER 登录用户名(默认 admin)
|
|
39
|
+
PASS 登录密码(不设置则首次运行随机生成,并保存到密码文件)
|
|
40
|
+
PASS_FILE 密码文件路径(默认 ~/.config/remote-fileshare/password)
|
|
41
|
+
TRUST_PROXY=1 放在本机反向代理(Caddy/Nginx)后面时设置,从 X-Forwarded-For 读取真实客户端 IP"""
|
|
42
|
+
|
|
43
|
+
UPLOAD_TMP = re.compile(r"^\..+\.[0-9a-f]{16}\.upload$")
|
|
44
|
+
CHUNK = 256 * 1024
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class HttpError(Exception):
|
|
48
|
+
def __init__(self, status, message):
|
|
49
|
+
super().__init__(message)
|
|
50
|
+
self.status = status
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
ERRNO_STATUS = {
|
|
54
|
+
errno.ENOENT: 404, errno.ENOTDIR: 404, errno.EEXIST: 409, errno.ENOTEMPTY: 409,
|
|
55
|
+
errno.EACCES: 403, errno.EPERM: 403, errno.EXDEV: 400, errno.ENOSPC: 507,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def sha256(value):
|
|
60
|
+
return hashlib.sha256(value.encode("utf-8", "surrogatepass")).digest()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Config:
|
|
64
|
+
def __init__(self):
|
|
65
|
+
args = [a for a in sys.argv[1:] if not a.startswith("-")]
|
|
66
|
+
self.user = os.environ.get("FS_USER") or "admin"
|
|
67
|
+
try:
|
|
68
|
+
self.port = int(os.environ.get("PORT") or 8001) or 8001
|
|
69
|
+
except ValueError:
|
|
70
|
+
self.port = 8001
|
|
71
|
+
self.host = os.environ.get("HOST") or "0.0.0.0"
|
|
72
|
+
self.trust_proxy = os.environ.get("TRUST_PROXY") == "1"
|
|
73
|
+
self.pass_file = os.environ.get("PASS_FILE") or os.path.join(os.path.expanduser("~"), ".config", "remote-fileshare", "password")
|
|
74
|
+
root = os.path.abspath(args[0] if args else ".")
|
|
75
|
+
if not os.path.isdir(root):
|
|
76
|
+
sys.exit(f"共享目录不存在:{root}")
|
|
77
|
+
self.root = os.path.realpath(root)
|
|
78
|
+
self.password_generated = False
|
|
79
|
+
self.password = self.load_password()
|
|
80
|
+
self.password_hash = sha256(self.password)
|
|
81
|
+
|
|
82
|
+
def load_password(self):
|
|
83
|
+
if os.environ.get("PASS"):
|
|
84
|
+
return os.environ["PASS"]
|
|
85
|
+
try:
|
|
86
|
+
with open(self.pass_file, encoding="utf-8") as f:
|
|
87
|
+
saved = f.read().strip()
|
|
88
|
+
if saved:
|
|
89
|
+
return saved
|
|
90
|
+
except FileNotFoundError:
|
|
91
|
+
pass
|
|
92
|
+
generated = secrets.token_urlsafe(18)
|
|
93
|
+
os.makedirs(os.path.dirname(self.pass_file), mode=0o700, exist_ok=True)
|
|
94
|
+
fd = os.open(self.pass_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
95
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
96
|
+
f.write(generated + "\n")
|
|
97
|
+
self.password_generated = True
|
|
98
|
+
return generated
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# 登录失败限流:同一 IP 在 WINDOW 内失败 MAX_FAILS 次后锁定 WINDOW。
|
|
102
|
+
MAX_FAILS, WINDOW = 10, 10 * 60
|
|
103
|
+
failures = {}
|
|
104
|
+
failures_lock = threading.Lock()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def locked(ip):
|
|
108
|
+
with failures_lock:
|
|
109
|
+
f = failures.get(ip)
|
|
110
|
+
if not f:
|
|
111
|
+
return False
|
|
112
|
+
if time.time() - f["since"] > WINDOW:
|
|
113
|
+
del failures[ip]
|
|
114
|
+
return False
|
|
115
|
+
return f["count"] >= MAX_FAILS
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def record_failure(ip):
|
|
119
|
+
with failures_lock:
|
|
120
|
+
now = time.time()
|
|
121
|
+
f = failures.get(ip)
|
|
122
|
+
if not f or now - f["since"] > WINDOW:
|
|
123
|
+
failures[ip] = {"count": 1, "since": now}
|
|
124
|
+
else:
|
|
125
|
+
f["count"] += 1
|
|
126
|
+
if len(failures) > 10000:
|
|
127
|
+
for k in [k for k, v in failures.items() if now - v["since"] > WINDOW]:
|
|
128
|
+
del failures[k]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def clear_failures(ip):
|
|
132
|
+
with failures_lock:
|
|
133
|
+
failures.pop(ip, None)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def esc(value):
|
|
137
|
+
return "".join({"&": "&", "<": "<", ">": ">", '"': """, "'": "'"}.get(c, c) for c in str(value))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def js_json(value):
|
|
141
|
+
return json.dumps(value, ensure_ascii=False).replace("<", "\\u003c")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def encode_component(value):
|
|
145
|
+
"""与 JavaScript 的 encodeURIComponent 相同。"""
|
|
146
|
+
return urllib.parse.quote(value, safe="!~*'()")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def disposition(kind, filename):
|
|
150
|
+
encoded = urllib.parse.quote(filename, safe="")
|
|
151
|
+
fallback = re.sub(r'["\\]', "_", re.sub(r"[^\x20-\x7E]", "_", filename)) or "download"
|
|
152
|
+
return f"{kind}; filename=\"{fallback}\"; filename*=UTF-8''{encoded}"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def size_text(size):
|
|
156
|
+
if size < 1024:
|
|
157
|
+
return f"{size} B"
|
|
158
|
+
units = ["KB", "MB", "GB", "TB"]
|
|
159
|
+
n, i = size / 1024, 0
|
|
160
|
+
while n >= 1024 and i < len(units) - 1:
|
|
161
|
+
n /= 1024
|
|
162
|
+
i += 1
|
|
163
|
+
# 与 JavaScript 的 toFixed 一致:对浮点数的精确值四舍五入(半数进位)。
|
|
164
|
+
return f"{Decimal(n).quantize(Decimal('0.1') if n < 10 else Decimal('1'), rounding=ROUND_HALF_UP)} {units[i]}"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def time_text(mtime):
|
|
168
|
+
t = time.localtime(mtime)
|
|
169
|
+
return f"{t.tm_year}/{t.tm_mon}/{t.tm_mday} {t.tm_hour:02d}:{t.tm_min:02d}:{t.tm_sec:02d}"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# 预览时使用的类型。HTML/SVG 一律当纯文本或放进 CSP 沙箱,避免上传的页面在本站执行脚本。
|
|
173
|
+
TEXT = "text/plain; charset=utf-8"
|
|
174
|
+
MIME = {
|
|
175
|
+
"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "gif": "image/gif", "webp": "image/webp", "avif": "image/avif", "bmp": "image/bmp", "ico": "image/x-icon", "svg": "image/svg+xml",
|
|
176
|
+
"mp4": "video/mp4", "webm": "video/webm", "mov": "video/quicktime", "mp3": "audio/mpeg", "m4a": "audio/mp4", "wav": "audio/wav", "ogg": "audio/ogg", "flac": "audio/flac",
|
|
177
|
+
"pdf": "application/pdf",
|
|
178
|
+
}
|
|
179
|
+
for _ext in "txt md markdown log csv tsv json jsonl yaml yml toml ini conf env xml html htm css js mjs cjs ts tsx jsx py rb go rs java kt c h cc cpp hpp cs php sh bash zsh sql diff patch".split():
|
|
180
|
+
MIME[_ext] = TEXT
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def preview_type(name):
|
|
184
|
+
return MIME.get(os.path.splitext(name)[1][1:].lower())
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# 文件夹内排序:数字按数值比较,其余不区分大小写、按码位比较。与 Node 版的 compareNames 规则相同。
|
|
188
|
+
def natural_key(name):
|
|
189
|
+
return [(0, int(part), "") if DIGITS.fullmatch(part) else (1, 0, part.lower()) for part in DIGITS.split(name) if part]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
DIGITS = re.compile(r"([0-9]+)")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def parse_range(header, size):
|
|
196
|
+
"""解析单段 Range 请求。返回 None 表示整个文件,False 表示范围无效。"""
|
|
197
|
+
if not header:
|
|
198
|
+
return None
|
|
199
|
+
m = re.match(r"^bytes=(\d*)-(\d*)$", header.strip())
|
|
200
|
+
if not m or (not m.group(1) and not m.group(2)):
|
|
201
|
+
return None
|
|
202
|
+
if not m.group(1):
|
|
203
|
+
start, end = max(0, size - int(m.group(2))), size - 1
|
|
204
|
+
else:
|
|
205
|
+
start = int(m.group(1))
|
|
206
|
+
end = min(int(m.group(2)), size - 1) if m.group(2) else size - 1
|
|
207
|
+
if start > end or start >= size:
|
|
208
|
+
return False
|
|
209
|
+
return start, end
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class TarWriter:
|
|
213
|
+
"""把 tarfile 的输出直接写到响应里,流式打包不落盘。"""
|
|
214
|
+
|
|
215
|
+
def __init__(self, wfile):
|
|
216
|
+
self.wfile = wfile
|
|
217
|
+
|
|
218
|
+
def write(self, data):
|
|
219
|
+
self.wfile.write(data)
|
|
220
|
+
return len(data)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class Handler(BaseHTTPRequestHandler):
|
|
224
|
+
protocol_version = "HTTP/1.1"
|
|
225
|
+
server_version = "remote-fileshare/" + __version__
|
|
226
|
+
# 两次收到数据之间最多等待的秒数,用来回收空闲连接;不限制整个上传的总时长。
|
|
227
|
+
timeout = 300
|
|
228
|
+
|
|
229
|
+
@property
|
|
230
|
+
def cfg(self):
|
|
231
|
+
return self.server.cfg
|
|
232
|
+
|
|
233
|
+
def log_message(self, format, *args):
|
|
234
|
+
pass
|
|
235
|
+
|
|
236
|
+
def log(self, message):
|
|
237
|
+
stamp = datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
238
|
+
print(f"{stamp} {self.client_ip()} {message}", flush=True)
|
|
239
|
+
|
|
240
|
+
# 反向代理后面所有请求都来自本机,必须取代理追加的 X-Forwarded-For 最后一项,否则登录限流会把所有人一起锁住。
|
|
241
|
+
# 只信任来自本机的代理,防止外部直接伪造这个头。
|
|
242
|
+
def client_ip(self):
|
|
243
|
+
remote = self.client_address[0] if self.client_address else "-"
|
|
244
|
+
if self.cfg.trust_proxy and re.match(r"^(127\.|::1$|::ffff:127\.)", remote):
|
|
245
|
+
forwarded = [v.strip() for v in (self.headers.get("X-Forwarded-For") or "").split(",") if v.strip()]
|
|
246
|
+
if forwarded:
|
|
247
|
+
return forwarded[-1]
|
|
248
|
+
return remote
|
|
249
|
+
|
|
250
|
+
# ---- 响应工具 ----
|
|
251
|
+
def send(self, status, headers=None, body=b""):
|
|
252
|
+
self.send_response(status)
|
|
253
|
+
headers = dict(headers or {})
|
|
254
|
+
headers.setdefault("Content-Length", str(len(body)))
|
|
255
|
+
for k, v in headers.items():
|
|
256
|
+
self.send_header(k, v)
|
|
257
|
+
if self.body_pending():
|
|
258
|
+
self.send_header("Connection", "close")
|
|
259
|
+
self.close_connection = True
|
|
260
|
+
self.end_headers()
|
|
261
|
+
if body and self.command != "HEAD":
|
|
262
|
+
self.wfile.write(body)
|
|
263
|
+
|
|
264
|
+
def fail(self, status, message):
|
|
265
|
+
self.send(status, {"Content-Type": "text/plain; charset=utf-8", "X-Content-Type-Options": "nosniff"}, (message + "\n").encode("utf-8"))
|
|
266
|
+
|
|
267
|
+
def body_pending(self):
|
|
268
|
+
if getattr(self, "_body_read", False):
|
|
269
|
+
return False
|
|
270
|
+
return bool(self.headers.get("Content-Length", "0") not in ("", "0") or self.headers.get("Transfer-Encoding"))
|
|
271
|
+
|
|
272
|
+
# ---- 路径 ----
|
|
273
|
+
def outside(self, base, target):
|
|
274
|
+
rel = os.path.relpath(target, base)
|
|
275
|
+
return rel == ".." or rel.startswith(".." + os.sep) or os.path.isabs(rel)
|
|
276
|
+
|
|
277
|
+
def decode_path(self, raw):
|
|
278
|
+
try:
|
|
279
|
+
decoded = urllib.parse.unquote_to_bytes(raw.encode("latin-1")).decode("utf-8")
|
|
280
|
+
except (UnicodeError, ValueError):
|
|
281
|
+
raise HttpError(400, "路径编码无效")
|
|
282
|
+
if "\0" in decoded:
|
|
283
|
+
raise HttpError(400, "路径包含非法字符")
|
|
284
|
+
decoded = decoded.replace("\\", "/").lstrip("/")
|
|
285
|
+
# relpath 先归一化两边,共享根是 / 时也不会出现 // 前缀的比较错误。
|
|
286
|
+
target = os.path.normpath(os.path.join(self.cfg.root, decoded))
|
|
287
|
+
if self.outside(self.cfg.root, target):
|
|
288
|
+
raise HttpError(403, "禁止访问共享目录之外的路径")
|
|
289
|
+
return target
|
|
290
|
+
|
|
291
|
+
def assert_real_inside(self, target):
|
|
292
|
+
real = os.path.realpath(target)
|
|
293
|
+
os.stat(real)
|
|
294
|
+
if self.outside(self.cfg.root, real):
|
|
295
|
+
raise HttpError(403, "符号链接指向共享目录之外")
|
|
296
|
+
return real
|
|
297
|
+
|
|
298
|
+
# 用于即将创建的路径:沿父目录向上找到第一个已存在的目录,校验它没有经符号链接跳出共享根。
|
|
299
|
+
def assert_creatable_inside(self, target):
|
|
300
|
+
current = target
|
|
301
|
+
while not os.path.exists(current):
|
|
302
|
+
parent = os.path.dirname(current)
|
|
303
|
+
if parent == current:
|
|
304
|
+
break
|
|
305
|
+
current = parent
|
|
306
|
+
self.assert_real_inside(current)
|
|
307
|
+
|
|
308
|
+
def url_path(self, target):
|
|
309
|
+
rel = os.path.relpath(target, self.cfg.root)
|
|
310
|
+
if rel == ".":
|
|
311
|
+
return "/"
|
|
312
|
+
return "/" + "/".join(encode_component(p) for p in rel.split(os.sep))
|
|
313
|
+
|
|
314
|
+
def child_path(self, directory, name):
|
|
315
|
+
return self.decode_path(self.url_path(directory) + "/" + "/".join(encode_component(p) for p in name.split("/")))
|
|
316
|
+
|
|
317
|
+
def rel(self, target):
|
|
318
|
+
rel = os.path.relpath(target, self.cfg.root)
|
|
319
|
+
return "" if rel == "." else rel
|
|
320
|
+
|
|
321
|
+
# ---- 认证与跨站防护 ----
|
|
322
|
+
def authorized(self):
|
|
323
|
+
value = self.headers.get("Authorization") or ""
|
|
324
|
+
if not value.startswith("Basic "):
|
|
325
|
+
return False
|
|
326
|
+
try:
|
|
327
|
+
decoded = base64.b64decode(value[6:]).decode("utf-8", "replace")
|
|
328
|
+
except (ValueError, TypeError):
|
|
329
|
+
return False
|
|
330
|
+
if ":" not in decoded:
|
|
331
|
+
return False
|
|
332
|
+
user, password = decoded.split(":", 1)
|
|
333
|
+
user_ok = hmac.compare_digest(sha256(user), sha256(self.cfg.user))
|
|
334
|
+
pass_ok = hmac.compare_digest(sha256(password), self.cfg.password_hash)
|
|
335
|
+
return user_ok and pass_ok
|
|
336
|
+
|
|
337
|
+
# 浏览器会自动带上缓存的 Basic Auth 凭据,所以跨站页面发来的写操作必须拒绝(CSRF)。
|
|
338
|
+
# curl 等非浏览器客户端不带 Origin 头,不受影响。
|
|
339
|
+
def same_origin(self):
|
|
340
|
+
origin = self.headers.get("Origin")
|
|
341
|
+
if origin is None:
|
|
342
|
+
return self.headers.get("Sec-Fetch-Site") != "cross-site"
|
|
343
|
+
try:
|
|
344
|
+
parsed = urllib.parse.urlsplit(origin)
|
|
345
|
+
except ValueError:
|
|
346
|
+
return False
|
|
347
|
+
return bool(parsed.scheme) and parsed.netloc.lower() == (self.headers.get("Host") or "").lower()
|
|
348
|
+
|
|
349
|
+
# ---- 请求入口 ----
|
|
350
|
+
def do_GET(self):
|
|
351
|
+
self.handle_request()
|
|
352
|
+
|
|
353
|
+
do_HEAD = do_PUT = do_POST = do_DELETE = do_PATCH = do_OPTIONS = do_GET
|
|
354
|
+
|
|
355
|
+
def handle_request(self):
|
|
356
|
+
# 同一个 Handler 会处理一条 keep-alive 连接上的所有请求,状态必须按请求重置。
|
|
357
|
+
self._body_read = False
|
|
358
|
+
try:
|
|
359
|
+
self.route()
|
|
360
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
361
|
+
self.close_connection = True
|
|
362
|
+
except Exception as err: # noqa: BLE001 与 Node 版一致:任何意外错误都返回 500
|
|
363
|
+
try:
|
|
364
|
+
self.fail(500, "服务器错误:" + str(err))
|
|
365
|
+
except OSError:
|
|
366
|
+
self.close_connection = True
|
|
367
|
+
|
|
368
|
+
def route(self):
|
|
369
|
+
ip = self.client_ip()
|
|
370
|
+
if locked(ip):
|
|
371
|
+
return self.fail(429, "登录失败次数过多,请稍后再试")
|
|
372
|
+
if not self.authorized():
|
|
373
|
+
if self.headers.get("Authorization"):
|
|
374
|
+
record_failure(ip)
|
|
375
|
+
self.log("登录失败")
|
|
376
|
+
return self.send(401, {"WWW-Authenticate": 'Basic realm="File Share", charset="UTF-8"', "Content-Type": "text/plain; charset=utf-8"}, "需要登录\n".encode("utf-8"))
|
|
377
|
+
clear_failures(ip)
|
|
378
|
+
|
|
379
|
+
raw_path, _, raw_query = self.path.partition("?")
|
|
380
|
+
query = urllib.parse.parse_qs(raw_query.encode("latin-1").decode("utf-8", "replace"), keep_blank_values=True)
|
|
381
|
+
reading = self.command in ("GET", "HEAD")
|
|
382
|
+
if not reading and not self.same_origin():
|
|
383
|
+
return self.fail(403, "拒绝跨站请求")
|
|
384
|
+
try:
|
|
385
|
+
target = self.decode_path(raw_path)
|
|
386
|
+
except HttpError as err:
|
|
387
|
+
return self.fail(err.status, str(err))
|
|
388
|
+
|
|
389
|
+
try:
|
|
390
|
+
self.dispatch(target, raw_path, raw_query, query, reading)
|
|
391
|
+
except HttpError as err:
|
|
392
|
+
self.fail(err.status, str(err))
|
|
393
|
+
except OSError as err:
|
|
394
|
+
status = ERRNO_STATUS.get(err.errno, 500)
|
|
395
|
+
self.fail(status, "服务器错误:" + str(err) if status == 500 else str(err))
|
|
396
|
+
|
|
397
|
+
def dispatch(self, target, raw_path, raw_query, query, reading):
|
|
398
|
+
root = self.cfg.root
|
|
399
|
+
if self.command == "PUT":
|
|
400
|
+
if target == root or raw_path.endswith("/"):
|
|
401
|
+
return self.fail(400, "上传需要指定文件名")
|
|
402
|
+
self.assert_creatable_inside(os.path.dirname(target))
|
|
403
|
+
os.makedirs(os.path.dirname(target), exist_ok=True)
|
|
404
|
+
self.assert_real_inside(os.path.dirname(target))
|
|
405
|
+
return self.receive_upload(target)
|
|
406
|
+
|
|
407
|
+
if self.command == "POST" and "mkdir" in query:
|
|
408
|
+
name = query["mkdir"][0]
|
|
409
|
+
if not name:
|
|
410
|
+
return self.fail(400, "目录名不能为空")
|
|
411
|
+
self.assert_real_inside(target)
|
|
412
|
+
dest = self.child_path(target, name)
|
|
413
|
+
self.assert_creatable_inside(dest)
|
|
414
|
+
os.makedirs(dest, exist_ok=True)
|
|
415
|
+
self.assert_real_inside(dest)
|
|
416
|
+
self.log(f"新建目录 {self.rel(dest)}")
|
|
417
|
+
return self.send(201)
|
|
418
|
+
|
|
419
|
+
if self.command == "POST" and "rename" in query:
|
|
420
|
+
if target == root:
|
|
421
|
+
return self.fail(403, "不能改名共享根目录")
|
|
422
|
+
name = query["rename"][0]
|
|
423
|
+
if not name:
|
|
424
|
+
return self.fail(400, "新名称不能为空")
|
|
425
|
+
os.lstat(target)
|
|
426
|
+
self.assert_real_inside(os.path.dirname(target))
|
|
427
|
+
dest = self.child_path(os.path.dirname(target), name)
|
|
428
|
+
if dest == root:
|
|
429
|
+
return self.fail(400, "新名称无效")
|
|
430
|
+
if dest == target:
|
|
431
|
+
return self.send(204)
|
|
432
|
+
if dest.startswith(target + os.sep):
|
|
433
|
+
return self.fail(400, "不能把文件夹移动到它自己里面")
|
|
434
|
+
if os.path.lexists(dest):
|
|
435
|
+
return self.fail(409, "目标已存在:" + name)
|
|
436
|
+
self.assert_creatable_inside(os.path.dirname(dest))
|
|
437
|
+
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
|
438
|
+
self.assert_real_inside(os.path.dirname(dest))
|
|
439
|
+
os.rename(target, dest)
|
|
440
|
+
self.log(f"改名 {self.rel(target)} → {self.rel(dest)}")
|
|
441
|
+
return self.send(204)
|
|
442
|
+
|
|
443
|
+
if self.command == "DELETE":
|
|
444
|
+
if target == root:
|
|
445
|
+
return self.fail(403, "不能删除共享根目录")
|
|
446
|
+
# 删除的是链接本身而不是它指向的内容,只需保证所在目录在共享根内。
|
|
447
|
+
st = os.lstat(target)
|
|
448
|
+
self.assert_real_inside(os.path.dirname(target))
|
|
449
|
+
if statmod.S_ISDIR(st.st_mode):
|
|
450
|
+
shutil.rmtree(target)
|
|
451
|
+
else:
|
|
452
|
+
os.unlink(target)
|
|
453
|
+
self.log(f"删除 {self.rel(target)}")
|
|
454
|
+
return self.send(204)
|
|
455
|
+
|
|
456
|
+
if not reading:
|
|
457
|
+
return self.fail(405, "不支持此方法")
|
|
458
|
+
real = self.assert_real_inside(target)
|
|
459
|
+
st = os.stat(real)
|
|
460
|
+
if "tar" in query:
|
|
461
|
+
if not statmod.S_ISDIR(st.st_mode):
|
|
462
|
+
return self.fail(400, "只能打包文件夹")
|
|
463
|
+
return self.stream_tar(real)
|
|
464
|
+
if statmod.S_ISDIR(st.st_mode):
|
|
465
|
+
if not raw_path.endswith("/"):
|
|
466
|
+
return self.send(301, {"Location": raw_path + "/" + ("?" + raw_query if "?" in self.path else "")})
|
|
467
|
+
return self.render_directory(real)
|
|
468
|
+
if not statmod.S_ISREG(st.st_mode):
|
|
469
|
+
return self.fail(403, "不支持此文件类型")
|
|
470
|
+
return self.stream_file(real, st, "preview" in query)
|
|
471
|
+
|
|
472
|
+
# ---- 目录页面 ----
|
|
473
|
+
def render_directory(self, target):
|
|
474
|
+
entries = []
|
|
475
|
+
with os.scandir(target) as it:
|
|
476
|
+
for d in it:
|
|
477
|
+
full = os.path.join(target, d.name)
|
|
478
|
+
try:
|
|
479
|
+
st = os.stat(full)
|
|
480
|
+
except OSError:
|
|
481
|
+
continue
|
|
482
|
+
if UPLOAD_TMP.match(d.name):
|
|
483
|
+
continue
|
|
484
|
+
entries.append((d.name, full, statmod.S_ISDIR(st.st_mode), st))
|
|
485
|
+
entries.sort(key=lambda e: (not e[2], natural_key(e[0]), e[0]))
|
|
486
|
+
|
|
487
|
+
rel = self.rel(target)
|
|
488
|
+
crumbs = ['<a href="/">共享空间</a>']
|
|
489
|
+
if rel:
|
|
490
|
+
current = self.cfg.root
|
|
491
|
+
for part in rel.split(os.sep):
|
|
492
|
+
current = os.path.join(current, part)
|
|
493
|
+
crumbs.append(f'<span>/</span><a href="{self.url_path(current)}/">{esc(part)}</a>')
|
|
494
|
+
|
|
495
|
+
rows = []
|
|
496
|
+
for name, full, is_dir, st in entries:
|
|
497
|
+
href = self.url_path(full) + ("/" if is_dir else "")
|
|
498
|
+
dl = href + "?tar" if is_dir else href
|
|
499
|
+
link_attr = 'title="打开文件夹"' if is_dir else 'download title="点击下载"'
|
|
500
|
+
preview = f'<a class="act sub" href="{href}?preview" target="_blank" rel="noopener">预览</a>' if not is_dir and preview_type(name) else ""
|
|
501
|
+
rows.append(
|
|
502
|
+
f'<div class="row" data-name="{esc(name.lower())}" draggable="true" data-download="{esc(dl)}" data-filename="{esc(name + ".tar.gz" if is_dir else name)}">\n'
|
|
503
|
+
f' <div class="name">{ui.ICON_DIR if is_dir else ui.ICON_FILE}<a href="{href}" {link_attr}>{esc(name)}</a></div>\n'
|
|
504
|
+
f' <div class="size">{"—" if is_dir else esc(size_text(st.st_size))}</div>\n'
|
|
505
|
+
f' <div class="time">{esc(time_text(st.st_mtime))}</div>\n'
|
|
506
|
+
f' <div class="actions"><a class="dl" href="{esc(dl)}" {"" if is_dir else "download"} data-name="{esc(name)}" data-tar="{1 if is_dir else ""}">{ui.DL_ICON}{"打包下载" if is_dir else "下载"}</a>{preview}'
|
|
507
|
+
f'<button class="sub" data-action="copy" data-path="{esc(full)}">复制路径</button>'
|
|
508
|
+
f'<button class="sub" data-action="rename" data-url="{esc(href)}" data-name="{esc(name)}">改名</button>'
|
|
509
|
+
f'<button class="sub danger" data-action="delete" data-url="{esc(href)}" data-name="{esc(name)}">删除</button></div>\n'
|
|
510
|
+
f' </div>'
|
|
511
|
+
)
|
|
512
|
+
base = self.url_path(target) + ("" if target == self.cfg.root else "/")
|
|
513
|
+
html = (
|
|
514
|
+
'<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
|
|
515
|
+
f'<meta name="color-scheme" content="light dark"><link rel="icon" href="{ui.FAVICON}"><title>{esc(rel or "共享空间")} · 远程文件共享</title>'
|
|
516
|
+
f"<style>\n{ui.STYLE}\n</style></head><body>{ui.TOP}{''.join(crumbs)}{ui.MIDDLE}{''.join(rows) or ui.EMPTY}{ui.BOTTOM}"
|
|
517
|
+
f"<script>\nconst BASE={js_json(base)}, DIR={js_json(target)};\n{ui.SCRIPT}\n</script></body></html>"
|
|
518
|
+
).encode("utf-8")
|
|
519
|
+
self.send(200, {
|
|
520
|
+
"Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff",
|
|
521
|
+
"Referrer-Policy": "no-referrer", "X-Frame-Options": "DENY",
|
|
522
|
+
}, html)
|
|
523
|
+
|
|
524
|
+
# ---- 下载 ----
|
|
525
|
+
def stream_file(self, target, st, preview):
|
|
526
|
+
name = os.path.basename(target)
|
|
527
|
+
ctype = (preview_type(name) or "application/octet-stream") if preview else "application/octet-stream"
|
|
528
|
+
etag = f'"{st.st_size:x}-{st.st_mtime_ns // 1_000_000:x}"'
|
|
529
|
+
last_modified = formatdate(st.st_mtime, usegmt=True)
|
|
530
|
+
headers = {
|
|
531
|
+
"Content-Type": ctype,
|
|
532
|
+
"Content-Disposition": disposition("inline" if preview else "attachment", name),
|
|
533
|
+
"Accept-Ranges": "bytes",
|
|
534
|
+
"Last-Modified": last_modified,
|
|
535
|
+
"ETag": etag,
|
|
536
|
+
"X-Content-Type-Options": "nosniff",
|
|
537
|
+
}
|
|
538
|
+
# 预览的内容在沙箱里打开,即使是 SVG 或带脚本的文件也拿不到本站权限(PDF 阅读器在沙箱里打不开,单独放行)。
|
|
539
|
+
if preview and ctype != "application/pdf":
|
|
540
|
+
headers["Content-Security-Policy"] = "sandbox"
|
|
541
|
+
if_range = self.headers.get("If-Range")
|
|
542
|
+
rng = parse_range(self.headers.get("Range"), st.st_size)
|
|
543
|
+
if rng and if_range and if_range not in (etag, last_modified):
|
|
544
|
+
rng = None
|
|
545
|
+
if rng is False:
|
|
546
|
+
return self.send(416, {"Content-Range": f"bytes */{st.st_size}"})
|
|
547
|
+
if rng:
|
|
548
|
+
start, end = rng
|
|
549
|
+
headers["Content-Range"] = f"bytes {start}-{end}/{st.st_size}"
|
|
550
|
+
status = 206
|
|
551
|
+
else:
|
|
552
|
+
start, end = 0, st.st_size - 1
|
|
553
|
+
status = 200
|
|
554
|
+
length = end - start + 1 if st.st_size else 0
|
|
555
|
+
headers["Content-Length"] = str(length)
|
|
556
|
+
self.send_response(status)
|
|
557
|
+
for k, v in headers.items():
|
|
558
|
+
self.send_header(k, v)
|
|
559
|
+
self.end_headers()
|
|
560
|
+
if self.command == "HEAD" or not length:
|
|
561
|
+
return
|
|
562
|
+
with open(target, "rb") as f:
|
|
563
|
+
f.seek(start)
|
|
564
|
+
remaining = length
|
|
565
|
+
while remaining > 0:
|
|
566
|
+
chunk = f.read(min(CHUNK, remaining))
|
|
567
|
+
if not chunk:
|
|
568
|
+
break
|
|
569
|
+
self.wfile.write(chunk)
|
|
570
|
+
remaining -= len(chunk)
|
|
571
|
+
if remaining:
|
|
572
|
+
self.close_connection = True
|
|
573
|
+
|
|
574
|
+
def stream_tar(self, target):
|
|
575
|
+
root = self.cfg.root
|
|
576
|
+
base = (os.path.basename(root) or "remote-fileshare") if target == root else os.path.basename(target)
|
|
577
|
+
self.send_response(200)
|
|
578
|
+
self.send_header("Content-Type", "application/gzip")
|
|
579
|
+
self.send_header("Content-Disposition", disposition("attachment", base + ".tar.gz"))
|
|
580
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
581
|
+
self.send_header("Connection", "close")
|
|
582
|
+
self.end_headers()
|
|
583
|
+
self.close_connection = True
|
|
584
|
+
if self.command == "HEAD":
|
|
585
|
+
return
|
|
586
|
+
|
|
587
|
+
def skip_uploads(info):
|
|
588
|
+
return None if UPLOAD_TMP.match(os.path.basename(info.name)) else info
|
|
589
|
+
|
|
590
|
+
arcname = "." if target == root else os.path.basename(target)
|
|
591
|
+
# 响应头已经发出,中途出错只能断开连接,让客户端知道归档不完整。
|
|
592
|
+
try:
|
|
593
|
+
with tarfile.open(fileobj=TarWriter(self.wfile), mode="w|gz", format=tarfile.PAX_FORMAT) as tar:
|
|
594
|
+
tar.add(target, arcname=arcname, filter=skip_uploads)
|
|
595
|
+
except Exception as err: # noqa: BLE001
|
|
596
|
+
print(f"打包 {self.rel(target) or '.'} 失败:{err}", file=sys.stderr, flush=True)
|
|
597
|
+
raise ConnectionResetError from err
|
|
598
|
+
|
|
599
|
+
# ---- 上传 ----
|
|
600
|
+
def read_body(self, out):
|
|
601
|
+
"""把请求体写入 out,支持 Content-Length 和 chunked。返回写入的字节数,连接中断时返回 None。"""
|
|
602
|
+
self._body_read = True
|
|
603
|
+
written = 0
|
|
604
|
+
if "chunked" in (self.headers.get("Transfer-Encoding") or "").lower():
|
|
605
|
+
while True:
|
|
606
|
+
line = self.rfile.readline(65537)
|
|
607
|
+
if not line:
|
|
608
|
+
return None
|
|
609
|
+
size = int(line.split(b";", 1)[0].strip() or b"0", 16)
|
|
610
|
+
if size == 0:
|
|
611
|
+
while self.rfile.readline(65537) not in (b"\r\n", b"\n", b""):
|
|
612
|
+
pass
|
|
613
|
+
return written
|
|
614
|
+
remaining = size
|
|
615
|
+
while remaining:
|
|
616
|
+
chunk = self.rfile.read(min(CHUNK, remaining))
|
|
617
|
+
if not chunk:
|
|
618
|
+
return None
|
|
619
|
+
out.write(chunk)
|
|
620
|
+
written += len(chunk)
|
|
621
|
+
remaining -= len(chunk)
|
|
622
|
+
self.rfile.readline(65537)
|
|
623
|
+
remaining = int(self.headers.get("Content-Length") or 0)
|
|
624
|
+
while remaining:
|
|
625
|
+
chunk = self.rfile.read(min(CHUNK, remaining))
|
|
626
|
+
if not chunk:
|
|
627
|
+
return None
|
|
628
|
+
out.write(chunk)
|
|
629
|
+
written += len(chunk)
|
|
630
|
+
remaining -= len(chunk)
|
|
631
|
+
return written
|
|
632
|
+
|
|
633
|
+
def receive_upload(self, target):
|
|
634
|
+
tmp = os.path.join(os.path.dirname(target), f".{os.path.basename(target)}.{secrets.token_hex(8)}.upload")
|
|
635
|
+
try:
|
|
636
|
+
with open(tmp, "xb") as out:
|
|
637
|
+
try:
|
|
638
|
+
written = self.read_body(out)
|
|
639
|
+
except (OSError, ValueError):
|
|
640
|
+
written = None
|
|
641
|
+
if written is None:
|
|
642
|
+
self.close_connection = True
|
|
643
|
+
os.remove(tmp)
|
|
644
|
+
return
|
|
645
|
+
if os.path.isdir(target):
|
|
646
|
+
os.remove(tmp)
|
|
647
|
+
return self.fail(409, "同名文件夹已存在")
|
|
648
|
+
os.replace(tmp, target)
|
|
649
|
+
except OSError as err:
|
|
650
|
+
if os.path.exists(tmp):
|
|
651
|
+
os.remove(tmp)
|
|
652
|
+
return self.fail(500, "写入失败:" + str(err))
|
|
653
|
+
self.log(f"上传 {self.rel(target)} ({size_text(written)})")
|
|
654
|
+
self.send(201)
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
class Server(ThreadingHTTPServer):
|
|
658
|
+
daemon_threads = True
|
|
659
|
+
allow_reuse_address = True
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def local_addresses():
|
|
663
|
+
found = []
|
|
664
|
+
try:
|
|
665
|
+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
|
666
|
+
s.connect(("192.0.2.1", 80))
|
|
667
|
+
found.append(s.getsockname()[0])
|
|
668
|
+
except OSError:
|
|
669
|
+
pass
|
|
670
|
+
return found
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def main():
|
|
674
|
+
if "-h" in sys.argv or "--help" in sys.argv:
|
|
675
|
+
print(HELP.format(version=__version__))
|
|
676
|
+
return
|
|
677
|
+
if "-v" in sys.argv or "--version" in sys.argv:
|
|
678
|
+
print(__version__)
|
|
679
|
+
return
|
|
680
|
+
cfg = Config()
|
|
681
|
+
Server.address_family = socket.AF_INET6 if ":" in cfg.host else socket.AF_INET
|
|
682
|
+
try:
|
|
683
|
+
httpd = Server((cfg.host, cfg.port), Handler)
|
|
684
|
+
except OSError as err:
|
|
685
|
+
sys.exit(f"无法监听 {cfg.host}:{cfg.port}:{err}")
|
|
686
|
+
httpd.cfg = cfg
|
|
687
|
+
print(f"remote-fileshare {__version__} 已启动:http://{cfg.host}:{cfg.port}", flush=True)
|
|
688
|
+
if cfg.host in ("0.0.0.0", "::"):
|
|
689
|
+
for ip in ["127.0.0.1"] + local_addresses():
|
|
690
|
+
print(f"访问地址:http://{ip}:{cfg.port}", flush=True)
|
|
691
|
+
print(f"共享目录:{cfg.root}")
|
|
692
|
+
print(f"用户名:{cfg.user}")
|
|
693
|
+
if os.environ.get("PASS"):
|
|
694
|
+
print("密码:来自环境变量 PASS")
|
|
695
|
+
elif cfg.password_generated:
|
|
696
|
+
print(f"密码:{cfg.password}(已随机生成并保存到 {cfg.pass_file})")
|
|
697
|
+
else:
|
|
698
|
+
print(f"密码:保存在 {cfg.pass_file}")
|
|
699
|
+
if cfg.root == os.path.abspath(os.sep):
|
|
700
|
+
print("警告:共享根目录是整个文件系统,任何登录的人都能读写所有文件。", file=sys.stderr)
|
|
701
|
+
sys.stdout.flush()
|
|
702
|
+
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
|
|
703
|
+
try:
|
|
704
|
+
httpd.serve_forever()
|
|
705
|
+
except (KeyboardInterrupt, SystemExit):
|
|
706
|
+
pass
|
|
707
|
+
finally:
|
|
708
|
+
httpd.server_close()
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
if __name__ == "__main__":
|
|
712
|
+
main()
|
remote_fileshare/ui.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# 由 scripts/sync-ui.js 从 remote-fileshare.js 自动生成,请勿手动修改。
|
|
2
|
+
LOGO = "<svg viewBox=\"0 0 44 44\" aria-hidden=\"true\"><rect width=\"44\" height=\"44\" rx=\"12\" fill=\"#4f46e5\"/><path d=\"M15 18l7-7 7 7M22 12v20M29 26l-7 7-7-7\" fill=\"none\" stroke=\"white\" stroke-width=\"3\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg>"
|
|
3
|
+
FAVICON = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 44 44'%3E%3Crect width='44' height='44' rx='12' fill='%234f46e5'/%3E%3Cpath d='M15 18l7-7 7 7M22 12v20M29 26l-7 7-7-7' fill='none' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"
|
|
4
|
+
TOP = "<div class=\"progressTop\" id=\"progressTop\"></div><div class=\"drop\" id=\"drop\">松开即可上传到这里</div><main class=\"wrap\"><header class=\"brand\"><svg viewBox=\"0 0 44 44\" aria-hidden=\"true\"><rect width=\"44\" height=\"44\" rx=\"12\" fill=\"#4f46e5\"/><path d=\"M15 18l7-7 7 7M22 12v20M29 26l-7 7-7-7\" fill=\"none\" stroke=\"white\" stroke-width=\"3\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg><div><h1>远程文件共享</h1><p>浏览器 ⇄ 远程服务器,大文件、整个文件夹随传随取 · 复制路径即可发给 AI agent</p></div></header><nav class=\"crumbs\">"
|
|
5
|
+
MIDDLE = "<button class=\"copyDir\" id=\"copyDir\" title=\"复制当前目录在服务器上的绝对路径\">复制当前目录路径</button></nav><div class=\"toolbar\"><input id=\"filter\" class=\"filter\" type=\"search\" placeholder=\"筛选当前目录…\" autocomplete=\"off\"><button class=\"btn\" id=\"upload\">上传文件</button><button class=\"btn secondary\" id=\"uploadFolder\">上传文件夹</button><button class=\"btn secondary\" id=\"mkdir\">新建目录</button><input id=\"picker\" type=\"file\" multiple><input id=\"folderPicker\" type=\"file\" webkitdirectory multiple></div><div class=\"status\" id=\"status\">点文件名或「下载」按钮即可下载到本地;文件夹用「打包下载」得到 .tar.gz。把文件拖到页面上即可上传。</div><section class=\"card\"><div class=\"row head\"><div>名称</div><div>大小</div><div class=\"time\">修改时间</div><div></div></div><div id=\"list\">"
|
|
6
|
+
EMPTY = "<div class=\"empty\"><strong>这里还是空的</strong>上传文件、把文件拖到页面上,或新建一个目录开始整理。</div>"
|
|
7
|
+
BOTTOM = "</div></section></main>"
|
|
8
|
+
STYLE = ":root{--bg:#f5f6fb;--card:#fff;--text:#181a27;--muted:#70748a;--line:#e5e7f0;--hover:#f0f1ff;--primary:#4f46e5;--primary2:#3730a3;--danger:#d83b2d;--shadow:0 15px 45px rgba(42,45,80,.08)}\n@media(prefers-color-scheme:dark){:root{--bg:#11131b;--card:#1a1d29;--text:#f0f1f8;--muted:#9297ad;--line:#303444;--hover:#24283a;--primary:#8b83ff;--primary2:#b0abff;--danger:#ff695c;--shadow:0 18px 50px rgba(0,0,0,.25)}}\n*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:15px/1.45 system-ui,-apple-system,\"Segoe UI\",\"Microsoft YaHei\",sans-serif}a{color:var(--primary);text-decoration:none}button,input{font:inherit}.wrap{width:min(1100px,calc(100% - 32px));margin:0 auto;padding:38px 0 70px}.brand{display:flex;align-items:center;gap:13px;margin-bottom:30px}.brand svg{width:42px;height:42px}.brand h1{font-size:22px;margin:0;letter-spacing:-.02em}.brand p{margin:1px 0 0;color:var(--muted);font-size:13px}.toolbar{display:flex;gap:10px;align-items:center;margin:18px 0}.filter{min-width:160px;flex:1;border:1px solid var(--line);color:var(--text);background:var(--card);border-radius:11px;padding:10px 13px;outline:none}.filter:focus{border-color:var(--primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--primary) 16%,transparent)}.btn{border:0;background:var(--primary);color:white;border-radius:10px;padding:10px 14px;cursor:pointer;font-weight:650;white-space:nowrap}.btn.secondary{background:var(--card);color:var(--primary);border:1px solid var(--line)}.btn:disabled{opacity:.5;cursor:default}.crumbs{font:13px ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--muted);display:flex;gap:8px;flex-wrap:wrap;align-items:center}.crumbs .copyDir{margin-left:auto;border:0;background:transparent;color:var(--primary);cursor:pointer;font:12px system-ui,sans-serif;padding:2px 0}.card{background:var(--card);border:1px solid var(--line);border-radius:17px;box-shadow:var(--shadow);overflow:hidden}.row{display:grid;grid-template-columns:minmax(200px,1fr) 90px 170px 330px;align-items:center;min-height:58px;padding:0 18px;border-bottom:1px solid var(--line);transition:background .15s}.row:last-child{border-bottom:0}.row:hover{background:var(--hover)}.head{min-height:43px;color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.06em}.name{min-width:0;display:flex;align-items:center;gap:10px}.name>a:first-of-type{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text);font-weight:600}.typeIcon{width:21px;height:21px;flex:none;fill:none;stroke:var(--muted);stroke-width:1.7}.typeIcon.folder{fill:color-mix(in srgb,var(--primary) 17%,transparent);stroke:var(--primary)}.size,.time,.crumbs{font-family:ui-monospace,SFMono-Regular,Consolas,monospace}.size,.time{font-size:12px;color:var(--muted)}.actions{display:flex;justify-content:flex-start;align-items:center;gap:2px}.actions .sub{opacity:.3;transition:opacity .15s}.row:hover .actions .sub,.row:focus-within .actions .sub{opacity:1}.dl{display:inline-flex;align-items:center;gap:5px;margin-right:6px;padding:6px 12px;border-radius:9px;background:color-mix(in srgb,var(--primary) 13%,transparent);color:var(--primary);font-size:13px;font-weight:650;white-space:nowrap;transition:background .15s,color .15s}.dl:hover,.dl:focus-visible{background:var(--primary);color:#fff}.dl svg{width:15px;height:15px;fill:none;stroke:currentColor;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round}.status.ok{color:var(--primary)}.actions button,.actions .act{border:0;background:transparent;color:var(--primary);cursor:pointer;padding:7px;font-size:14px}.actions .danger:hover{color:var(--danger)}.empty{text-align:center;padding:70px 20px;color:var(--muted)}.empty strong{display:block;color:var(--text);font-size:18px;margin-bottom:5px}.drop{position:fixed;z-index:20;inset:14px;display:none;place-items:center;border:3px dashed var(--primary);border-radius:24px;background:color-mix(in srgb,var(--bg) 88%,transparent);backdrop-filter:blur(5px);font-size:24px;font-weight:750;color:var(--primary);pointer-events:none}.drop.show{display:grid}.progressTop{position:fixed;z-index:30;left:0;top:0;height:3px;background:var(--danger);width:0;transition:width .15s}.status{min-height:24px;color:var(--muted);font-size:13px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.status.error{color:var(--danger)}#picker,#folderPicker{display:none}\n@media(max-width:600px){.wrap{width:min(100% - 20px,1100px);padding-top:20px}.toolbar{flex-wrap:wrap}.filter{flex-basis:100%}.row{grid-template-columns:minmax(80px,1fr) 52px 172px;padding:0 8px}.time{display:none}.head{grid-template-columns:minmax(80px,1fr) 52px 172px}.actions .sub{opacity:.8}.actions [data-action=copy]{display:none}.actions button,.actions .act{padding:6px 4px;font-size:12px}.dl{padding:5px 9px;font-size:12px;margin-right:2px}.brand{margin-bottom:20px}}\n@media(hover:none){.actions .sub{opacity:1}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important}}"
|
|
9
|
+
SCRIPT = "const list=document.querySelector('#list'), statusEl=document.querySelector('#status'), bar=document.querySelector('#progressTop');\nconst pathURL=p=>BASE+p.split('/').filter(Boolean).map(encodeURIComponent).join('/');\nconst human=b=>{const u=['B','KB','MB','GB','TB'];let i=0;while(b>=1024&&i<u.length-1){b/=1024;i++}return b.toFixed(i&&b<10?1:0)+' '+u[i]};\nconst clock=s=>!isFinite(s)?'--':s>=3600?Math.floor(s/3600)+'时'+Math.floor(s%3600/60)+'分':s>=60?Math.floor(s/60)+'分'+Math.round(s%60)+'秒':Math.round(s)+'秒';\nlet uploading=false;window.addEventListener('beforeunload',e=>{if(uploading){e.preventDefault();e.returnValue=''}});\ndocument.querySelector('#filter').addEventListener('input',e=>{const q=e.target.value.trim().toLocaleLowerCase();document.querySelectorAll('#list .row').forEach(r=>r.hidden=!r.dataset.name.includes(q))});\ndocument.querySelector('#upload').onclick=()=>document.querySelector('#picker').click();document.querySelector('#uploadFolder').onclick=()=>document.querySelector('#folderPicker').click();\ndocument.querySelector('#picker').onchange=e=>uploadFiles([...e.target.files]).catch(()=>{});document.querySelector('#folderPicker').onchange=e=>uploadFiles([...e.target.files]).catch(()=>{});\nfunction setStatus(text,error=false,ok=false){statusEl.textContent=text;statusEl.classList.toggle('error',error);statusEl.classList.toggle('ok',ok)}\ndocument.addEventListener('click',e=>{const a=e.target.closest('a[download],a.dl');if(!a)return;const name=a.dataset.name||a.textContent.trim();setStatus(a.dataset.tar?'正在打包「'+name+'」,开始下载 '+name+'.tar.gz…(文件夹大时要等几秒才开始,请留意浏览器的下载栏)':'已开始下载「'+name+'」,请留意浏览器的下载栏',false,true)});\nasync function uploadFiles(files){if(!files.length||uploading)return;uploading=true;document.querySelectorAll('.toolbar .btn').forEach(b=>b.disabled=true);const total=files.reduce((s,f)=>s+f.size,0),start=Date.now();let done=0;try{for(let i=0;i<files.length;i++){const f=files[i],rel=f.webkitRelativePath||f._relativePath||f.name;await new Promise((ok,bad)=>{const x=new XMLHttpRequest();x.open('PUT',pathURL(rel));x.upload.onprogress=e=>{const loaded=done+(e.lengthComputable?e.loaded:0),secs=(Date.now()-start)/1000,speed=loaded/Math.max(secs,.001);const pct=total?Math.round(loaded/total*100):100;setStatus('上传第 '+(i+1)+' / 共 '+files.length+' 个 · '+pct+'% · '+human(loaded)+' / '+human(total)+' · '+human(speed)+'/s · 剩余 '+clock((total-loaded)/speed)+' · '+rel);bar.style.width=pct+'%'};x.onload=()=>x.status>=200&&x.status<300?ok():bad(new Error('上传失败:'+rel+' · '+(x.responseText.trim()||('HTTP '+x.status))));x.onerror=()=>bad(new Error('网络错误,上传中断:'+rel));x.send(f)});done+=f.size}setStatus('上传完成('+files.length+' 个,'+human(total)+'),正在刷新…');bar.style.width='100%';uploading=false;setTimeout(()=>location.reload(),300)}catch(e){setStatus(e.message,true);bar.style.width='0';throw e}finally{uploading=false;document.querySelectorAll('.toolbar .btn').forEach(b=>b.disabled=false)}}\ndocument.querySelector('#mkdir').onclick=async()=>{const name=prompt('新目录名称(可包含相对路径)');if(!name)return;const r=await fetch(BASE+'?mkdir='+encodeURIComponent(name),{method:'POST'});if(r.ok)location.reload();else setStatus(await r.text(),true)};\nasync function copyText(text){if(navigator.clipboard&&window.isSecureContext){await navigator.clipboard.writeText(text)}else{const t=document.createElement('textarea');t.value=text;t.style.cssText='position:fixed;left:-9999px;top:0';document.body.appendChild(t);t.focus();t.select();const ok=document.execCommand('copy');t.remove();if(!ok)throw new Error('浏览器未允许复制')}}\nasync function copyPath(p){try{await copyText(p);setStatus('已复制:'+p)}catch(err){setStatus('复制失败:'+err.message,true)}}\ndocument.querySelector('#copyDir').onclick=()=>copyPath(DIR);\nlist.addEventListener('click',async e=>{const b=e.target.closest('button[data-action]');if(!b)return;if(b.dataset.action==='copy')return copyPath(b.dataset.path);if(b.dataset.action==='rename'){const name=prompt('新名称或相对路径(相对于当前目录)',b.dataset.name);if(!name||name===b.dataset.name)return;const r=await fetch(b.dataset.url+'?rename='+encodeURIComponent(name),{method:'POST'});if(r.ok)location.reload();else setStatus(await r.text(),true)}else{if(!confirm('确定删除“'+b.dataset.name+'”?文件夹会连同内容一起删除,此操作不可撤销。'))return;const r=await fetch(b.dataset.url,{method:'DELETE'});if(r.ok)location.reload();else setStatus(await r.text(),true)}});\nlet dragDepth=0,draggingOut=false;const drop=document.querySelector('#drop');\ndocument.addEventListener('dragstart',e=>{const row=e.target.closest&&e.target.closest('.row[data-download]');if(!row)return;draggingOut=true;const absolute=new URL(row.dataset.download,location.href).href;e.dataTransfer.setData('DownloadURL','application/octet-stream:'+row.dataset.filename+':'+absolute);e.dataTransfer.effectAllowed='copy'});document.addEventListener('dragend',()=>{draggingOut=false;dragDepth=0;drop.classList.remove('show')});\nconst hasFiles=e=>[...(e.dataTransfer?.types||[])].includes('Files');\ndocument.addEventListener('dragenter',e=>{if(draggingOut||!hasFiles(e))return;e.preventDefault();dragDepth++;drop.classList.add('show')});document.addEventListener('dragover',e=>{if(!draggingOut&&hasFiles(e))e.preventDefault()});document.addEventListener('dragleave',e=>{if(draggingOut||!hasFiles(e))return;dragDepth=Math.max(0,dragDepth-1);if(!dragDepth)drop.classList.remove('show')});\ndocument.addEventListener('drop',async e=>{if(draggingOut||!hasFiles(e))return;e.preventDefault();dragDepth=0;drop.classList.remove('show');const items=[...e.dataTransfer.items],files=[];if(items.some(i=>i.webkitGetAsEntry)){const entries=items.map(i=>i.webkitGetAsEntry&&i.webkitGetAsEntry()).filter(Boolean);for(const entry of entries)await walkEntry(entry,'',files)}else files.push(...e.dataTransfer.files);uploadFiles(files).catch(()=>{})});\nasync function walkEntry(entry,prefix,out){if(entry.isFile){await new Promise(resolve=>entry.file(f=>{f._relativePath=prefix+f.name;out.push(f);resolve()},resolve))}else if(entry.isDirectory){const reader=entry.createReader(),all=[];while(true){const batch=await new Promise(resolve=>reader.readEntries(resolve,()=>resolve([])));if(!batch.length)break;all.push(...batch)}for(const child of all)await walkEntry(child,prefix+entry.name+'/',out)}}"
|
|
10
|
+
DL_ICON = "<svg viewBox=\"0 0 24 24\" aria-hidden=\"true\"><path d=\"M12 4v11m0 0l-4.5-4.5M12 15l4.5-4.5M5 19.5h14\"/></svg>"
|
|
11
|
+
ICON_DIR = "<svg class=\"typeIcon folder\" viewBox=\"0 0 24 24\" aria-hidden=\"true\"><path d=\"M3 6.5a2 2 0 0 1 2-2h5l2 2H19a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z\"/></svg>"
|
|
12
|
+
ICON_FILE = "<svg class=\"typeIcon file\" viewBox=\"0 0 24 24\" aria-hidden=\"true\"><path d=\"M6 2.8h8l4 4v14.4H6z\"/><path d=\"M14 2.8v4h4\"/></svg>"
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: remote-fileshare
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: 远程文件共享:零依赖的网页文件共享服务器,在浏览器和远程服务器之间传文件,适合配合远程 AI agent 使用
|
|
5
|
+
Project-URL: Homepage, https://github.com/chao5go/remote-fileshare
|
|
6
|
+
Project-URL: Issues, https://github.com/chao5go/remote-fileshare/issues
|
|
7
|
+
Author: chao5go
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: ai-agent,file-server,file-sharing,http-server,remote,self-hosted,upload,zero-dependency
|
|
11
|
+
Classifier: Environment :: Web Environment
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: System Administrators
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Communications :: File Sharing
|
|
17
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
18
|
+
Requires-Python: >=3.8
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# remote-fileshare · 远程文件共享
|
|
22
|
+
|
|
23
|
+
**在浏览器和远程服务器之间传文件。** 一行命令在服务器上启动,打开网页就能上传、下载、管理文件。不用装 FTP,不用配 scp,也不用折腾 VS Code 插件。
|
|
24
|
+
|
|
25
|
+
适合这样的场景:你在本地电脑上通过 Claude Code、Codex、opencode 等 **AI agent** 远程操作一台服务器,需要把图片、文档、整个文件夹传给 agent 处理,或者把 agent 生成的结果拿回本地。
|
|
26
|
+
|
|
27
|
+
> ⚠️ **特别提醒**:服务器在境外时,直接在 VS Code / 终端里拖拽上传大文件会很慢,还容易中断。传大文件、整个文件夹,建议用 remote-fileshare 的网页上传:浏览器直连服务器,带进度、速度和剩余时间,体验好很多。
|
|
28
|
+
|
|
29
|
+

|
|
30
|
+
|
|
31
|
+
## 安装与启动
|
|
32
|
+
|
|
33
|
+
在**远程服务器**上运行下面任意一种,有 Node.js 或 Python 其中一个就够了:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npx remote-fileshare /path/to/share # Node.js 18+
|
|
37
|
+
uvx remote-fileshare /path/to/share # Python 3.8+,用 uv
|
|
38
|
+
pipx run remote-fileshare /path/to/share # Python 3.8+,用 pipx
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
不传目录时共享当前目录,默认端口 8001。启动后终端会打印访问地址、用户名和密码:
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
remote-fileshare 1.1.0 已启动:http://0.0.0.0:8001
|
|
45
|
+
访问地址:http://127.0.0.1:8001
|
|
46
|
+
访问地址:http://10.0.0.5:8001
|
|
47
|
+
共享目录:/path/to/share
|
|
48
|
+
用户名:admin
|
|
49
|
+
密码:fRu_aghQlXQ6cJ1bOpWiw-Te(已随机生成并保存到 ~/.config/remote-fileshare/password)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
然后在**本地电脑**的浏览器打开 `http://服务器公网IP:8001` 即可。查看服务器公网 IP:`curl -s ifconfig.me`。
|
|
53
|
+
|
|
54
|
+
<details>
|
|
55
|
+
<summary>其他安装方式</summary>
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
npm install -g remote-fileshare # 全局安装,之后直接运行 remote-fileshare
|
|
59
|
+
uv tool install remote-fileshare # 同上,Python 版
|
|
60
|
+
pip install remote-fileshare
|
|
61
|
+
|
|
62
|
+
# 不经过 npm / PyPI,直接从 GitHub 运行最新代码
|
|
63
|
+
npx github:chao5go/remote-fileshare /path/to/share
|
|
64
|
+
uvx --from git+https://github.com/chao5go/remote-fileshare remote-fileshare /path/to/share
|
|
65
|
+
|
|
66
|
+
# Node 版是一个零依赖的单文件,也可以直接下载运行
|
|
67
|
+
curl -fsSLO https://raw.githubusercontent.com/chao5go/remote-fileshare/main/remote-fileshare.js
|
|
68
|
+
node remote-fileshare.js /path/to/share
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
</details>
|
|
72
|
+
|
|
73
|
+
Node 版和 Python 版功能、接口、网页完全一致,共用同一套测试,选你服务器上现成的那个即可。两个版本都**不依赖任何第三方包**。
|
|
74
|
+
|
|
75
|
+
## 功能
|
|
76
|
+
|
|
77
|
+
- **全站密码保护**:首次运行随机生成强密码;密码比对防时序攻击;连续登录失败自动锁定 IP
|
|
78
|
+
- **上传**:多选文件、整个文件夹(保留目录结构)、拖拽到页面;流式写入不占内存;显示进度、速度、剩余时间;没有超时限制,慢速线路传几个小时也不会被掐断
|
|
79
|
+
- **下载**:每个文件都有醒目的「下载」按钮,中文文件名正常;支持断点续传;文件夹一键「打包下载」成 `.tar.gz`,流式生成不落盘;在 Chrome / Edge 里还能把文件直接从网页拖到桌面
|
|
80
|
+
- **预览**:图片、PDF、音视频、文本/代码在新标签页打开(放在沙箱里,上传的网页文件无法在本站执行脚本)
|
|
81
|
+
- **管理**:新建目录、改名/移动(不会覆盖已有文件)、删除;即时筛选
|
|
82
|
+
- **给 AI agent 用**:每个文件都能「复制路径」,复制的是它在服务器上的绝对路径,直接粘贴给 agent
|
|
83
|
+
- **好看、好用**:明暗主题自动切换,手机可用
|
|
84
|
+
|
|
85
|
+
## 长期运行
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
nohup npx remote-fileshare /path/to/share > fileshare.log 2>&1 &
|
|
89
|
+
pkill -f remote-fileshare # 停止
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
更推荐用 systemd 托管,开机自启、崩溃自动重启,示例见 [`contrib/remote-fileshare.service`](contrib/remote-fileshare.service)。
|
|
93
|
+
|
|
94
|
+
## 配置
|
|
95
|
+
|
|
96
|
+
全部通过环境变量设置:
|
|
97
|
+
|
|
98
|
+
| 变量 | 默认值 | 说明 |
|
|
99
|
+
| --- | --- | --- |
|
|
100
|
+
| `PORT` | `8001` | 监听端口 |
|
|
101
|
+
| `HOST` | `0.0.0.0` | 监听地址,只想本机访问可设为 `127.0.0.1` |
|
|
102
|
+
| `FS_USER` | `admin` | 登录用户名 |
|
|
103
|
+
| `PASS` | 随机生成 | 登录密码,设置后不再读写密码文件 |
|
|
104
|
+
| `PASS_FILE` | `~/.config/remote-fileshare/password` | 自动生成的密码保存位置;改密码可以直接编辑它后重启 |
|
|
105
|
+
| `TRUST_PROXY` | 不开启 | 放在本机反向代理后面时设为 `1`,从 `X-Forwarded-For` 读取真实客户端 IP(用于登录限流和日志) |
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
PORT=9000 PASS='一个足够长的密码' npx remote-fileshare ~/share
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### HTTPS
|
|
112
|
+
|
|
113
|
+
remote-fileshare 本身只提供 HTTP,以保持零依赖。需要 HTTPS 时,在前面放一个 [Caddy](https://caddyserver.com),证书会自动申请和续期:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
HOST=127.0.0.1 TRUST_PROXY=1 npx remote-fileshare /path/to/share # 只监听本机,不直接暴露
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Caddy 配置见 [`contrib/Caddyfile`](contrib/Caddyfile)。有域名时只需要两行配置。**没有域名也可以**:Let's Encrypt 支持直接给公网 IP 签证书(需要 Caddy 2.10 以上版本),访问地址就是 `https://你的IP`,也可以带端口,例如 `https://你的IP:8001`。两种方式都需要放行 80 端口,证书申请和续期要用它。
|
|
120
|
+
|
|
121
|
+
## 配合 AI agent 使用
|
|
122
|
+
|
|
123
|
+
- **把文件交给 agent**:网页上传后,点该文件的「复制路径」(或页面上方的「复制当前目录路径」),把路径粘贴进对话,例如「帮我看看 /root/share/截图.png」。
|
|
124
|
+
- **把 agent 的产出拿回来**:让 agent 把结果写到共享目录里,刷新网页点「下载」;整个文件夹点「打包下载」。
|
|
125
|
+
- **让 agent 用命令行操作**(agent 在另一台机器上时):
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
U='admin:你的密码'; S='http://服务器IP:8001'
|
|
129
|
+
curl -u "$U" -T report.pdf "$S/报告/report.pdf" # 上传(自动创建目录)
|
|
130
|
+
curl -u "$U" -o report.pdf "$S/报告/report.pdf" # 下载
|
|
131
|
+
curl -u "$U" -C - -o big.iso "$S/big.iso" # 断点续传下载
|
|
132
|
+
curl -u "$U" "$S/报告/?tar" | tar xz # 打包下载整个目录
|
|
133
|
+
curl -u "$U" -X POST -G --data-urlencode "mkdir=新目录" "$S/" # 新建目录
|
|
134
|
+
curl -u "$U" -X POST -G --data-urlencode "rename=归档/a.txt" "$S/a.txt" # 改名 / 移动
|
|
135
|
+
curl -u "$U" -X DELETE "$S/a.txt" # 删除
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
路径里的中文可以原样写;`?` 后面的参数含中文时要像上面那样用 `-G --data-urlencode` 编码。
|
|
139
|
+
|
|
140
|
+
## HTTP 接口
|
|
141
|
+
|
|
142
|
+
| 请求 | 作用 | 成功状态码 |
|
|
143
|
+
| --- | --- | --- |
|
|
144
|
+
| `GET /目录/` | 目录网页 | 200 |
|
|
145
|
+
| `GET /文件` | 下载(支持 `Range`) | 200 / 206 |
|
|
146
|
+
| `GET /文件?preview` | 内联预览 | 200 |
|
|
147
|
+
| `GET /目录/?tar` | 流式打包下载 `.tar.gz` | 200 |
|
|
148
|
+
| `PUT /路径/文件` | 上传原始文件体,自动创建父目录,同名文件会被覆盖 | 201 |
|
|
149
|
+
| `POST /目录/?mkdir=名称` | 新建目录,名称可以包含 `/` | 201 |
|
|
150
|
+
| `POST /路径?rename=新名称` | 改名;新名称相对当前所在目录,可含 `/` 实现移动;目标已存在返回 409 | 204 |
|
|
151
|
+
| `DELETE /路径` | 删除文件,或递归删除文件夹 | 204 |
|
|
152
|
+
|
|
153
|
+
## 安全说明
|
|
154
|
+
|
|
155
|
+
- 这个服务能读写共享目录里的**所有文件**,请使用足够长的随机密码(默认生成的就是)。
|
|
156
|
+
- 不要把共享目录设成 `/`,尤其是在公网服务器上。
|
|
157
|
+
- 直接用 HTTP 时,密码和文件在网络上都是明文传输。公网使用强烈建议按上文加 HTTPS,或者通过 SSH 隧道访问:`ssh -L 8001:127.0.0.1:8001 服务器`,然后打开 `http://127.0.0.1:8001`。
|
|
158
|
+
- 已经做了的防护:路径穿越与符号链接越界校验、禁止删除共享根目录、文件名 HTML 转义、拒绝跨站写请求(CSRF)、预览内容放进 CSP 沙箱、登录失败限流。
|
|
159
|
+
|
|
160
|
+
## 常见问题
|
|
161
|
+
|
|
162
|
+
**打不开网页?** 没配 HTTPS 时,先检查地址栏:浏览器经常自动把 http 改成 https,必须完整输入 `http://IP:8001`。再检查云服务器的安全组 / 防火墙有没有放行 8001 端口。还打不开就用手机流量试一下,能打开说明是你所在的网络拦截了。
|
|
163
|
+
|
|
164
|
+
**传到一半断了?** 网页上传是逐个文件进行的,已经传完的文件不受影响,重新选择剩下的文件继续即可。下载中断可以用浏览器的「继续」或 `curl -C -` 续传。
|
|
165
|
+
|
|
166
|
+
**怎么换密码?** 设置 `PASS` 环境变量,或者编辑 `~/.config/remote-fileshare/password` 后重启服务。
|
|
167
|
+
|
|
168
|
+
## 开发
|
|
169
|
+
|
|
170
|
+
```
|
|
171
|
+
remote-fileshare.js Node 版(单文件,网页界面的唯一来源)
|
|
172
|
+
src/remote_fileshare/ Python 版
|
|
173
|
+
server.py 服务器实现
|
|
174
|
+
ui.py 网页界面,由 scripts/sync-ui.js 从 Node 版生成,不要手动改
|
|
175
|
+
test/fileshare.test.js 端到端测试,两个版本共用
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
npm test # 测 Node 版
|
|
180
|
+
npm run test:python # 用同一套测试测 Python 版(需要 python3)
|
|
181
|
+
npm run sync-ui # 改了 remote-fileshare.js 里的网页界面后,同步到 Python 版
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
测试会在临时目录里启动真实的服务器逐项验证,并对比两个版本生成的页面必须完全一致。不需要安装任何依赖。
|
|
185
|
+
|
|
186
|
+
[`docs/PROMPT.md`](docs/PROMPT.md) 是这个项目最初的 Claude Code 提示词。
|
|
187
|
+
|
|
188
|
+
## English
|
|
189
|
+
|
|
190
|
+
**remote-fileshare** moves files between your browser and a remote server. Run one command on the server, open the page, and upload, download and manage files. It is built for the AI-agent workflow, where you drive a remote (often overseas) server through Claude Code, Codex or opencode and need to hand files to the agent or bring its output back.
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
npx remote-fileshare /path/to/share # Node.js 18+
|
|
194
|
+
uvx remote-fileshare /path/to/share # Python 3.8+
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
- Zero dependencies in both the Node.js and Python versions. They share one test suite and render identical pages.
|
|
198
|
+
- Password-protected. A random password is generated on first run, and failed logins are rate-limited.
|
|
199
|
+
- Upload files, whole folders or drag-and-drop, with progress, speed and ETA. There is no request timeout.
|
|
200
|
+
- Download with resume support. Folders stream as `.tar.gz`. Images, PDFs, media and code can be previewed in a sandbox.
|
|
201
|
+
- Copy any file's absolute server path in one click to paste to your agent.
|
|
202
|
+
- HTTPS works by putting Caddy in front, even without a domain (Let's Encrypt IP certificates). See [`contrib/Caddyfile`](contrib/Caddyfile).
|
|
203
|
+
|
|
204
|
+
The UI is in Chinese. Configuration uses the environment variables listed above.
|
|
205
|
+
|
|
206
|
+
## 许可证
|
|
207
|
+
|
|
208
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
remote_fileshare/__init__.py,sha256=3qrck9fkQsV-lxPIpvAVknuPKxdS5V7sXdKES2n338E,132
|
|
2
|
+
remote_fileshare/__main__.py,sha256=3dYKHfmWsrdExFlTFlcR5a_icR9fAkn06Yh14TQkEd8,33
|
|
3
|
+
remote_fileshare/server.py,sha256=Y-nz2p5ikQ0Vc3y2qtMyVFRpXqeOWWHsSlMOTMgkPaM,29406
|
|
4
|
+
remote_fileshare/ui.py,sha256=hhEAXwwILD7XHcFGEtI_eP_UVfRpsocHlpzX9VPuBYg,14497
|
|
5
|
+
remote_fileshare-1.1.0.dist-info/METADATA,sha256=8kEZwr-28a_zZF-NeD2GpyNni2DwLy_JQBzJ8wNFy7o,11598
|
|
6
|
+
remote_fileshare-1.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
7
|
+
remote_fileshare-1.1.0.dist-info/entry_points.txt,sha256=ycB-uJwZDF4-jgNeP-R8RF8rehvDmTIXM3mjvMyZ1wc,66
|
|
8
|
+
remote_fileshare-1.1.0.dist-info/licenses/LICENSE,sha256=42wJHqZ0MpMjjfJu9HsLosEomIYfKz4Kin3ik_Y1pxc,1066
|
|
9
|
+
remote_fileshare-1.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 yanggogo2
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|