Computer Keyboard - Głównie JavaScript

Głównie JavaScript

by Jakub T. Jankiewicz

Dzielenie i upload plików na części

Nóż który tnie warzywa
źródło pixabay.com, licencja Simplified Pixabay License

Trochę czasu minęło od ostatniego wpisu, ale spowodowane był to tym, że wakacje i urlopy. Ale przejdźmy do rzeczy, tak jak obiecałem, w tym wpisie przedstawię, jak podzielić plik na części, aby ominąć limit danych (np. ten w PHP).

Dzielenie pliku na części nie jest tak skomplikowane jak upload katalogów. Mamy jedną funkcje a właściwie metodę o nazwie slice, która wycina pewną część pliku. Metoda slice ma swoje prefiksy w różnych przeglądarkach. Należy ona do interface’u Blob, po którym dziedziczy obiekt File (więcej o funkcji slice na MDN). Wywołanie metody zwraca nam obiekt typu Blob, który można z kolei dodać do obiektu FormData, aby wysłać na serwer.

Kod, do dzielenia pliku na części oraz wgrywania ich na serwer, wygląda tak:

function upload_by_chunks(file, options) {
    var settings = Object.assign({}, {
       chunk_size: 1048576 // 1MB
    }, options);
    var chunk_size = settings.chunk_size;
    function slice(start, end) { // 1
        if (file.slice) {
            return file.slice(start, end);
        } else if (file.webkitSlice) {
            return file.webkitSlice(start, end);
        } else if (file.mozSlice) {
            return file.mozSlice(start, end);
        }
    }
    return new Promise(function(resolve, reject) {
        function process(start, end) {
            if (start < file.size) {
                var chunk = slice(start, end); // 2
                var formData = new FormData(); // 3
                formData.append('file', chunk, file.name); // 4
                formData.append('path', path);
                fetch('lib/upload.php', { // 5
                    body: formData,
                    method: 'POST'
                }).then(function(response) {
                    if (response.error) {
                        throw new Error(response.error); // 6
                    }
                    process(end, end + chunk_size); // 7
                }).catch(function(e) {
                    reject(e); // 8
                });
            } else {
                resolve();
            }
        }
        process(0, chunk_size) // 9
    });
}

Najpierw definiujemy funkcję pomocniczą, która obsłuży prefiksy przeglądarek (1). W głównym kodzie zwracamy obietnicę, w której mamy funkcję - wywołujemy ją pierwszy raz (9) z wartościami początkowymi. Już wewnątrz funkcji, wycinamy cześć, którą wyślemy na serwer (2). Tworzymy obiekt FormData (3) - w taki sposób wysyła się pliki na serwer za pomocą AJAX-a. Następnie dodajemy naszą cześć (4). I wysyłamy na serwer za pomocą funkcji fetch, która zwraca obietnicę. Jeśli się powiedzie (nie będzię błędu HTTP) sprawdzamy, czy nie występuje jakiś inny błąd, który został wysłany z serwera (zakładamy że serwer zwraca obiekt JSON). Jeśli wartość pola error nie będzie wartością typu false (może być np. null lub undefined) to zwrócony zostanie wyjątek (6), który zostanie obsłużony przez metodę catch obietnicy i zostanie odrzucona główna obietnica (8). W przypadku, gdy wszystko jest ok, zostanie wywołana rekurencyjnie funkcja process z nowymi wartościami wskazującymi na nowy kawałek pliku (7).

Aby taki kod zadziałał serwer musi być dodatkowo przygotowany. Skrypt upload.php musi dodawać każdą następną cześć na koniec pliku. Musi też być obsłużone nadpisywanie plików, np. gdy wgrywasz plik, który już istnieje na serwerze. Wtedy musisz usunąć poprzedni plik, aby nie było w nim dwóch plików jeden za drugim.

W PHP można to osiągnąć, pisząc kod, który usunie plik przed zapisywaniem kolejnych części. (można np. dodać dodatkowy parametr dla pierwszej części pliku). Najpierw jednak dodajmy parametry w JavaScript.

function upload_by_chunks(file, path, options) {
    var settings = Object.assign({}, {
        chunk_size: 1048576 // 1MB
    }, options);
    var chunk_size = settings.chunk_size;
    function slice(start, end) {
        if (file.slice) {
            return file.slice(start, end);
        } else if (file.webkitSlice) {
            return file.webkitSlice(start, end);
        } else if (file.mozSlice) {
            return file.mozSlice(start, end);
        }
    }
    return new Promise(function(resolve, reject) {
        function process(start, end, options) {
            var settings = Object.assign({}, { // 1
                chunk: true
            }, options);
            if (start < file.size) {
                var chunk = slice(start, end);
                var formData = new FormData();
                formData.append('file', chunk, file.name);
                formData.append('path', path);
                Object.keys(settings).forEach(function(key) {  // 2
                    formData.append(key, settings[key]);
                });
                fetch('upload.php', {
                    body: formData,
                    method: 'POST'
                }).then(function(response) {
                    if (response.error) {
                        throw new Error(response.error);
                    }
                    process(end, end + chunk_size);
                }).catch(function(e) {
                    reject(e);
                });
            } else {
                resolve();
            }
        }
        process(0, chunk_size, {
            first: true // 3
        });
    });
}

Dodaliśmy kilka dodatkowych linijek kodu. W (1) tworzymy obiekt ustawień funkcji, który zawiera domyślną wartość chunk: true. W (2) dodajemy wszystkie opcje, te przekazane jako argument oraz domyślny chunk: true, do obiektu FormData. W (3) przekazujemy dodatkową opcje first: true, która zostanie przesłana do serwera dzięki obiektowi FormData (2). Będzie ona określała pierwszą część pliku.

Z takim kodem w JavaScript wystarczy że napiszemy nasz plik upload.php:

// ----------------------------------------------------------------------------
// :: function that check if it's safe to use function on directory
// ----------------------------------------------------------------------------
function safe_dir($path) {
    $basedir = ini_get('open_basedir');
    if ($basedir == "") {
        return true;
    }
    foreach (explode(":", $basedir) as $safe) {
        if (preg_match("%^$safe%", $path)) {
            return true;
        }
    }
    return false;
}


header('Content-type: application/json');

try {
    if (!isset($_POST['path'])) {
        throw new Exception('Wrong request');
    }
    if (!isset($_FILES['file'])) {
        throw new Exception('No File');
    }
    if (preg_match("/\.\./", $_POST['path'])) {
        throw new Exception('No directory traversal');
    }
    $fname = basename($_FILES['file']['name']);
    $path = $_POST['path'] == '.' ? getcwd() : $_POST['path'];
    $full_name = $path . '/' . $fname;
    if ($_FILES['file']['error'] !== UPLOAD_ERR_OK) {
        switch ($_FILES['file']['error']) {
            case UPLOAD_ERR_NO_FILE:
                throw new Exception('File not sent.');
        case UPLOAD_ERR_INI_SIZE:
        case UPLOAD_ERR_FORM_SIZE:
            throw new Exception('Exceeded filesize limit.');
        default:
            throw new Exception('Unknown error.');
        }
    } elseif ((file_exists($full_name) && !is_writable($full_name)) ||
              !safe_dir($_POST['path'])) {
        throw new Exception('File "'. $fname . '" is not writable');
    } else {
         if (file_exists($full_name) && isset($_POST['first']) &&
             is_writable($full_name) && safe_dir($_POST['path'])) {
             unlink($full_name); // 1
         }
         if (isset($_POST['chunk'])) {
             $contents = file_get_contents($_FILES['file']['tmp_name']); // 2
             $file = fopen($full_name, 'a+'); // 3
             if (!$file) {
                 throw new Exception('Can\'t save file.');
             }
             if (fwrite($file, $contents) != strlen($contents)) { // 4
                 throw new Exception('Not all bytes saved.');
             }
             echo json_encode(array('success' => true));
             fclose($file);
         } else {
             if (!move_uploaded_file($_FILES['file']['tmp_name'], // 5
                                     $full_name)) {
                 throw new Exception('Can\'t save file.');
             }
             echo json_encode(array('success' => true));
         }
    }
} catch(Exception $e) {
    echo json_encode(array('error' => $e->getMessage()));
}

Natomiast w kodzie PHP, po sprawdzeniu początkowych wyjątków, sprawdzamy czy mamy zdefiniowany zmienną POST o nazwie first. Jeśli tak to znaczy że musimy usunąć stary plik, ale tylko jeśli już istnieje (1). Następnie pobieramy zawartość pliku (2), jeśli jest to część pliku (chunk), to otwieramy plik docelowy (3) i zapisujemy zawartość tego co było przesłane (4), jeśli ilość bajtów się nie zgadza zwracany jest wyjątek. Jeśli nie jest to część pliku tylko cały plik, używamy funkcji move_uploaded_file. Jeśli zapis się powiedzie zwracamy JSON-a z powodzeniem zapisu.

Dzięki takiemu plikowi mamy możliwość zwykłego uploadu oraz uploadu na części.

I to by było na tyle. Do tego kodu można by jeszcze dodać pasek postępu. Wystarczy obliczyć ile części zostanie wygenerowanych i procentowo zwiększać po wgraniu każdej z nich. Ale to zostawię jako ćwiczenie dla czytelnika.

Dodatkowo w kodzie PHP można jeszcze, tworzyć plik tymczasowy z częściami i tylko gdy całość zostanie poprawnie zapisana, nadpisywać oryginalny plik. Dzięki temu nie zostaniemy z niepełnym plikiem w docelowym miejscu. Plik tymczasowy natomiast, można usunąć przy pierwszym niepoprawnym zapisie.

źródło strony (aby zobaczyć kod na GitHubie, musisz kliknąć przycisk raw)
Komentarze

Hasło, które podasz umożliwi ponowne zalogowanie się i np. usunięcie komentarza, jest dobrowolne. Email jest szyfrowany i używany do wysyłania powiadomień o odpowiedziach do komentarzy oraz do pobierania awatara dzięki usłudze Gravatar.com.