mini-cms-mcp
# mini-cms-mcp
Un CMS mic pentru site-uri de câteva pagini și un blog, administrat de un asistent AI prin MCP (Model Context Protocol).
**Principiul: AI-ul e singurul editor. Poate schimba conținutul, niciodată codul.**
**Site și documentație: [cms.paycode.ro](https://cms.paycode.ro)**, care rulează chiar pe mini-cms-mcp și e administrat din
conversația cu Claude, inclusiv de pe telefon. Acolo sunt [ghidul de instalare pas cu pas](https://cms.paycode.ro/instalare-cms-administrat-de-ai),
cu capturi din cPanel, și articolele despre [de ce există](https://cms.paycode.ro/de-ce-am-renuntat-la-wordpress) și
[cine îl scrie](https://cms.paycode.ro/despre).
- PHP simplu (8.0+). Fără Composer, fără bază de date, fără fișiere de pe alte servere, fără panou de administrare.
- Merge pe orice găzduire PHP obișnuită (Apache/cPanel). MCP prin Streamable HTTP fără sesiuni: fiecare cerere e un POST cu răspuns JSON.
- Circa 3.000 de rânduri PHP pe server, plus teste automate (307 verificări, inclusiv instalarea, actualizarea, copia de siguranță, OAuth, SEO și măsurarea).
- Se leagă de Claude Code (cheie în antet) și de conectorul din claude.ai, web și telefon (OAuth, aprobat cu cheia site-ului).
- Instalarea: o comandă pe calculator și un zip urcat în cPanel.
## Structura
```
site/ ← se urcă pe server, ca rădăcină a site-ului
index.php site-ul public (pagini, articole, sitemap, feed, robots, llms.txt)
mcp.php punctul de intrare pentru AI → https://site/mcp
jurnal.php jurnalul, pentru om (cheia se trimite prin formular)
imagini.php urcarea imaginilor din browser, pentru om (cheia de scriere, prin formular)
.htaccess reguli Apache (doar mod_rewrite și mod_headers, în IfModule)
assets/stil.css aspectul implicit
assets/teme/ teme alese cu seteaza_site: <nume>.css + fonturile în <nume>/ (de bază: simpluspv, cinesunt)
app/ codul (blocat din web)
sabloane/ șabloanele HTML (blocate din web)
date/ creat automat: pagini, articole, versiuni, jurnal, documentele PDF (blocat din web)
media/ creat automat: imaginile urcate
teste/ruleaza.php testele: pornesc o copie a site-ului și încearcă funcțiile și atacurile
unelte/instaleaza.php instalarea: teste, chei, config.php, pachetul .zip, verificarea serverului, legarea Claude Code
unelte/actualizeaza.php actualizarea codului de pe depozit, cerută de om (cheia de cod), cu copie și punere înapoi
unelte/copie.php copia de siguranță: salvează tot site-ul pe calculator și îl poate pune la loc (sau pe alt site)
unelte/urca-imagine.php urcă poze de pe calculator: le întoarce după telefon, le micșorează, scoate locația GPS
unelte/editor.php editorii unui site (de ex. clientul): cheie pe numele lor, adăugare și scoatere (cheia de cod)
unelte/tema.php temele proprii ale unui site (lucrări pentru un client): le pune, le înlocuiește, le scoate (cheia de cod)
unelte/comun.php funcțiile comune ale uneltelor
unelte/stdio.php puntea stdio: un site demo gol, local, pentru inspectoare și cataloage MCP (Dockerfile)
unelte/genereaza-cheie.php, unelte/router-local.php
```
## Instalare, în doi pași
Condiția: un domeniu sau subdomeniu doar pentru site (site-ul stă la rădăcină), cu PHP 8.0+ și HTTPS.
Ghidul complet, pentru cine n-a instalat niciodată PHP, cu capturi din cPanel:
[cms.paycode.ro/instalare-cms-administrat-de-ai](https://cms.paycode.ro/instalare-cms-administrat-de-ai).
**1. Pe calculator**, din folderul repo-ului:
```
php unelte/instaleaza.php https://test.exemplu.ro
```
Comanda rulează testele, generează cele două chei, scrie `config.php` (adresa și **doar amprentele** cheilor) și face
pachetul `minicms-<versiune>-<nume>.zip`. Cheile și pachetul stau în folderul de deasupra repo-ului, în afara lui git:
`chei-<nume>.json` și `_livrare/<nume>/`. Cheile nu apar niciodată pe ecran; copiază-le în managerul de parole.
La o nouă rulare, cheile existente se refolosesc, iar pachetul și config-ul vechi se păstrează cu data în nume.
**2. În cPanel**: File Manager → folderul domeniului → Upload zip-ul → Extract → șterge zip-ul. Apoi Enter în terminal.
Comanda verifică serverul (`/mcp` 405; dosarele interne, jurnalul și zip-ul blocate — 403 sau 404, după găzduire, fără nimic
din fișier în răspuns; ambele chei), leagă Claude Code
de site cu cheia de scriere (pentru folderul proiectului) și arată adresa jurnalului.
Dacă ceva nu e în regulă, spune cauza și așteaptă să repari pe server. Verificarea se poate relua oricând:
```
php unelte/instaleaza.php https://test.exemplu.ro --verifica
```
După instalare, în Claude: *„Cheamă despre_site, apoi setează numele site-ului, descrierea și autorul.”*
Numele, descrierea, autorul, limba și culoarea sunt conținut: le schimbă AI-ul cu `seteaza_site`, rămân în jurnal și
au versiuni. `config.php` ține doar ce nu trebuie să schimbe AI-ul: adresa și amprentele. Nu intră în git și nu poate
fi modificat prin MCP; ajunge pe server o singură dată, în pachetul urcat de om.
Dacă folderul avea deja un `.htaccess` pus de cPanel (MultiPHP), după Extract alegi din nou versiunea de PHP în
MultiPHP Manager, ca cPanel să-și rescrie blocul.
## Teme
O temă e o foaie de stil pusă de om în `site/assets/teme/<nume>.css`, cu fonturile ei în `site/assets/teme/<nume>/`
(găzduite pe site: CSP-ul permite fonturi doar de pe același domeniu). Se încarcă după `assets/stil.css` și schimbă doar
aspectul, pe același HTML. AI-ul vede temele în `despre_site` și alege una cu `seteaza_site` (`tema`, `""` = aspectul
implicit); nu poate scrie CSS. O temă aleasă dar scoasă de pe server e ignorată, fără eroare.
Tema `simpluspv` copiază blogul de pe simpluspv.eu (Bricolage Grotesque, Hanken Grotesk, Newsreader, toate sub SIL OFL 1.1).
Culoarea principală rămâne cea din identitate. Știe și două clase din articolele de acolo: `p.aerisit` (spațiu mai mare
după paragraf) și `img.ingust` (captură de telefon, 340 px, centrată), plus containerul video `div.cai-video`.
Tema `cinesunt` e aspectul site-ului cinesunt.info, mutat aici din CMS-ul lui vechi (Space Grotesk, Inter, JetBrains Mono,
tot SIL OFL 1.1): erou pe două coloane cu o cartelă de chat, pașii metodei, pastile colorate pe domenii, carduri cu
eticheta peste imagine. Prima pagină se așază în aceeași grilă cu titlul din șablon, doar din CSS.
**Ce știe tema despre ea o spune AI-ului:** primul comentariu din foaia de stil apare în `despre_site` (`teme.despre`).
Acolo își descrie autorul temei blocurile (clasele) pe care le știe, ca AI-ul să le folosească în conținut fără să
ghicească. Pagina poartă și clasa tipului ei pe `<body>` (`pagina-acasa`, `pagina-articol`, `pagina-lista`…), iar
fiecare etichetă clasa `eticheta-<slug>`, deci o temă poate așeza diferit prima pagină și poate colora etichetele.
### Teme de bază și teme proprii
În depozit stau doar **temele de bază** (`simpluspv`, `cinesunt`): vin cu fiecare pachet, pe orice site. O temă făcută
pentru un client e o **temă proprie**: nu intră în depozit și nici în pachet. Stă doar pe calculatorul tău și pe serverul
acelui client. Dosarul ei de lucru e `teme/`, lângă depozit (implicit `<dosar>/teme/`, deci în afara repo-ului), așezat
exact ca `assets/teme/`: `client.css` și, lângă ea, dosarul `client/` cu fonturile și imaginile.
```
php unelte/tema.php https://site.ro temele de pe site: de bază și proprii, plus cea aleasă
php unelte/tema.php https://site.ro --pune=client pune sau înlocuiește tema din teme/client.css + teme/client/
php unelte/tema.php https://site.ro --scoate=client scoate tema proprie de pe site
php unelte/instaleaza.php https://site.ro --tema=client la instalare: tema se pune imediat după verificarea serverului
```
- **Merge cu cheia de cod**, ca actualizarea codului: AI-ul (cheia de scriere, conectorul din claude.ai) nu poate pune CSS
pe site. După ce tema e pe server, AI-ul o vede în `despre_site` (`teme.proprii`) și o alege cu `seteaza_site`.
- **Tema se pune întreagă.** Ce era pe server și lipsește din versiunea nouă iese. Versiunea înlocuită sau scoasă se
păstrează întâi în `date/versiuni/teme/<nume>/<data>/`.
- **Sincronizarea cu depozitul nu atinge temele proprii**, nici dacă depozitul capătă cândva o temă cu același nume:
registrul lor stă în `date/teme-proprii.json`, iar `date/` nu vine niciodată din depozit. Invers, o temă de bază nu
poate fi înlocuită cu una proprie: tema clientului primește alt nume.
- **Ce intră într-o temă:** `.css`, `.woff2`, `.woff`, `.ttf`, `.otf`, `.png`, `.jpg`, `.gif`, `.webp`, `.avif`; cel mult
80 de fișiere, 2 MB fiecare, 6 MB în total. Nu intră SVG (deschis direct, ar rula script pe domeniul site-ului) și nici
fișiere ascunse; `Thumbs.db` și `desktop.ini` se sar singure.
- Unealta verifică adresele din foaie: un font care lipsește, sau care vine din dosarul altei teme (o temă făcută din
copia uneia de bază, redenumită doar pe jumătate), e semnalat înainte de trimitere.
- Fonturile se țin în cache un an. Când schimbi un font, dă-i fișierului alt nume. Foaia de stil se reîncarcă singură.
- Copia de siguranță (`copie.php`) ține doar numele temei. Pusă pe alt site care nu are tema, copia pune restul
identității și îți spune comanda cu care urci tema.
## Blocurile comune și paginile de pornire
Un site nou nu mai pornește gol. Pentru asta există două piese, amândouă conținut, nu cod pus de AI:
- **Blocurile comune**, în `assets/stil.css`, deci pe orice site și sub orice temă: `bloc-carduri` (cu `bloc-card`), `bloc-pasi`
(pași numerotați), `bloc-citat` (cu `cite`), `bloc-actiune` (chemarea de la final, cu `a.buton`), `bloc-coloane`, `bloc-galerie`,
`bloc-nota` (plus `nota-verde`, `nota-galbena`, `nota-rosie`) și `bloc-cifre`. Întrebările frecvente rămân `h2` + `h3` + răspuns,
direct în conținut, fără cutie: altfel `FAQPage` nu le mai găsește. AI-ul le vede în `despre_site` (`blocuri`), fiecare cu rostul
lui și un exemplu de HTML. Testele verifică exemplele: fiecare trece prin filtru neatins și fiecare clasă are reguli în foaia de bază.
Regulile au prefixul `main .continut`, ca să stea deasupra regulilor generale ale unei teme; o temă le restilizează cu același prefix.
- **Paginile de pornire**: comanda `pagini_de_pornire` (cheia de citire ajunge) întoarce scheletele paginilor acasă, despre, servicii,
contact și confidențialitate, făcute din blocuri, cu locuri `[[COMPLETEAZĂ: …]]`, plus întrebările de pus omului. **Nu creează
nimic.** AI-ul le completează cu ce află de la om, le salvează ca ciorne, trimite linkul de previzualizare, iar omul aprobă.
Răspunsul arată și care pagini există deja, ca să nu fie suprascrise.
- **Un loc de completat oprește publicarea**: `publica` refuză un element care mai are `[[COMPLETEAZĂ…]]` în titlu, descriere sau
conținut, iar `salveaza` refuză să-l pună pe o pagină deja publicată. Un schelet uitat nu ajunge pe site.
- **Pagina de confidențialitate** descrie ce face tehnic site-ul, verificat în cod: nu pune cookie-uri proprii, nu are formulare,
nu păstrează căutările, fonturile și imaginile vin de pe site, iar jurnalul lui scrie doar administrarea și încercările de a intra
în ea, nu vizitele. Ce e pornit la momentul cererii intră singur: Cloudflare, GA4 (cu atenționarea că site-ul nu are banner de
consimțământ) și videoclipurile YouTube/Vimeo din pagini. Datele firmei și ale găzduirii rămân locuri de completat. **Nu e
consultanță juridică:** proprietarul o verifică înainte de publicare.
## Actualizarea codului, fără zip și fără cPanel
Din 0.9, site-ul își ia singur versiunea nouă din depozit — dar **numai când i-o ceri tu**, niciodată de la sine și
niciodată la cererea AI-ului. Prima instalare rămâne cu pachetul urcat de mână; de la ea încolo:
```
php unelte/actualizeaza.php https://site.ro
```
Comanda întreabă serverul ce versiune are și ce e în depozit, îți arată fișierele care s-ar schimba, apoi (după ce
confirmi, sau direct cu `--acum`) îi cere să se sincronizeze. Același lucru se poate face și din browser, pe
`https://site.ro/actualizare.php`, lipind cheia de cod în formular.
**Cheia de cod e a treia cheie**, alături de cea de citire și cea de scriere. Stă doar pe calculatorul tău, în
`chei-<nume>.json`, iar pe server e tot o amprentă. **Nu e cheie de MCP:** AI-ul nu o primește niciodată, iar un token
OAuth al conectorului din claude.ai nu deschide nimic din actualizare. Principiul rămâne cel de la început — asistentul
schimbă conținutul, nu codul.
Ce face serverul, în ordine, într-o singură cerere:
1. descarcă pachetul depozitului (`depozit` din `config.php`, implicit `paycodero/mini-cms-mcp`, ramura `main`);
2. verifică fiecare cale din el: doar din dosarul `site/`, fără `..`, doar extensii din lista permisă;
`app/config.php`, `date/` și `media/` nu se ating **niciodată**;
3. refuză o versiune mai veche decât cea instalată (`forta` o acceptă, dacă chiar vrei să cobori);
4. salvează fișierele pe care urmează să le înlocuiască, în `date/versiuni/cod/<data>/`;
5. scrie doar ce diferă;
6. **își cere singur prima pagină**: dacă nu mai răspunde 200, pune la loc versiunea veche în aceeași cerere și îți
spune ce a pățit. Dacă nu poate ajunge la el însuși (unele găzduiri nu permit), spune „necunoscut" și nu presupune
nimic — verificarea din afară o face comanda de pe calculatorul tău, care pune și ea înapoi copia dacă site-ul
nu răspunde cum trebuie.
Două lecții de pe o găzduire reală (cinesunt.info, 21 septembrie 2026), reparate în 0.12:
- **Memoria PHP a găzduirii (OPcache).** Unele găzduiri țin codul compilat și nu se uită după fișierul schimbat: pe disc
e versiunea nouă, dar site-ul rulează tot codul vechi și raportează versiunea veche. Acum serverul își golește memoria
pentru fiecare fișier PHP pe care îl scrie. Iar comanda de pe calculator pune copia înapoi **numai dacă site-ul nu mai
răspunde**; dacă merge dar raportează încă versiunea veche, așteaptă și reverifică, apoi spune ce vede, fără să atingă ceva.
- **Punerea înapoi a unei copii nu mai poate închide site-ul.** Copiile făcute înainte de 0.12 conțineau paza dosarului de
date (un `.htaccess` cu „Require all denied”), iar punerea înapoi o scria peste `.htaccess`-ul site-ului: 403 pe orice
adresă, fără cale de reparat din afară. Acum copiile nu mai au paza lor, iar punerea înapoi sare peste ea și la copiile vechi.
Dacă ți s-a întâmplat pe o versiune mai veche: în cPanel urci din nou `site/.htaccess` peste cel din rădăcină, apoi
alegi din nou versiunea PHP în MultiPHP Manager.
Copiile rămân pe server: `php unelte/actualizeaza.php https://site.ro --copii` le listează, iar `--pune=<copie>` pune
una înapoi. `'actualizare' => false` în `config.php` scoate cu totul pagina și comanda.
⚠️ **Prețul acestei comodități, spus pe față:** serverul are încredere în depozit. Cine ajunge la contul de GitHub poate
pune cod pe site. Ce se poate fără GitHub — cale în afara dosarului `site/`, extensie nepermisă, atingerea configurării,
coborârea versiunii — e refuzat de server. Dacă vrei și apărare împotriva unui depozit compromis, pasul următor e
semnarea pachetului cu o cheie privată de pe calculatorul tău; nu e construită.
## Imaginile, fără base64 prin conversație
`urca_imagine` primea imaginea codată base64: bun pentru o siglă mică, prea scump și prea lent pentru o poză. O poză atașată
în chat nu ajută nici ea: ajunge la AI ca imagine de privit, nu ca fișier pe care să-l poată trimite mai departe. Drumurile:
- **Poza e la om (telefon, calculator, chat) — drumul obișnuit, din 0.13:** AI-ul cheamă `link_urcare` și îi dă omului un link
semnat, valabil 30 de minute (5–120). Omul îl deschide, alege poza, apasă „Urcă”; nicio cheie, nimic de completat. Apoi AI-ul
o găsește cu `listeaza_imagini` și o pune unde trebuie. Semnătura folosește cheia previzualizărilor într-un domeniu separat,
deci un link de previzualizare nu merge ca link de urcare; un link falsificat se numără ca încercare eșuată.
- **E deja pe internet:** `urca_imagine` cu `url` (doar https, portul 443). Serverul o descarcă singur. Apărarea contra
SSRF: numele se rezolvă o dată și **toate** adresele găsite trebuie să fie publice (nu 127.x, 10.x, 192.168.x, 169.254.x
și celelalte rezervate; la IPv6 trec doar adresele globale, `2000::/3`, deci IPv4 ascuns în IPv6 e refuzat în orice formă
ar fi scris: `::ffff:127.0.0.1`, `::127.0.0.1`, `::a9fe:a9fe`); conexiunea se face exact la adresa verificată, deci
DNS-ul nu poate fi schimbat între verificare și descărcare; redirecționările, cel mult 3, trec fiecare prin aceleași
verificări. Fără curl și fără `allow_url_fopen`. `'imagini_url' => false` în `config.php` oprește drumul.
- **E pe calculator (Claude Code):**
```
php unelte/urca-imagine.php https://site.ro poza.jpg [alta.png …] [--latime=1600] [--original]
```
Întoarce poza după orientarea din telefon, o micșorează la 1600 px pe latura mare și o rescrie, deci JPEG-ul pierde
datele EXIF, inclusiv locația GPS. La final scrie adresele `/media/...`. Are nevoie de GD; pe Windows, dacă `php_gd.dll`
stă lângă PHP dar e oprit în `php.ini`, comanda îl pornește doar pentru ea. Fără GD, pozele pleacă neatinse.
- **Fără AI:** `https://site.ro/imagini.php`, cu cheia de scriere în formular (niciodată în adresă, ca la jurnal).
Pagina micșorează pozele în browser înainte de trimitere, deci pleacă repede și fără locația GPS; cheia de citire e
refuzată, fiecare fișier e verificat și scris în jurnal, pagina nu se indexează. Apoi îi dai AI-ului adresa. Se scoate
cu `'pagina_imagini' => false`.
Toate trei trec prin aceleași verificări ca base64: tipul aflat din conținut, cel mult 5 MB, fără cod ascuns, fără SVG.
## Documentele PDF
`urca_fisier` pune pe site un document PDF (cel mult 25 MB) și întoarce adresa lui, `/fisiere/<nume>.pdf`, de pus într-un
link în conținut. Numele îl alege AI-ul (curățat: litere mici, cifre, cratime) și rămâne adresa documentului.
- **Nu stau în dosarul public**, ca imaginile, ci în `date/fisiere/`. Le servește PHP, cu `Content-Type: application/pdf`,
`nosniff`, `Content-Disposition: inline` și ETag. Un fișier urcat nu poate fi rulat de server oricum s-ar numi, antetele
merg și fără `mod_headers`, actualizarea codului nu le atinge, iar copia de siguranță le ia odată cu conținutul.
- **Tipul se află din conținut:** începe cu `%PDF-` și se încheie cu `%%EOF`. O imagine, un text redenumit sau un PDF tăiat
sunt refuzate.
- **De unde vine:** din `url` (https, aceeași apărare SSRF ca la imagini, 60 de secunde), așa că merg și documentele mari;
din `continut_base64` încap cel mult ~6 MB, din cauza limitei de 8 MB pe cerere.
- **Înlocuirea** aceluiași nume cu alt conținut cere `inlocuieste=true`. Adresa rămâne aceeași, deci linkurile din pagini nu
se strică, iar versiunea veche se păstrează în `date/versiuni/fisiere/`. Același conținut urcat din nou nu scrie nimic.
- **`sterge_fisier`** refuză un document legat dintr-o pagină sau dintr-un articol, în afară de cazul `forteaza=true`.
- Documentul nu primește Content-Security-Policy, și asta e intenționat: `sandbox` și `object-src 'none'` fac vizualizatorul
PDF din Chrome să arate „blocat”. Protecția e tipul fix cu `nosniff`: browserul nu-l tratează niciodată ca pagină a site-ului.
- Pe un site mutat de pe alt CMS, un PDF vechi pus de mână în dosarul public `/fisiere/` are întâietate, fiindcă e fișier real.
Un document nou cu același nume e refuzat, ca să nu stea ascuns în spatele celui vechi.
## Site multilingv (RO + EN) — 0.20
Un site poate fi în mai multe limbi. Se pornește cu `seteaza_site` (`limbi`), o listă de coduri unde **prima e limba
implicită** și stă la rădăcină (`/`), iar celelalte primesc un prefix de adresă (`/en/...`). O singură limbă sau lista goală
= site monolingv, exact ca înainte — funcția e aditivă, nimic nu se schimbă pe site-urile cu o singură limbă.
- **Fiecare pagină și articol are un câmp `limba`** (una dintre `limbi`; lipsa lui = limba implicită) și un câmp `grup`, care
leagă un element de traducerile lui: `despre` (ro) și `about` (en) primesc același `grup`, iar site-ul știe astfel că sunt
aceeași pagină în limbi diferite. Se pun la `salveaza`.
- **O singură adresă canonică pe limbă:** pagina în engleză se vede la `/en/<slug>` și **nu** la rădăcină; cea în română la
`/<slug>`. Prima pagină a fiecărei limbi stă la `/` și `/en` (slugul ei redirecționează acolo).
- **Comutatorul de limbă** apare în antet și duce la traducerea paginii curente (sau la prima pagină a limbii, dacă nu are una).
- **hreflang** peste tot: `<link rel="alternate" hreflang="…">` + `x-default` în `<head>`, și `xhtml:link` în sitemap — așa
Google și asistenții AI știu că paginile sunt aceeași, în limbi diferite. `<html lang>`, JSON-LD `inLanguage`, feed-ul și
llms.txt urmează limba paginii.
- **Identitatea tradusă:** `seteaza_site` (`traduceri`) dă, per limbă, variantele de `nume`, `descriere`, `subsol` și
`nume_articole`. Ce lipsește într-o limbă cade pe valoarea de bază. ex. `{"en":{"nume":"The New Journal…"}}`.
## Copia de siguranță
```
php unelte/copie.php https://site.ro
```
Salvează tot site-ul în `_copii/<nume>/<data>/` (în folderul de deasupra repo-ului): `export.json` cu paginile și articolele
(inclusiv ciornele și cele programate), identitatea și redirecționările, plus imaginile și documentele PDF, verificate după amprentă. Folosește
cheia de citire. Nicio copie nu se scrie peste alta.
```
php unelte/copie.php https://site-nou.ro --pune=_copii/<nume>/<data>
```
Pune copia pe un site (de obicei unul nou, gol), cu cheia de scriere: imaginile cu aceleași adrese, apoi identitatea,
elementele cu starea, data publicării și autorul lor, și redirecționările. Pe un site care are deja conținut cere și
`--peste`; elementele cu același slug se modifică, cu versiunea anterioară păstrată.
## Schimbarea cheilor
Dacă o cheie a scăpat: `php unelte/instaleaza.php https://site --chei-noi`. Cheile vechi se păstrează cu data în nume,
config-ul și pachetul se refac. Urci pe server doar `app/config.php` din `_livrare/<nume>/` (sau tot pachetul): din acel
moment cheile vechi nu mai merg. Apoi `--verifica`, care înlocuiește și cheia din conexiunea Claude Code.
## Editorii: clientul scrie cu cheia lui (0.19)
Când clientul vrea să modifice singur site-ul, primește **cheia lui**, nu pe a ta. Poate tot ce poate cheia de scriere —
creează, modifică, **publică direct**, retrage, șterge (reversibil), restaurează, urcă imagini și PDF-uri, redirecționează —
afară de două lucruri, care rămân la tine: identitatea site-ului (`seteaza_site`: nume, logo, temă, subsol, mențiunea
realizatorului) și conexiunile OAuth (`retrage_conexiune`). Comenzile acestea nici nu apar în lista lui.
```
php unelte/editor.php https://site.ro editorii de pe site
php unelte/editor.php https://site.ro --adauga="Maria Popescu" cheie nouă pentru Maria, pusă pe site
php unelte/editor.php https://site.ro --scoate="Maria Popescu" cheia ei nu mai merge, nici conexiunile ei OAuth
```
- Cheia se scrie în `chei-<nume>-editor-<om>.json`, lângă cheile tale, cu adresa MCP și comanda `claude mcp add` gata de
copiat: fișierul acela i-l dai omului. Pe server ajunge doar amprenta, în `date/securitate/editori.json`.
- Merge cu **cheia de cod**, ca temele: AI-ul nu își poate face singur chei. Nu se urcă nimic în cPanel.
- **Cine, ce, când:** fiecare rând din jurnal are `cine` (`admin` sau numele editorului), inclusiv refuzurile; fiecare
pagină și articol ține `modificat_de`, care trece în versiuni — `listeaza_versiuni` arată cine a scris fiecare versiune.
Pozele urcate cu un link de urcare apar pe numele celui care a cerut linkul (semnat, nu se poate schimba).
- Pe claude.ai (web, aplicația pentru calculator, telefon): își ia singur codul de conectare de pe `https://site/oauth/conectare`,
cu cheia lui, apoi aprobă tot cu ea. Tokenul rămâne al lui; când îl scoți, moare odată cu cheia. În Claude Code se leagă
direct cu cheia în antet (comanda e în fișierul lui), fără cod.
## Conectorul din claude.ai (web și telefon)
Legarea se face într-o **fereastră deschisă de tine**, de pe calculator (din 0.6 — înainte, oricine putea porni o aprobare pe
site-ul tău, cu numele „Claude", și dacă o aprobai primea el token-urile):
```
php unelte/instaleaza.php https://site.ro --oauth
```
Comanda cere serverului, cu cheia de scriere, să deschidă înregistrarea 15 minute și îți arată în terminal un **cod de conectare**
de 6 cifre. Fără terminal (0.19.1): pagina `https://site/oauth/conectare` face același lucru, după ce lipești cheia de scriere
sau o cheie de editor; codul apare pe pagină. Apoi, în claude.ai: **Settings → Connectors → Add custom connector**, cu adresa `https://site/mcp`. Claude se
înregistrează singur și deschide pagina de aprobare a site-ului: acolo introduci **codul din terminal** și **cheia de scriere**
(sau pe cea de citire, pentru acces doar de citire) și apeși *Permite*. După aprobare fereastra se închide singură; o închizi mai
devreme cu `--oauth --inchide`. De acolo, Claude primește token-uri temporare; conectorul apare și în aplicația de telefon.
Reînnoirea token-urilor merge oricând, și cu fereastra închisă: o conexiune aprobată nu se rupe.
- În afara ferestrei, `/oauth/inregistrare` și pagina de aprobare răspund „închis": un link de aprobare trimis de un străin nu
deschide nimic. Codul de 6 cifre e singurul lucru pe care nu-l poate avea cineva care îți citește codul sursă.
- `'oauth' => 'deschis'` în `config.php` readuce purtarea din 0.5 (fără fereastră, fără cod), iar `false` scoate OAuth cu totul.
- OAuth 2.1 cu PKCE (S256) obligatoriu, înregistrare automată a clientului (RFC 7591), descoperire prin
`/.well-known/oauth-protected-resource` și `/.well-known/oauth-authorization-server`.
- Codul de aprobare poate fi trimis doar spre `claude.ai`, `claude.com` sau calculatorul omului (`localhost`, pentru Claude Code);
alte gazde se adaugă în `config.php`, la `'oauth_gazde'`.
- Token de acces 1 oră, de reînnoire 60 de zile, rotit la fiecare folosire. Pe server stau doar amprentele lor.
- Un token are drepturile cheii cu care a fost aprobat și nu mai merge după `--chei-noi`. Accesul se vede cu
`listeaza_conexiuni` și se retrage cu `retrage_conexiune`. Fiecare pas e în jurnal, fără token-uri sau coduri.
## Legarea de mână la Claude Code
`instaleaza.php` o face singur. De mână, cu cheia citită din fișier (PowerShell), din folderul proiectului:
```
$k = (Get-Content chei-<nume>.json -Raw | ConvertFrom-Json).scriere.cheie
claude mcp add --transport http <nume> https://site/mcp --header "Authorization: Bearer $k"
```
Dacă găzduirea nu transmite antetul `Authorization` către PHP, se folosește `--header "X-API-Key: $k"`
(`instaleaza.php` detectează singur cazul). Cheia de citire se folosește pentru un asistent care doar verifică.
Conectorul din claude.ai (web, telefon) cere OAuth, care e în lucru (vezi mai jos).
## Comenzile
| Comanda | Cheie | Ce face |
|---|---|---|
| `despre_site` | citire | regulile site-ului, HTML-ul permis, blocurile comune, temele, rolul cheii |
| `pagini_de_pornire` | citire | scheletele paginilor unui site nou (acasă, despre, servicii, contact, confidențialitate), de completat cu omul; nu creează nimic |
| `listeaza` | citire | paginile și articolele, fără conținut |
| `citeste` | citire | un element întreg |
| `cauta` | citire | caută în titluri, descrieri, conținut |
| `listeaza_versiuni` | citire | versiunile salvate automat ale unui element |
| `listeaza_imagini` | citire | imaginile din `/media/` |
| `citeste_jurnal` | citire | ultimele intrări și verificarea lanțului |
| `previzualizeaza` | citire | link temporar (implicit 60 de minute) la care omul vede o ciornă exact ca pe site, înainte de publicare |
| `listeaza_redirectionari` | citire | adresele vechi care trimit spre adrese noi |
| `exporta` | citire | tot conținutul, pentru copia de siguranță (vezi `unelte/copie.php`) |
| `listeaza_conexiuni` | citire | aplicațiile legate prin OAuth (conectorul claude.ai): cine, cu ce drepturi, dacă au acces acum |
| `salveaza` | scriere | creează (ca ciornă) sau modifică o pagină ori un articol (pe un site multilingv: și `limba` + `grup`) |
| `seteaza_site` | scriere | numele, descrierea, autorul, limba (și `limbi` + `traduceri` pentru un site multilingv), culoarea, logo-ul, favicon-ul, tema, legăturile și textul din subsol, mențiunea realizatorului, identificatorul GA4, numele articolelor și data vizibilă; păstrează versiunea anterioară |
| `publica` / `retrage` | scriere | pune pe site / scoate de pe site (rămâne ciornă); `publica` cu `la` în viitor programează, în trecut păstrează data |
| `retrage_conexiune` | scriere | anulează accesul unei aplicații legate prin OAuth |
| `redirectioneaza` | scriere | adresă veche → adresă nouă de pe site, 301 (doar când la adresa veche nu mai e nimic); `la` gol o scoate |
| `sterge` | scriere | mută elementul între versiuni (reversibil) |
| `restaureaza` | scriere | aduce înapoi o versiune, ca ciornă |
| `link_urcare` | scriere | un link temporar la care omul urcă poze fără cheie; AI-ul le găsește apoi cu `listeaza_imagini` |
| `urca_imagine` | scriere | JPEG/PNG/GIF/WebP, max 5 MB, din `url` (https, cu apărare SSRF) sau `continut_base64`; extensia se stabilește din conținut |
| `sterge_imagine` | scriere | mută imaginea între versiuni; refuză dacă e folosită |
| `listeaza_fisiere` | citire | documentele PDF din `/fisiere/` |
| `urca_fisier` | scriere | un PDF, max 25 MB, din `url` (https, cu apărare SSRF) sau `continut_base64` (~6 MB); adresa `/fisiere/<nume>.pdf`; `inlocuieste=true` pune conținut nou la aceeași adresă, cu versiunea veche păstrată |
| `sterge_fisier` | scriere | mută documentul între versiuni; refuză dacă e legat din conținut |
Paginile și articolele au adrese comune: `/despre`, `/primul-articol`. Pagina `acasa` e prima pagină.
Vizitatorii au căutare (`/cauta?q=…`, în antet): doar în ce e pe site, cu sau fără diacritice ("sedinta" găsește "ședința").
Articolele programate apar singure la ora lor: nu e nevoie de sarcini programate pe server.
## SEO și citit de agenți
Site-ul e făcut ca să fie găsit de oameni prin Google și Bing, dar și citit de asistenți AI, fără nicio unealtă în plus.
- **Adrese generate singure:** `/sitemap.xml` (cu `lastmod`, coperțile ca imagini și paginile de etichetă), `/feed.xml`
(RSS cu legătură spre el însuși, autor și etichete), `/robots.txt` (îi numește pe rând pe Googlebot, Bingbot, GPTBot,
ClaudeBot, PerplexityBot și restul, și arată unde e `llms.txt`), `/llms.txt` (rezumatul site-ului pentru modele:
adresă, limbă, autor, lista paginilor și a articolelor cu descriere, dată și etichete).
- **Date structurate (JSON-LD):** `Article` cu editor, limbă, etichete și pagina-părinte · `WebSite` cu `SearchAction`
(căutarea site-ului, pentru Google) · `BreadcrumbList` pe fiecare pagină · **`FAQPage` construit singur** dintr-o
secțiune `<h2>Întrebări frecvente</h2>` cu `<h3>` întrebare + răspuns, dacă articolul are una.
- **Cardurile sociale:** `og:` complet, cu `og:locale`, măsurile copertei, textul ei alternativ, data publicării și a
modificării, etichetele. Titlul din bara browserului nu repetă numele site-ului când e deja în el.
- **Verificarea în Search Console și Bing Webmaster Tools:** `'verificari' => ['google-site-verification' => '…',
'msvalidate.01' => '…']` în `config.php` pune etichetele meta cerute.
- **IndexNow:** la publicare, modificare sau retragere, adresa pleacă singură spre Bing (și Yandex, Seznam, Naver).
Cheia stă în `date/securitate/` și se servește la `https://site/<cheie>.txt`, fără niciun fișier pus în rădăcină.
Se oprește cu `'indexnow' => false`. Google nu are un punct echivalent: acolo rămâne sitemap-ul.
- **Măsurarea (GA4):** `seteaza_site` (`ga4`) primește DOAR identificatorul, ex. `G-798XLP278H` — eticheta o compune
site-ul, cu nonce-ul paginii, iar sursele de care are nevoie intră singure în CSP. AI-ul poate porni măsurarea, dar
nu poate pune JavaScript în pagină: identificatorul e validat cu un tipar, iar orice altceva e refuzat. Se încarcă
numai pe paginile publice — jurnalul, actualizarea, căutarea, previzualizările și aprobările nu se măsoară.
- **Rețeaua autorului:** `seteaza_site` (`legaturi`) pune celelalte site-uri și conturi în subsol, pe fiecare
pagină, și aceleași adrese în `sameAs` din datele structurate — de acolo află Google și Bing că profilurile sunt
ale aceleiași entități. Sunt conținut, nu configurare: rămân la locul lor când urci un pachet nou.
- **Textul din subsol:** `seteaza_site` (`subsol`) pune o mențiune scurtă pe fiecare pagină și în llms.txt — ce nu oferă
site-ul, sau firma și CUI-ul. Text simplu, fără HTML.
- **Cine a făcut site-ul:** `seteaza_site` (`realizare`, ex. „Website realizat cu AI și miniCMS”, plus `realizare_url`)
pune mențiunea pe rândul cu ©, ca link dacă are adresă. Nu e o legătură a autorului: nu intră în `sameAs`, nu are
`rel="me"` și nu apare în llms.txt, ca Google să nu lege site-ul realizatorului de entitatea autorului.
- **Cum se numesc articolele:** `seteaza_site` (`nume_articole`, ex. `ghiduri`) schimbă cuvântul din meniu, de pe prima
pagină și din liste („Ultimele ghiduri", „Toate ghidurile"). Adresa rămâne `/articole`.
- **Meniul pe două niveluri:** o pagină cu `parinte` (slugul unei pagini din meniu) stă în submeniul ei, în ordinea din
`meniu`; adresa rămâne `/<slug>`. Submeniul se deschide la mouse și din tastatură, fără JavaScript; pe ecrane tactile,
pagina-părinte își listează singură subpaginile („În această secțiune”), iar subpagina are un link înapoi și firimituri
Acasă › Secțiune › Pagină. Un singur nivel de subpagini; o pagină care are subpagini nu se șterge până nu le muți.
- **Data pe pagini:** implicit nu apare (pe un site de documentație o dată lângă titlu face conținutul să pară vechi);
`arata_data` = `da` o pune pe articol și pe carduri, pentru un blog. În sitemap, feed și datele structurate e oricum.
- **Evenimente:** un articol care anunță un concert, un curs sau o lansare primește câmpul `eveniment` (început, sfârșit, tip,
loc, adresă, artiști, organizator, bilete, stare). Din el site-ul compune datele structurate `Event` (sau `MusicEvent`,
`Festival`…) pentru Google: conținutul nu poate avea `<script>`, deci JSON-LD scris de mână ar fi scos. Cardul arată ziua
și ora evenimentului (și „amânat”/„anulat”), iar prima pagină pune întâi evenimentele care urmează, cel mai apropiat primul,
apoi restul articolelor.
- **Sub articol:** „Citește mai departe" cu două articole, întâi cele cu aceeași primă etichetă. Coperta nu se repetă sus
când aceeași imagine e deja în text. Blocurile `<pre>` primesc un buton „Copiază", pus de șablon (cu nonce).
- **Întrebările frecvente** (`FAQPage`) se opresc la primul `<aside>` sau `<section>`: un bloc de final pus după ele nu
se mai lipește de ultimul răspuns.
- **Iconița site-ului:** o pui cu `seteaza_site` (`favicon`), iar `/apple-touch-icon.png` trimite spre ea, pentru telefon.
⚠️ Adresa `/favicon.ico` e singura care nu se poate rezolva din cod pe un domeniu prin **Cloudflare**: e prinsă la
margine și nu ajunge niciodată la PHP (verificat: `/altceva.ico` și `/favicon.ICO` ajung, `/favicon.ico` nu).
Dacă o vrei și pe aceea, omul pune un `favicon.ico` adevărat în rădăcina site-ului, o singură dată.
- **Fără sărituri la încărcare:** coperțile și miniaturile primesc `width`/`height` din fișier, iar coperta articolului
are `fetchpriority="high"` (e candidatul LCP).
## Securitate
- Nicio comandă nu scrie fișiere `.php`, șabloane, configurare sau jurnal. Conținutul stă în JSON, în `date/`.
- HTML-ul trece printr-o listă de etichete și atribute permise, la scriere și la afișare. Se scot `script`, `style`, formulare, SVG, evenimentele `on*`, `javascript:`, iframe-urile care nu sunt YouTube/Vimeo. Răspunsul spune AI-ului ce s-a scos. Un `id` nu poate lua numele variabilelor citite de eticheta GA4 (`dataLayer`, `gtag`, `google…`): în browser, un id devine și variabilă globală.
- Două chei (citire / scriere). Pe server stau doar amprentele SHA-256, comparate cu `hash_equals`.
- 8 încercări eșuate în 5 minute = adresă blocată 15 minute, pe toate punctele de intrare. La IPv6 se blochează prefixul /64,
nu adresa exactă: cine are un bloc întreg nu trece prin plafon schimbând adresa la fiecare cerere.
- Adresele care răspund fără cheie (fluxul OAuth) au și un plafon pe numărul de cereri, nu doar pe eșecuri.
- Cererile din browser de pe alt site (antet `Origin` străin) sunt refuzate. Cererile peste 8 MB sunt refuzate înainte de a fi citite.
- Înainte de orice modificare se salvează o versiune. Ștergerea mută fișierul între versiuni.
- Paginile publice au Content-Security-Policy cu nonce (fără `unsafe-inline`), `nosniff`, `X-Frame-Options: DENY`.
- Imaginile: tipul se află din conținut; fișierele cu cod PHP ascuns și SVG-urile sunt refuzate.
- Documentele PDF: tipul se află din conținut (`%PDF-` … `%%EOF`); stau în afara rădăcinii web (`date/fisiere/`) și le servește PHP, ca `application/pdf` cu `nosniff`.
- Arhivele și copiile de siguranță (`.zip`, `.tar`, `.gz`, `.sql`, `.bak` etc.) nu se servesc: pachetul de instalare uitat pe server nu se poate descărca.
- IP-ul real din `CF-Connecting-IP` e crezut doar când cererea vine chiar din rețeaua Cloudflare; altfel antetul e ignorat. Setarea se potrivește singură, cu sau fără Cloudflare (`'cloudflare' => false` o oprește).
- HSTS pe orice răspuns servit prin https (`'hsts' => false` îl oprește).
- Linkurile de previzualizare sunt semnate (HMAC, cheie în `date/securitate/`), expiră în cel mult 24 de ore, nu se indexează și sunt scrise în jurnal, inclusiv încercările cu semnătură greșită.
- Redirecționările duc doar spre adrese de pe același site; adresele site-ului (`/mcp`, `/app`, `/date`, …) nu se pot redirecționa, iar buclele sunt refuzate.
Excepție: sub `/articole/` site-ul nu servește nimic, deci acolo se pot scrie (adresele unui blog mutat). Fără ele,
`/articole/<slug>` trimite singur, cu 301, spre `/<slug>` când articolul (sau pagina) există și se vede pe site.
## Jurnalul
Fiecare apel (citire, scriere, încercare eșuată, blocare) e un rând JSON în `date/jurnal/AAAA-LL.ndjson`: când, IP, cheie, cine (`admin` sau numele editorului), comandă, țintă, rezultat, amprenta conținutului scris, durata. Fără rotație care să șteargă istoric.
Fiecare rând poartă amprenta rândului anterior (lanț SHA-256): un rând modificat, scos sau adăugat pe dinafară rupe lanțul, iar verificarea arată unde. AI-ul poate citi jurnalul, nu îl poate modifica.
Lanțul e **tamper-evident, nu tamper-proof**: prinde editarea sau ștergerea unui rând, dar cine are drept de scriere pe `date/`
poate recalcula tot lanțul și rescrie `.lant`. Pentru dovadă în fața cuiva din afară, copiază periodic amprenta de final în altă
parte. Adresele vizitatorilor care deschid linkuri de previzualizare se scriu trunchiate (ultimul octet la IPv4, prefixul /64 la
IPv6). La 16 MB, fișierul lunii se arhivează singur sub un nume care îi păstrează locul în lanț.
## Teste
```
php teste/ruleaza.php
```
Pornesc o copie a site-ului într-un dosar temporar, cu chei de unică folosință, pe serverul PHP încorporat, și verifică protocolul, cheile, conținutul, versiunile, imaginile, accesul la fișierele interne, antetele și jurnalul, inclusiv atacurile: cod PHP trimis ca conținut, imagini cu cod ascuns, `../` în adrese, lanț de jurnal modificat de mână.
## nginx
`.htaccess` nu se aplică pe nginx. Echivalentul minim:
```
location ~ ^/(app|sabloane|date)(/|$) { deny all; }
location ~ /\.(?!well-known/) { deny all; }
location ~* \.(zip|tar|gz|tgz|7z|rar|sql|bak|old|orig|swp)$ { deny all; }
location ~* ^/media/.+\.(php[0-9]?|phtml|phar|pht|shtml|cgi|pl|py|sh)$ { deny all; }
location = /mcp { rewrite ^ /mcp.php last; }
location / { try_files $uri /index.php$is_args$args; }
```
## Limitări cunoscute
- `post_max_size` al găzduirii limitează mărimea imaginilor urcate (base64 adaugă ~33%).
- Căutarea și listele citesc toate fișierele JSON la fiecare cerere: potrivit pentru zeci sau sute de elemente, nu pentru zeci de mii.
## Licență
MIT — vezi [LICENSE](LICENSE). Poți folosi, modifica și distribui codul, inclusiv comercial, cu condiția să păstrezi mențiunea de copyright.
TDQS
Scored across 26 tools
Most tools are clearly separated by resource and action, and the descriptions are specific enough to guide an agent. The main risk is the delete/unpublish family (sterge, retrage, sterge_imagine, sterge_fisier), which can look similar at a glance but is differentiated in the descriptions.
All names are lowercase snake_case, which helps readability, but they mix conventions: many are bare verbs (listeaza, citeste, sterge), others are verb_noun (sterge_imagine, urca_fisier), and a few are noun phrases (despre_site, pagini_de_pornire, link_urcare). This is still understandable but not a consistent predictable pattern.
At 26 tools, the server sits above the 25+ threshold and is heavy even for a mini-CMS with media, files, redirects, and connection management. The number of choices increases the chance of mis-selection and would benefit from consolidation.
The set covers the full lifecycle for the main content resources: save, read, list, search, publish, retract, delete, restore, preview, versions, redirects, media, files, export, and connection management. There are no evident dead ends in the described domain.