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.logging.log4j.core.layout;
018
019import java.io.IOException;
020import java.io.InterruptedIOException;
021import java.io.LineNumberReader;
022import java.io.PrintWriter;
023import java.io.StringReader;
024import java.io.StringWriter;
025import java.lang.management.ManagementFactory;
026import java.nio.charset.Charset;
027import java.nio.charset.StandardCharsets;
028import java.util.ArrayList;
029
030import org.apache.logging.log4j.Level;
031import org.apache.logging.log4j.core.Layout;
032import org.apache.logging.log4j.core.LogEvent;
033import org.apache.logging.log4j.core.config.LoggerConfig;
034import org.apache.logging.log4j.core.config.Node;
035import org.apache.logging.log4j.core.config.plugins.Plugin;
036import org.apache.logging.log4j.core.config.plugins.PluginAttribute;
037import org.apache.logging.log4j.core.config.plugins.PluginBuilderAttribute;
038import org.apache.logging.log4j.core.config.plugins.PluginBuilderFactory;
039import org.apache.logging.log4j.core.config.plugins.PluginFactory;
040import org.apache.logging.log4j.core.pattern.DatePatternConverter;
041import org.apache.logging.log4j.core.util.Transform;
042import org.apache.logging.log4j.util.Strings;
043
044/**
045 * Outputs events as rows in an HTML table on an HTML page.
046 * <p>
047 * Appenders using this layout should have their encoding set to UTF-8 or UTF-16, otherwise events containing non ASCII
048 * characters could result in corrupted log files.
049 * </p>
050 */
051@Plugin(name = "HtmlLayout", category = Node.CATEGORY, elementType = Layout.ELEMENT_TYPE, printObject = true)
052public final class HtmlLayout extends AbstractStringLayout {
053
054    /**
055     * Default font family: {@value}.
056     */
057    public static final String DEFAULT_FONT_FAMILY = "arial,sans-serif";
058
059    private static final String TRACE_PREFIX = "<br />&nbsp;&nbsp;&nbsp;&nbsp;";
060    private static final String REGEXP = Strings.LINE_SEPARATOR.equals("\n") ? "\n" : Strings.LINE_SEPARATOR + "|\n";
061    private static final String DEFAULT_TITLE = "Log4j Log Messages";
062    private static final String DEFAULT_CONTENT_TYPE = "text/html";
063    private static final String DEFAULT_DATE_PATTERN = "JVM_ELAPSE_TIME";
064
065    private final long jvmStartTime = ManagementFactory.getRuntimeMXBean().getStartTime();
066
067    // Print no location info by default
068    private final boolean locationInfo;
069    private final String title;
070    private final String contentType;
071    private final String font;
072    private final String fontSize;
073    private final String headerSize;
074    private final DatePatternConverter datePatternConverter;
075
076    /**Possible font sizes */
077    public static enum FontSize {
078        SMALLER("smaller"), XXSMALL("xx-small"), XSMALL("x-small"), SMALL("small"), MEDIUM("medium"), LARGE("large"),
079        XLARGE("x-large"), XXLARGE("xx-large"),  LARGER("larger");
080
081        private final String size;
082
083        private FontSize(final String size) {
084            this.size = size;
085        }
086
087        public String getFontSize() {
088            return size;
089        }
090
091        public static FontSize getFontSize(final String size) {
092            for (final FontSize fontSize : values()) {
093                if (fontSize.size.equals(size)) {
094                    return fontSize;
095                }
096            }
097            return SMALL;
098        }
099
100        public FontSize larger() {
101            return this.ordinal() < XXLARGE.ordinal() ? FontSize.values()[this.ordinal() + 1] : this;
102        }
103    }
104
105    private HtmlLayout(final boolean locationInfo, final String title, final String contentType, final Charset charset,
106        final String font, final String fontSize, final String headerSize, String datePattern, String timezone) {
107        super(charset);
108        this.locationInfo = locationInfo;
109        this.title = title;
110        this.contentType = addCharsetToContentType(contentType);
111        this.font = font;
112        this.fontSize = fontSize;
113        this.headerSize = headerSize;
114        this.datePatternConverter = DEFAULT_DATE_PATTERN.equals(datePattern) ? null
115            : DatePatternConverter.newInstance(new String[] {datePattern, timezone});
116    }
117
118    /**
119     * For testing purposes.
120     */
121    public String getTitle() {
122        return title;
123    }
124
125    /**
126     * For testing purposes.
127     */
128    public boolean isLocationInfo() {
129        return locationInfo;
130    }
131
132    @Override
133    public boolean requiresLocation() {
134        return locationInfo;
135    }
136
137    private String addCharsetToContentType(final String contentType) {
138        if (contentType == null) {
139            return DEFAULT_CONTENT_TYPE + "; charset=" + getCharset();
140        }
141        return contentType.contains("charset") ? contentType : contentType + "; charset=" + getCharset();
142    }
143
144    /**
145     * Formats as a String.
146     *
147     * @param event The Logging Event.
148     * @return A String containing the LogEvent as HTML.
149     */
150    @Override
151    public String toSerializable(final LogEvent event) {
152        final StringBuilder sbuf = getStringBuilder();
153
154        sbuf.append(Strings.LINE_SEPARATOR).append("<tr>").append(Strings.LINE_SEPARATOR);
155
156        sbuf.append("<td>");
157
158        if (datePatternConverter == null) {
159            sbuf.append(event.getTimeMillis() - jvmStartTime);
160        } else {
161            datePatternConverter.format(event, sbuf);
162        }
163        sbuf.append("</td>").append(Strings.LINE_SEPARATOR);
164
165        final String escapedThread = Transform.escapeHtmlTags(event.getThreadName());
166        sbuf.append("<td title=\"").append(escapedThread).append(" thread\">");
167        sbuf.append(escapedThread);
168        sbuf.append("</td>").append(Strings.LINE_SEPARATOR);
169
170        sbuf.append("<td title=\"Level\">");
171        if (event.getLevel().equals(Level.DEBUG)) {
172            sbuf.append("<font color=\"#339933\">");
173            sbuf.append(Transform.escapeHtmlTags(String.valueOf(event.getLevel())));
174            sbuf.append("</font>");
175        } else if (event.getLevel().isMoreSpecificThan(Level.WARN)) {
176            sbuf.append("<font color=\"#993300\"><strong>");
177            sbuf.append(Transform.escapeHtmlTags(String.valueOf(event.getLevel())));
178            sbuf.append("</strong></font>");
179        } else {
180            sbuf.append(Transform.escapeHtmlTags(String.valueOf(event.getLevel())));
181        }
182        sbuf.append("</td>").append(Strings.LINE_SEPARATOR);
183
184        String escapedLogger = Transform.escapeHtmlTags(event.getLoggerName());
185        if (Strings.isEmpty(escapedLogger)) {
186            escapedLogger = LoggerConfig.ROOT;
187        }
188        sbuf.append("<td title=\"").append(escapedLogger).append(" logger\">");
189        sbuf.append(escapedLogger);
190        sbuf.append("</td>").append(Strings.LINE_SEPARATOR);
191
192        if (locationInfo) {
193            final StackTraceElement element = event.getSource();
194            sbuf.append("<td>");
195            sbuf.append(Transform.escapeHtmlTags(element.getFileName()));
196            sbuf.append(':');
197            sbuf.append(element.getLineNumber());
198            sbuf.append("</td>").append(Strings.LINE_SEPARATOR);
199        }
200
201        sbuf.append("<td title=\"Message\">");
202        sbuf.append(Transform.escapeHtmlTags(event.getMessage().getFormattedMessage()).replaceAll(REGEXP, "<br />"));
203        sbuf.append("</td>").append(Strings.LINE_SEPARATOR);
204        sbuf.append("</tr>").append(Strings.LINE_SEPARATOR);
205
206        if (event.getContextStack() != null && !event.getContextStack().isEmpty()) {
207            sbuf.append("<tr><td bgcolor=\"#EEEEEE\" style=\"font-size : ").append(fontSize);
208            sbuf.append(";\" colspan=\"6\" ");
209            sbuf.append("title=\"Nested Diagnostic Context\">");
210            sbuf.append("NDC: ").append(Transform.escapeHtmlTags(event.getContextStack().toString()));
211            sbuf.append("</td></tr>").append(Strings.LINE_SEPARATOR);
212        }
213
214        if (event.getContextData() != null && !event.getContextData().isEmpty()) {
215            sbuf.append("<tr><td bgcolor=\"#EEEEEE\" style=\"font-size : ").append(fontSize);
216            sbuf.append(";\" colspan=\"6\" ");
217            sbuf.append("title=\"Mapped Diagnostic Context\">");
218            sbuf.append("MDC: ").append(Transform.escapeHtmlTags(event.getContextData().toMap().toString()));
219            sbuf.append("</td></tr>").append(Strings.LINE_SEPARATOR);
220        }
221
222        final Throwable throwable = event.getThrown();
223        if (throwable != null) {
224            sbuf.append("<tr><td bgcolor=\"#993300\" style=\"color:White; font-size : ").append(fontSize);
225            sbuf.append(";\" colspan=\"6\">");
226            appendThrowableAsHtml(throwable, sbuf);
227            sbuf.append("</td></tr>").append(Strings.LINE_SEPARATOR);
228        }
229
230        return sbuf.toString();
231    }
232
233    @Override
234    /**
235     * @return The content type.
236     */
237    public String getContentType() {
238        return contentType;
239    }
240
241    private void appendThrowableAsHtml(final Throwable throwable, final StringBuilder sbuf) {
242        final StringWriter sw = new StringWriter();
243        final PrintWriter pw = new PrintWriter(sw);
244        try {
245            throwable.printStackTrace(pw);
246        } catch (final RuntimeException ex) {
247            // Ignore the exception.
248        }
249        pw.flush();
250        final LineNumberReader reader = new LineNumberReader(new StringReader(sw.toString()));
251        final ArrayList<String> lines = new ArrayList<>();
252        try {
253          String line = reader.readLine();
254          while (line != null) {
255            lines.add(line);
256            line = reader.readLine();
257          }
258        } catch (final IOException ex) {
259            if (ex instanceof InterruptedIOException) {
260                Thread.currentThread().interrupt();
261            }
262            lines.add(ex.toString());
263        }
264        boolean first = true;
265        for (final String line : lines) {
266            if (!first) {
267                sbuf.append(TRACE_PREFIX);
268            } else {
269                first = false;
270            }
271            sbuf.append(Transform.escapeHtmlTags(line));
272            sbuf.append(Strings.LINE_SEPARATOR);
273        }
274    }
275
276    private StringBuilder appendLs(final StringBuilder sbuilder, final String s) {
277        sbuilder.append(s).append(Strings.LINE_SEPARATOR);
278        return sbuilder;
279    }
280
281    private StringBuilder append(final StringBuilder sbuilder, final String s) {
282        sbuilder.append(s);
283        return sbuilder;
284    }
285
286    /**
287     * Returns appropriate HTML headers.
288     * @return The header as a byte array.
289     */
290    @Override
291    public byte[] getHeader() {
292        final StringBuilder sbuf = new StringBuilder();
293        append(sbuf, "<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01 Transitional//EN\" ");
294        appendLs(sbuf, "\"http://www.w3.org/TR/html4/loose.dtd\">");
295        appendLs(sbuf, "<html>");
296        appendLs(sbuf, "<head>");
297        append(sbuf, "<meta charset=\"");
298        append(sbuf, getCharset().toString());
299        appendLs(sbuf, "\"/>");
300        append(sbuf, "<title>").append(title);
301        appendLs(sbuf, "</title>");
302        appendLs(sbuf, "<style type=\"text/css\">");
303        appendLs(sbuf, "<!--");
304        append(sbuf, "body, table {font-family:").append(font).append("; font-size: ");
305        appendLs(sbuf, headerSize).append(";}");
306        appendLs(sbuf, "th {background: #336699; color: #FFFFFF; text-align: left;}");
307        appendLs(sbuf, "-->");
308        appendLs(sbuf, "</style>");
309        appendLs(sbuf, "</head>");
310        appendLs(sbuf, "<body bgcolor=\"#FFFFFF\" topmargin=\"6\" leftmargin=\"6\">");
311        appendLs(sbuf, "<hr size=\"1\" noshade=\"noshade\">");
312        appendLs(sbuf, "Log session start time " + new java.util.Date() + "<br>");
313        appendLs(sbuf, "<br>");
314        appendLs(sbuf,
315                "<table cellspacing=\"0\" cellpadding=\"4\" border=\"1\" bordercolor=\"#224466\" width=\"100%\">");
316        appendLs(sbuf, "<tr>");
317        appendLs(sbuf, "<th>Time</th>");
318        appendLs(sbuf, "<th>Thread</th>");
319        appendLs(sbuf, "<th>Level</th>");
320        appendLs(sbuf, "<th>Logger</th>");
321        if (locationInfo) {
322            appendLs(sbuf, "<th>File:Line</th>");
323        }
324        appendLs(sbuf, "<th>Message</th>");
325        appendLs(sbuf, "</tr>");
326        return sbuf.toString().getBytes(getCharset());
327    }
328
329    /**
330     * Returns the appropriate HTML footers.
331     * @return the footer as a byte array.
332     */
333    @Override
334    public byte[] getFooter() {
335        final StringBuilder sbuf = new StringBuilder();
336        appendLs(sbuf, "</table>");
337        appendLs(sbuf, "<br>");
338        appendLs(sbuf, "</body></html>");
339        return getBytes(sbuf.toString());
340    }
341
342    /**
343     * Creates an HTML Layout.
344     * @param locationInfo If "true", location information will be included. The default is false.
345     * @param title The title to include in the file header. If none is specified the default title will be used.
346     * @param contentType The content type. Defaults to "text/html".
347     * @param charset The character set to use. If not specified, the default will be used.
348     * @param fontSize The font size of the text.
349     * @param font The font to use for the text.
350     * @return An HTML Layout.
351     */
352    @Deprecated
353    @PluginFactory
354    public static HtmlLayout createLayout(
355            @PluginAttribute(value = "locationInfo") final boolean locationInfo,
356            @PluginAttribute(value = "title", defaultString = DEFAULT_TITLE) final String title,
357            @PluginAttribute("contentType") String contentType,
358            @PluginAttribute(value = "charset", defaultString = "UTF-8") final Charset charset,
359            @PluginAttribute("fontSize") String fontSize,
360            @PluginAttribute(value = "fontName", defaultString = DEFAULT_FONT_FAMILY) final String font) {
361        final FontSize fs = FontSize.getFontSize(fontSize);
362        fontSize = fs.getFontSize();
363        final String headerSize = fs.larger().getFontSize();
364        if (contentType == null) {
365            contentType = DEFAULT_CONTENT_TYPE + "; charset=" + charset;
366        }
367        return new HtmlLayout(locationInfo, title, contentType, charset, font, fontSize, headerSize, DEFAULT_DATE_PATTERN,
368                null);
369    }
370
371    /**
372     * Creates an HTML Layout using the default settings.
373     *
374     * @return an HTML Layout.
375     */
376    public static HtmlLayout createDefaultLayout() {
377        return newBuilder().build();
378    }
379
380    @PluginBuilderFactory
381    public static Builder newBuilder() {
382        return new Builder();
383    }
384
385    public static class Builder implements org.apache.logging.log4j.core.util.Builder<HtmlLayout> {
386
387        @PluginBuilderAttribute
388        private boolean locationInfo = false;
389
390        @PluginBuilderAttribute
391        private String title = DEFAULT_TITLE;
392
393        @PluginBuilderAttribute
394        private String contentType = null; // defer default value in order to use specified charset
395
396        @PluginBuilderAttribute
397        private Charset charset = StandardCharsets.UTF_8;
398
399        @PluginBuilderAttribute
400        private FontSize fontSize = FontSize.SMALL;
401
402        @PluginBuilderAttribute
403        private String fontName = DEFAULT_FONT_FAMILY;
404
405        @PluginBuilderAttribute
406        private String datePattern = DEFAULT_DATE_PATTERN;
407
408        @PluginBuilderAttribute
409        private String timezone = null; // null means default timezone
410
411        private Builder() {
412        }
413
414        public Builder withLocationInfo(final boolean locationInfo) {
415            this.locationInfo = locationInfo;
416            return this;
417        }
418
419        public Builder withTitle(final String title) {
420            this.title = title;
421            return this;
422        }
423
424        public Builder withContentType(final String contentType) {
425            this.contentType = contentType;
426            return this;
427        }
428
429        public Builder withCharset(final Charset charset) {
430            this.charset = charset;
431            return this;
432        }
433
434        public Builder withFontSize(final FontSize fontSize) {
435            this.fontSize = fontSize;
436            return this;
437        }
438
439        public Builder withFontName(final String fontName) {
440            this.fontName = fontName;
441            return this;
442        }
443
444        public Builder setDatePattern(final String datePattern) {
445            this.datePattern = datePattern;
446            return this;
447        }
448
449        public Builder setTimezone(final String timezone) {
450            this.timezone = timezone;
451            return this;
452        }
453
454        @Override
455        public HtmlLayout build() {
456            // TODO: extract charset from content-type
457            if (contentType == null) {
458                contentType = DEFAULT_CONTENT_TYPE + "; charset=" + charset;
459            }
460            return new HtmlLayout(locationInfo, title, contentType, charset, fontName, fontSize.getFontSize(),
461                fontSize.larger().getFontSize(), datePattern, timezone);
462        }
463    }
464}