blob: 16e09389dec7e1965d7afa491e02af73c549b1af [file] [log] [blame]
// Copyright (C) 2008 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.gerrit.httpd;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.common.flogger.FluentLogger;
import com.google.common.io.ByteStreams;
import com.google.gerrit.common.Nullable;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.StringWriter;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import java.util.zip.GZIPOutputStream;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerException;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathExpression;
import javax.xml.xpath.XPathExpressionException;
import javax.xml.xpath.XPathFactory;
import org.jsoup.parser.Parser;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;
/** Utility functions to deal with HTML using W3C DOM operations. */
public class HtmlDomUtil {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
/** Standard character encoding we prefer (UTF-8). */
public static final Charset ENC = UTF_8;
/** DOCTYPE for a standards mode HTML document. */
public static final String HTML_STRICT =
"-//W3C//DTD HTML 4.01//EN\" \"http://www.w3.org/TR/html4/strict.dtd";
/** Convert a document to a UTF-8 byte sequence. */
public static byte[] toUTF8(Document hostDoc) throws IOException {
return toString(hostDoc).getBytes(ENC);
}
/** Compress the document. */
public static byte[] compress(byte[] raw) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
GZIPOutputStream gz = new GZIPOutputStream(out);
gz.write(raw);
gz.finish();
gz.flush();
return out.toByteArray();
}
/** Convert a document to a String, assuming later encoding to UTF-8. */
public static String toString(Document hostDoc) throws IOException {
try {
StringWriter out = new StringWriter();
DOMSource domSource = new DOMSource(hostDoc);
StreamResult streamResult = new StreamResult(out);
TransformerFactory tf = TransformerFactory.newInstance();
Transformer serializer = tf.newTransformer();
serializer.setOutputProperty(OutputKeys.ENCODING, ENC.name());
serializer.setOutputProperty(OutputKeys.METHOD, "html");
serializer.setOutputProperty(OutputKeys.INDENT, "no");
serializer.setOutputProperty(OutputKeys.DOCTYPE_PUBLIC, HtmlDomUtil.HTML_STRICT);
serializer.transform(domSource, streamResult);
return out.toString();
} catch (TransformerException e) {
throw new IOException("Error transforming page", e);
}
}
/** Find an element by its "id" attribute; null if no element is found. */
@Nullable
public static Element find(Node parent, String name) {
NodeList list = parent.getChildNodes();
for (int i = 0; i < list.getLength(); i++) {
Node n = list.item(i);
if (n instanceof Element) {
Element e = (Element) n;
if (name.equals(e.getAttribute("id"))) {
return e;
}
}
Element r = find(n, name);
if (r != null) {
return r;
}
}
return null;
}
/** Append an HTML &lt;input type="hidden"&gt; to the form. */
public static void addHidden(Element form, String name, String value) {
Element in = form.getOwnerDocument().createElement("input");
in.setAttribute("type", "hidden");
in.setAttribute("name", name);
in.setAttribute("value", value);
form.appendChild(in);
}
/** Construct a new empty document. */
public static Document newDocument() {
try {
return newBuilder().newDocument();
} catch (ParserConfigurationException e) {
throw new RuntimeException("Cannot create new document", e);
}
}
/** Clone a document so it can be safely modified on a per-request basis. */
public static Document clone(Document doc) throws IOException {
Document d;
try {
d = newBuilder().newDocument();
} catch (ParserConfigurationException e) {
throw new IOException("Cannot clone document", e);
}
Node n = d.importNode(doc.getDocumentElement(), true);
d.appendChild(n);
return d;
}
/** Parse an XHTML file from our CLASSPATH and return the instance. */
@Nullable
public static Document parseFile(Class<?> context, String name) throws IOException {
try (InputStream in = context.getResourceAsStream(name)) {
if (in == null) {
return null;
}
Document doc = newBuilder().parse(in);
compact(doc);
return doc;
} catch (SAXException | ParserConfigurationException | IOException e) {
throw new IOException("Error reading " + name, e);
}
}
private static void compact(Document doc) {
try {
String expr = "//text()[normalize-space(.) = '']";
XPathFactory xp = XPathFactory.newInstance();
XPathExpression e = xp.newXPath().compile(expr);
NodeList empty = (NodeList) e.evaluate(doc, XPathConstants.NODESET);
for (int i = 0; i < empty.getLength(); i++) {
Node node = empty.item(i);
node.getParentNode().removeChild(node);
}
} catch (XPathExpressionException e) {
// Don't do the whitespace removal.
}
}
/** Read a Read a UTF-8 text file from our CLASSPATH and return it. */
@Nullable
public static String readFile(Class<?> context, String name) throws IOException {
try (InputStream in = context.getResourceAsStream(name)) {
if (in == null) {
return null;
}
return new String(ByteStreams.toByteArray(in), ENC);
} catch (IOException e) {
throw new IOException("Error reading " + name, e);
}
}
/** Parse an XHTML file from the local drive and return the instance. */
@Nullable
public static Document parseFile(Path path) throws IOException {
try (InputStream in = Files.newInputStream(path)) {
Document doc = newBuilder().parse(in);
compact(doc);
return doc;
} catch (NoSuchFileException e) {
return null;
} catch (SAXException | ParserConfigurationException | IOException e) {
throw new IOException("Error reading " + path, e);
}
}
/** Read a UTF-8 text file from the local drive. */
@Nullable
public static String readFile(Path parentDir, String name) throws IOException {
if (parentDir == null) {
return null;
}
Path path = parentDir.resolve(name);
try (InputStream in = Files.newInputStream(path)) {
return new String(ByteStreams.toByteArray(in), ENC);
} catch (NoSuchFileException e) {
return null;
} catch (IOException e) {
throw new IOException("Error reading " + path, e);
}
}
private static DocumentBuilder newBuilder() throws ParserConfigurationException {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setValidating(false);
factory.setExpandEntityReferences(false);
factory.setIgnoringComments(true);
factory.setCoalescing(true);
return factory.newDocumentBuilder();
}
/**
* Attaches nonce to all script elements in html.
*
* <p>The returned html is not guaranteed to have the same formatting as the input.
*
* @return Updated html or {#link Optional.empty()} if parsing failed.
*/
public static Optional<String> attachNonce(String html, String nonce) {
Parser parser = Parser.htmlParser();
org.jsoup.nodes.Document document = parser.parseInput(html, "");
if (!parser.getErrors().isEmpty()) {
logger.atSevere().atMostEvery(5, TimeUnit.MINUTES).log(
"Html couldn't be parsed to attach nonce. Errors: %s", parser.getErrors());
return Optional.empty();
}
document.getElementsByTag("script").attr("nonce", nonce);
return Optional.of(
document
.outputSettings(
new org.jsoup.nodes.Document.OutputSettings().prettyPrint(false).indentAmount(0))
.outerHtml());
}
}