#!/usr/bin/env python3
"""
树成林 AI 班务系统 — HTTP 服务（多线程安全版）
修复: 路径遍历防护 + 并发支持 + /open 安全沙箱
"""
from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn
from urllib.parse import unquote
import os, sys, json, mimetypes, base64, subprocess
from urllib.parse import quote
import zipfile, io, secrets

# pythonw.exe 静默运行时 stderr 为 None，http.server 内部 log_message() 调 sys.stderr.write() 会崩
# 导致客户端收到空响应/连接断开。重定向到 /dev/null 防止崩溃。
if sys.stderr is None:
    sys.stderr = open(os.devnull, 'w')
if sys.stdout is None:
    sys.stdout = open(os.devnull, 'w')

DESKTOP = os.path.dirname(os.path.abspath(__file__))
DESKTOP_REAL = os.path.realpath(DESKTOP)
FILES_DIR = os.path.join(DESKTOP, 'files')
os.makedirs(FILES_DIR, exist_ok=True)  # 本地用户解压即用

# 允许在 /open 端点写入的文件扩展名白名单
SAFE_EXTENSIONS = {'.txt', '.md', '.csv', '.json', '.xlsx', '.docx', '.pdf',
                   '.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg',
                   '.mp3', '.mp4', '.wav', '.webm'}


class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
    """多线程 HTTP 服务器，每个请求一个线程"""
    daemon_threads = True


def safe_path(requested_path: str) -> str | None:
    """解析请求路径，防路径遍历攻击。返回绝对路径或 None（不合法）"""
    raw = requested_path.lstrip('/').split('?')[0]
    raw = unquote(raw)
    if not raw:
        return os.path.join(DESKTOP, 'index.html')

    # 拒绝含 null 字节的路径
    if '\x00' in raw:
        return None

    candidate = os.path.realpath(os.path.join(DESKTOP, raw))
    # 必须在 DESKTOP 子树内
    if not candidate.startswith(DESKTOP_REAL + os.sep) and candidate != DESKTOP_REAL:
        return None
    return candidate


class H(BaseHTTPRequestHandler):
    @staticmethod
    def _detect_charset(data):
        """检测文本编码：UTF-8 → GBK → 未知"""
        if not data: return None
        try:
            data.decode('utf-8')
            return 'utf-8'
        except UnicodeDecodeError:
            pass
        try:
            data.decode('gbk')
            return 'gbk'
        except UnicodeDecodeError:
            pass
        return None

    def _send(self, code, content_type='text/plain', body=b''):
        self.send_response(code)
        self.send_header('Content-Type', content_type)
        self.send_header('X-Content-Type-Options', 'nosniff')
        self.send_header('X-Frame-Options', 'DENY')
        self.send_header('X-Content-Security-Policy', "default-src 'self'")
        self.end_headers()
        if body:
            self.wfile.write(body)

    def do_GET(self):
        # /__local_check —— 前端检测是否本地部署
        if self.path == '/__local_check':
            self._send(200, 'application/json', b'{"local":true}')
            return
        # /check-files —— 前端检查服务器是否有资源文件
        if self.path == '/check-files':
            self._check_files()
            return
        # /download-files —— 直接下载服务器 files/ 目录的 zip
        if self.path == '/download-files':
            self._download_server_files()
            return
        path = safe_path(self.path)
        if path is None or not os.path.isfile(path):
            self._send(404, body=b'404 Not Found')
            return

        # 后端私有文件：禁止外部直接访问，本站页面 fetch 放行
        basename = os.path.basename(path)
        if (basename.startswith('subj_') or basename == 'ext_quotes.txt'):
            referer = self.headers.get('Referer', '')
            # 无 Referer（同源请求）或 Referer 含本站域名 → 放行；否则拒绝
            if referer and self.headers.get('Host', '') not in referer:
                self._send(403, body=b'403 Forbidden')
                return

        mime, _ = mimetypes.guess_type(path)
        try:
            with open(path, 'rb') as f:
                body = f.read()
            ct = mime or 'application/octet-stream'
            if ct.startswith('text/') or ct in ('application/json', 'application/javascript'):
                cs = self._detect_charset(body)
                if cs:
                    ct = f'{ct}; charset={cs}'
            # .tmp/ 文件
            if '/.tmp/' in path:
                host = self.headers.get('Host', '')
                is_local = host.startswith('localhost') or host.startswith('127.')
                if is_local:
                    ct_final = ct  # 本地：正确 MIME
                else:
                    ct_final = 'application/octet-stream'  # 远程：强制下载
                self.send_response(200)
                self.send_header('Content-Type', ct_final)
                self.send_header('X-Content-Type-Options', 'nosniff')
                self.send_header('X-Frame-Options', 'DENY')
                self.end_headers()
                self.wfile.write(body)
                return
            self._send(200, ct, body)
        except PermissionError:
            self._send(403, body=b'403 Forbidden')

    def _check_files(self):
        """返回服务器 files/ 目录下的文件和文件夹列表"""
        import glob
        files_dir = os.path.join(DESKTOP, 'files')
        result = []
        if os.path.isdir(files_dir):
            for f in sorted(os.listdir(files_dir)):
                fp = os.path.join(files_dir, f)
                if f.startswith('.'):
                    continue
                if os.path.isdir(fp):
                    result.append({'name': f, 'type': 'dir', 'size': 0})
                elif os.path.isfile(fp):
                    result.append({'name': f, 'size': os.path.getsize(fp)})
        self._send(200, 'application/json', json.dumps(result).encode())

    def _download_server_files(self):
        """打包整站部署包：server.py + index.html + files/ + 依赖"""
        INCLUDE = {'server.py', 'index.html', 'manifest.json', 'three.min.js', 'ext_quotes.txt', 'runserver.bat', '一键部署.bat', '一键部署.vbs', '班务系统.url', 'setup.bat', 'setup-remove.bat', 'uninstall.bat',
                   'autostart.bat', 'setup-autostart.bat', 'remove-autostart.bat',
                   'silent-server.vbs', 'xlsx.full.min.js', 'banwu-server.exe', 'diagnose.html',
                   'ext_quotes.txt'}
        INCLUDE_PREFIX = ('subj_',)  # 所有学科文件
        EXCLUDE = {'.git', '.tmp', '.gitignore'}
        buf = io.BytesIO()
        with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
            # 打包根目录文件
            for fname in sorted(os.listdir(DESKTOP)):
                if fname in EXCLUDE or fname.startswith('.') or 'bak' in fname:
                    continue
                fp = os.path.join(DESKTOP, fname)
                if not os.path.isfile(fp):
                    continue
                if fname in INCLUDE or fname.startswith(INCLUDE_PREFIX):
                    zf.write(fp, '班务系统/app/' + fname)
                    # exe 和 vbs 也放根层级
                    if fname in ('banwu-server.exe', '一键部署.vbs'):
                        zf.write(fp, '班务系统/' + fname)
            # 打包 files/ 目录（递归含子文件夹）
            files_dir = os.path.join(DESKTOP, 'files')
            if os.path.isdir(files_dir):
                for root, dirs, filenames in os.walk(files_dir):
                    for fname in filenames:
                        fp = os.path.join(root, fname)
                        if not fname.startswith('.'):
                            arcname = '班务系统/app/files/' + os.path.relpath(fp, files_dir).replace(chr(92), '/')
                            zf.write(fp, arcname)
            # 附带使用说明
            readme = u"""班务系统 · 本地部署包

使用方法：
1. 双击「一键部署.vbs」→ 浏览器自动打开
2. 或双击 banwu-server.exe → 手动访问 http://localhost:8765

提示：
- 一键部署.vbs  → 静默启动 + 创建桌面快捷方式 + 打开浏览器（推荐）
- setup.bat      → 配域名（需管理员权限，可选）
- setup-autostart.bat → 开机自启（需管理员权限）
- 个性化设置在菜单 → 个性化

零依赖，无需安装 Python 或任何运行环境。\n"""

            zf.writestr('使用说明.txt', readme.encode('utf-8'))
        zip_data = buf.getvalue()
        self.send_response(200)
        self.send_header('Content-Type', 'application/zip')
        self.send_header('Content-Disposition',
                         'attachment; filename="banwu_deploy.zip"')
        self.send_header('Content-Length', str(len(zip_data)))
        self.end_headers()
        self.wfile.write(zip_data)

    def _handle_download_all(self):
        """打包前端传来的文件列表为 zip，触发下载"""
        try:
            length = int(self.headers.get('Content-Length', 0))
            if length > 50 * 1024 * 1024:
                self._send(413, body=b'413 Payload Too Large')
                return
            if length == 0:
                self._send(400, body=b'400 Empty body')
                return
            data = json.loads(self.rfile.read(length))
            files = data.get('files', [])
            token = data.get('token', '')
            # 简单 token 验证
            if token != '0a292a69ee56bac496d1fb2d0e339ae1':
                self._send(403, body=b'403 Forbidden')
                return
            if not files or not isinstance(files, list):
                self._send(400, body=b'400 No files')
                return
            # 在内存中创建 zip
            buf = io.BytesIO()
            with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
                for f in files:
                    name = str(f.get('name', 'unnamed'))[:255]
                    b64 = f.get('data_b64', '')
                    if not b64:
                        continue
                    # 净化文件名，防路径遍历
                    safe = os.path.basename(name)
                    if not safe or safe != name:
                        safe = ''.join(c for c in name if c.isalnum() or c in '._- ()（）' or '一' <= c <= '鿿')
                    try:
                        content = base64.b64decode(b64)
                        zf.writestr(safe, content)
                    except Exception:
                        continue
            zip_data = buf.getvalue()
            self.send_response(200)
            self.send_header('Content-Type', 'application/zip')
            self.send_header('Content-Disposition', 'attachment; filename="files.zip"')
            self.send_header('Content-Length', str(len(zip_data)))
            self.send_header('X-Content-Type-Options', 'nosniff')
            self.end_headers()
            self.wfile.write(zip_data)
        except (json.JSONDecodeError, UnicodeDecodeError):
            self._send(400, body=b'400 Invalid JSON')
        except Exception:
            self._send(500, body=b'500 Internal Error')

    def do_POST(self):
        # /download-all —— 打包所有文件为 zip 下载
        if self.path == '/download-all':
            self._handle_download_all()
            return
        if self.path != '/open':
            self._send(404, body=b'404 Not Found')
            return

        try:
            length = int(self.headers.get('Content-Length', 0))
            if length > 50 * 1024 * 1024:
                self._send(413, body=b'413 Payload Too Large')
                return
            if length == 0:
                self._send(400, body=b'400 Empty body')
                return

            data = json.loads(self.rfile.read(length))
            fname = str(data.get('name', ''))[:255]  # 截断到 255 字符
            # 优先 base64（紧凑），回退 byte 数组（旧客户端兼容）
            b64 = data.get('data_b64', '')
            if b64:
                content = base64.b64decode(b64)
            else:
                content = bytes(data.get('data', []))

            # 文件名校验
            if not fname:
                self._send(400, body=b'400 Missing filename')
                return
            basename = os.path.basename(fname)
            # 净化：只保留安全字符，替换危险字符
            safe_name = ''.join(c for c in basename if c.isalnum() or c in '._- ()（）' or '一' <= c <= '鿿' or '　' <= c <= '〿')
            if not safe_name or safe_name != basename:
                self._send(400, body=b'400 Invalid filename characters')
                return

            ext = os.path.splitext(safe_name)[1].lower()
            if ext not in SAFE_EXTENSIONS:
                self._send(400, body=b'400 File type not allowed')
                return

            tmp_dir = os.path.join(DESKTOP, '.tmp')
            os.makedirs(tmp_dir, exist_ok=True, mode=0o700)
            tmp_path = os.path.join(tmp_dir, safe_name)

            with open(tmp_path, 'wb') as f:
                f.write(content)

            # Windows: start /MAX 置顶打开（CREATE_NO_WINDOW 隐藏 cmd 黑框）
            try:
                if sys.platform == 'win32':
                    subprocess.Popen(
                        ['cmd', '/c', 'start', '/MAX', '', tmp_path],
                        creationflags=subprocess.CREATE_NO_WINDOW
                    )
                elif sys.platform == 'darwin':
                    subprocess.Popen(['open', tmp_path])
                else:
                    subprocess.Popen(['xdg-open', tmp_path])
            except Exception:
                pass  # 无桌面环境（服务器）→ 静默跳过

            resp = json.dumps({'ok': True, 'path': f'.tmp/{safe_name}'})
            self._send(200, 'application/json', resp.encode())

        except (json.JSONDecodeError, UnicodeDecodeError):
            self._send(400, body=b'400 Invalid JSON')
        except Exception:
            # 不泄露内部错误详情
            self._send(500, body=b'500 Internal Error')


if __name__ == '__main__':
    port = int(sys.argv[1]) if len(sys.argv) > 1 else 8765
    server = ThreadingHTTPServer(('127.0.0.1', port), H)
    print(f'班务系统已启动 → http://127.0.0.1:{port} (nginx 反代在前面)')
    server.serve_forever()
