Files

1 line
42 KiB
JSON

{"repo": "beak2825/remotezip-web", "files": {"app.py": {"212e2ce6f9ca2bf2d0386f98a8652a7549254d20": {"t": 1785604169.2261007, "n": 39630, "h": "cb29f654ce715a19", "c": "#!/usr/bin/env python3\n\"\"\"\nzipstream - Browse remote ZIP files without downloading them.\nRange-request based ZIP central directory parser + on-the-fly file streaming.\n\"\"\"\n\nimport os\nimport io\nimport json\nimport zlib\nimport struct\nimport sqlite3\nimport hashlib\nimport mimetypes\nimport threading\nimport urllib.request\nimport urllib.error\nfrom datetime import datetime\nfrom flask import Flask, request, Response, render_template_string, jsonify, redirect, url_for, stream_with_context\n\napp = Flask(__name__)\nDB_PATH = os.path.expanduser(\"~/.zipstream_cache.db\")\ndb_lock = threading.Lock()\n\n# ──────────────────────────────────────────────\n# DATABASE\n# ──────────────────────────────────────────────\n\ndef db_init():\n with sqlite3.connect(DB_PATH) as con:\n con.execute(\"\"\"\n CREATE TABLE IF NOT EXISTS zip_cache (\n url_hash TEXT PRIMARY KEY,\n url TEXT NOT NULL,\n total_size INTEGER,\n entry_count INTEGER,\n fetched_at TEXT,\n entries_json TEXT\n )\n \"\"\")\n con.commit()\n\ndef url_hash(url):\n return hashlib.sha256(url.encode()).hexdigest()\n\ndef cache_get(url):\n h = url_hash(url)\n with sqlite3.connect(DB_PATH) as con:\n row = con.execute(\n \"SELECT url, total_size, entry_count, fetched_at, entries_json FROM zip_cache WHERE url_hash=?\",\n (h,)\n ).fetchone()\n if row:\n return {\n \"url\": row[0],\n \"total_size\": row[1],\n \"entry_count\": row[2],\n \"fetched_at\": row[3],\n \"entries\": json.loads(row[4])\n }\n return None\n\ndef cache_set(url, total_size, entries):\n h = url_hash(url)\n now = datetime.utcnow().isoformat()\n entries_json = json.dumps(entries)\n with db_lock:\n with sqlite3.connect(DB_PATH) as con:\n con.execute(\"\"\"\n INSERT OR REPLACE INTO zip_cache\n (url_hash, url, total_size, entry_count, fetched_at, entries_json)\n VALUES (?, ?, ?, ?, ?, ?)\n \"\"\", (h, url, total_size, len(entries), now, entries_json))\n con.commit()\n\ndef cache_list_all():\n with sqlite3.connect(DB_PATH) as con:\n rows = con.execute(\n \"SELECT url, total_size, entry_count, fetched_at FROM zip_cache ORDER BY fetched_at DESC\"\n ).fetchall()\n return [{\"url\": r[0], \"total_size\": r[1], \"entry_count\": r[2], \"fetched_at\": r[3]} for r in rows]\n\n# ──────────────────────────────────────────────\n# ZIP PARSING (via range requests)\n# ──────────────────────────────────────────────\n\ndef http_head(url):\n req = urllib.request.Request(url, method='HEAD')\n req.add_header('User-Agent', 'zipstream/1.0')\n try:\n with urllib.request.urlopen(req, timeout=15) as r:\n size = r.headers.get('Content-Length')\n accept_ranges = r.headers.get('Accept-Ranges', '')\n return int(size) if size else None, accept_ranges\n except Exception as e:\n raise RuntimeError(f\"HEAD request failed: {e}\")\n\ndef http_range(url, start, end):\n req = urllib.request.Request(url)\n req.add_header('User-Agent', 'zipstream/1.0')\n req.add_header('Range', f'bytes={start}-{end}')\n try:\n with urllib.request.urlopen(req, timeout=30) as r:\n return r.read()\n except Exception as e:\n raise RuntimeError(f\"Range request failed ({start}-{end}): {e}\")\n\ndef find_eocd(data):\n \"\"\"Find End of Central Directory record signature.\"\"\"\n sig = b'PK\\x05\\x06'\n # Search from end\n pos = data.rfind(sig)\n if pos == -1:\n raise ValueError(\"EOCD signature not found — not a valid ZIP or needs more tail bytes\")\n return pos\n\ndef parse_eocd(data, base_offset):\n \"\"\"Parse EOCD record, return (cd_offset, cd_size, total_entries).\"\"\"\n # Check for ZIP64 EOCD locator just before EOCD\n zip64_locator_sig = b'PK\\x06\\x07'\n eocd_offset_in_data = data.rfind(b'PK\\x05\\x06')\n \n # Try ZIP64 first\n locator_pos = data.rfind(zip64_locator_sig)\n if locator_pos != -1:\n # ZIP64 EOCD locator found\n zip64_eocd_offset = struct.unpack_from('<Q', data, locator_pos + 8)[0]\n zip64_eocd_sig = b'PK\\x06\\x06'\n zip64_pos = data.find(zip64_eocd_sig)\n if zip64_pos != -1:\n total_entries = struct.unpack_from('<Q', data, zip64_pos + 32)[0]\n cd_size = struct.unpack_from('<Q', data, zip64_pos + 40)[0]\n cd_offset = struct.unpack_from('<Q', data, zip64_pos + 48)[0]\n return cd_offset, cd_size, total_entries\n\n # Standard EOCD\n pos = eocd_offset_in_data\n disk_entries = struct.unpack_from('<H', data, pos + 8)[0]\n total_entries = struct.unpack_from('<H', data, pos + 10)[0]\n cd_size = struct.unpack_from('<I', data, pos + 12)[0]\n cd_offset = struct.unpack_from('<I', data, pos + 16)[0]\n return cd_offset, cd_size, total_entries\n\ndef parse_central_directory(cd_data):\n \"\"\"Parse central directory records into list of entry dicts.\"\"\"\n entries = []\n pos = 0\n sig = b'PK\\x01\\x02'\n while pos < len(cd_data) - 4:\n if cd_data[pos:pos+4] != sig:\n break\n compress_method = struct.unpack_from('<H', cd_data, pos + 10)[0]\n mod_time_raw = struct.unpack_from('<H', cd_data, pos + 12)[0]\n mod_date_raw = struct.unpack_from('<H', cd_data, pos + 14)[0]\n crc32 = struct.unpack_from('<I', cd_data, pos + 16)[0]\n compressed_size = struct.unpack_from('<I', cd_data, pos + 20)[0]\n uncompressed_size = struct.unpack_from('<I', cd_data, pos + 24)[0]\n fname_len = struct.unpack_from('<H', cd_data, pos + 28)[0]\n extra_len = struct.unpack_from('<H', cd_data, pos + 30)[0]\n comment_len = struct.unpack_from('<H', cd_data, pos + 32)[0]\n local_header_off = struct.unpack_from('<I', cd_data, pos + 42)[0]\n\n fname_bytes = cd_data[pos+46 : pos+46+fname_len]\n try:\n fname = fname_bytes.decode('utf-8')\n except UnicodeDecodeError:\n fname = fname_bytes.decode('cp437', errors='replace')\n\n # Decode MS-DOS timestamp\n try:\n second = (mod_time_raw & 0x1F) * 2\n minute = (mod_time_raw >> 5) & 0x3F\n hour = (mod_time_raw >> 11) & 0x1F\n day = mod_date_raw & 0x1F\n month = (mod_date_raw >> 5) & 0x0F\n year = ((mod_date_raw >> 9) & 0x7F) + 1980\n dt = datetime(year, month, day, hour, minute, second)\n mod_str = dt.strftime('%Y-%m-%d %H:%M')\n except Exception:\n mod_str = '—'\n\n # Handle ZIP64 extended info in extra field\n extra = cd_data[pos+46+fname_len : pos+46+fname_len+extra_len]\n if compressed_size == 0xFFFFFFFF or uncompressed_size == 0xFFFFFFFF or local_header_off == 0xFFFFFFFF:\n epos = 0\n while epos < len(extra) - 4:\n eid = struct.unpack_from('<H', extra, epos)[0]\n elen = struct.unpack_from('<H', extra, epos+2)[0]\n if eid == 0x0001: # ZIP64 extended info\n vals = []\n for i in range(elen // 8):\n vals.append(struct.unpack_from('<Q', extra, epos+4+i*8)[0])\n idx = 0\n if uncompressed_size == 0xFFFFFFFF and idx < len(vals):\n uncompressed_size = vals[idx]; idx += 1\n if compressed_size == 0xFFFFFFFF and idx < len(vals):\n compressed_size = vals[idx]; idx += 1\n if local_header_off == 0xFFFFFFFF and idx < len(vals):\n local_header_off = vals[idx]\n break\n epos += 4 + elen\n\n is_dir = fname.endswith('/')\n entries.append({\n \"name\": fname,\n \"is_dir\": is_dir,\n \"compress_method\": compress_method,\n \"compressed_size\": compressed_size,\n \"uncompressed_size\": uncompressed_size,\n \"local_header_offset\": local_header_off,\n \"crc32\": crc32,\n \"modified\": mod_str,\n })\n pos += 46 + fname_len + extra_len + comment_len\n return entries\n\ndef fetch_zip_directory(url):\n \"\"\"Full pipeline: HEAD → range tail → parse EOCD → range CD → return entries.\"\"\"\n total_size, accept_ranges = http_head(url)\n if total_size is None:\n raise RuntimeError(\"Server did not return Content-Length\")\n if 'bytes' not in accept_ranges.lower():\n raise RuntimeError(\"Server does not support range requests (Accept-Ranges != bytes)\")\n\n # Fetch last 128KB (handles large comments / ZIP64)\n tail_size = min(131072, total_size)\n tail_start = total_size - tail_size\n tail = http_range(url, tail_start, total_size - 1)\n\n cd_offset, cd_size, total_entries = parse_eocd(tail, tail_start)\n\n # If central directory is within our tail, use it; else fetch separately\n cd_start_in_tail = cd_offset - tail_start\n if cd_start_in_tail >= 0 and cd_start_in_tail + cd_size <= len(tail):\n cd_data = tail[cd_start_in_tail : cd_start_in_tail + cd_size]\n else:\n cd_data = http_range(url, cd_offset, cd_offset + cd_size - 1)\n\n entries = parse_central_directory(cd_data)\n return total_size, entries\n\n# ──────────────────────────────────────────────\n# FILE STREAMING\n# ──────────────────────────────────────────────\n\nLOCAL_HEADER_FIXED = 30 # bytes before variable fields\n\ndef stream_entry(url, entry):\n \"\"\"Fetch and decompress a single ZIP entry on-the-fly.\"\"\"\n off = entry['local_header_offset']\n # Read local file header to get actual variable field lengths\n header_data = http_range(url, off, off + LOCAL_HEADER_FIXED - 1)\n if header_data[:4] != b'PK\\x03\\x04':\n raise RuntimeError(\"Bad local file header signature\")\n fname_len = struct.unpack_from('<H', header_data, 26)[0]\n extra_len = struct.unpack_from('<H', header_data, 28)[0]\n data_start = off + LOCAL_HEADER_FIXED + fname_len + extra_len\n data_end = data_start + entry['compressed_size'] - 1\n\n method = entry['compress_method']\n compressed = http_range(url, data_start, data_end)\n\n if method == 0:\n return compressed\n elif method == 8:\n return zlib.decompress(compressed, -15)\n else:\n raise RuntimeError(f\"Unsupported compression method: {method}\")\n\n# ──────────────────────────────────────────────\n# MIME / RENDER DECISION\n# ──────────────────────────────────────────────\n\nRENDER_IN_BROWSER = {\n # text\n '.txt', '.md', '.csv', '.log', '.ini', '.cfg', '.conf', '.yaml', '.yml',\n '.json', '.xml', '.html', '.htm', '.css', '.js', '.ts', '.sh', '.bat',\n '.c', '.cpp', '.h', '.py', '.rb', '.go', '.rs', '.java', '.kt', '.swift',\n '.toml', '.env', '.gitignore', '.dockerfile', '.sql', '.r',\n # media\n '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico',\n '.mp4', '.webm', '.ogg', '.mp3', '.wav', '.flac',\n '.pdf',\n}\n\nFORCE_DOWNLOAD = {\n '.exe', '.msi', '.dll', '.so', '.dylib',\n '.zip', '.gz', '.tar', '.rar', '.7z', '.bz2', '.xz',\n '.bin', '.iso', '.img',\n '.pyc', '.pyo',\n '.deb', '.rpm', '.apk',\n}\n\ndef should_render(filename):\n ext = os.path.splitext(filename)[1].lower()\n if ext in FORCE_DOWNLOAD:\n return False\n if ext in RENDER_IN_BROWSER:\n return True\n # Default: try to render text-ish things\n mime, _ = mimetypes.guess_type(filename)\n if mime and (mime.startswith('text/') or mime.startswith('image/') or\n mime.startswith('video/') or mime.startswith('audio/')):\n return True\n return False\n\ndef get_mime(filename):\n ext = os.path.splitext(filename)[1].lower()\n mime_map = {\n '.py': 'text/plain', '.sh': 'text/plain', '.bat': 'text/plain',\n '.rs': 'text/plain', '.go': 'text/plain', '.toml': 'text/plain',\n '.yaml': 'text/plain', '.yml': 'text/plain', '.env': 'text/plain',\n '.gitignore': 'text/plain', '.dockerfile': 'text/plain',\n '.md': 'text/plain', '.log': 'text/plain', '.sql': 'text/plain',\n }\n if ext in mime_map:\n return mime_map[ext]\n mime, _ = mimetypes.guess_type(filename)\n return mime or 'application/octet-stream'\n\n# ──────────────────────────────────────────────\n# HELPERS\n# ──────────────────────────────────────────────\n\ndef fmt_size(n):\n if n is None: return '—'\n for unit in ['B', 'KB', 'MB', 'GB', 'TB']:\n if n < 1024:\n return f\"{n:.1f} {unit}\" if unit != 'B' else f\"{n} B\"\n n /= 1024\n return f\"{n:.1f} PB\"\n\ndef build_tree(entries):\n \"\"\"Build nested folder structure from flat entry list.\"\"\"\n root = {\"__files__\": [], \"__dirs__\": {}}\n for e in entries:\n if e['is_dir']:\n continue\n parts = e['name'].split('/')\n node = root\n for part in parts[:-1]:\n if part not in node['__dirs__']:\n node['__dirs__'][part] = {\"__files__\": [], \"__dirs__\": {}}\n node = node['__dirs__'][part]\n node['__files__'].append(e)\n return root\n\n# ──────────────────────────────────────────────\n# ROUTES\n# ──────────────────────────────────────────────\n\n@app.route('/')\ndef index():\n recent = cache_list_all()\n return render_template_string(HOME_TEMPLATE, recent=recent, fmt_size=fmt_size)\n\n@app.route('/browse')\ndef browse():\n url = request.args.get('url', '').strip()\n path = request.args.get('path', '')\n if not url:\n return redirect('/')\n\n error = None\n cached = cache_get(url)\n if cached:\n total_size = cached['total_size']\n entries = cached['entries']\n fetched_at = cached['fetched_at']\n from_cache = True\n else:\n try:\n total_size, entries = fetch_zip_directory(url)\n cache_set(url, total_size, entries)\n fetched_at = datetime.utcnow().isoformat()\n from_cache = False\n except Exception as e:\n error = str(e)\n entries = []\n total_size = 0\n fetched_at = None\n from_cache = False\n\n # Filter entries to current path\n if path:\n prefix = path if path.endswith('/') else path + '/'\n visible = [e for e in entries if e['name'].startswith(prefix)]\n # strip prefix for display\n stripped = []\n for e in visible:\n ec = dict(e)\n ec['display_name'] = ec['name'][len(prefix):]\n stripped.append(ec)\n else:\n stripped = []\n for e in entries:\n ec = dict(e)\n ec['display_name'] = ec['name']\n stripped.append(ec)\n\n # Build breadcrumb\n crumbs = []\n if path:\n parts = path.split('/')\n for i, p in enumerate(parts):\n crumbs.append({\"name\": p, \"path\": '/'.join(parts[:i+1])})\n\n # Get immediate children only\n def get_children(entries_list, current_prefix):\n dirs_seen = set()\n files = []\n subdirs = []\n for e in entries_list:\n dn = e['display_name']\n if not dn or dn == '/':\n continue\n parts = dn.split('/')\n if len(parts) == 1 and not e['is_dir']:\n files.append(e)\n elif len(parts) >= 2:\n d = parts[0]\n if d not in dirs_seen:\n dirs_seen.add(d)\n full_path = (current_prefix + '/' + d).lstrip('/')\n subdirs.append({\"name\": d, \"path\": full_path})\n return subdirs, files\n\n subdirs, files = get_children(stripped, path)\n\n zip_name = url.split('/')[-1].split('?')[0] or 'archive.zip'\n\n return render_template_string(BROWSE_TEMPLATE,\n url=url, path=path, zip_name=zip_name,\n subdirs=subdirs, files=files, crumbs=crumbs,\n total_size=total_size, entry_count=len(entries),\n fetched_at=fetched_at, from_cache=from_cache,\n error=error, fmt_size=fmt_size\n )\n\n@app.route('/download')\ndef download():\n url = request.args.get('url', '').strip()\n entry_name = request.args.get('entry', '').strip()\n if not url or not entry_name:\n return \"Missing url or entry parameter\", 400\n\n cached = cache_get(url)\n if cached:\n entries = cached['entries']\n else:\n try:\n total_size, entries = fetch_zip_directory(url)\n cache_set(url, total_size, entries)\n except Exception as e:\n return f\"Error fetching ZIP directory: {e}\", 500\n\n entry = next((e for e in entries if e['name'] == entry_name), None)\n if not entry:\n return f\"Entry not found: {entry_name}\", 404\n if entry['is_dir']:\n return \"Cannot download a directory\", 400\n\n try:\n data = stream_entry(url, entry)\n except Exception as e:\n return f\"Error streaming entry: {e}\", 500\n\n filename = os.path.basename(entry_name)\n mime = get_mime(filename)\n render = should_render(filename)\n\n disposition = 'inline' if render else 'attachment'\n\n # For viewer page (text/code/image/video)\n view_param = request.args.get('view', '0')\n if view_param == '1' and render:\n return render_template_string(VIEWER_TEMPLATE,\n url=url, entry=entry_name, filename=filename,\n mime=mime, data=data, fmt_size=fmt_size\n )\n\n resp = Response(data, mimetype=mime)\n resp.headers['Content-Disposition'] = f'{disposition}; filename=\"{filename}\"'\n resp.headers['Content-Length'] = str(len(data))\n return resp\n\n@app.route('/api/cache')\ndef api_cache():\n return jsonify(cache_list_all())\n\n@app.route('/api/invalidate', methods=['POST'])\ndef api_invalidate():\n url = request.json.get('url', '')\n if not url:\n return jsonify({\"error\": \"no url\"}), 400\n h = url_hash(url)\n with db_lock:\n with sqlite3.connect(DB_PATH) as con:\n con.execute(\"DELETE FROM zip_cache WHERE url_hash=?\", (h,))\n con.commit()\n return jsonify({\"ok\": True})\n\n# ──────────────────────────────────────────────\n# TEMPLATES\n# ──────────────────────────────────────────────\n\nBASE_STYLE = \"\"\"\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link href=\"https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;700&family=Space+Grotesk:wght@300;400;500;600&display=swap\" rel=\"stylesheet\">\n<style>\n :root {\n --bg: #0a0b0e;\n --bg2: #111318;\n --bg3: #181b22;\n --border: #1e2330;\n --border2: #2a3045;\n --text: #c8d0e0;\n --text2: #7a8499;\n --text3: #4a5268;\n --accent: #4f8ef7;\n --accent2: #2d6ae0;\n --green: #3ecf8e;\n --yellow: #f5c842;\n --red: #f05a5a;\n --purple: #a78bfa;\n --mono: 'JetBrains Mono', monospace;\n --sans: 'Space Grotesk', sans-serif;\n }\n *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n html { font-size: 14px; }\n body { background: var(--bg); color: var(--text); font-family: var(--sans); min-height: 100vh; }\n a { color: var(--accent); text-decoration: none; }\n a:hover { color: #7db0ff; }\n\n .topbar {\n background: var(--bg2);\n border-bottom: 1px solid var(--border);\n padding: 12px 24px;\n display: flex;\n align-items: center;\n gap: 16px;\n position: sticky;\n top: 0;\n z-index: 100;\n }\n .logo {\n font-family: var(--mono);\n font-weight: 700;\n font-size: 1.1rem;\n color: var(--accent);\n letter-spacing: -0.5px;\n display: flex;\n align-items: center;\n gap: 6px;\n }\n .logo span { color: var(--text2); font-weight: 300; }\n .logo-icon { font-size: 1.3rem; }\n\n .url-form {\n flex: 1;\n display: flex;\n gap: 8px;\n max-width: 700px;\n }\n .url-input {\n flex: 1;\n background: var(--bg3);\n border: 1px solid var(--border2);\n color: var(--text);\n font-family: var(--mono);\n font-size: 0.8rem;\n padding: 8px 12px;\n border-radius: 6px;\n outline: none;\n transition: border-color 0.15s;\n }\n .url-input:focus { border-color: var(--accent); }\n .url-input::placeholder { color: var(--text3); }\n .btn {\n background: var(--accent);\n color: #fff;\n border: none;\n padding: 8px 16px;\n border-radius: 6px;\n font-family: var(--sans);\n font-size: 0.85rem;\n font-weight: 500;\n cursor: pointer;\n transition: background 0.15s;\n white-space: nowrap;\n }\n .btn:hover { background: var(--accent2); }\n .btn-ghost {\n background: transparent;\n border: 1px solid var(--border2);\n color: var(--text2);\n }\n .btn-ghost:hover { border-color: var(--accent); color: var(--accent); background: transparent; }\n .btn-sm { padding: 5px 10px; font-size: 0.78rem; }\n\n .container { max-width: 1100px; margin: 0 auto; padding: 24px; }\n .page-title {\n font-family: var(--mono);\n font-size: 1.8rem;\n font-weight: 700;\n color: var(--text);\n margin-bottom: 6px;\n }\n .page-sub { color: var(--text2); font-size: 0.9rem; margin-bottom: 32px; }\n\n .card {\n background: var(--bg2);\n border: 1px solid var(--border);\n border-radius: 10px;\n overflow: hidden;\n }\n .card-header {\n padding: 14px 20px;\n border-bottom: 1px solid var(--border);\n display: flex;\n align-items: center;\n justify-content: space-between;\n background: var(--bg3);\n }\n .card-title {\n font-family: var(--mono);\n font-size: 0.85rem;\n font-weight: 500;\n color: var(--text2);\n letter-spacing: 0.5px;\n text-transform: uppercase;\n }\n\n .file-table { width: 100%; border-collapse: collapse; }\n .file-table th {\n text-align: left;\n padding: 10px 16px;\n font-family: var(--mono);\n font-size: 0.72rem;\n text-transform: uppercase;\n letter-spacing: 0.8px;\n color: var(--text3);\n border-bottom: 1px solid var(--border);\n background: var(--bg3);\n font-weight: 400;\n }\n .file-table td {\n padding: 9px 16px;\n border-bottom: 1px solid var(--border);\n font-size: 0.88rem;\n }\n .file-table tr:last-child td { border-bottom: none; }\n .file-table tr:hover td { background: rgba(79,142,247,0.04); }\n\n .fname {\n display: flex;\n align-items: center;\n gap: 8px;\n font-family: var(--mono);\n font-size: 0.83rem;\n }\n .icon { font-size: 1rem; width: 18px; text-align: center; flex-shrink: 0; }\n .size-col { color: var(--text2); font-family: var(--mono); font-size: 0.8rem; white-space: nowrap; }\n .date-col { color: var(--text3); font-family: var(--mono); font-size: 0.78rem; white-space: nowrap; }\n .method-badge {\n font-family: var(--mono);\n font-size: 0.68rem;\n padding: 2px 6px;\n border-radius: 3px;\n background: var(--bg3);\n border: 1px solid var(--border2);\n color: var(--text3);\n }\n\n .breadcrumb {\n display: flex;\n align-items: center;\n gap: 6px;\n padding: 12px 20px;\n border-bottom: 1px solid var(--border);\n font-family: var(--mono);\n font-size: 0.82rem;\n flex-wrap: wrap;\n background: var(--bg2);\n }\n .breadcrumb a { color: var(--accent); }\n .breadcrumb .sep { color: var(--text3); }\n .breadcrumb .current { color: var(--text); }\n\n .meta-bar {\n display: flex;\n gap: 24px;\n padding: 12px 20px;\n border-bottom: 1px solid var(--border);\n background: var(--bg2);\n flex-wrap: wrap;\n }\n .meta-item { display: flex; flex-direction: column; gap: 2px; }\n .meta-label { font-size: 0.7rem; color: var(--text3); text-transform: uppercase; letter-spacing: 0.6px; font-family: var(--mono); }\n .meta-value { font-size: 0.9rem; font-family: var(--mono); font-weight: 500; color: var(--text); }\n\n .hero-form {\n background: var(--bg2);\n border: 1px solid var(--border);\n border-radius: 12px;\n padding: 32px;\n margin-bottom: 32px;\n text-align: center;\n }\n .hero-form h2 { font-family: var(--mono); font-size: 1.1rem; color: var(--text2); margin-bottom: 16px; }\n .hero-input-row { display: flex; gap: 10px; max-width: 600px; margin: 0 auto; }\n .hero-input {\n flex: 1;\n background: var(--bg3);\n border: 1px solid var(--border2);\n color: var(--text);\n font-family: var(--mono);\n font-size: 0.85rem;\n padding: 12px 16px;\n border-radius: 8px;\n outline: none;\n transition: border-color 0.15s;\n }\n .hero-input:focus { border-color: var(--accent); }\n .hero-input::placeholder { color: var(--text3); }\n\n .recent-grid {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));\n gap: 12px;\n }\n .recent-card {\n background: var(--bg2);\n border: 1px solid var(--border);\n border-radius: 8px;\n padding: 14px 16px;\n cursor: pointer;\n transition: border-color 0.15s, background 0.15s;\n text-decoration: none;\n display: block;\n }\n .recent-card:hover { border-color: var(--accent); background: rgba(79,142,247,0.04); }\n .recent-name {\n font-family: var(--mono);\n font-size: 0.85rem;\n color: var(--accent);\n margin-bottom: 6px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n .recent-meta { display: flex; gap: 12px; }\n .recent-tag {\n font-size: 0.72rem;\n font-family: var(--mono);\n color: var(--text3);\n background: var(--bg3);\n padding: 2px 7px;\n border-radius: 3px;\n }\n\n .error-box {\n background: rgba(240,90,90,0.08);\n border: 1px solid rgba(240,90,90,0.3);\n border-radius: 8px;\n padding: 16px 20px;\n color: var(--red);\n font-family: var(--mono);\n font-size: 0.85rem;\n margin: 16px 20px;\n }\n\n .tag-cached {\n font-size: 0.7rem;\n font-family: var(--mono);\n color: var(--green);\n background: rgba(62,207,142,0.1);\n border: 1px solid rgba(62,207,142,0.25);\n padding: 2px 7px;\n border-radius: 3px;\n }\n .tag-live {\n font-size: 0.7rem;\n font-family: var(--mono);\n color: var(--yellow);\n background: rgba(245,200,66,0.1);\n border: 1px solid rgba(245,200,66,0.25);\n padding: 2px 7px;\n border-radius: 3px;\n }\n .tag-dl {\n font-size: 0.7rem;\n font-family: var(--mono);\n color: var(--text3);\n background: var(--bg3);\n border: 1px solid var(--border2);\n padding: 2px 7px;\n border-radius: 3px;\n }\n\n .spinner {\n display: inline-block;\n width: 14px; height: 14px;\n border: 2px solid var(--border2);\n border-top-color: var(--accent);\n border-radius: 50%;\n animation: spin 0.7s linear infinite;\n vertical-align: middle;\n }\n @keyframes spin { to { transform: rotate(360deg); } }\n\n .loading-overlay {\n display: none;\n position: fixed; inset: 0;\n background: rgba(10,11,14,0.7);\n backdrop-filter: blur(4px);\n z-index: 999;\n align-items: center;\n justify-content: center;\n flex-direction: column;\n gap: 12px;\n }\n .loading-overlay.active { display: flex; }\n .loading-text { font-family: var(--mono); color: var(--text2); font-size: 0.9rem; }\n\n .empty-dir {\n padding: 32px;\n text-align: center;\n color: var(--text3);\n font-family: var(--mono);\n font-size: 0.85rem;\n }\n</style>\n\"\"\"\n\nHOME_TEMPLATE = \"\"\"<!DOCTYPE html>\n<html>\n<head><title>zipstream</title>\"\"\" + BASE_STYLE + \"\"\"</head>\n<body>\n<div class=\"topbar\">\n <a href=\"/\" class=\"logo\"><span class=\"logo-icon\">📦</span>zip<span>stream</span></a>\n <form class=\"url-form\" action=\"/browse\" method=\"get\" onsubmit=\"showLoad()\">\n <input class=\"url-input\" name=\"url\" placeholder=\"https://example.com/archive.zip\" autocomplete=\"off\" autofocus>\n <button class=\"btn\" type=\"submit\">Browse</button>\n </form>\n</div>\n\n<div class=\"loading-overlay\" id=\"loadOverlay\">\n <div class=\"spinner\" style=\"width:28px;height:28px;border-width:3px;\"></div>\n <div class=\"loading-text\">Fetching ZIP directory…</div>\n</div>\n\n<div class=\"container\">\n <div class=\"page-title\">zipstream</div>\n <div class=\"page-sub\">Browse remote ZIP files without downloading them. Range requests only.</div>\n\n <div class=\"hero-form\">\n <h2>// paste a zip url</h2>\n <form action=\"/browse\" method=\"get\" onsubmit=\"showLoad()\">\n <div class=\"hero-input-row\">\n <input class=\"hero-input\" name=\"url\" placeholder=\"https://example.com/archive.zip\" autocomplete=\"off\">\n <button class=\"btn\" type=\"submit\" style=\"padding:12px 24px;\">→ Browse</button>\n </div>\n </form>\n </div>\n\n {% if recent %}\n <div class=\"card\">\n <div class=\"card-header\">\n <span class=\"card-title\">Recent Archives</span>\n <span style=\"color:var(--text3);font-size:0.78rem;font-family:var(--mono);\">{{ recent|length }} cached</span>\n </div>\n <div style=\"padding:16px;\">\n <div class=\"recent-grid\">\n {% for r in recent %}\n <a class=\"recent-card\" href=\"/browse?url={{ r.url | urlencode }}\">\n <div class=\"recent-name\">{{ r.url.split('/')[-1].split('?')[0] or r.url }}</div>\n <div style=\"font-size:0.72rem;color:var(--text3);font-family:var(--mono);margin-bottom:8px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;\">{{ r.url }}</div>\n <div class=\"recent-meta\">\n <span class=\"recent-tag\">{{ fmt_size(r.total_size) }}</span>\n <span class=\"recent-tag\">{{ r.entry_count }} files</span>\n <span class=\"recent-tag\">{{ r.fetched_at[:10] if r.fetched_at else '—' }}</span>\n </div>\n </a>\n {% endfor %}\n </div>\n </div>\n </div>\n {% endif %}\n</div>\n<script>\nfunction showLoad() {\n document.getElementById('loadOverlay').classList.add('active');\n}\n</script>\n</body></html>\n\"\"\"\n\nBROWSE_TEMPLATE = \"\"\"<!DOCTYPE html>\n<html>\n<head><title>{{ zip_name }} — zipstream</title>\"\"\" + BASE_STYLE + \"\"\"</head>\n<body>\n<div class=\"topbar\">\n <a href=\"/\" class=\"logo\"><span class=\"logo-icon\">📦</span>zip<span>stream</span></a>\n <form class=\"url-form\" action=\"/browse\" method=\"get\" onsubmit=\"showLoad()\">\n <input class=\"url-input\" name=\"url\" value=\"{{ url }}\" autocomplete=\"off\">\n <button class=\"btn\" type=\"submit\">Browse</button>\n <button type=\"button\" class=\"btn btn-ghost btn-sm\" onclick=\"invalidate('{{ url }}')\">↺ Refresh</button>\n </form>\n</div>\n\n<div class=\"loading-overlay\" id=\"loadOverlay\">\n <div class=\"spinner\" style=\"width:28px;height:28px;border-width:3px;\"></div>\n <div class=\"loading-text\">Fetching…</div>\n</div>\n\n<div class=\"card\" style=\"margin:16px 24px; border-radius:10px; overflow:hidden;\">\n {% if error %}\n <div class=\"error-box\">⚠ {{ error }}</div>\n {% else %}\n\n <div class=\"meta-bar\">\n <div class=\"meta-item\">\n <span class=\"meta-label\">Archive</span>\n <span class=\"meta-value\" style=\"font-size:0.85rem;color:var(--accent);\">{{ zip_name }}</span>\n </div>\n <div class=\"meta-item\">\n <span class=\"meta-label\">Total Size</span>\n <span class=\"meta-value\">{{ fmt_size(total_size) }}</span>\n </div>\n <div class=\"meta-item\">\n <span class=\"meta-label\">Files</span>\n <span class=\"meta-value\">{{ entry_count }}</span>\n </div>\n <div class=\"meta-item\">\n <span class=\"meta-label\">Status</span>\n <span class=\"meta-value\">\n {% if from_cache %}<span class=\"tag-cached\">● cached</span>\n {% else %}<span class=\"tag-live\">● live</span>{% endif %}\n </span>\n </div>\n {% if fetched_at %}\n <div class=\"meta-item\">\n <span class=\"meta-label\">Fetched</span>\n <span class=\"meta-value\" style=\"font-size:0.8rem;color:var(--text2);\">{{ fetched_at[:16].replace('T',' ') }}</span>\n </div>\n {% endif %}\n </div>\n\n <div class=\"breadcrumb\">\n <a href=\"/browse?url={{ url | urlencode }}\">🏠 root</a>\n {% for crumb in crumbs %}\n <span class=\"sep\">/</span>\n <a href=\"/browse?url={{ url | urlencode }}&path={{ crumb.path | urlencode }}\">{{ crumb.name }}</a>\n {% endfor %}\n </div>\n\n <table class=\"file-table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Size</th>\n <th>Compressed</th>\n <th>Modified</th>\n <th>Method</th>\n <th></th>\n </tr>\n </thead>\n <tbody>\n {% if crumbs %}\n <tr>\n <td colspan=\"6\">\n <div class=\"fname\">\n <span class=\"icon\">↑</span>\n {% set parent_path = crumbs[:-1]|map(attribute='path')|list|join('/') if crumbs|length > 1 else '' %}\n <a href=\"/browse?url={{ url | urlencode }}{% if parent_path %}&path={{ parent_path | urlencode }}{% endif %}\">..</a>\n </div>\n </td>\n </tr>\n {% endif %}\n\n {% for d in subdirs %}\n <tr>\n <td>\n <div class=\"fname\">\n <span class=\"icon\">📁</span>\n <a href=\"/browse?url={{ url | urlencode }}&path={{ d.path | urlencode }}\">{{ d.name }}/</a>\n </div>\n </td>\n <td class=\"size-col\">—</td>\n <td class=\"size-col\">—</td>\n <td class=\"date-col\">—</td>\n <td></td>\n <td></td>\n </tr>\n {% endfor %}\n\n {% for f in files %}\n {% set fname = f.display_name.split('/')[-1] %}\n {% set ext = fname.rsplit('.',1)[-1].lower() if '.' in fname else '' %}\n {% set is_renderable = ext in ['txt','md','json','xml','csv','log','ini','cfg','conf','yaml','yml','toml','env','sh','py','js','ts','html','htm','css','c','cpp','h','rs','go','java','rb','kt','swift','sql','r','bat','gitignore','dockerfile','png','jpg','jpeg','gif','webp','svg','bmp','ico','mp4','webm','ogg','mp3','wav','flac','pdf'] %}\n <tr>\n <td>\n <div class=\"fname\">\n <span class=\"icon\">\n {% if ext in ['png','jpg','jpeg','gif','webp','svg','bmp','ico'] %}🖼️\n {% elif ext in ['mp4','webm','ogg','avi','mov'] %}🎬\n {% elif ext in ['mp3','wav','flac','aac'] %}🎵\n {% elif ext in ['py','js','ts','rs','go','c','cpp','h','java','rb','kt','swift','sh','bat'] %}💻\n {% elif ext in ['json','xml','yaml','yml','toml','csv'] %}📋\n {% elif ext in ['txt','md','log'] %}📄\n {% elif ext in ['pdf'] %}📕\n {% elif ext in ['zip','gz','tar','rar','7z'] %}📦\n {% elif ext in ['exe','msi','deb','apk'] %}⚙️\n {% else %}📄{% endif %}\n </span>\n {% if is_renderable %}\n <a href=\"/download?url={{ url | urlencode }}&entry={{ f.name | urlencode }}&view=1\">{{ fname }}</a>\n {% else %}\n <a href=\"/download?url={{ url | urlencode }}&entry={{ f.name | urlencode }}\">{{ fname }}</a>\n {% endif %}\n </div>\n </td>\n <td class=\"size-col\">{{ fmt_size(f.uncompressed_size) }}</td>\n <td class=\"size-col\">{{ fmt_size(f.compressed_size) }}</td>\n <td class=\"date-col\">{{ f.modified }}</td>\n <td><span class=\"method-badge\">{{ 'deflate' if f.compress_method == 8 else ('store' if f.compress_method == 0 else f.compress_method) }}</span></td>\n <td>\n <a href=\"/download?url={{ url | urlencode }}&entry={{ f.name | urlencode }}\" class=\"btn btn-ghost btn-sm\">↓</a>\n </td>\n </tr>\n {% endfor %}\n\n {% if not subdirs and not files %}\n <tr><td colspan=\"6\"><div class=\"empty-dir\">Empty directory</div></td></tr>\n {% endif %}\n </tbody>\n </table>\n {% endif %}\n</div>\n\n<script>\nfunction showLoad() {\n document.getElementById('loadOverlay').classList.add('active');\n}\nfunction invalidate(url) {\n if (!confirm('Re-fetch directory from server?')) return;\n fetch('/api/invalidate', {\n method: 'POST',\n headers: {'Content-Type':'application/json'},\n body: JSON.stringify({url})\n }).then(() => {\n showLoad();\n window.location.reload();\n });\n}\n</script>\n</body></html>\n\"\"\"\n\nVIEWER_TEMPLATE = \"\"\"<!DOCTYPE html>\n<html>\n<head><title>{{ filename }} — zipstream</title>\"\"\" + BASE_STYLE + \"\"\"\n<style>\n.viewer-bar {\n background: var(--bg2);\n border-bottom: 1px solid var(--border);\n padding: 10px 20px;\n display: flex;\n align-items: center;\n gap: 12px;\n position: sticky; top: 0; z-index: 100;\n}\n.viewer-path {\n font-family: var(--mono);\n font-size: 0.82rem;\n color: var(--text2);\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n flex: 1;\n}\n.viewer-path .sep { color: var(--text3); margin: 0 4px; }\npre {\n margin: 24px;\n background: var(--bg2);\n border: 1px solid var(--border);\n border-radius: 8px;\n padding: 20px;\n font-family: var(--mono);\n font-size: 0.82rem;\n line-height: 1.6;\n overflow-x: auto;\n white-space: pre-wrap;\n word-break: break-word;\n color: var(--text);\n}\n.media-wrap {\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 32px;\n min-height: 60vh;\n}\nimg, video, audio { max-width: 100%; border-radius: 8px; }\nvideo { max-height: 80vh; }\niframe.pdf-view {\n width: calc(100% - 48px);\n height: calc(100vh - 120px);\n margin: 24px;\n border: 1px solid var(--border);\n border-radius: 8px;\n display: block;\n background: white;\n}\n.size-note { color: var(--text3); font-family: var(--mono); font-size: 0.78rem; }\n</style>\n</head>\n<body>\n<div class=\"viewer-bar\">\n <a href=\"/\" class=\"logo\" style=\"flex-shrink:0;\"><span>📦</span>zip<span style=\"color:var(--text2);font-weight:300;\">stream</span></a>\n <div class=\"viewer-path\">\n {% set parts = entry.split('/') %}\n {% for i, p in parts|enumerate %}\n {% if loop.last %}\n <span style=\"color:var(--text);\">{{ p }}</span>\n {% else %}\n {% set partial = parts[:loop.index]|join('/') %}\n <a href=\"/browse?url={{ url | urlencode }}&path={{ partial | urlencode }}\">{{ p }}</a><span class=\"sep\">/</span>\n {% endif %}\n {% endfor %}\n </div>\n <span class=\"size-note\">{{ fmt_size(data|length) }}</span>\n <a href=\"/browse?url={{ url | urlencode }}&path={{ entry.rsplit('/',1)[0] | urlencode }}\" class=\"btn btn-ghost btn-sm\">← back</a>\n <a href=\"/download?url={{ url | urlencode }}&entry={{ entry | urlencode }}\" class=\"btn btn-sm\">↓ Download</a>\n</div>\n\n{% set ext = filename.rsplit('.',1)[-1].lower() if '.' in filename else '' %}\n\n{% if ext in ['png','jpg','jpeg','gif','webp','svg','bmp','ico'] %}\n <div class=\"media-wrap\">\n <img src=\"data:{{ mime }};base64,{{ data | b64encode }}\" alt=\"{{ filename }}\">\n </div>\n{% elif ext in ['mp4','webm','ogg'] %}\n <div class=\"media-wrap\">\n <video controls autoplay>\n <source src=\"data:{{ mime }};base64,{{ data | b64encode }}\" type=\"{{ mime }}\">\n </video>\n </div>\n{% elif ext in ['mp3','wav','flac','aac'] %}\n <div class=\"media-wrap\" style=\"flex-direction:column;gap:12px;\">\n <div style=\"font-family:var(--mono);color:var(--text2);\">{{ filename }}</div>\n <audio controls autoplay>\n <source src=\"data:{{ mime }};base64,{{ data | b64encode }}\" type=\"{{ mime }}\">\n </audio>\n </div>\n{% elif ext == 'pdf' %}\n <iframe class=\"pdf-view\" src=\"data:application/pdf;base64,{{ data | b64encode }}\"></iframe>\n{% else %}\n <pre>{{ data | decode_safe }}</pre>\n{% endif %}\n\n</body></html>\n\"\"\"\n\n# ──────────────────────────────────────────────\n# JINJA2 FILTERS / GLOBALS\n# ──────────────────────────────────────────────\n\nimport base64\n\n@app.template_filter('b64encode')\ndef b64encode_filter(data):\n return base64.b64encode(data).decode('ascii')\n\n@app.template_filter('decode_safe')\ndef decode_safe_filter(data):\n try:\n return data.decode('utf-8')\n except Exception:\n return data.decode('latin-1', errors='replace')\n\n@app.template_filter('urlencode')\ndef urlencode_filter(s):\n import urllib.parse\n return urllib.parse.quote(str(s), safe='')\n\n@app.template_global('enumerate')\ndef jinja_enumerate(it):\n return enumerate(it)\n\n# ──────────────────────────────────────────────\n# MAIN\n# ──────────────────────────────────────────────\n\nif __name__ == '__main__':\n db_init()\n print(\"📦 zipstream running → http://localhost:5000\")\n print(f\" Cache: {DB_PATH}\")\n app.run(host='0.0.0.0', port=5000, debug=False, threaded=True)\n"}}}}