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}