-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.py
More file actions
314 lines (279 loc) · 11.5 KB
/
Copy pathserver.py
File metadata and controls
314 lines (279 loc) · 11.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""DSF 一键数据服务器
--------------------------------------------------------------------------
在项目目录启动后:
1. 作为静态服务器托管 DSF 网页(http://127.0.0.1:8000/);
2. 提供数据接口,把每次改动自动写入同目录 dsf-data.json ——
网页每次启动时先读取该文件恢复数据。这份文件就是 DSF 的“本地记忆”,
换浏览器 / 清缓存 / 换端口都不会丢。
用法:
python server.py # 默认端口 8000
python server.py 8080 # 指定端口
(或直接双击 start-dsf.cmd)
访问控制(v0.3.2 起):
/api/state 是本机回环上的无鉴权写接口,若不设防,用户浏览任意网站时该网站都能
直接 PUT 覆盖 dsf-data.json(简单请求不触发 CORS 预检,写入照样生效)。因此:
1) Host 必须是回环地址(防 DNS rebinding);
2) Origin 必须同源,且必须携带页面内联注入的会话 Token(防跨站请求伪造)。
页面由本服务器托管,Token 由 index.html 占位符在响应时替换,正常使用无感。
"""
import json
import os
import re
import secrets
import sys
import threading
import webbrowser
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse
from urllib.parse import unquote as urllib_parse_unquote
ROOT = os.path.dirname(os.path.abspath(__file__))
DATA_FILE = os.path.join(ROOT, 'dsf-data.json')
API_PATH = '/api/state'
INDEX_FILE = 'index.html'
TOKEN_PLACEHOLDER = '__DSF_TOKEN__'
SESSION_TOKEN = secrets.token_hex(16)
LOOPBACK_HOSTS = ('127.0.0.1', 'localhost', '[::1]', '::1')
# 静态托管的最小 MIME 表(SimpleHTTPRequestHandler 自带,这里只补 charset)
TEXT_MIME = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.md': 'text/markdown; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
}
def log(msg):
"""控制台输出(兼容 Windows 旧代码页)"""
try:
print(msg, flush=True)
except Exception:
try:
sys.stdout.buffer.write((msg + '\n').encode('utf-8', 'replace'))
sys.stdout.flush()
except Exception:
pass
class DSFHandler(SimpleHTTPRequestHandler):
server_version = 'DSF/0.3.2'
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=ROOT, **kwargs)
# ---------- 工具 ----------
def _send(self, code, body=b'', ctype='application/json; charset=utf-8', extra=None):
self.send_response(code)
self.send_header('Content-Type', ctype)
self.send_header('Content-Length', str(len(body)))
self.send_header('Cache-Control', 'no-store')
self.send_header('X-Content-Type-Options', 'nosniff')
for key, value in (extra or {}).items():
self.send_header(key, value)
self.end_headers()
if body:
self.wfile.write(body)
def _deny(self, message):
"""拒绝并关闭连接:避免“已读取一半的请求体”污染 keep-alive 连接"""
self.close_connection = True
self._send(403, json.dumps({'error': message}).encode('utf-8'),
extra={'Connection': 'close'})
def _request_origin(self):
"""返回 (origin, host_ok, origin_ok);origin 为 None 表示未携带 Origin"""
origin = self.headers.get('Origin')
host = (self.headers.get('Host') or '').split(':')[0].lower()
host = host or '127.0.0.1'
host_ok = host in LOOPBACK_HOSTS
if not origin:
return None, host_ok, True
try:
parsed = urlparse(origin)
except Exception:
return origin, host_ok, False
origin_host = (parsed.netloc or '').lower()
origin_ok = bool(origin_host) and origin_host == (self.headers.get('Host') or '').lower()
return origin, host_ok, origin_ok
def _guard(self):
"""统一访问控制:回环 Host + 同源 Origin + 会话 Token。通过返回 True"""
origin, host_ok, origin_ok = self._request_origin()
if not host_ok:
self._deny('forbidden host')
return False
if not origin_ok:
self._deny('forbidden origin')
return False
if self.headers.get('X-DSF-Token') != SESSION_TOKEN:
self._deny('forbidden token')
return False
return True
def _send_index_with_token(self):
path = os.path.join(ROOT, INDEX_FILE)
try:
with open(path, 'r', encoding='utf-8') as fh:
html = fh.read().replace(TOKEN_PLACEHOLDER, SESSION_TOKEN)
except OSError as exc:
self._send(500, json.dumps({'error': str(exc)}).encode('utf-8'))
return
self._send(200, html.encode('utf-8'), TEXT_MIME['.html'])
def _path_forbidden(self):
"""静态托管的防护:目录穿越 / 隐藏目录 / node_modules / 数据文件本身"""
rel = urlparse(self.path).path
rel = urllib_parse_unquote(rel)
if rel in ('', '/'):
return False
target = os.path.abspath(os.path.join(ROOT, rel.lstrip('/')))
if target != ROOT and not target.startswith(ROOT + os.sep):
return True # 目录穿越
if target == DATA_FILE:
return True # 数据文件不经静态托管外泄(页面走 /api/state)
segments = os.path.relpath(target, ROOT).split(os.sep)
for seg in segments:
if seg.startswith('.') or seg == 'node_modules':
return True
return False
def guess_type(self, path):
"""给文本资源补上 charset(否则某些浏览器会把中文当 latin-1 渲染)"""
ctype = super().guess_type(path)
ext = os.path.splitext(path)[1].lower()
if ext in TEXT_MIME:
return TEXT_MIME[ext]
if ctype.startswith('text/') and 'charset' not in ctype:
return ctype + '; charset=utf-8'
return ctype
def log_message(self, fmt, *args):
# 数据接口的每次轮询不刷屏;普通静态请求保留默认日志
if urlparse(self.path).path.startswith('/api/'):
return
super().log_message(fmt, *args)
# ---------- 路由 ----------
def do_OPTIONS(self):
"""预检:仅同源放行(跨站预检会因为拿不到允许头而失败)"""
origin, host_ok, origin_ok = self._request_origin()
if not host_ok:
self._deny('forbidden host')
return
if origin and not origin_ok:
self._deny('forbidden origin')
return
if origin:
self._send(204, b'', extra={
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'GET, PUT, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, X-DSF-Token',
'Access-Control-Max-Age': '600',
'Vary': 'Origin',
})
else:
self._send(204, b'')
def do_GET(self):
path = urlparse(self.path).path
if path == API_PATH:
if not self._guard():
return
try:
if os.path.exists(DATA_FILE):
with open(DATA_FILE, 'r', encoding='utf-8') as fh:
body = fh.read()
else:
body = 'null' # 尚无数据文件
self._send(200, body.encode('utf-8'))
except Exception as exc:
self._send(500, json.dumps({'error': str(exc)}).encode('utf-8'))
return
if path == '/' or path == '/' + INDEX_FILE:
origin, host_ok, origin_ok = self._request_origin()
if not host_ok or not origin_ok:
self._deny('forbidden host')
return
self._send_index_with_token()
return
if self._path_forbidden():
self._send(403, b'{"error":"forbidden"}')
return
super().do_GET()
def do_PUT(self):
if urlparse(self.path).path != API_PATH:
self._send(405, b'{"error":"method not allowed"}')
return
if not self._guard():
return
# GET/PUT 都需要校验 JSON 合法性;同时用唯一的临时文件,避免并发写互相截断
try:
length = int(self.headers.get('Content-Length') or 0)
payload = self.rfile.read(length)
json.loads(payload.decode('utf-8')) # 先校验 JSON 合法性
tmp = '%s.%d.%d.tmp' % (DATA_FILE, os.getpid(), threading.get_ident())
try:
with open(tmp, 'wb') as fh:
fh.write(payload)
os.replace(tmp, DATA_FILE) # 原子替换,避免写到一半损坏
except Exception:
if os.path.exists(tmp):
try:
os.remove(tmp)
except OSError:
pass
raise
self._send(200, b'{"ok":true}')
except Exception as exc:
self._send(500, json.dumps({'error': str(exc)}).encode('utf-8'))
def _open_browser(url):
try:
webbrowser.open(url, new=2)
except Exception:
pass
def ensure_data_file():
"""首次启动时创建空数据文件:
让“服务器模式是否生效”可一眼确认(目录里出现 dsf-data.json)。"""
if os.path.exists(DATA_FILE):
return
import time
base = {
'v': 1,
'savedAt': int(time.time() * 1000),
'folders': [],
'sessions': [],
'recentClosed': [],
'settings': {'sidebarCollapsed': False, 'lastImportFolderId': ''}
}
tmp = DATA_FILE + '.tmp'
with open(tmp, 'w', encoding='utf-8') as fh:
json.dump(base, fh, ensure_ascii=False, indent=2)
os.replace(tmp, DATA_FILE)
def main():
port = 8000
if len(sys.argv) > 1:
try:
port = int(sys.argv[1])
except ValueError:
log('用法:python server.py [端口],默认 8000')
sys.exit(1)
port = int(os.environ.get('DSF_PORT', port))
try:
server = ThreadingHTTPServer(('127.0.0.1', port), DSFHandler)
except OSError:
log('端口 %d 被占用:请换端口(python server.py 8080),'
'或先关闭占用该端口的程序。' % port)
sys.exit(1)
ensure_data_file()
log('')
log('=====================================================')
log(' DSF 服务器已启动')
log(' 打开: http://127.0.0.1:%d/' % port)
log(' 数据文件: %s' % DATA_FILE)
log(' 关闭: 在此窗口按 Ctrl+C')
log('=====================================================')
log('')
log('提示:每次改动都会自动保存到上面的数据文件;')
log('重启电脑后请再次双击 start-dsf.cmd 打开,数据会自动恢复。')
log('')
url = 'http://127.0.0.1:%d/' % port
# 稍等片刻让服务器就绪,再自动打开默认浏览器(DSF_NO_BROWSER=1 可关闭)
if os.environ.get('DSF_NO_BROWSER') != '1':
threading.Timer(1.0, lambda: _open_browser(url)).start()
try:
server.serve_forever()
except KeyboardInterrupt:
log('已停止。数据已保存在 dsf-data.json。')
finally:
server.server_close()
if __name__ == '__main__':
main()