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.microservice.examples; 018 019import static org.apache.juneau.commons.utils.Shorts.*; 020import static org.apache.juneau.commons.utils.StringUtils.*; 021 022import java.io.*; 023import java.nio.charset.*; 024import java.nio.file.*; 025import java.time.*; 026import java.util.*; 027import java.util.stream.*; 028 029import org.apache.juneau.bean.*; 030import org.apache.juneau.commons.*; 031import org.apache.juneau.commons.bean.*; 032import org.apache.juneau.commons.time.*; 033import org.apache.juneau.commons.utils.*; 034import org.apache.juneau.config.*; 035import org.apache.juneau.http.*; 036import org.apache.juneau.http.Path; 037import org.apache.juneau.http.classic.response.*; 038import org.apache.juneau.marshall.html.*; 039import org.apache.juneau.microservice.logging.*; 040import org.apache.juneau.rest.server.*; 041import org.apache.juneau.rest.server.beans.*; 042import org.apache.juneau.rest.server.converter.*; 043import org.apache.juneau.rest.server.servlet.*; 044 045/** 046 * REST resource for viewing and accessing log files. 047 * 048 * <h5 class='section'>Routes</h5> 049 * <table class='styled'> 050 * <tr><th>Request</th><th>Does</th></tr> 051 * <tr><td>{@code GET /logs/{path}}</td><td>Information on a file, or a directory listing</td></tr> 052 * <tr><td>{@code GET /logs/view/{path}}</td><td>The file's contents as text, or as HTML with {@code ?highlight=true}</td></tr> 053 * <tr><td>{@code GET /logs/parse/{path}}</td><td>The file's parsed log entries</td></tr> 054 * <tr><td>{@code GET /logs/download/{path}}</td><td>The file's contents as {@code application/octet-stream}</td></tr> 055 * <tr><td>{@code DELETE /logs/{path}}</td><td>Deletes the file or directory, when {@code Logging/allowDeletes} is on</td></tr> 056 * </table> 057 * 058 * <p> 059 * The {@code view}, {@code parse} and {@code download} prefixes are reserved: they always name an action, never a 060 * directory. A log directory with one of those names cannot be listed through {@code GET /logs/view} and so on, but 061 * its files are still reachable through the action routes, for example {@code GET /logs/view/view/app.log}. The 062 * listing's link to such a directory routes to the action and is answered with {@code 404}, as is any action on a 063 * directory. {@code DELETE} reserves no prefix: {@code DELETE /logs/view/app.log} deletes {@code app.log} in the 064 * {@code view} directory. 065 * 066 * <p> 067 * Every read is a plain {@code GET}, and the {@code method} query parameter is not accepted, so no {@code GET} link 068 * can delete anything. A delete must be a real {@code DELETE}, sent by a REST client or from the command line; the 069 * HTML listing has no delete link, because a browser link or form cannot carry the header-borne token a CSRF 070 * boundary requires. A {@code DELETE} cannot be sent cross-site without a CORS preflight, so a boundary in front of 071 * this resource (see {@link AdminBoundary}) admits it without a token: 072 * <p class='bcode'> 073 * curl -X DELETE http://localhost:10000/logs/old.log 074 * </p> 075 * 076 * <p> 077 * This resource has no authentication of its own. Do not expose it on an untrusted network. 078 * 079 * <h5 class='section'>See Also:</h5><ul> 080 * <li class='jc'>{@link AdminBoundary} 081 * </ul> 082 */ 083@Rest( 084 path="/logs", 085 title="Log files", 086 description="Log files from this service" 087) 088@HtmlConfig(uriAnchorText="PROPERTY_NAME") 089@SuppressWarnings({ 090 "resource" // Streams and parsers returned to servlet container or caller; lifecycle managed externally 091}) 092public class LogsResource extends BasicRestServlet { 093 094 /** File action link. */ 095 @Response(schema = @Schema(description = "File action")) 096 public static class Action extends LinkString { 097 098 /** 099 * Constructor. 100 * 101 * @param name Action name. 102 * @param uri Action URI. 103 * @param uriArgs URI format arguments. 104 */ 105 public Action(String name, String uri, Object...uriArgs) { 106 super(name, uri, uriArgs); 107 } 108 109 /** {@inheritDoc} */ 110 @Override /* Overridden from LinkString */ 111 public Action setName(String value) { 112 super.setName(value); 113 return this; 114 } 115 116 /** {@inheritDoc} */ 117 @Override /* Overridden from LinkString */ 118 public Action setUri(java.net.URI value) { 119 super.setUri(value); 120 return this; 121 } 122 123 /** {@inheritDoc} */ 124 @Override /* Overridden from LinkString */ 125 public Action setUri(String value) { 126 super.setUri(value); 127 return this; 128 } 129 130 /** {@inheritDoc} */ 131 @Override /* Overridden from LinkString */ 132 public Action setUri(String value, Object...args) { 133 super.setUri(value, args); 134 return this; 135 } 136 } 137 138 /** File or directory details for REST response. */ 139 @Response(schema = @Schema(description = "File or directory details")) 140 @BeanType(properties = "type,name,size,lastModified,actions,files") 141 public static class FileResource { 142 static final FileFilter FILE_FILTER = f2 -> f2.isDirectory() || f2.getName().endsWith(".log"); 143 static final Comparator<FileResource> FILE_COMPARATOR = (o1, o2) -> { 144 int c = o1.getType().compareTo(o2.getType()); 145 return c != 0 ? c : o1.getName().compareTo(o2.getName()); 146 }; 147 private final File f; 148 private final String path; 149 150 private final String uri; 151 152 private final boolean includeChildren; 153 154 /** 155 * Constructor. 156 * 157 * @param f The file or directory. 158 * @param path The path. 159 * @param includeChildren Whether to include child files. 160 */ 161 public FileResource(File f, String path, boolean includeChildren) { 162 this.f = f; 163 this.path = path; 164 this.uri = "servlet:/" + or(path, ""); 165 this.includeChildren = includeChildren; 166 } 167 168 /** 169 * Returns the read actions for this file: view, highlighted, parsed and download. 170 * 171 * <p> 172 * Every action is a plain {@code GET} link. There is no delete action: a delete is a {@code DELETE} request, 173 * which a link cannot send. 174 * 175 * @return The list of actions. 176 */ 177 @Html(format = HtmlFormat.HTML_CDC) 178 public List<Action> getActions() { 179 var l = new ArrayList<Action>(); 180 if (f.canRead() && ! f.isDirectory()) { 181 var p = or(path, ""); 182 l.add(new Action("view", "servlet:/view/" + p)); 183 l.add(new Action("highlighted", "servlet:/view/" + p + "?highlight=true")); 184 l.add(new Action("parsed", "servlet:/parse/" + p)); 185 l.add(new Action("download", "servlet:/download/" + p)); 186 } 187 return l; 188 } 189 190 /** 191 * Returns child files when this is a directory and includeChildren is <jk>true</jk>. 192 * 193 * @return Child files, or <jk>null</jk> when this is a file or includeChildren is false. 194 */ 195 @SuppressWarnings({ 196 "java:S1168" // Intentional null when file or !includeChildren. 197 }) 198 public Set<FileResource> getFiles() { 199 if (f.isFile() || ! includeChildren) 200 return null; 201 return Arrays.stream(f.listFiles(FILE_FILTER)) 202 .map(fc -> new FileResource(fc, (nn(path) ? (path + '/') : "") + urlEncode(fc.getName()), false)) 203 .collect(Collectors.toCollection(() -> new TreeSet<>(FILE_COMPARATOR))); 204 } 205 206 /** 207 * Returns the last modified date of the file. 208 * 209 * @return The last modified date as an {@link Instant}. 210 */ 211 public Instant getLastModified() { return Instant.ofEpochMilli(f.lastModified()); } 212 213 /** 214 * Returns the file name as a link. 215 * 216 * @return The name as a link. 217 */ 218 public LinkString getName() { return new LinkString(f.getName(), uri); } 219 220 /** 221 * Returns the file size, or number of items for directories. 222 * 223 * @return The size. 224 */ 225 public long getSize() { return f.isDirectory() ? f.listFiles().length : f.length(); } 226 227 /** 228 * Returns the type: "dir" or "file". 229 * 230 * @return The type. 231 */ 232 public String getType() { return (f.isDirectory() ? "dir" : "file"); } 233 } 234 235 @Response(schema = @Schema(type = "string", format = "binary", description = "Contents of file")) 236 static class FileContents extends FileInputStream { 237 238 /** 239 * Constructor. 240 * 241 * @param file The file to read. 242 * @throws FileNotFoundException If the file is not found. 243 */ 244 public FileContents(File file) throws FileNotFoundException { 245 super(file); 246 } 247 } 248 249 @Response(schema = @Schema(description = "Redirect to root page on success")) 250 static class RedirectToRoot extends SeeOtherRoot {} 251 252 private static final long serialVersionUID = 1L; 253 254 private static BufferedReader getReader(File f) throws IOException { 255 return new BufferedReader(new InputStreamReader(new FileInputStream(f), Charset.defaultCharset())); 256 } 257 258 /** Log directory path. Set once in @RestInit, shared by all requests. */ 259 private static File logDir; 260 261 /** Log entry formatter. Set once in @RestInit, shared by all requests. */ 262 private static LogEntryFormatter leFormatter; 263 264 /** Whether deletes are allowed. Set once in @RestInit, shared by all requests. */ 265 private static boolean allowDeletes; 266 267 /** 268 * Deletes a log file on the file system. 269 * 270 * @param path The file path. 271 * @return Redirect to root on success. 272 * @throws MethodNotAllowed If deletes are not enabled. 273 */ 274 @Mutating("Deletes log files") 275 @RestDelete( 276 path="/*", 277 summary="Delete log file", 278 description="Delete a log file on the file system." 279 ) 280 public RedirectToRoot deleteFile(@Path("/*") String path) throws MethodNotAllowed { 281 deleteFile(getFile(path)); 282 return new RedirectToRoot(); 283 } 284 /** 285 * Downloads the contents of a file. 286 * 287 * @param res The response. 288 * @param path The file path. 289 * @return The file contents stream. 290 * @throws NotFound If the file is not found, or is a directory. 291 */ 292 @RestGet( 293 path="/download/*", 294 summary="Download file", 295 description="Download the contents of a file.\nContent-Type is set to 'application/octet-stream'." 296 ) 297 public FileContents downloadFile(RestResponse res, @Path("/*") String path) throws NotFound { 298 var f = getRegularFile(path); 299 res.setContentType("application/octet-stream"); 300 try { 301 return new FileContents(f); 302 } catch (@SuppressWarnings("unused") FileNotFoundException e) { 303 throw new NotFound("File not found"); 304 } 305 } 306 307 /** 308 * Returns information about the specified file or directory. 309 * 310 * @param req The request. 311 * @param path The file path. 312 * @return The file or directory details. 313 * @throws NotFound If the file is not found. 314 * @throws Exception If an error occurs. 315 */ 316 @RestGet( 317 path="/*", 318 summary="View information on file or directory", 319 description="Returns information about the specified file or directory." 320 ) 321 @HtmlDocConfig( 322 nav={"<h5>Folder: $RA{fullPath}</h5>"} 323 ) 324 @SuppressWarnings({ 325 "java:S112" // throws Exception intentional - callback/lifecycle method 326 }) 327 public FileResource getFile(RestRequest req, @Path("/*") String path) throws NotFound, Exception { 328 329 var dir = getFile(path); 330 req.setAttribute("fullPath", dir.getAbsolutePath()); 331 332 return new FileResource(dir, path, true); 333 } 334 335 /** 336 * Initializes log directory and formatter from config. 337 * 338 * @param config The microservice configuration. 339 */ 340 @RestInit 341 @SuppressWarnings({ 342 "java:S2696" // RestInit must be instance method; static fields shared across requests by design 343 }) 344 public void init(Config config) { 345 logDir = new File(config.get("Logging/logDir").asString().orElse("logs")); 346 allowDeletes = config.get("Logging/allowDeletes").asBoolean().orElse(true); 347 leFormatter = new LogEntryFormatter( 348 config.get("Logging/format").asString().orElse("[{date} {level}] {msg}%n"), 349 config.get("Logging/dateFormat").asString().orElse("yyyy.MM.dd hh:mm:ss"), 350 config.get("Logging/useStackTraceHashes").asBoolean().orElse(true) 351 ); 352 } 353 354 /** 355 * Views the contents of a log file with optional filtering. 356 * 357 * @param res The response. 358 * @param path The file path. 359 * @param highlight Add severity color highlighting. 360 * @param start Start timestamp filter. 361 * @param end End timestamp filter. 362 * @param thread Thread name filter. 363 * @param loggers Logger filter. 364 * @param severity Severity filter. 365 * @throws NotFound If the file is not found, or is a directory. 366 * @throws IOException If an I/O error occurs. 367 */ 368 @RestGet( 369 path="/view/*", 370 summary="View contents of log file", 371 description="View the contents of a log file." 372 ) 373 @SuppressWarnings({ 374 "java:S107", // Method has many parameters; acceptable for builder/configuration methods 375 "java:S3776" // Cognitive complexity acceptable for this specific logic 376 }) 377 public void viewFile( 378 RestResponse res, 379 @Path("/*") String path, 380 @Query(name="highlight", schema=@Schema(d="Add severity color highlighting.")) boolean highlight, 381 @Query(name="start", schema=@Schema(d="Start timestamp (ISO8601, full or partial).\nDon't print lines logged before the specified timestamp.\nUse any of the following formats: yyyy, yyyy-MM, yyyy-MM-dd, yyyy-MM-ddThh, yyyy-MM-ddThh:mm, yyyy-MM-ddThh:mm:ss, yyyy-MM-ddThh:mm:ss.SSS")) String start, 382 @Query(name="end", schema=@Schema(d="End timestamp (ISO8601, full or partial).\nDon't print lines logged after the specified timestamp.\nUse any of the following formats: yyyy, yyyy-MM, yyyy-MM-dd, yyyy-MM-ddThh, yyyy-MM-ddThh:mm, yyyy-MM-ddThh:mm:ss, yyyy-MM-ddThh:mm:ss.SSS")) String end, 383 @Query(name="thread", schema=@Schema(d="Thread name filter.\nOnly show log entries with the specified thread name.")) String thread, 384 @Query(name="loggers", schema=@Schema(d="Logger filter (simple class name).\nOnly show log entries if they were produced by one of the specified loggers.")) String[] loggers, 385 @Query(name="severity",schema=@Schema( d="Severity filter.\nOnly show log entries with the specified severity.")) String[] severity 386 ) throws NotFound, IOException { 387 388 var f = getRegularFile(path); 389 390 var startDate = o(start).filter(x1 -> ! isBlank(x1)).map(x2 -> GranularZonedDateTime.of(start).getZonedDateTime()).map(GregorianCalendar::from).map(Calendar::getTime).orElse(null); 391 var endDate = o(end).filter(x11 -> ! isBlank(x11)).map(x4 -> GranularZonedDateTime.of(end).getZonedDateTime()).map(GregorianCalendar::from).map(Calendar::getTime).orElse(null); 392 393 if (! highlight) { 394 var o = getReader(f, startDate, endDate, thread, loggers, severity); 395 res.setContentType("text/plain"); 396 if (o instanceof Reader) 397 res.setContent(o); 398 else { 399 try (var p = (LogParser)o; Writer w = res.getNegotiatedWriter()) { 400 p.writeTo(w); 401 } 402 } 403 return; 404 } 405 406 res.setContentType("text/html"); 407 try (var w = res.getNegotiatedWriter()) { 408 w.println("<html><body style='font-family:monospace;font-size:8pt;white-space:pre;'>"); 409 try (var lp = getLogParser(f, startDate, endDate, thread, loggers, severity)) { 410 if (! lp.hasNext()) 411 w.append("<span style='color:gray'>[EMPTY]</span>"); 412 else 413 for (var le : lp) { 414 var s = ie(le.severity) ? ' ' : le.severity.charAt(0); 415 String color = "black"; 416 //SEVERE|WARNING|INFO|CONFIG|FINE|FINER|FINEST 417 if (s == 'I') 418 color = "#006400"; 419 else if (s == 'W') 420 color = "#CC8400"; 421 else if (s == 'E' || s == 'S') 422 color = "#DD0000"; 423 else if (s == 'D' || s == 'F' || s == 'T') 424 color = "#000064"; 425 w.append("<span style='color:").append(color).append("'>"); 426 le.appendHtml(w).append("</span>"); 427 } 428 w.append("</body></html>"); 429 } 430 } 431 } 432 /** 433 * Views the parsed contents of a log file with filtering. 434 * 435 * @param req The request. 436 * @param path The file path. 437 * @param start Start timestamp filter. 438 * @param end End timestamp filter. 439 * @param thread Thread name filter. 440 * @param loggers Logger filter. 441 * @param severity Severity filter. 442 * @return The log parser iterable. 443 * @throws NotFound If the file is not found. 444 * @throws IOException If an I/O error occurs. 445 */ 446 @RestGet( 447 path="/parse/*", 448 converters=Queryable.class, 449 summary="View parsed contents of file", 450 description="View the parsed contents of a file.", 451 swagger=@OpSwagger( 452 parameters={ 453 Queryable.SWAGGER_PARAMS 454 } 455 ) 456 ) 457 @HtmlDocConfig( 458 nav={"<h5>Folder: $RA{fullPath}</h5>"} 459 ) 460 public LogParser viewParsedEntries( 461 RestRequest req, 462 @Path("/*") String path, 463 @Query(name="start", schema=@Schema(d="Start timestamp (ISO8601, full or partial).\nDon't print lines logged before the specified timestamp.\nUse any of the following formats: yyyy, yyyy-MM, yyyy-MM-dd, yyyy-MM-ddThh, yyyy-MM-ddThh:mm, yyyy-MM-ddThh:mm:ss, yyyy-MM-ddThh:mm:ss.SSS")) String start, 464 @Query(name="end", schema=@Schema(d="End timestamp (ISO8601, full or partial).\nDon't print lines logged after the specified timestamp.\nUse any of the following formats: yyyy, yyyy-MM, yyyy-MM-dd, yyyy-MM-ddThh, yyyy-MM-ddThh:mm, yyyy-MM-ddThh:mm:ss, yyyy-MM-ddThh:mm:ss.SSS")) String end, 465 @Query(name="thread", schema=@Schema(d="Thread name filter.\nOnly show log entries with the specified thread name.")) String thread, 466 @Query(name="loggers", schema=@Schema(d="Logger filter (simple class name).\nOnly show log entries if they were produced by one of the specified loggers.")) String[] loggers, 467 @Query(name="severity", schema=@Schema(d="Severity filter.\nOnly show log entries with the specified severity.")) String[] severity 468 ) throws NotFound, IOException { 469 470 var f = getRegularFile(path); 471 req.setAttribute("fullPath", f.getAbsolutePath()); 472 473 var startDate = o(start).filter(x1 -> ! isBlank(x1)).map(x2 -> GranularZonedDateTime.of(start).getZonedDateTime()).map(GregorianCalendar::from).map(Calendar::getTime).orElse(null); 474 var endDate = o(end).filter(x11 -> ! isBlank(x11)).map(x4 -> GranularZonedDateTime.of(end).getZonedDateTime()).map(GregorianCalendar::from).map(Calendar::getTime).orElse(null); 475 476 return getLogParser(f, startDate, endDate, thread, loggers, severity); 477 } 478 479 private void deleteFile(File f) { 480 if (! allowDeletes) 481 throw new MethodNotAllowed("DELETE not enabled"); 482 if (f.isDirectory()) { 483 var files = f.listFiles(); 484 if (nn(files)) { 485 for (var fc : files) 486 deleteFile(fc); 487 } 488 } 489 try { 490 Files.delete(f.toPath()); 491 } catch (@SuppressWarnings("unused") IOException e) { 492 throw new Forbidden("Could not delete file %s", f.getAbsolutePath()); 493 } 494 } 495 496 /** 497 * Resolves a request path against {@link #logDir} with a canonical-path boundary check 498 * so {@code ..} segments, absolute paths, and symlink-out cannot escape the configured root. 499 * 500 * <p> 501 * The check is intentionally applied at this single funnel because every public operation on 502 * this resource (view, parse, download, delete, info) resolves the user-supplied path through 503 * this method. Delegates to {@link FileUtils#resolveSafely(File, String)} — the canonical 504 * shared implementation; the caller-layer mapping is: boundary violation → 403 (Forbidden), 505 * non-existent target → 404 (NotFound). 506 */ 507 private static File getFile(String path) throws NotFound { 508 try { 509 return FileUtils.resolveSafely(logDir, path) 510 .orElseThrow(() -> new NotFound("File not found.")); 511 } catch (IllegalArgumentException e) { 512 throw new Forbidden(e.getMessage()); 513 } 514 } 515 516 /** 517 * Resolves a request path as {@link #getFile(String)} does, and requires a file: the read actions have nothing to 518 * show for a directory. 519 */ 520 private static File getRegularFile(String path) throws NotFound { 521 var f = getFile(path); 522 if (f.isDirectory()) 523 throw new NotFound("File not found."); 524 return f; 525 } 526 527 private static LogParser getLogParser(File f, Date start, Date end, String thread, String[] loggers, String[] severity) throws IOException { 528 return new LogParser(leFormatter, f, start, end, thread, loggers, severity); 529 } 530 531 private static Object getReader(File f, Date start, Date end, String thread, String[] loggers, String[] severity) throws IOException { 532 if (start == null && end == null && thread == null && loggers == null) 533 return getReader(f); 534 return getLogParser(f, start, end, thread, loggers, severity); 535 } 536}