/** * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package org.jooby.handlers; import static java.util.Objects.requireNonNull; import java.net.MalformedURLException; import java.net.URL; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.text.MessageFormat; import java.time.Duration; import java.util.Date; import java.util.Map; import org.jooby.Asset; import org.jooby.Jooby; import org.jooby.MediaType; import org.jooby.Request; import org.jooby.Response; import org.jooby.Route; import org.jooby.Status; import org.jooby.internal.URLAsset; import com.google.common.base.Strings; import com.typesafe.config.ConfigFactory; import com.typesafe.config.ConfigValueFactory; import javaslang.Function1; import javaslang.Function2; import javaslang.control.Try; /** * Serve static resources, via {@link Jooby#assets(String)} or variants. * * <h1>e-tag support</h1> * <p> * It generates <code>ETag</code> headers using {@link Asset#etag()}. It handles * <code>If-None-Match</code> header automatically. * </p> * <p> * <code>ETag</code> handling is enabled by default. If you want to disabled etag support * {@link #etag(boolean)}. * </p> * * <h1>modified since support</h1> * <p> * It generates <code>Last-Modified</code> header using {@link Asset#lastModified()}. It handles * <code>If-Modified-Since</code> header automatically. * </p> * * <h1>CDN support</h1> * <p> * Asset can be serve from a content delivery network (a.k.a cdn). All you have to do is to set the * <code>assets.cdn</code> property. * </p> * * <pre> * assets.cdn = "http://d7471vfo50fqt.cloudfront.net" * </pre> * * <p> * Resolved assets are redirected to the cdn. * </p> * * @author edgar * @since 0.1.0 */ public class AssetHandler implements Route.Handler { private interface Loader { URL getResource(String name); } private static final Function1<String, String> prefix = prefix().memoized(); private Function2<Request, String, String> fn; private Loader loader; private String cdn; private boolean etag = true; private long maxAge = -1; private boolean lastModified = true; /** * <p> * Creates a new {@link AssetHandler}. The handler accepts a location pattern, that serve for * locating the static resource. * </p> * * Given <code>assets("/assets/**", "/")</code> with: * * <pre> * GET /assets/js/index.js it translates the path to: /assets/js/index.js * </pre> * * Given <code>assets("/js/**", "/assets")</code> with: * * <pre> * GET /js/index.js it translate the path to: /assets/js/index.js * </pre> * * Given <code>assets("/webjars/**", "/META-INF/resources/webjars/{0}")</code> with: * * <pre> * GET /webjars/jquery/2.1.3/jquery.js it translate the path to: /META-INF/resources/webjars/jquery/2.1.3/jquery.js * </pre> * * @param pattern Pattern to locate static resources. * @param loader The one who load the static resources. */ public AssetHandler(final String pattern, final ClassLoader loader) { init(Route.normalize(pattern), Paths.get("public"), loader); } /** * <p> * Creates a new {@link AssetHandler}. The handler accepts a location pattern, that serve for * locating the static resource. * </p> * * Given <code>assets("/assets/**", "/")</code> with: * * <pre> * GET /assets/js/index.js it translates the path to: /assets/js/index.js * </pre> * * Given <code>assets("/js/**", "/assets")</code> with: * * <pre> * GET /js/index.js it translate the path to: /assets/js/index.js * </pre> * * Given <code>assets("/webjars/**", "/META-INF/resources/webjars/{0}")</code> with: * * <pre> * GET /webjars/jquery/2.1.3/jquery.js it translate the path to: /META-INF/resources/webjars/jquery/2.1.3/jquery.js * </pre> * * @param basedir Base directory. */ public AssetHandler(final Path basedir) { init("/{0}", basedir, getClass().getClassLoader()); } /** * <p> * Creates a new {@link AssetHandler}. The location pattern can be one of. * </p> * * Given <code>/</code> like in <code>assets("/assets/**", "/")</code> with: * * <pre> * GET /assets/js/index.js it translates the path to: /assets/js/index.js * </pre> * * Given <code>/assets</code> like in <code>assets("/js/**", "/assets")</code> with: * * <pre> * GET /js/index.js it translate the path to: /assets/js/index.js * </pre> * * Given <code>/META-INF/resources/webjars/{0}</code> like in * <code>assets("/webjars/**", "/META-INF/resources/webjars/{0}")</code> with: * * <pre> * GET /webjars/jquery/2.1.3/jquery.js it translate the path to: /META-INF/resources/webjars/jquery/2.1.3/jquery.js * </pre> * * @param pattern Pattern to locate static resources. */ public AssetHandler(final String pattern) { init(Route.normalize(pattern), Paths.get("public"), getClass().getClassLoader()); } /** * @param etag Turn on/off etag support. * @return This handler. */ public AssetHandler etag(final boolean etag) { this.etag = etag; return this; } /** * @param enabled Turn on/off last modified support. * @return This handler. */ public AssetHandler lastModified(final boolean enabled) { this.lastModified = enabled; return this; } /** * @param cdn If set, every resolved asset will be serve from it. * @return This handler. */ public AssetHandler cdn(final String cdn) { this.cdn = Strings.emptyToNull(cdn); return this; } /** * @param maxAge Set the cache header max-age value. * @return This handler. */ public AssetHandler maxAge(final Duration maxAge) { return maxAge(maxAge.getSeconds()); } /** * @param maxAge Set the cache header max-age value in seconds. * @return This handler. */ public AssetHandler maxAge(final long maxAge) { this.maxAge = maxAge; return this; } /** * Parse value as {@link Duration}. If the value is already a number then it uses as seconds. * Otherwise, it parse expressions like: 8m, 1h, 365d, etc... * * @param maxAge Set the cache header max-age value in seconds. * @return This handler. */ public AssetHandler maxAge(final String maxAge) { Try.of(() -> Long.parseLong(maxAge)) .recover(x -> ConfigFactory.empty() .withValue("v", ConfigValueFactory.fromAnyRef(maxAge)) .getDuration("v") .getSeconds()) .onSuccess(this::maxAge); return this; } @Override public void handle(final Request req, final Response rsp) throws Throwable { String path = req.path(); URL resource = resolve(req, path); if (resource != null) { String localpath = resource.getPath(); int jarEntry = localpath.indexOf("!/"); if (jarEntry > 0) { localpath = localpath.substring(jarEntry + 2); } URLAsset asset = new URLAsset(resource, path, MediaType.byPath(localpath).orElse(MediaType.octetstream)); if (asset.exists()) { // cdn? if (cdn != null) { String absUrl = cdn + path; rsp.redirect(absUrl); rsp.end(); } else { doHandle(req, rsp, asset); } } } } private void doHandle(final Request req, final Response rsp, final Asset asset) throws Throwable { // handle etag if (this.etag) { String etag = asset.etag(); boolean ifnm = req.header("If-None-Match").toOptional() .map(etag::equals) .orElse(false); if (ifnm) { rsp.header("ETag", etag).status(Status.NOT_MODIFIED).end(); return; } rsp.header("ETag", etag); } // Handle if modified since if (this.lastModified) { long lastModified = asset.lastModified(); if (lastModified > 0) { boolean ifm = req.header("If-Modified-Since").toOptional(Long.class) .map(ifModified -> lastModified / 1000 <= ifModified / 1000) .orElse(false); if (ifm) { rsp.status(Status.NOT_MODIFIED).end(); return; } rsp.header("Last-Modified", new Date(lastModified)); } } // cache max-age if (maxAge > 0) { rsp.header("Cache-Control", "max-age=" + maxAge); } send(req, rsp, asset); } /** * Send an asset to the client. * * @param req Request. * @param rsp Response. * @param asset Resolve asset. * @throws Exception If send fails. */ protected void send(final Request req, final Response rsp, final Asset asset) throws Throwable { rsp.send(asset); } private URL resolve(final Request req, final String path) throws Exception { String target = fn.apply(req, path); return resolve(target); } /** * Resolve a path as a {@link URL}. * * @param path Path of resource to resolve. * @return A URL or <code>null</code> for unresolved resource. * @throws Exception If something goes wrong. */ protected URL resolve(final String path) throws Exception { return loader.getResource(path); } private void init(final String pattern, final Path basedir, final ClassLoader loader) { requireNonNull(loader, "Resource loader is required."); this.fn = pattern.equals("/") ? (req, p) -> prefix.apply(p) : (req, p) -> MessageFormat.format(prefix.apply(pattern), vars(req)); this.loader = loader(basedir, loader); } private static Object[] vars(final Request req) { Map<Object, String> vars = req.route().vars(); return vars.values().toArray(new Object[vars.size()]); } private static Loader loader(final Path basedir, final ClassLoader classloader) { if (Files.exists(basedir)) { return name -> { Path path = basedir.resolve(name).normalize(); if (Files.exists(path) && path.startsWith(basedir)) { try { return path.toUri().toURL(); } catch (MalformedURLException x) { // shh } } return classloader.getResource(name); }; } return classloader::getResource; } private static Function1<String, String> prefix() { return p -> p.substring(1); } }