/* Tango - Personal Media Center Copyright (C) 2021 Ulrich Hilger This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ package de.uhilger.tango.api; import com.sun.net.httpserver.Headers; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import de.uhilger.tango.App; import de.uhilger.tango.Server; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Date; import java.util.Iterator; import java.util.List; import java.util.logging.Logger; /** * Die Klasse FileHandler dient zur Auslieferung von Dateiinhalten über * HTTP. * * Für das Streaming über HTTP wird die Auslieferung von Teilinhalten * mit dem Accept-Ranges-Header angeboten und via Range-Header unterstützt. * (vgl. https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests) * * @author Ulrich Hilger * @version 0.1, 25. März 2021 */ public class FileHandler extends JsonHelper implements HttpHandler { /* Der Logger fuer diesen FileHandler */ private static final Logger logger = Logger.getLogger(FileHandler.class.getName()); /* Headernamen */ public static final String RANGE_HEADER = "Range"; public static final String CONTENT_RANGE_HEADER = "Content-Range"; public static final String ACCEPT_RANGES_HEADER = "Accept-Ranges"; public static final String LAST_MODIFIED_DATE_HEADER = "Last-Modified"; public static final String CONTENT_TYPE = "Content-Type"; public static final String CONTENT_LENGTH = "Content-Length"; /* Statuscodes */ public static final int SC_OK = 200; public static final int SC_PARTIAL_CONTENT = 206; public static final int SC_NOT_FOUND = 404; /* HTTP Methoden */ public static final String HTTP_GET = "GET"; /* String Konstanten */ public static final String STR_BLANK = " "; public static final String STR_COMMA = ","; public static final String STR_DOT = "."; /* ResourceBundle-Kennungen */ public static final String RB_BYTES = "bytes"; public static final String RB_DASH = "dash"; public static final String RB_NOT_FOUND = "notFound"; public static final String RB_LM_PATTERN = "lmPattern"; public static final String RB_RANGE_PATTERN = "rangePattern"; public static final String RB_WELCOME_FILE = "welcomeFile"; /* Ablageort fuer Webinhalte */ protected final String fileBase; /** * Ein neues Objekt der Klasse FileHandler erzeugen * * @param absoluteDirectoryPathAndName der absolute Pfad und Name des * Ordners im Dateisystem, der die Inhalte enthaelt, die von diesem * Handler ausgeliefert werden sollen */ public FileHandler(String absoluteDirectoryPathAndName) { this.fileBase = absoluteDirectoryPathAndName; } /** * Die Datei ermitteln, die sich aus dem angefragten URL ergibt, prüfen, * ob die Datei existiert und den Inhalt der Datei abhängig davon, ob ein * Range-Header vorhanden ist, ganz oder teilweise ausliefern. * * @param e das Objekt mit Methoden zur Untersuchung der Anfrage sowie zum * Anfertigen und Senden der Antwort * @throws IOException falls etwas schief geht entsteht dieser Fehler */ @Override public void handle(HttpExchange e) throws IOException { String fName = getFileName(e); if (fName.startsWith(STR_DOT)) { sendNotFound(e, fName); } else { Headers headers = e.getRequestHeaders(); if (headers.containsKey(RANGE_HEADER)) { serveFileParts(e, new File(fileBase, fName)); } else { if (fName.length() < 1 || fName.endsWith(Server.SLASH)) { fName += App.getRs(RB_WELCOME_FILE); } serveFile(e, new File(fileBase, fName)); } } } /** * Den Namen der gewünschten Datei aus der HTTP-Anfrage ermitteln * * @param e das Objekt mit Methoden zur Untersuchung der Anfrage sowie zum * Anfertigen und Senden der Antwort * @return Name der gewünschten Datei */ protected String getFileName(HttpExchange e) { String ctxPath = e.getHttpContext().getPath(); String uriPath = e.getRequestURI().getPath(); logger.fine(uriPath); return uriPath.substring(ctxPath.length()); } /** * Den Inhalt einer Datei ausliefern * * @param e das Objekt mit Methoden zur Untersuchung der Anfrage sowie zum * Anfertigen und Senden der Antwort * @param file die Datei, deren Inhalt ausgeliefert werden soll * @throws IOException falls etwas schief geht entsteht dieser Fehler */ protected void serveFile(HttpExchange e, File file) throws IOException { if (file.exists()) { setHeaders(e, file); e.getResponseHeaders().set(CONTENT_LENGTH, Long.toString(file.length())); e.sendResponseHeaders(SC_OK, file.length()); if(HTTP_GET.equalsIgnoreCase(e.getRequestMethod())) { InputStream in = new FileInputStream(file); OutputStream os = e.getResponseBody(); int b = in.read(); while (b > -1) { os.write(b); b = in.read(); } in.close(); os.flush(); os.close(); } } else { sendNotFound(e, file.getName()); } } /** * Einen Teil des Inhalts einer Datei ausliefern * * Wenn eine Range angefragt wird, hat die Antwort einen Content-Range Header * wie folgt: * * * Content-Range: bytes 0-1023/146515 * Content-Length: 1024 * * * Wenn mehrere Ranges angefragt werden, hat die Antwort mehrere Content-Range * Header als Multipart Response. Multipart Responses fehlen dieser * Implementierung noch. * * (vgl. https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests) * * @param e das Objekt mit Methoden zur Untersuchung der Anfrage sowie zum * Anfertigen und Senden der Antwort * @param file die Datei, deren Inhalt teilweise ausgeliefert werden soll * @throws IOException falls etwas schief geht entsteht dieser Fehler */ /* */ protected void serveFileParts(HttpExchange e, File file) throws IOException { if (file.exists()) { setHeaders(e, file); long responseLength = 0; long start = 0; long end; RangeGroup rangeGroup = parseRanges(e, file); Iterator i = rangeGroup.getRanges(); Headers resHeaders = e.getResponseHeaders(); while (i.hasNext()) { Range range = i.next(); start = range.getStart(); end = range.getEnd(); resHeaders.add(CONTENT_RANGE_HEADER, contentRangeHdr(range, file)); responseLength += (end - start); } e.sendResponseHeaders(SC_PARTIAL_CONTENT, responseLength); if(HTTP_GET.equalsIgnoreCase(e.getRequestMethod())) { InputStream is = new FileInputStream(file); OutputStream os = e.getResponseBody(); if (start > 0) { is.skip(start); } long count = 0; int byteRead = is.read(); while (byteRead > -1 && count < responseLength) { ++count; os.write(byteRead); byteRead = is.read(); } os.flush(); os.close(); is.close(); } } else { sendNotFound(e, file.getName()); } } /** * Die Byte-Ranges aus dem Range-Header ermitteln. * * Der Range-Header kann unterschiedliche Abschnitte bezeichnen, Beispiele: * Range: bytes=200-1000, 2000-6576, 19000- Range: bytes=0-499, -500 (vgl. * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range) * * @param e das Objekt mit Methoden zur Untersuchung der Anfrage sowie zum * Anfertigen und Senden der Antwort * @param file die Datei, deren Inhalt ausgeliefert werden soll * @return die angefragten Byte-Ranges */ protected RangeGroup parseRanges(HttpExchange e, File file) { RangeGroup ranges = new RangeGroup(); String rangeHeader = e.getRequestHeaders().get(RANGE_HEADER).toString(); /* Inhalt des Range-Headers von nicht benoetigten Angaben befreien Ein Range Header enthaelt neben den Start- und Endwerten der Ranges auch die Angabe "bytes:". Es ist aber keine andere Auspraegung als Bytes spezifiziert, daher muss die Angabe nicht ausgewertet werden und kann entfallen. Der Range-Header kann zudem noch eckige Klammern haben wie in [bytes=200-1000]. Der regulaere Ausdruck "[^\\d-,]" bezeichnet alle Zeichen, die keine Ziffern 0-9, Bindestrich oder Komma sind. */ rangeHeader = rangeHeader.replaceAll(App.getRs(RB_RANGE_PATTERN), ""); /* Die Ranges ermitteln. Nach dem vorangegangenen Schritt besteht der Header-Ausdruck nur noch aus einer mit Kommas getrennten Liste aus Start- und Endwerten wie z.B. "-103,214-930,1647-" Ein Range-Ausdruck kann dann drei verschiedene Auspraegungen haben: 1. Startwert fehlt, z.B. -200 2. Start und Ende sind vorhanden, z.B. 101-200 3. Endwert fehlt, z.B. 201- Teilt man einen Range-String mit der Methode String.split("-") am Bindestrich ('-') in ein String-Array 'values' gilt: values.length < 2: Fall 3 ist gegeben values.length > 1 und values[0].length < 1: Fall 1 ist gegeben ansonsten: Fall 2 ist gegeben */ String[] rangeArray = rangeHeader.split(STR_COMMA); for (String rangeStr : rangeArray) { Range range = new Range(); String[] values = rangeStr.split(App.getRs(RB_DASH)); if (values.length < 2) { // Fall 3 range.setStart(Long.parseLong(values[0])); range.setEnd(file.length()); } else { if (values[0].length() < 1) { // Fall 1 range.setStart(0); range.setEnd(Long.parseLong(values[1])); } else { // Fall 2 range.setStart(Long.parseLong(values[0])); range.setEnd(Long.parseLong(values[1])); } } ranges.addRange(range); } return ranges; } /** * Einen Content-Range Header erzeugen * * @param range die Range, aus deren Inhalt der Header erzeugt werden soll * @param file die Datei, die den Inhalt liefert, der vom Header * bezeichnet wird * @return der Inhalt des Content-Range Headers */ protected String contentRangeHdr(Range range, File file) { StringBuilder sb = new StringBuilder(); sb.append(App.getRs(RB_BYTES)); sb.append(STR_BLANK); sb.append(range.getStart()); sb.append(App.getRs(RB_DASH)); sb.append(range.getEnd()); sb.append(Server.SLASH); sb.append(file.length()); return sb.toString(); } /** * Die Header erzeugen, die unabhängig davon, ob der ganze * Inhalt oder nur Teile davon ausgeliefert werden sollen, in der * Antwort stehen sollen * * @param e das Objekt mit Methoden zur Untersuchung der Anfrage sowie zum * Anfertigen und Senden der Antwort * @param file die Datei, für die die Header gelten * @throws IOException falls etwas schief geht entsteht dieser Fehler */ protected void setHeaders(HttpExchange e, File file) throws IOException { Headers resHeaders = e.getResponseHeaders(); resHeaders.add(ACCEPT_RANGES_HEADER, App.getRs(RB_BYTES)); String mimeType = Files.probeContentType(file.toPath()); if (mimeType != null) { resHeaders.add(CONTENT_TYPE, mimeType); } SimpleDateFormat sdf = new SimpleDateFormat(App.getRs(RB_LM_PATTERN)); Date date = new Date(file.lastModified()); resHeaders.add(LAST_MODIFIED_DATE_HEADER, sdf.format(date)); } /** * Eine nicht gefunden Antwort senden * * @param e das Objekt mit Methoden zur Untersuchung der Anfrage sowie zum * Anfertigen und Senden der Antwort * @param fname Name der Datei, die nicht gefunden wurde * @throws IOException falls etwas schief geht entsteht dieser Fehler */ protected void sendNotFound(HttpExchange e, String fname) throws IOException { OutputStream os = e.getResponseBody(); String response = fname + STR_BLANK + App.getRs(RB_NOT_FOUND); byte[] bytes = response.getBytes(StandardCharsets.UTF_8); e.sendResponseHeaders(SC_NOT_FOUND, bytes.length); os.write(bytes); os.flush(); os.close(); } /** * Eine Range bezeichnet einen zusammenhängenden Bereich * aus Bytes, der sich aus den Bytepositionen des Beginns und Endes * des Bereiches ergibt. */ public class Range { private long start; private long end; /** * Den Beginn dieser Range ermitteln * * @return Beginn dieser Range */ public long getStart() { return start; } /** * Den Beginn dieser Range angeben * * @param start Beginn dieser Range */ public void setStart(long start) { this.start = start; } /** * Das Ende dieser Range ermitteln * * @return Ende dieser Range */ public long getEnd() { return end; } /** * Das Ende dieser Range angeben * * @param end Ende dieser Range */ public void setEnd(long end) { this.end = end; } } /** * Eine Gruppe aus Ranges */ class RangeGroup { private List ranges; private long totalSize; /** * Ein neues Objekt der Klasse RangeGroup erzeugen */ public RangeGroup() { ranges = new ArrayList(); } /** * Dieser RangeGroup eine Range hinzufuegen. * * @param range die Range, die dieser RangeGroup hinzugefuegt werden soll */ public void addRange(Range range) { ranges.add(range); totalSize += range.getEnd() - range.getStart(); } /** * Die Gesamtgröße dieser RangeGroup ermitteln, also die Summe * der Anzahl von Bytes aller ihrer Ranges. * * @return die Größe dieser RangeGroup in Bytes */ public long getSize() { return totalSize; } /** * Einen Iterator über die Ranges dieser RangeGroup abrufen * * @return Iterator über die Ranges dieser RangeGroup */ public Iterator getRanges() { return ranges.iterator(); } } }