1 | // Copyright (C) 2009 Google Inc. |
2 | // |
3 | // Licensed under the Apache License, Version 2.0 (the "License"); |
4 | // you may not use this file except in compliance with the License. |
5 | // You may obtain a copy of the License at |
6 | // |
7 | // http://www.apache.org/licenses/LICENSE-2.0 |
8 | // |
9 | // Unless required by applicable law or agreed to in writing, software |
10 | // distributed under the License is distributed on an "AS IS" BASIS, |
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
12 | // See the License for the specific language governing permissions and |
13 | // limitations under the License. |
14 | |
15 | package com.google.caja.ancillary.servlet; |
16 | |
17 | import com.google.caja.lexer.CharProducer; |
18 | import com.google.caja.lexer.InputSource; |
19 | import com.google.caja.lexer.ParseException; |
20 | import com.google.caja.parser.html.DomParser; |
21 | import com.google.caja.parser.html.HtmlQuasiBuilder; |
22 | import com.google.caja.parser.html.Nodes; |
23 | import com.google.caja.reporting.Message; |
24 | import com.google.caja.reporting.MessageLevel; |
25 | import com.google.caja.reporting.MessageQueue; |
26 | import com.google.caja.reporting.SimpleMessageQueue; |
27 | import com.google.caja.util.ContentType; |
28 | import com.google.caja.util.Lists; |
29 | import com.google.caja.util.Pair; |
30 | |
31 | import java.io.IOException; |
32 | import java.io.OutputStream; |
33 | import java.io.Reader; |
34 | import java.io.UnsupportedEncodingException; |
35 | import java.io.Writer; |
36 | import java.net.URI; |
37 | import java.net.URISyntaxException; |
38 | import java.net.URLDecoder; |
39 | import java.util.Collections; |
40 | import java.util.EnumSet; |
41 | import java.util.List; |
42 | import java.util.regex.Matcher; |
43 | import java.util.regex.Pattern; |
44 | |
45 | import javax.servlet.http.HttpServlet; |
46 | import javax.servlet.http.HttpServletRequest; |
47 | import javax.servlet.http.HttpServletResponse; |
48 | |
49 | import org.w3c.dom.Document; |
50 | import org.w3c.dom.DocumentFragment; |
51 | import org.w3c.dom.Node; |
52 | |
53 | /** |
54 | * Allows web developers to lint, minify, and generate documentation for their |
55 | * code via a web interface. |
56 | * |
57 | * @author mikesamuel@gmail.com |
58 | */ |
59 | public class CajaWebToolsServlet extends HttpServlet { |
60 | private static final long serialVersionUID = -5232422153254165200L; |
61 | final StaticFiles staticFiles; |
62 | private final Pattern staticFilePath; |
63 | |
64 | /** |
65 | * @param cacheId an alphanumeric string that can be added to a directory |
66 | * name in a URL to version all resources in that directory. |
67 | */ |
68 | public CajaWebToolsServlet(String cacheId) { |
69 | this.staticFiles = new StaticFiles(cacheId); |
70 | // Matches "favicon.ico" and paths under <tt>/files-.../</tt> that do not |
71 | // contain any pathname element that starts with a ., so no parent directory |
72 | // names, and no UNIX hidden files. |
73 | this.staticFilePath = Pattern.compile( |
74 | "^/(?:(favicon\\.ico)|" |
75 | + Pattern.quote("files-" + cacheId) // A directory containing cache Id |
76 | + "/((?:[^/.]+/)*[^/.]+(?:\\.[^/.]+)))$"); |
77 | } |
78 | |
79 | @Override |
80 | public void doGet(HttpServletRequest req, HttpServletResponse resp) |
81 | throws IOException { |
82 | String reqPath = req.getPathInfo(); |
83 | // Redirect to /index preserving any query string. |
84 | if (null == reqPath || "/".equals(reqPath)) { |
85 | try { |
86 | String query = req.getQueryString(); |
87 | URI indexUri = new URI( |
88 | null, null, Verb.INDEX.relRequestPath, query, null); |
89 | resp.sendRedirect(indexUri.toString()); |
90 | } catch (URISyntaxException ex) { |
91 | ex.printStackTrace(); |
92 | // Let process report an error |
93 | } |
94 | return; |
95 | } |
96 | Matcher m = staticFilePath.matcher(reqPath); |
97 | if (m.matches()) { |
98 | // Allow GETs of static files. |
99 | String path = m.group(2); |
100 | if (path == null) { path = m.group(1); } |
101 | staticFiles.serve("files/" + path, req, resp); |
102 | } else { |
103 | // Process a dynamic operation. |
104 | process(reqPath, req.getQueryString(), resp); |
105 | } |
106 | } |
107 | |
108 | @Override |
109 | public void doPost(HttpServletRequest req, HttpServletResponse resp) |
110 | throws IOException { |
111 | String reqPath = req.getPathInfo(); |
112 | // Special case uploads since they require very different processing. |
113 | if ("/upload".equals(reqPath)) { |
114 | UploadPage.doUpload(req, resp); |
115 | return; |
116 | } |
117 | StringBuilder query = new StringBuilder(); |
118 | Reader in = req.getReader(); |
119 | try { |
120 | char[] buf = new char[1024]; |
121 | for (int n; (n = in.read(buf)) > 0;) { query.append(buf, 0, n); } |
122 | } finally { |
123 | in.close(); |
124 | } |
125 | process(reqPath, query.toString(), resp); |
126 | } |
127 | |
128 | /** |
129 | * Processes a dynamic request which cannot be satisfied by |
130 | * {@link StaticFiles} or the special upload handler. |
131 | */ |
132 | private void process(String reqPath, String query, HttpServletResponse out) |
133 | throws IOException { |
134 | Result result = handle(reqPath, parseQueryString(query)); |
135 | // Serve the result |
136 | if (result.status != 0) { out.setStatus(result.status); } |
137 | String contentType = result.getContentType(); |
138 | if (contentType != null) { out.setContentType(contentType); } |
139 | for (Pair<String, String> header : result.headers) { |
140 | if (containsControlChar(header.b)) { |
141 | throw new IOException("Split header <<" + header + ">>"); |
142 | } |
143 | out.setHeader(header.a, header.b); |
144 | } |
145 | if (result.content != null) { |
146 | if (result.content.isText()) { |
147 | Writer w = out.getWriter(); |
148 | try { |
149 | result.content.toWriter(w); |
150 | } finally { |
151 | w.close(); |
152 | } |
153 | } else { |
154 | OutputStream os = out.getOutputStream(); |
155 | try { |
156 | result.content.toOutputStream(os); |
157 | } finally { |
158 | os.close(); |
159 | } |
160 | } |
161 | } |
162 | } |
163 | |
164 | /** Expose query parameters in an order-preserving way. */ |
165 | private static List<Pair<String, String>> parseQueryString(String query) { |
166 | List<Pair<String, String>> out = Lists.newArrayList(); |
167 | if (query != null) { |
168 | if (query.startsWith("?")) { query = query.substring(1); } |
169 | if (!"".equals(query)) { |
170 | for (String kv : query.split("&")) { |
171 | int eq = kv.indexOf('='); |
172 | if (eq >= 0) { |
173 | out.add(Pair.pair(uriDecode(kv.substring(0, eq)), |
174 | uriDecode(kv.substring(eq + 1)))); |
175 | } else { |
176 | out.add(Pair.pair(uriDecode(kv), "")); |
177 | } |
178 | } |
179 | } |
180 | } |
181 | return out; |
182 | } |
183 | |
184 | private static String uriDecode(String s) { |
185 | try { |
186 | return URLDecoder.decode(s, "UTF-8"); |
187 | } catch (UnsupportedEncodingException ex) { |
188 | throw new RuntimeException(ex); |
189 | } |
190 | } |
191 | |
192 | /** |
193 | * Handles a request. |
194 | * @param reqPath the URI path requested. |
195 | * @param params query parameters in the order they appear. |
196 | * @return the response to send back. |
197 | */ |
198 | Result handle(String reqPath, List<Pair<String, String>> params) { |
199 | MessageQueue mq = new SimpleMessageQueue(); |
200 | Request req = null; |
201 | if (reqPath.startsWith("/")) { |
202 | // The verb is specified in the path, but in the index page, there is |
203 | // a select box for the verb, so for /index, the param processing below |
204 | // might set the verb in request. |
205 | Verb verb = Verb.fromRelReqPath(reqPath.substring(1)); |
206 | if (verb != null) { |
207 | req = Request.create(verb, staticFiles); |
208 | } |
209 | } |
210 | if (req == null) { |
211 | return errorPage( |
212 | 404, "File not found " + reqPath + ". Expected a path in " |
213 | + EnumSet.allOf(Verb.class), |
214 | mq, new Request()); |
215 | } |
216 | |
217 | List<Job> inputJobs = Lists.newArrayList(); |
218 | Processor p = new Processor(req, mq); |
219 | try { |
220 | Verb v = req.verb; |
221 | // Process all the parameters |
222 | for (Pair<String, String> cgiParam : params) { |
223 | String name = cgiParam.a; |
224 | String value = cgiParam.b; |
225 | if ("".equals(value)) { continue; } |
226 | Request.handler(v, name).handle(name, value, req); |
227 | } |
228 | // Parse all the inputs. |
229 | for (Input input : req.inputs) { |
230 | if ("".equals(input.code.trim())) { continue; } |
231 | InputSource is = new InputSource(req.baseUri.resolve(input.path)); |
232 | CharProducer cp = CharProducer.Factory.fromString(input.code, is); |
233 | req.srcMap.put(is, cp.clone()); |
234 | req.mc.addInputSource(is); |
235 | URI baseUri = req.baseUri != null ? req.baseUri : is.getUri(); |
236 | try { |
237 | inputJobs.add(p.parse(cp, input.t, null, baseUri)); |
238 | } catch (ParseException ex) { |
239 | ex.toMessageQueue(mq); |
240 | } |
241 | } |
242 | } catch (BadInputException ex) { |
243 | return errorPage(ex.getMessage(), mq, req); |
244 | } |
245 | |
246 | // Take the inputs and generate output jobs. |
247 | List<Job> jobs; |
248 | if (req.verb == Verb.INDEX) { |
249 | jobs = Collections.singletonList( |
250 | Job.html(IndexPage.render(req), null)); |
251 | } else if (req.verb == Verb.HELP) { |
252 | jobs = Collections.singletonList( |
253 | Job.html(HelpPage.render(staticFiles), null)); |
254 | } else { |
255 | try { |
256 | jobs = p.process(inputJobs); |
257 | } catch (IOException ex) { |
258 | ex.printStackTrace(); |
259 | return errorPage(ex.getMessage(), mq, req); |
260 | } |
261 | if (jobs.isEmpty() && !inputJobs.isEmpty()) { |
262 | return errorPage(null, mq, req); |
263 | } |
264 | } |
265 | |
266 | // Reduce the output jobs down to one output job. |
267 | // This may involve concatenating javascript or css files, or combining |
268 | // heterogenous file types into a single HTML file. |
269 | Content content = p.reduce(jobs); |
270 | |
271 | // Report errors if we have unresolved errors. |
272 | // For the lint page, which incorporates errors into the regular output, |
273 | // the message queue has already been drained. |
274 | if (MessageLevel.ERROR.compareTo(maxMessageLevel(mq)) < 0) { |
275 | return errorPage(null, mq, req); |
276 | } |
277 | |
278 | Result result = new Result(HttpServletResponse.SC_OK, content, mq); |
279 | if (req.verb == Verb.ECHO) { |
280 | // Force a download so that /echo can't be used as an open redirector. |
281 | String downloadPath = null; |
282 | if (req.inputs.size() == 1) { downloadPath = req.inputs.get(0).path; } |
283 | if (downloadPath == null || "".equals(downloadPath) |
284 | || downloadPath.startsWith("unnamed-") |
285 | || containsControlChar(downloadPath)) { |
286 | downloadPath = "caja_tools_output." + content.type.ext; |
287 | } |
288 | result.headers.add(Pair.pair( |
289 | "Content-disposition", |
290 | "attachment; filename=" + rfc822QuotedString(downloadPath))); |
291 | } |
292 | return result; |
293 | } |
294 | |
295 | private static MessageLevel maxMessageLevel(MessageQueue mq) { |
296 | MessageLevel max = MessageLevel.values()[0]; |
297 | for (Message msg : mq.getMessages()) { |
298 | MessageLevel lvl = msg.getMessageLevel(); |
299 | if (max.compareTo(lvl) < 0) { max = lvl; } |
300 | } |
301 | return max; |
302 | } |
303 | |
304 | private Result errorPage(String title, MessageQueue mq, Request req) { |
305 | return errorPage(HttpServletResponse.SC_BAD_REQUEST, title, mq, req); |
306 | } |
307 | |
308 | private Result errorPage( |
309 | int status, String title, MessageQueue mq, Request req) { |
310 | Document doc = DomParser.makeDocument(null, null); |
311 | HtmlQuasiBuilder b = HtmlQuasiBuilder.getBuilder(doc); |
312 | DocumentFragment messages = Reporter.messagesToFragment(mq, req, b); |
313 | Node errorDoc = b.substV( |
314 | "" |
315 | + "<html>" |
316 | + "<head>" |
317 | + "<meta http-equiv=Content-Type content=text/html;charset=UTF-8 />" |
318 | + "<title>@title</title>" |
319 | + "</head>" |
320 | + "<body>@header@messages</body>" |
321 | + "</html>", |
322 | "title", title != null ? title : "Errors in input", |
323 | "header", title != null ? b.substV("<h1>@t</h1>", "t", title) : "", |
324 | "messages", messages); |
325 | Content errorHtml = new Content(Nodes.render(errorDoc), ContentType.HTML); |
326 | return new Result(status, errorHtml, mq); |
327 | } |
328 | |
329 | private static boolean containsControlChar(String s) { |
330 | for (int i = 0, n = s.length(); i < n; ++i) { |
331 | if (s.charAt(i) < 0x20 && s.charAt(i) != '\t') { return true; } |
332 | } |
333 | return false; |
334 | } |
335 | |
336 | // as referenced from rfc 2045 via rfc 2183 |
337 | private static String rfc822QuotedString(String s) { |
338 | int n = s.length(); |
339 | StringBuilder sb = new StringBuilder(n + 16); |
340 | sb.append('"'); |
341 | int pos = 0; |
342 | for (int i = 0; i < n; ++i) { |
343 | char ch = s.charAt(i); |
344 | if (ch == '"' || ch == '\\') { |
345 | sb.append(s, pos, i).append('\\'); |
346 | pos = i; |
347 | } |
348 | } |
349 | sb.append(s, pos, n).append('"'); |
350 | return sb.toString(); |
351 | } |
352 | } |