Computer Keyboard - Głównie JavaScript

Głównie JavaScript

by Jakub T. Jankiewicz

Upload katalogów i plików poprzez Drag & Drop

Segregatory (Katalogi) na półce
źródło pxhere, licencja CC0 Public Domain

Do niedawna możliwy był upload tylko pojedynczych plików. Przeglądarki jednak (Chrome oraz Firefox) dodały nową funkcje obsługi katalogów. Funkcja dostępna jest poprzez mechanizm przeciągnij i upuść (ang. Drag & Drop).

Wstęp

Obsługa katalogów, nie była w żadnym standardzie, ale to nie przeszkodziło przeglądarkom dodania tej funkcjonalności. Już po jej dodaniu powstała jednak specyfikacja File and Directory Entries API, stworzona w ramach projektu WICG. Jest to projekt zapoczątkowany przez W3C, którego celem jest dodanie miejsca, gdzie twórcy przeglądarek oraz programiści mogą dyskutować i nowych API. Szczerze mówiąc dowiedziałem się o tym przedsięwzięciu dopiero szukając informacji o tym API, którego używałem już od jakiegoś czasu.

Jeśli w swojej aplikacji obsługujesz upload plików, to warto także dodać upload katalogów. Niestety o ile mi wiadomo działa tylko poprzez Drag & Drop. Na końcu znajdzie się cały kod na CodePen. Demo loguje pliki w konsoli zamiast wywoływać fetch i wgrywać pliki na serwer. Aby mieć prawdziwy upload plików i katalogów, wystarczy tylko podmienić funkcje upload_file, na taką która używa fetch, jak w jednym z przykładów w tym artykule.

Przygotowanie Drag & Drop Plików i Katalogów

Pierwszą rzecz jaką trzeba zrobić, aby obsłużyć zdarzenie Drag & Drop dla plików i katalogów, to wyłączenie domyślnej obsługi zdarzeń.

document.body.addEventListener('drop', function(e) {
    e.preventDefault();
});
document.body.addEventListener('dragover', function(e) {
    e.preventDefault();
});
document.body.addEventListener('dragenter', function(e) {
    e.preventDefault();
});

Jest to kod potrzebny, aby w ogóle działał Drag & Drop plików. Zdarzenia dragover oraz dragenter trzeba po prostu wyłączyć. Natomiast nasz główny kod będzie w zdarzeniu drop.

Dostęp do plików zdarzenia drop

Aby uzyskać dostęp do plików i katalogów musimy rozważyć dwa przypadki.

  • Upload jednego lub listy plików
  • Upload jednego lub wielu katalogów

dostęp do plików mamy poprzez event.dataTransfer.files lub event.target.files w zależności od przeglądarki.

Nasz kod może wyglądać tak:

const files = Array.from(event.dataTransfer.files || event.target.files || []);

Obiekt files, jest to FileList, obiekt tablico podobny, dlatego trzeba go skonwertować na prawdziwą tablicę, abyśmy mogli go przetworzyć. To API dostępne było już od dawna, może służyć do uploadu zwykłych plików (przy upuszczaniu jednego lub wielu plików).

Do tego dochodzi nowe API:

const items = Array.from(event.dataTransfer.items);

Item mogą to być pliku lub katalogi. Aby kod był uniwersalny powinno się obsłużyć files oraz items. Jest to API dostępne w przeglądarce Google Chrome, przeglądarka FireFox udostępnia API bazujące na obietnicach (ang. Promises).

Aby wgrać pliki na serwer, trzeba użyć obiektu FormData, wraz z Ajaxem.

const form = new FormData();
for (file of files) {
    form.append('files[]', file);
}
const url = 'upload.php';
fetch(url, {
    method: 'post',
    body: form
}).then(function() {
    alert('Upload Done');
});

Zamiast php może być dowolny inny język, użyte zostało nowe API fetch. Warto z niego korzystać, ponieważ jest to prostsze API niż XHR, wsparcie jest duże, a w przeglądarkach, które nie zaimplementowały tego API, można użyć polyfill, na przykład minimalistyczny unfetch.

W przypadku wielu plików, warto także podzielić upload i wgrywać pliki po jednym, ponieważ większość technologii back-end’owych posiada limity na ilość danych, jaką można przesłać.

Upload Katalogów

W przypadku katalogów proces jest nieco bardziej skomplikowany, mamy też dwa różne API z prefiksami. Inne funkcje w przeglądarce Chrome a inne w FireFox.

Przeglądarka Google Chrome oraz Chromium

W przeglądarce Chrome mamy funkcje o nazwie webkitGetAsEntry, która zwraca właściwy obiekt. Musimy wywołać tą funkcję dla każdego elementu item.

if (items.length) {
    if (items[0].webkitGetAsEntry) {
        var entries = [];
        items.forEach(function(item) {
            var entry = item.webkitGetAsEntry();
            if (entry) {
                entries.push(entry);
            }
        });
        var promise = new Promise(function(resolve) {
            (function recur() {
                var entry = entries.shift();
                if (entry) {
                    process_tree(entry, upload_file, 'path').then(recur);
                } else {
                    resolve();
                }
            })();
        });
    }
}

W powyższym kodzie mamy dwie niezdefiniowane funkcje: upload_file oraz process_tree. Kod tych funkcji podany będzie za chwile.

Przeglądarka FireFox

W przeglądarce FireFox mamy dostęp do funkcji event.dataTransfer.getFilesAndDirectories, która zwraca obietnicę wszystkich plików i katalogów. Kod obsługi wygląda tak:

if (event.dataTransfer.getFilesAndDirectories) {
    event.dataTransfer.getFilesAndDirectories().then(function(items) {
        return new Promise(function(resolve) {
            (function recur() {
                var item = items.shift();
                if (entry) {
                    process_tree(item, upload_file, 'path').then(recur);
                } else {
                    resolve();
                }
            })();
        });
    });
}

Upload jednego pliku

Pierwsza funkcja, której nam brakuje to zwykły upload, który może wyglądać tak:

function upload_file(file, path) {
    const form = new FormData();
    form.append('path', path);
    form.append('file', file);
    const url = 'upload.php';
    return fetch(url, {
        method: 'post',
        body: form,
    });
}

Przetwarzanie Katalogów i Plików

Poniżej druga funkcja, której nie pokazałem. Tak wygląda przetwarzanie drzewa katalogów w obu przeglądarkach.

function process_tree(tree, process_file, path) {
    return new Promise(function(resolve, reject) {
        function process(entries, callback) {
            entries = entries.slice();
            (function recur() {
                var entry = entries.shift();
                if (entry) {
                    callback(entry).then(recur).catch(reject);
                } else {
                    resolve();
                }
            })();
        }
        function process_and_resolve(file) {
            process_file(file, path).then(function() {
                defered.resolve();
            }).catch(reject);
        }
        function process_entries(entries) {
            process(entries, function(entry) {
                return process_tree(entry, process_file, path + "/" + tree.name);
            });
        }
        if (typeof Directory != 'undefined' && tree instanceof Directory) { // firefox
            tree.getFilesAndDirectories().then(process_entries);
        } else if (typeof File != 'undefined' && tree instanceof File) { // firefox
            process_and_resolve(tree);
        } else if (tree.isFile) { // chrome
            tree.file(process_and_resolve);
        } else if (tree.isDirectory) { // chrome
            var dirReader = tree.createReader();
            dirReader.readEntries(process_entries);
        }
    });
}

Na koniec jak obiecałem demo na Codepen.

Serwer powinien odpowiednio przetworzyć poszczególne pliki uwzględniając katalog, także gdy nie istnieje. Warto tutaj sprawdzać czy nie użyto tzw. directory traversal (czyli czy użytkownik nie użył znaków .., aby wgrać plik w miejsce poza katalogiem docelowym), co byłoby poważnym błędem mogącym dać furtkę hakerom (a właściwie krakerom). Więcej o tego typu błędach możesz przeczytać w artykule o włamywaniu się oraz ochronie stron i aplikacji www.

W kolejnym wpisie przedstawię jak podzielić pliki na części, aby wgrać pliki na serwer, gdy ich wielkość przekroczy limit (np. ten który jest w PHP).

ź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.