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