| Server IP : 188.114.96.3 / Your IP : 216.73.216.143 Web Server : Apache System : Linux sv058.home.net.pl 6.8.2 husak_dirty_pages_fix #1 SMP PREEMPT_DYNAMIC Tue Apr 2 22:06:51 CEST 2024 x86_64 User : ( 3194769) PHP Version : 8.5.12 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : ON | Sudo : ON | Pkexec : OFF Directory : /polana/admin/ |
Upload File : |
<?php
declare(strict_types=1);
/**
* POLANA SEO CRM Improvements V2 — administrator only; conservative non-destructive import.
* Does not create posts, change site_url, H1, publication status, or SMTP.
*/
require_once dirname(__DIR__).'/includes/functions.php';
require_admin_role();
header('Cache-Control: private, no-store');
header('X-Robots-Tag: noindex,nofollow');
function psi_e($s): string { return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
function psi_len(string $s): int { return function_exists('mb_strlen') ? mb_strlen($s,'UTF-8') : strlen($s); }
function psi_allowed(string $type,string $id,string $field): bool {
static $settings=['home_title','home_description','menu_seo_title','menu_seo_description','menu_seo_intro','contact_title','contact_description'];
if($type==='settings') return in_array($id,$settings,true) && $field==='setting_value';
if($type==='blog') return (bool)preg_match('/^[a-z0-9][a-z0-9-]{0,219}$/D',$id) && in_array($field,['seo_title','seo_description'],true);
return false;
}
function psi_current(PDO $db,array $row): array {
if($row['typ']==='settings'){
$q=$db->prepare('SELECT setting_value FROM settings WHERE setting_key=? LIMIT 1');
$q->execute([$row['identyfikator']]);
} else {
// Column is an explicit hard-coded whitelist, not arbitrary CSV SQL.
$field=$row['pole']==='seo_title'?'seo_title':'seo_description';
$q=$db->prepare('SELECT `'.$field.'` FROM blog_posts WHERE slug=? LIMIT 1');
$q->execute([$row['identyfikator']]);
}
$v=$q->fetchColumn();
return ['exists'=>$v!==false, 'value'=>$v===false?null:$v];
}
function psi_parse_csv(string $path): array {
$f=fopen($path,'rb');if(!$f)throw new RuntimeException('Nie mogę otworzyć przesłanego CSV.');
try {
$header=fgetcsv($f,0,';', '"','\\');
if(!is_array($header))throw new RuntimeException('Brak nagłówka.');
$header[0]=preg_replace('/^\xEF\xBB\xBF/','',(string)$header[0]);
if($header!==['typ','identyfikator','pole','wartosc','tryb','uwagi'])throw new RuntimeException('Zły format CSV. Wymagane: typ;identyfikator;pole;wartosc;tryb;uwagi.');
$rows=[];$seen=[];
while(($c=fgetcsv($f,0,';', '"','\\'))!==false){
if($c===[null])continue;
if(count($c)!==6)throw new RuntimeException('Nieprawidłowa liczba kolumn w wierszu '.(count($rows)+2));
[$type,$id,$field,$value,$mode,$note]=array_map('trim',$c);
if(!psi_allowed($type,$id,$field))throw new RuntimeException('Niedozwolone pole: '.substr($type.'/'.$id.'/'.$field,0,150));
if(!in_array($mode,['replace','if_empty'],true))throw new RuntimeException('Niedozwolony tryb: '.$mode);
if($value===''||psi_len($value)>($field==='seo_title'||$type==='settings'&&str_ends_with($id,'_title')?255:($field==='seo_description'?320:650)))throw new RuntimeException('Pusta lub zbyt długa wartość: '.$id.'/'.$field);
$unique=$type.'|'.$id.'|'.$field;
if(isset($seen[$unique]))throw new RuntimeException('Powtórzony klucz '.$unique);
$seen[$unique]=true;
$rows[]=['typ'=>$type,'identyfikator'=>$id,'pole'=>$field,'wartosc'=>$value,'tryb'=>$mode,'uwagi'=>$note];
if(count($rows)>200)throw new RuntimeException('Limit: 200 wierszy na jeden import.');
}
return $rows;
} finally {fclose($f);}
}
function psi_backup_table(PDO $db): void {
$db->exec("CREATE TABLE IF NOT EXISTS polana_seo_import_history (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
batch_id VARCHAR(60) NOT NULL,
entity_type VARCHAR(20) NOT NULL,
entity_key VARCHAR(220) NOT NULL,
field_name VARCHAR(40) NOT NULL,
old_present TINYINT(1) NOT NULL,
old_value MEDIUMTEXT NULL,
new_value MEDIUMTEXT NOT NULL,
reverted_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY ix_batch(batch_id, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
}
function psi_apply(PDO $db,array $rows,array $selected): array {
psi_backup_table($db);$selected=array_unique(array_map('intval',$selected));
if(!$selected)throw new RuntimeException('Nie zaznaczono żadnej zmiany.');
if(count($selected)>200)throw new RuntimeException('Zbyt wiele zmian.');
$batch='seo_'.gmdate('Ymd_His').'_'.bin2hex(random_bytes(5));
$changed=0;$skipped=[];
$db->beginTransaction();
try {
foreach($selected as $index){
if(!array_key_exists($index,$rows))throw new RuntimeException('Nieprawidłowa pozycja formularza.');
$item=$rows[$index];
$current=psi_current($db,$item);
// Prevent stale preview overwriting changes made by someone else.
if($current['exists']!==$item['_current_exists']||$current['value']!==$item['_current_value']){
$skipped[]=$item['identyfikator'].' (zmienione od podglądu)';continue;
}
if(!$current['exists'] && $item['typ']==='blog'){
$skipped[]=$item['identyfikator'].' (brak wpisu)';continue;
}
if($item['tryb']==='if_empty' && trim((string)($current['value']??''))!==''){
$skipped[]=$item['identyfikator'].' (pole nie jest puste)';continue;
}
if($current['exists'] && $current['value']===$item['wartosc']){continue;}
if($item['typ']==='settings'){
$q=$db->prepare('INSERT INTO settings (setting_key,setting_value) VALUES (?,?) ON DUPLICATE KEY UPDATE setting_value=VALUES(setting_value)');
$q->execute([$item['identyfikator'],$item['wartosc']]);
} else {
$field=$item['pole']==='seo_title'?'seo_title':'seo_description';
$q=$db->prepare('UPDATE blog_posts SET `'.$field.'`=? WHERE slug=?');
$q->execute([$item['wartosc'],$item['identyfikator']]);
}
$b=$db->prepare('INSERT INTO polana_seo_import_history (batch_id,entity_type,entity_key,field_name,old_present,old_value,new_value) VALUES (?,?,?,?,?,?,?)');
$b->execute([$batch,$item['typ'],$item['identyfikator'],$item['pole'],$current['exists']?1:0,$current['value'],$item['wartosc']]);
$changed++;
}
if($changed)admin_log('polana_seo_csv_import','Import SEO: '.$changed.' pól, partia '.$batch);
$db->commit();
} catch(Throwable $ex){$db->rollBack();throw $ex;}
return ['batch'=>$changed?$batch:'','changed'=>$changed,'skipped'=>$skipped];
}
function psi_rollback(PDO $db,string $batch): array {
if(!preg_match('/^seo_\d{8}_\d{6}_[a-f0-9]{10}$/D',$batch))throw new RuntimeException('Błędna identyfikacja kopii.');
$q=$db->prepare('SELECT * FROM polana_seo_import_history WHERE batch_id=? AND reverted_at IS NULL ORDER BY id DESC');$q->execute([$batch]);
$rows=$q->fetchAll(PDO::FETCH_ASSOC);if(!$rows)throw new RuntimeException('Brak kopii do przywrócenia.');
$done=0;$skip=0;
$db->beginTransaction();
try{
foreach($rows as $r){
$item=['typ'=>$r['entity_type'],'identyfikator'=>$r['entity_key'],'pole'=>$r['field_name']];
if(!psi_allowed($item['typ'],$item['identyfikator'],$item['pole'])){$skip++;continue;}
$now=psi_current($db,$item);
if(!$now['exists'] || $now['value']!==$r['new_value']){$skip++;continue;}
if($item['typ']==='settings'){
if(!$r['old_present']){
$st=$db->prepare('DELETE FROM settings WHERE setting_key=? AND setting_value=?');
$st->execute([$item['identyfikator'],$r['new_value']]);
}else{
$st=$db->prepare('UPDATE settings SET setting_value=? WHERE setting_key=?');
$st->execute([$r['old_value'],$item['identyfikator']]);
}
}else{
$field=$item['pole']==='seo_title'?'seo_title':'seo_description';
$st=$db->prepare('UPDATE blog_posts SET `'.$field.'`=? WHERE slug=?');
$st->execute([$r['old_value'],$item['identyfikator']]);
}
$st=$db->prepare('UPDATE polana_seo_import_history SET reverted_at=NOW() WHERE id=?');$st->execute([$r['id']]);
$done++;
}
admin_log('polana_seo_csv_rollback','Cofnięto SEO CSV: '.$done.', pominięto '.$skip);
$db->commit();
}catch(Throwable $e){$db->rollBack();throw $e;}
return [$done,$skip];
}
/** Reviewed proposals from 01_SEO_META_STRONY.csv, never applied without preview+confirmation. */
function psi_v2_prepared(): array {
$source=[
['settings','home_title','setting_value','Moja Polana Smaków – restauracja na Kazimierzu | Kraków','replace','Strona główna: marka + lokalizacja; nie przejmuj frazy osobowej z /andrzej-polan bez analizy GSC.'],
['settings','home_description','setting_value','Moja Polana Smaków Andrzeja Polana przy ul. Bożego Ciała 15 na krakowskim Kazimierzu. Poznaj sezonowe menu, kuchnię polską i informacje o rezerwacjach.','replace','Opis strony głównej; zweryfikuj aktualną ofertę.'],
['settings','menu_seo_title','setting_value','Menu restauracji na Kazimierzu | Moja Polana Smaków','replace','Wersja /menu-seo; oddzielona od graficznej karty QR.'],
['settings','menu_seo_description','setting_value','Poznaj aktualne menu Mojej Polany Smaków w Krakowie: polskie dania, sezonowe składniki, napoje i wina. Sprawdź kartę przed wizytą na Kazimierzu.','replace','Nie obiecuj stałej dostępności dania.'],
['settings','menu_seo_intro','setting_value','Sezonowe menu Mojej Polany Smaków na Kazimierzu. Sprawdź aktualne dania, napoje i wina przed wizytą.','replace','Tekst wprowadzający do SEO wersji menu.'],
['settings','contact_title','setting_value','Kontakt i rezerwacje | Moja Polana Smaków Kraków','replace','Nie zmienia adresów SMTP.'],
['settings','contact_description','setting_value','Skontaktuj się z Moją Polaną Smaków przy ul. Bożego Ciała 15 w Krakowie. Sprawdź lokalizację, informacje o rezerwacjach i aktualne godziny otwarcia.','replace','Zawiera adres potwierdzony przez użytkownika.'],
];
$rows=[];
foreach($source as $values){
[$typ,$id,$field,$wartosc,$tryb,$uwagi]=$values;
$rows[]=['typ'=>$typ,'identyfikator'=>$id,'pole'=>$field,'wartosc'=>$wartosc,'tryb'=>$tryb,'uwagi'=>$uwagi];
}
return $rows;
}
$error='';$notice='';$preview=$_SESSION['polana_seo_csv_preview']??null;
if(is_array($preview) && (time()-(int)($preview['time']??0)>1800)){$preview=null;unset($_SESSION['polana_seo_csv_preview']);}
if(($_SERVER['REQUEST_METHOD']??'')==='POST'){
verify_csrf();
try{
$action=(string)($_POST['action']??'');
if($action==='preview_prepared'){
$rows=psi_v2_prepared();
foreach($rows as &$row){$c=psi_current(db(),$row);$row['_current_exists']=$c['exists'];$row['_current_value']=$c['value'];}unset($row);
$_SESSION['polana_seo_csv_preview']=['time'=>time(),'rows'=>$rows];$preview=$_SESSION['polana_seo_csv_preview'];
$notice='Wczytano 7 propozycji SEO. Nic nie zostało zmienione; wybierz co zatwierdzić.';
}elseif($action==='preview'){
$f=$_FILES['csv_file']??null;
if(!is_array($f)||($f['error']??99)!==UPLOAD_ERR_OK||($f['size']??0)>512000)throw new RuntimeException('Wgraj plik CSV do 500 KB.');
if(!is_uploaded_file((string)$f['tmp_name']))throw new RuntimeException('Nieprawidłowy upload.');
$rows=psi_parse_csv((string)$f['tmp_name']);
foreach($rows as &$row){$c=psi_current(db(),$row);$row['_current_exists']=$c['exists'];$row['_current_value']=$c['value'];}unset($row);
$_SESSION['polana_seo_csv_preview']=['time'=>time(),'rows'=>$rows];$preview=$_SESSION['polana_seo_csv_preview'];
$notice='Podgląd wczytany: '.count($rows).' wierszy. Żadnych danych jeszcze nie zmieniono.';
}elseif($action==='apply'){
if(!$preview||!is_array($preview['rows']??null))throw new RuntimeException('Wczytaj CSV od nowa (podgląd wygasł).');
$r=psi_apply(db(),$preview['rows'],(array)($_POST['selected']??[]));
unset($_SESSION['polana_seo_csv_preview']);$preview=null;
if($r['batch'])$_SESSION['polana_seo_last_import']=$r['batch'];
$notice='Zmieniono pól: '.$r['changed'].'. Pominięto: '.count($r['skipped']).'.'.($r['skipped']?' Przykłady: '.implode(', ',array_slice($r['skipped'],0,3)): '');
}elseif($action==='rollback'){
$batch=(string)($_SESSION['polana_seo_last_import']??'');
[$done,$skip]=psi_rollback(db(),$batch);
unset($_SESSION['polana_seo_last_import']);
$notice='Cofnięto: '.$done.' zmian. Pominięto (zmienione przez kogoś później): '.$skip.'.';
}else throw new RuntimeException('Nieznana operacja.');
}catch(Throwable $e){$error=$e->getMessage();}
}
/** Database-backed checks. They are diagnostics, not GSC measurements. */
function psi_v2_audit(PDO $db): array {
$r=['site_url'=>'','published'=>0,'missing_meta'=>0,'duplicate_titles'=>[],'error'=>''];
try {
$q=$db->prepare("SELECT setting_value FROM settings WHERE setting_key='site_url' LIMIT 1");
$q->execute();$r['site_url']=(string)($q->fetchColumn()?:'');
$q=$db->query("SELECT COUNT(*) AS total, SUM(CASE WHEN TRIM(COALESCE(seo_title,''))='' OR TRIM(COALESCE(seo_description,''))='' THEN 1 ELSE 0 END) AS missing FROM blog_posts WHERE status='published'");
$v=$q->fetch(PDO::FETCH_ASSOC)?:[];
$r['published']=(int)($v['total']??0);$r['missing_meta']=(int)($v['missing']??0);
$q=$db->query("SELECT LOWER(TRIM(COALESCE(NULLIF(seo_title,''),title))) AS label, COUNT(*) AS n, GROUP_CONCAT(slug ORDER BY id SEPARATOR ', ') AS slugs FROM blog_posts WHERE status='published' GROUP BY label HAVING COUNT(*)>1 ORDER BY n DESC LIMIT 15");
$r['duplicate_titles']=$q->fetchAll(PDO::FETCH_ASSOC);
} catch (Throwable $e) { $r['error']='Nie można wykonać pełnego audytu bazy (schemat może się różnić): '.$e->getMessage(); }
$r['domain_ok']=rtrim(strtolower($r['site_url']),'/')==='https://mojapolanasmakow.pl';
return $r;
}
$v2audit=psi_v2_audit(db());
render_admin_header('SEO — poprawki z CRM V2');
?>
<style>
.psi{max-width:1250px;margin:25px auto;padding:15px}.psi .panel{background:#fff;padding:18px;border:1px solid #d7e2d5;border-radius:14px;margin:14px 0}.psi .actions{display:flex;gap:12px;flex-wrap:wrap;align-items:center}.psi .btn{padding:10px 15px;background:#356d4c;color:#fff;border:0;border-radius:8px;cursor:pointer}.psi .muted{color:#566b5a}.psi .warn{color:#a94b21;background:#fff4ea;padding:12px;border-radius:9px}.psi .success{color:#236d42;background:#eaf5e9;padding:12px;border-radius:9px}.psi table{width:100%;border-collapse:collapse}.psi th,.psi td{padding:11px;text-align:left;vertical-align:top;border-bottom:1px solid #e3ece1}.psi td:nth-child(4),.psi td:nth-child(5){max-width:350px;overflow-wrap:anywhere}.psi tr.ineligible{opacity:.65}.psi .scroll{overflow-x:auto}.psi code{word-break:break-all}
@media(max-width:700px){.psi{padding:8px}.psi th,.psi td{min-width:140px}}
</style>
<main class="psi">
<p><a href="/admin/polana-seo-centrum.php">← SEO i Google</a> · <a href="/admin/polana-zarzadzanie.php">Zarządzanie</a></p>
<h1>Poprawki SEO z CRM — Polana V2</h1><p class="muted">Import tylko do istniejących ustawień i istniejących artykułów bloga. Podgląd → wybór wierszy → zatwierdzenie. Brak zmian publikacji, Cloudflare i H1.</p>
<?php if($error):?><div class="warn" role="alert"><?=psi_e($error)?></div><?php endif;?>
<?php if($notice):?><div class="success" role="status"><?=psi_e($notice)?></div><?php endif;?>
<section class="panel" id="audit-v2">
<h2>Stan witryny przed poprawkami</h2>
<p><b>Domena w MariaDB:</b> <code><?=psi_e($v2audit['site_url']?:'(brak)')?></code>
<?php if(!$v2audit['domain_ok']):?><strong style="color:#a24a27"> — wymaga sprawdzenia!</strong> <a href="/admin/polana-v168-ustawienia.php">Popraw domenę w ustawieniach</a><?php else:?><strong style="color:#237843"> — poprawna</strong><?php endif;?></p>
<p><b>Opublikowane artykuły:</b> <?= (int)$v2audit['published'] ?> · <b>bez kompletu metadanych:</b> <?= (int)$v2audit['missing_meta'] ?>.</p>
<p><b>Duplikaty tytułów SEO w opublikowanym blogu:</b> <?=count($v2audit['duplicate_titles'])?> grup (ta kontrola nie mierzy duplikatów innych podstron).</p>
<?php if($v2audit['error']):?><p class="warn"><?=psi_e($v2audit['error'])?></p><?php endif;?>
<?php if($v2audit['duplicate_titles']):?><div class="scroll"><table><thead><tr><th>Powielony TITLE</th><th>Liczba</th><th>Slug wpisów</th></tr></thead><tbody><?php foreach($v2audit['duplicate_titles'] as $dupe):?><tr><td><?=psi_e($dupe['label'])?></td><td><?= (int)$dupe['n'] ?></td><td><?=psi_e($dupe['slugs'])?></td></tr><?php endforeach;?></tbody></table></div><?php endif;?>
<p class="muted">Dodatkowy audyt: <a href="/admin/polana-seo-centrum.php">SEO Health</a> · <a target="_blank" rel="noopener" href="/sitemap.xml">Sitemap</a> · <a target="_blank" rel="noopener" href="https://search.google.com/search-console">GSC</a>.</p>
</section>
<section class="panel"><h2>1. Wczytaj poprawki bez wybierania pliku</h2>
<p>7 bezpiecznie przygotowanych propozycji do podglądu: strona główna, menu SEO i kontakt. Bez zmiany URL profilu Andrzeja Polana, H1, kanonikalnej domeny ani statusów bloga.</p>
<form method="post" class="actions"><?=csrf_field()?><input type="hidden" name="action" value="preview_prepared"><button class="btn" type="submit">Pokaż proponowane poprawki (podgląd)</button></form>
<p class="muted">Istniejące wartości zobaczysz obok propozycji. Tryb <b>replace</b> zapisuje tylko ręcznie zaznaczone pola.</p>
</section>
<section class="panel"><h2>2. Import CSV (40 artykułów)</h2>
<form method="post" enctype="multipart/form-data" class="actions">
<?=csrf_field()?>
<input type="hidden" name="action" value="preview">
<input type="file" name="csv_file" accept=".csv,text/csv" required>
<button class="btn" type="submit">Wczytaj i pokaż zmiany (bez zapisu)</button>
</form>
<p class="muted">Z paczki: <code>01_SEO_META_STRONY.csv</code> albo <code>02_SEO_BLOG_40_UZUPELNIJ_PUSTE.csv</code>. Kodowanie UTF-8, separator średnik.</p>
</section>
<?php if($preview && is_array($preview['rows']??null)):?>
<section class="panel"><h2>3. Podgląd porównania</h2><p class="warn">Wiersze „Zastąp” mogą zmienić istniejące tytuły i metaopisy; zaznaczaj je świadomie. Wiersze „Tylko puste” nigdy nie nadpisują istniejących treści.</p>
<form method="post" onsubmit="return confirm('Zastosować zaznaczone zmiany SEO w bazie?');">
<?=csrf_field()?>
<input type="hidden" name="action" value="apply">
<div class="actions"><label><input type="checkbox" id="psi-select-all"> Zaznacz wszystkie możliwe</label><button class="btn" type="submit">Zastosuj zaznaczone</button></div>
<div class="scroll"><table><thead><tr><th>Wybierz</th><th>Typ / pole</th><th>Klucz / slug</th><th>Obecna wartość</th><th>Nowa wartość</th><th>Tryb</th></tr></thead><tbody>
<?php foreach($preview['rows'] as $i=>$row):
$exists=$row['_current_exists'];$current=(string)($row['_current_value']??'');$same=$exists&&$current===$row['wartosc'];
$eligible=($row['typ']==='settings'||$exists)&&!$same&&($row['tryb']==='replace'||trim($current)==='');
$checked=$eligible&&$row['tryb']==='if_empty';
?>
<tr class="<?=$eligible?'':'ineligible'?>"><td><input class="psi-pick" type="checkbox" name="selected[]" value="<?=$i?>" <?=$eligible?'':'disabled'?> <?=$checked?'checked':''?>></td>
<td><?=psi_e($row['typ'].' / '.$row['pole'])?></td><td><?=psi_e($row['identyfikator'])?></td>
<td><?=psi_e($exists?($current===''?'(puste)':$current):'(nie istnieje)')?></td><td><?=psi_e($row['wartosc'])?></td>
<td><?=psi_e($row['tryb'])?><?php if(!$eligible):?><br><small><?=psi_e(!$exists&&$row['typ']==='blog'?'Brak wpisu':($same?'Bez zmian':'Już wypełnione'))?></small><?php endif;?></td></tr>
<?php endforeach;?></tbody></table></div>
<div class="actions" style="margin-top:15px"><button class="btn" type="submit">Zapisz wybrane w MariaDB</button></div></form></section>
<script>(function(){const all=document.getElementById('psi-select-all');const picks=[...document.querySelectorAll('.psi-pick:not(:disabled)')];if(all)all.addEventListener('change',()=>{picks.forEach(x=>x.checked=all.checked)});})();</script>
<?php endif;?>
<?php if(!empty($_SESSION['polana_seo_last_import'])):?>
<section class="panel"><h2>4. Cofnięcie ostatniej partii</h2><p>Identyfikator: <?=psi_e($_SESSION['polana_seo_last_import'])?>. Przywracane są wyłącznie wartości niezmienione po imporcie.</p>
<form method="post" onsubmit="return confirm('Cofnąć ostatni import SEO?');"><?=csrf_field()?><input type="hidden" name="action" value="rollback"><button type="submit" class="btn">Cofnij ostatnią partię</button></form>
</section><?php endif;?>
</main>
<?php render_admin_footer(); ?>