From 517278f66f14dbd65c71c3583e5168bcb5e5a0c3 Mon Sep 17 00:00:00 2001 From: Mike Swanson Date: Wed, 20 May 2026 14:38:14 -0700 Subject: [PATCH] sync: auto-sync from DESKTOP-0O8A1RL at 2026-05-20 14:38:07 Author: Mike Swanson Machine: DESKTOP-0O8A1RL Timestamp: 2026-05-20 14:38:07 --- .claude/CLAUDE.md | 1 + .claude/commands/forum-post.md | 324 +++++++++++++++++++++++++++++ session-logs/2026-05-20-session.md | 97 +++++++++ 3 files changed, 422 insertions(+) create mode 100644 .claude/commands/forum-post.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index f3f59cc..fdbe24a 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -253,6 +253,7 @@ Vault structure: `infrastructure/`, `clients/`, `services/`, `projects/`, `msp-t | `/feature-request` | Howard submits a GuruRMM feature request — Claude classifies it and messages Mike | | `/shape-spec` | Pre-implementation spec for a GuruRMM feature — produces plan.md, shape.md, references.md, standards.md | | `/rmm-audit` | Full end-to-end audit of GuruRMM: API coverage, UI gaps, Rust/TS quality, security, data integrity. Produces timestamped report + updates UI_GAPS.md | +| `/forum-post` | Post a technical article to community.azcomputerguru.com — drafts from context, shows preview, inserts via paramiko SSH to Flarum DB | --- diff --git a/.claude/commands/forum-post.md b/.claude/commands/forum-post.md new file mode 100644 index 0000000..4171bf3 --- /dev/null +++ b/.claude/commands/forum-post.md @@ -0,0 +1,324 @@ +Post a technical article to community.azcomputerguru.com (Flarum forum). + +Converts markdown to Flarum's s9e TextFormatter XML format and inserts directly into the +database via paramiko SSH to IX. Shows a preview and waits for user confirmation before posting. + +--- + +## Usage + +``` +/forum-post Draft interactively — Claude asks for title, content, tag +/forum-post Claude drafts from conversation context, then confirms +``` + +Arguments are optional. With no args, Claude uses the current conversation context to +determine what to post (the most recent technical problem solved, fix documented, etc.). + +--- + +## Infrastructure + +| Item | Value | +|---|---| +| Forum URL | https://community.azcomputerguru.com | +| DB host | localhost (on IX) | +| DB name | azcompu_flarum | +| DB user | azcompu_flarum | +| DB pass | `Fl@rum2026!CGS` | +| IX SSH | root@172.16.3.10 — password from vault: `infrastructure/ix-server.sops.yaml credentials.password` | +| Admin user_id | 1 (MikeSwanson) | + +Get the IX SSH password via vault before connecting: +```bash +bash "$CLAUDETOOLS_ROOT/.claude/scripts/vault.sh" get-field infrastructure/ix-server.sops.yaml credentials.password +``` + +--- + +## Known Tags + +| tag_id | Name | When to use | +|--------|------|-------------| +| 7 | How-Tos & Tips | Technical fixes, how-tos, diagnostics, field notes | + +To see all current tags: query `SELECT id, name_singular FROM tags;` on azcompu_flarum. +Default to tag_id=7 unless the user specifies otherwise. + +--- + +## Phase 1: Gather Inputs + +Collect in one pass — do not ask field by field: + +1. **Title** — descriptive, search-friendly, sentence case. Include the technology/product. + Good: `OneDrive KFM Fails After Folder Redirection Migration -- Here's What's Actually Going On` + Bad: `OneDrive issue fix` + +2. **Content** — full markdown body. Minimum useful length: 3-4 paragraphs with concrete detail. + Write from the conversation context: what was the problem, what was tried, what actually worked, what to remember. + +3. **Tag** — default How-Tos & Tips (7) unless user says otherwise. + +Generate the slug from the title: lowercase, spaces/punctuation to hyphens, remove apostrophes, +collapse multiple hyphens. Max ~90 chars. Example: +`onedrive-kfm-fails-after-folder-redirection-migration-heres-whats-actually-going-on` + +--- + +## Phase 2: Show Preview + +Before posting, show: + +``` +FORUM POST PREVIEW +------------------ +Title: +Tag: How-Tos & Tips +Slug: <slug> +URL: https://community.azcomputerguru.com/d/<next_id>-<slug> + +--- Content --- +<first 500 chars of markdown> +... + +Post this? (yes/no) +``` + +Wait for explicit confirmation before executing. + +--- + +## Phase 3: Execute + +Write and run a Python script (use `py` on Windows). The script must: + +1. **Get IX SSH password** from vault (see Infrastructure above) +2. **Connect via paramiko** (`AutoAddPolicy`, password auth) +3. **Generate the s9e XML** from the markdown (see Converter section below) +4. **Build the PHP insert script** (see PHP Template below) +5. **SFTP upload** the PHP script to `/tmp/flarum_post_<timestamp>.php` on IX +6. **Run** `php /tmp/flarum_post_<timestamp>.php 2>&1` via SSH +7. **Parse output** — look for `Discussion ID: N` and `Post ID: N` +8. **Clean up** — `rm /tmp/flarum_post_<timestamp>.php` +9. **Report** the live URL + +--- + +## Markdown → s9e XML Converter + +Flarum stores post content as s9e TextFormatter XML, not raw markdown. The stored format +must match what Flarum's TextFormatter produces. Based on confirmed existing posts: + +### Inline elements + +| Markdown | s9e XML | +|---|---| +| `**bold**` | `<STRONG><s>**</s>bold<e>**</e></STRONG>` | +| `*italic*` | `<EM><s>*</s>italic<e>*</e></EM>` | +| `` `code` `` | `<C><s>`</s>code<e>`</e></C>` | + +XML-escape `&` → `&`, `<` → `<`, `>` → `>` in all text content and code spans. + +### Block elements + +| Markdown | s9e XML | +|---|---| +| `## Heading` | `<H2><s>## </s>Heading</H2>` | +| `### Heading` | `<H3><s>### </s>Heading</H3>` | +| Paragraph | `<p>text</p>` | +| `- item` (list) | `<LIST><LI><s>- </s>item</LI>\n<LI>...</LI></LIST>` | +| `1. item` (ordered) | `<LIST type="decimal"><LI><s>1. </s>item</LI>\n...</LIST>` | +| ` ```lang ` fenced block | `<CODE lang="lang"><s>```lang</s><i>\n</i>code\n<e>```</e></CODE>` | + +Block elements are separated by `\n\n` (two real newlines) inside the `<r>` root. +List items are separated by `\n` (one newline). +Entire content is wrapped: `<r>...</r>`. + +### Python converter (copy this directly into the py script) + +```python +import re + +def xml_escape(t): + return t.replace('&', '&').replace('<', '<').replace('>', '>') + +def inline_to_xml(text): + result = '' + i = 0 + while i < len(text): + if text[i:i+2] == '**': + end = text.find('**', i+2) + if end != -1: + inner = inline_to_xml(text[i+2:end]) + result += '<STRONG><s>**</s>' + inner + '<e>**</e></STRONG>' + i = end + 2; continue + if text[i] == '`': + end = text.find('`', i+1) + if end != -1: + result += '<C><s>`</s>' + xml_escape(text[i+1:end]) + '<e>`</e></C>' + i = end + 1; continue + if text[i] == '*' and text[i:i+2] != '**': + j = i + 1; end = -1 + while j < len(text): + if text[j] == '*' and text[j:j+2] != '**': end = j; break + j += 1 + if end != -1: + result += '<EM><s>*</s>' + xml_escape(text[i+1:end]) + '<e>*</e></EM>' + i = end + 1; continue + result += xml_escape(text[i]); i += 1 + return result + +def md_to_s9e(md): + lines = md.split('\n') + elements = [] + i = 0 + while i < len(lines): + line = lines[i] + if not line.strip(): i += 1; continue + if line.startswith('## '): + elements.append('<H2><s>## </s>' + inline_to_xml(line[3:]) + '</H2>'); i += 1 + elif line.startswith('### '): + elements.append('<H3><s>### </s>' + inline_to_xml(line[4:]) + '</H3>'); i += 1 + elif line.startswith('- '): + items = [] + while i < len(lines) and lines[i].startswith('- '): + items.append('<LI><s>- </s>' + inline_to_xml(lines[i][2:]) + '</LI>'); i += 1 + elements.append('<LIST>' + '\n'.join(items) + '</LIST>') + elif re.match(r'^\d+\. ', line): + items = [] + while i < len(lines) and re.match(r'^\d+\. ', lines[i]): + m = re.match(r'^(\d+)\. (.*)', lines[i]) + items.append('<LI><s>' + m.group(1) + '. </s>' + inline_to_xml(m.group(2)) + '</LI>'); i += 1 + elements.append('<LIST type="decimal">' + '\n'.join(items) + '</LIST>') + elif line.startswith('```'): + lang = line[3:].strip(); code_lines = []; i += 1 + while i < len(lines) and not lines[i].startswith('```'): + code_lines.append(xml_escape(lines[i])); i += 1 + i += 1 + tag = f'<CODE lang="{lang}">' if lang else '<CODE>' + elements.append(tag + f'<s>```{lang}</s><i>\n</i>' + '\n'.join(code_lines) + '\n<e>```</e></CODE>') + else: + para_lines = [] + while i < len(lines) and lines[i].strip(): + l = lines[i] + if l.startswith('## ') or l.startswith('### ') or l.startswith('- ') or l.startswith('```') or re.match(r'^\d+\. ', l): break + para_lines.append(l); i += 1 + elements.append('<p>' + inline_to_xml('\n'.join(para_lines)) + '</p>') + return '<r>' + '\n\n'.join(elements) + '</r>' +``` + +--- + +## PHP Insert Template + +Use `%%XML_CONTENT%%` as the placeholder — replace with the generated s9e XML before uploading. +The closing nowdoc marker `FLARUM_POST_XML_END;` must be at column 0 with no leading whitespace. + +```php +<?php +ini_set('display_errors', 1); error_reporting(E_ALL); +$dsn = 'mysql:host=localhost;dbname=azcompu_flarum;charset=utf8mb4'; +$pdo = new PDO($dsn, 'azcompu_flarum', 'Fl@rum2026!CGS', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); + +$user_id = 1; $tag_id = %%TAG_ID%%; +$title = %%TITLE_PHP%%; +$slug = '%%SLUG%%'; +$now = date('Y-m-d H:i:s'); + +$content = <<<'FLARUM_POST_XML_END' +%%XML_CONTENT%% +FLARUM_POST_XML_END; + +// Insert discussion first (post refs are nullable) +$stmt = $pdo->prepare("INSERT INTO discussions (title, comment_count, post_number_index, created_at, user_id, slug, is_private, is_approved) VALUES (?, 1, 1, ?, ?, ?, 0, 1)"); +$stmt->execute([$title, $now, $user_id, $slug]); +$disc_id = $pdo->lastInsertId(); +echo "Discussion ID: $disc_id\n"; + +// Insert post +$stmt = $pdo->prepare("INSERT INTO posts (discussion_id, number, created_at, user_id, type, content, is_private, is_approved) VALUES (?, 1, ?, ?, 'comment', ?, 0, 1)"); +$stmt->execute([$disc_id, $now, $user_id, $content]); +$post_id = $pdo->lastInsertId(); +echo "Post ID: $post_id\n"; + +// Link post back to discussion +$pdo->prepare("UPDATE discussions SET first_post_id=?, last_post_id=?, last_posted_at=?, last_posted_user_id=?, last_post_number=1 WHERE id=?")->execute([$post_id, $post_id, $now, $user_id, $disc_id]); + +// Tag +$pdo->prepare("INSERT INTO discussion_tag (discussion_id, tag_id) VALUES (?, ?)")->execute([$disc_id, $tag_id]); + +echo "Done! URL: https://community.azcomputerguru.com/d/$disc_id-$slug\n"; +``` + +### Template substitutions + +| Placeholder | How to fill | +|---|---| +| `%%TAG_ID%%` | Integer (e.g. `7`) | +| `%%TITLE_PHP%%` | PHP double-quoted string with escaped `"` — e.g. `"The Title Here"` | +| `%%SLUG%%` | URL-safe slug string | +| `%%XML_CONTENT%%` | The output of `md_to_s9e(content_md)` | + +Build the PHP script in Python using `.replace()` — never f-strings (curly braces in XML content +will cause Python to try to expand them as template expressions). + +--- + +## Paramiko Execution Pattern + +```python +import paramiko, time + +client = paramiko.SSHClient() +client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +client.connect('172.16.3.10', username='root', password=IX_PASS, timeout=10) + +# Upload +remote_path = f'/tmp/flarum_post_{int(time.time())}.php' +sftp = client.open_sftp() +with sftp.open(remote_path, 'wb') as f: + f.write(php_script.encode('utf-8')) +sftp.close() + +# Execute +def run_chan(cmd): + chan = client.get_transport().open_session() + chan.exec_command(cmd) + chan.shutdown_write() + out = b'' + while not chan.exit_status_ready(): + if chan.recv_ready(): out += chan.recv(4096) + while chan.recv_ready(): out += chan.recv(4096) + return out.decode('utf-8', errors='replace'), chan.recv_exit_status() + +out, rc = run_chan(f'php {remote_path} 2>&1') +run_chan(f'rm -f {remote_path}') +client.close() +``` + +--- + +## Error Handling + +| rc | Symptom | Cause | Fix | +|---|---|---|---| +| 255, no output | Script crashes silently | Exception with display_errors off | Script already includes `ini_set('display_errors',1)` | +| FK constraint error | Cannot add child row | Post inserted before discussion | Use discussion-first insert order (template above does this) | +| Empty curl response | localhost curl returns nothing | HTTP→HTTPS redirect on vhost | Use paramiko PHP approach, not curl | +| Cloudflare challenge | API blocked externally | Bot protection | Always insert via paramiko, never via external HTTP | + +--- + +## After Posting + +Report: +``` +[SUCCESS] Posted to community.azcomputerguru.com +Discussion: #<id> +URL: https://community.azcomputerguru.com/d/<id>-<slug> +Tag: How-Tos & Tips +``` + +Do NOT try to verify via curl or browser — Cloudflare blocks external API calls and +localhost curl has a redirect issue. The DB output is the authoritative confirmation. diff --git a/session-logs/2026-05-20-session.md b/session-logs/2026-05-20-session.md index 8cd91d7..eef8a95 100644 --- a/session-logs/2026-05-20-session.md +++ b/session-logs/2026-05-20-session.md @@ -548,3 +548,100 @@ Historical search: 21325332-a2a1-49c0-abb8-d0c6b88c7b0f (results to admin@cascad - Discord prompt: projects/discord-bot/DISCORD_CLAUDE.md - EXO InvokeCommand: POST /adminapi/beta/{tenant}/InvokeCommand - Historical search job: GET /adminapi/beta/{tenant}/HistoricalSearch('21325332-a2a1-49c0-abb8-d0c6b88c7b0f') + +--- + +## Update: 14:34 PT — Flarum forum posts + WordPress search audit + /forum-post skill + +### User +- **User:** Mike Swanson (mike) +- **Machine:** DESKTOP-0O8A1RL +- **Role:** admin + +### Session Summary + +Posted two technical articles to community.azcomputerguru.com via paramiko SSH + PHP PDO inserts into the Flarum database. The Flarum posting workflow had to be developed from scratch this session — the forum's Cloudflare Bot Fight Mode blocks external API calls, and the local `curl` from IX returns empty due to an HTTP→HTTPS redirect issue on the vhost. The only working approach: SSH to IX, build a PHP script with PDO inserts, SFTP upload to `/tmp/`, execute via SSH channel, parse output for Discussion ID and Post ID. + +First post was the OneDrive KFM folder redirection article (continued from a prior session context). The Flarum s9e TextFormatter XML format had to be reverse-engineered from existing posts — content is not stored as raw markdown but as a custom XML dialect with `<s>` (start marker) and `<e>` (end marker) wrappers inside each formatting element. A Python converter was written to translate markdown to this format. The first insert failed with a foreign key constraint because `posts.discussion_id` references `discussions.id` — reordering to insert discussion first (post refs are nullable at creation), then post, then UPDATE the discussion to set `first_post_id`/`last_post_id` resolved it. + +After the OneDrive article was posted, ran a server-wide WordPress search indexing audit across the IX cPanel server. Found 194 wp-config.php files; 18 sites had `blog_public=0` (search discouraged). Of those, 7 were confirmed production sites whose owners were unaware. Fixed 13 sites (updated `blog_public` to 1), skipped the forms.bestmassageintucson.com site which is intentionally not indexed. The WordPress search audit findings were then written up as a second forum article and posted via the same paramiko workflow. + +Converted the entire Flarum posting workflow into a reusable `/forum-post` skill at `.claude/commands/forum-post.md`. The skill covers all four phases: gather inputs (title, content, tag), preview, execute (paramiko SSH → SFTP upload PHP → execute → parse output), and report. Includes the complete Python md_to_s9e converter, PHP insert template with proper discussion-first ordering, error handling table, and hard rule to not verify via curl/browser. Added the command to CLAUDE.md's commands table. + +### Key Decisions + +- **paramiko over curl/API**: Cloudflare blocks external Flarum API calls; localhost curl on IX has redirect issues. Only working path is SSH→PHP→PDO. This is now the canonical approach documented in the skill. +- **Python .replace() not f-strings for PHP template substitution**: XML content contains `{}` curly braces; Python f-strings would try to expand them, causing a KeyError. Used `.replace('%%XML_CONTENT%%', xml_content)` throughout. +- **PHP nowdoc for XML content**: `<<<'FLARUM_POST_XML_END'` heredoc with closing marker at column 0 — no escaping needed for XML special characters inside. +- **Discussion-first insert order**: Flarum's `posts.discussion_id` has a FK constraint. Insert discussion with NULL post refs, then post, then UPDATE discussion. Template hardcodes this order. +- **bestmassage skip**: forms.bestmassageintucson.com is intentionally noindexed (likely a contact form subdomain) — excluded from the bulk fix. +- **Unique /tmp filename per run**: `flarum_post_<timestamp>.php` prevents collision if skill is run concurrently; always cleaned up after execution. + +### Problems Encountered + +- **FK constraint on posts.discussion_id**: First insert attempt failed with `SQLSTATE[23000]: 1452 Cannot add or update a child row`. Fix: reorder inserts — discussion first (post refs nullable), then post, then UPDATE. +- **PHP exit 255 no output**: `display_errors` was off by default. Added `ini_set('display_errors', 1); error_reporting(E_ALL);` to the PHP script to surface the FK error above. +- **Shell quoting in WP-config scan**: First bash-based scan used nested quotes and got empty blog_public values for all sites. Fix: Python + paramiko SFTP to read files directly, parse with regex — no quoting issues. +- **Python f-string curly brace conflict**: Initial PHP template using f-strings caused KeyError on `{` in XML content. Fix: switched to `.replace()` with `%%PLACEHOLDER%%` markers throughout. +- **Flarum admin detection**: `users` table has no `is_admin` column. Admin status is in `group_user` table (group_id=1 = Admin). user_id=1 (MikeSwanson) confirmed admin. +- **Chrome extension disconnected throughout**: Could not visually verify forum posts. Both posts confirmed via DB output only (Discussion ID/Post ID echoed by PHP script). + +### Configuration Changes + +- **Created:** `.claude/commands/forum-post.md` — Complete /forum-post skill with infrastructure refs, md→s9e converter, PHP template, paramiko pattern, error table +- **Modified:** `.claude/CLAUDE.md` — Added `/forum-post` to Commands & Skills table +- **Created (tmp):** `.claude/tmp/flarum_do_insert2.py` — Working OneDrive KFM post script +- **Created (tmp):** `.claude/tmp/flarum_search_insert.py` — WordPress search post script +- **Created (tmp):** `.claude/tmp/check_search.py`, `check_search2.py` — WP audit scripts +- **Created (tmp):** `.claude/tmp/fix_search.py` — Bulk blog_public fix script + +### Credentials & Secrets + +- **IX SSH root password:** `t4qygLl7{1zJcUj#022W^FBQ>}qYp-Od` — vault: `infrastructure/ix-server.sops.yaml credentials.password` +- **Flarum DB:** host=localhost (on IX), dbname=`azcompu_flarum`, user=`azcompu_flarum`, pass=`Fl@rum2026!CGS` + +### Infrastructure & Servers + +- **IX server:** 172.16.3.10 — root SSH, runs WHM/cPanel, hosts ~95 active WordPress sites + the Flarum forum +- **Flarum forum:** community.azcomputerguru.com — DB on localhost (IX), admin user_id=1 (MikeSwanson), group_id=1=Admin +- **WordPress sites:** Under `/home/*/public_html/` on IX. 194 wp-config.php files found, ~95 real sites + +### Commands & Outputs + +```bash +# WP search audit (Python paramiko approach) +py D:/claudetools/.claude/tmp/check_search2.py +# Found 18 disabled, 77 enabled, rest errors/stubs + +# Bulk fix +py D:/claudetools/.claude/tmp/fix_search.py +# FIXED (13): [list of sites] +# SKIPPED (1): forms.bestmassageintucson.com + +# Flarum post #1 (OneDrive KFM) +py D:/claudetools/.claude/tmp/flarum_do_insert2.py +# Discussion ID: 8 +# Post ID: 7 + +# Flarum post #2 (WordPress search) +py D:/claudetools/.claude/tmp/flarum_search_insert.py +# Discussion ID: 9 +# Post ID: 8 +``` + +### Pending / Incomplete Tasks + +- **WebSvr GuruRMM agent**: Blocked — CentOS 7 glibc 2.17 incompatible with current musl/glibc build targets. Options: add `x86_64-unknown-linux-musl` build target to pipeline, or skip (WebSvr is legacy/decommission candidate). No decision made. +- **IX backup offsite**: No remote transport configured in WHM. Comet Backup cron exists but binary not in PATH — non-functional. Needs S3/SFTP/Backblaze destination or Comet fix. +- **azcomputerguru.com SEO remaining**: + - Slider Revolution duplicate H1 — manual WP Admin fix + - Footer social icons still `href="#"` — need actual social profile URLs from Mike + - Google Search Console property + sitemap submission +- **Chrome extension**: Remained disconnected all session — could not visually verify forum posts + +### Reference Information + +- Forum post #1 (OneDrive KFM): https://community.azcomputerguru.com/d/8-onedrive-kfm-fails-after-folder-redirection-migration-heres-whats-actually-going-on +- Forum post #2 (WordPress search): https://community.azcomputerguru.com/d/9-wordpress-discourage-search-engines-setting-how-7-production-sites-lost-their-indexing +- Flarum s9e XML format reference: `.claude/commands/forum-post.md` (Markdown → s9e XML Converter section) +- forum-post skill: `.claude/commands/forum-post.md`