/*
Mediazentrale - 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.mediaz.api;
import com.sun.net.httpserver.Headers;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import de.uhilger.mediaz.App;
import de.uhilger.mediaz.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();
}
}
}