001/*
002 * Copyright (c) 2004-2010, Kohsuke Kawaguchi
003 * All rights reserved.
004 *
005 * Redistribution and use in source and binary forms, with or without modification, are permitted provided
006 * that the following conditions are met:
007 *
008 *     * Redistributions of source code must retain the above copyright notice, this list of
009 *       conditions and the following disclaimer.
010 *     * Redistributions in binary form must reproduce the above copyright notice, this list of
011 *       conditions and the following disclaimer in the documentation and/or other materials
012 *       provided with the distribution.
013 *
014 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS
015 * OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
016 * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS
017 * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
018 * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA,
019 * OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
020 * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
021 * THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
022 */
023
024package org.kohsuke.stapler;
025
026import java.io.ByteArrayOutputStream;
027import java.io.IOException;
028import java.io.InputStream;
029import java.io.OutputStream;
030import java.io.OutputStreamWriter;
031import java.io.PrintWriter;
032import java.io.Writer;
033import java.net.HttpURLConnection;
034import java.net.URL;
035import java.util.Enumeration;
036import java.util.List;
037import java.util.Map;
038import java.util.Map.Entry;
039import javax.annotation.Nonnull;
040import javax.servlet.ServletException;
041import javax.servlet.ServletOutputStream;
042import javax.servlet.http.HttpServletRequest;
043import javax.servlet.http.HttpServletResponse;
044import javax.servlet.http.HttpServletResponseWrapper;
045
046import com.jcraft.jzlib.GZIPOutputStream;
047import net.sf.json.JsonConfig;
048import org.apache.commons.io.IOUtils;
049import org.kohsuke.stapler.compression.CompressionFilter;
050import org.kohsuke.stapler.compression.FilterServletOutputStream;
051import org.kohsuke.stapler.export.DataWriter;
052import org.kohsuke.stapler.export.ExportConfig;
053import org.kohsuke.stapler.export.Flavor;
054import org.kohsuke.stapler.export.Model;
055import org.kohsuke.stapler.export.ModelBuilder;
056import org.kohsuke.stapler.export.NamedPathPruner;
057import org.kohsuke.stapler.export.TreePruner;
058import org.kohsuke.stapler.export.TreePruner.ByDepth;
059
060/**
061 * {@link StaplerResponse} implementation.
062 * 
063 * @author Kohsuke Kawaguchi
064 */
065public class ResponseImpl extends HttpServletResponseWrapper implements StaplerResponse {
066    private final Stapler stapler;
067    private final HttpServletResponse response;
068
069    enum OutputMode { BYTE, CHAR }
070
071    private OutputMode mode=null;
072    private Throwable origin;
073
074    private JsonConfig jsonConfig;
075
076    /**
077     * {@link ServletOutputStream} or {@link PrintWriter}, set when {@link #mode} is set.
078     */
079    private Object output=null;
080
081    public ResponseImpl(Stapler stapler, HttpServletResponse response) {
082        super(response);
083        this.stapler = stapler;
084        this.response = response;
085    }
086
087    @Override
088    public ServletOutputStream getOutputStream() throws IOException {
089        if(mode==OutputMode.CHAR)
090            throw new IllegalStateException("getWriter has already been called. Its call site is in the nested exception",origin);
091        if(mode==null) {
092            recordOutput(super.getOutputStream());
093        }
094        return (ServletOutputStream)output;
095    }
096
097    @Override
098    public PrintWriter getWriter() throws IOException {
099        if(mode==OutputMode.BYTE)
100            throw new IllegalStateException("getOutputStream has already been called. Its call site is in the nested exception",origin);
101        if(mode==null) {
102            recordOutput(super.getWriter());
103        }
104        return (PrintWriter)output;
105    }
106
107    private <T extends ServletOutputStream> T recordOutput(T obj) {
108        this.output = obj;
109        this.mode = OutputMode.BYTE;
110        this.origin = new Throwable();
111        return obj;
112    }
113
114    private <T extends PrintWriter> T recordOutput(T obj) {
115        this.output = obj;
116        this.mode = OutputMode.CHAR;
117        this.origin = new Throwable();
118        return obj;
119    }
120
121    public void forward(Object it, String url, StaplerRequest request) throws ServletException, IOException {
122        stapler.invoke(request, response, it, url);
123    }
124
125    public void forwardToPreviousPage(StaplerRequest request) throws ServletException, IOException {
126        String referer = request.getHeader("Referer");
127        if(referer==null)   referer=".";
128        sendRedirect(referer);
129    }
130
131    @Override
132    public void sendRedirect(@Nonnull String url) throws IOException {
133        // WebSphere doesn't apparently handle relative URLs, so
134        // to be safe, always resolve relative URLs to absolute URLs by ourselves.
135        // see http://www.nabble.com/Hudson%3A-1.262%3A-Broken-link-using-update-manager-to21067157.html
136        if(url.startsWith("http://") || url.startsWith("https://") || url.startsWith("/")) {
137            // absolute URLs
138            super.sendRedirect(url);
139            return;
140        }
141
142        // example: /foo/bar/zot + ../abc -> /foo/bar/../abc
143        String base = Stapler.getCurrentRequest().getRequestURI();
144        base = base.substring(0,base.lastIndexOf('/')+1);
145        if(!url.equals("."))
146            base += url;
147        super.sendRedirect(base);
148    }
149
150    public void sendRedirect2(@Nonnull String url) throws IOException {
151        // Tomcat doesn't encode URL (servlet spec isn't very clear on it)
152        // so do the encoding by ourselves
153        sendRedirect(encode(url));
154    }
155
156    public void sendRedirect(int statusCode, @Nonnull String url) throws IOException {
157        if (statusCode==SC_MOVED_TEMPORARILY) {
158            sendRedirect(url);  // to be safe, let the servlet container handles this default case
159            return;
160        }
161
162        if(url.startsWith("http://") || url.startsWith("https://")) {
163            // absolute URLs
164            url = encode(url);
165        } else {
166            StaplerRequest req = Stapler.getCurrentRequest();
167
168            if (!url.startsWith("/")) {
169                // WebSphere doesn't apparently handle relative URLs, so
170                // to be safe, always resolve relative URLs to absolute URLs by ourselves.
171                // see http://www.nabble.com/Hudson%3A-1.262%3A-Broken-link-using-update-manager-to21067157.html
172
173                // example: /foo/bar/zot + ../abc -> /foo/bar/../abc
174                String base = req.getRequestURI();
175                base = base.substring(0,base.lastIndexOf('/')+1);
176                if(!url.equals("."))
177                    url = base+encode(url);
178                else
179                    url = base;
180
181                assert url.startsWith("/");
182            }
183
184            StringBuilder buf = new StringBuilder(req.getScheme()).append("://").append(req.getServerName());
185            if ((req.getScheme().equals("http") && req.getServerPort()!=80)
186            || (req.getScheme().equals("https") && req.getServerPort()!=443))
187                buf.append(':').append(req.getServerPort());
188            url = buf.append(url).toString();
189        }
190
191        setStatus(statusCode);
192        setHeader("Location",url);
193        getOutputStream().close();
194    }
195
196
197    public void serveFile(StaplerRequest req, URL resource, long expiration) throws ServletException, IOException {
198        if(!stapler.serveStaticResource(req,this,resource,expiration))
199            sendError(SC_NOT_FOUND);
200    }
201
202    public void serveFile(StaplerRequest req, URL resource) throws ServletException, IOException {
203        serveFile(req, resource, -1);
204    }
205
206    public void serveLocalizedFile(StaplerRequest request, URL res) throws ServletException, IOException {
207        serveLocalizedFile(request,res,-1);
208    }
209
210    public void serveLocalizedFile(StaplerRequest request, URL res, long expiration) throws ServletException, IOException {
211        if(!stapler.serveStaticResource(request, this, stapler.selectResourceByLocale(res,request.getLocale()), expiration))
212            sendError(SC_NOT_FOUND);
213    }
214
215    public void serveFile(StaplerRequest req, InputStream data, long lastModified, long expiration, long contentLength, String fileName) throws ServletException, IOException {
216        if(!stapler.serveStaticResource(req,this,data,lastModified,expiration,contentLength,fileName))
217            sendError(SC_NOT_FOUND);        
218    }
219
220    public void serveFile(StaplerRequest req, InputStream data, long lastModified, long expiration, int contentLength, String fileName) throws ServletException, IOException {
221        serveFile(req,data,lastModified,expiration,(long)contentLength,fileName);
222    }
223
224    public void serveFile(StaplerRequest req, InputStream data, long lastModified, long contentLength, String fileName) throws ServletException, IOException {
225        serveFile(req,data,lastModified,-1,contentLength,fileName);
226    }
227
228    public void serveFile(StaplerRequest req, InputStream data, long lastModified, int contentLength, String fileName) throws ServletException, IOException {
229        serveFile(req,data,lastModified,(long)contentLength,fileName);
230    }
231
232    @SuppressWarnings({"unchecked", "rawtypes"}) // API design flaw prevents this from type-checking
233    public void serveExposedBean(StaplerRequest req, Object exposedBean, Flavor flavor) throws ServletException, IOException {
234        serveExposedBean(req, exposedBean, new ExportConfig().withFlavor(flavor).withPrettyPrint(req.hasParameter("pretty")));
235    }
236
237    @Override
238    public void serveExposedBean(StaplerRequest req, Object exposedBean, ExportConfig config) throws ServletException, IOException {
239        String pad=null;
240        Flavor flavor = config.getFlavor();
241        setContentType(flavor.contentType);
242        Writer w = getCompressedWriter(req);
243
244        if (flavor==Flavor.JSON || flavor==Flavor.JSONP) { // for compatibility reasons, accept JSON for JSONP as well.
245            pad = req.getParameter("jsonp");
246            if(pad!=null) w.write(pad+'(');
247        }
248
249        TreePruner pruner;
250        String tree = req.getParameter("tree");
251        if (tree != null) {
252            try {
253                pruner = new NamedPathPruner(tree);
254            } catch (IllegalArgumentException x) {
255                throw new ServletException("Malformed tree expression: " + x, x);
256            }
257        } else {
258            int depth = 0;
259            try {
260                String s = req.getParameter("depth");
261                if (s != null) {
262                    depth = Integer.parseInt(s);
263                }
264            } catch (NumberFormatException e) {
265                throw new ServletException("Depth parameter must be a number");
266            }
267            pruner = new ByDepth(1 - depth);
268        }
269        DataWriter dw = flavor.createDataWriter(exposedBean, w, config);
270        if (exposedBean instanceof Object[]) {
271            // TODO: extend the contract of DataWriter to capture this
272            // TODO: make this work with XML flavor (or at least reject this better)
273            dw.startArray();
274            for (Object item : (Object[])exposedBean)
275                writeOne(pruner, dw, item);
276            dw.endArray();
277        } else {
278            writeOne(pruner, dw, exposedBean);
279        }
280
281        if(pad!=null) w.write(')');
282        w.close();
283    }
284
285    private void writeOne(TreePruner pruner, DataWriter dw, Object item) throws IOException {
286        Model p = MODEL_BUILDER.get(item.getClass());
287        p.writeTo(item, pruner, dw);
288    }
289
290    private boolean acceptsGzip(HttpServletRequest req) {
291        String acceptEncoding = req.getHeader("Accept-Encoding");
292        return acceptEncoding!=null && acceptEncoding.contains("gzip");
293    }
294
295    public OutputStream getCompressedOutputStream(HttpServletRequest req) throws IOException {
296        if (mode!=null) // we already made the call and created OutputStream/Writer
297            return getOutputStream();
298
299        if(!acceptsGzip(req))
300            return getOutputStream();   // compression not applicable here
301
302        if (CompressionFilter.activate(req))
303            return getOutputStream(); // CompressionFilter will set up compression. no need to do anything
304
305        // CompressionFilter not available, so do it on our own.
306        // see CompressionFilter for why this is not desirable
307        setHeader("Content-Encoding","gzip");
308        return recordOutput(new FilterServletOutputStream(new GZIPOutputStream(super.getOutputStream()), super.getOutputStream()));
309    }
310
311    public Writer getCompressedWriter(HttpServletRequest req) throws IOException {
312        if (mode!=null)
313            return getWriter();
314
315        if(!acceptsGzip(req))
316            return getWriter();   // compression not available
317
318        if (CompressionFilter.activate(req))
319            return getWriter(); // CompressionFilter will set up compression. no need to do anything
320
321        // CompressionFilter not available, so do it on our own.
322        // see CompressionFilter for why this is not desirable
323        setHeader("Content-Encoding","gzip");
324        return recordOutput(new PrintWriter(new OutputStreamWriter(new GZIPOutputStream(super.getOutputStream()),getCharacterEncoding())));
325    }
326
327    public int reverseProxyTo(URL url, StaplerRequest req) throws IOException {
328        HttpURLConnection con = (HttpURLConnection) url.openConnection();
329        con.setDoOutput(true);
330
331        Enumeration h = req.getHeaderNames();
332        while(h.hasMoreElements()) {
333            String key = (String) h.nextElement();
334            Enumeration v = req.getHeaders(key);
335            while (v.hasMoreElements()) {
336                con.addRequestProperty(key,(String)v.nextElement());
337            }
338        }
339
340        // copy the request body
341        con.setRequestMethod(req.getMethod());
342        // TODO: how to set request headers?
343        copyAndClose(req.getInputStream(), con.getOutputStream());
344
345        // copy the response
346        int code = con.getResponseCode();
347        setStatus(code,con.getResponseMessage());
348        Map<String,List<String>> rspHeaders = con.getHeaderFields();
349        for (Entry<String, List<String>> header : rspHeaders.entrySet()) {
350            if(header.getKey()==null)   continue;   // response line
351            for (String value : header.getValue()) {
352                addHeader(header.getKey(),value);
353            }
354        }
355
356        copyAndClose(con.getInputStream(), getOutputStream());
357
358        return code;
359    }
360
361    public void setJsonConfig(JsonConfig config) {
362        jsonConfig = config;
363    }
364
365    public JsonConfig getJsonConfig() {
366        if (jsonConfig == null) {
367            jsonConfig = new JsonConfig();
368        }
369        return jsonConfig;
370    }
371
372    private void copyAndClose(InputStream in, OutputStream out) throws IOException {
373        IOUtils.copy(in, out);
374        IOUtils.closeQuietly(in);
375        IOUtils.closeQuietly(out);
376    }
377
378    /**
379     * Escapes non-ASCII characters.
380     */
381    public static @Nonnull String encode(@Nonnull String s) {
382        try {
383            boolean escaped = false;
384
385            StringBuilder out = new StringBuilder(s.length());
386
387            ByteArrayOutputStream buf = new ByteArrayOutputStream();
388            OutputStreamWriter w = new OutputStreamWriter(buf,"UTF-8");
389
390            for (int i = 0; i < s.length(); i++) {
391                int c = (int) s.charAt(i);
392                if (c<128 && c!=' ') {
393                    out.append((char) c);
394                } else {
395                    // 1 char -> UTF8
396                    w.write(c);
397                    w.flush();
398                    for (byte b : buf.toByteArray()) {
399                        out.append('%');
400                        out.append(toDigit((b >> 4) & 0xF));
401                        out.append(toDigit(b & 0xF));
402                    }
403                    buf.reset();
404                    escaped = true;
405                }
406            }
407
408            return escaped ? out.toString() : s;
409        } catch (IOException e) {
410            throw new Error(e); // impossible
411        }
412    }
413
414    private static char toDigit(int n) {
415        char ch = Character.forDigit(n,16);
416        if(ch>='a')     ch = (char)(ch-'a'+'A');
417        return ch;
418    }
419
420    /*package*/ static ModelBuilder MODEL_BUILDER = new ModelBuilder();
421}