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.terminal.region;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020
021import java.io.*;
022import java.util.*;
023
024import org.apache.juneau.http.*;
025import org.apache.juneau.http.response.BadRequest;
026import org.apache.juneau.http.response.NotFound;
027import org.apache.juneau.rest.server.*;
028import org.apache.juneau.terminal.report.*;
029import org.apache.juneau.rest.server.util.*;
030
031/**
032 * Static helpers that implement the terminal endpoints and serve the terminal runtime.
033 *
034 * <p>
035 * {@link TerminalMixin} delegates to these; an application that wants its own paths writes {@code @RestGet} methods
036 * that call them and gets the same validation, cap and headers.
037 *
038 * <h5 class='section'>Bytes headers:</h5>
039 * <ul>
040 *    <li>{@code Term-Next}: the offset to ask for next.
041 *    <li>{@code Term-End}: the source's current end.
042 *    <li>{@code Term-Done}: {@code true} once the producer is finished and the reader is at the end.
043 *    <li>{@code Term-Cols}, {@code Term-Rows}: the producer's terminal size.
044 *    <li>{@code Term-Truncated}: {@code true} when the bytes start later than {@code from}.
045 *    <li>{@code Term-Error}: only ever {@code gone}, when the log disappeared.
046 * </ul>
047 */
048public final class TerminalEndpoints {
049
050   /** Classpath location of the terminal runtime. */
051   static final String JS_RESOURCE = "/org/apache/juneau/terminal/juneau-terminal.js";
052
053   /** Classpath location of the terminal stylesheet. */
054   static final String CSS_RESOURCE = "/org/apache/juneau/terminal/juneau-terminal.css";
055
056   private static final String JS_CONTENT_TYPE = "text/javascript;charset=utf-8";
057   private static final String CSS_CONTENT_TYPE = "text/css;charset=utf-8";
058   private static final String CACHE_CONTROL = "max-age=86400, public";
059   private static final String OCTETS = "application/octet-stream";
060
061   /** Anchored on this class so the cache-buster carries this module's version. */
062   private static final ClasspathAssetCache ASSET_CACHE = new ClasspathAssetCache(TerminalEndpoints.class);
063
064   private TerminalEndpoints() {}
065
066   /**
067    * Checks a terminal id against {@code ^[A-Za-z0-9_-]{1,128}$}.
068    *
069    * @param id The id from the URL path.
070    * @return The same id.
071    * @throws BadRequest If the id does not match.
072    */
073   public static String checkId(String id) {
074      if (! TerminalViewChecks.isRunId(id))
075         throw new BadRequest("id must match [A-Za-z0-9_-]{1,128}");
076      return id;
077   }
078
079   /**
080    * Serves {@code ?from=<offset>&max=<n>} as raw bytes with the {@code Term-*} headers.
081    *
082    * <p>
083    * Answers {@code 404} for an empty source, {@code 400} for a malformed {@code from} or {@code max}, {@code 416}
084    * with {@code Term-End} when {@code from} is past the end, and otherwise {@code 200}.  A {@code from} equal to
085    * the end is a {@code 200} with no bytes.
086    *
087    * <p>
088    * A gone source answers {@code 200} with no bytes, {@code Term-Done: true} and {@code Term-Error: gone}, whatever
089    * {@code from} is.  Its {@code Term-Next} and {@code Term-End} are {@code from} itself, so a client that keeps
090    * polling never rewinds; the chunk's own offsets are not consulted.
091    *
092    * @param source The resolved source; empty answers {@code 404}.
093    * @param req The request.
094    * @param res The response.
095    * @throws IOException If the source could not be read or the response written.
096    */
097   public static void bytes(Optional<TerminalSource> source, RestRequest req, RestResponse res) throws IOException {
098      var src = source.orElseThrow(NotFound::new);
099      var from = number(req, "from", 0, TerminalEvent.MAX_SAFE_INT);
100      var max = (int)number(req, "max", TerminalSource.MAX_READ_BYTES, Integer.MAX_VALUE);
101      var c = src.read(from, Math.min(max, TerminalSource.MAX_READ_BYTES));
102      res.setHeader("Cache-Control", "no-store");
103      res.setHeader("X-Content-Type-Options", "nosniff");
104      res.setHeader("Term-Cols", String.valueOf(c.cols()));
105      res.setHeader("Term-Rows", String.valueOf(c.rows()));
106      if (c.gone()) {
107         res.setHeader("Term-Next", String.valueOf(from));
108         res.setHeader("Term-End", String.valueOf(from));
109         res.setHeader("Term-Done", "true");
110         res.setHeader("Term-Error", "gone");
111         write(res, new byte[0]);
112         return;
113      }
114      res.setHeader("Term-End", String.valueOf(c.end()));
115      if (from > c.end()) {
116         res.setStatus(416);
117         return;
118      }
119      res.setHeader("Term-Next", String.valueOf(c.next()));
120      res.setHeader("Term-Done", String.valueOf(c.done()));
121      res.setHeader("Term-Truncated", String.valueOf(c.truncated()));
122      write(res, c.bytes());
123   }
124
125   /**
126    * Serves every byte still held as an attachment named {@code <id>.log}.
127    *
128    * <p>
129    * A gone source still answers {@code 200} with an empty body, and carries {@code Term-Error: gone} as {@code bytes}
130    * does.
131    *
132    * <p>
133    * The download is not capped and takes no range: it streams the whole log, {@link TerminalSource#MAX_READ_BYTES}
134    * at a time, up to the end at the time of the request.  The id is checked here as well as by
135    * {@link TerminalMixin}, because it becomes the file name.
136    *
137    * @param source The resolved source; empty answers {@code 404}.
138    * @param id The terminal id (also the download file name).
139    * @param res The response.
140    * @throws IOException If the source could not be read or the response written.
141    */
142   public static void raw(Optional<TerminalSource> source, String id, RestResponse res) throws IOException {
143      checkId(id);
144      var src = source.orElseThrow(NotFound::new);
145      res.setHeader("Cache-Control", "no-store");
146      res.setHeader("X-Content-Type-Options", "nosniff");
147      if (src.read(0, 0).gone())
148         res.setHeader("Term-Error", "gone");
149      res.setHeader("Content-Disposition", "attachment; filename=\"" + id + ".log\"");
150      res.setContentType(OCTETS);
151      var out = res.getOutputStream();
152      src.copyTo(out);
153      out.flush();
154   }
155
156   /**
157    * The terminal runtime.
158    *
159    * @return {@code juneau-terminal.js} as a JavaScript {@link HttpResource}.
160    */
161   public static HttpResource script() {
162      return ASSET_CACHE.serve(JS_RESOURCE, JS_CONTENT_TYPE, CACHE_CONTROL);
163   }
164
165   /**
166    * The terminal stylesheet.
167    *
168    * @return {@code juneau-terminal.css} as a CSS {@link HttpResource}.
169    */
170   public static HttpResource stylesheet() {
171      return ASSET_CACHE.serve(CSS_RESOURCE, CSS_CONTENT_TYPE, CACHE_CONTROL);
172   }
173
174   /**
175    * Returns the absolute URL of a terminal asset, resolved against the request, with a
176    * {@code ?v=<buildVersion>-<hash8>} cache-buster.
177    *
178    * @param req The current request.
179    * @param path {@link TerminalMixin#JS_PATH} or {@link TerminalMixin#CSS_PATH}.
180    * @return The asset URL.
181    * @throws IllegalArgumentException If this module does not ship the given path.
182    */
183   public static String terminalAssetUrl(RestRequest req, String path) {
184      var resource = resourceFor(path);
185      return req.getUriResolver().resolve("servlet:" + path) + ASSET_CACHE.cacheBuster(resource);
186   }
187
188   private static String resourceFor(String path) {
189      if (TerminalMixin.JS_PATH.equals(path))
190         return JS_RESOURCE;
191      if (TerminalMixin.CSS_PATH.equals(path))
192         return CSS_RESOURCE;
193      throw iaex("TerminalEndpoints does not serve '%s'.", path);
194   }
195
196   private static long number(RestRequest req, String name, long dflt, long limit) {
197      var s = req.getQueryParam(name).asString().orElse(null);
198      if (s == null)
199         return dflt;
200      if (! s.matches("0|[1-9]\\d{0,15}"))
201         throw new BadRequest(name + " must be a non-negative integer");
202      var n = Long.parseLong(s);
203      if (n > limit)
204         throw new BadRequest(name + " must be at most " + limit);
205      return n;
206   }
207
208   private static void write(RestResponse res, byte[] bytes) throws IOException {
209      res.setContentType(OCTETS);
210      res.setContentLength(bytes.length);
211      var out = res.getOutputStream();
212      out.write(bytes);
213      out.flush();
214   }
215}