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