Dateien ausliefern mit Java Servlets

Veröffentlicht am 2. May 2010

Wenn man dynamische Web-Applikationen programmieren möchte, stehen einem eine große Auswahl an Technologien zur Verfügung. Sehr verbreitet ist vor allem im Business-Bereich die Java-Servlet-Technologie.

Üblicherweise ist an vordester Front ein gewöhnlicher Webserver (z.B. Apache HTTPD) vorgeschaltet, der eingehende HTTP-Requests zunächst verarbeitet.

Verweist der Request auf eine dynamische Resource (also eine Seite der Web-Applikation), so wird der Request an den Servlet-Container (z.B. Apache Tomcat) weitergeleitet und dort von dem zugehörigen Servlet verarbeitet.

Webserver Konfiguration Webserver Konfiguration

Dadurch wird gewährleistet, dass statische Resourcen direkt von dem Webserver verarbeitet werden, was einige Vorteile mit sich bringt:

  • Es muss kein Tomcat-Thread erstellt werden. Der HTTPD-Webserver arbeitet üblichweise im Prefork-Modus, so dass benötigte Threads bereits vorher erstellt wurden, und den Request sofort abarbeiten können. Dadurch wird eine hohe Performanz erreicht
  • Die statischen Resourcen müssen nicht dem Servlet-Kontext zugänglich sein. Das ist vor allem hilfreich, wenn der Webserver und der Servlet-Container in unterschiedlichen Verzeichnissen (oder sogar auf verschiedene Maschinen) operieren.

In einigen Fällen kann es allerdings sinnvoll sein, wenn das Ausliefern von statischen Resourcen (Bilder, Stylesheets, Downloads, …) nicht vom Webserver durchgeführt wird, sondern von einem Servlet übernommen wird.

Das kann einige Gründe haben:

  • Der Apache Webserver implementiert nur einige wenige Zugriffssicherungensmechanismen (z.B. Zugangsschutz mit festem Username/Passwort).
    Wird eine komplexer Zugriffssicherung benötigt, muss diese in einer dynamischen Komponente (hier: Servlet) ausprogrammiert werden.
    Das wird vor allem relevant, wenn die Resource in die Seite eingebettet werden soll oder wenn der Zugangsschutz vom Zustand der Web-Applikation abhängt (ist der Benutzer angemeldet?)
  • In einigen Fällen kann es vorkommen, dass statische Resourcen vor dem Ausliefern dynamisch verändert oder generiert werden müssen.
  • Möchte man in dem Servlet eine Statistik über tatsächlich angeforderte Dateien führen, ist es auch am einfachsten, diese Dateien über das Servlet auszuliefern
  • Falls die Zuordnung zwischen Resourcen-URLs und Dateien im Dateisystem des Servers sich dynamisch ändert, muss die Datei ebenfalls über eine dynamische Komponente ausgeliefert werden

Diesen Fall, dass ein Java-Servlet das Ausliefern einer beliebigen Datei übernehmen soll, möchte ich nun hier genauer betrachten.

Die grundlegende Servlet-Architektur

Ich gehe in diesem Artikel davon aus, dass die Klasse [var]DownloadHandler[/var] zum Ausliefern der Dateien Teil einer größeren Web-Applikation ist.

Wird eine auslieferbare Resource angefordert, so sollte die Web-Applikation diesen Request bereits an eine Instanz der Klasse DownloadHandler weiterleiten, indem die Methode handleFileDownload aufgerufen wird.

Um ihrer Aufgabe gerecht werden zu können, benötigt diese Funktion folgende Parameter:

  • Ein Request-Objekt vom Typ HttpServletRequest. Dieses Objekt wird von Tomcat bei einem Aufruf erstellt und an die Web-Applikation übergeben.
    Dieses Objekt enthält alle benötigten Informationen über den Aufruf.
    Über das Request-Objekt lässt sich beispielsweise der aufgerufene Pfad ablesen, aus dem sich die angeforderte Datei ermitteln lässt
  • Ein Response-Objekt vom Typ HttpServletResponse. Dieses Objekt wird ebenfalls von Tomcat zur Verfügung gestellt und ermöglicht es, mit dem Client zu kommunizieren. Hiermit wird die Ausgabe der Web-Applikation an den Client geschickt. Es lassen sich außerdem HTTP-Header setzen.

Bei einem Aufruf des Download Handlers sind folgende Aufgaben aufzuführen

  1. Bestimmen der auszuliefernden Datei aus dem Request-Parameter. Ist diese Datei nicht vorhanden, muss ein Fehlercode erzeugt werden
  2. Bestimmen, ob der Benutzer diese Datei erhalten darf (= Zugriffsschutz). Darf der Benutzer diese Datei nicht herunterladen, muss ein Fehlercode erzeugt werden
  3. Setzen der korrekten HTTP-Header, damit der Client die geschickte Datei richtig interpretieren kann (z.B. Dateigröße, Dateityp, …)
  4. Öffnen der Datei und Schicken des Dateiinhalts durch den Ausgabe-Stream des Response-Objekts

Es müssen also von der Web-Applikation noch folgende Informationen bereitgestellt werden:

  • Welche URL auf welche Datei im Dateisystem passt.
    Diese Information ist abhängig davon, wie die Web-Applikation URLs erzeugt und auflöste.
    Ich werde daher hier annehmen, dass eine Methode getFileFromRequest existiert, wie ein Objekt vom Typ HttpServletRequest auf ein File-Objekt auflöst.
    Achtung: Es ist nicht empfehlenswert, die URL direkt auf einen Teil des Dateisystems zu mappen, da sonst sehr große Sicherheitslücken entstehen könnten (der Benutzer könnte ggf. beliebige Dateien des Systems herunterladen, inklusive Passwortlisten etc.).
    Stattdessen wird empfohlen, mit einer “White-List”, also einer fest vorgegebenen Liste von erlaubten URLs, zu arbeiten und abweichende Anfragen sofort abzublocken.
  • Nach welchen Kriterien entschieden wird, ob der Client die betreffende Datei herunterladen darf.
    An dieser Stelle sind benötigte Zugangsschutz-Machanismen zu implementieren.
    Ich gehe hier davon aus, dass eine Methode isDownloadAllowed existiert, die entscheidet, ob der Client die Datei herunterladen darf.
  • Welchen Medientyp der angeforderte Datei besitzt.
    Obwohl dieses Thema ausreichend komplex ist, kann üblicherweise der Medientyp direkt von der Dateiendung abgeleitet werden.
    Ich werde hier eine solche Medientyp-Bestimmung implementieren.

Die grundlegende Struktur des Download Handlers könnte also wie folgt aussehen:

package de.planet_metax.dlservlet;

import java.io.File;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * Diese Klasse soll das Übermitteln einer Datei an den Client ermöglichen (= Download-Funktion).
 * @author Christian Simon
 */
public class DownloadHandler {

	/**
	 * Bestimme, welche Datei von dem request angefordert wurde.
	 * Falls eine ungültige Datei angefordert wurde, 
	 * @param request die Anfrage
	 * @return File-Objekt oder null, falls der Request ungültig war
	 */
	private File getFileFromRequest(HttpServletRequest request) {
		// TODO: Resource in Datei auflösen
		return null;
	}
	
	/**
	 * Prüfe, ob die Datei an den Client geschickt werden darf (Zugriffsschutz)
	 * @param file die Datei
	 * @return true, falls der Zugriff legitim ist
	 */
	private boolean isDownloadAllowed(File file) {
		// TODO: Zugriffsschutz
		return true;
	}
	
	/**
	 * Ermittele den Content-Typ ("MIME-Type") der Datei
	 * @param file die Datei
	 * @return Content Typ
	 */
	private String getContentType(File file) {
		// TODO: Medientyp bestimmen
		return "text/plain";
	}
	
	/**
	 * Verarbeite den Download-Request.
	 * @param request der Request
	 * @param response der Response
	 * @throws ServletException falls ein Fehler auftritt
	 */
	public void handleFileDownload(HttpServletRequest request, HttpServletResponse response) throws ServletException {
		// TODO: Datei übermitteln
	}
	
}

Das Auswählen der Datei und der Zugriffsschutz

Ein effektiver Zugriffsschutz sollte eine Verbindung zum Account-System der Web-Applikation haben.
So lassen sich verschiedene Zugriffsrechte für verschiedene Dateien implementieren.
Da dies in diesem Beispiel nicht möglich ist, möchte ich einen sehr einfachen Zugriffsschutz implementieren:
Es ist der Zugriff auf alle Dateien erlaubt, die nicht mit “geheim.” beginnen:

private boolean isDownloadAllowed(File file) {
		if (file.getName().startsWith("geheim.")) {
			return false;
		}
		return true;
	}

Ähnlich einfach möchte ich das Auswählen der Datei regeln:
Über den HTTP-Parameter downloadfile kann ein Eintrag aus einer White-List ausgewählt werden.
Alle ungültigen Werte führen zu einem Fehler:

private static final Hashtable<String, File> fileMap;
	static {
		fileMap = new Hashtable<String, File>();
		fileMap.put("file1.txt", new File("downloads/textfiles/text1.txt"));
		fileMap.put("file2.jpg", new File("downloads/images/image2.jpg"));
		fileMap.put("file3.png", new File("downloads/images/image3.png"));
		fileMap.put("file4.dat", new File("downloads/data/data4.dat"));
		fileMap.put("geheim.zip", new File("downloads/zip/geheim.zip"));
	}
	
	private File getFileFromRequest(HttpServletRequest request) {
		String downloadfile = request.getParameter("downloadfile");
		if (downloadfile == null) {
			return null;
		}
		if (fileMap.containsKey(downloadfile)) {
			return fileMap.get(downloadfile);
		}
		return null;
	}

Bestimmen des Content-Typs

Der Content-Type (auch: MIME-Type, Medientyp) ist eine wichtige Information für Browser, da diese festlegt, wie die empfangenen Daten zu interpretieren sind.
Der Content-Type "text/html" erzwingt beispielsweise eine Darstellung in der HTML-Engine des Browsers, wogegen der Content-Type "application/pdf" das Dokument im Acrobat Reader (oder einem anderen PDF-Viewer) anzeigt.

In den meisten Fällen lässt sich der Content-Type an der Endung der Datei festmachen. Es ist zwar z.B. auch möglich, eine Textdatei mit der Endung “.jpg” zu versehen, aber ich gehe in diesem Beispiel einmal davon aus, dass der Inhalt aller Dateien zu ihrer Endung passt.

Daher lässt sich der Medientyp einfach anhand eines Endungsvergleiches mit dem Dateinamen bestimmen:

private String getContentType(File file) {
		String filename = file.getName();
		if (filename.endsWith(".txt")) return "text/plain";
		if (filename.endsWith(".jpg")) return "image/jpeg";
		if (filename.endsWith(".png")) return "image/png";
		if (filename.endsWith(".pdf")) return "application/pdf";
		if (filename.endsWith(".zip")) return "application/zip";
		// ...
		
		// Dieser Content-Typ bedeutet "Binärdaten" und erzwingt in jedem
		// Browser einen Download.
		return "application/octet-stream";
	}

Das Ausliefern der Datei

Mit diesen Methoden ist es nun nicht mehr schwer, die Datei auszuliefern.

Dazu verwende ich den anfangs erläuterten Ablauf unter Einbeziehung der soeben definierten Methoden.

In diesem Beispiel wird (wie oben beschrieben) die herunterzuladende Datei über den HTTP-Parameter downloadfile festgelegt.
Ein korrekter Aufruf des Download-Servlets könnte also wie folgt aussehen:

http://server.tld/DownloadServlet/Download.do?downloadfile=file2.jpg

Hier sieht man nun das Endergebnis der einfachen Download-Klasse.
Den Java-Quelltext dieses Beispiels kann man auch herunterladen.

package de.planet_metax.dlservlet;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Hashtable;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * Diese Klasse soll das Übermitteln einer Datei an den Client ermöglichen (= Download-Funktion).
 * @author Christian Simon
 */
public class DownloadHandler {

	/**
	 * White-List für die erlaubten Dateien.
	 * Jeder Dateiname (links) wird einer Datei (rechts) zugeordnet.
	 */
	private static final Hashtable<String, File> fileMap;
	static {
		fileMap = new Hashtable<String, File>();
		fileMap.put("file1.txt", new File("downloads/textfiles/text1.txt"));
		fileMap.put("file2.jpg", new File("downloads/images/image2.jpg"));
		fileMap.put("file3.png", new File("downloads/images/image3.png"));
		fileMap.put("file4.dat", new File("downloads/data/data4.dat"));
		fileMap.put("geheim.zip", new File("downloads/zip/geheim.zip"));
	}
	
	/**
	 * Bestimme, welche Datei von dem request angefordert wurde.
	 * Falls eine ungültige Datei angefordert wurde, 
	 * @param request die Anfrage
	 * @return File-Objekt oder null, falls der Request ungültig war
	 */
	private File getFileFromRequest(HttpServletRequest request) {
		String downloadfile = request.getParameter("downloadfile");
		if (downloadfile == null) {
			return null;
		}
		if (fileMap.containsKey(downloadfile)) {
			return fileMap.get(downloadfile);
		}
		return null;
	}
	
	/**
	 * Prüfe, ob die Datei an den Client geschickt werden darf (Zugriffsschutz)
	 * @param file die Datei
	 * @return true, falls der Zugriff legitim ist
	 */
	private boolean isDownloadAllowed(File file) {
		if (file.getName().startsWith("geheim.")) {
			return false;
		}
		return true;
	}
	
	/**
	 * Ermittele den Content-Typ ("MIME-Type") der Datei
	 * @param file die Datei
	 * @return Content Typ
	 */
	private String getContentType(File file) {
		String filename = file.getName();
		if (filename.endsWith(".txt")) return "text/plain";
		if (filename.endsWith(".jpg")) return "image/jpeg";
		if (filename.endsWith(".png")) return "image/png";
		if (filename.endsWith(".pdf")) return "application/pdf";
		if (filename.endsWith(".zip")) return "application/zip";
		// ...
		
		// Dieser Content-Typ bedeutet "Binärdaten" und erzwingt in jedem
		// Browser einen Download.
		return "application/octet-stream";
	}
	
	/**
	 * Verarbeite den Download-Request.
	 * Die Exceptions werden ggf. von den Servlet-Klassen ausgelöst.
	 * @param request der Request
	 * @param response der Response
	 * @throws ServletException falls ein Fehler auftritt
	 * @throws IOException falls ein Fehler auftritt
	 */
	public void handleFileDownload(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
		
		// Bestimme die Download-Datei
		File downloadFile = getFileFromRequest(request);
		
		if (downloadFile == null || !downloadFile.exists()) {
			// Die Datei konnte nicht zugeordnet werden, oder existiert nicht.
			// Schicke Statuscode: 404 - NOT FOUND
			response.sendError(HttpServletResponse.SC_NOT_FOUND);
			return;
		}
		
		if (!isDownloadAllowed(downloadFile)) {
			// Der Zugriff wurde verweigert.
			// Schicke Statuscode: 403 - FORBIDDEN
			response.sendError(HttpServletResponse.SC_FORBIDDEN);
			return;
		}
		
		String contentType = getContentType(downloadFile);
		
		// Schicke HTTP Header
		response.setContentLength((int) downloadFile.length());
		response.setContentType(contentType);
		response.setDateHeader("Last-Modified", downloadFile.lastModified());
		
		// Öffne Streams
		InputStream in = new FileInputStream(downloadFile);
		OutputStream out = response.getOutputStream();
		int BUFFER_SIZE = 16384;
		
		// Kopiere den Inhalt des Inputstreams in den Outputstream
		try {
			byte[] buffer = new byte[BUFFER_SIZE];
	 		int bytesRead = 0;
	 		while ((bytesRead = in.read(buffer)) > 0) {
	 			out.write(buffer, 0, bytesRead);
	 		}
	 		out.flush();
		} finally {
			// Schließe Inputstream
			try {
				in.close();
	 		} catch (IOException e) { }
		}
		
	}
	
}
Kategorie: Java