Persoenliche Mediazentrale
ulrich
2021-04-24 94b1c2f60bde70d681b4bf8a5cd04b86e9ccf4ed
commit | author | age
c3b1d1 1 /*
94b1c2 2   Tango - Personal Media Center
c3b1d1 3   Copyright (C) 2021  Ulrich Hilger
U 4
5   This program is free software: you can redistribute it and/or modify
6   it under the terms of the GNU Affero General Public License as
7   published by the Free Software Foundation, either version 3 of the
8   License, or (at your option) any later version.
9
10   This program is distributed in the hope that it will be useful,
11   but WITHOUT ANY WARRANTY; without even the implied warranty of
12   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13   GNU Affero General Public License for more details.
14
15   You should have received a copy of the GNU Affero General Public License
16   along with this program.  If not, see <https://www.gnu.org/licenses/>.
17  */
94b1c2 18 package de.uhilger.tango.api;
c3b1d1 19
U 20 import com.sun.net.httpserver.Headers;
21 import com.sun.net.httpserver.HttpExchange;
22 import com.sun.net.httpserver.HttpHandler;
94b1c2 23 import de.uhilger.tango.App;
U 24 import de.uhilger.tango.Server;
c3b1d1 25 import java.io.File;
U 26 import java.io.FileInputStream;
27 import java.io.IOException;
28 import java.io.InputStream;
29 import java.io.OutputStream;
30 import java.nio.charset.StandardCharsets;
31 import java.nio.file.Files;
32 import java.text.SimpleDateFormat;
33 import java.util.ArrayList;
34 import java.util.Date;
35 import java.util.Iterator;
36 import java.util.List;
37 import java.util.logging.Logger;
38
39 /**
40  * Die Klasse FileHandler dient zur Auslieferung von Dateiinhalten &uuml;ber
41  * HTTP.
42  *
43  * F&uuml;r das Streaming &uuml;ber HTTP wird die Auslieferung von Teilinhalten
44  * mit dem Accept-Ranges-Header angeboten und via Range-Header unterst&uuml;tzt.
45  * (vgl. https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests)
46  *
47  * @author Ulrich Hilger
48  * @version 0.1, 25. M&auml;rz 2021
49  */
8d7d35 50 public class FileHandler extends JsonHelper implements HttpHandler {
c3b1d1 51
U 52   /* Der Logger fuer diesen FileHandler */
53   private static final Logger logger = Logger.getLogger(FileHandler.class.getName());
54
55   /* Headernamen */
56   public static final String RANGE_HEADER = "Range";
57   public static final String CONTENT_RANGE_HEADER = "Content-Range";
58   public static final String ACCEPT_RANGES_HEADER = "Accept-Ranges";
59   public static final String LAST_MODIFIED_DATE_HEADER = "Last-Modified";
60   public static final String CONTENT_TYPE = "Content-Type";
61   public static final String CONTENT_LENGTH = "Content-Length";
62
63   /* Statuscodes */
64   public static final int SC_OK = 200;
65   public static final int SC_PARTIAL_CONTENT = 206;
66   public static final int SC_NOT_FOUND = 404;
67
68   /* HTTP Methoden */
69   public static final String HTTP_GET = "GET";
70   
71   /* String Konstanten */
72   public static final String STR_BLANK = " ";
73   public static final String STR_COMMA = ",";
74   public static final String STR_DOT = ".";
2b6134 75
U 76   /* ResourceBundle-Kennungen */
77   public static final String RB_BYTES = "bytes";
78   public static final String RB_DASH = "dash";
79   public static final String RB_NOT_FOUND = "notFound";
80   public static final String RB_LM_PATTERN = "lmPattern";
81   public static final String RB_RANGE_PATTERN = "rangePattern";
82   public static final String RB_WELCOME_FILE = "welcomeFile";
c3b1d1 83
U 84   /* Ablageort fuer Webinhalte */
86bbf7 85   protected final String fileBase;
c3b1d1 86
U 87   /**
88    * Ein neues Objekt der Klasse FileHandler erzeugen
89    *
90    * @param absoluteDirectoryPathAndName der absolute Pfad und Name des 
91    * Ordners im Dateisystem, der die Inhalte enthaelt, die von diesem 
92    * Handler ausgeliefert werden sollen
93    */
94   public FileHandler(String absoluteDirectoryPathAndName) {
95     this.fileBase = absoluteDirectoryPathAndName;
96   }
97
98   /**
99    * Die Datei ermitteln, die sich aus dem angefragten URL ergibt, pr&uuml;fen,
100    * ob die Datei existiert und den Inhalt der Datei abh&auml;ngig davon, ob ein
101    * Range-Header vorhanden ist, ganz oder teilweise ausliefern.
102    *
103    * @param e das Objekt mit Methoden zur Untersuchung der Anfrage sowie zum
104    * Anfertigen und Senden der Antwort
105    * @throws IOException falls etwas schief geht entsteht dieser Fehler
106    */
107   @Override
108   public void handle(HttpExchange e) throws IOException {
109     String fName = getFileName(e);
110     if (fName.startsWith(STR_DOT)) {
111       sendNotFound(e, fName);
112     } else {
113       Headers headers = e.getRequestHeaders();
114       if (headers.containsKey(RANGE_HEADER)) {
115         serveFileParts(e, new File(fileBase, fName));
116       } else {
0e9cd3 117         if (fName.length() < 1 || fName.endsWith(Server.SLASH)) {
2b6134 118           fName += App.getRs(RB_WELCOME_FILE);
c3b1d1 119         }
U 120         serveFile(e, new File(fileBase, fName));
121       }
122     }
123   }
124
125   /**
126    * Den Namen der gew&uuml;nschten Datei aus der HTTP-Anfrage ermitteln
127    * 
128    * @param e das Objekt mit Methoden zur Untersuchung der Anfrage sowie zum
129    * Anfertigen und Senden der Antwort
130    * @return Name der gew&uuml;nschten Datei
131    */
132   protected String getFileName(HttpExchange e) {
133     String ctxPath = e.getHttpContext().getPath();
134     String uriPath = e.getRequestURI().getPath();
b29119 135     logger.fine(uriPath);
c3b1d1 136     return uriPath.substring(ctxPath.length());
U 137   }
138   
139   /**
140    * Den Inhalt einer Datei ausliefern
141    *
142    * @param e das Objekt mit Methoden zur Untersuchung der Anfrage sowie zum
143    * Anfertigen und Senden der Antwort
144    * @param file die Datei, deren Inhalt ausgeliefert werden soll
145    * @throws IOException falls etwas schief geht entsteht dieser Fehler
146    */
147   protected void serveFile(HttpExchange e, File file) throws IOException {
148     if (file.exists()) {
149       setHeaders(e, file);
150       e.getResponseHeaders().set(CONTENT_LENGTH, Long.toString(file.length()));
151       e.sendResponseHeaders(SC_OK, file.length());
152       if(HTTP_GET.equalsIgnoreCase(e.getRequestMethod())) {
153         InputStream in = new FileInputStream(file);
154         OutputStream os = e.getResponseBody();
155         int b = in.read();
156         while (b > -1) {
157           os.write(b);
158           b = in.read();
159         }
160         in.close();
161         os.flush();
162         os.close();
163       }
164     } else {
165       sendNotFound(e, file.getName());
166     }
167   }
168
169   /**
170    * Einen Teil des Inhalts einer Datei ausliefern
171    *
172    * Wenn eine Range angefragt wird, hat die Antwort einen Content-Range Header
173    * wie folgt:
174    *
175    * <code>
176    * Content-Range: bytes 0-1023/146515
177    * Content-Length: 1024
178    * </code>
179    *
180    * Wenn mehrere Ranges angefragt werden, hat die Antwort mehrere Content-Range
181    * Header als Multipart Response. Multipart Responses fehlen dieser
182    * Implementierung noch.
183    *
184    * (vgl. https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests)
185    *
186    * @param e das Objekt mit Methoden zur Untersuchung der Anfrage sowie zum
187    * Anfertigen und Senden der Antwort
188    * @param file die Datei, deren Inhalt teilweise ausgeliefert werden soll
189    * @throws IOException falls etwas schief geht entsteht dieser Fehler
190    */
191   /*
192    */
193   protected void serveFileParts(HttpExchange e, File file) throws IOException {
194     if (file.exists()) {
195       setHeaders(e, file);
196       long responseLength = 0;
197       long start = 0;
198       long end;
199       RangeGroup rangeGroup = parseRanges(e, file);
200       Iterator<Range> i = rangeGroup.getRanges();
201       Headers resHeaders = e.getResponseHeaders();
202       while (i.hasNext()) {
203         Range range = i.next();
204         start = range.getStart();
205         end = range.getEnd();
206         resHeaders.add(CONTENT_RANGE_HEADER, contentRangeHdr(range, file));
207         responseLength += (end - start);
208       }
209       e.sendResponseHeaders(SC_PARTIAL_CONTENT, responseLength);
210       if(HTTP_GET.equalsIgnoreCase(e.getRequestMethod())) {
211         InputStream is = new FileInputStream(file);
212         OutputStream os = e.getResponseBody();
213         if (start > 0) {
214           is.skip(start);
215         }
216         long count = 0;
217         int byteRead = is.read();
218         while (byteRead > -1 && count < responseLength) {
219           ++count;
220           os.write(byteRead);
221           byteRead = is.read();
222         }
223         os.flush();
224         os.close();
225         is.close();
226       }
227     } else {
228       sendNotFound(e, file.getName());
229     }
230   }
231
232   /**
233    * Die Byte-Ranges aus dem Range-Header ermitteln.
234    *
235    * Der Range-Header kann unterschiedliche Abschnitte bezeichnen, Beispiele:
236    * Range: bytes=200-1000, 2000-6576, 19000- Range: bytes=0-499, -500 (vgl.
237    * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range)
238    *
239    * @param e das Objekt mit Methoden zur Untersuchung der Anfrage sowie zum
240    * Anfertigen und Senden der Antwort
241    * @param file die Datei, deren Inhalt ausgeliefert werden soll
242    * @return die angefragten Byte-Ranges
243    */
244   protected RangeGroup parseRanges(HttpExchange e, File file) {
245     RangeGroup ranges = new RangeGroup();
246     String rangeHeader = e.getRequestHeaders().get(RANGE_HEADER).toString();
247
248     /*
249       Inhalt des Range-Headers von nicht benoetigten Angaben befreien
250     
251       Ein Range Header enthaelt neben den Start- und Endwerten der Ranges auch 
252       die Angabe "bytes:". Es ist aber keine andere Auspraegung als Bytes 
253       spezifiziert, daher muss die Angabe nicht ausgewertet werden und kann 
254       entfallen. Der Range-Header kann zudem noch eckige Klammern haben 
255       wie in [bytes=200-1000].
256     
257       Der regulaere Ausdruck "[^\\d-,]" bezeichnet alle Zeichen, die keine 
258       Ziffern 0-9, Bindestrich oder Komma sind.
259      */
2b6134 260     rangeHeader = rangeHeader.replaceAll(App.getRs(RB_RANGE_PATTERN), "");
c3b1d1 261
U 262     /*
263       Die Ranges ermitteln. 
264     
265       Nach dem vorangegangenen Schritt besteht der Header-Ausdruck nur noch 
266       aus einer mit Kommas getrennten Liste aus Start- und Endwerten wie z.B. 
267       "-103,214-930,1647-"
268     
269       Ein Range-Ausdruck kann dann drei verschiedene Auspraegungen haben:
270       1. Startwert fehlt, z.B. -200
271       2. Start und Ende sind vorhanden, z.B. 101-200
272       3. Endwert fehlt, z.B. 201-
273       
274       Teilt man einen Range-String mit der Methode String.split("-") am 
275       Bindestrich ('-') in ein String-Array 'values' gilt:
276       values.length < 2: Fall 3 ist gegeben
277       values.length > 1 und values[0].length < 1: Fall 1 ist gegeben
278       ansonsten: Fall 2 ist gegeben
279      */
280     String[] rangeArray = rangeHeader.split(STR_COMMA);
281     for (String rangeStr : rangeArray) {
282       Range range = new Range();
2b6134 283       String[] values = rangeStr.split(App.getRs(RB_DASH));
c3b1d1 284       if (values.length < 2) {
U 285         // Fall 3
286         range.setStart(Long.parseLong(values[0]));
287         range.setEnd(file.length());
288       } else {
289         if (values[0].length() < 1) {
290           // Fall 1
291           range.setStart(0);
292           range.setEnd(Long.parseLong(values[1]));
293         } else {
294           // Fall 2
295           range.setStart(Long.parseLong(values[0]));
296           range.setEnd(Long.parseLong(values[1]));
297         }
298       }
299       ranges.addRange(range);
300     }
301     return ranges;
302   }
303
304   /**
305    * Einen Content-Range Header erzeugen
306    * 
307    * @param range die Range, aus deren Inhalt der Header erzeugt werden soll
308    * @param file  die Datei, die den Inhalt liefert, der vom Header 
309    * bezeichnet wird
310    * @return der Inhalt des Content-Range Headers
311    */
312   protected String contentRangeHdr(Range range, File file) {
313     StringBuilder sb = new StringBuilder();
2b6134 314     sb.append(App.getRs(RB_BYTES));
c3b1d1 315     sb.append(STR_BLANK);
U 316     sb.append(range.getStart());
2b6134 317     sb.append(App.getRs(RB_DASH));
c3b1d1 318     sb.append(range.getEnd());
0e9cd3 319     sb.append(Server.SLASH);
c3b1d1 320     sb.append(file.length());
U 321     return sb.toString();
322   }
323
324   /**
325    * Die Header erzeugen, die unabh&auml;ngig davon, ob der ganze 
326    * Inhalt oder nur Teile davon ausgeliefert werden sollen, in der 
327    * Antwort stehen sollen 
328    * 
329    * @param e das Objekt mit Methoden zur Untersuchung der Anfrage sowie zum
330    * Anfertigen und Senden der Antwort
331    * @param file  die Datei, f&uuml;r die die Header gelten
332    * @throws IOException falls etwas schief geht entsteht dieser Fehler
333    */
334   protected void setHeaders(HttpExchange e, File file) throws IOException {
335     Headers resHeaders = e.getResponseHeaders();
2b6134 336     resHeaders.add(ACCEPT_RANGES_HEADER, App.getRs(RB_BYTES));
c3b1d1 337     String mimeType = Files.probeContentType(file.toPath());
U 338     if (mimeType != null) {
339       resHeaders.add(CONTENT_TYPE, mimeType);
340     }
2b6134 341     SimpleDateFormat sdf = new SimpleDateFormat(App.getRs(RB_LM_PATTERN));
c3b1d1 342     Date date = new Date(file.lastModified());
U 343     resHeaders.add(LAST_MODIFIED_DATE_HEADER, sdf.format(date));
344   }
345
346   /**
347    * Eine nicht gefunden Antwort senden
348    *
349    * @param e das Objekt mit Methoden zur Untersuchung der Anfrage sowie zum
350    * Anfertigen und Senden der Antwort
351    * @param fname Name der Datei, die nicht gefunden wurde
352    * @throws IOException falls etwas schief geht entsteht dieser Fehler
353    */
354   protected void sendNotFound(HttpExchange e, String fname) throws IOException {
355     OutputStream os = e.getResponseBody();
2b6134 356     String response = fname + STR_BLANK + App.getRs(RB_NOT_FOUND);
c3b1d1 357     byte[] bytes = response.getBytes(StandardCharsets.UTF_8);
U 358     e.sendResponseHeaders(SC_NOT_FOUND, bytes.length);
359     os.write(bytes);
360     os.flush();
361     os.close();
362   }
363
364   /**
365    * Eine Range bezeichnet einen zusammenh&auml;ngenden Bereich 
366    * aus Bytes, der sich aus den Bytepositionen des Beginns und Endes 
367    * des Bereiches ergibt.
368    */
369   public class Range {
370
371     private long start;
372     private long end;
373
374     /**
375      * Den Beginn dieser Range ermitteln
376      *
377      * @return Beginn dieser Range
378      */
379     public long getStart() {
380       return start;
381     }
382
383     /**
384      * Den Beginn dieser Range angeben
385      *
386      * @param start Beginn dieser Range
387      */
388     public void setStart(long start) {
389       this.start = start;
390     }
391
392     /**
393      * Das Ende dieser Range ermitteln
394      *
395      * @return Ende dieser Range
396      */
397     public long getEnd() {
398       return end;
399     }
400
401     /**
402      * Das Ende dieser Range angeben
403      *
404      * @param end Ende dieser Range
405      */
406     public void setEnd(long end) {
407       this.end = end;
408     }
409   }
410
411   /**
412    * Eine Gruppe aus Ranges
413    */
414   class RangeGroup {
415
416     private List<Range> ranges;
417     private long totalSize;
418
419     /**
420      * Ein neues Objekt der Klasse RangeGroup erzeugen
421      */
422     public RangeGroup() {
423       ranges = new ArrayList();
424     }
425
426     /**
427      * Dieser RangeGroup eine Range hinzufuegen.
428      *
429      * @param range die Range, die dieser RangeGroup hinzugefuegt werden soll
430      */
431     public void addRange(Range range) {
432       ranges.add(range);
433       totalSize += range.getEnd() - range.getStart();
434     }
435
436     /**
437      * Die Gesamtgr&ouml;&szlig;e dieser RangeGroup ermitteln, also die Summe
438      * der Anzahl von Bytes aller ihrer Ranges.
439      *
440      * @return die Gr&ouml;&szlig;e dieser RangeGroup in Bytes
441      */
442     public long getSize() {
443       return totalSize;
444     }
445
446     /**
447      * Einen Iterator &uuml;ber die Ranges dieser RangeGroup abrufen
448      *
449      * @return Iterator &uuml;ber die Ranges dieser RangeGroup
450      */
451     public Iterator<Range> getRanges() {
452       return ranges.iterator();
453     }
454   }
455 }