9ba1e8652603f17e956e19368abb08bfad7f45f1
[idea/community.git] / platform / built-in-server / src / org / jetbrains / builtInWebServer / BuiltInWebServer.kt
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.builtInWebServer
17
18 import com.google.common.cache.CacheBuilder
19 import com.google.common.net.InetAddresses
20 import com.intellij.ide.impl.ProjectUtil
21 import com.intellij.ide.util.PropertiesComponent
22 import com.intellij.notification.NotificationType
23 import com.intellij.openapi.application.ApplicationNamesInfo
24 import com.intellij.openapi.application.PathManager
25 import com.intellij.openapi.diagnostic.Logger
26 import com.intellij.openapi.diagnostic.catchAndLog
27 import com.intellij.openapi.ide.CopyPasteManager
28 import com.intellij.openapi.project.Project
29 import com.intellij.openapi.project.ProjectManager
30 import com.intellij.openapi.ui.MessageDialogBuilder
31 import com.intellij.openapi.ui.Messages
32 import com.intellij.openapi.util.SystemInfoRt
33 import com.intellij.openapi.util.io.FileUtil
34 import com.intellij.openapi.util.io.FileUtilRt
35 import com.intellij.openapi.util.io.endsWithName
36 import com.intellij.openapi.util.registry.Registry
37 import com.intellij.openapi.util.text.StringUtil
38 import com.intellij.openapi.vfs.VirtualFile
39 import com.intellij.util.*
40 import com.intellij.util.io.URLUtil
41 import com.intellij.util.net.NetUtils
42 import io.netty.channel.Channel
43 import io.netty.channel.ChannelHandlerContext
44 import io.netty.handler.codec.http.*
45 import io.netty.handler.codec.http.cookie.DefaultCookie
46 import io.netty.handler.codec.http.cookie.ServerCookieDecoder
47 import io.netty.handler.codec.http.cookie.ServerCookieEncoder
48 import org.jetbrains.ide.BuiltInServerManagerImpl
49 import org.jetbrains.ide.HttpRequestHandler
50 import org.jetbrains.io.*
51 import org.jetbrains.notification.SingletonNotificationManager
52 import java.awt.datatransfer.StringSelection
53 import java.io.IOException
54 import java.math.BigInteger
55 import java.net.InetAddress
56 import java.nio.file.Files
57 import java.nio.file.Path
58 import java.nio.file.Paths
59 import java.nio.file.attribute.PosixFileAttributeView
60 import java.nio.file.attribute.PosixFilePermission
61 import java.security.SecureRandom
62 import java.util.*
63 import java.util.concurrent.TimeUnit
64 import javax.swing.SwingUtilities
65
66 internal val LOG = Logger.getInstance(BuiltInWebServer::class.java)
67
68 // name is duplicated in the ConfigImportHelper
69 private const val IDE_TOKEN_FILE = "user.web.token"
70
71 private val notificationManager by lazy {
72   SingletonNotificationManager(BuiltInServerManagerImpl.NOTIFICATION_GROUP.value, NotificationType.INFORMATION, null)
73 }
74
75 class BuiltInWebServer : HttpRequestHandler() {
76   override fun isAccessible(request: HttpRequest) = request.isLocalOrigin(onlyAnyOrLoopback = false, hostsOnly = true)
77
78   override fun isSupported(request: FullHttpRequest) = super.isSupported(request) || request.method() == HttpMethod.POST
79
80   override fun process(urlDecoder: QueryStringDecoder, request: FullHttpRequest, context: ChannelHandlerContext): Boolean {
81     var host = request.host
82     if (host.isNullOrEmpty()) {
83       return false
84     }
85
86     val portIndex = host!!.indexOf(':')
87     if (portIndex > 0) {
88       host = host.substring(0, portIndex)
89     }
90
91     val projectName: String?
92     val isIpv6 = host[0] == '[' && host.length > 2 && host[host.length - 1] == ']'
93     if (isIpv6) {
94       host = host.substring(1, host.length - 1)
95     }
96
97     if (isIpv6 || InetAddresses.isInetAddress(host) || isOwnHostName(host) || host.endsWith(".ngrok.io")) {
98       if (urlDecoder.path().length < 2) {
99         return false
100       }
101       projectName = null
102     }
103     else {
104       projectName = host
105     }
106     return doProcess(urlDecoder, request, context, projectName)
107   }
108 }
109
110 internal fun isActivatable() = Registry.`is`("ide.built.in.web.server.activatable", false)
111
112 internal const val TOKEN_PARAM_NAME = "_ijt"
113 const val TOKEN_HEADER_NAME = "x-ijt"
114
115 private val STANDARD_COOKIE by lazy {
116   val productName = ApplicationNamesInfo.getInstance().lowercaseProductName
117   val configPath = PathManager.getConfigPath()
118   val file = Paths.get(configPath, IDE_TOKEN_FILE)
119   var token: String? = null
120   if (file.exists()) {
121     try {
122       token = UUID.fromString(file.readText()).toString()
123     }
124     catch (e: Exception) {
125       LOG.warn(e)
126     }
127   }
128   if (token == null) {
129     token = UUID.randomUUID().toString()
130     file.write(token!!)
131     val view = Files.getFileAttributeView(file, PosixFileAttributeView::class.java)
132     if (view != null) {
133       try {
134         view.setPermissions(setOf(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE))
135       }
136       catch (e: IOException) {
137         LOG.warn(e)
138       }
139     }
140   }
141
142   // explicit setting domain cookie on localhost doesn't work for chrome
143   // http://stackoverflow.com/questions/8134384/chrome-doesnt-create-cookie-for-domain-localhost-in-broken-https
144   val cookie = DefaultCookie(productName + "-" + Integer.toHexString(configPath.hashCode()), token!!)
145   cookie.isHttpOnly = true
146   cookie.setMaxAge(TimeUnit.DAYS.toSeconds(365 * 10))
147   cookie.setPath("/")
148   cookie
149 }
150
151 // expire after access because we reuse tokens
152 private val tokens = CacheBuilder.newBuilder().expireAfterAccess(1, TimeUnit.MINUTES).build<String, Boolean>()
153
154 fun acquireToken(): String {
155   var token = tokens.asMap().keys.firstOrNull()
156   if (token == null) {
157     token = TokenGenerator.generate()
158     tokens.put(token, java.lang.Boolean.TRUE)
159   }
160   return token
161 }
162
163 // http://stackoverflow.com/a/41156 - shorter than UUID, but secure
164 private object TokenGenerator {
165   private val random = SecureRandom()
166
167   fun generate(): String = BigInteger(130, random).toString(32)
168 }
169
170 private fun doProcess(urlDecoder: QueryStringDecoder, request: FullHttpRequest, context: ChannelHandlerContext, projectNameAsHost: String?): Boolean {
171   val decodedPath = URLUtil.unescapePercentSequences(urlDecoder.path())
172   var offset: Int
173   var isEmptyPath: Boolean
174   val isCustomHost = projectNameAsHost != null
175   var projectName: String
176   if (isCustomHost) {
177     projectName = projectNameAsHost!!
178     // host mapped to us
179     offset = 0
180     isEmptyPath = decodedPath.isEmpty()
181   }
182   else {
183     offset = decodedPath.indexOf('/', 1)
184     projectName = decodedPath.substring(1, if (offset == -1) decodedPath.length else offset)
185     isEmptyPath = offset == -1
186   }
187
188   var candidateByDirectoryName: Project? = null
189   val project = ProjectManager.getInstance().openProjects.firstOrNull(fun(project: Project): Boolean {
190     if (project.isDisposed) {
191       return false
192     }
193
194     val name = project.name
195     if (isCustomHost) {
196       // domain name is case-insensitive
197       if (projectName.equals(name, ignoreCase = true)) {
198         if (!SystemInfoRt.isFileSystemCaseSensitive) {
199           // may be passed path is not correct
200           projectName = name
201         }
202         return true
203       }
204     }
205     else {
206       // WEB-17839 Internal web server reports 404 when serving files from project with slashes in name
207       if (decodedPath.regionMatches(1, name, 0, name.length, !SystemInfoRt.isFileSystemCaseSensitive)) {
208         val isEmptyPathCandidate = decodedPath.length == (name.length + 1)
209         if (isEmptyPathCandidate || decodedPath[name.length + 1] == '/') {
210           projectName = name
211           offset = name.length + 1
212           isEmptyPath = isEmptyPathCandidate
213           return true
214         }
215       }
216     }
217
218     if (candidateByDirectoryName == null && compareNameAndProjectBasePath(projectName, project)) {
219       candidateByDirectoryName = project
220     }
221     return false
222   }) ?: candidateByDirectoryName ?: return false
223
224   if (isActivatable() && !PropertiesComponent.getInstance().getBoolean("ide.built.in.web.server.active")) {
225     notificationManager.notify("Built-in web server is deactivated, to activate, please use Open in Browser", null)
226     return false
227   }
228
229   if (isEmptyPath) {
230     // we must redirect "jsdebug" to "jsdebug/" as nginx does, otherwise browser will treat it as a file instead of a directory, so, relative path will not work
231     redirectToDirectory(request, context.channel(), projectName, null)
232     return true
233   }
234
235   val path = toIdeaPath(decodedPath, offset)
236   if (path == null) {
237     HttpResponseStatus.BAD_REQUEST.orInSafeMode(HttpResponseStatus.NOT_FOUND).send(context.channel(), request)
238     return true
239   }
240
241   for (pathHandler in WebServerPathHandler.EP_NAME.extensions) {
242     LOG.catchAndLog {
243       if (pathHandler.process(path, project, request, context, projectName, decodedPath, isCustomHost)) {
244         return true
245       }
246     }
247   }
248   return false
249 }
250
251 internal fun HttpRequest.isSignedRequest(): Boolean {
252   // we must check referrer - if html cached, browser will send request without query
253   val token = headers().get(TOKEN_HEADER_NAME)
254       ?: QueryStringDecoder(uri()).parameters().get(TOKEN_PARAM_NAME)?.firstOrNull()
255       ?: referrer?.let { QueryStringDecoder(it).parameters().get(TOKEN_PARAM_NAME)?.firstOrNull() }
256
257   // we don't invalidate token — allow to make subsequent requests using it (it is required for our javadoc DocumentationComponent)
258   return token != null && tokens.getIfPresent(token) != null
259 }
260
261 @JvmOverloads
262 internal fun validateToken(request: HttpRequest, channel: Channel, isSignedRequest: Boolean = request.isSignedRequest()): HttpHeaders? {
263   request.headers().get(HttpHeaderNames.COOKIE)?.let {
264     for (cookie in ServerCookieDecoder.STRICT.decode(it)) {
265       if (cookie.name() == STANDARD_COOKIE.name()) {
266         if (cookie.value() == STANDARD_COOKIE.value()) {
267           return EmptyHttpHeaders.INSTANCE
268         }
269         break
270       }
271     }
272   }
273
274   if (isSignedRequest) {
275     return DefaultHttpHeaders().set(HttpHeaderNames.SET_COOKIE, ServerCookieEncoder.STRICT.encode(STANDARD_COOKIE) + "; SameSite=strict")
276   }
277
278   val urlDecoder = QueryStringDecoder(request.uri())
279   if (!urlDecoder.path().endsWith("/favicon.ico")) {
280     val url = "${channel.uriScheme}://${request.host!!}${urlDecoder.path()}"
281     SwingUtilities.invokeAndWait {
282       ProjectUtil.focusProjectWindow(null, true)
283
284       if (MessageDialogBuilder
285           .yesNo("", "Page '" + StringUtil.trimMiddle(url, 50) + "' requested without authorization, " +
286               "\nyou can copy URL and open it in browser to trust it.")
287           .icon(Messages.getWarningIcon())
288           .yesText("Copy authorization URL to clipboard")
289           .show() == Messages.YES) {
290         CopyPasteManager.getInstance().setContents(StringSelection(url + "?" + TOKEN_PARAM_NAME + "=" + acquireToken()))
291       }
292     }
293   }
294
295   HttpResponseStatus.UNAUTHORIZED.orInSafeMode(HttpResponseStatus.NOT_FOUND).send(channel, request)
296   return null
297 }
298
299 private fun toIdeaPath(decodedPath: String, offset: Int): String? {
300   // must be absolute path (relative to DOCUMENT_ROOT, i.e. scheme://authority/) to properly canonicalize
301   val path = decodedPath.substring(offset)
302   if (!path.startsWith('/')) {
303     return null
304   }
305   return FileUtil.toCanonicalPath(path, '/').substring(1)
306 }
307
308 fun compareNameAndProjectBasePath(projectName: String, project: Project): Boolean {
309   val basePath = project.basePath
310   return basePath != null && endsWithName(basePath, projectName)
311 }
312
313 fun findIndexFile(basedir: VirtualFile): VirtualFile? {
314   val children = basedir.children
315   if (children == null || children.isEmpty()) {
316     return null
317   }
318
319   for (indexNamePrefix in arrayOf("index.", "default.")) {
320     var index: VirtualFile? = null
321     val preferredName = indexNamePrefix + "html"
322     for (child in children) {
323       if (!child.isDirectory) {
324         val name = child.name
325         //noinspection IfStatementWithIdenticalBranches
326         if (name == preferredName) {
327           return child
328         }
329         else if (index == null && name.startsWith(indexNamePrefix)) {
330           index = child
331         }
332       }
333     }
334     if (index != null) {
335       return index
336     }
337   }
338   return null
339 }
340
341 fun findIndexFile(basedir: Path): Path? {
342   val children = basedir.directoryStreamIfExists({
343     val name = it.fileName.toString()
344     name.startsWith("index.") || name.startsWith("default.")
345   }) { it.toList() } ?: return null
346
347   for (indexNamePrefix in arrayOf("index.", "default.")) {
348     var index: Path? = null
349     val preferredName = "${indexNamePrefix}html"
350     for (child in children) {
351       if (!child.isDirectory()) {
352         val name = child.fileName.toString()
353         if (name == preferredName) {
354           return child
355         }
356         else if (index == null && name.startsWith(indexNamePrefix)) {
357           index = child
358         }
359       }
360     }
361     if (index != null) {
362       return index
363     }
364   }
365   return null
366 }
367
368 // is host loopback/any or network interface address (i.e. not custom domain)
369 // must be not used to check is host on local machine
370 internal fun isOwnHostName(host: String): Boolean {
371   if (NetUtils.isLocalhost(host)) {
372     return true
373   }
374
375   try {
376     val address = InetAddress.getByName(host)
377     if (host == address.hostAddress || host.equals(address.canonicalHostName, ignoreCase = true)) {
378       return true
379     }
380
381     val localHostName = InetAddress.getLocalHost().hostName
382     // WEB-8889
383     // develar.local is own host name: develar. equals to "develar.labs.intellij.net" (canonical host name)
384     return localHostName.equals(host, ignoreCase = true) || (host.endsWith(".local") && localHostName.regionMatches(0, host, 0, host.length - ".local".length, true))
385   }
386   catch (ignored: IOException) {
387     return false
388   }
389 }
390
391 internal fun canBeAccessedDirectly(path: String): Boolean {
392   for (fileHandler in WebServerFileHandler.EP_NAME.extensions) {
393     for (ext in fileHandler.pageFileExtensions) {
394       if (FileUtilRt.extensionEquals(path, ext)) {
395         return true
396       }
397     }
398   }
399   return false
400 }