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