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.rest.server.staticfile; 018 019import static java.nio.charset.StandardCharsets.*; 020 021import java.net.*; 022import java.util.*; 023 024import org.apache.juneau.http.*; 025import org.apache.juneau.http.response.*; 026import org.apache.juneau.rest.server.*; 027import org.apache.juneau.rest.server.util.*; 028 029/** 030 * Mixin that serves WebJar files from the classpath at {@code /webjars/*}. 031 * 032 * <p> 033 * Compose it into the resource that renders console pages, next to {@code DataTablesMixin}: 034 * {@code @Rest(mixins={FreemarkerMixin.class, ViewsMixin.class, DataTablesMixin.class, WebJarsMixin.class})}. 035 * The console toolkit packs ({@code jquery}, {@code datatables}, {@code datatables-buttons}) resolve their URLs 036 * through {@link WebJarResolver} to this mount; a host that doesn't mount it renders the page, but those URLs 404. 037 * 038 * <p> 039 * Only files under {@code META-INF/resources/webjars/} are served, and only with a known extension ({@code js}, 040 * {@code css}, {@code map}, {@code woff2}, {@code woff}, {@code ttf}, {@code svg}, {@code png}). The remainder path 041 * is checked as delivered and again after one round of percent-decoding, and is never normalized: a {@code .} or 042 * {@code ..} segment, an empty segment, a leading {@code /}, a backslash or an encoded separator returns 404, as does 043 * a directory or a missing file. Responses carry {@code Cache-Control: public, max-age=31536000, immutable}; the 044 * URLs are cache-busted by {@link WebJarResolver}. 045 * 046 * <h5 class='section'>Mixin-only deployment:</h5> 047 * <p> 048 * The mount path is pinned at the op level by {@code @RestGet(path="/webjars/*")}; a class-level 049 * {@code @Rest(paths=...)} would be ignored under the mixin pattern. 050 * 051 * @since 10.0.0 052 */ 053@Rest 054public class WebJarsMixin { 055 056 /** The mount path, relative to the host. */ 057 public static final String PATH = "/webjars"; 058 059 /** {@code Cache-Control} for every served file. */ 060 static final String CACHE_CONTROL = "public, max-age=31536000, immutable"; 061 062 private static final Map<String,String> CONTENT_TYPES = Map.of( 063 "js", "text/javascript;charset=utf-8", 064 "css", "text/css;charset=utf-8", 065 "map", "application/json", 066 "woff2", "font/woff2", 067 "woff", "font/woff", 068 "ttf", "font/ttf", 069 "svg", "image/svg+xml", 070 "png", "image/png" 071 ); 072 073 private static final ClasspathAssetCache ASSETS = new ClasspathAssetCache(WebJarsMixin.class); 074 075 /** 076 * [GET /webjars/*] — serve one WebJar file. 077 * 078 * @param req The current request. 079 * @return The file. 080 * @throws NotFound If the path fails a check, has an unknown extension, or names no file. 081 */ 082 @RestGet(path=PATH + "/*", summary="WebJar files", swagger=@OpSwagger(ignore=true)) 083 public HttpResource getWebJarFile(RestRequest req) { 084 var path = checkedPath(req.getPathParams().getRemainderUndecoded().asString().orElse(null)); 085 var type = contentType(path); 086 if (type == null || WebJarsMixin.class.getResource("/" + WebJarResolver.WEBJARS_ROOT + path) == null) 087 throw new NotFound(); 088 return ASSETS.serve("/" + WebJarResolver.WEBJARS_ROOT + path, type, CACHE_CONTROL); 089 } 090 091 /** 092 * [HEAD /webjars/*] — the {@code GET} headers without the body (the response processor drops the body). 093 * 094 * @param req The current request. 095 * @return The file. 096 * @throws NotFound If the path fails a check, has an unknown extension, or names no file. 097 */ 098 @RestOp(method="HEAD", path=PATH + "/*", summary="WebJar files (HEAD)", swagger=@OpSwagger(ignore=true)) 099 public HttpResource headWebJarFile(RestRequest req) { 100 return getWebJarFile(req); 101 } 102 103 /** 104 * Checks a raw (undecoded) remainder path, then its percent-decoded form. 105 * 106 * @param raw The raw remainder path, or <jk>null</jk>. 107 * @return The decoded path, or <jk>null</jk> if either form fails a check. 108 */ 109 static String checkedPath(String raw) { 110 if (raw == null || ! safe(raw)) 111 return null; 112 String decoded; 113 try { 114 decoded = URLDecoder.decode(raw.replace("+", "%2B"), UTF_8); 115 } catch (IllegalArgumentException e) { 116 return null; 117 } 118 return safe(decoded) ? decoded : null; 119 } 120 121 /** 122 * Returns the content type for a path's extension, compared case-insensitively. 123 * 124 * @param path The path. Can be <jk>null</jk>. 125 * @return The content type, or <jk>null</jk> if the path is <jk>null</jk> or the extension isn't served. 126 */ 127 static String contentType(String path) { 128 if (path == null) 129 return null; 130 var name = path.substring(path.lastIndexOf('/') + 1); 131 var dot = name.lastIndexOf('.'); 132 return dot < 0 ? null : CONTENT_TYPES.get(name.substring(dot + 1).toLowerCase(Locale.ROOT)); 133 } 134 135 private static boolean safe(String p) { 136 if (p.isEmpty() || p.startsWith("/") || p.indexOf('\\') >= 0) 137 return false; 138 var lower = p.toLowerCase(Locale.ROOT); 139 if (lower.contains("%2f") || lower.contains("%5c")) 140 return false; 141 for (var seg : p.split("/", -1)) 142 if (seg.isEmpty() || ".".equals(seg) || "..".equals(seg)) 143 return false; 144 return true; 145 } 146}