| // 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 <input type="hidden"> 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()); | |
| } | |
| } |