#!/usr/bin/env python3
"""
anon.py — anonfilesnew.com 一体化命令行工具 (上传 + 下载 + 删除 + stream)
anon.go 的 Python 移植（修复 CSRF/session 不匹配）

用法:
  python anon.py upload <file> [expiry] [workers]
  python anon.py download <url> <outfile> [workers] [segMB]
  python anon.py rm <file_id> <token> [more pairs...]
  python anon.py stream <文件页URL>

expiry: 1 / 3 / 7 / 15 / 30 / 1337 (1337 = Never)
"""
import os, sys, re, json, time, base64, hashlib, threading
from concurrent.futures import ThreadPoolExecutor

import requests
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler

HOME_URL    = "https://anonfilesnew.com/"
TUS_URL     = "https://api.baycdn.net/upload"
VAPI_URL    = "https://v-api.baycdn.net/upload"
VAPI_STATUS = "https://v-api.baycdn.net/status/"
API_BASE    = "https://anonfilesnew.com/v3"
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
      "(KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36")

CHUNK_SIZE   = 16 * 1024 * 1024
V_CHUNK_SIZE = 25 * 1024 * 1024
V_WORKERS_DEF  = 4
DL_WORKERS_DEF = 16
DL_SEG_MB_DEF  = 8

CSRF_RE     = re.compile(r'<meta name="X-CSRF-Token" content="([^"]+)"')
U_RE        = re.compile(r'u=\{(.+?)\},o=\[\[')
O_RE        = re.compile(r'o=(\[\[.+?\]\]),l=""')
ITEM_RE     = re.compile(r'_(\w+):"([^"]+)"')
ORD_RE      = re.compile(r'\["(_\w+)",(\d+)\]')
XOR_RE      = re.compile(r'charCodeAt\(t\)\^(\d+)\+t')
CSRF_DEL_RE = re.compile(r'name="_csrf" value="([^"]+)"')

# ==================== 进度条 ====================

def show_prog(done, total, start, inst_spd, extra=""):
    pct = done / total * 100 if total else 0
    avg = done / 1048576 / max(time.time() - start, 0.001)
    spd = inst_spd if inst_spd > 0 else avg
    eta = int((total - done) / (spd * 1048576)) if spd > 0 else 0
    bar_w = 20
    filled = min(int(pct / 100 * bar_w), bar_w)
    bar = "=" * filled + "-" * (bar_w - filled)
    sys.stdout.write(f"\r[{bar}] {pct:6.1f}% {spd:5.1f}MB/s (avg {avg:4.1f}) ETA {eta}s {extra}")
    sys.stdout.flush()

def start_ticker(progress, total, start, get_extra, lock):
    stop = threading.Event()
    def run():
        last, last_t = 0, None
        while not stop.wait(1.0):
            with lock:
                d = progress[0]
            now = time.time()
            inst = (d - last) / 1048576 / (now - last_t) if last_t else 0.0
            show_prog(d, total, start, inst, get_extra())
            last, last_t = d, now
    threading.Thread(target=run, daemon=True).start()
    return stop

# ==================== AES-256-GCM ====================

def gcm_enc(plain: str) -> str:
    key, nonce = os.urandom(32), os.urandom(12)
    ct = AESGCM(key).encrypt(nonce, plain.encode(), None)
    obj = {"k": base64.b64encode(key).decode(),
           "iv": base64.b64encode(nonce).decode(),
           "ct": base64.b64encode(ct).decode()}
    return base64.b64encode(json.dumps(obj).encode()).decode()

def gcm_dec(b64str: str) -> str:
    obj = json.loads(base64.b64decode(b64str))
    key = base64.b64decode(obj["k"])
    iv  = base64.b64decode(obj["iv"])
    ct  = base64.b64decode(obj["ct"])
    return AESGCM(key).decrypt(iv, ct, None).decode()

# ==================== CSRF ====================

def fetch_csrf(sess):
    """GET 主页拿 CSRF；失败抛异常"""
    r = sess.get(HOME_URL, timeout=30)
    m = CSRF_RE.search(r.text)
    if not m:
        raise RuntimeError("no CSRF token in home page")
    return m.group(1)

# ==================== 上传：通用 ====================

def post_json(path, body, csrf, want_meta=False, sess=None):
    sess = sess or requests.Session()
    headers = {
        "Content-Type": "application/json",
        "X-CSRF-Token": csrf,
        "Origin": "https://anonfilesnew.com",
        "Referer": HOME_URL,
        "User-Agent": UA,
    }
    if want_meta:
        headers["Accept"] = "application/json, text/javascript, */*; q=0.01"
        headers["X-Requested-With"] = "XMLHttpRequest"
    r = sess.post(API_BASE + path, json=body, headers=headers, timeout=300)
    print(f"POST {path} -> {r.status_code}: {r.text.strip()[:400]}")
    if r.status_code != 200:
        return False
    if want_meta:
        try:
            j = r.json()
            if j.get("success"):
                md = j["data"]["metadata"]
                print(f"\n[+] FILE PAGE: https://anonfilesnew.com/s/{md['file_id']}")
                print(f"[+] token: {md.get('token', '')}")
        except Exception:
            pass
    return True

# ==================== 上传：tus 普通文件 ====================

def upload_tus(filepath, data, size, name, mime, md5hex, csrf, expiry, sess):
    """注意：sess 由外部传入，保持 cookie 一致，避免 CSRF/session 不匹配"""
    sess.headers["User-Agent"] = UA
    b64 = lambda s: base64.b64encode(s.encode()).decode()
    meta = f"filename {b64(name)},filetype {b64(mime)},filesize {b64(str(size))}"
    r = sess.post(TUS_URL, headers={
        "Tus-Resumable": "1.0.0",
        "Upload-Length": str(size),
        "Upload-Metadata": meta,
        "Origin": "https://anonfilesnew.com",
        "Referer": HOME_URL,
    }, timeout=300)
    if r.status_code != 201:
        print("tus create failed:", r.status_code, r.text[:200])
        return
    loc = r.headers.get("Location", "")
    if not loc.startswith("http"):
        loc = "https://api.baycdn.net" + loc
    print("tus:", loc)

    progress = [0]
    lock = threading.Lock()
    start_all = time.time()
    total_chunks = (size + CHUNK_SIZE - 1) // CHUNK_SIZE
    stop = start_ticker(progress, size, start_all,
                        lambda: f"piece {progress[0]//CHUNK_SIZE+1}/{total_chunks}", lock)
    offset = 0
    try:
        with open(filepath, "rb") as f:
            while offset < size:
                f.seek(offset)
                piece = f.read(CHUNK_SIZE)
                if not piece:
                    break
                pr = sess.patch(loc, data=piece, headers={
                    "Tus-Resumable": "1.0.0",
                    "Upload-Offset": str(offset),
                    "Content-Type": "application/offset+octet-stream",
                }, timeout=300)
                if pr.status_code not in (200, 204):
                    print(f"\nPATCH @{offset} -> {pr.status_code}: {pr.text[:200]}")
                    return
                new_off = pr.headers.get("Upload-Offset")
                if new_off and int(new_off) > offset:
                    offset = int(new_off)
                else:
                    print("\n!! no progress, abort")
                    return
                with lock:
                    progress[0] = offset
    finally:
        stop.set()
    dt = time.time() - start_all
    print(f"\nupload done in {dt:.0f}s ({size/1048576/dt:.2f} MB/s)")

    # 关键修复：/v3/upload 前用同一个 session 重新抓 CSRF
    # （原 CSRF 可能已过期/一次性，或被 /file/upload 消耗）
    try:
        csrf = fetch_csrf(sess)
        print("CSRF refreshed:", csrf)
    except Exception as e:
        print("csrf refresh err:", e, "— 用旧 token 继续尝试")

    payload = {
        "aB": gcm_enc(loc),
        "bC": gcm_enc(name),
        "cD": gcm_enc(str(size)),
        "dE": gcm_enc(mime),
        "eF": "",
        "fG": gcm_enc(md5hex),
        "gH": gcm_enc(expiry),
    }
    post_json("/upload", payload, csrf, want_meta=True, sess=sess)
    print("all done")

# ==================== 上传：v-api 视频分块 ====================

def upload_video(data, name, size, expiry, workers, sess_main):
    n = max(1, (size + V_CHUNK_SIZE - 1) // V_CHUNK_SIZE)
    workers = min(workers, n)
    print(f"video upload: {n} chunks of {V_CHUNK_SIZE} bytes, {workers} connections")

    start_all = time.time()
    done_blocks = [0]
    lock = threading.Lock()
    file_id = [""]
    token = [""]
    stop = start_ticker(done_blocks, n, start_all,
                        lambda: f"chunks {done_blocks[0]}/{n}", lock)

    def send_chunk(i):
        start = i * V_CHUNK_SIZE
        end = min(start + V_CHUNK_SIZE, size)
        piece = data[start:end]
        form = {"fne": name, "cki": str(i), "ttc": str(n), "fxp": expiry}
        if i > 0 and file_id[0]:
            form["id"] = file_id[0]
        last_code, last_body = 0, b""
        for attempt in range(3):
            # v-api 是独立 CDN，不需要主站 cookie；单独 session 即可
            s = requests.Session()
            s.headers.update({"User-Agent": UA,
                              "Origin": "https://anonfilesnew.com",
                              "Referer": HOME_URL})
            try:
                r = s.post(VAPI_URL, files={"chk": ("blob", piece)},
                           data=form, timeout=300)
            except Exception as e:
                print(f"\nchunk {i+1}/{n} attempt {attempt+1} err: {e}")
                time.sleep(3)
                continue
            last_code, last_body = r.status_code, r.content
            if r.status_code == 200:
                return r.status_code, r.content
            print(f"\nchunk {i+1}/{n} attempt {attempt+1} -> {r.status_code}: {r.text[:150]}")
            time.sleep(3)
        return last_code, last_body

    # 首块
    sc, body = send_chunk(0)
    if sc != 200:
        print("first chunk failed")
        stop.set()
        return
    try:
        j = json.loads(body)
        if j.get("success"):
            if j.get("id"):
                file_id[0] = j["id"]
            md = j.get("data", {}).get("metadata", {}) or {}
            if md.get("file_id"):
                file_id[0] = md["file_id"]
                token[0] = md.get("token", "")
    except Exception:
        pass
    with lock:
        done_blocks[0] = 1
    show_prog(done_blocks[0], n, start_all, 0, f"chunks {done_blocks[0]}/{n}")
    print(f"\nsession id: {file_id[0]}")
    if not file_id[0]:
        stop.set()
        return

    # 剩余块并发
    def worker(idx):
        sc, body = send_chunk(idx)
        if sc != 200:
            print(f"\nchunk {idx+1}/{n} FAILED after retries")
            return
        try:
            j = json.loads(body)
            md = j.get("data", {}).get("metadata", {}) or {}
            if md.get("file_id"):
                with lock:
                    if not token[0]:
                        file_id[0] = md["file_id"]
                        token[0] = md.get("token", "")
        except Exception:
            pass
        with lock:
            done_blocks[0] += 1

    with ThreadPoolExecutor(max_workers=workers) as ex:
        list(ex.map(worker, range(1, n)))
    stop.set()
    dt = time.time() - start_all
    print(f"\nALL chunks done in {dt:.0f}s ({size/1048576/dt:.2f} MB/s)")

    if not file_id[0]:
        print("!! no file_id from video upload")
        return
    print(f"[+] FILE PAGE: https://anonfilesnew.com/s/{file_id[0]} (token={token[0]})")

    # 轮询处理状态
    print("[+] polling video processing status...")
    t0 = time.time()
    while True:
        r = sess_main.get(VAPI_STATUS + file_id[0], headers={
            "User-Agent": UA, "X-User-ID": "",
            "Origin": "https://anonfilesnew.com", "Referer": HOME_URL,
        }, timeout=60)
        try:
            st = r.json()
        except Exception:
            st = {}
        print(f"[{time.time()-t0:.0f}s] status={st.get('status')} "
              f"progress={st.get('progress')} stages={st.get('stages')} "
              f"msg={st.get('message')}")
        if st.get("processingComplete"):
            print("[+] processing complete")
            return
        if st.get("status") == "failed":
            print("[!] processing FAILED")
            return
        time.sleep(3)

# ==================== 上传入口 ====================

def cmd_upload(filepath, expiry, workers):
    st = os.stat(filepath)
    size = st.st_size
    name = os.path.basename(filepath)
    with open(filepath, "rb") as f:
        data = f.read()
    ext = os.path.splitext(name)[1].lower()
    mime_map = {".mp4": "video/mp4", ".webm": "video/webm",
                ".mov": "video/quicktime", ".mkv": "video/x-matroska"}
    mime = mime_map.get(ext, "application/octet-stream")
    md5hex = hashlib.md5(data).hexdigest()
    print(f"file={name} size={size} mime={mime} md5={md5hex} expiry={expiry}")

    # 全程共用同一个 Session（含 cookiejar）
    sess = requests.Session()
    sess.headers["User-Agent"] = UA
    try:
        csrf = fetch_csrf(sess)
    except Exception as e:
        print(e)
        return
    print("CSRF:", csrf)

    files_plain = json.dumps([{"name": name, "size": size}])
    files_enc = gcm_enc(files_plain)
    try:
        print("self-decrypt:", gcm_dec(files_enc))
    except Exception as e:
        print("self-decrypt err:", e)
    if not post_json("/file/upload", {"files": files_enc}, csrf, sess=sess):
        return

    if mime.startswith("video/"):
        upload_video(data, name, size, expiry, workers, sess)
    else:
        upload_tus(filepath, data, size, name, mime, md5hex, csrf, expiry, sess)

# ==================== 下载：basePath 解码 ====================

def decode_base_path(html: str) -> str:
    m = U_RE.search(html)
    if not m:
        return ""
    vals = {it[0]: it[1] for it in ITEM_RE.findall(m.group(1))}

    mo = O_RE.search(html)
    if not mo:
        return ""
    pairs = [(it[0].lstrip("_"), int(it[1])) for it in ORD_RE.findall(mo.group(1))]
    if not vals or not pairs:
        return ""
    pairs.sort(key=lambda x: x[1])

    prefix = "https://anonfilesnew.com/content/v4?s="
    key = 78
    mx = XOR_RE.search(html)
    if mx:
        try:
            key = int(mx.group(1))
        except ValueError:
            pass
    cands = [key] + [k for k in range(256) if k != key]
    for k in cands:
        buf = bytearray()
        ok = True
        for pname, porder in pairs:
            b64 = vals.get(pname)
            if b64 is None:
                ok = False
                break
            try:
                raw = base64.b64decode(b64)
            except Exception:
                ok = False
                break
            for t, b in enumerate(raw):
                buf.append(b ^ ((k + t + porder) & 0xFF))
        if ok and bytes(buf).startswith(prefix.encode()):
            return buf.decode("utf-8", errors="replace")
    return ""

def resolve_url(url: str) -> str:
    if "/content/v4?" in url:
        return url
    r = requests.get(url, headers={"User-Agent": UA}, timeout=30)
    print(f"page: HTTP {r.status_code}, {len(r.content)} bytes")
    if r.status_code != 200:
        raise RuntimeError(f"page HTTP {r.status_code}")
    bp = decode_base_path(r.text)
    if not bp:
        raise RuntimeError("decode fail: no u/o pattern in page (invalid id or private?)")
    return bp

# ==================== 下载：多线程 Range ====================

def dl_get_headers(extra=None):
    h = {"User-Agent": UA, "Referer": "https://anonfilesnew.com/"}
    if extra:
        h.update(extra)
    return h

def download_seg(sess, url, f, flock, s, e, progress, plock):
    try:
        r = sess.get(url, headers=dl_get_headers({"Range": f"bytes={s}-{e}"}),
                     stream=True, timeout=180)
    except Exception:
        return False
    if r.status_code != 206:
        r.close()
        return False
    n2 = 0
    try:
        for chunk in r.iter_content(1 << 20):
            if not chunk:
                continue
            with flock:
                f.seek(s + n2)
                f.write(chunk)
            n2 += len(chunk)
            with plock:
                progress[0] += len(chunk)
    except Exception:
        r.close()
        return False
    r.close()
    return n2 >= e - s + 1

def cmd_download(url, out_path, workers, seg_mb):
    try:
        dl_url = resolve_url(url)
    except Exception as e:
        print(e, file=sys.stderr); sys.exit(1)
    print("resolved:", dl_url[:120] + "...")

    sess = requests.Session()
    r = sess.get(dl_url, headers=dl_get_headers(), stream=True, timeout=30)
    total = int(r.headers.get("Content-Length", 0) or 0)
    r.close()
    if total <= 0 or total < 4096:
        r2 = sess.get(dl_url, headers=dl_get_headers({"Range": "bytes=0-0"}),
                      stream=True, timeout=30)
        cr = r2.headers.get("Content-Range", "")
        r2.close()
        if not cr:
            print("no Content-Range; server does not support Range", file=sys.stderr)
            sys.exit(1)
        total = int(cr.split("/")[-1])
    if total <= 0:
        print("unknown size", file=sys.stderr); sys.exit(1)
    print(f"total: {total} bytes ({total/1048576:.2f} MB)")

    exist = os.path.getsize(out_path) if os.path.exists(out_path) else 0
    if exist >= total:
        print(f"already complete: {out_path}")
        return
    f = open(out_path, "r+b" if exist > 0 else "wb")
    if exist > 0:
        print(f"resume from {exist} bytes ({exist/total*100:.1f}%)")

    seg = seg_mb * 1048576
    progress = [exist]
    flock, plock = threading.Lock(), threading.Lock()
    start = time.time()

    def worker(s, e):
        s_sess = requests.Session()
        while not download_seg(s_sess, dl_url, f, flock, s, e, progress, plock):
            time.sleep(3)

    ranges = []
    s = exist
    while s < total:
        e = min(s + seg - 1, total - 1)
        ranges.append((s, e))
        s += seg

    with ThreadPoolExecutor(max_workers=workers) as ex:
        futs = [ex.submit(worker, s, e) for s, e in ranges]
        while True:
            done = sum(1 for fu in futs if fu.done())
            with plock:
                d = progress[0]
            pct = d / total * 100
            spd = (d - exist) / 1048576 / max(time.time() - start, 0.001)
            sys.stdout.write(f"\rprogress: {d} / {total} ({pct:.1f}%)  {spd:.1f} MB/s")
            sys.stdout.flush()
            if done == len(futs):
                break
            time.sleep(3)
        for fu in futs:
            fu.result()
    print()
    f.close()

    got = os.path.getsize(out_path)
    if got != total:
        print(f"MISMATCH: got {got} want {total}", file=sys.stderr); sys.exit(1)
    print(f"OK: {out_path} complete ({got} bytes, {got/1048576:.2f} MB, "
          f"{int(time.time()-start)}s)")

# ==================== 删除 ====================

def cmd_remove(pairs):
    for i in range(0, len(pairs) - 1, 2):
        file_id, token = pairs[i], pairs[i+1]
        sess = requests.Session()
        sess.headers.update({"User-Agent": UA, "Origin": "https://anonfilesnew.com",
                             "Referer": HOME_URL})
        url = f"https://anonfilesnew.com/file/{file_id}/remove?t={token}"
        r = sess.get(url, timeout=60)
        if r.status_code != 200:
            print(f"{file_id}: GET -> {r.status_code} {r.text[:200].strip()}")
            continue
        m = CSRF_DEL_RE.search(r.text)
        if not m:
            print(f"{file_id}: no _csrf in page (token invalid?)")
            continue
        csrf = m.group(1)
        pr = sess.post(url, data={"_csrf": csrf}, headers={
            "Content-Type": "application/x-www-form-urlencoded",
            "X-Requested-With": "XMLHttpRequest",
            "Referer": url,
        }, timeout=60)
        print(f"{file_id}: POST -> {pr.status_code} {pr.text[:300].strip()}")
        time.sleep(1)
        ir = sess.get(f"https://api.anonfilesnew.com/v3/file/{file_id}/info", timeout=60)
        print(f"{file_id}: info after -> {ir.status_code} {ir.text[:160].strip()}")
        print()

# ==================== stream 本地代理 ====================

class StreamHandler(BaseHTTPRequestHandler):
    signed = None
    page_url = None
    lock = threading.Lock()

    def log_message(self, *a):
        pass

    def _forward(self, signed, method, range_hdr):
        h = dl_get_headers({"Range": range_hdr} if range_hdr else None)
        try:
            return requests.request(method, signed, headers=h, stream=True, timeout=300)
        except Exception as e:
            self.send_error(502, f"upstream err: {e}")
            return None

    def _proxy(self, method):
        with StreamHandler.lock:
            s = StreamHandler.signed
        rng = self.headers.get("Range")
        resp = self._forward(s, method, rng)
        if resp is None:
            return
        if resp.status_code in (410, 403) and method == "GET":
            resp.close()
            try:
                s2 = resolve_url(StreamHandler.page_url)
            except Exception as e:
                self.send_error(502, f"token refresh fail: {e}")
                return
            with StreamHandler.lock:
                StreamHandler.signed = s2
            print("[+] token expired, refreshed")
            resp = self._forward(s2, method, rng)
            if resp is None:
                return
        self.send_response(resp.status_code)
        for h in ["Content-Type", "Content-Length", "Content-Range", "Accept-Ranges",
                  "Content-Disposition", "Last-Modified", "ETag"]:
            if h in resp.headers:
                self.send_header(h, resp.headers[h])
        self.end_headers()
        if method == "HEAD":
            resp.close()
            return
        try:
            for chunk in resp.iter_content(65536):
                if chunk:
                    self.wfile.write(chunk)
        except Exception:
            pass
        finally:
            resp.close()

    def do_GET(self):
        self._proxy("GET")

    def do_HEAD(self):
        self._proxy("HEAD")

def cmd_stream(page_url):
    try:
        signed = resolve_url(page_url)
    except Exception as e:
        print("resolve fail:", e)
        return
    StreamHandler.signed = signed
    StreamHandler.page_url = page_url
    print("[+] stream proxy on 127.0.0.1:8340 — token auto-refresh on expiry (Ctrl+C to stop)")
    print("[+] mpv http://127.0.0.1:8340/video.mp4")
    ThreadingHTTPServer(("127.0.0.1", 8340), StreamHandler).serve_forever()

# ==================== main ====================

def main():
    import argparse
    p = argparse.ArgumentParser(description="anon — anonfilesnew.com 一体化工具")
    sub = p.add_subparsers(dest="cmd")

    pu = sub.add_parser("upload")
    pu.add_argument("file")
    pu.add_argument("expiry", nargs="?", default="3")
    pu.add_argument("workers", nargs="?", type=int, default=V_WORKERS_DEF)

    pd = sub.add_parser("download")
    pd.add_argument("url")
    pd.add_argument("outfile")
    pd.add_argument("workers", nargs="?", type=int, default=DL_WORKERS_DEF)
    pd.add_argument("segMB", nargs="?", type=int, default=DL_SEG_MB_DEF)

    pr = sub.add_parser("rm")
    pr.add_argument("pairs", nargs="+")

    ps = sub.add_parser("stream")
    ps.add_argument("url")

    args = p.parse_args()
    if not args.cmd:
        p.print_help()
        return
    if args.cmd == "upload":
        cmd_upload(args.file, args.expiry, max(1, args.workers))
    elif args.cmd == "download":
        cmd_download(args.url, args.outfile, max(1, args.workers), max(1, args.segMB))
    elif args.cmd == "rm":
        if len(args.pairs) < 2:
            print("usage: anon.py rm <file_id> <token> [more pairs]")
            return
        cmd_remove(args.pairs)
    elif args.cmd == "stream":
        cmd_stream(args.url)

if __name__ == "__main__":
    main()
