This commit is contained in:
2026-06-12 17:19:26 +08:00
parent a3ebb28ce0
commit 8d2e18c5a4
20 changed files with 3011 additions and 411 deletions
+770 -134
View File
File diff suppressed because one or more lines are too long
-276
View File
@@ -1,276 +0,0 @@
import json
import uuid
from pathlib import Path
cells = []
def _src(text):
lines = text.split("\n")
out = [line + "\n" if i < len(lines) - 1 else line for i, line in enumerate(lines)]
if out and out[-1] == "":
out.pop()
return out
def md(text):
cells.append({"cell_type": "markdown", "id": uuid.uuid4().hex[:8],
"metadata": {}, "source": _src(text)})
def code(text):
cells.append({"cell_type": "code", "execution_count": None, "id": uuid.uuid4().hex[:8],
"metadata": {}, "outputs": [], "source": _src(text)})
# ----------------------------------------------------------------------------
md("""# The GPT tokenizer — Byte Pair Encoding from scratch
Turn raw text into tokens the model can read — **concept -> code -> Your turn** each step.
This is notebook **07**, the final piece of the pipeline. Every notebook so far fed the model
either single characters (Shakespeare) or single letters (names). Real GPTs use **subword**
tokens built by **Byte Pair Encoding (BPE)**. Here we build a BPE tokenizer from scratch, then
see why tokenization quietly causes many famous LLM quirks. Follows Karpathy's
*"Let's build the GPT Tokenizer"*.""")
md("""## Prologue — in plain English
A neural net only eats numbers, so text must be chopped into pieces (**tokens**) and each piece
mapped to an id. Two extremes:
- **One token per character** (what we did): tiny vocabulary, but sequences are very long and
the model must relearn common spellings everywhere.
- **One token per word**: short sequences, but the vocabulary explodes and unseen words break it.
**BPE** is the practical middle: start from raw bytes, then repeatedly **merge the most common
adjacent pair** into a new token. Frequent chunks like `the`, `ing`, or ` and` become single
tokens; rare text still survives as smaller pieces.
Real-life picture: inventing shorthand. You start writing letter by letter, notice you keep
writing "t-h-e", so you invent one squiggle for "the." Then you notice " a-n-d" and give it a
squiggle too. BPE does this automatically, keeping the most useful shorthands.""")
# ----------------------------------------------------------------------------
md("""### 1.1 Text -> UTF-8 bytes (the 256 starting tokens)
Computers already store text as **bytes** (numbers 0-255) via UTF-8. So our alphabet starts
with exactly 256 tokens — every possible byte. Plain English letters are one byte each;
accented or non-Latin characters take several bytes (which is why they cost more tokens later).""")
code("""sample = "Hello world! tokenization is fun."
the_bytes = sample.encode("utf-8")
print("text :", sample)
print("bytes :", list(the_bytes))
print("length :", len(the_bytes), "bytes ->", len(the_bytes), "starting tokens")
multi = "café na\u00efve" # accented letters take >1 byte each
print("\\nnon-ASCII example:", multi)
print("bytes:", list(multi.encode("utf-8")), "(note: more bytes than characters)")""")
# ----------------------------------------------------------------------------
md("""### 2.1 Two tiny helpers: count pairs, and merge a pair
- `get_stats` counts how often each adjacent pair appears.
- `merge` replaces every occurrence of a chosen pair with a single new token id.
That is the entire mechanism of BPE.""")
code("""def get_stats(ids):
counts = {}
for pair in zip(ids, ids[1:]):
counts[pair] = counts.get(pair, 0) + 1
return counts
def merge(ids, pair, idx):
new_ids = []
i = 0
while i < len(ids):
if i < len(ids) - 1 and ids[i] == pair[0] and ids[i + 1] == pair[1]:
new_ids.append(idx)
i += 2
else:
new_ids.append(ids[i])
i += 1
return new_ids
# demo on a toy list: merge the most common pair
demo = [1, 2, 3, 1, 2, 3, 1, 2]
st = get_stats(demo)
top = max(st, key=st.get)
print("counts:", st)
print("most common pair:", top, "->", st[top], "times")
print("after merging", top, "into 99:", merge(demo, top, 99))""")
md("""### 2.2 Train BPE — learn the merges
We train on a few KB of real text (Shakespeare). Starting from raw bytes, we repeatedly find
the most common pair and merge it, recording each merge. After `num_merges` rounds we have a
vocabulary of `256 + num_merges` tokens.""")
code("""train_text = open("input.txt", "r", encoding="utf-8").read()[:20000]
ids = list(train_text.encode("utf-8"))
print("training bytes:", len(ids))
num_merges = 60
merges = {} # (a, b) -> new_id
work = list(ids)
for i in range(num_merges):
stats = get_stats(work)
pair = max(stats, key=stats.get)
idx = 256 + i
work = merge(work, pair, idx)
merges[pair] = idx
print("merges learned:", len(merges))
print("tokens after training:", len(work), "(was", len(ids), "bytes)")
print(f"compression: {len(ids) / len(work):.2f}x")""")
# ----------------------------------------------------------------------------
md("""### 3.1 encode and decode
- **decode**: turn token ids back into text. We build each token's byte string by stitching the
merges back together.
- **encode**: turn new text into token ids by applying the learned merges, always doing the
*earliest-learned* applicable merge first.""")
code("""# build the id -> bytes table
vocab = {idx: bytes([idx]) for idx in range(256)}
for (p0, p1), idx in merges.items():
vocab[idx] = vocab[p0] + vocab[p1]
def decode(ids):
data = b"".join(vocab[idx] for idx in ids)
return data.decode("utf-8", errors="replace")
def encode(text):
tokens = list(text.encode("utf-8"))
while len(tokens) >= 2:
stats = get_stats(tokens)
# pick the pair whose merge was learned earliest
pair = min(stats, key=lambda p: merges.get(p, float("inf")))
if pair not in merges:
break
tokens = merge(tokens, pair, merges[pair])
return tokens
trial = "the king and the queen"
enc = encode(trial)
print("text :", trial)
print("tokens:", enc)
print("count :", len(enc), "tokens for", len(trial), "characters")
print("decode round-trip ok:", decode(enc) == trial)""")
md("""### 3.2 See what the learned tokens are
The merges discovered the common chunks of English on their own. Let's print a few learned
tokens (ids >= 256) as the text they stand for.""")
code("""print("a few learned tokens:")
for idx in range(256, 256 + min(20, num_merges)):
piece = vocab[idx].decode("utf-8", errors="replace")
print(f" id {idx}: {piece!r}")""")
# ----------------------------------------------------------------------------
md("""### 4.1 Regex pre-splitting (what real GPTs add)
A subtlety: plain BPE might merge across spaces and punctuation (e.g. glue `dog.` or `the the`
into weird tokens). GPT-2 first **splits** the text into sensible chunks with a regex (words,
runs of spaces, punctuation), then runs BPE **inside** each chunk only.
Real GPT uses the `regex` library with Unicode classes; here is a simplified version with the
standard `re` module to show the idea.""")
code("""import re
# simplified GPT-2-style splitter (real one uses the `regex` lib with \\p{L}, \\p{N})
pat = re.compile(r"'s|'t|'re|'ve|'m|'ll|'d| ?\\w+| ?[^\\s\\w]+|\\s+")
demo_text = "Hello, world! It's 2026 already."
chunks = re.findall(pat, demo_text)
print("split into chunks (BPE then runs inside each one):")
print(chunks)""")
md("""### 4.2 Special tokens
Real tokenizers also reserve **special tokens** that are not learned from text but inserted to
mark structure, for example `<|endoftext|>` between documents, or chat markers like
`<|im_start|>` / `<|im_end|>`. They get their own ids above the learned vocabulary.
Real-life picture: punctuation the model never "spells" — single reserved symbols that mean
"new document starts here" or "the user is speaking now."
```
<|endoftext|> -> id 50256 in GPT-2 (separates documents)
<|im_start|> -> chat role marker in chat models
```""")
# ----------------------------------------------------------------------------
md("""### 5.1 Why tokenization causes LLM quirks
Many puzzling LLM behaviours trace back to how text is tokenized. We measure a few.
- **Spelling / reversing words is hard**: a word is often a *single* token, so the model does
not "see" its letters.
- **Arithmetic is fragile**: numbers split into arbitrary chunks, not clean digits.
- **Non-English costs more**: it falls back to many byte-tokens, so the same meaning uses more
tokens (and more money / context).""")
code("""def n_tokens(s):
return len(encode(s))
print("spelling: 'extraordinary' ->", n_tokens("extraordinary"), "token(s)")
print(" the model sees a chunk, not the 13 letters\\n")
print("numbers : '1234567' ->", n_tokens("1234567"), "tokens (split oddly, not per-digit)")
print(" '127 + 677' ->", n_tokens("127 + 677"), "tokens\\n")
eng = "hello how are you"
non = "\u4f60\u597d\u4f60\u597d\u4f60\u597d" # non-English of similar visible length
print("non-English costs more tokens for similar content:")
print(" english :", repr(eng), "->", n_tokens(eng), "tokens")
print(" non-latin :", repr(non), "->", n_tokens(non), "tokens")""")
md("""**Your turn 7** — Raise `num_merges` (e.g. to 300) in section 2.2, retrain, and re-check the
compression ratio and the token counts in section 5.1. More merges = better compression, but a
bigger vocabulary. This trade-off is exactly what real tokenizer designers tune.""")
# ----------------------------------------------------------------------------
md("""## Series wrap-up
You built the whole stack, from the ground up:
- `00_micrograd.ipynb` — backprop from scratch (the engine)
- `01_build_gpt.ipynb` — the bigram baseline
- `02_makemore_mlp.ipynb` — an MLP with embeddings
- `03_batchnorm_activations.ipynb` — keeping deep nets healthy
- `04_backprop_ninja.ipynb` — gradients by hand
- `05_wavenet.ipynb` — a deeper, hierarchical model
- `06_build_gpt_attention.ipynb` — the transformer (GPT)
- `07_gpt_tokenizer.ipynb` — turning text into tokens (this notebook)
Together these cover Andrej Karpathy's *Neural Networks: Zero to Hero* course, on real data,
with runnable code at every step.
**Checklist**
- [ ] Text -> UTF-8 bytes (256 base tokens)
- [ ] `get_stats` + `merge` = the BPE mechanism
- [ ] Trained merges; measured compression
- [ ] encode / decode round-trips
- [ ] Regex pre-splitting and special tokens
- [ ] Explained tokenization-caused LLM quirks""")
# ----------------------------------------------------------------------------
nb = {
"cells": cells,
"metadata": {
"kernelspec": {"display_name": ".venv (3.12.10)", "language": "python", "name": "python3"},
"language_info": {"name": "python", "version": "3.12.10"},
},
"nbformat": 4,
"nbformat_minor": 5,
}
out_path = Path(r"d:\BeastProjects\gpt_from_scratch\07_gpt_tokenizer.ipynb")
out_path.write_text(json.dumps(nb, ensure_ascii=False, indent=2), encoding="utf-8")
print("wrote", out_path, "with", len(cells), "cells")
+475
View File
@@ -0,0 +1,475 @@
# Kylin Remote Control & Monitoring Setup
Complete reference for controlling and monitoring a Kylin Linux machine from a
Windows 11 PC over a local phone-hotspot network. No VPN is used for the SSH
connection itself — only the local Wi-Fi/hotspot.
---
## 0. Bootstrap — START HERE in a new session
**The connection is already fully set up** (SSH key login + passwordless sudo).
A new session does **NOT** need to repeat the setup in §2–§3 — those sections are
records of what was already done. Just connect and go.
**Verify everything with one command** (run from the Windows machine, e.g. Claude Code's Bash tool):
```bash
ssh -o BatchMode=yes -o ConnectTimeout=10 kylin 'echo SSH_OK; whoami; hostname; sudo -n true && echo SUDO_OK'
```
Expected: `SSH_OK` / `caichl` / `caichl-HUAWEIQINGYUNL420` / `SUDO_OK`.
-**`SSH_OK` + `SUDO_OK`** → full control. Run anything (incl. `sudo`) via
`ssh -o BatchMode=yes kylin "<command>"`. No password needed.
-**asks for password / times out** → most likely the **hotspot IP changed**
(`192.168.43.248` is DHCP-assigned and can change between sessions). Ask the user
to re-check the Kylin IP (`hostname -I` on that machine), update
`C:\Users\adusu\.ssh\config`, then re-verify. If it asks for a password, the key
may have been removed — see §2.3 / §2.4 to re-establish.
- ️ SSH prints a harmless **post-quantum warning** to stderr — ignore it (the
examples in this doc filter it out).
**Connect manually:** `ssh kylin` (alias in `C:\Users\adusu\.ssh\config``caichl@192.168.43.248`,
with the pinned cipher/MAC already configured — see §2).
> A fresh Claude Code session also auto-loads memory pointers (`MEMORY.md` →
> `kylin-remote-machine`, `kylin-command-logging`, `kylin-mihomo-vpn`) that link
> back here. This file is the authoritative, self-contained reference.
---
## 1. The two machines
| Role | Details |
|------|---------|
| **Controller** | Windows 11 (`C:\Users\adusu`), where Claude Code + VPN run |
| **Target** | Kylin V10 SP1, Huawei QINGYUN L420, **ARM64 / aarch64** |
| **Network** | Shared phone hotspot, subnet `192.168.43.x` |
| **Target IP** | `192.168.43.248` |
| **Target user** | `caichl` |
> **Key idea:** Claude Code needs the VPN to reach Anthropic's servers, but the
> SSH hop from Windows → Kylin is **local LAN traffic** and needs no VPN. The two
> connections are independent.
---
## 2. SSH connection
### 2.1 Required cipher/MAC (important)
The default cipher fails over this hotspot link with
`Corrupted MAC on input`. The connection must pin a specific cipher and MAC:
```
-c aes256-ctr -m hmac-sha2-256
```
### 2.2 Windows SSH config
File: `C:\Users\adusu\.ssh\config`
```sshconfig
Host kylin kyln
HostName 192.168.43.248
User caichl
Ciphers aes256-ctr
MACs hmac-sha2-256
```
With this in place, simply run:
```powershell
ssh kylin # or: ssh kyln (both aliases work)
```
The equivalent explicit command (no config) is:
```powershell
ssh -m hmac-sha2-256 -c aes256-ctr caichl@192.168.43.248
```
### 2.3 Passwordless login (SSH key)
The Windows key `~/.ssh/id_ed25519.pub` was copied to the Kylin machine:
```powershell
Get-Content $env:USERPROFILE\.ssh\id_ed25519.pub | `
ssh kylin "mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
```
Result: `ssh kylin` logs in with **no password**.
### 2.4 Passwordless sudo
So admin commands can run non-interactively from Windows:
```bash
# Run once on the Kylin machine (asks for password the one time)
sudo bash -c 'echo "caichl ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/caichl-nopasswd; \
chmod 440 /etc/sudoers.d/caichl-nopasswd; \
visudo -cf /etc/sudoers.d/caichl-nopasswd'
```
> **Security note:** This makes `caichl` a passwordless-sudo account — acceptable
> for a disposable machine only. To revert: `sudo rm /etc/sudoers.d/caichl-nopasswd`.
After this, from Windows you can run, e.g.:
```powershell
ssh -o BatchMode=yes kylin "sudo systemctl status ssh"
```
### 2.5 Account credentials (recovery only)
> ⚠️ **Plaintext password below — keep this file private; do NOT commit or share it.**
| Item | Value |
|------|-------|
| User | `caichl` |
| Password | `Caicai@2026` |
**Normally not needed** — SSH login uses the key (§2.3) and sudo is passwordless
(§2.4). Use this only to **recover** if the key auth or sudoers drop-in is ever lost
(e.g. to log in interactively and re-copy the key, or to re-create
`/etc/sudoers.d/caichl-nopasswd`). To change it: run `passwd` on the Kylin machine.
---
## 3. Initial SSH server setup (done on the Kylin machine)
```bash
sudo apt update
sudo apt install -y openssh-server
sudo systemctl enable --now ssh
hostname -I # find the IP (192.168.43.248)
whoami # confirm the username (caichl)
```
---
## 4. Monitoring
### 4.1 SSH logins & sessions (works out of the box)
```bash
who # who is logged in + source
w # sessions + current command
last # login history with IPs
lastb # FAILED login attempts (sudo)
sudo grep "Accepted" /var/log/auth.log # successful SSH logins
sudo grep "Failed" /var/log/auth.log # failed/suspicious attempts
sudo tail -f /var/log/auth.log # live SSH activity
```
### 4.2 Command logging — no reboot (WORKING)
Logs every command typed in an interactive shell, with user, tty, source IP,
and the command text.
**`/etc/profile.d/cmdlog.sh`**
```bash
# Log interactive shell commands to syslog (facility local6) -> /var/log/cmdlog.log
if [ -n "$BASH" ] && [ -n "$PS1" ]; then
export PROMPT_COMMAND='logger -p local6.notice -t cmdlog "user=$USER tty=$(tty 2>/dev/null) from=$(who am i 2>/dev/null | sed -n "s/.*(\(.*\)).*/\1/p") cmd=$(history 1 | sed "s/^ *[0-9]* *//")"'
fi
```
**`/etc/rsyslog.d/30-cmdlog.conf`**
```
local6.* /var/log/cmdlog.log
```
**Setup commands**
```bash
sudo touch /var/log/cmdlog.log
sudo chown syslog:adm /var/log/cmdlog.log # MUST be syslog-owned (rsyslog drops privs)
sudo chmod 640 /var/log/cmdlog.log
sudo systemctl restart rsyslog
```
**View the logs**
```bash
sudo cat /var/log/cmdlog.log # all logged commands
sudo tail -f /var/log/cmdlog.log # live
```
Example line:
```
Jun 11 10:54:22 caichl-HUAWEIQINGYUNL420 cmdlog: user=caichl tty=/dev/pts/5 from=192.168.43.180 cmd=echo hello
```
> **Gotcha:** rsyslog runs as the `syslog` user. If `/var/log/cmdlog.log` is owned
> by `root:root`, nothing is written. It must be `chown syslog:adm`.
### 4.3 System-wide auditing — auditd (QUEUED, UNVERIFIED)
`auditd` is installed and enabled, with a rule logging all `execve` for real
users:
**`/etc/audit/rules.d/cmdlog.rules`**
```
-a exit,always -F arch=b64 -S execve -F auid>=1000 -F auid!=4294967295 -k cmdlog
```
It cannot run yet because the kernel booted with `audit=0`. This was changed to
`audit=1`:
```bash
sudo sed -i 's/audit=0/audit=1/' /etc/default/grub
# /boot is read-only on this device — remount to rebuild grub
sudo mount -o remount,rw /boot
sudo update-grub
sudo mount -o remount,ro /boot
```
> **Caveat:** This device appears to boot via **Huawei firmware, not standard
> GRUB** (running kernel `-27` is not the one in `grub.cfg` which lists `-11`, and
> the kernel cmdline has Huawei-specific params). So the `audit=1` change **may
> not take effect** on reboot. The no-reboot logging in §4.2 already covers
> interactive command logging, so auditd is a bonus.
**Verify after any reboot**
```bash
cat /proc/sys/kernel/audit_enabled # want: 1
sudo systemctl is-active auditd # want: active
sudo ausearch -k cmdlog -i | tail -40 # human-readable command audit
sudo aureport -x --summary # summary of executables run
```
---
## 5. Device-specific quirks (Huawei Kylin ARM64)
- **Architecture is ARM64 (aarch64).** Software must have an ARM64 build; x86-only
proprietary apps will not install/run.
- **`/boot` is a separate ext4 partition mounted read-only.** Remount rw to change
GRUB, then back to ro (see §4.3).
- **Boot likely controlled by Huawei firmware**, limiting effectiveness of GRUB
cmdline edits.
- **Hotspot link needs `aes256-ctr` / `hmac-sha2-256`** or SSH fails with
"Corrupted MAC on input".
---
## 6. Quick reference
```powershell
# Connect
ssh kylin
# Run a one-off command from Windows
ssh kylin "uptime"
# Run an admin command from Windows
ssh kylin "sudo systemctl status ssh"
# See logged commands
ssh kylin "sudo tail -50 /var/log/cmdlog.log"
# Watch live
ssh kylin "sudo tail -f /var/log/cmdlog.log"
# SSH login history
ssh kylin "last"
```
---
## 7. Troubleshooting
| Symptom | Cause / Fix |
|---------|-------------|
| `Corrupted MAC on input` | Use `-c aes256-ctr -m hmac-sha2-256` (already in config) |
| `Connection timed out` | Both devices not on same hotspot, or AP/client isolation on the phone — disable isolation |
| `ssh kyln` "could not resolve" | Alias spelling — config now accepts both `kylin` and `kyln` |
| Still asks for password | SSH key not copied (§2.3) |
| `sudo` asks for password | sudoers drop-in empty/missing (§2.4) |
| `audit support not in kernel` | Kernel booted `audit=0`; needs reboot with `audit=1` (§4.3) |
| `cmdlog.log` empty | File not owned by `syslog:adm`; `sudo chown syslog:adm /var/log/cmdlog.log` then restart rsyslog |
| `grub.cfg: Read-only file system` | `/boot` is read-only; `sudo mount -o remount,rw /boot` first |
| VPN GUI app won't run (`GLIBC_2.34 not found`) | Kylin has glibc 2.31; modern Flutter/Electron clients need 2.34+. Use the Go-based mihomo core instead (see §8) |
---
## 8. VPN / proxy: mihomo + web dashboard
**Why not FlClash / GUI clients:** Kylin V10 SP1 has **glibc 2.31**, but FlClash 0.8.93
and similar modern GUI clients require **glibc 2.34+** and fail to run. Upgrading
glibc would risk breaking the OS. mihomo is a **statically-linked Go binary** with
no glibc dependency, so it runs fine — controlled via a browser dashboard.
### Current state
> **VPN is currently OFF.** The `mihomo` service is **stopped** and **disabled**
> (auto-start removed), so it stays off across reboots until re-enabled. Ports
> `7890`/`9090` are closed. The full configuration below is intact and ready to
> resume.
### Turn the VPN on / off (run on Kylin, or via `ssh kylin`)
```bash
# TURN ON (and restore auto-start on boot)
sudo systemctl enable --now mihomo
# TURN OFF for this session only (comes back on next reboot)
sudo systemctl stop mihomo
# TURN OFF and keep it off across reboots <-- current state
sudo systemctl stop mihomo && sudo systemctl disable mihomo
# Check state
systemctl is-active mihomo # active / inactive
systemctl is-enabled mihomo # enabled / disabled
```
> Reminder: if you had set the Kylin **system proxy** to `127.0.0.1:7890`, switch
> it back to "None" while the VPN is off, or apps will fail to reach the network.
### Desktop access
A launcher **"Mihomo VPN Dashboard"** was added to the app menu (Network category)
at `/usr/share/applications/mihomo-dashboard.desktop`; it opens
`http://127.0.0.1:9090/ui/`. (Only works while the service is running.)
### Installed components
| Item | Location / value |
|------|------------------|
| mihomo core | `/usr/local/bin/mihomo` (Clash.Meta v1.19.27, arm64) |
| Config | `/etc/mihomo/config.yaml` |
| Web dashboard (zashboard) | `/etc/mihomo/ui/` |
| systemd service | `/etc/systemd/system/mihomo.service` (auto-starts on boot) |
| Proxy port (HTTP+SOCKS) | `7890` |
| Dashboard API | `0.0.0.0:9090` |
| Dashboard secret | `428968aebf7c301565c6b6d3a11826c4` |
### Install steps (done; for reference / rebuild)
```bash
# binary (downloaded on Windows through VPN, scp'd over to dodge chicken-and-egg)
gunzip mihomo-linux-arm64-vX.gz
sudo install -m 755 mihomo-linux-arm64 /usr/local/bin/mihomo
# dashboard
sudo mkdir -p /etc/mihomo/ui && sudo unzip zashboard-dist.zip -d /etc/mihomo/ui
# config.yaml: mixed-port 7890, external-controller 0.0.0.0:9090, secret, external-ui: ui,
# proxy-providers (subscription URL), PROXY (select) + AUTO (url-test) groups
sudo systemctl enable --now mihomo
```
> The subscription URL lives in `/etc/mihomo/config.yaml` under `proxy-providers:`.
> `external-ui-name` must NOT be set (it makes mihomo look in a `ui/<name>` subdir
> and auto-download); serve `/etc/mihomo/ui` directly.
### Open the dashboard
- **From Windows browser:** `http://192.168.43.248:9090/ui/`
- backend/API: `http://192.168.43.248:9090` • secret: `428968aebf7c301565c6b6d3a11826c4`
- **From Kylin desktop browser:** `http://127.0.0.1:9090/ui/`
### Use the proxy
Point apps at `127.0.0.1:7890` (HTTP/SOCKS), or set the Kylin system proxy
(Settings → Network → Proxy → Manual → `127.0.0.1:7890`). For transparent
whole-system routing, enable mihomo **TUN mode** (`/dev/net/tun` is present).
### Manage via API (examples)
```bash
S=428968aebf7c301565c6b6d3a11826c4
# switch PROXY group to a node / AUTO
curl -X PUT -H "Authorization: Bearer $S" http://127.0.0.1:9090/proxies/PROXY -d '{"name":"AUTO"}'
# verify exit IP goes through the proxy
curl -x http://127.0.0.1:7890 -s https://api.ip.sb/geoip
# update subscription / reload
sudo systemctl restart mihomo
```
**Verified working:** exit IP resolved to Singapore and `google.com/generate_204`
returned HTTP 204 through the proxy.
---
## 9. Browsers
| Browser | App-menu name | Type / location | Notes |
|---------|---------------|-----------------|-------|
| **Chromium** | "Chromium" | snap `chromium` v149 (`/snap/bin/chromium`) | Installed as the Chrome/Edge equivalent — same engine, Chrome Web Store extensions work. **Set as the default browser.** |
- **Chrome & Edge cannot be installed** — neither has an official ARM64 Linux build
(same architecture wall as the VPN GUI clients). Chromium is the substitute.
- Installing the chromium snap first required **updating snapd** (Kylin shipped 2.54,
too old → error `assumes unsupported features: snapd2.55`). Fix:
`sudo snap install snapd` (brought it to 2.75.2), then `sudo snap install chromium`.
- Default browser is recorded in `~/.config/mimeapps.list`
(`text/html`, `x-scheme-handler/http(s)``chromium_chromium.desktop`). If a
double-click opens the wrong app, right-click the file → **Open with → Chromium →
Set as default**.
### Desktop instruction files (for the user at the machine)
- `~/桌面/VPN_Guide.html` + `~/桌面/VPN_Guide.md` (copies also in `~/Desktop/`):
a user-facing "how to turn the VPN on/off" guide. Double-click the **`.html`** to
read it rendered in a browser; the **`.md`** opens in **VS Code** (`code`, installed)
with `Ctrl+Shift+V` preview.
- App-menu launcher **"Mihomo VPN Dashboard"** → opens `http://127.0.0.1:9090/ui/`
(only works while the mihomo service is running).
---
## 10. Inventory — everything set up on the Kylin machine
| Area | What | State |
|------|------|-------|
| Access | SSH alias `kylin`/`kyln`, key login, passwordless sudo | ✅ active |
| Monitoring | SSH login logs (`auth.log`); command logging → `/var/log/cmdlog.log` | ✅ active |
| Monitoring | auditd execve rule | ⏳ queued (needs reboot + `audit=1`; may not take on Huawei firmware) |
| VPN | mihomo core + zashboard dashboard, subscription configured | ⛔ installed, **OFF** (stopped + disabled) |
| Browser | Chromium v149 (default), 360/CNOOC browser | ✅ installed |
| Editors | VS Code (`code`), pluma, WPS Office | ✅ pre-installed |
| IDE | Cursor 3.7.27 (`/opt/cursor`) via `env -i` wrapper | ✅ installed (see §11) |
| Desktop docs | `VPN_Guide.html` / `.md`, dashboard launcher | ✅ on desktop |
---
## 11. Cursor IDE (Electron) — install + crash fixes
Cursor (AI code editor, Electron-based) installed as an **extracted AppImage** at
`/opt/cursor` (currently **v3.7.27**, ARM64). It will NOT run with a normal launcher
on this Huawei/Kylin box — it needs a special wrapper.
### Why the special launcher (three Kylin/Huawei ARM64 problems)
1. **EFAULT machine-ID probe crash** — Cursor runs a command to generate a unique
machine ID; the Kylin kernel blocks that memory `write` as suspicious →
`Error: EFAULT: bad address in system call argument, write` → instant crash.
**Fix:** `env -i` strips the environment (especially D-Bus) so Cursor skips the probe.
2. **Mali GPU crash (段错误 / segfault)** — hardware rendering conflicts with Huawei's
Mali GPU driver (`dmesg`: `mali gpu: kctx ... create/destroyed` at crash time).
**Fix:** `--disable-gpu` (software rendering).
3. **Blocked sandbox**`--no-sandbox`.
Also installed `xdg-desktop-portal` + `xdg-desktop-portal-gtk` (1.6.0) for the
Wayland file picker (necessary but not sufficient alone).
### The launcher (already installed)
- **Wrapper:** `/usr/local/bin/cursor-launch`
- **Start-menu entry:** `/usr/share/applications/cursor.desktop``Exec=/usr/local/bin/cursor-launch %F`
```sh
#!/bin/sh
exec env -i \
HOME="$HOME" DISPLAY="${DISPLAY:-:0}" PATH="$PATH" \
/opt/cursor/usr/share/cursor/cursor --no-sandbox --disable-gpu "$@"
```
### Updating Cursor to a newer version
1. Get the latest ARM64 AppImage URL (downloads on Windows through the VPN):
```bash
curl -sL -A "Mozilla/5.0" -H "Accept: application/json" \
"https://www.cursor.com/api/download?platform=linux-arm64&releaseTrack=stable"
# -> JSON with "downloadUrl" (…/Cursor-X.Y.Z-aarch64.AppImage) and "version"
curl -L -C - -o Cursor-new.AppImage "<downloadUrl>"
```
2. `scp` it to Kylin, then:
```bash
chmod +x Cursor-new.AppImage
cd /tmp && ./Cursor-new.AppImage --appimage-extract # -> /tmp/squashfs-root
sudo rm -rf /opt/cursor && sudo cp -r /tmp/squashfs-root /opt/cursor
sudo chown -R root:root /opt/cursor && rm -rf /tmp/squashfs-root
```
3. **No change needed** to the wrapper or menu entry — paths stay the same.
Verify glibc first (must need ≤ 2.31): `objdump -T /opt/cursor/usr/share/cursor/cursor | grep -oE 'GLIBC_[0-9.]+' | sort -V | tail`.
### Cleanup note
Old AppImages in `~/software/` (`Cursor-3.2.11`, `Cursor-3.7.27`) and the user's
manual extract `~/software/squashfs-root` (3.2.11) are redundant now that `/opt/cursor`
is the install — safe to delete to reclaim space.
+27
View File
@@ -0,0 +1,27 @@
import os
import pandas as pdc
from openpyxl import load_workbook
input_folder = r"d:\input"
merged_dfs = []
total_rows = 0
excel_files = [f for f in os.listdir(input_folder) if f.endswith(".xlsx")]
for excel_file in sorted(excel_files):
try:
wb = load_workbook(excel_file, data_only=False)
sheet_name = wb.sheetnames[0] # Get first sheet name
df = pdc.read_excel(excel_file, sheet_name=sheet_name)
merged_dfs.append(df)
total_rows += len(df)
except Exception as e:
print(f"Error processing {excel_file}: " + str(e))
continue
if merged_dfs:
merged_output = pd.concat(merged_dfs, ignore_index=True)
output_file = r"d:\output\merged_file.xlsx"
merged_output.to_excel(output_file, index=False)
print(f"Successfully saved {total_rows} rows to output file")
+403
View File
@@ -0,0 +1,403 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MLP Playground — Neuron → Layer → MLP (section 5.1)</title>
<script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
<style>
:root {
--bg: #0f1420;
--panel: #161d2e;
--panel-2: #1d2940;
--ink: #e8edf6;
--muted: #94a3b8;
--line: #2a3550;
--pos: #4ade80; /* positive weight / activation */
--neg: #f87171; /* negative weight / activation */
--accent: #60a5fa;
--accent-2: #fbbf24;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background: radial-gradient(1200px 600px at 70% -10%, #1b2740 0%, var(--bg) 55%);
color: var(--ink);
font-family: "Segoe UI", system-ui, -apple-system, Roboto, sans-serif;
line-height: 1.5;
padding: 22px clamp(14px, 3vw, 40px) 60px;
}
h1 { font-size: clamp(20px, 2.6vw, 28px); margin: 0 0 4px; }
.sub { color: var(--muted); margin: 0 0 18px; max-width: 70ch; }
.sub b { color: var(--ink); }
.layout { display: grid; grid-template-columns: 300px 1fr; gap: 18px; align-items: start; }
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
.panel {
background: linear-gradient(180deg, var(--panel) 0%, var(--panel-2) 100%);
border: 1px solid var(--line);
border-radius: 14px;
padding: 16px 16px 18px;
box-shadow: 0 10px 30px rgba(0,0,0,.35);
}
.panel h2 { font-size: 14px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); margin: 0 0 12px; }
.field { margin-bottom: 16px; }
.field label { display: flex; justify-content: space-between; font-size: 13px; margin-bottom: 6px; }
.field label .val { font-variant-numeric: tabular-nums; color: var(--accent); font-weight: 600; }
input[type=range] { width: 100%; accent-color: var(--accent); }
.btn {
appearance: none; border: 1px solid var(--line); cursor: pointer;
background: var(--panel-2); color: var(--ink);
padding: 9px 12px; border-radius: 9px; font-size: 13px; font-weight: 600;
transition: .15s; width: 100%; margin-bottom: 9px;
}
.btn:hover { border-color: var(--accent); color: #fff; }
.btn.primary { background: linear-gradient(180deg, #3b82f6, #2563eb); border-color: #2563eb; }
.btn.primary:hover { background: linear-gradient(180deg, #4f93ff, #2f6fe0); }
.legend { display: flex; flex-direction: column; gap: 7px; font-size: 12.5px; color: var(--muted); }
.legend .row { display: flex; align-items: center; gap: 8px; }
.swatch { width: 22px; height: 4px; border-radius: 3px; }
.stage { background: rgba(0,0,0,.18); border-radius: 14px; }
svg { display: block; width: 100%; height: auto; }
.out-card {
margin-top: 14px; padding: 14px 16px; border-radius: 12px;
border: 1px solid var(--line); background: rgba(0,0,0,.2);
display: flex; align-items: center; justify-content: space-between; gap: 14px;
}
.out-card .label { color: var(--muted); font-size: 13px; }
.out-card .verdict { font-size: 26px; font-weight: 800; font-variant-numeric: tabular-nums; }
/* tooltip */
.tip {
position: fixed; pointer-events: none; z-index: 50; opacity: 0;
background: #0b1322; border: 1px solid var(--accent); color: var(--ink);
padding: 8px 10px; border-radius: 9px; font-size: 12.5px; max-width: 240px;
box-shadow: 0 8px 24px rgba(0,0,0,.5); transition: opacity .12s;
}
.tip b { color: var(--accent); }
.tip .mono { font-family: ui-monospace, "Cascadia Code", Consolas, monospace; }
/* node + edge base styles */
.edge { transition: stroke-opacity .2s; }
.node circle { stroke: #0b1322; stroke-width: 1.5px; cursor: pointer; }
.node-label { font-size: 11px; fill: var(--muted); }
.layer-title { font-size: 12px; fill: var(--ink); font-weight: 700; text-anchor: middle; }
.layer-cap { font-size: 11px; fill: var(--muted); text-anchor: middle; }
.hint { color: var(--muted); font-size: 12px; margin-top: 10px; }
.pill { display:inline-block; padding:2px 7px; border:1px solid var(--line); border-radius:999px; font-size:11px; color:var(--muted); }
</style>
</head>
<body>
<h1>MLP Playground — <span style="color:var(--accent)">Neuron → Layer → MLP</span></h1>
<p class="sub">
The reviewer-panel story from <b>section 5.1</b>, made live. Two inputs
(<b>income</b>, <b>credit</b>) flow through two panels of 8 reviewers into one final judge:
the <span class="pill">2 → 8 → 8 → 1</span> network. Every line is a weight, every dot is a
<span class="mono">tanh(w·x + b)</span> opinion. Drag the inputs, hover anything, or press play.
</p>
<div class="layout">
<div class="panel">
<h2>Inputs (the application)</h2>
<div class="field">
<label>Income (x₁) <span class="val" id="x1v">0.50</span></label>
<input type="range" id="x1" min="-1" max="1" step="0.01" value="0.5" />
</div>
<div class="field">
<label>Credit (x₂) <span class="val" id="x2v">-0.30</span></label>
<input type="range" id="x2" min="-1" max="1" step="0.01" value="-0.3" />
</div>
<h2 style="margin-top:6px">Controls</h2>
<button class="btn primary" id="play">▶ Animate forward pass</button>
<button class="btn" id="reroll">🎲 New random weights</button>
<button class="btn" id="reset">↺ Reset inputs</button>
<h2 style="margin-top:18px">Read the colors</h2>
<div class="legend">
<div class="row"><span class="swatch" style="background:var(--pos)"></span> positive weight / activation</div>
<div class="row"><span class="swatch" style="background:var(--neg)"></span> negative weight / activation</div>
<div class="row"><span class="swatch" style="background:var(--muted);height:2px"></span> thin line = weak, thick = strong</div>
</div>
<div class="out-card">
<span class="label">Final judge<br>score (tanh)</span>
<span class="verdict" id="verdict"></span>
</div>
<p class="hint" id="verdict-text">Closer to <b style="color:var(--pos)">+1</b> = likely good tenant, closer to <b style="color:var(--neg)">1</b> = likely risky.</p>
</div>
<div class="panel stage">
<svg id="net" viewBox="0 0 980 560" preserveAspectRatio="xMidYMid meet" aria-label="MLP diagram"></svg>
<p class="hint" style="padding:0 6px 4px">
Hover a <b>line</b> to see its weight, or a <b>dot</b> to see that reviewer's
<span class="mono">w·x + b</span> and squashed opinion. Layers left→right: form → junior panel → senior panel → final judge.
</p>
</div>
</div>
<div class="tip" id="tip"></div>
<script>
// ----------------------------------------------------------------------------
// Tiny MLP, faithful to section 5.1: Neuron = tanh(w·x + b), Layer = neurons in
// parallel, MLP = layers in sequence. Shape: 2 -> 8 -> 8 -> 1.
// ----------------------------------------------------------------------------
const SIZES = [2, 8, 8, 1];
const LAYER_TITLES = ["Inputs", "Hidden panel 1", "Hidden panel 2", "Final judge"];
const LAYER_CAPS = ["income, credit", "8 junior reviewers", "8 senior reviewers", "1 score"];
const rand = () => Math.random() * 2 - 1; // uniform(-1, 1), like micrograd
const tanh = (z) => Math.tanh(z);
// Build network: weights[l] is matrix [nout x nin], biases[l] is [nout].
let weights, biases;
function buildNetwork() {
weights = []; biases = [];
for (let l = 0; l < SIZES.length - 1; l++) {
const nin = SIZES[l], nout = SIZES[l + 1];
weights.push(Array.from({length: nout}, () => Array.from({length: nin}, rand)));
biases.push(Array.from({length: nout}, rand));
}
}
buildNetwork();
// Forward pass. Returns activations per layer (layer 0 = raw inputs) and the
// pre-activation (w·x+b) for each neuron so the tooltip can show both.
function forward(x) {
const acts = [x.slice()];
const preacts = [x.slice()]; // inputs have no pre-activation; mirror for indexing
let cur = x.slice();
for (let l = 0; l < weights.length; l++) {
const z = [], a = [];
for (let j = 0; j < weights[l].length; j++) {
let s = biases[l][j];
for (let i = 0; i < cur.length; i++) s += weights[l][j][i] * cur[i];
z.push(s); a.push(tanh(s));
}
preacts.push(z); acts.push(a); cur = a;
}
return { acts, preacts };
}
// ----------------------------------------------------------------------------
// Layout geometry
// ----------------------------------------------------------------------------
const W = 980, H = 560, PAD_X = 90, PAD_TOP = 70, PAD_BOT = 50;
const svg = d3.select("#net");
const tip = d3.select("#tip");
function layerX(l) { return PAD_X + (W - 2 * PAD_X) * (l / (SIZES.length - 1)); }
function nodeY(l, j) {
const n = SIZES[l];
const top = PAD_TOP, bot = H - PAD_BOT, span = bot - top;
if (n === 1) return (top + bot) / 2;
return top + span * (j / (n - 1));
}
// color scales: weights & activations mapped to red(-)/green(+)
function weightColor(w) { return w >= 0 ? "var(--pos)" : "var(--neg)"; }
function actColor(a) { return a >= 0 ? "var(--pos)" : "var(--neg)"; }
function weightWidth(w) { return 0.6 + Math.min(5.5, Math.abs(w) * 3.2); }
const gEdges = svg.append("g").attr("class", "edges");
const gNodes = svg.append("g").attr("class", "nodes");
const gTitles = svg.append("g").attr("class", "titles");
// layer titles + captions
function drawTitles() {
gTitles.selectAll("*").remove();
SIZES.forEach((n, l) => {
gTitles.append("text").attr("class", "layer-title")
.attr("x", layerX(l)).attr("y", 28).text(LAYER_TITLES[l]);
gTitles.append("text").attr("class", "layer-cap")
.attr("x", layerX(l)).attr("y", 46).text(LAYER_CAPS[l]);
});
}
// build edges + nodes once (geometry is static; only color/width/anim update)
let edgeSel, nodeSel;
function drawStructure() {
// edges
const edges = [];
for (let l = 0; l < weights.length; l++) {
for (let j = 0; j < SIZES[l + 1]; j++) {
for (let i = 0; i < SIZES[l]; i++) {
edges.push({ l, i, j,
x1: layerX(l), y1: nodeY(l, i),
x2: layerX(l + 1), y2: nodeY(l + 1, j) });
}
}
}
edgeSel = gEdges.selectAll("line").data(edges).join("line")
.attr("class", "edge")
.attr("x1", d => d.x1).attr("y1", d => d.y1)
.attr("x2", d => d.x2).attr("y2", d => d.y2)
.on("mousemove", (e, d) => {
const w = weights[d.l][d.j][d.i];
showTip(e, `<b>Weight</b> · panel line<br>
<span class="mono">layer ${d.l}${d.l + 1}</span><br>
from neuron ${d.i} → neuron ${d.j}<br>
<span class="mono">w = ${w.toFixed(3)}</span><br>
<span style="color:${w>=0?'var(--pos)':'var(--neg)'}">${w>=0?'boosts':'damps'} this signal</span>`);
})
.on("mouseleave", hideTip);
// nodes
const nodes = [];
SIZES.forEach((n, l) => {
for (let j = 0; j < n; j++) nodes.push({ l, j, x: layerX(l), y: nodeY(l, j) });
});
const g = gNodes.selectAll("g.node").data(nodes).join("g").attr("class", "node")
.attr("transform", d => `translate(${d.x},${d.y})`);
g.selectAll("circle").data(d => [d]).join("circle").attr("r", d => d.l === 0 || d.l === SIZES.length - 1 ? 13 : 11);
nodeSel = g;
// input row labels
gNodes.selectAll("text.in-lab").remove();
["x₁ income", "x₂ credit"].forEach((t, i) => {
gNodes.append("text").attr("class", "node-label in-lab")
.attr("x", layerX(0) - 22).attr("y", nodeY(0, i) + 4)
.attr("text-anchor", "end").text(t);
});
g.on("mousemove", (e, d) => {
if (d.l === 0) {
showTip(e, `<b>Input</b><br><span class="mono">x${d.j+1} = ${state.acts[0][d.j].toFixed(3)}</span><br>${d.j===0?'monthly income':'credit score'} (raw form field)`);
} else {
const z = state.preacts[d.l][d.j], a = state.acts[d.l][d.j];
const role = d.l === SIZES.length - 1 ? "final judge" : `reviewer ${d.j} in panel ${d.l}`;
showTip(e, `<b>Neuron</b> · ${role}<br>
<span class="mono">w·x + b = ${z.toFixed(3)}</span><br>
<span class="mono">tanh(...) = <b style="color:${a>=0?'var(--pos)':'var(--neg)'}">${a.toFixed(3)}</b></span><br>
${a>=0?'leaning yes':'leaning no'}`);
}
}).on("mouseleave", hideTip);
}
// ----------------------------------------------------------------------------
// Paint current state (colors + widths) onto edges and nodes
// ----------------------------------------------------------------------------
let state = { acts: [], preacts: [] };
function recompute() {
const x1 = +d3.select("#x1").property("value");
const x2 = +d3.select("#x2").property("value");
state = forward([x1, x2]);
paint();
updateVerdict();
}
function paint() {
edgeSel
.attr("stroke", d => weightColor(weights[d.l][d.j][d.i]))
.attr("stroke-width", d => weightWidth(weights[d.l][d.j][d.i]))
.attr("stroke-opacity", 0.45);
nodeSel.select("circle")
.attr("fill", d => {
const a = state.acts[d.l] ? state.acts[d.l][d.j] : 0;
const mag = Math.min(1, Math.abs(a));
const base = a >= 0 ? [74,222,128] : [248,113,113];
// blend toward dark panel for low magnitude
const mix = (c) => Math.round(40 + (c - 40) * (0.25 + 0.75 * mag));
return `rgb(${mix(base[0])},${mix(base[1])},${mix(base[2])})`;
});
}
function updateVerdict() {
const out = state.acts[state.acts.length - 1][0];
const el = d3.select("#verdict");
el.text((out >= 0 ? "+" : "") + out.toFixed(3))
.style("color", out >= 0 ? "var(--pos)" : "var(--neg)");
d3.select("#verdict-text").html(
out >= 0
? `Score <b style="color:var(--pos)">${out.toFixed(2)}</b> → leaning <b>good tenant</b>.`
: `Score <b style="color:var(--neg)">${out.toFixed(2)}</b> → leaning <b>risky</b>.`
);
}
// ----------------------------------------------------------------------------
// Animated forward pass: light up layer by layer, pulse edges between them
// ----------------------------------------------------------------------------
let playing = false;
function animate() {
if (playing) return;
playing = true;
// dim everything first
edgeSel.attr("stroke-opacity", 0.08);
nodeSel.select("circle").attr("opacity", 0.25);
d3.select("#play").attr("disabled", true).text("▶ playing…");
const STEP = 620;
// light input layer
lightLayer(0, 0);
for (let l = 0; l < weights.length; l++) {
pulseEdges(l, STEP * (l + 0.15));
lightLayer(l + 1, STEP * (l + 1));
}
setTimeout(() => {
edgeSel.transition().duration(300).attr("stroke-opacity", 0.45);
nodeSel.select("circle").transition().duration(300).attr("opacity", 1);
playing = false;
d3.select("#play").attr("disabled", null).text("▶ Animate forward pass");
}, STEP * (weights.length + 1) + 250);
}
function lightLayer(l, delay) {
nodeSel.filter(d => d.l === l).select("circle")
.transition().delay(delay).duration(280)
.attr("opacity", 1)
.attr("r", d => (d.l === 0 || d.l === SIZES.length - 1 ? 13 : 11) + 4)
.transition().duration(220)
.attr("r", d => d.l === 0 || d.l === SIZES.length - 1 ? 13 : 11);
}
function pulseEdges(l, delay) {
edgeSel.filter(d => d.l === l)
.transition().delay(delay).duration(300).attr("stroke-opacity", 0.9)
.transition().duration(320).attr("stroke-opacity", 0.18);
}
// ----------------------------------------------------------------------------
// Tooltip helpers
// ----------------------------------------------------------------------------
function showTip(e, html) {
tip.html(html).style("opacity", 1)
.style("left", (e.clientX + 14) + "px")
.style("top", (e.clientY + 14) + "px");
}
function hideTip() { tip.style("opacity", 0); }
// ----------------------------------------------------------------------------
// Wire up controls
// ----------------------------------------------------------------------------
function syncLabels() {
d3.select("#x1v").text((+d3.select("#x1").property("value")).toFixed(2));
d3.select("#x2v").text((+d3.select("#x2").property("value")).toFixed(2));
}
d3.select("#x1").on("input", () => { syncLabels(); recompute(); });
d3.select("#x2").on("input", () => { syncLabels(); recompute(); });
d3.select("#play").on("click", animate);
d3.select("#reroll").on("click", () => { buildNetwork(); drawStructure(); recompute(); });
d3.select("#reset").on("click", () => {
d3.select("#x1").property("value", 0.5);
d3.select("#x2").property("value", -0.3);
syncLabels(); recompute();
});
// ----------------------------------------------------------------------------
// Init
// ----------------------------------------------------------------------------
drawTitles();
drawStructure();
syncLabels();
recompute();
</script>
</body>
</html>
View File
BIN
View File
Binary file not shown.
+86
View File
@@ -0,0 +1,86 @@
# Server Information
**SSH Access:** `ssh root@39.102.124.161` (Alibaba Cloud)
**OS:** CentOS with Baota panel pre-configured
**Website:** https://www.thereisnospoonadu.com/
**Web Server:** Tengine (Alibaba's fork of Nginx) — use `tengine` commands, NOT `nginx`
## Gitea (self-hosted Git server)
**Web UI:** https://git.thereisnospoonadu.com
**Admin username:** beastgitea2026
**API token:** paste fresh when needed (not stored)
**SSH remote:** `git@git.thereisnospoonadu.com:beastgitea2026/<repo>.git`
**SSH port:** 222 — configured in `~/.ssh/config` on dev machine
**Gitea data:** `/var/lib/gitea/` | **Docker container name:** `gitea`
**Tengine config:** `/etc/tengine/conf.d/gitea.conf`
## Critical File Paths
**Web Root:** `/www/wwwroot/soft_download/`
**Tengine Config (ACTUAL):** `/etc/tengine/conf.d/thereisnospoonadu.conf`
- Site uses HTTPS (port 443), HTTP redirects to HTTPS
- Always test changes with: `tengine -t && tengine -s reload`
**Auth File:** `/etc/tengine/.htpasswd`
**Logs:** Check tengine log paths inside `/etc/tengine/conf.d/thereisnospoonadu.conf`
## User Management
Add a new end user (can login and download files):
```bash
# Add user (file already exists — do NOT use -c or it will overwrite all users)
htpasswd /etc/tengine/.htpasswd username
# Verify a user's password
htpasswd -v /etc/tengine/.htpasswd username
# Delete a user
htpasswd -D /etc/tengine/.htpasswd username
# List current users
cat /etc/tengine/.htpasswd
```
Note: `htpasswd` requires `httpd-tools` package (`yum install -y httpd-tools`).
## Architecture
**Download Portal System:**
- Login page: `/login.html` (public)
- File listing: `/list/` (requires HTTP Basic Auth via Authorization header)
- File downloads: Direct links to `.exe`, `.md`, `.pdf`, etc. (NO auth required)
- Security model: Must login to see file list, but downloads are public once you know the filename
**Key Files:**
- `/www/wwwroot/soft_download/index.html` - Main file browser
- `/www/wwwroot/soft_download/login.html` - Login page
- `/www/wwwroot/soft_download/api/validate` - Auth validation endpoint
## Important Notes
- When making Tengine changes, ALWAYS edit `/etc/tengine/conf.d/thereisnospoonadu.conf`
- Test against the actual domain: `curl -I https://www.thereisnospoonadu.com/filename`
- PHP is NOT configured/working on this server - use Tengine-only solutions
- Large files (170MB+ exe) need native browser downloads, not blob/XHR approach
## Verification Checklist (Before Making Changes)
```bash
# 1. Verify which Tengine config is active
tengine -T | grep -A 30 'server_name.*thereisnospoonadu'
# 2. Test current behavior against actual domain
curl -I https://www.thereisnospoonadu.com/版本升级说明(用记事本打开).md
# 3. After changes: test and reload
tengine -t && tengine -s reload
```
## Troubleshooting (Only When Issues Occur)
```bash
# Find log paths from config
grep 'access_log\|error_log' /etc/tengine/conf.d/thereisnospoonadu.conf
```
**Note:** Logs are automatically rotated daily, keeping 7 days of history (compressed).
+276
View File
@@ -0,0 +1,276 @@
#!/usr/bin/env python3
import re
import os
from http.server import HTTPServer, BaseHTTPRequestHandler
from datetime import datetime, timedelta
from collections import defaultdict
LOG_FILE = "/usr/local/nginx/logs/download_access.log"
LOG_RE = re.compile(
r'(?P<ip>\S+) - (?P<user>\S+) \[(?P<time>[^\]]+)\] "(?P<request>[^"]*)" '
r'(?P<status>\d+) (?P<bytes>\d+) "(?P<referer>[^"]*)" "(?P<ua>[^"]*)"'
)
DOWNLOAD_EXTS = re.compile(
r'\.(exe|md|pdf|zip|rar|7z|tar|gz|txt|doc|docx|xls|xlsx|ppt|pptx|deb)(\?|$)',
re.IGNORECASE
)
def fmt_bytes(b):
b = int(b)
for unit in ['B', 'KB', 'MB', 'GB']:
if b < 1024:
return "{:.1f} {}".format(b, unit)
b /= 1024
return "{:.1f} TB".format(b)
def parse_time(s):
try:
return datetime.strptime(s, "%d/%b/%Y:%H:%M:%S %z")
except Exception:
return None
def parse_logs():
entries = []
try:
with open(LOG_FILE, 'r', errors='replace') as f:
for line in f:
m = LOG_RE.match(line)
if not m:
continue
user = m.group('user')
entries.append({
'ip': m.group('ip'),
'user': user,
'time': parse_time(m.group('time')),
'request': m.group('request'),
'status': int(m.group('status')),
'bytes': int(m.group('bytes')),
})
except FileNotFoundError:
pass
return entries
def build_stats(entries):
users = defaultdict(lambda: {
'logins': [], 'downloads': [], 'total_bytes': 0, 'ips': set()
})
all_downloads = []
for e in entries:
u = e['user']
t = e['time']
req = e['request']
parts = req.split(' ')
path = parts[1] if len(parts) >= 2 else req
# For anonymous download entries, extract username from ?u= query param
if u == '-' and DOWNLOAD_EXTS.search(path) and e['status'] in (200, 206):
m = re.search(r'[?&]u=([^&\s]+)', path)
if m:
try:
from urllib.parse import unquote
u = unquote(m.group(1))
except Exception:
u = m.group(1)
else:
continue
if u == '-':
continue
users[u]['ips'].add(e['ip'])
# Count login: either /api/validate or /list/ success
if e['status'] == 200 and t:
if '/api/validate' in path or path.rstrip('/') == '/list':
users[u]['logins'].append(t)
if DOWNLOAD_EXTS.search(path) and e['status'] in (200, 206):
filename = path.split('/')[-1].split('?')[0]
try:
from urllib.parse import unquote
filename = unquote(filename)
# Tengine logs non-ASCII bytes as \xNN sequences; decode them as UTF-8
def _decode_nginx_escapes(s):
parts = re.split(r'((?:\\x[0-9A-Fa-f]{2})+)', s)
out = []
for part in parts:
if part.startswith('\\x'):
raw = bytes(int(h, 16) for h in re.findall(r'\\x([0-9A-Fa-f]{2})', part))
out.append(raw.decode('utf-8', errors='replace'))
else:
out.append(part)
return ''.join(out)
filename = _decode_nginx_escapes(filename)
except Exception:
pass
users[u]['downloads'].append({'time': t, 'file': filename, 'bytes': e['bytes']})
users[u]['total_bytes'] += e['bytes']
all_downloads.append({'user': u, 'time': t, 'file': filename, 'bytes': e['bytes']})
def sort_key(x):
t = x['time']
if t is None:
return datetime.min.replace(tzinfo=None)
try:
return t.replace(tzinfo=None)
except Exception:
return datetime.min.replace(tzinfo=None)
all_downloads.sort(key=sort_key, reverse=True)
return users, all_downloads
def render_html(users, all_downloads):
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
user_rows = ""
for uname in sorted(users.keys()):
data = users[uname]
logins = sorted(data['logins'], reverse=True)
last_login = logins[0].strftime("%Y-%m-%d %H:%M") if logins else "&mdash;"
login_count = len(logins)
dl_count = len(data['downloads'])
total_data = fmt_bytes(data['total_bytes'])
ips = ", ".join(sorted(data['ips']))
if logins:
delta = datetime.now(logins[0].tzinfo) - logins[0]
is_active = delta < timedelta(hours=24)
else:
is_active = False
status_class = "active" if is_active else "inactive"
status_label = "Active 24h" if is_active else "Inactive"
user_rows += (
"<tr>"
"<td><span class='username'>{}</span></td>"
"<td><span class='badge {}'>{}</span></td>"
"<td>{}</td>"
"<td class='num'>{}</td>"
"<td class='num'>{}</td>"
"<td class='num'>{}</td>"
"<td class='ip'>{}</td>"
"</tr>"
).format(uname, status_class, status_label, last_login,
login_count, dl_count, total_data, ips)
dl_rows = ""
for d in all_downloads[:100]:
t = d['time'].strftime("%Y-%m-%d %H:%M") if d['time'] else "&mdash;"
dl_rows += (
"<tr>"
"<td>{}</td>"
"<td><span class='username'>{}</span></td>"
"<td class='filename'>{}</td>"
"<td class='num'>{}</td>"
"</tr>"
).format(t, d['user'], d['file'], fmt_bytes(d['bytes']))
total_users = len(users)
total_dls = sum(len(v['downloads']) for v in users.values())
total_data_all = fmt_bytes(sum(v['total_bytes'] for v in users.values()))
no_users = "<tr><td colspan='7' class='empty'>No authenticated activity yet</td></tr>"
no_dls = "<tr><td colspan='4' class='empty'>No downloads recorded yet</td></tr>"
html = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="60">
<title>Admin Dashboard</title>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f1f5f9; color: #1e293b; min-height: 100vh; }}
header {{ background: #ffffff; border-bottom: 1px solid #e2e8f0; padding: 20px 32px; display: flex; align-items: center; justify-content: space-between; }}
header h1 {{ font-size: 1.4rem; font-weight: 600; color: #7c3aed; }}
.refresh-note {{ font-size: 0.75rem; color: #94a3b8; }}
.stats-bar {{ display: flex; gap: 16px; padding: 24px 32px; }}
.stat-card {{ background: #ffffff; border: 1px solid #e2e8f0; border-radius: 10px; padding: 18px 24px; flex: 1; }}
.stat-card .label {{ font-size: 0.75rem; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em; }}
.stat-card .value {{ font-size: 2rem; font-weight: 700; color: #7c3aed; margin-top: 4px; }}
.section {{ padding: 0 32px 32px; }}
.section h2 {{ font-size: 1rem; font-weight: 600; color: #64748b; margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.05em; }}
table {{ width: 100%; border-collapse: collapse; background: #ffffff; border: 1px solid #e2e8f0; border-radius: 10px; overflow: hidden; }}
th {{ background: #f8fafc; padding: 12px 16px; text-align: left; font-size: 0.75rem; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; }}
td {{ padding: 12px 16px; border-top: 1px solid #f1f5f9; font-size: 0.875rem; }}
tr:hover td {{ background: #f8fafc; }}
.username {{ color: #2563eb; font-weight: 500; }}
.filename {{ color: #059669; font-family: monospace; font-size: 0.8rem; }}
.ip {{ color: #64748b; font-size: 0.8rem; font-family: monospace; }}
.num {{ text-align: right; color: #1e293b; }}
.badge {{ padding: 2px 10px; border-radius: 999px; font-size: 0.7rem; font-weight: 600; }}
.badge.active {{ background: #d1fae5; color: #065f46; }}
.badge.inactive {{ background: #f1f5f9; color: #94a3b8; }}
.empty {{ text-align: center; color: #94a3b8; padding: 32px; }}
</style>
</head>
<body>
<header>
<h1>Admin Dashboard &mdash; thereisnospoonadu.com</h1>
<span class="refresh-note">Last updated: {now} &nbsp;|&nbsp; Auto-refresh every 60s</span>
</header>
<div class="stats-bar">
<div class="stat-card"><div class="label">Total Users</div><div class="value">{total_users}</div></div>
<div class="stat-card"><div class="label">Total Downloads</div><div class="value">{total_dls}</div></div>
<div class="stat-card"><div class="label">Total Data Served</div><div class="value">{total_data_all}</div></div>
</div>
<div class="section">
<h2>User Summary</h2>
<table>
<thead><tr>
<th>Username</th><th>Status</th><th>Last Login</th>
<th style="text-align:right">Logins</th>
<th style="text-align:right">Downloads</th>
<th style="text-align:right">Data Used</th>
<th>IP Address(es)</th>
</tr></thead>
<tbody>{user_rows}</tbody>
</table>
</div>
<div class="section">
<h2>Download History (last 100)</h2>
<table>
<thead><tr><th>Time</th><th>User</th><th>File</th><th style="text-align:right">Size</th></tr></thead>
<tbody>{dl_rows}</tbody>
</table>
</div>
</body>
</html>""".format(
now=now,
total_users=total_users,
total_dls=total_dls,
total_data_all=total_data_all,
user_rows=user_rows if user_rows else no_users,
dl_rows=dl_rows if dl_rows else no_dls,
)
return html
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
entries = parse_logs()
users, all_downloads = build_stats(entries)
html = render_html(users, all_downloads)
body = html.encode('utf-8')
self.send_response(200)
self.send_header('Content-Type', 'text/html; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, format, *args):
pass
if __name__ == '__main__':
server = HTTPServer(('127.0.0.1', 8765), Handler)
print("Admin dashboard running on 127.0.0.1:8765")
server.serve_forever()
+14
View File
@@ -0,0 +1,14 @@
<?php
/**
* Used by Nginx auth_request. Returns 200 if session valid, 401 otherwise.
* Also returns X-Auth-User header so Tengine can log the authenticated username.
* Deploy to /www/wwwroot/soft_download/auth_check.php
*/
session_start();
if (!empty($_SESSION['soft_download_user'])) {
header('X-Auth-User: ' . $_SESSION['soft_download_user']);
http_response_code(200);
exit;
}
http_response_code(401);
exit;
+175
View File
@@ -0,0 +1,175 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>软件下载页面</title>
<style>
body{font-size:18px!important;line-height:2;font-family:sans-serif;margin:.5em 1.5em 2em}
h1{font-size:24px!important;margin-top:0;margin-bottom:.5em;border-bottom:2px solid #ccc;padding-bottom:.3em}
.breadcrumb{font-size:18px;margin-bottom:.75em}
.breadcrumb a{color:#0066cc;cursor:pointer}
.loading{font-size:20px;color:#666}
.error{color:#c00;font-size:18px}
.user-bar{margin-bottom:.4em;padding:.25em 0;display:flex;align-items:center;justify-content:flex-end;gap:1em}
.user-bar .logged-in{color:green}
.user-bar .logout-btn{padding:.3em .8em;font-size:16px;cursor:pointer;background:#0066cc;color:#fff;border:none;border-radius:4px}
.user-bar .logout-btn:hover{background:#0052a3}
.file-list{width:100%}
.file-table{width:100%;border-collapse:collapse;margin-top:.25em;table-layout:fixed}
.file-table th,.file-table td{padding:.45em .65em;border-bottom:1px solid #ddd;text-align:left;vertical-align:middle;word-break:break-word}
.file-table th{font-size:17px;font-weight:600;color:#333;background:#f5f5f5;border-bottom:2px solid #ccc}
.file-table td:nth-child(1){width:52%}
.file-table td:nth-child(2),.file-table td:nth-child(3){font-size:16px;color:#555;white-space:nowrap}
.file-table a{font-size:19px!important;color:#0066cc;text-decoration:none}
.file-table a:hover{text-decoration:underline}
.file-table .dir{font-weight:bold;cursor:pointer}
.file-table .empty-msg{text-align:center;color:#666;padding:1em}
</style>
</head>
<body>
<div class="user-bar">
<span class="logged-in">当前已登录: <span id="username-display"></span></span>
<button class="logout-btn" id="logout-btn">注销</button>
</div>
<h1>文件列表 <span id="path-display"></span></h1>
<div class="breadcrumb" id="breadcrumb"></div>
<div id="content">
<div class="loading" id="loading">正在加载...</div>
<div class="file-list" id="file-list" style="display:none"></div>
<div class="error" id="error" style="display:none"></div>
</div>
<script>
(function(){
var authHeader=sessionStorage.getItem("auth");
var username=sessionStorage.getItem("username");
if(!authHeader){
window.location.href="/login.html?return="+encodeURIComponent(window.location.pathname);
return;
}
document.getElementById("username-display").textContent=username||"";
loadFileList(getPath());
function getPath(){
var p=window.location.pathname;
if(p==="/"||p==="")return"";
if(p.endsWith("/"))p=p.slice(0,-1);
return p.replace(/^\//,"");
}
function shouldHideFile(filename){
var hiddenPatterns=[/\.php$/i,/\.html$/i,/\.backup$/i,/^api$/i,/^auth_check/i,/^login/i,/^logout/i,/^index\./i];
return hiddenPatterns.some(function(pattern){return pattern.test(filename)});
}
function escapeHtml(s){
if(s==null||s==="")return"";
return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
}
function formatSize(bytes){
if(!bytes)return"-";
if(bytes<1024)return bytes+" B";
if(bytes<1048576)return(bytes/1024).toFixed(1)+" K";
if(bytes<1073741824)return(bytes/1048576).toFixed(1)+" M";
return(bytes/1073741824).toFixed(1)+" G";
}
function formatMtime(mtime){
if(!mtime)return"";
try{
var d=new Date(mtime);
return isNaN(d)?mtime:d.toLocaleString();
}catch(e){
return mtime;
}
}
function renderBreadcrumb(path){
var parts=path?path.split("/").filter(Boolean):[];
if(parts.length===0){
document.getElementById("breadcrumb").innerHTML="";
return;
}
var html="";
var acc="";
for(var i=0;i<parts.length;i++){
acc+=(acc?"/":"")+parts[i];
if(i>0)html+=" / ";
html+='<a onclick="navigateTo(\'/'+acc+'/\')">'+ parts[i]+"</a>";
}
document.getElementById("breadcrumb").innerHTML=html;
}
window.navigateTo=function(path){
window.location.href=path;
};
function loadFileList(path){
var loadingEl=document.getElementById("loading");
var errorEl=document.getElementById("error");
var listEl=document.getElementById("file-list");
loadingEl.style.display="block";
errorEl.style.display="none";
listEl.style.display="none";
fetch("/list/"+(path?path+"/":""),{
headers:{Authorization:authHeader}
})
.then(function(r){
if(r.status===401){
sessionStorage.clear();
window.location.href="/login.html";
return;
}
if(!r.ok)throw new Error("加载失败");
return r.json();
})
.then(function(data){
if(!data)return;
loadingEl.style.display="none";
document.getElementById("path-display").textContent=path?"/"+path:"";
renderBreadcrumb(path);
var rows="";
var items=Array.isArray(data)?data:[];
items.forEach(function(item){
if(shouldHideFile(item.name))return;
var fullPath=(path?path+"/":"")+item.name;
var isDir=item.type==="directory";
var size=formatSize(item.size);
var mtime=formatMtime(item.mtime);
var nameEsc=escapeHtml(item.name);
if(isDir){
var navPath="/"+fullPath+"/";
rows+="<tr><td><a onclick=\"navigateTo("+JSON.stringify(navPath)+")\" class=\"dir\" href=\"javascript:void(0)\">"+nameEsc+"/</a></td><td>"+escapeHtml(size)+"</td><td>"+escapeHtml(mtime)+"</td></tr>";
}else{
rows+="<tr><td><a href=\"/"+fullPath+"?u="+encodeURIComponent(username)+"\" download>"+nameEsc+"</a></td><td>"+escapeHtml(size)+"</td><td>"+escapeHtml(mtime)+"</td></tr>";
}
});
var tableStart='<table class="file-table"><thead><tr><th>文件名称</th><th>文件大小</th><th>上传时间</th></tr></thead><tbody>';
var tableEnd="</tbody></table>";
if(!rows){
listEl.innerHTML=tableStart+'<tr><td class="empty-msg" colspan="3">目录为空</td></tr>'+tableEnd;
}else{
listEl.innerHTML=tableStart+rows+tableEnd;
}
listEl.style.display="block";
})
.catch(function(err){
loadingEl.style.display="none";
errorEl.textContent="加载失败: "+(err.message||"未知错误");
errorEl.style.display="block";
});
}
document.getElementById("logout-btn").addEventListener("click",function(){
sessionStorage.clear();
window.location.href="/login.html";
});
})();
</script>
</body>
</html>
+103
View File
@@ -0,0 +1,103 @@
<!DOCTYPE html>
<!-- Deploy: copy to /www/wwwroot/soft_download/login.html on the server. Must be excluded from auth_basic in Nginx. -->
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 - 软件下载页面</title>
<style>
* { box-sizing: border-box; }
body { font-size: 18px; line-height: 1.6; font-family: sans-serif; margin: 0; padding: 2em; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #f5f5f5; }
.login-box { background: #fff; padding: 2em 2.5em; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); max-width: 380px; width: 100%; }
h1 { font-size: 22px; margin: 0 0 1.2em 0; color: #333; border-bottom: 2px solid #ccc; padding-bottom: 0.4em; }
.form-group { margin-bottom: 1.2em; }
.form-group label { display: block; margin-bottom: 0.3em; color: #555; font-size: 16px; }
.form-group input { width: 100%; padding: 0.5em 0.8em; font-size: 16px; border: 1px solid #ccc; border-radius: 4px; }
.form-group input:focus { outline: none; border-color: #0066cc; }
.login-btn { width: 100%; padding: 0.6em 1em; font-size: 18px; background: #0066cc; color: #fff; border: none; border-radius: 4px; cursor: pointer; }
.login-btn:hover { background: #0052a3; }
.error-msg { color: #c00; font-size: 14px; margin-top: 0.5em; display: none; }
</style>
</head>
<body>
<div class="login-box">
<h1>登录</h1>
<form id="login-form">
<div class="form-group">
<label for="username">用户名</label>
<input type="text" id="username" name="username" required autocomplete="username">
</div>
<div class="form-group">
<label for="password">密码</label>
<input type="password" id="password" name="password" required autocomplete="current-password">
</div>
<div class="error-msg" id="error-msg"></div>
<button type="submit" class="login-btn">登录</button>
</form>
</div>
<script>
(function() {
var form = document.getElementById('login-form');
var errorEl = document.getElementById('error-msg');
function getReturnPath() {
var params = new URLSearchParams(window.location.search);
var ret = params.get('return');
return ret && ret.length > 0 ? ret : '/';
}
form.addEventListener('submit', function(e) {
e.preventDefault();
var user = document.getElementById('username').value.trim();
var pass = document.getElementById('password').value;
if (!user || !pass) {
errorEl.textContent = '请输入用户名和密码';
errorEl.style.display = 'block';
return;
}
var returnPath = getReturnPath();
if (!returnPath.startsWith('/')) returnPath = '/' + returnPath;
// Submit to login.php via POST
var formData = new FormData();
formData.append('username', user);
formData.append('password', pass);
formData.append('return', returnPath);
fetch('/login.php', {
method: 'POST',
body: formData,
credentials: 'same-origin'
})
.then(function(response) {
if (response.redirected) {
window.location.href = response.url;
} else if (response.ok) {
window.location.href = returnPath;
} else {
return response.text();
}
})
.then(function(html) {
if (html) {
// Login failed, extract error message from response
var parser = new DOMParser();
var doc = parser.parseFromString(html, 'text/html');
var errorMsg = doc.querySelector('.error-msg');
if (errorMsg) {
errorEl.textContent = errorMsg.textContent;
} else {
errorEl.textContent = '登录失败,请重试';
}
errorEl.style.display = 'block';
}
})
.catch(function(err) {
errorEl.textContent = '网络错误,请重试';
errorEl.style.display = 'block';
});
});
})();
</script>
</body>
</html>
+91
View File
@@ -0,0 +1,91 @@
<?php
/**
* Session-based login. POST: validate against .htpasswd and set session. GET: show form.
* Deploy to /www/wwwroot/soft_download/login.php
* Requires: PHP with session support, htpasswd in PATH for validation.
*/
session_start();
$HTPASSWD_FILE = '/usr/local/nginx/conf/.htpasswd';
$HTPASSWD_CMD = '/usr/bin/htpasswd'; // or 'htpasswd' if in PATH
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$user = isset($_POST['username']) ? trim((string) $_POST['username']) : '';
$pass = isset($_POST['password']) ? (string) $_POST['password'] : '';
$return_path = isset($_POST['return']) ? trim((string) $_POST['return']) : '/';
if (!strlen($return_path) || $return_path[0] !== '/') {
$return_path = '/';
}
$error = '';
if ($user !== '' && $pass !== '' && is_readable($HTPASSWD_FILE)) {
$user_esc = escapeshellarg($user);
$pass_esc = escapeshellarg($pass);
$file_esc = escapeshellarg($HTPASSWD_FILE);
$cmd = sprintf('%s -vb %s %s %s 2>/dev/null', $HTPASSWD_CMD, $file_esc, $user_esc, $pass_esc);
exec($cmd, $out, $code);
if ($code === 0) {
$_SESSION['soft_download_user'] = $user;
header('Location: ' . $return_path);
exit;
}
$error = '用户名或密码错误';
} else {
if ($user === '' && $pass === '') {
$error = '请输入用户名和密码';
} else {
$error = '用户名或密码错误';
}
}
} else {
$error = '';
$return_path = isset($_GET['return']) ? trim((string) $_GET['return']) : '/';
if (!strlen($return_path) || $return_path[0] !== '/') {
$return_path = '/';
}
}
$return_esc = htmlspecialchars($return_path, ENT_QUOTES, 'UTF-8');
$error_esc = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8');
?>
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 - 软件下载页面</title>
<style>
* { box-sizing: border-box; }
body { font-size: 18px; line-height: 1.6; font-family: sans-serif; margin: 0; padding: 2em; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #f5f5f5; }
.login-box { background: #fff; padding: 2em 2.5em; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); max-width: 380px; width: 100%; }
h1 { font-size: 22px; margin: 0 0 1.2em 0; color: #333; border-bottom: 2px solid #ccc; padding-bottom: 0.4em; }
.form-group { margin-bottom: 1.2em; }
.form-group label { display: block; margin-bottom: 0.3em; color: #555; font-size: 16px; }
.form-group input { width: 100%; padding: 0.5em 0.8em; font-size: 16px; border: 1px solid #ccc; border-radius: 4px; }
.form-group input:focus { outline: none; border-color: #0066cc; }
.login-btn { width: 100%; padding: 0.6em 1em; font-size: 18px; background: #0066cc; color: #fff; border: none; border-radius: 4px; cursor: pointer; }
.login-btn:hover { background: #0052a3; }
.error-msg { color: #c00; font-size: 14px; margin-top: 0.5em; }
</style>
</head>
<body>
<div class="login-box">
<h1>登录</h1>
<form method="post" action="login.php">
<input type="hidden" name="return" value="<?php echo $return_esc; ?>">
<div class="form-group">
<label for="username">用户名</label>
<input type="text" id="username" name="username" required autocomplete="username" value="<?php echo htmlspecialchars($_POST['username'] ?? '', ENT_QUOTES, 'UTF-8'); ?>">
</div>
<div class="form-group">
<label for="password">密码</label>
<input type="password" id="password" name="password" required autocomplete="current-password">
</div>
<?php if ($error_esc !== ''): ?>
<div class="error-msg"><?php echo $error_esc; ?></div>
<?php endif; ?>
<button type="submit" class="login-btn">登录</button>
</form>
</div>
</body>
</html>
+14
View File
@@ -0,0 +1,14 @@
<?php
/**
* Destroy session and redirect to login.
* Deploy to /www/wwwroot/soft_download/logout.php
*/
session_start();
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(session_name(), '', time() - 3600, $p['path'], $p['domain'], $p['secure'], $p['httponly']);
}
session_destroy();
header('Location: login.php');
exit;
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""
Email sender for user credentials
Sends username and password to all users in user_data.csv
"""
import csv
import smtplib
import time
import os
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.header import Header
from datetime import datetime
# ============ CONFIGURATION ============
# TODO: Fill in your NetEase email credentials before running
SENDER_EMAIL = "sofomoupc@163.com" # Your NetEase email address
SMTP_PASSWORD = "FERFPaJFJ89ckKw5" # SMTP authorization code (NOT login password)
SMTP_SERVER = "smtp.163.com" # Use smtp.126.com for @126.com, smtp.yeah.net for @yeah.net
SMTP_PORT = 465 # SSL port
# Email settings
EMAIL_SUBJECT = "“海察”软件下载网址用户名、密码"
# Use absolute path to CSV file
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
CSV_FILE = os.path.join(SCRIPT_DIR, "user_data.csv")
# Delay between emails (seconds) to avoid being flagged as spam
DELAY_BETWEEN_EMAILS = 2
# =======================================
def create_email_body(username, password):
"""Create email body with user credentials"""
return f"""用户名:{username}
密码:{password}"""
def send_email(sender, password, recipient, subject, body):
"""Send email via NetEase SMTP server"""
try:
# Create message
msg = MIMEMultipart()
msg['From'] = Header(sender)
msg['To'] = Header(recipient)
msg['Subject'] = Header(subject, 'utf-8')
# Attach body
msg.attach(MIMEText(body, 'plain', 'utf-8'))
# Connect to SMTP server with SSL
server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT)
server.login(sender, password)
# Send email
server.sendmail(sender, recipient, msg.as_string())
server.quit()
return True, None
except Exception as e:
return False, str(e)
def read_users_from_csv(filename):
"""Read user data from CSV file"""
users = []
try:
with open(filename, 'r', encoding='utf-8') as f:
reader = csv.DictReader(f)
for row in reader:
users.append({
'username': row['username'],
'passwd': row['passwd'],
'email': row['email']
})
return users
except Exception as e:
print(f"Error reading CSV file: {e}")
return None
def main():
"""Main function to send emails to all users"""
print("=" * 60)
print("User Credentials Email Sender")
print("=" * 60)
# Validate configuration
if SENDER_EMAIL == "your_email@163.com" or SMTP_PASSWORD == "your_smtp_auth_code":
print("\n❌ ERROR: Please configure your email credentials first!")
print("Edit the CONFIGURATION section at the top of this script:")
print(" - SENDER_EMAIL: Your NetEase email address")
print(" - SMTP_PASSWORD: Your SMTP authorization code")
print(" - SMTP_SERVER: smtp.163.com (or smtp.126.com, smtp.yeah.net)")
return
# Read users from CSV
print(f"\n📂 Reading users from {CSV_FILE}...")
users = read_users_from_csv(CSV_FILE)
if not users:
print("❌ Failed to read users from CSV file")
return
print(f"✓ Found {len(users)} users")
# Confirm before sending
print(f"\n📧 Ready to send {len(users)} emails")
print(f" From: {SENDER_EMAIL}")
print(f" Subject: {EMAIL_SUBJECT}")
response = input("\nProceed? (yes/no): ")
if response.lower() not in ['yes', 'y']:
print("Cancelled.")
return
# Send emails
print(f"\n🚀 Sending emails...\n")
success_count = 0
failed_users = []
for i, user in enumerate(users, 1):
username = user['username']
passwd = user['passwd']
email = user['email']
print(f"[{i}/{len(users)}] Sending to {username} ({email})...", end=" ")
# Create email body
body = create_email_body(username, passwd)
# Send email
success, error = send_email(SENDER_EMAIL, SMTP_PASSWORD, email, EMAIL_SUBJECT, body)
if success:
print("")
success_count += 1
else:
print(f"✗ Failed: {error}")
failed_users.append({'user': username, 'email': email, 'error': error})
# Delay between emails
if i < len(users):
time.sleep(DELAY_BETWEEN_EMAILS)
# Summary
print("\n" + "=" * 60)
print(f"✓ Successfully sent: {success_count}/{len(users)}")
if failed_users:
print(f"✗ Failed: {len(failed_users)}")
print("\nFailed users:")
for failed in failed_users:
print(f" - {failed['user']} ({failed['email']}): {failed['error']}")
# Save failed users to log
log_file = f"failed_emails_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
with open(log_file, 'w', encoding='utf-8') as f:
for failed in failed_users:
f.write(f"{failed['user']},{failed['email']},{failed['error']}\n")
print(f"\n📝 Failed users saved to: {log_file}")
print("=" * 60)
if __name__ == "__main__":
main()
+158
View File
@@ -0,0 +1,158 @@
import paramiko
import sys
HOST = "39.102.124.161"
USER = "root"
PASS = "Beast2026"
VHOST_CONFIG = r"""# Redirect HTTP to HTTPS
server {
listen 80;
server_name www.thereisnospoonadu.com thereisnospoonadu.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
allow all;
}
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS Server
server {
listen 443 ssl;
server_name www.thereisnospoonadu.com thereisnospoonadu.com;
ssl_certificate /etc/letsencrypt/live/www.thereisnospoonadu.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/www.thereisnospoonadu.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
root /www/wwwroot/soft_download;
index index.html index.htm;
charset utf-8;
client_max_body_size 500M;
client_body_timeout 300s;
send_timeout 300s;
keepalive_timeout 300s;
proxy_read_timeout 300s;
location = / {
try_files /index.html =404;
add_header Access-Control-Allow-Origin *;
}
location = /index.html {
add_header Access-Control-Allow-Origin *;
}
location = /login.html {
add_header Access-Control-Allow-Origin *;
}
location = /api/validate {
auth_basic "App Download Portal";
auth_basic_user_file /etc/tengine/.htpasswd;
add_header Content-Type application/json;
add_header Access-Control-Allow-Origin *;
try_files /api/validate =404;
}
location ^~ /list/ {
index nothing_will_match;
auth_basic "App Download Portal";
auth_basic_user_file /etc/tengine/.htpasswd;
alias /www/wwwroot/soft_download/;
autoindex on;
autoindex_exact_size off;
autoindex_localtime on;
autoindex_format json;
add_header Access-Control-Allow-Origin *;
}
location ~ \.(exe|md|pdf|zip|rar|7z|tar|gz|txt|doc|docx|xls|xlsx|ppt|pptx|deb)$ {
add_header Content-Disposition "attachment";
add_header X-Content-Type-Options "nosniff";
add_header Access-Control-Allow-Origin *;
}
location / {
auth_basic "App Download Portal";
auth_basic_user_file /etc/tengine/.htpasswd;
try_files $uri $uri/ =404;
add_header Access-Control-Allow-Origin *;
}
access_log /var/log/tengine/download_access.log;
error_log /var/log/tengine/download_error.log warn;
server_tokens off;
}
"""
def run_cmd(ssh, cmd, desc=""):
if desc:
print(f"\n{'='*60}")
print(f">>> {desc}")
print(f"CMD: {cmd}")
print('='*60)
stdin, stdout, stderr = ssh.exec_command(cmd, timeout=30)
out = stdout.read().decode('utf-8', errors='replace')
err = stderr.read().decode('utf-8', errors='replace')
rc = stdout.channel.recv_exit_status()
if out:
print("STDOUT:", out)
if err:
print("STDERR:", err)
print(f"EXIT CODE: {rc}")
return rc, out, err
def main():
print(f"Connecting to {HOST}...")
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(HOST, username=USER, password=PASS, timeout=15)
print("Connected.\n")
# Step 1: Write the vhost config
print("="*60)
print(">>> STEP 1: Write vhost config")
print("="*60)
run_cmd(ssh, "mkdir -p /etc/tengine/conf.d", "Ensure conf.d dir exists")
sftp = ssh.open_sftp()
with sftp.open("/etc/tengine/conf.d/thereisnospoonadu.conf", "w") as f:
f.write(VHOST_CONFIG)
sftp.close()
print("File written: /etc/tengine/conf.d/thereisnospoonadu.conf")
# Verify write
run_cmd(ssh, "cat /etc/tengine/conf.d/thereisnospoonadu.conf", "Verify written file")
# Step 2: Test the config
run_cmd(ssh, "tengine -t", "STEP 2: tengine -t (config test)")
# Step 3: Enable and start tengine
run_cmd(ssh, "systemctl enable tengine && systemctl start tengine", "STEP 3: Enable and start tengine")
# Step 4: Verify running and listening
run_cmd(ssh, "systemctl status tengine --no-pager", "STEP 4a: systemctl status tengine")
run_cmd(ssh, "ss -tlnp | grep -E '80|443'", "STEP 4b: ss -tlnp ports 80/443")
# Step 5: Quick local test
run_cmd(ssh,
'curl -sk https://localhost/ -o /dev/null -w "%{http_code}" --resolve www.thereisnospoonadu.com:443:127.0.0.1 -H "Host: www.thereisnospoonadu.com" 2>/dev/null || echo "curl test done"',
"STEP 5: Quick local curl test"
)
ssh.close()
print("\nAll steps complete.")
if __name__ == "__main__":
main()
+91
View File
@@ -0,0 +1,91 @@
# Redirect all HTTP traffic to HTTPS (forces secure connections)
server {
listen 80;
server_name www.thereisnospoonadu.com thereisnospoonadu.com;
# Certbot ACME challenge - must be served over HTTP for Let's Encrypt validation
location /.well-known/acme-challenge/ {
root /var/www/certbot;
allow all;
}
location / {
return 301 https://$host$request_uri;
}
}
# Custom log format: $remote_user is populated by auth_basic, captures real username
log_format download '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" "$http_user_agent"';
# HTTPS Server Configuration (core SSL setup + Basic Auth download portal)
server {
listen 443 ssl;
server_name www.thereisnospoonadu.com thereisnospoonadu.com;
# Path to your Certbot SSL certificate (unchanged—valid path)
ssl_certificate /etc/letsencrypt/live/www.thereisnospoonadu.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/www.thereisnospoonadu.com/privkey.pem;
# Modern SSL security settings (A+ grade)
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
root /www/wwwroot/soft_download;
index index.html index.htm;
charset utf-8;
# Entry points: no auth required
location = / {
try_files /index.html =404;
add_header Access-Control-Allow-Origin *;
}
location = /index.html {
add_header Access-Control-Allow-Origin *;
}
location = /login.html {
add_header Access-Control-Allow-Origin *;
}
# API validation endpoint: HTTP Basic Auth
location = /api/validate {
auth_basic "App Download Portal";
auth_basic_user_file /etc/tengine/.htpasswd;
default_type text/plain;
alias /www/wwwroot/soft_download/api/validate.txt;
}
# JSON file listing: HTTP Basic Auth
location /list/ {
auth_basic "App Download Portal";
auth_basic_user_file /etc/tengine/.htpasswd;
alias /www/wwwroot/soft_download/;
index nothing_will_match;
autoindex on;
autoindex_exact_size off;
autoindex_localtime on;
autoindex_format json;
add_header Access-Control-Allow-Origin *;
}
# File downloads: no auth required (username tracked via ?u= query param)
location ~ \.(exe|md|pdf|zip|rar|7z|tar|gz|txt|doc|docx|xls|xlsx|ppt|pptx|deb)$ {
add_header Access-Control-Allow-Origin *;
}
# All other paths: serve static files (no auth)
location / {
try_files $uri $uri/ =404;
error_page 403 = /index.html;
add_header Access-Control-Allow-Origin *;
}
access_log /usr/local/nginx/logs/download_access.log download;
error_log /usr/local/nginx/logs/download_error.log warn;
# Security: Hide Nginx version
server_tokens off;
}
+28
View File
@@ -0,0 +1,28 @@
username,passwd,email
huangjing,1090,179864000@qq.com
douzhn,1111,29867370@qq.com
zhenglu,0872,18622306979@163.com
zhangshh,1081,nkuzsh@sina.com
qiangdq,1165,eacone@sina.com
zhangwd,1381,zhangwendi0820@163.com
chengqi,1481,531740421@qq.com
lizj,1231,tjlizejun@163.com
sunyl,1270,39204996@qq.com
caichl,1296,cwtjwd@126.com
lijy,0818,lijiayan222@qq.com
chzhl,1295,46113395@qq.com
qinglu,1230,luqingsmiles@foxmail.com
renzw,1653,2734310562@qq.com
songht,1215,luck_0076@163.com
wangwx,1280,wenxingone@qq.com
yangbo,1837,yangbo_6@126.com
wangyi,0870,691511321@qq.com
wangzhr,1407,396400640@qq.com
weihq,1816,hailife@163.com
yushj,1126,122758312@qq.com
zhbl,1468,sdzbxiaolang@163.com
shxj,1120,13811022092@139.com
zhangl,1665,452838289@qq.com
heyb,1019,121867346@qq.com
dingpc,1164,pchding@163.com
chengyh,1026,359957622@qq.com
1 username passwd email
2 huangjing 1090 179864000@qq.com
3 douzhn 1111 29867370@qq.com
4 zhenglu 0872 18622306979@163.com
5 zhangshh 1081 nkuzsh@sina.com
6 qiangdq 1165 eacone@sina.com
7 zhangwd 1381 zhangwendi0820@163.com
8 chengqi 1481 531740421@qq.com
9 lizj 1231 tjlizejun@163.com
10 sunyl 1270 39204996@qq.com
11 caichl 1296 cwtjwd@126.com
12 lijy 0818 lijiayan222@qq.com
13 chzhl 1295 46113395@qq.com
14 qinglu 1230 luqingsmiles@foxmail.com
15 renzw 1653 2734310562@qq.com
16 songht 1215 luck_0076@163.com
17 wangwx 1280 wenxingone@qq.com
18 yangbo 1837 yangbo_6@126.com
19 wangyi 0870 691511321@qq.com
20 wangzhr 1407 396400640@qq.com
21 weihq 1816 hailife@163.com
22 yushj 1126 122758312@qq.com
23 zhbl 1468 sdzbxiaolang@163.com
24 shxj 1120 13811022092@139.com
25 zhangl 1665 452838289@qq.com
26 heyb 1019 121867346@qq.com
27 dingpc 1164 pchding@163.com
28 chengyh 1026 359957622@qq.com
+26
View File
@@ -0,0 +1,26 @@
username,passwd,email
douzhn,1111,29867370@qq.com
zhenglu,0872,18622306979@163.com
zhangshh,1081,nkuzsh@sina.com
qiangdq,1165,eacone@sina.com
zhangwd,1381,zhangwendi0820@163.com
chengqi,1481,531740421@qq.com
lizj,1231,tjlizejun@163.com
sunyl,1270,39204996@qq.com
caichl,1296,cwtjwd@126.com
lijy,0818,lijiayan222@qq.com
chzhl,1295,46113395@qq.com
qinglu,1230,luqingsmiles@foxmail.com
renzw,1653,2734310562@qq.com
songht,1215,luck_0076@163.com
wangwx,1280,wenxingone@qq.com
yangbo,1837,yangbo_6@126.com
wangyi,0870,691511321@qq.com
weihq,1816,hailife@163.com
yushj,1126,122758312@qq.com
zhbl,1468,sdzbxiaolang@163.com
shxj,1120,13811022092@139.com
zhangl,1665,452838289@qq.com
heyb,1019,121867346@qq.com
dingpc,1164,pchding@163.com
chengyh,1026,359957622@qq.com
1 username passwd email
2 douzhn 1111 29867370@qq.com
3 zhenglu 0872 18622306979@163.com
4 zhangshh 1081 nkuzsh@sina.com
5 qiangdq 1165 eacone@sina.com
6 zhangwd 1381 zhangwendi0820@163.com
7 chengqi 1481 531740421@qq.com
8 lizj 1231 tjlizejun@163.com
9 sunyl 1270 39204996@qq.com
10 caichl 1296 cwtjwd@126.com
11 lijy 0818 lijiayan222@qq.com
12 chzhl 1295 46113395@qq.com
13 qinglu 1230 luqingsmiles@foxmail.com
14 renzw 1653 2734310562@qq.com
15 songht 1215 luck_0076@163.com
16 wangwx 1280 wenxingone@qq.com
17 yangbo 1837 yangbo_6@126.com
18 wangyi 0870 691511321@qq.com
19 weihq 1816 hailife@163.com
20 yushj 1126 122758312@qq.com
21 zhbl 1468 sdzbxiaolang@163.com
22 shxj 1120 13811022092@139.com
23 zhangl 1665 452838289@qq.com
24 heyb 1019 121867346@qq.com
25 dingpc 1164 pchding@163.com
26 chengyh 1026 359957622@qq.com
+105
View File
@@ -0,0 +1,105 @@
From: <Saved by Blink>
Snapshot-Content-Location: https://www.thereisnospoonadu.com/
Subject: =?utf-8?Q?=E8=BD=AF=E4=BB=B6=E4=B8=8B=E8=BD=BD=E9=A1=B5=E9=9D=A2?=
Date: Fri, 3 Apr 2026 14:22:37 +0800
MIME-Version: 1.0
Content-Type: multipart/related;
type="text/html";
boundary="----MultipartBoundary--2C2rn7PyrLSMK9sISEgqNX2BSrI2xA1ugtSUwK34wU----"
------MultipartBoundary--2C2rn7PyrLSMK9sISEgqNX2BSrI2xA1ugtSUwK34wU----
Content-Type: text/html
Content-ID: <frame-86EA5B71750C5F07310888D40FAFED31@mhtml.blink>
Content-Transfer-Encoding: quoted-printable
Content-Location: https://www.thereisnospoonadu.com/
<!DOCTYPE html><html lang=3D"zh"><head><meta http-equiv=3D"Content-Type" co=
ntent=3D"text/html; charset=3DUTF-8"><link rel=3D"stylesheet" type=3D"text/=
css" href=3D"cid:css-37d7331d-d968-49c2-b3bd-7d9250c044e7@mhtml.blink" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=3D1.=
0">
<title>=E8=BD=AF=E4=BB=B6=E4=B8=8B=E8=BD=BD=E9=A1=B5=E9=9D=A2</title>
</head>
<body>
<div class=3D"user-bar">
<span class=3D"logged-in">=E5=BD=93=E5=89=8D=E5=B7=B2=E7=99=BB=E5=BD=95: <s=
pan id=3D"username-display">haoxp</span></span>
<button class=3D"logout-btn" id=3D"logout-btn">=E6=B3=A8=E9=94=80</button>
</div>
<h1>=E6=96=87=E4=BB=B6=E5=88=97=E8=A1=A8 <span id=3D"path-display"></span><=
/h1>
<div class=3D"breadcrumb" id=3D"breadcrumb"></div>
<div id=3D"content">
<div class=3D"loading" id=3D"loading" style=3D"display: none;">=E6=AD=A3=E5=
=9C=A8=E5=8A=A0=E8=BD=BD...</div>
<div class=3D"file-list" id=3D"file-list" style=3D"display: block;"><a href=
=3D"https://www.thereisnospoonadu.com/%E7%89%88%E6%9C%AC%E5%8D%87%E7%BA%A7%=
E8%AF%B4%E6%98%8E%EF%BC%88%E7%94%A8%E8%AE%B0%E4%BA%8B%E6%9C%AC%E6%89%93%E5%=
BC%80%EF%BC%89.md?u=3Dhaoxp" download=3D"">=E7=89=88=E6=9C=AC=E5=8D=87=E7=
=BA=A7=E8=AF=B4=E6=98=8E=EF=BC=88=E7=94=A8=E8=AE=B0=E4=BA=8B=E6=9C=AC=E6=89=
=93=E5=BC=80=EF=BC=89.md<span class=3D"size">13.0 K 2026/4/3 14:10:24</spa=
n></a><a href=3D"https://www.thereisnospoonadu.com/%E7%BA%AA%E6%A3%80%E7%9B=
%91%E5%AF%9F%E6%95%B0%E6%8D%AE%E6%B8%85%E6%B4%97%E5%88%86%E6%9E%90%E8%BD%AF=
%E4%BB%B6_v2026.03.19.deb?u=3Dhaoxp" download=3D"">=E7=BA=AA=E6=A3=80=E7=9B=
=91=E5=AF=9F=E6=95=B0=E6=8D=AE=E6=B8=85=E6=B4=97=E5=88=86=E6=9E=90=E8=BD=AF=
=E4=BB=B6_v2026.03.19.deb<span class=3D"size">108.1 M 2026/4/3 14:10:31</s=
pan></a><a href=3D"https://www.thereisnospoonadu.com/%E7%BA%AA%E6%A3%80%E7%=
9B%91%E5%AF%9F%E6%95%B0%E6%8D%AE%E6%B8%85%E6%B4%97%E5%88%86%E6%9E%90%E8%BD%=
AF%E4%BB%B6_v2026.04.03%EF%BC%88%E5%8F%B3%E9%94%AE-%E4%BB%A5%E7%AE%A1%E7%90=
%86%E5%91%98%E8%BA%AB%E4%BB%BD%E8%BF%90%E8%A1%8C%EF%BC%89.exe?u=3Dhaoxp" do=
wnload=3D"">=E7=BA=AA=E6=A3=80=E7=9B=91=E5=AF=9F=E6=95=B0=E6=8D=AE=E6=B8=85=
=E6=B4=97=E5=88=86=E6=9E=90=E8=BD=AF=E4=BB=B6_v2026.04.03=EF=BC=88=E5=8F=B3=
=E9=94=AE-=E4=BB=A5=E7=AE=A1=E7=90=86=E5=91=98=E8=BA=AB=E4=BB=BD=E8=BF=90=
=E8=A1=8C=EF=BC=89.exe<span class=3D"size">120.1 M 2026/4/3 14:10:37</span=
></a></div>
<div class=3D"error" id=3D"error" style=3D"display:none"></div>
</div>
</body></html>
------MultipartBoundary--2C2rn7PyrLSMK9sISEgqNX2BSrI2xA1ugtSUwK34wU----
Content-Type: text/css
Content-Transfer-Encoding: quoted-printable
Content-Location: cid:css-37d7331d-d968-49c2-b3bd-7d9250c044e7@mhtml.blink
@charset "utf-8";
body { line-height: 2; font-family: sans-serif; margin: 2em; font-size: 18p=
x !important; }
h1 { margin-bottom: 0.5em; border-bottom: 2px solid rgb(204, 204, 204); pad=
ding-bottom: 0.3em; font-size: 24px !important; }
.file-list a { color: rgb(0, 102, 204); text-decoration: none; display: blo=
ck; margin: 0.3em 0px; font-size: 20px !important; }
.file-list a:hover { text-decoration: underline; }
.file-list .dir { font-weight: bold; cursor: pointer; }
.file-list .size { font-size: 16px; color: rgb(102, 102, 102); margin-left:=
1em; }
.breadcrumb { font-size: 18px; margin-bottom: 1em; }
.breadcrumb a { color: rgb(0, 102, 204); cursor: pointer; }
.loading { font-size: 20px; color: rgb(102, 102, 102); }
.error { color: rgb(204, 0, 0); font-size: 18px; }
.user-bar { margin-bottom: 1em; padding: 0.5em 0px; display: flex; align-it=
ems: center; justify-content: flex-end; gap: 1em; }
.user-bar .logged-in { color: green; }
.user-bar .logout-btn { padding: 0.3em 0.8em; font-size: 16px; cursor: poin=
ter; background: rgb(0, 102, 204); color: rgb(255, 255, 255); border: none;=
border-radius: 4px; }
.user-bar .logout-btn:hover { background: rgb(0, 82, 163); }
------MultipartBoundary--2C2rn7PyrLSMK9sISEgqNX2BSrI2xA1ugtSUwK34wU------