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 * <script src="${assetUrl('/js/app.js')}"></script> <#-- /js/app.js?v=1a2b3c4d --> 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}