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/*] &mdash; 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/*] &mdash; 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}