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