Správa redirectů s využitím databáze

Sun, 8 Mar 2020 13:18:00 +1100

Je tomu už příliš dlouho, co jsem naposledy psal nějaký článek z oboru, a tak je nejvyšší čas tento nedostatek napravit. Je tomu už podobně dlouho, co jsem naposledy něco skutečně programoval, a tak doufám, že můj kód nebude ostuda. Ale chtěl bych své články na téma zábava zase občas proložit něčím víc technickým. Většina mých článků je směrovaná na vývojáře začínající, dnes to bude ale spíš na ty středně pokročilé. Podíváme se na správu redirectů.

Pokud jste úplný začátečník, těžko si nacpete následující kód do vlastního webu. (V takové situaci doporučuji použít čistě .htaccess.) Používáte-li komplexní redakční systém, podobný plugin už nejspíš obsahuje. A pokud ne, můžete si vzít následující kód jako inspiraci, co změnit či upravit.

Proč chceme přesměrovat

Pokud jste přesměrování jedné adresy na druhou ještě nepotřebovali, pravděpodobně se svému webu málo věnujete :-) V opačném případě jistě víte, že změna URL adresy znamená ztrátu pozice v Googlu, kterou jste složitě budovali. A právě tohle vyřešíte přesměrováním s hlavičkou 301.

Možností jak danou funkcionalitu integrovat do systému je více a já vám dnes ukážu jednu z nich. Redirecty uložené v samotné tabulce mají jednu velkou výhodu, kterou je jejich přehlednost. Pokud bychom tyto informace ukládali automaticky a řešili je například na úrovní ukládání článku (změní se url článku, uložím si automaticky redirect), museli bychom provádět obrovské množství různých kontrol napříč mnoha tabulkami. A co víc, snadno ztratíme přehled, co směruje kam. Nemluvě o automatickém čištění všech předchozích redirectů s každým uložením.

Nevýhoda odděleného řešení je pak samozřejmě ta, že na to člověk musí myslet. Ale pokud se o svůj web staráte, tak URL, která skáče na první stránce v Googlu asi neměníte každý den. A nastavit případný redirect člověk jistě nezapomene.

Kód #1: SQL

Začneme tabulkou v databázi. Ukládat chceme výchozí url, cílovou url, checkbox pro přesnou shodu, checkbox pro přesnou náhradu (vysvětlím níže) a poznámku. Ta bude sloužit ke krátkému popisu, proč redirect vlastně zakládám. Na závěr samozřejmě možnost deaktivace a čas vytvoření záznamu. original_url chceme mít jako unikátní klíč, protože více redirectů ze stejné adresy je nesmysl.

CREATE TABLE IF NOT EXISTS `project_redirects` (
	`id` int(10) unsigned NOT NULL   AUTO_INCREMENT,
	`original_url` varchar(255) NOT NULL,
	`redirect_url` varchar(255) NOT NULL,
	`exact_match` tinyint(1) DEFAULT '1',
	`do_replace` tinyint(1)  DEFAULT '0',
	`note` varchar(255) DEFAULT NULL,
	`active` tinyint(1) DEFAULT '1',
	`timestamp` timestamp  DEFAULT CURRENT_TIMESTAMP,
	PRIMARY KEY (`id`),
	UNIQUE KEY `original_url` (`original_url`)
) ENGINE=MyISAM  DEFAULT CHARSET=utf8 ;

Jak to bude fungovat

Ze struktury tabulky už nejspíš pochopíte i chování. Uložíme původní a cílovou url a při každém načtení stránky zjistíme, jestli má aktuální URL kam přesměrovat. Porovnáme vždy relativní i absolutní variantu cesty.

Pole exact_match nám řekne, jestli budeme testovat přesnou shodu nebo nasadíme funkci strpos. Pole do_replace následně definuje, jestli provádíme klasický str_replace nebo chceme směrovat vše na novou url.

Jaké případy tedy umíme podchytit?

Jedna konkrétní adresa na novou adresu: /kategorie/ => /nova-kategorie/

Zahodíme celou větev a přesměrujeme na novou adresu: /kategorie/vše/ => /nova-kategorie/

Celá větev změní jen část své adresy: /kategorie/cokoli/ => /nova-kategorie/cokoli/

Porovnání přes strpos === 0 zaručí, že se podmínka chytí jenom na adresy, které daným stringem začínají. Takže když bude stejný string zanořený někde hlouběji, nic se nestane. Vše je navíc navrženo tak, abych tu root_url nemusel nikam ukládat, ať u případné změny domény neřeším absolutní cesty bůhví kde v databázi.

Kód #2: .htaccess

Abychom mohli provádět efektivnější redirecty, jsou potřeba i nějaké ty "hezké url". Následující zápis nasměruje vše, co není existující soubor nebo složka na index. Pro spuštění v podadresáři je pak potřeba přidat i RewriteBase.

RewriteEngineOn
#RewriteBase /redir/
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(.*)$ index.php?path=$1 [L,QSA]

Kód #3: základní proměnné, funkce a připojení k databázi

Zpracujeme primární GET proměnnou, nastavíme ROOT a připojíme se k databázi. Databázi si jistě vyřeší každý po svém, tady jsem uvedl jen minimum kódu tak, aby ukázka fungovala. (Pro sešny platí to samé.) Funkce get_url nám pak vrátí celou absolutní URL, na které se zrovna nacházíme.

$_root_url = 'http://localhost/redir/';
$_path = (!empty($_GET['path']) ? (string)$_GET['path'] : '');

$config['db_host'] = 'localhost';
$config['db_user'] = 'root';
$config['db_pass'] = '';
$config['db_name'] = 'evil';

$sqli = new mysqli($config['db_host'], $config['db_user'], $config['db_pass'], $config['db_name']);
$sqli->set_charset('utf8');
if(!empty($sqli->connect_error)){
 	exit($sqli->connect_error);
}

session_start();

function get_url($header = false){
	$pure_url = null;
	$html_url = null;

	if(!$pure_url){
		$url = (isset($_SERVER['HTTPS']) ? 'https://' : 'http://');
		$url .= $_SERVER['SERVER_NAME'];
		$port = explode(':', $_SERVER['HTTP_HOST']);
		if(!empty($port[1])){
			$url .= ':'.$port[1];
		}
		$url .= $_SERVER['REQUEST_URI'];
		$pure_url = $url;
		$html_url = str_replace('&', '&', $pure_url);
	}

	return $header ? $pure_url : $html_url;
}

Kód #4: Samotná funkce

Hned první blok kódu vznikl po analýze adres, kterou často navštěvují spamboti. Jakože fakt často. Takže mezera (respektive plus) hranatá závorka se automaticky přesměruje na root.

Pak už jen vytáhneme všechny existující redirecty z databáze a pustíme se do kontroly. SQL dotaz by šel určitě lépe vydefinovat tak, aby se netahaly úplně všechny řádky, ale na druhou stranu počítám s nějakou lidsky rozumnou správou. Fakt tam nechceme mít stovky řádků. Což bychom ani mít neměli, když je možnost vypnout exact_match a obsáhnout i větší množství záznamů jedním řádkem.

V cyklu podle exact_match zvolíme způsob porovnání. No a následuje samotné ošetření smyčky. Redirect loop totiž není příliš fajn věc, protože vám to zpravidla zahlásí až prohlížeč. Žádnou chybovku od serveru nedostaneme, což se opravdu špatně ladí. Třeba Chrome už se v poslední době umoudřil a stránku zabije poměrně rychle, ovšem na to se nedá vždy spolehnout. Raději si tam podstrčím hlášku vlastní, abych hned viděl, kde je problém. Stejné ošetření pak můžeme provádět během jakéhokoli systémového redirectu.

// redirekty z tabulky
// plus nejake natvrdo
function redirect_check($_path, $_root_url, $sqli){

	// tohle delaji spamboti
	if(strpos($_path,' [') !== false){
		header('Location: ' . $_root_url);
		return false;
	}

	$redir_query = $sqli->query("SELECT original_url, redirect_url, exact_match, do_replace FROM project_redirects WHERE `active` = 1 ORDER BY id DESC");
	$redirects = array();
	while($row = $redir_query->fetch_assoc()){
		$redirects[] = $row;
	}

	// podminka vzdy pro relativni i absolutni URL
	// vse bud relativni nebo absolutni
	// neni-li exact_match, testujeme jestli url danym retezcem zacina, at to nereplacuje kdekoli
	// neni-li replace (default 0), pak to redirektuje vse co danym stringem zacina na cilovou url
	foreach($redirects as $item){
		if(!empty($item['exact_match'])){

			if(($_path == $item['original_url']) || (get_url(true) == $item['original_url'])){
				$redirect_target = $item['redirect_url'];
			}

		}else{

			if((strpos(get_url(true), $item['original_url']) === 0) || (strpos($_path, $item['original_url']) === 0)){
				if(!empty($item['do_replace'])){
					$redirect_target = str_replace($item['original_url'], $item['redirect_url'], $_path);
				}else{
					$redirect_target = $item['redirect_url'];
				}
			}

		}
	}

	if(!empty($redirect_target)){

		// zastupny znak pro redirekt na homepage
		if($redirect_target == '/'){
			$redirect_target = $_root_url;
		}

		if((strpos($redirect_target, 'http://') === false) && (strpos($redirect_target, 'https://') === false)){
			$redirect_target = $_root_url . $redirect_target;
		}

		// redirect_happened bude vzdy prazdny, pokud nedojde k zacykleni
		if(!empty($_SESSION['redirect_happened']) && $_SESSION['redirect_target_url'] == get_url(true)){

			// potreba vynulovat
			// aby to zase nechciplo, kdyz to v administraci opravim
			// cela konstrukce musi byt obalena v try catch
			// musi se porovnavat URL, nestaci 1/0, kvuli requestovym spam botum
			$exception_message = 'Redirect linking to another.';
$exception_message.= ' Previous URL: ' . $_SESSION['redirect_prev_url'];
$exception_message.= ' Next URL: ' . $_SESSION['redirect_target_url'];

			$_SESSION['redirect_happened']   = false;
			$_SESSION['redirect_target_url'] = null;
			$_SESSION['redirect_prev_url']   = null;

			exit($exception_message);
		}

		$_SESSION['redirect_happened']   = true;
		$_SESSION['redirect_target_url'] = $redirect_target;
		$_SESSION['redirect_prev_url']   = get_url(true);

		header('Location: ' . $redirect_target, true, 301);
		return;
	}

	$_SESSION['redirect_happened']   = false;
	$_SESSION['redirect_target_url'] = null;
	$_SESSION['redirect_prev_url']   = null;

	// doplneni lomitka
	// lze vyresit komplexeni : ale pro ukazku staci
	if(!empty($_path) && substr($_path, -1) != '/'){
		if((strpos($_SERVER['REQUEST_URI'], '?') === false) && (strpos($_SERVER['REQUEST_URI'], '&') === false)){

			// u nekterych url treba lomitko nechceme
			if($_path != 'sitemap.xml'){
				header('Location: ' . get_url() . '/', false);
				return;
			}
		}
	}

	return $redirects;
}

$_redirects = redirect_check($_path, $_root_url, $sqli);

Ošetříme zacyklení

Funkce běží a zjistí, jestli má proběhnout redirect. V tu chvíli se zeptá: neproběhl už naposled nějaký?

Nastavíme do _SESSION potřebné hodnoty a zabijeme skript.

Pokud podmínka splněna není, tedy je vše v pořádku, proměnné v _SESSION zase vynulujeme. Protože kdybychom to neudělali, hodnoty zůstanou uložené a bude se to cyklit i v případě, že chybné přesměrování v administraci opravíme.

A protože vynulování proměnných nastane jen tehdy, je-li vše v naprostém pořádku, máme automaticky ošetřené i víceúrovňové zacyklení: tedy když odkaz 1 směruje na odkaz 2, ten na odkaz 3 a ten zase zpátky na 1. Jednoduše se testuje, jestli jeden redirect nesměřuje na jiný. Je jedno, jestli to po dvou / třech přesměrováních skončí na normální adrese nebo ve smyčce. Nechceme ani jedno.

Na řádek s exitem samozřejmě doporučuji nějaké pokročilé odchytávání výjimek: např. logovací soubor nebo odeslání mailu.

Závěrem ještě doplníme lomítko, protože je s tím méně ladění, než to řešit v .htaccessu.

Kód #5: Testovací data

INSERT INTO `project_redirects` (`id`, `original_url`, `redirect_url`, `exact_match`, `do_replace`, `note`, `active`, `timestamp`) VALUES
(1, 'test-url-1/', 'test-url-2/', 1, 0, 'test', 1, '2020-02-02 13:37:33'),
(2, 'test-url-2/', 'test-url-1/', 1, 0, 'test zacykleni', 1, '2020-02-02 13:37:33'),
(3, 'kategorie/', 'nova-kategorie/', 0, 0, 'vse na jednu jedinou url', 1, '2020-02-02 13:37:33'),
(4, 'kategorie-x/', 'nova-kategorie-x/', 0, 1, 'vcetne deti', 1, '2020-02-02 13:37:33'),
(5, 'nesmysl/', '/', 1, 0, 'test redirectu na root', 1, '2020-02-02 13:37:33');

A na úplný závěr ještě jeden tip: při testování redirectů doporučuji vždy smazat historii prohlížeče, protože si ty redirecty cachují. Což má jít vyřešit zasláním no-cache v headeru a exitem, nicméně to není bohužel stoprocentní.

index.php
.htaccess
redirect.sql

ZIP archív ke stažení