Merge remote-tracking branch 'origin/master' into IDEA-CR-10038
[idea/community.git] / platform / built-in-server / src / org / jetbrains / ide / RestService.java
1 /*
2  * Copyright 2000-2015 JetBrains s.r.o.
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  * http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package org.jetbrains.ide;
17
18 import com.google.common.base.Supplier;
19 import com.google.common.cache.Cache;
20 import com.google.common.cache.CacheBuilder;
21 import com.google.common.cache.CacheLoader;
22 import com.google.common.cache.LoadingCache;
23 import com.google.gson.Gson;
24 import com.google.gson.GsonBuilder;
25 import com.google.gson.stream.JsonReader;
26 import com.google.gson.stream.JsonWriter;
27 import com.google.gson.stream.MalformedJsonException;
28 import com.intellij.ide.IdeBundle;
29 import com.intellij.openapi.diagnostic.Logger;
30 import com.intellij.openapi.project.Project;
31 import com.intellij.openapi.project.ProjectManager;
32 import com.intellij.openapi.util.NotNullLazyValue;
33 import com.intellij.openapi.util.Ref;
34 import com.intellij.openapi.util.io.BufferExposingByteArrayOutputStream;
35 import com.intellij.openapi.util.registry.Registry;
36 import com.intellij.openapi.util.text.StringUtil;
37 import com.intellij.openapi.util.text.StringUtilRt;
38 import com.intellij.openapi.vfs.CharsetToolkit;
39 import com.intellij.openapi.wm.IdeFocusManager;
40 import com.intellij.openapi.wm.IdeFrame;
41 import com.intellij.util.ExceptionUtil;
42 import com.intellij.util.ObjectUtils;
43 import com.intellij.util.containers.ContainerUtil;
44 import com.intellij.util.net.NetUtils;
45 import io.netty.buffer.ByteBufInputStream;
46 import io.netty.buffer.Unpooled;
47 import io.netty.channel.Channel;
48 import io.netty.channel.ChannelHandlerContext;
49 import io.netty.handler.codec.http.*;
50 import org.jetbrains.annotations.NotNull;
51 import org.jetbrains.annotations.Nullable;
52 import org.jetbrains.io.NettyKt;
53 import org.jetbrains.io.Responses;
54
55 import javax.swing.*;
56 import java.awt.*;
57 import java.io.IOException;
58 import java.io.InputStreamReader;
59 import java.io.OutputStream;
60 import java.io.OutputStreamWriter;
61 import java.lang.reflect.InvocationTargetException;
62 import java.net.InetAddress;
63 import java.net.InetSocketAddress;
64 import java.net.URI;
65 import java.net.URISyntaxException;
66 import java.util.List;
67 import java.util.concurrent.TimeUnit;
68 import java.util.concurrent.atomic.AtomicInteger;
69
70 import static com.intellij.ide.impl.ProjectUtil.showYesNoDialog;
71
72 /**
73  * Document your service using <a href="http://apidocjs.com">apiDoc</a>. To extract big example from source code, consider to use *.coffee file near your source file.
74  * (or Python/Ruby, but coffee recommended because it's plugin is lightweight). See {@link AboutHttpService} for example.
75  *
76  * Don't create JsonReader/JsonWriter directly, use only provided {@link #createJsonReader}, {@link #createJsonWriter} methods (to ensure that you handle in/out according to REST API guidelines).
77  *
78  * @see <a href="http://www.vinaysahni.com/best-practices-for-a-pragmatic-restful-api">Best Practices for Designing a Pragmatic RESTful API</a>.
79  */
80 public abstract class RestService extends HttpRequestHandler {
81   protected static final Logger LOG = Logger.getInstance(RestService.class);
82   public static final String PREFIX = "api";
83
84   protected final NotNullLazyValue<Gson> gson = new NotNullLazyValue<Gson>() {
85     @NotNull
86     @Override
87     protected Gson compute() {
88       return new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create();
89     }
90   };
91
92   private final LoadingCache<InetAddress, AtomicInteger> abuseCounter =
93     CacheBuilder.newBuilder().expireAfterWrite(1, TimeUnit.MINUTES).build(CacheLoader.from((Supplier<AtomicInteger>)AtomicInteger::new));
94
95   private final Cache<String, Boolean> trustedOrigins =
96     CacheBuilder.newBuilder().maximumSize(1024).expireAfterWrite(1, TimeUnit.DAYS).build();
97
98   @Override
99   public final boolean isSupported(@NotNull FullHttpRequest request) {
100     if (!isMethodSupported(request.method())) {
101       return false;
102     }
103
104     String uri = request.uri();
105
106     if (isPrefixlessAllowed() && checkPrefix(uri, getServiceName())) {
107       return true;
108     }
109
110     String serviceName = getServiceName();
111     int minLength = 1 + PREFIX.length() + 1 + serviceName.length();
112     if (uri.length() >= minLength &&
113         uri.charAt(0) == '/' &&
114         uri.regionMatches(true, 1, PREFIX, 0, PREFIX.length()) &&
115         uri.regionMatches(true, 2 + PREFIX.length(), serviceName, 0, serviceName.length())) {
116       if (uri.length() == minLength) {
117         return true;
118       }
119       else {
120         char c = uri.charAt(minLength);
121         return c == '/' || c == '?';
122       }
123     }
124     return false;
125   }
126
127   /**
128    * Service url must be "/api/$serviceName", but to preserve backward compatibility, prefixless path could be also supported
129    */
130   protected boolean isPrefixlessAllowed() {
131     return false;
132   }
133
134   /**
135    * Use human-readable name or UUID if it is an internal service.
136    */
137   @NotNull
138   protected abstract String getServiceName();
139
140   protected abstract boolean isMethodSupported(@NotNull HttpMethod method);
141
142   @Override
143   public final boolean process(@NotNull QueryStringDecoder urlDecoder, @NotNull FullHttpRequest request, @NotNull ChannelHandlerContext context) throws IOException {
144     try {
145       AtomicInteger counter = abuseCounter.get(((InetSocketAddress)context.channel().remoteAddress()).getAddress());
146       if (counter.incrementAndGet() > Registry.intValue("ide.rest.api.requests.per.minute", 30)) {
147         Responses.send(Responses.orInSafeMode(HttpResponseStatus.TOO_MANY_REQUESTS, HttpResponseStatus.OK), context.channel(), request);
148         return true;
149       }
150
151       if (!isHostTrusted(request)) {
152         Responses.send(Responses.orInSafeMode(HttpResponseStatus.FORBIDDEN, HttpResponseStatus.OK), context.channel(), request);
153         return true;
154       }
155
156       String error = execute(urlDecoder, request, context);
157       if (error != null) {
158         Responses.send(HttpResponseStatus.BAD_REQUEST, context.channel(), request, error);
159       }
160     }
161     catch (Throwable e) {
162       HttpResponseStatus status;
163       // JsonReader exception
164       //noinspection InstanceofCatchParameter
165       if (e instanceof MalformedJsonException || (e instanceof IllegalStateException && e.getMessage().startsWith("Expected a "))) {
166         LOG.warn(e);
167         status = HttpResponseStatus.BAD_REQUEST;
168       }
169       else {
170         LOG.error(e);
171         status = HttpResponseStatus.INTERNAL_SERVER_ERROR;
172       }
173       Responses.send(status, context.channel(), request, ExceptionUtil.getThrowableText(e));
174     }
175     return true;
176   }
177
178   private boolean isHostTrusted(@NotNull FullHttpRequest request) throws InterruptedException, InvocationTargetException {
179     String referrer = NettyKt.getOrigin(request);
180     if (referrer == null) {
181       referrer = NettyKt.getReferrer(request);
182     }
183
184     String host;
185     try {
186       host = StringUtil.nullize(referrer == null ? null : new URI(referrer).getHost());
187     }
188     catch (URISyntaxException ignored) {
189       return false;
190     }
191
192     Ref<Boolean> isTrusted = Ref.create();
193     if (host != null) {
194       if (NetUtils.isLocalhost(host)) {
195         isTrusted.set(true);
196       }
197       else {
198         isTrusted.set(trustedOrigins.getIfPresent(host));
199       }
200     }
201
202     if (isTrusted.isNull()) {
203       SwingUtilities.invokeAndWait(() -> {
204         isTrusted.set(showYesNoDialog(
205           IdeBundle.message("warning.use.rest.api", getServiceName(), ObjectUtils.chooseNotNull(host, "unknown host")),
206           "title.use.rest.api"));
207         if (host != null) {
208           trustedOrigins.put(host, isTrusted.get());
209         }
210       });
211     }
212     return isTrusted.get();
213   }
214
215   protected static void activateLastFocusedFrame() {
216     IdeFrame frame = IdeFocusManager.getGlobalInstance().getLastFocusedFrame();
217     if (frame instanceof Window) {
218       ((Window)frame).toFront();
219     }
220   }
221
222   /**
223    * Return error or send response using {@link #sendOk(FullHttpRequest, ChannelHandlerContext)}, {@link #send(BufferExposingByteArrayOutputStream, FullHttpRequest, ChannelHandlerContext)}
224    */
225   @Nullable("error text or null if successful")
226   public abstract String execute(@NotNull QueryStringDecoder urlDecoder, @NotNull FullHttpRequest request, @NotNull ChannelHandlerContext context) throws IOException;
227
228   @NotNull
229   protected static JsonReader createJsonReader(@NotNull FullHttpRequest request) {
230     JsonReader reader = new JsonReader(new InputStreamReader(new ByteBufInputStream(request.content()), CharsetToolkit.UTF8_CHARSET));
231     reader.setLenient(true);
232     return reader;
233   }
234
235   @NotNull
236   protected static JsonWriter createJsonWriter(@NotNull OutputStream out) {
237     JsonWriter writer = new JsonWriter(new OutputStreamWriter(out, CharsetToolkit.UTF8_CHARSET));
238     writer.setIndent("  ");
239     return writer;
240   }
241
242   @Nullable
243   protected static Project getLastFocusedOrOpenedProject() {
244     IdeFrame lastFocusedFrame = IdeFocusManager.getGlobalInstance().getLastFocusedFrame();
245     Project project = lastFocusedFrame == null ? null : lastFocusedFrame.getProject();
246     if (project == null) {
247       Project[] openProjects = ProjectManager.getInstance().getOpenProjects();
248       return openProjects.length > 0 ? openProjects[0] : null;
249     }
250     return project;
251   }
252
253   protected static void sendOk(@NotNull FullHttpRequest request, @NotNull ChannelHandlerContext context) {
254     sendStatus(HttpResponseStatus.OK, HttpUtil.isKeepAlive(request), context.channel());
255   }
256
257   protected static void sendStatus(@NotNull HttpResponseStatus status, boolean keepAlive, @NotNull Channel channel) {
258     DefaultFullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, status);
259     HttpUtil.setContentLength(response, 0);
260     Responses.addCommonHeaders(response);
261     Responses.addNoCache(response);
262     if (keepAlive) {
263       HttpUtil.setKeepAlive(response, true);
264     }
265     response.headers().set("X-Frame-Options", "Deny");
266     Responses.send(response, channel, !keepAlive);
267   }
268
269   protected static void send(@NotNull BufferExposingByteArrayOutputStream byteOut, @NotNull HttpRequest request, @NotNull ChannelHandlerContext context) {
270     HttpResponse response = Responses.response("application/json", Unpooled.wrappedBuffer(byteOut.getInternalBuffer(), 0, byteOut.size()));
271     Responses.addNoCache(response);
272     response.headers().set("X-Frame-Options", "Deny");
273     Responses.send(response, context.channel(), request);
274   }
275
276   @Nullable
277   protected static String getStringParameter(@NotNull String name, @NotNull QueryStringDecoder urlDecoder) {
278     return ContainerUtil.getLastItem(urlDecoder.parameters().get(name));
279   }
280
281   protected static int getIntParameter(@NotNull String name, @NotNull QueryStringDecoder urlDecoder) {
282     return StringUtilRt.parseInt(StringUtil.nullize(ContainerUtil.getLastItem(urlDecoder.parameters().get(name)), true), -1);
283   }
284
285   protected static boolean getBooleanParameter(@NotNull String name, @NotNull QueryStringDecoder urlDecoder) {
286     return getBooleanParameter(name, urlDecoder, false);
287   }
288
289   protected static boolean getBooleanParameter(@NotNull String name, @NotNull QueryStringDecoder urlDecoder, boolean defaultValue) {
290     List<String> values = urlDecoder.parameters().get(name);
291     if (ContainerUtil.isEmpty(values)) {
292       return defaultValue;
293     }
294
295     String value = values.get(values.size() - 1);
296     // if just name specified, so, true
297     return value.isEmpty() || Boolean.parseBoolean(value);
298   }
299 }