/*
|
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 <https://www.gnu.org/licenses/>.
|
*/
|
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:
|
*
|
* <code>
|
* Content-Range: bytes 0-1023/146515
|
* Content-Length: 1024
|
* </code>
|
*
|
* 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<Range> 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<Range> 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<Range> getRanges() {
|
return ranges.iterator();
|
}
|
}
|
}
|