c
This commit is contained in:
+770
-134
File diff suppressed because one or more lines are too long
-276
@@ -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")
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
@@ -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>
|
||||
Binary file not shown.
@@ -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).
|
||||
@@ -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 "—"
|
||||
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 "—"
|
||||
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 — thereisnospoonadu.com</h1>
|
||||
<span class="refresh-note">Last updated: {now} | 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()
|
||||
@@ -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;
|
||||
@@ -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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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------
|
||||
Reference in New Issue
Block a user