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}