Update to v8.16.0

This commit is contained in:
aapanel.com
2026-07-06 09:16:25 +08:00
parent 88012af9cf
commit c529749cf8
2034 changed files with 122712 additions and 38986 deletions
+231
View File
@@ -0,0 +1,231 @@
---
name: aaPanel Site Design
description: >
Design a website's UI spec and author a complete static front-end (HTML/CSS/JS) — the model
picks a style, fetches the UI spec, and writes clean static pages. The artifact is a static
file set under the project dir (draft → generated). Use it to:
design a website / landing page / portfolio / official site / business site / personal site /
company homepage / brand site. Scope: UI design +
static front-end ONLY (HTML/CSS/JS). Available in website-type sessions via the Skills tool (chat_type=website). NOT for: WordPress (use
WordPress, empty/bare sites, deploying user-supplied source code, or any non-UI panel task —
these are refused with a pointer to a chat session (this skill never exits the website session).
---
# aaPanel Site Design
> **Single Source of Truth**: tool-usage limits (ProjectSave only for info docs; no Write/cp/echo on them; memory ProjectSave only, no NoteSave/DocSave; Hard Red Lines scope) live in the **website system_prompt** (`dynamic.py designer_prompts`, `prompts_name="website"`) — prompts are maintained centrally in `dynamic.py`. This document governs the **design workflow** (S0S3, state machine, iteration). Changes to those limits must be made in `dynamic.py`, not here.
> **STOP. Read this document completely before doing anything.** Do NOT skip sections.
> **1 session = 1 project**, bound under `memories/projects/<id>/` — this skill drives that project's design workflow (S0S3). Scope, forbiddens, and Hard Red Lines are in the system prompt.
---
## Core Rules
1. **UI design + static front-end.** Always a **single-page UI** (one page, all
sections + anchor nav; never multi-page). CSS hand-written inlined in `<style>`, JS inlined in
`<script>`; all assets CDN-linked, zero framework, zero JS runtime (Lucide icons excepted).
2. **Three writers, three jobs** (workflow spine): `ProjectSave` → project info docs + `meta_snapshot` + session binding; `Write` → the UI artifact; `RunCommand` → shell (`curl` style-spec fetch, image search). Info docs always overwrite with current factual content (not a changelog), on BOTH init AND iteration. **Strict per-writer limits live in the system prompt.**
3. **One session = one project.** `project_id` is set at S2 and never changes; route by project
existence (ITERATE vs NEW).
4. **website session only** — runtime-enforced (chat sessions cannot load this skill); no check needed.
5. **Track progress with `TodoWrite`** — create the task list before set-up, using **neutral step titles** (no internal phase codes or file names), e.g. *Plan · Recommend a style · Set up the project · Fetch images · Build the UI*; mark one `in_progress` at a time, flip to `completed` when done.
6. **Iteration resets to `generated`.** ANY write to the UI (re-generate) forces `status→generated`;
sync `meta_snapshot` accordingly.
7. **Iteration spec judgment (autonomous).** Decide by the nature of the change (both overwrite
the progress doc with current factual progress):
- **Light edit** (color / copy / image swap) → edit the UI only; do NOT touch the spec / the design. Then `ProjectSave(project_id=<id>, files={"meta.md": <full, status=generated>, "progress.md": <full>}, meta_snapshot={"status":"generated"})`.
- **Heavy edit** (style / layout / design system) → **re-fetch the style spec** (curl -o overwrite from the new stem) + rewrite the design + regenerate the UI, then `ProjectSave(project_id=<id>, files={"meta.md": <full, style field updated + status=generated>, "design.md": <full new>, "progress.md": <full>}, meta_snapshot={"style": <new-stem>, "status":"generated"})`. No new session needed.
8. **UI spec = remote fetch + persist**: at S2 download the chosen style's design spec from
`https://node.aapanel.com/aapanel/awesome/<Style-File>.md` (fallback host `jp1-node.aapanel.com`)
and save it as the project's style spec — a self-contained copy. After that the project never
needs the network again. See S1 for the style-file list, S2 step 2 for the fetch.
9. **Every image slot gets a real photo** — fetch via the skill script BEFORE writing HTML (see S3 Images).
---
## Tooling
| step | tools (risk) |
|---|---|
| Load skill | `Skills` (low) |
| S0 Route | `TodoWrite` (low) · `Read` (low, iterate only) |
| S1 Style | — (chat-only; catalog self-contained) |
| S2 Init | `RunCommand` (high, curl style-spec) · `ProjectSave` (low, internal) · `TodoWrite` (low) |
| S3 Generate | `Read` (low) · `RunCommand` (high, images first) · `Write` (high) · `ProjectSave` (low) · `TodoWrite` (low) |
---
## State Machine
```
draft ──S3 generate──→ generated
↑ │
└─ S3 iterate ──┘ re-generate forces generated
```
- `draft`: project init done, artifact not yet generated.
- `generated`: the static UI artifact has been written.
- Any UI rewrite (S3 iteration) → forces `generated` (Core Rules #6).
---
## S0 — Intent + Route
> Session type is runtime-enforced (chat sessions cannot load this skill) — reaching here means a
> website session. No session check needed.
**Scope & route** — one decision tree:
**Out of scope → refuse.** Scope + refusal wording live in the system prompt (UI design only; deploying THIS skill's generated artifact is the sole in-scope exception — only on explicit user request + Hard Red Lines consent; anything else — WordPress, empty/bare site, foreign source code, SSL/DB/firewall/service/file management — refuse and point the user to a new chat session, never exit). Route the in-scope request below.
**In scope → route by project existence** (check the injected `## Active project` block for current
`project_id` + `status`):
- **ITERATE** (project exists): `Read` the project info (meta for `status`, design, progress) + the style spec + the current UI. Skip S1/S2 — style and `project_id` are set. Pick up by `status`:
- `draft` → read the existing UI to see what's done, then regenerate at S3 (overwrite it, preserving finished sections). If the UI is absent (init crashed mid-S2): **verify the style spec is complete** (Read it; if truncated — size well under ~30KB or cuts off mid-token — re-fetch via `curl -o`); **verify the design is well-formed** (if half-written, rewrite it); then generate fresh at S3.
- `generated` → apply the iteration spec judgment at S3 (Core Rules #7).
- Either way, Core Rules #7 decides at S3: re-fetch the spec (heavy) or edit the UI only (light).
Listen for what the user wants changed (or ask one focused question), then go to S3. For a
brand-new project, tell them to start a new website session (Core Rules #4).
- **NEW** (no current project): extract **topic/subject** + **purpose** from the user's one line. If the intent is thin (you can't yet say what the site is for and who it's for), **keep asking focused questions to draw it out — do NOT jump to init (S2) on a guess**. One question at a time, plain language, never an interrogation. Only once topic + purpose are clear enough for a one-line summary → S1 → S2 → S3.
---
## S1 — Style Recommendation (before project init, chat-only)
Recommend **autonomously by intent** from the **Full Style Catalog** below (74 neutral style files) —
you are NOT bound to any fixed subset, and this is independent of any front-end style API. Pick **23**
that fit the user's topic / industry, present each as `file — one-line tone — best-for hint`. If the
user doesn't like them, swap in a fresh batch from the catalog.
- Present 23; user picks one, swaps, or says "you decide" (you pick + say why).
- The user may also name any style file directly (e.g. "use `Emerald-Devtool`") — accept verbatim.
- Record the chosen **style file stem** (no `.md` suffix, e.g. `Brand-Marketing`) → it is the
the project meta's `style` value. At S2 the fetch URL appends `.md`: `.../awesome/<stem>.md`.
### Full Style Catalog — 74 style files (hardcoded list below; fetch any as `<file>.md`)
| Theme | Style files |
|---|---|
| **Dev / AI tools** | `AI-Developer` `AI-Lab` `Agent-Engineering` `Refined-AI` `Sunset-AI` `Gradient-AI` `Frontier-AI` `Gradient-Infra` `Dark-Devtool` `Editorial-Devtool` `Emerald-Devtool` `Gradient-Devtool` `Midnight-Devtool` `Terminal-Devtool` `Warm-Devtool` `Terminal-Mono` `Docs-Platform` `Developer-Platform` `Dark-Builder` `Visual-Builder` `App-Showcase` `Content-Platform` `Playful-Devtool` |
| **Minimal / Tech** | `Minimal-Tech` `Minimal-Docs` `Software-Craft` `Engineering-Tech` `Aerospace-Mission` `Tech-Corporate` `Enterprise` `Tech-Magazine` |
| **Data / DB** | `Dark-Database` `Dual-Database` `Vibrant-Data` |
| **Finance / Crypto** | `Crypto-Finance` `Institutional-Fintech` `Dark-Crypto` `Gradient-Finance` `Fintech-Brochure` `Fintech-Editorial` `Financial-Brand` |
| **Auto / Mobility** | `Corporate-Auto` `Motorsport-Engineering` `Luxury-Automotive` `Cinematic-Luxury` `Supercar-Luxury` `Auto-Editorial` `Mobility-Mono` |
| **Commerce / Retail** | `E-Commerce` `Cinematic-Commerce` `Product-Commerce` `Retail-Lifestyle` |
| **Brand / Editorial / Media** | `Brand-Marketing` `Console-Marketing` `Telecom-Editorial` `Magazine-Editorial` `Voice-Magazine` `Warm-Editorial` `Productivity-Editorial` `Editorial-Workflow` `Museum-Gallery` `Studio-Monochrome` `Mono-Serif` `Generative-Studio` `Retro-Gaming` `Retro-Web` `Music-Streaming` |
| **Workspace / SaaS** | `Friendly-SaaS` `Customer-SaaS` `Playful-Workspace` `Illustrated-Workspace` `Workplace-Messaging` `Visual-Discovery` `Workflow-Warm` |
---
## S2 — Init Project (the moment style is chosen)
> Init when topic + style are both settled. Not earlier (empty shell), not later (nowhere to put files).
1. **Generate `project_id`**: `snake_case` topic + timestamp suffix `%Y%m%d%H%M%S` (e.g. `photo_studio_20260630120000`). Must match `^[a-z0-9_]+(?:_[a-z0-9]+)*$` (underscores only; no consecutive/trailing). This is the only id — no separate uuid.
2. **Fetch `ui_spec.md`** (remote style spec → write directly to disk, do NOT relay through the model):
`mkdir -p /www/server/panel/data/agent/memories/projects/<id>/site && curl -fsSL --create-dirs https://node.aapanel.com/aapanel/awesome/<stem>.md -o /www/server/panel/data/agent/memories/projects/<id>/ui_spec.md`
(`<stem>` from S1, e.g. `Brand-Marketing`). The `-o` writes the ~31KB spec straight to the project
dir — it never enters the model context, so no truncation/distortion. Try `node` first; on failure
retry once on `jp1-node.aapanel.com`; only if both fail, tell the user and pause. `Read` it back
(large file ~31KB — use a large `limit`) only when you need its tokens for S3.
3. **Build `meta.md`** with this exact flat frontmatter (parsed as plain `key: value` — no YAML lists/nesting;
`status` is the single field the active-project scan filters on):
```
---
id: photo_studio_20260630120000
subject: Photography Studio Official Site
industry: photography / visual
style: Brand-Marketing
status: draft
---
# Project meta — authoritative source
One-paragraph positioning. Sections planned. Current phase.
```
- `status` MUST be one of: `draft`, `generated`.
4. **Build `design.md`**: positioning + section tree + per-section outline + component list. It **references**
`ui_spec.md` ("visual spec = see ui_spec.md") and does NOT duplicate it.
5. **Build `progress.md`**: phases S0S3 each marked pending; note current = S2 done.
6. **Persist project info + bind session** — ONE `ProjectSave` call (batch all info docs + binding together):
`ProjectSave(project_id=<id>, files={"meta.md": <full content>, "design.md": <full>, "progress.md": <full>}, meta_snapshot={"status": "draft", "subject": <topic>, "style": <stem>})`.
This binds the session as a website session. (Per-writer limits — ProjectSave only for info docs — are in the system prompt.)
7. Update `TodoWrite` (set-up complete), advance to build the UI.
---
## S3 — Generate Static Artifact (`draft` → `generated`)
Generate the pure-static UI. **Always a single-page UI** (all sections + anchor nav) — one page, never multi-page. Everything is
**AI-authored + CDN-linked — zero local asset files**.
**Generation order (do not reorder):**
1. Read the style spec for tokens (colors / type / components) — **large spec (~31KB): use a large `limit` (e.g. 3000+) to avoid truncation; read what you need, not necessarily all**.
2. **Fetch images first** — run the skill script below for EVERY image slot, collect the direct
image URLs. You must have these URLs in hand BEFORE writing the UI (Core Rules #9).
3. Write the UI with all CSS inlined in `<style>` and JS inlined in `<script>` — no external files.
Do NOT author the UI with placeholder / empty image `src` intending to "fill in later" — fetch first, then author.
### Iteration spec judgment (autonomous — Core Rules #7)
On re-entry (artifact already exists), judge the change:
- **Light edit** (color / copy / image swap) → edit the UI only; do NOT touch the spec / the design.
- **Heavy edit** (new style / layout / design system) → re-fetch the style spec (`curl -o` overwrite from
the same or a new `<stem>.md`) + rewrite the design, then regenerate the UI.
Either way → `status→generated` (Core Rules #6).
### Asset policy — all external links, nothing local
- **CSS**: hand-write per the style spec's tokens (colors / typography / components / spacing) as `:root`
CSS variables + structured rules, all inlined in `<style>` inside the UI. No CSS framework.
The UI spec is **internalized into CSS variables** (tokens → `:root`).
- **Fonts**: Google Fonts via `<link>`. Map by **type**, NOT by the spec's font name — the fetched
The style spec uses neutral fictitious names (e.g. `Brand Marketing Cereal VF`) that exist on no CDN.
Translate the spec's *sans / serif / mono + weight* to reachable Google Fonts, then a system fallback:
| spec type | Google Font (primary → alt → fallback) |
|---|---|
| sans | Inter → Manrope → system-ui |
| serif | Playfair Display → Merriweather → Georgia |
| mono | JetBrains Mono → Fira Code → monospace |
**Weight map**: Thin 100 · Light 300 · Regular 400 · Medium 500 · SemiBold 600 · Bold 700 · Black 900.
Always end the CSS `font-family` stack with the system fallback (e.g. `'Inter', system-ui, -apple-system, sans-serif`).
- **Icons**: Lucide via CDN — `<script src="https://unpkg.com/lucide@latest"></script>`, mark icons with
`<i data-lucide="<name>"></i>`, then call `lucide.createIcons()` after DOM ready. (Pin a specific
version like `lucide@0.460.0` instead of `@latest` if reproducibility matters.)
- **Images**: direct links via the skill script — see below.
### Images (MANDATORY, Core Rules #9)
Every image slot gets a real photo — fetch via the skill script BEFORE writing HTML (generation order step 2). Call it **once**, one `--block` per image group. The script path, full command, parameter format (incl. `primary`/`secondary` main+fallback), and output format are all in the **system prompt** — run it directly and fill `<img src>` from the `=== label ===` output.
**On repeated failure (both queries empty): drop that slot and render text-only — do not keep retrying or substitute placeholders. If ALL slots come back empty, stop and tell the user the image service is unavailable.**
### Persist + transition
- The UI artifact was already written in step 3 — no extra write needed.
- On completion: `ProjectSave(project_id=<id>, files={"meta.md": <full content>, "progress.md": <full>}, meta_snapshot={"status": "generated"})` (full overwrite; design.md unchanged since S2).
- Mark the UI build done in `TodoWrite`.
- **Then offer 2 next steps** (every generation): **preview** the UI · **iterate** (request changes). **Deployment (going live) is NOT a default suggestion** — proceed ONLY when the user explicitly asks, and ONLY after Hard Red Lines consent; at most a one-line nudge when they signal intent to publish, never push it. When it proceeds, use panel tools (`SiteCreate`/`RunCommand`); ensure every deployed file is owned `www:www` and mode `644` (directories `755`) — it does NOT exit the session, does NOT bind/track any address.
---
## Resume & Progress
- **The progress doc is the project's factual progress — the source of truth across sessions.** Every phase transition + iteration writes it (phase + iteration history). On re-entry, read it to know exactly where the project stands; it is authoritative — more precise than `status` alone.
- **Entry routing is the S0 route** (NEW vs ITERATE by project existence). On any re-entry — same session
resumed or a fresh turn — re-run the S0 route: if the project exists, `Read` the project meta for `status` and follow
the ITERATE branch (status tells you where to pick up).
---
## Output Format
Report to the user in plain natural language:
```
Style chosen: <name> — <tone>
Sections: <list>
Status: <draft | generated>
Artifact: the project UI (open to preview)
Next: preview · iterate (request changes)
```
@@ -0,0 +1,186 @@
import argparse, json, os, sys, time, urllib.error, urllib.parse, urllib.request
from concurrent.futures import ThreadPoolExecutor
CLIENT_ID = os.environ.get("UNSPLASH_CLIENT_ID", "")
if not CLIENT_ID:
print("ERROR: UNSPLASH_CLIENT_ID env not set (comMod should inject it)", file=sys.stderr)
sys.exit(2)
API = "https://api.unsplash.com/search/photos"
ORIENTATIONS = {"landscape", "portrait", "squarish"}
RETRIES = 3
RETRY_SLEEP = 1
RETRY_CAP = 8
FETCH_TIMEOUT = 6
HEAD_TIMEOUT = 3
HEAD_RETRIES = 1
PEXELS_API = "https://api.pexels.com/v1/search"
PEXELS_KEY = os.environ.get("PEXELS_API_KEY", "")
PEXELS_SIZE_W = {"regular": 1080, "small": 400}
PEXELS_ORIENT = {"landscape": "landscape", "portrait": "portrait", "squarish": "square"} # squarish -> square
def _backoff(i):
return min(RETRY_SLEEP * (2 ** i), RETRY_CAP)
def _fetch_json(url, headers=None):
for i in range(RETRIES + 1):
try:
req = urllib.request.Request(url, headers=headers or {})
with urllib.request.urlopen(req, timeout=FETCH_TIMEOUT) as r:
return json.load(r)
except urllib.error.HTTPError as e:
if (e.code == 429 or 500 <= e.code < 600) and i < RETRIES:
time.sleep(_backoff(i))
continue
print(f"ERROR: HTTP {e.code} {e.reason}", file=sys.stderr)
return None
except urllib.error.URLError as e:
if i < RETRIES:
time.sleep(_backoff(i))
continue
print(f"ERROR: network failed after {RETRIES + 1} tries: {e.reason}", file=sys.stderr)
return None
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
return None
return None
def _head_ok(url):
for i in range(HEAD_RETRIES + 1):
try:
req = urllib.request.Request(url, method="HEAD")
if urllib.request.urlopen(req, timeout=HEAD_TIMEOUT).getcode() == 200:
return True
except Exception:
if i < HEAD_RETRIES:
time.sleep(_backoff(i))
continue
return False
def _search(query, size, count, orientation):
url = API + "?" + urllib.parse.urlencode({
"query": query, "per_page": count,
"orientation": orientation, "client_id": CLIENT_ID})
data = _fetch_json(url)
if data is None:
return []
if "errors" in data:
print(f"ERROR: [{query}] API: {data['errors']}", file=sys.stderr)
return []
out = []
for p in data.get("results", []):
img = p.get("urls", {}).get(size, "")
alt = (p.get("alt_description") or "").replace("\t", " ").replace("\n", " ").replace("\r", " ").strip()
if not img:
continue
if _head_ok(img):
out.append((img, alt))
return out
def _search_pexels(query, size, count, orientation):
# Pexels fallback: Unsplash 无结果/失败时调用. key 缺失静默跳过.
if not PEXELS_KEY:
return []
url = PEXELS_API + "?" + urllib.parse.urlencode({
"query": query, "per_page": count,
"orientation": PEXELS_ORIENT.get(orientation, orientation)})
data = _fetch_json(url, {"Authorization": PEXELS_KEY, "User-Agent": "site_design-images/1.0"})
if data is None:
return []
if data.get("errors"):
print(f"ERROR: [{query}] Pexels: {data['errors']}", file=sys.stderr)
return []
w = PEXELS_SIZE_W.get(size, 1080)
out = []
for p in data.get("photos", []):
original = p.get("src", {}).get("original", "")
if not original:
continue
img = f"{original}?auto=compress&cs=tinysrgb&w={w}"
alt = (p.get("alt") or "").replace("\t", " ").replace("\n", " ").replace("\r", " ").strip()
if _head_ok(img):
out.append((img, alt))
return out
def fetch_block(primary, secondary, size, count, orientation):
for q in (primary, secondary):
if not q:
continue
found = _search(q, size, count, orientation)
if found:
return found
# fallback
for q in (primary, secondary):
if not q:
continue
found = _search_pexels(q, size, count, orientation)
if found:
return found
return []
def parse_block(s):
p = [x.strip() for x in s.split("|")]
if len(p) < 5:
raise ValueError(f'--block needs label|primary|secondary|size|count (got: {s})')
if len(p) > 6:
raise ValueError(f'too many fields ({len(p)}): queries must not contain "|" (got: {s})')
label, primary, secondary, size, count = p[0], p[1], p[2], p[3], p[4]
orientation = p[5] if len(p) > 5 else "landscape"
if not label:
raise ValueError("label (1st field) must not be empty")
if not primary and not secondary:
raise ValueError("primary and secondary both empty — need at least one query")
if size not in ("regular", "small"):
raise ValueError(f"size must be regular|small (got {size})")
if orientation not in ORIENTATIONS:
raise ValueError(f"orientation must be landscape|portrait|squarish (got {orientation})")
try:
count = int(count)
except ValueError:
raise ValueError(f"count must be int (got {count})")
if count < 1:
raise ValueError(f"count must be >= 1 (got {count})")
count = min(count, 30)
return label, primary, secondary, size, count, orientation
def main():
ap = argparse.ArgumentParser(description="Multi-block image search for Site Design skill")
ap.add_argument("--block", action="append", required=True,
metavar='"label|primary|secondary|size|count[|orientation]"',
help="one per image block; primary first, secondary as fallback")
a = ap.parse_args()
blocks = []
for s in a.block:
try:
blocks.append(parse_block(s))
except ValueError as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(2)
def _run(b):
label, primary, secondary, size, count, orientation = b
try:
return label, fetch_block(primary, secondary, size, count, orientation)
except Exception as e:
print(f"ERROR: [{label}] {e}", file=sys.stderr)
return label, []
with ThreadPoolExecutor(max_workers=min(8, len(blocks))) as ex:
results = list(ex.map(_run, blocks))
for label, rows in results:
print(f"=== {label} ===")
for img, alt in rows:
print(f"{img}\t{alt}")
if __name__ == "__main__":
main()
@@ -0,0 +1,255 @@
---
name: aaPanel Website-Troubleshoot
description: >
Diagnose and fix aaPanel website access problems and service start failures.
Use this skill when users report: site not opening, 403/404/500/502/503 errors,
blank/white page, database connection errors, nginx or php-fpm start failure,
service start failed with no specific reason, or any site down / access denied.
Trigger keywords: site down, website not opening, website unreachable, 502,
500, 403, 404, blank page, white page, nginx error, php-fpm error, start failed,
database connection failed, connection refused, too many connections, access
denied, site unreachable, service unavailable, bad gateway, site anomaly.
---
# aaPanel Website Troubleshoot
> **STOP. Read this document completely before doing anything.**
> **Your FIRST action is always `TodoWrite`** — create the diagnostic task list before running any probe or command.
> Do NOT skip sections. Execute the decision tree in order.
> Never show raw commands, btpython paths, or code blocks to users — report as plain text.
---
## Core Rules
1. **Always run the pre-check first** — never skip the `curl --resolve` loopback verification.
2. **Read before fixing** — diagnose the root cause before executing any repair action.
3. **Max 15 tool calls per task** — if exceeded, summarize findings and stop.
4. **Never let users type commands** — always execute via `RunCommand`.
5. **On error during fix** — report to user, do NOT retry automatically.
6. **Always inspect raw service logs** when a service reports "start failed" — the panel hint is usually too vague to act on.
7. **Forbidden operations**: delete site, drop database, delete files, `rm -rf`, `DROP TABLE/DATABASE`.
8. **Track every diagnosis with TodoWrite** — before starting Step 0, create a todo list of the planned diagnostic steps; set exactly one to `in_progress` while working it, mark `completed` the moment it finishes. Use `TodoRead` to re-check state if interrupted.
---
## Step 0 — Pre-Check (mandatory)
Before any diagnosis, confirm the site actually belongs to this server and the request is reaching it.
1. **First action — create your task list**: call `TodoWrite` to lay out the steps for this case (pre-check → probes → status-code branch → fix). Do this before any other tool call.
2. **Run**: `RunCommand: curl --resolve <domain>:80:127.0.0.1 -sS -o /dev/null -w '%{http_code}\\n' http://<domain>/`
- Returns the actual HTTP status code reaching the panel's nginx.
3. **List sites**: `SiteList()` → find the matching `id`, `path`, `project_type`.
4. **If the site is not on this panel** (`curl` returns connection error or a different site): report to user that the domain may not be hosted here, do NOT proceed.
> Skip pre-check only if the user explicitly confirms the site is on this server, or provides a `site_id` directly.
---
## Step 1 — Two Probes
These probes are read-only and side-effect free. Create them, test, then **delete them** when done.
### Probe A — Static file (verifies domain binding + nginx + root path)
1. `RunCommand: echo 'aaPanel probe $(date)' > <site_path>/.probe_static.html`
2. `RunCommand: chown www:www <site_path>/.probe_static.html && chmod 644 <site_path>/.probe_static.html`
3. `RunCommand: curl --resolve <domain>:80:127.0.0.1 -sS http://<domain>/.probe_static.html`
4. **Expect**: returns the probe text with HTTP 200.
5. Clean up: `RunCommand: rm -f <site_path>/.probe_static.html`
### Probe B — PHP file (verifies PHP runtime + php-fpm socket)
1. `RunCommand: echo '<?php echo "aaPanel PHP probe ".PHP_VERSION; ?>' > <site_path>/.probe_php.php`
2. `RunCommand: chown www:www <site_path>/.probe_php.php && chmod 644 <site_path>/.probe_php.php`
3. `RunCommand: curl --resolve <domain>:80:127.0.0.1 -sS http://<domain>/.probe_php.php`
4. **Expect**: returns the probe text with the PHP version string.
5. Clean up: `RunCommand: rm -f <site_path>/.probe_php.php`
> Get `site_path` from `SiteList` → matching `path` field. `GetSitesConf([site_id])` returns the **raw vhost config text** (not structured fields) — locate the `root`, `access_log`, and `include enable-php-<ver>.conf;` directives by reading that text to verify the root path.
---
## Step 2 — Status Code Decision Tree
### 403 Forbidden — Permission / Ownership
**Goal**: confirm `www` owns the site directory tree, with 755/644 mode and readable parents.
1. `GetSitesConf([site_id])` → get `root` directory.
2. `RunCommand: ls -la <root>/` and `RunCommand: ls -la <parent_of_root>/` (parent matters).
3. Check:
- Owner / group = `www:www` on root and all parents
- Directory mode = `755`
- File mode = `644`
4. If any are wrong (auto-fix, low risk):
- `RunCommand: chown -R www:www <root>`
- `RunCommand: find <root> -type d -exec chmod 755 {} \;`
- `RunCommand: find <root> -type f -exec chmod 644 {} \;`
5. Re-test with the pre-check curl. If 403 persists, inspect the nginx error log:
- `Read: /www/wwwlogs/<domain>.error.log` (last 50 lines, look for `directory index of "..." is forbidden` or `access denied`).
### 404 Not Found — Path / Rewrite
**First classify**: is the missing URL expected to be served dynamically (PHP route / framework route) or statically (a real file)?
- **Static 404** (e.g. `/favicon.ico`, `/uploads/foo.jpg`):
1. `GetSitesConf([site_id])` → check `root` and `access_log` location.
2. `RunCommand: ls -la <root>/<expected_path>` → file missing? wrong root?
3. Verify `root` directive in vhost config matches the actual `site_path` from `SiteList`.
- **Dynamic 404** (e.g. `/category/news/`, `/article/123`, `/admin/login`):
1. `GetSitesConf([site_id])` → look for `include .../rewrite/<domain>.conf;` line.
2. `Read: /www/server/panel/vhost/rewrite/<domain>.conf` (if exists).
3. **Common cause**: rewrite rules missing or wrong for the framework (WordPress / ThinkPHP / Laravel / Typecho / etc.).
4. Fix (high risk — needs panel confirm): edit the rewrite file with `SearchReplace` per the framework's standard rules, then `RunCommand: nginx -t && nginx -s reload`.
### 500 Internal Server Error — Three Branches
Run the pre-check curl to confirm 500. Then check each branch:
#### Branch A — Database
1. Read `GetSitesConf` to find the project root, then look for the framework's DB config:
- WordPress: `Read: <root>/wp-config.php` → extract `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `DB_HOST`.
- Other frameworks: look for `.env`, `config/database.php`, `application/database.php`, etc.
2. `MysqlTestConnection(db_name=<extracted_name>)` → returns `connected` and `version` on success.
3. If connection fails, the error message is auto-mapped by `mysql_engine.MYSQL_ERROR_MAP` — use that mapping directly:
- `2002` / `2003` → MySQL is down. Go to **Service Start Failure → MySQL**.
- `1045` → wrong password. Compare `DB_PASSWORD` in config with `databases` table; if mismatch, report to user (do NOT auto-reset DB password).
- `1146` → table missing. `MysqlListDatabases` / `MysqlDescribeTable` to confirm, then report.
- `1040` → too many connections. `MysqlQuery("SHOW GLOBAL STATUS LIKE 'Threads_connected'")` + `GetTopProcesses` to see who is connecting; suggest raising `max_connections` (high risk).
#### Branch B — Code (uncaught exception)
1. Enable PHP error display (high risk — needs confirm, and only if not in production):
- `Read: /www/server/php/<ver>/etc/php.ini` → check `display_errors` and `error_reporting`.
2. `Read: /www/wwwlogs/<domain>.error.log` (last 100 lines) → look for PHP `Fatal error` / `Parse error` / `Uncaught` lines.
3. Report the file:line and error message to the user. Do NOT auto-edit site code.
#### Branch C — Config (HTTP 200 but blank page)
1. This is the **fastcgi.conf breakage** pattern: status is 200, body is empty, and the site worked before.
2. `Read: /www/server/nginx/conf/fastcgi.conf` → check whether it has been emptied / over-commented.
3. If abnormal, report to user. Fix requires restoring the panel's default `fastcgi.conf` (high risk — needs confirm and original file).
### 502 / 503 Bad Gateway / Service Unavailable — PHP-FPM Environment
1. `RunCommand: ps -ef \| grep php-fpm \| grep -v grep` → is php-fpm running?
2. `ServiceStatus("php-fpm-<ver>")` → confirm install + run state. Identify which version from `GetSitesConf`'s `include enable-php-<ver>.conf;` line.
3. `GetSitesConf([site_id])` → check the vhost actually includes the **same** `enable-php-<ver>.conf` as the running version.
4. `RunCommand: curl -sS http://127.0.0.1/phpfpm_<ver>_status` → check active processes, listen queue, max children.
- If `listen queue` is non-zero → process pool exhausted. See OOM in **Service Start Failure → php-fpm**.
5. `GetSystemResources()` → if memory free < 200MB, OOM-killer may have hit php-fpm.
6. For **reverse-proxy 502**: `RunCommand: curl -v <upstream_host>` from the panel host → check if the upstream is reachable (firewall / DNS / upstream down).
---
## Step 3 — Service Start Failure
> **Core principle**: when the panel says "start failed" with no useful detail, **always read the raw service log first**. The panel hint is too vague to act on.
### 3.1 nginx
```bash
# 1. Syntax check — gives the exact file:line of any error
/www/server/nginx/sbin/nginx -t
```
Three common failure modes:
| Symptom | Cause | Fix |
|---|---|---|
| `unknown directive "xxx" in /www/server/panel/vhost/nginx/<site>.conf:N` | Site vhost has a typo / unsupported directive | `Read` the line, fix with `SearchReplace` (high risk), then re-run `nginx -t` |
| `bind() to 0.0.0.0:80 failed` / `bind() to [::]:443 failed` | Port occupied | `RunCommand: netstat -tlnp \| grep -E ':80\|:443'` → identify the conflicting process, report to user |
| `host not found in upstream "<name>"` | Upstream DNS unresolved at startup | Verify with `RunCommand: dig +short <name>`; if not resolvable, report to user. Fix: add `resolver` directive to the vhost, or replace with an IP (high risk) |
After the fix, **always** run `nginx -t` again, then `RunCommand: /etc/init.d/nginx restart` (or `ServiceRestart("nginx")`, high risk — needs confirm).
### 3.2 php-fpm
Get the version from the failing site's `enable-php-<ver>.conf` reference, then:
```bash
# 1. Tail the raw php-fpm error log — most informative
tail -n 50 /www/server/php/<ver>/var/log/php-fpm.log
```
| Symptom | Cause | Fix |
|---|---|---|
| `Unable to load dynamic library '<ext>.so'` / `segfault (core dumped)` | A newly installed PHP extension is broken | Bisect: in the panel go to App Store → PHP → Settings → Extensions, uninstall newly added extensions one by one, restarting (`RunCommand: /etc/init.d/php-fpm-<ver> restart`) after each |
| `active (exited)` immediately after start | OOM-killer killed it | `RunCommand: dmesg \| grep -E "php\|oom" \| tail -20` + `GetSystemResources` → reduce `pm.max_children` in `/www/server/php/<ver>/etc/php-fpm.conf` (high risk) |
| `bind() to /tmp/php-cgi-<ver>.sock failed: Address already in use` | Old php-fpm process still alive | `RunCommand: pkill php-fpm && sleep 2 && /etc/init.d/php-fpm-<ver> start` (high risk) |
| `ERROR: [/www/server/php/<ver>/etc/php-fpm.conf:N] unknown parameter` | Manual edit broke a directive | `Read` the line, fix or reset via panel App Store → PHP → Settings → Config → Reset (high risk) |
| `Unable to access php-fpm.sock: Permission denied` | Socket file owner wrong | `RunCommand: ls -la /tmp/php-cgi-<ver>.sock` → if not `www:www`, `RunCommand: chown www:www /tmp/php-cgi-<ver>.sock && chown -R www:www /www/server/php/<ver>/var/log/` (low risk) |
---
## Step 4 — Tool Risk Levels
Tools are gated by their registered `risk_level`, not by what the command does.
| risk_level | Tools | Typical use | Handling |
|---|---|---|---|
| **low** | `Read`, `GetSitesConf`, `GetSitesLogs`, `ServiceStatus`, `GetSystemResources`, `GetTopProcesses`, `MysqlTestConnection`, `MysqlQuery`, `MysqlListDatabases`, `MysqlDescribeTable`, `TodoRead`, `TodoWrite` | read configs/logs, check service state, test DB connection, list DBs/tables, track todos | Execute directly |
| **high** | `RunCommand`, `ServiceRestart`, `SearchReplace`, `Write`, `MysqlExecute` | any shell command (incl. read-only `curl`/`ls`/`netstat`/`ps` and `chown`/`chmod`), restart service, edit nginx/php config, write file, SQL write | Every call goes through the panel confirmation gate |
| **forbidden** | — | delete site, drop database, delete files, `rm -rf`, `DROP TABLE/DATABASE` | Never execute |
- `RunCommand` is `risk_level="high"` as a tool, so **even read-only probes** (`curl`, `ls`, `netstat`) trigger the confirmation gate. Prefer the low-risk read tools (`Read` for logs/configs, `ServiceStatus` for state) to gather facts without confirmation.
- After any config edit (`SearchReplace`/`Write`), **must** run `nginx -t` to validate before reload.
---
## Step 5 — Output Format
When reporting findings to the user, always use **plain natural language** in this shape:
```
Root cause: <one-sentence root cause>
Executed steps:
- <step 1>
- <step 2>
...
Recommendations:
- <prevention or follow-up action>
```
Rules:
- **Never** paste raw shell commands, btpython paths, or config diffs into the user-facing message.
- **Never** ask the user to type commands — if a fix is needed, describe it and let the user confirm.
- If the diagnosis is incomplete (e.g. could not reproduce, logs unclear), say so explicitly. Do NOT fabricate a cause.
---
## Quick Reference — Common Error Codes
These are auto-mapped by `mysql_engine.MYSQL_ERROR_MAP`; the mapping is shown for agent's own reasoning, **do not echo the codes to the user verbatim**.
| SQLSTATE / Code | Friendly Meaning | Next Step |
|---|---|---|
| `2002` / `2003` | Cannot connect to MySQL socket / port | Check `ServiceStatus("mysql")` |
| `1045` | Wrong password for DB user | Compare `wp-config.php` with panel's `databases` table |
| `1146` | Table does not exist | `MysqlListDatabases` + `MysqlDescribeTable` to confirm |
| `1040` | Too many connections | `MysqlQuery("SHOW GLOBAL STATUS LIKE 'Threads_connected'")` + `GetTopProcesses` |
| `1452` | Foreign key constraint fails | Inspect the referenced row, report to user |
---
## Quick Reference — File / Log Paths
| What | Path |
|---|---|
| Nginx vhost config | `/www/server/panel/vhost/nginx/<domain>.conf` |
| Apache vhost config | `/www/server/panel/vhost/apache/<domain>.conf` |
| Rewrite rules | `/www/server/panel/vhost/rewrite/<domain>.conf` |
| Nginx main config | `/www/server/nginx/conf/nginx.conf` |
| Nginx fastcgi config (whitespace-page culprit) | `/www/server/nginx/conf/fastcgi.conf` |
| Site access log | `/www/wwwlogs/<domain>.log` |
| Site error log | `/www/wwwlogs/<domain>.error.log` |
| PHP-FPM error log | `/www/server/php/<ver>/var/log/php-fpm.log` |
| PHP-FPM socket | `/tmp/php-cgi-<ver>.sock` |
| PHP-FPM pool config | `/www/server/php/<ver>/etc/php-fpm.conf` |
| WordPress DB config | `<site_root>/wp-config.php` |
+215
View File
@@ -0,0 +1,215 @@
---
name: aaPanel WordPress
description: >
Create and manage WordPress sites on aaPanel servers. Use this skill when users want to:
deploy new WordPress sites, install/activate themes and plugins via WP-CLI, get site info,
list WordPress sites, manage WordPress installations, or perform any WP-CLI operations.
Also use for: troubleshooting WP deployments, PHP version detection, or WordPress site management.
Trigger keywords: WordPress, WP, deploy WP, install WP, WP theme, WP plugin, create site.
---
# aaPanel WordPress
> **STOP. Do NOT read wp_helper.py. Do NOT run --help.**
> All commands, parameters, and usage are in this document.
> Execute commands directly using the format and tables below.
> Reading the script file will waste time and provide no useful information.
---
## Script Path
The wp_helper.py script is located at:
`/www/server/panel/data/agent/skills/wordpress_deploy/script/wp_helper.py`
Always use this absolute path when calling the script via btpython.
---
## Core Rules
1. **Read this SKILL.md completely before doing anything.** Do NOT skip sections — read the entire document first, then execute.
2. **NEVER read wp_helper.py or run --help.** Everything you need is in this document.
3. **NEVER show raw commands, code blocks, or btpython paths to users.** Execute directly, report as plain text.
4. **Never let users type commands.** Always execute via RunCommand.
5. **On error** (`"status": false`): report to user, do NOT retry automatically.
6. **Max 8 RunCommand calls** per task.
7. **Forbidden operations**: delete site, drop database, delete files.
---
## Two Modes
This skill operates in two modes. Determine which one applies before doing anything.
### Mode 1: Create a new WordPress site
**Steps — execute directly, do NOT read script, do NOT run --help, do NOT check existing sites:**
1. Collect parameters from user: domain (required), site title. Other params use defaults.
2. Execute immediately:
`btpython /www/server/panel/data/agent/skills/wordpress_deploy/script/wp_helper.py create-site '{"domain":"<domain>","weblog_title":"<title>","user_name":"admin","admin_email":"admin@example.com","admin_password":"<auto_or_user_provided>"}'`
3. Report result to user: site URL, admin URL, admin_user, admin_password.
If `create-site` returns an error about missing MySQL, PHP, or web server → see Environment Check section below.
**Do NOT**: list sites first, check databases, read the script, run --help, or explore tools. Just execute create-site directly.
### Mode 2: Query or modify an existing WordPress site
For anything else on an existing site (themes, plugins, posts, pages, options, users, db, core update, etc.):
1. `btpython /www/server/panel/data/agent/skills/wordpress_deploy/script/wp_helper.py site-info '{"domain":"<domain>"}'` → get `s_id`
2. `btpython /www/server/panel/data/agent/skills/wordpress_deploy/script/wp_helper.py site-wcli '{"s_id":<id>}'` → get `cmd_prefix`
3. RunCommand: `{cmd_prefix} <wp-cli subcommand>`
The script does NOT have a `wp-cli` subcommand. Use `site-wcli` to get the prefix, then call WP-CLI directly.
---
## Default Values
| Parameter | Default |
|-----------|---------|
| user_name | admin |
| admin_email | admin@example.com |
| admin_password | Auto-generated 12-char strong password |
| php_version | Auto-detect highest installed |
| language | en_US |
| prefix | wp_ |
**domain is always required.** Ask user if not provided.
---
## Commands
**Example execution pattern**:
```
btpython /www/server/panel/data/agent/skills/wordpress_deploy/script/wp_helper.py create-site '{"domain":"example.com","weblog_title":"My Blog","user_name":"admin","admin_email":"admin@example.com","admin_password":"StrongPass123!"}'
```
### create-site
Create a new WordPress site with database, web server config (Nginx/Apache/OLS), and WP-CLI installation. Automatically generates wp-config.php, sets file permissions, and writes rewrite rules. Returns site ID, database name, and admin login URL.
| Parameter | Required | Type | Description |
|-----------|----------|------|-------------|
| domain | Yes | string | Domain name |
| weblog_title | Yes | string | Site title |
| user_name | Yes | string | Admin username |
| admin_email | Yes | string | Admin email |
| admin_password | Yes | string | Admin password (min 8 chars) |
| php_version | No | string | PHP version (e.g. "82", "81") |
| language | No | string | WP language, default "en_US" |
| prefix | No | string | DB table prefix, default "wp_" |
| ps | No | string | Site remark |
**Returns**: status, domain, path, s_id, d_id, database, admin_url, admin_user
### site-info
Get site details by domain or s_id.
| Parameter | Required | Type | Description |
|-----------|----------|------|-------------|
| domain | One of | string | Domain name |
| s_id | One of | int | Site ID |
**Returns**: status, data.id, data.path, data.name, data.status, data.database, data.domains
### list-sites
List all WordPress sites (project_type=WP2). No parameters.
### ensure-wp-cli
Check and install WP-CLI if missing. Call before any WP-CLI operations.
### site-wcli
Returns the WP-CLI command prefix for a site. Required before any theme/plugin/core/option/db/user operation — different sites may use different PHP versions, so the prefix must be resolved per-site.
| Parameter | Required | Type | Description |
|-----------|----------|------|-------------|
| s_id | Yes | int | Site ID (from `site-info` or `list-sites`) |
**Returns**: `cmd_prefix` — a string like `/www/server/php/82/bin/php -d memory_limit=512M /usr/local/bin/wp --allow-root --path=/www/wwwroot/example.com`
**Usage**: Append WP-CLI subcommand to `cmd_prefix`, execute via RunCommand.
**Example flow** (install plugin on site ID 166):
1. `btpython /www/server/panel/data/agent/skills/wordpress_deploy/script/wp_helper.py site-wcli '{"s_id":166}'` → get `cmd_prefix`
2. RunCommand: `{cmd_prefix} plugin install redis-cache --activate`
**Common WP-CLI subcommands**:
| Operation | Append to cmd_prefix |
|-----------|---------------------|
| Install theme | `theme install <slug> --activate` |
| List themes | `theme list` |
| Activate theme | `theme activate <slug>` |
| Install plugin | `plugin install <slug> --activate` |
| List plugins | `plugin list` |
| Activate plugin | `plugin activate <slug>` |
| Deactivate plugin | `plugin deactivate <slug>` |
| Update WP core | `core update` |
| List users | `user list` |
| Search-replace | `search-replace "old" "new" --dry-run` |
| Get option | `option get <option_name>` |
| Set option | `option update <option_name> <value>` |
| Export DB | `db export /tmp/backup.sql` |
| Import DB | `db import /tmp/backup.sql` |
| List posts | `post list --post_type=post --fields=ID,post_title,post_status --format=table` |
| Get post content | `post get <post_id> --field=post_content` |
| Update post content | `post update <post_id> --post_content='<new content>'` |
| List pages | `post list --post_type=page --fields=ID,post_title,post_status --format=table` |
| Search posts | `post list --s='<keyword>' --fields=ID,post_title --format=table` |
| Count posts | `post list --post_type=post --format=count` |
---
## Workflows
### Create new site
1. Collect: domain, site title, admin info
2. `create-site` → report site URL, admin URL, admin_user, admin_password
### Any existing site operation (Mode 2)
1. `btpython /www/server/panel/data/agent/skills/wordpress_deploy/script/wp_helper.py site-info '{"domain":"<domain>"}'``s_id`
2. `btpython /www/server/panel/data/agent/skills/wordpress_deploy/script/wp_helper.py site-wcli '{"s_id":<id>}'``cmd_prefix`
3. RunCommand: `{cmd_prefix} <wp-cli subcommand>`
### Resolve Site ID
- Domain given → `btpython ...wp_helper.py site-info '{"domain":"xxx"}'` → data.id
- Vague request → `btpython ...wp_helper.py list-sites '{}'` → match by name/path
- Just created → use returned s_id
- Not found → ask whether to create
**Never fabricate post content or IDs. Always query first.**
---
## Environment Check & Error Handling
### Missing environment components (MySQL, PHP, Web Server)
When `create-site` returns an error about missing MySQL, PHP, or web server:
1. Read the error message to identify **which specific component** is missing or errored (MySQL / PHP / Nginx / Apache / OpenLiteSpeed).
2. Report the missing component to the user with a clear message, for example:
> "Creation failed: MySQL is not installed. Please go to aaPanel App Store to install MySQL and try again."
> "Creation failed: PHP is not installed. Please go to aaPanel App Store to install PHP and try again."
> "Creation failed: Web server (Nginx/Apache) is not installed. Please go to aaPanel App Store to install one and try again."
3. **STOP immediately after reporting.** Do NOT try to fix the issue, do NOT suggest workarounds, do NOT check for alternatives, do NOT proceed with any other action. Just report and wait for the user.
**Key**: always name the exact missing component in the error message — do not use generic "environment error". Never attempt to resolve environment issues yourself.
### Other errors
- **PHP errors / PHP not working**: report to user: "PHP is not installed or has errors. Please go to aaPanel App Store to install PHP and try again."
- **PHP version mismatch**: show available versions
- **WP-CLI download failed**: provide manual install command
- **Site already exists**: show existing site ID
- **Database creation failed**: check MySQL root password
- **WordPress download failed**: check network connectivity
@@ -0,0 +1,707 @@
#!/usr/bin/env btpython
"""
WordPress Helper - Site creation and WP-CLI management.
Usage: btpython wp_helper.py <command> [JSON args]
"""
import os
import sys
import json
import subprocess
import sqlite3
import time
import re
import shutil
import random
import string
import traceback
import logging
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Any
# ========== Configuration ==========
PANEL_PATH = '/www/server/panel'
PANEL_DB = os.path.join(PANEL_PATH, 'data/default.db')
WP_CLI_PATH = '/usr/local/bin/wp'
VHOST_NGINX = os.path.join(PANEL_PATH, 'vhost/nginx')
VHOST_APACHE = os.path.join(PANEL_PATH, 'vhost/apache')
VHOST_OLS = os.path.join(PANEL_PATH, 'vhost/openlitespeed')
VHOST_OLS_DETAIL = os.path.join(PANEL_PATH, 'vhost/openlitespeed/detail')
VHOST_REWRITE = os.path.join(PANEL_PATH, 'vhost/rewrite')
WWWLOGS = '/www/wwwlogs'
# Logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
handlers=[logging.StreamHandler(sys.stderr)]
)
logger = logging.getLogger('wp_helper')
# ========== Output ==========
def json_out(data: Dict[str, Any]) -> None:
"""Output JSON to stdout."""
print(json.dumps(data, ensure_ascii=False, default=str))
def run_command(commands: Dict[str, callable]) -> None:
"""Main command dispatcher."""
if len(sys.argv) < 2:
print(f"Usage: btpython wp_helper.py <command> [JSON args]")
print(f"Available: {', '.join(commands.keys())}")
sys.exit(1)
cmd = sys.argv[1]
if cmd not in commands:
json_out({"status": False, "msg": f"Unknown command: {cmd}, available: {', '.join(commands.keys())}"})
sys.exit(1)
args: Dict[str, Any] = {}
if len(sys.argv) > 2:
try:
args = json.loads(sys.argv[2])
except json.JSONDecodeError:
json_out({"status": False, "msg": f"JSON parse error: {sys.argv[2]}"})
sys.exit(1)
if not isinstance(args, dict):
json_out({"status": False, "msg": f"Invalid args type: expected dict, got {type(args).__name__}"})
sys.exit(1)
try:
logger.info(f"Executing command: {cmd}")
json_out(commands[cmd](args))
except Exception as e:
logger.error(f"Command {cmd} failed: {e}\n{traceback.format_exc()}")
json_out({"status": False, "msg": f"Execution error: {str(e)}", "traceback": traceback.format_exc()})
sys.exit(1)
# ========== Database ==========
def get_panel_db() -> sqlite3.Connection:
"""Get panel database connection."""
conn = sqlite3.connect(PANEL_DB)
conn.row_factory = sqlite3.Row
return conn
def db_query(sql: str, params: Tuple = (), fetchone: bool = False) -> Any:
"""Execute SELECT query."""
conn = get_panel_db()
try:
cur = conn.execute(sql, params)
if fetchone:
row = cur.fetchone()
return dict(row) if row else None
return [dict(r) for r in cur.fetchall()]
finally:
conn.close()
def db_execute(sql: str, params: Tuple = ()) -> int:
"""Execute INSERT/UPDATE/DELETE query. Returns last rowid."""
conn = get_panel_db()
try:
conn.execute(sql, params)
conn.commit()
return conn.execute('SELECT last_insert_rowid()').fetchone()[0]
finally:
conn.close()
def get_mysql_root_password() -> str:
"""Get MySQL root password from panel config."""
row = db_query('SELECT mysql_root FROM config WHERE id=1', fetchone=True)
return (row.get('mysql_root', '') if row else '') or ''
def get_mysql_bin() -> str:
"""Find MySQL binary path."""
for p in ['/www/server/mysql/bin/mysql', '/usr/bin/mysql', '/usr/local/bin/mysql']:
if os.path.exists(p):
return p
return 'mysql'
def run_mysql_sql(sql: str, root_password: Optional[str] = None) -> Tuple[bool, str]:
"""Execute MySQL SQL statement."""
if root_password is None:
root_password = get_mysql_root_password()
mysql_bin = get_mysql_bin()
try:
result = subprocess.run(
[mysql_bin, '-uroot', f'-p{root_password}', '-e', sql],
capture_output=True, text=True, timeout=30
)
return result.returncode == 0, result.stderr.strip()
except Exception as e:
return False, str(e)
# ========== Common Utilities ==========
def get_php_bin(version: str) -> str:
"""Get PHP binary path for version."""
return f'/www/server/php/{version}/bin/php'
def detect_php_version() -> Optional[str]:
"""Detect highest installed PHP version."""
php_base = "/www/server/php"
if not os.path.exists(php_base):
return None
versions = []
for d in os.listdir(php_base):
if d.isdigit() and os.path.exists(os.path.join(php_base, d, 'bin/php')):
versions.append(d)
return max(versions) if versions else None
def get_date() -> str:
"""Get current datetime string."""
return time.strftime('%Y-%m-%d %X')
def run_cmd(cmd: List[str], timeout: int = 60) -> Tuple[bool, str, str]:
"""Execute command safely (no shell=True)."""
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
return result.returncode == 0, result.stdout.strip(), result.stderr.strip()
except subprocess.TimeoutExpired:
return False, '', f'Command timed out after {timeout}s'
except Exception as e:
return False, '', str(e)
def shell(cmd: str, timeout: int = 60) -> Tuple[bool, str, str]:
"""Execute shell command (for simple operations only)."""
try:
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
return result.returncode == 0, result.stdout.strip(), result.stderr.strip()
except subprocess.TimeoutExpired:
return False, '', f'Command timed out after {timeout}s'
except Exception as e:
return False, '', str(e)
def write_file(path: str, content: str, mode: str = 'w') -> None:
"""Write content to file, creating directories if needed."""
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, mode) as f:
f.write(content)
def is_multi_webservice() -> bool:
"""Check if multiple web services are installed."""
return (os.path.exists('/www/server/nginx/sbin/nginx')
and os.path.exists('/www/server/apache/bin/apachectl')
and os.path.exists('/usr/local/lsws/bin/lswsctrl'))
def resolve_site(s_id: int) -> Tuple[Optional[Dict], Optional[str]]:
"""Resolve site info and PHP version from site ID."""
try:
s_id = int(s_id)
except (ValueError, TypeError):
return None, None
site = db_query('SELECT id,path,name FROM sites WHERE id=?', (s_id,), fetchone=True)
if not site:
return None, None
conf_path = os.path.join(VHOST_NGINX, f'{site["name"]}.conf')
php_ver = None
if os.path.exists(conf_path):
with open(conf_path, 'r') as f:
m = re.search(r'enable-php-(\d+)\.conf', f.read())
if m:
php_ver = m.group(1)
if not php_ver:
php_ver = detect_php_version()
return site, php_ver
# ========== WP-CLI ==========
def ensure_wp_cli() -> bool:
"""Check and install WP-CLI if not present."""
if os.path.exists(WP_CLI_PATH):
return True
try:
logger.info("Installing WP-CLI...")
subprocess.run(
['curl', '-sSL', '-o', WP_CLI_PATH,
'https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar'],
capture_output=True, timeout=120
)
os.chmod(WP_CLI_PATH, 0o755)
return os.path.exists(WP_CLI_PATH)
except Exception as e:
logger.error(f"WP-CLI installation failed: {e}")
return False
def run_wp_cli(args: List[str], php_version: str, cwd: Optional[str] = None) -> Tuple[bool, str, str]:
"""Execute WP-CLI command."""
php_bin = get_php_bin(php_version)
cmd = [php_bin, '-d', 'memory_limit=512M', WP_CLI_PATH, '--allow-root'] + args
if cwd:
cmd += ['--path=' + cwd]
return run_cmd(cmd, timeout=300)
# ========== Web Config Generation ==========
def gen_nginx_conf(domain: str, php_version: str, site_path: str) -> str:
"""Generate Nginx virtual host configuration."""
rewrite_conf = os.path.join(VHOST_REWRITE, domain + '.conf')
return f'''server
{{
listen 80;
listen [::]:80;
server_name {domain};
index index.php index.html index.htm default.php default.htm default.html;
root {site_path};
#SSL-START
#error_page 404/404.html;
#SSL-END
#ERROR-PAGE-START
error_page 404 /404.html;
error_page 502 /502.html;
#ERROR-PAGE-END
#PHP-INFO-START
include enable-php-{php_version}.conf;
#PHP-INFO-END
#REWRITE-START
include {rewrite_conf};
#REWRITE-END
location ~ ^/(\\.user\\.ini|\\.htaccess|\\.git|\\.env|\\.svn|\\.project|LICENSE|README.md)
{{
return 404;
}}
location ~ \\.well-known{{
allow all;
}}
if ( $uri ~ "^/\\.well-known/.*\\.(php|jsp|py|js|css|lua|ts|go|zip|tar\\.gz|rar|7z|sql|bak)$" ) {{
return 403;
}}
location ~ .*\\.(gif|jpg|jpeg|png|bmp|swf)$
{{
expires 30d;
error_log /dev/null;
access_log /dev/null;
}}
location ~ .*\\.(js|css)?$
{{
expires 12h;
error_log /dev/null;
access_log /dev/null;
}}
access_log {WWWLOGS}/{domain}.log;
error_log {WWWLOGS}/{domain}.error.log;
}}'''
def gen_apache_conf(domain: str, php_version: str, site_path: str, port: str = '80') -> str:
"""Generate Apache virtual host configuration."""
php_proxy = f'unix:/tmp/php-cgi-{php_version}.sock|fcgi://localhost'
return f'''<VirtualHost *:{port}>
ServerAdmin webmaster@example.com
DocumentRoot "{site_path}"
ServerName {time.strftime('%Y%m%d%H%M%S')}.{domain}
ServerAlias {domain}
#errorDocument 404 /404.html
ErrorLog "{WWWLOGS}/{domain}-error_log"
CustomLog "{WWWLOGS}/{domain}-access_log" combined
#DENY FILES
<Files ~ (\\.user\\.ini|\\.htaccess|\\.git|\\.env|\\.svn|\\.project|LICENSE|README.md)$>
Order allow,deny
Deny from all
</Files>
#PHP
<FilesMatch \\.php$>
SetHandler "proxy:{php_proxy}"
</FilesMatch>
#PATH
<Directory "{site_path}">
SetOutputFilter DEFLATE
Options FollowSymLinks
AllowOverride All
Require all granted
DirectoryIndex index.php index.html index.htm default.php default.html default.htm
</Directory>
</VirtualHost>'''
def gen_ols_conf(domain: str, php_version: str, site_path: str) -> Tuple[str, str]:
"""Generate OpenLiteSpeed configuration."""
if not site_path.endswith('/'):
site_path += '/'
main_conf = f"""
#VHOST {domain} START
virtualhost {domain} {{
vhRoot {site_path}
configFile {PANEL_PATH}/vhost/openlitespeed/detail/{domain}.conf
allowSymbolLink 1
enableScript 1
restrained 1
setUIDMode 0
}}
#VHOST {domain} END
"""
detail_conf = f'''docRoot $VH_ROOT
vhDomain $VH_NAME
adminEmails example@example.com
enableGzip 1
enableIpGeo 1
index {{
useServer 0
indexFiles index.php,index.html
}}
errorlog /www/wwwlogs/$VH_NAME_ols.error_log {{
useServer 0
logLevel ERROR
rollingSize 10M
}}
accesslog /www/wwwlogs/$VH_NAME_ols.access_log {{
useServer 0
logFormat '%{{X-Forwarded-For}}i %h %l %u %t "%r" %>s %b "%{{Referer}}i" "%{{User-Agent}}i"'
logHeaders 5
rollingSize 10M
keepDays 10 compressArchive 1
}}
scripthandler {{
add lsapi:{domain} php
}}
extprocessor {domain} {{
type lsapi
address UDS://tmp/lshttpd/{domain}.sock
maxConns 300
env LSAPI_CHILDREN=300
env LSAPI_AVOID_FORK=1
initTimeout 600
retryTimeout 5
persistConn 1
pcKeepAliveTimeout 30
respBuffer 0
autoStart 1
path /usr/local/lsws/lsphp{php_version}/bin/lsphp
extUser www
extGroup www
memSoftLimit 2047M
memHardLimit 2047M
procSoftLimit 1000
procHardLimit 1100
}}
phpIniOverride {{
php_admin_value open_basedir "/tmp/:{site_path}"
}}
expires {{
enableExpires 1
expiresByType image/*=A43200,text/css=A43200,application/x-javascript=A43200,application/javascript=A43200,font/*=A43200,application/x-font-ttf=A43200
}}
rewrite {{
enable 1
autoLoadHtaccess 1
include {PANEL_PATH}/vhost/openlitespeed/proxy/{domain}/urlrewrite/*.conf
include {PANEL_PATH}/vhost/apache/redirect/{domain}/*.conf
include {PANEL_PATH}/vhost/openlitespeed/redirect/{domain}/*.conf
}}
include {PANEL_PATH}/vhost/openlitespeed/proxy/{domain}/*.conf
'''
return main_conf, detail_conf
def write_wordpress_rewrite(domain: str) -> None:
"""Write WordPress rewrite rules."""
rewrite_path = os.path.join(VHOST_REWRITE, domain + '.conf')
with open(rewrite_path, 'w') as f:
f.write("location /\n{\n try_files $uri $uri/ /index.php?$args;\n}\n\n"
"rewrite /wp-admin$ $scheme://$host$uri/ permanent;\n")
def ensure_ols_listen_conf(domain: str) -> None:
"""Ensure OpenLiteSpeed listen configuration includes domain."""
listen_dir = os.path.join(PANEL_PATH, 'vhost', 'openlitespeed', 'listen')
os.makedirs(listen_dir, exist_ok=True)
listen_file = os.path.join(listen_dir, '80.conf')
listen_conf = ''
if os.path.exists(listen_file):
with open(listen_file, 'r') as f:
listen_conf = f.read()
map_line = f'\tmap\t{domain} {domain}'
if listen_conf:
rep = r'map\s+{0}.*'.format(re.escape(domain))
if not re.search(rep, listen_conf):
listen_conf = re.sub(r'secure\s*0', 'secure 0\n' + map_line, listen_conf)
else:
listen_conf = f"\nlistener Default80{{\n address *:80\n secure 0\n{map_line}\n}}\n"
with open(listen_file, 'w') as f:
f.write(listen_conf)
def write_all_web_configs(domain: str, php_version: str, site_path: str) -> None:
"""Write all web server configurations (Nginx, Apache, OLS)."""
multi = is_multi_webservice()
apache_port = '8288' if multi else '80'
write_file(os.path.join(VHOST_NGINX, domain + '.conf'), gen_nginx_conf(domain, php_version, site_path))
write_file(os.path.join(VHOST_APACHE, domain + '.conf'), gen_apache_conf(domain, php_version, site_path, port=apache_port))
htaccess = os.path.join(site_path, '.htaccess')
if not os.path.exists(htaccess):
write_file(htaccess, ' ')
shell(f'chmod 644 {htaccess}')
shell(f'chown www:www {htaccess}')
ols_main, ols_detail = gen_ols_conf(domain, php_version, site_path)
write_file(os.path.join(VHOST_OLS, domain + '.conf'), ols_main, mode='a+')
write_file(os.path.join(VHOST_OLS_DETAIL, domain + '.conf'), ols_detail)
ensure_ols_listen_conf(domain)
write_wordpress_rewrite(domain)
ok, _, _ = shell('nginx -t')
if ok:
shell('nginx -s reload')
# ========== Site Cleanup ==========
def _cleanup_site(s_id: int, d_id: int, domain: str, db_name: str, db_user: str, site_path: str) -> None:
"""Clean up site resources on failure."""
for fn, args in [
(db_execute, ('DELETE FROM domain WHERE pid=?', (s_id,))),
(db_execute, ('DELETE FROM wordpress_onekey WHERE s_id=?', (s_id,))),
(db_execute, ('DELETE FROM databases WHERE id=?', (d_id,))),
(db_execute, ('DELETE FROM sites WHERE id=?', (s_id,))),
]:
try:
fn(*args)
except Exception as e:
logger.warning(f"Cleanup query failed: {e}")
run_mysql_sql(
f"DROP DATABASE IF EXISTS `{db_name}`; DROP USER IF EXISTS `{db_user}`@'localhost'; FLUSH PRIVILEGES;",
get_mysql_root_password()
)
if os.path.exists(site_path):
shutil.rmtree(site_path, ignore_errors=True)
for p in [
os.path.join(VHOST_NGINX, domain + '.conf'),
os.path.join(VHOST_APACHE, domain + '.conf'),
os.path.join(VHOST_OLS_DETAIL, domain + '.conf'),
os.path.join(VHOST_REWRITE, domain + '.conf'),
]:
if os.path.exists(p):
os.remove(p)
ols_main = os.path.join(VHOST_OLS, domain + '.conf')
if os.path.exists(ols_main):
try:
content = open(ols_main, 'r').read()
content = re.sub(f'#VHOST {domain} START.*?#VHOST {domain} END\n?', '', content, flags=re.DOTALL)
open(ols_main, 'w').write(content)
except Exception as e:
logger.warning(f"OLS cleanup failed: {e}")
shell('nginx -s reload')
# ========== Commands ==========
def cmd_create_site(args: Dict[str, Any]) -> Dict[str, Any]:
"""Create a new WordPress site."""
required = ['weblog_title', 'user_name', 'admin_email', 'admin_password']
for key in required:
if key not in args or not args[key]:
return {"status": False, "msg": f"Missing required parameter: {key}"}
domain = args.get('domain', 'example.com')
if not isinstance(domain, str):
domain = str(domain)
domain = domain.strip().lower()
if not domain:
domain = 'example.com'
password = args['admin_password']
if not isinstance(password, str):
password = str(password)
if len(password) < 8:
return {"status": False, "msg": "Password must be at least 8 characters"}
existing = db_query('SELECT id FROM sites WHERE name=?', (domain,), fetchone=True)
if existing:
return {"status": False, "msg": f"Site {domain} already exists (ID: {existing['id']})"}
if not os.path.exists("/www/server/mysql"):
return {"status": False, "msg": "MySQL is not installed. Please install MySQL first."}
if not os.path.exists("/www/server/php"):
return {"status": False, "msg": "PHP is not installed. Please install PHP first."}
php_version = args.get('php_version', '')
if not isinstance(php_version, str):
php_version = str(php_version) if php_version else ''
php_version = php_version.strip()
if not php_version:
php_version = detect_php_version()
if not php_version:
return {"status": False, "msg": "No installed PHP version detected"}
if not os.path.exists(f"/www/server/php/{php_version}"):
available = [d for d in os.listdir("/www/server/php/") if d.isdigit()]
return {"status": False, "msg": f"PHP {php_version} not installed. Available: {', '.join(sorted(available))}"}
if not ensure_wp_cli():
return {"status": False, "msg": "WP-CLI download failed. Please install manually to /usr/local/bin/wp"}
site_path = f"/www/wwwroot/{domain}"
db_name = 'wp_' + ''.join(random.choices(string.ascii_lowercase + string.digits, k=8))
db_user = db_name
db_password = password
now = get_date()
prefix = args.get('prefix', 'wp_')
s_id = d_id = None
try:
os.makedirs(site_path, exist_ok=True)
ok, err = run_mysql_sql(
f"CREATE DATABASE IF NOT EXISTS `{db_name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; "
f"CREATE USER `{db_user}`@'localhost' IDENTIFIED BY '{db_password}'; "
f"GRANT ALL PRIVILEGES ON `{db_name}`.* TO `{db_user}`@'localhost'; FLUSH PRIVILEGES;"
)
if not ok:
return {"status": False, "msg": f"Failed to create database: {err}"}
d_id = db_execute(
'INSERT INTO databases (pid,sid,db_type,name,username,password,accept,type,ps,addtime) VALUES (?,?,?,?,?,?,?,?,?,?)',
(0, 0, 0, db_name, db_user, db_password, '127.0.0.1', 'MySQL', domain, now)
)
s_id = db_execute(
'INSERT INTO sites (name,path,status,ps,type_id,addtime,project_type,project_config) VALUES (?,?,?,?,?,?,?,?)',
(domain, site_path, '1', args.get('ps', domain), 0, now, 'WP2', '{}')
)
db_execute('UPDATE databases SET pid=? WHERE id=?', (s_id, d_id))
db_execute('INSERT INTO domain (pid,name,port,addtime) VALUES (?,?,?,?)', (s_id, domain, '80', now))
ok, out, err = run_wp_cli(['core', 'download', '--locale=' + args.get('language', 'en_US'), '--force'], php_version, cwd=site_path)
if not ok:
raise Exception(f"WordPress download failed: {err or out}")
ok, out, err = run_wp_cli([
'config', 'create', f'--dbname={db_name}', f'--dbuser={db_user}',
f'--dbpass={db_password}', '--dbhost=localhost', f'--dbprefix={prefix}', '--force',
], php_version, cwd=site_path)
if not ok:
raise Exception(f"wp-config.php creation failed: {err or out}")
title = args['weblog_title']
admin_user = args['user_name']
admin_email = args['admin_email']
ok, out, err = run_wp_cli([
'core', 'install', f'--url=http://{domain}', f'--title={title}',
f'--admin_user={admin_user}', f'--admin_password={password}',
f'--admin_email={admin_email}', '--skip-email'
], php_version, cwd=site_path)
if not ok:
raise Exception(f"WordPress installation failed: {err or out}")
write_all_web_configs(domain, php_version, site_path)
shell(f'chown -R www:www {site_path}')
shell(f'chmod -R 755 {site_path}')
db_execute(
'INSERT INTO wordpress_onekey (s_id,d_id,prefix,user,pass,site_type) VALUES (?,?,?,?,?,?)',
(s_id, d_id, prefix, args['user_name'], password, 'Default category')
)
logger.info(f"WordPress site created: {domain} (ID: {s_id})")
return {
"status": True, "msg": "WordPress site created successfully", "domain": domain,
"path": site_path, "s_id": s_id, "d_id": d_id, "database": db_name,
"admin_url": f"http://{domain}/wp-admin", "admin_user": args['user_name'],
}
except Exception as e:
if s_id is not None:
_cleanup_site(s_id, d_id or 0, domain, db_name, db_user, site_path)
return {"status": False, "msg": f"Deployment failed: {str(e)}"}
def cmd_site_info(args: Dict[str, Any]) -> Dict[str, Any]:
"""Get site details by domain or s_id."""
domain = args.get('domain', '')
if not isinstance(domain, str):
domain = str(domain) if domain else ''
domain = domain.strip().lower()
s_id = args.get('s_id')
if s_id is not None:
try:
s_id = int(s_id)
except (ValueError, TypeError):
return {"status": False, "msg": f"Invalid s_id: {s_id}"}
site = db_query('SELECT id,path,name,status,ps,project_type FROM sites WHERE id=?', (s_id,), fetchone=True)
elif domain:
site = db_query('SELECT id,path,name,status,ps,project_type FROM sites WHERE name=?', (domain,), fetchone=True)
else:
return {"status": False, "msg": "Please provide domain or s_id"}
if not site:
return {"status": False, "msg": "Site not found"}
site['database'] = db_query('SELECT name,username,password FROM databases WHERE pid=?', (site['id'],), fetchone=True)
site['domains'] = db_query('SELECT name,port FROM domain WHERE pid=?', (site['id'],))
return {"status": True, "data": site}
def cmd_list_sites(args: Dict[str, Any]) -> Dict[str, Any]:
"""List all WordPress sites."""
return {"status": True, "data": db_query("SELECT id,name,path,status,ps FROM sites WHERE project_type='WP2'")}
def cmd_ensure_wp_cli(args: Dict[str, Any]) -> Dict[str, Any]:
"""Check and install WP-CLI if not present."""
if ensure_wp_cli():
return {"status": True, "msg": "WP-CLI is ready", "path": WP_CLI_PATH}
return {"status": False, "msg": "WP-CLI installation failed. Please install manually: curl -o /usr/local/bin/wp https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar && chmod +x /usr/local/bin/wp"}
def cmd_site_wcli(args: Dict[str, Any]) -> Dict[str, Any]:
"""Get WP-CLI command prefix for a site."""
s_id = args.get('s_id')
if s_id is None:
return {"status": False, "msg": "Please provide s_id"}
try:
s_id = int(s_id)
except (ValueError, TypeError):
return {"status": False, "msg": f"Invalid s_id: {s_id}"}
site, php_ver = resolve_site(s_id)
if not site:
return {"status": False, "msg": "Site not found"}
php_bin = get_php_bin(php_ver)
site_path = site['path']
prefix = f"{php_bin} -d memory_limit=512M {WP_CLI_PATH} --allow-root --path={site_path}"
return {
"status": True, "domain": site['name'], "path": site_path,
"php_bin": php_bin, "wp_cli": WP_CLI_PATH, "cmd_prefix": prefix,
}
def cmd_wp_cli(args: Dict[str, Any]) -> Dict[str, Any]:
"""Execute WP-CLI command for a site."""
s_id = args.get('s_id')
wp_args = args.get('args', [])
if s_id is None:
return {"status": False, "msg": "Please provide s_id"}
try:
s_id = int(s_id)
except (ValueError, TypeError):
return {"status": False, "msg": f"Invalid s_id: {s_id}"}
if not wp_args:
return {"status": False, "msg": "Please provide args (WP-CLI subcommand argument list)"}
site, php_ver = resolve_site(s_id)
if not site:
return {"status": False, "msg": "Site not found"}
php_bin = get_php_bin(php_ver)
site_path = site['path']
cmd = [php_bin, '-d', 'memory_limit=512M', WP_CLI_PATH, '--allow-root', f'--path={site_path}'] + wp_args
returncode, stdout, stderr = run_cmd(cmd, timeout=300)
return {"status": returncode, "stdout": stdout, "stderr": stderr}
# ========== Entry Point ==========
run_command({
'create-site': cmd_create_site,
'site-info': cmd_site_info,
'list-sites': cmd_list_sites,
'ensure-wp-cli': cmd_ensure_wp_cli,
'site-wcli': cmd_site_wcli,
})
+27 -51
View File
@@ -4,23 +4,11 @@
"versions": [
{
"status": false,
"version": "1.24"
"version": "1.30"
},
{
"status": false,
"version": "1.25"
},
{
"status": false,
"version": "1.26"
},
{
"status": false,
"version": "1.27"
},
{
"status": false,
"version": "1.28"
"version": "1.31"
},
{
"status": false,
@@ -28,7 +16,23 @@
},
{
"status": false,
"version": "1.30"
"version": "1.28"
},
{
"status": false,
"version": "1.27"
},
{
"status": false,
"version": "1.26"
},
{
"status": false,
"version": "1.25"
},
{
"status": false,
"version": "1.24"
}
],
"type": "Web server",
@@ -119,6 +123,10 @@
{
"name": "PHP",
"versions": [
{
"status": false,
"version": "8.4"
},
{
"status": false,
"version": "8.3"
@@ -131,45 +139,13 @@
"status": false,
"version": "8.0"
},
{
"status": false,
"version": "8.5"
},
{
"status": false,
"version": "7.4"
},
{
"status": false,
"version": "7.3"
},
{
"status": false,
"version": "7.2"
},
{
"status": false,
"version": "7.1"
},
{
"status": false,
"version": "7.0"
},
{
"status": false,
"version": "5.2"
},
{
"status": false,
"version": "5.3"
},
{
"status": false,
"version": "5.4"
},
{
"status": false,
"version": "5.5"
},
{
"status": false,
"version": "5.6"
}
],
"type": "PHP",