001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.console.freemarker;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020
021import java.io.*;
022import java.util.*;
023import java.util.concurrent.*;
024import java.util.logging.*;
025
026import org.apache.juneau.commons.utils.*;
027
028import freemarker.template.*;
029
030/**
031 * The {@code assetUrl(path)} FreeMarker method: appends {@code ?v=<crc32>} of an adopter's own bundled static asset
032 * to its URL path, so a browser cannot keep a stale copy after a deploy.
033 *
034 * <p>
035 * Registered by {@link ConsoleFreemarkerMixin} when the builder is given
036 * {@link ConsoleFreemarkerMixin.Builder#adopterAssets(ClassLoader, String)}. The token is the same
037 * {@link ChecksumUtils#hash8(byte[]) CRC32 hash} Juneau uses for its own assets, computed once per path and cached.
038 * A path that already has a query string, is not context-root-absolute (e.g. an external URL), or names no bundled
039 * resource is returned unchanged; the last case also logs a warning once per path when dev mode is on.
040 *
041 * <h5 class='section'>Example:</h5>
042 * <p class='bcode'>
043 *    &lt;script src="${assetUrl('/js/app.js')}"&gt;&lt;/script&gt;   &lt;#-- /js/app.js?v=1a2b3c4d --&gt;
044 * </p>
045 *
046 * @since 10.0.0
047 */
048public final class AssetUrlMethodModel implements TemplateMethodModelEx {
049
050   /** The shared-variable name this method registers under. */
051   public static final String NAME = "assetUrl";
052
053   private static final Logger LOG = Logger.getLogger(AssetUrlMethodModel.class.getName());
054
055   private final ClassLoader loader;
056   private final String root;
057   private final boolean devMode;
058   private final Map<String,String> tokens = new ConcurrentHashMap<>();
059
060   AssetUrlMethodModel(ClassLoader loader, String resourceRoot, boolean devMode) {
061      this.loader = loader;
062      var r = resourceRoot.startsWith("/") ? resourceRoot.substring(1) : resourceRoot;
063      this.root = r.isEmpty() || r.endsWith("/") ? r : r + "/";
064      this.devMode = devMode;
065   }
066
067   @Override
068   public Object exec(@SuppressWarnings("rawtypes") List args) throws TemplateModelException {
069      if (args.size() != 1)
070         throw FtlAttrLists.reject(f("%s(path) takes exactly one argument; got '%s'.", NAME, args.size()));
071      return versioned(String.valueOf(args.get(0)));
072   }
073
074   /**
075    * Returns {@code path} with {@code ?v=<crc32>} appended, or unchanged when it cannot be versioned.
076    *
077    * @param path A context-root-absolute path such as {@code /js/app.js}.
078    * @return The versioned URL.
079    */
080   String versioned(String path) {
081      if (path.contains("?") || ! path.startsWith("/"))
082         return path;
083      var token = tokens.computeIfAbsent(path, this::token);
084      return token.isEmpty() ? path : path + "?v=" + token;
085   }
086
087   private String token(String path) {
088      var resource = root + path.substring(1);
089      try (var in = loader.getResourceAsStream(resource)) {
090         if (nn(in))
091            return ChecksumUtils.hash8(in.readAllBytes());
092      } catch (IOException e) {
093         LOG.log(Level.WARNING, e, () -> "assetUrl: cannot read bundled resource '" + resource + "'.");
094         return "";
095      }
096      if (devMode)
097         LOG.log(Level.WARNING, () -> "assetUrl: no bundled resource '" + resource + "' for '" + path + "'; serving it unversioned.");
098      return "";
099   }
100}