001/* 002 * JGrapes Event Driven Framework 003 * Copyright (C) 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.io.InputStream; 023import java.io.UnsupportedEncodingException; 024import java.net.JarURLConnection; 025import java.net.URI; 026import java.net.URISyntaxException; 027import java.net.URL; 028import java.net.URLConnection; 029import java.nio.file.FileSystemNotFoundException; 030import java.nio.file.Files; 031import java.nio.file.Path; 032import java.nio.file.Paths; 033import java.text.ParseException; 034import java.time.Instant; 035import java.time.temporal.ChronoField; 036import java.util.Optional; 037import java.util.function.Function; 038import java.util.jar.JarEntry; 039import java.util.regex.Pattern; 040import org.jdrupes.httpcodec.protocols.http.HttpConstants.HttpStatus; 041import org.jdrupes.httpcodec.protocols.http.HttpField; 042import org.jdrupes.httpcodec.protocols.http.HttpRequest; 043import org.jdrupes.httpcodec.protocols.http.HttpResponse; 044import org.jdrupes.httpcodec.types.CacheControlDirectives; 045import org.jdrupes.httpcodec.types.Converters; 046import org.jdrupes.httpcodec.types.Directive; 047import org.jdrupes.httpcodec.types.MediaType; 048import org.jgrapes.core.Event; 049import org.jgrapes.http.events.Request; 050import org.jgrapes.http.events.Response; 051import org.jgrapes.io.IOSubchannel; 052import org.jgrapes.io.events.Output; 053import org.jgrapes.io.util.InputStreamPipeline; 054 055/** 056 * Provides methods that support the creation of a {@link Response} events. 057 */ 058@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", 059 "PMD.AbstractClassWithoutAbstractMethod" }) 060public abstract class ResponseCreationSupport { 061 062 /** A default implementation for the max-age calculator. */ 063 @SuppressWarnings("PMD.LongVariable") 064 public static final MaxAgeCalculator DEFAULT_MAX_AGE_CALCULATOR 065 = new DefaultMaxAgeCalculator(); 066 067 /** 068 * Send a response to the given request with the given status code 069 * and reason phrase, including a `text/plain` body with the status 070 * code and reason phrase. 071 * 072 * @param request the request 073 * @param channel for responding; events will be sent using 074 * {@link IOSubchannel#respond(org.jgrapes.core.Event)} 075 * @param statusCode the status code to send 076 * @param reasonPhrase the reason phrase to send 077 */ 078 @SuppressWarnings("PMD.EmptyCatchBlock") 079 public static void sendResponse(HttpRequest request, 080 IOSubchannel channel, int statusCode, String reasonPhrase) { 081 HttpResponse response = request.response().get(); 082 response.setStatusCode(statusCode).setReasonPhrase(reasonPhrase) 083 .setHasPayload(true).setField( 084 HttpField.CONTENT_TYPE, 085 MediaType.builder().setType("text", "plain") 086 .setParameter("charset", "utf-8").build()); 087 // Act like a sub-component, i.e. generate events that are 088 // handled by this HTTP server as if sent from a sub-component. 089 channel.respond(new Response(response)); 090 try { 091 channel.respond(Output.from((statusCode + " " + reasonPhrase) 092 .getBytes("utf-8"), true)); 093 } catch (UnsupportedEncodingException e) { 094 // Supported by definition 095 } 096 } 097 098 /** 099 * Shorthand for invoking 100 * {@link #sendResponse(HttpRequest, IOSubchannel, int, String)} 101 * with a predefined HTTP status. 102 * 103 * @param request the request 104 * @param channel the channel 105 * @param status the status 106 */ 107 public static void sendResponse(HttpRequest request, 108 IOSubchannel channel, HttpStatus status) { 109 sendResponse(request, channel, status.statusCode(), 110 status.reasonPhrase()); 111 } 112 113 /** 114 * Creates and sends a response with static content. The content 115 * is looked up by invoking the resolver with the path from the request. 116 * 117 * The response includes a max-age header with a default value of 118 * 600. The value may be modified by specifying validity infos. 119 * 120 * @param request the request 121 * @param channel the channel 122 * @param resolver the resolver 123 * @param maxAgeCalculator the max age calculator, if `null` 124 * the default calculator is used. 125 * @return `true` if a response was sent 126 */ 127 @SuppressWarnings({ "PMD.NcssCount", 128 "PMD.UseStringBufferForStringAppends" }) 129 public static boolean sendStaticContent( 130 HttpRequest request, IOSubchannel channel, 131 Function<String, URL> resolver, MaxAgeCalculator maxAgeCalculator) { 132 String path = request.requestUri().getPath(); 133 URL resourceUrl = resolver.apply(path); 134 ResourceInfo info; 135 URLConnection resConn; 136 @SuppressWarnings("PMD.CloseResource") 137 InputStream resIn; 138 try { 139 if (resourceUrl == null) { 140 throw new IOException(); 141 } 142 info = ResponseCreationSupport.resourceInfo(resourceUrl); 143 if (Boolean.TRUE.equals(info.isDirectory())) { 144 throw new IOException(); 145 } 146 resConn = resourceUrl.openConnection(); 147 resIn = resConn.getInputStream(); 148 } catch (IOException e1) { 149 try { 150 if (!path.endsWith("/")) { 151 path += "/"; 152 } 153 path += "index.html"; 154 resourceUrl = resolver.apply(path); 155 if (resourceUrl == null) { 156 return false; 157 } 158 info = ResponseCreationSupport.resourceInfo(resourceUrl); 159 resConn = resourceUrl.openConnection(); 160 resIn = resConn.getInputStream(); 161 } catch (IOException e2) { 162 return false; 163 } 164 } 165 HttpResponse response = request.response().get(); 166 response.setField(HttpField.LAST_MODIFIED, 167 Optional.ofNullable(info.getLastModifiedAt()) 168 .orElseGet(() -> Instant.now())); 169 170 // Get content type and derive max age 171 MediaType mediaType = HttpResponse.contentType( 172 ResponseCreationSupport.uriFromUrl(resourceUrl)); 173 setMaxAge(response, 174 (maxAgeCalculator == null ? DEFAULT_MAX_AGE_CALCULATOR 175 : maxAgeCalculator).maxAge(request, mediaType)); 176 177 // Check if sending is really required. 178 Optional<Instant> modifiedSince = request 179 .findValue(HttpField.IF_MODIFIED_SINCE, Converters.DATE_TIME); 180 if (modifiedSince.isPresent() && info.getLastModifiedAt() != null 181 && !info.getLastModifiedAt().isAfter(modifiedSince.get())) { 182 response.setStatus(HttpStatus.NOT_MODIFIED); 183 channel.respond(new Response(response)); 184 } else { 185 response.setContentType(mediaType); 186 response.setStatus(HttpStatus.OK); 187 channel.respond(new Response(response)); 188 // Start sending content (Output events as resonses) 189 (new InputStreamPipeline(resIn, channel).suppressClosed()).run(); 190 } 191 return true; 192 } 193 194 /** 195 * Shorthand for invoking 196 * {@link #sendStaticContent(HttpRequest, IOSubchannel, Function, MaxAgeCalculator)} 197 * with the {@link HttpRequest} from the event. Also sets the result 198 * of the event to `true` and invokes {@link Event#stop()} 199 * if a response was sent. 200 * 201 * @param event the event 202 * @param channel the channel 203 * @param resolver the resolver 204 * @param maxAgeCalculator the max age calculator, if `null` 205 * the default calculator is used. 206 * @return `true` if a response was sent 207 * @throws ParseException the parse exception 208 */ 209 public static boolean sendStaticContent( 210 Request.In event, IOSubchannel channel, 211 Function<String, URL> resolver, MaxAgeCalculator maxAgeCalculator) { 212 if (sendStaticContent( 213 event.httpRequest(), channel, resolver, maxAgeCalculator)) { 214 event.setResult(true); 215 event.stop(); 216 return true; 217 } 218 return false; 219 } 220 221 /** 222 * Combines the known information about a resource. 223 */ 224 public static class ResourceInfo { 225 public Boolean isDirectory; 226 public Instant lastModifiedAt; 227 228 /** 229 * @param isDirectory 230 * @param lastModifiedAt 231 */ 232 public ResourceInfo(Boolean isDirectory, Instant lastModifiedAt) { 233 this.isDirectory = isDirectory; 234 this.lastModifiedAt = lastModifiedAt; 235 } 236 237 /** 238 * @return the isDirectory 239 */ 240 public Boolean isDirectory() { 241 return isDirectory; 242 } 243 244 /** 245 * @return the lastModifiedAt 246 */ 247 public Instant getLastModifiedAt() { 248 return lastModifiedAt; 249 } 250 } 251 252 /** 253 * Attempts to lookup the additional resource information for the 254 * given URL. 255 * 256 * If a {@link URL} references a file, it is easy to find out if 257 * the resource referenced is a directory and to get its last 258 * modification time. Getting the same information 259 * for a {@link URL} that references resources in a jar is a bit 260 * more difficult. This method handles both cases. 261 * 262 * @param resource the resource URL 263 * @return the resource info 264 */ 265 @SuppressWarnings("PMD.EmptyCatchBlock") 266 public static ResourceInfo resourceInfo(URL resource) { 267 try { 268 Path path = Paths.get(resource.toURI()); 269 return new ResourceInfo(Files.isDirectory(path), 270 Files.getLastModifiedTime(path).toInstant() 271 .with(ChronoField.NANO_OF_SECOND, 0)); 272 } catch (FileSystemNotFoundException | IOException 273 | URISyntaxException e) { 274 // Fall through 275 } 276 if ("jar".equals(resource.getProtocol())) { 277 try { 278 JarURLConnection conn 279 = (JarURLConnection) resource.openConnection(); 280 JarEntry entry = conn.getJarEntry(); 281 return new ResourceInfo(entry.isDirectory(), 282 entry.getLastModifiedTime().toInstant() 283 .with(ChronoField.NANO_OF_SECOND, 0)); 284 } catch (IOException e) { 285 // Fall through 286 } 287 } 288 try { 289 URLConnection conn = resource.openConnection(); 290 long lastModified = conn.getLastModified(); 291 if (lastModified != 0) { 292 return new ResourceInfo(null, Instant.ofEpochMilli( 293 lastModified).with(ChronoField.NANO_OF_SECOND, 0)); 294 } 295 } catch (IOException e) { 296 // Fall through 297 } 298 return new ResourceInfo(null, null); 299 } 300 301 /** 302 * Create a {@link URI} from a path. This is similar to calling 303 * `new URI(null, null, path, null)` with the {@link URISyntaxException} 304 * converted to a {@link IllegalArgumentException}. 305 * 306 * @param path the path 307 * @return the uri 308 * @throws IllegalArgumentException if the string violates 309 * RFC 2396 310 */ 311 @SuppressWarnings("PMD.AvoidUncheckedExceptionsInSignatures") 312 public static URI uriFromPath(String path) throws IllegalArgumentException { 313 try { 314 return new URI(null, null, path, null); 315 } catch (URISyntaxException e) { 316 throw new IllegalArgumentException(e); 317 } 318 } 319 320 /** 321 * Create a {@link URI} from a {@link URL}. This is similar to calling 322 * `url.toURI()` with the {@link URISyntaxException} 323 * converted to a {@link IllegalArgumentException}. 324 * 325 * @param url the url 326 * @return the uri 327 * @throws IllegalArgumentException if the url violates RFC 2396 328 */ 329 @SuppressWarnings("PMD.AvoidUncheckedExceptionsInSignatures") 330 public static URI uriFromUrl(URL url) throws IllegalArgumentException { 331 try { 332 return url.toURI(); 333 } catch (URISyntaxException e) { 334 throw new IllegalArgumentException(e); 335 } 336 } 337 338 /** 339 * Sets the cache control header in the given response. 340 * 341 * @param response the response 342 * @param maxAge the max age 343 * @return the value set 344 */ 345 public static long setMaxAge(HttpResponse response, int maxAge) { 346 CacheControlDirectives directives = new CacheControlDirectives(); 347 directives.add(new Directive("max-age", maxAge)); 348 response.setField(HttpField.CACHE_CONTROL, directives); 349 return maxAge; 350 } 351 352 /** 353 * Describes a calculator for the max-age property. 354 */ 355 @FunctionalInterface 356 public interface MaxAgeCalculator { 357 358 /** 359 * Calculate a max age value for a response using the given 360 * request and the media type of the repsonse. 361 * 362 * @param request the request, usually only the URI is 363 * considered for the calculation 364 * @param mediaType the media type of the response 365 * @return the max age value to be used in the response 366 */ 367 int maxAge(HttpRequest request, MediaType mediaType); 368 } 369 370 /** 371 * DefaultMaxAgeCalculator provides an implementation that 372 * tries to guess a good max age value by looking at the 373 * path of the requested resource. If the path contains 374 * the pattern "dash, followed by a number, followed by 375 * a dot and a number" it is assumed that the resource 376 * is versioned, i.e. its path changes if the resource 377 * changes. In this case a max age of one year is returned. 378 * In all other cases, a max age value of 60 (one minute) 379 * is returned. 380 */ 381 public static class DefaultMaxAgeCalculator implements MaxAgeCalculator { 382 383 public static final Pattern VERSION_PATTERN 384 = Pattern.compile("-[0-9]+\\.[0-9]+"); 385 386 @Override 387 public int maxAge(HttpRequest request, MediaType mediaType) { 388 if (VERSION_PATTERN.matcher( 389 request.requestUri().getPath()).find()) { 390 return 365 * 24 * 3600; 391 } 392 return 60; 393 } 394 395 } 396}