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