001/* 002 * JGrapes Event Driven Framework 003 * Copyright (C) 2016-2018 Michael N. Lipp 004 * 005 * This program is free software; you can redistribute it and/or modify it 006 * under the terms of the GNU Affero General Public License as published by 007 * the Free Software Foundation; either version 3 of the License, or 008 * (at your option) any later version. 009 * 010 * This program is distributed in the hope that it will be useful, but 011 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY 012 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License 013 * for more details. 014 * 015 * You should have received a copy of the GNU Affero General Public License along 016 * with this program; if not, see <http://www.gnu.org/licenses/>. 017 */ 018 019package org.jgrapes.http; 020 021import java.io.IOException; 022import java.net.MalformedURLException; 023import java.net.URI; 024import java.nio.file.FileSystem; 025import java.nio.file.FileSystemNotFoundException; 026import java.nio.file.Files; 027import java.nio.file.Path; 028import java.nio.file.Paths; 029import java.nio.file.StandardOpenOption; 030import java.text.ParseException; 031import java.time.Instant; 032import java.time.temporal.ChronoField; 033import java.util.Arrays; 034import java.util.Optional; 035import org.jdrupes.httpcodec.protocols.http.HttpConstants.HttpStatus; 036import org.jdrupes.httpcodec.protocols.http.HttpField; 037import org.jdrupes.httpcodec.protocols.http.HttpResponse; 038import org.jdrupes.httpcodec.types.Converters; 039import org.jdrupes.httpcodec.types.MediaType; 040import org.jgrapes.core.Channel; 041import org.jgrapes.core.Component; 042import org.jgrapes.http.ResponseCreationSupport.MaxAgeCalculator; 043import org.jgrapes.http.annotation.RequestHandler; 044import org.jgrapes.http.events.Request; 045import org.jgrapes.http.events.Response; 046import org.jgrapes.io.IOSubchannel; 047import org.jgrapes.io.events.StreamFile; 048 049/** 050 * A dispatcher for requests for static content, usually files. 051 */ 052public class StaticContentDispatcher extends Component { 053 054 private ResourcePattern resourcePattern; 055 private URI contentRoot; 056 private Path contentDirectory; 057 private MaxAgeCalculator maxAgeCalculator 058 = (request, mediaType) -> 365 * 24 * 3600; 059 060 /** 061 * Creates new dispatcher that tries to fulfill requests matching 062 * the given resource pattern from the given content root. 063 * 064 * An attempt is made to convert the content root to a {@link Path} 065 * in a {@link FileSystem}. If this fails, the content root is 066 * used as a URL against which requests are resolved and data 067 * is obtained by open an input stream from the resulting URL. 068 * In the latter case modification times aren't available. 069 * 070 * @param componentChannel this component's channel 071 * @param resourcePattern the pattern that requests must match 072 * in order to be handled by this component 073 * (see {@link ResourcePattern}) 074 * @param contentRoot the location with content to serve 075 * @see Component#Component(Channel) 076 */ 077 public StaticContentDispatcher(Channel componentChannel, 078 String resourcePattern, URI contentRoot) { 079 super(componentChannel); 080 try { 081 this.resourcePattern = new ResourcePattern(resourcePattern); 082 } catch (ParseException e) { 083 throw new IllegalArgumentException(e); 084 } 085 try { 086 this.contentDirectory = Paths.get(contentRoot); 087 } catch (FileSystemNotFoundException e) { 088 this.contentRoot = contentRoot; 089 } 090 RequestHandler.Evaluator.add(this, "onGet", resourcePattern); 091 } 092 093 /** 094 * Creates a new component base with its channel set to 095 * itself. 096 * 097 * @param resourcePattern the pattern that requests must match with to 098 * be handled by this component 099 * (see {@link ResourcePattern#matches(String, java.net.URI)}) 100 * @param contentRoot the location with content to serve 101 * @see Component#Component() 102 */ 103 public StaticContentDispatcher(String resourcePattern, URI contentRoot) { 104 this(Channel.SELF, resourcePattern, contentRoot); 105 } 106 107 /** 108 * @return the maxAgeCalculator 109 */ 110 public MaxAgeCalculator maxAgeCalculator() { 111 return maxAgeCalculator; 112 } 113 114 /** 115 * Sets the {@link MaxAgeCalculator} for generating the `Cache-Control` 116 * (`max-age`) header of the response. The default max age calculator 117 * used simply returns a max age of one year, since this component 118 * is intended to serve static content. 119 * 120 * @param maxAgeCalculator the maxAgeCalculator to set 121 */ 122 public void setMaxAgeCalculator(MaxAgeCalculator maxAgeCalculator) { 123 this.maxAgeCalculator = maxAgeCalculator; 124 } 125 126 /** 127 * Handles a `GET` request. 128 * 129 * @param event the event 130 * @param channel the channel 131 * @throws ParseException the parse exception 132 * @throws IOException Signals that an I/O exception has occurred. 133 */ 134 @RequestHandler(dynamic = true) 135 public void onGet(Request.In.Get event, IOSubchannel channel) 136 throws ParseException, IOException { 137 if (event.fulfilled()) { 138 return; 139 } 140 int prefixSegs = resourcePattern.matches(event.requestUri()); 141 if (prefixSegs < 0) { 142 return; 143 } 144 if (contentDirectory == null) { 145 getFromUri(event, channel, prefixSegs); 146 } else { 147 getFromFileSystem(event, channel, prefixSegs); 148 } 149 } 150 151 @SuppressWarnings("PMD.DataflowAnomalyAnalysis") 152 private boolean getFromFileSystem(Request.In.Get event, 153 IOSubchannel channel, int prefixSegs) 154 throws IOException, ParseException { 155 // Final wrapper for usage in closure 156 final Path[] assembly = { contentDirectory }; 157 Arrays.stream(event.requestUri().getPath().split("/")) 158 .skip(prefixSegs + 1) 159 .forEach(seg -> assembly[0] = assembly[0].resolve(seg)); 160 Path resourcePath = assembly[0]; 161 if (Files.isDirectory(resourcePath)) { 162 Path indexPath = resourcePath.resolve("index.html"); 163 if (Files.isReadable(indexPath)) { 164 resourcePath = indexPath; 165 } else { 166 return false; 167 } 168 } 169 if (!Files.isReadable(resourcePath)) { 170 return false; 171 } 172 173 // Get content type 174 HttpResponse response = event.httpRequest().response().get(); 175 MediaType mediaType = HttpResponse.contentType(resourcePath.toUri()); 176 177 // Derive max-age 178 ResponseCreationSupport.setMaxAge( 179 response, maxAgeCalculator.maxAge(event.httpRequest(), mediaType)); 180 181 // Check if sending is really required. 182 Instant lastModified = Files.getLastModifiedTime(resourcePath) 183 .toInstant().with(ChronoField.NANO_OF_SECOND, 0); 184 Optional<Instant> modifiedSince = event.httpRequest() 185 .findValue(HttpField.IF_MODIFIED_SINCE, Converters.DATE_TIME); 186 event.setResult(true); 187 event.stop(); 188 if (modifiedSince.isPresent() 189 && !lastModified.isAfter(modifiedSince.get())) { 190 response.setStatus(HttpStatus.NOT_MODIFIED); 191 response.setField(HttpField.LAST_MODIFIED, lastModified); 192 channel.respond(new Response(response)); 193 } else { 194 response.setContentType(mediaType); 195 response.setStatus(HttpStatus.OK); 196 response.setField(HttpField.LAST_MODIFIED, lastModified); 197 channel.respond(new Response(response)); 198 fire(new StreamFile(resourcePath, StandardOpenOption.READ), 199 channel); 200 } 201 return true; 202 } 203 204 private boolean getFromUri(Request.In.Get event, IOSubchannel channel, 205 int prefixSegs) throws ParseException { 206 return ResponseCreationSupport.sendStaticContent( 207 event, channel, path -> { 208 try { 209 return contentRoot.resolve( 210 ResourcePattern.removeSegments( 211 path, prefixSegs + 1)) 212 .toURL(); 213 } catch (MalformedURLException e) { 214 throw new IllegalArgumentException(e); 215 } 216 }, maxAgeCalculator); 217 } 218 219}