IDEA-155360 Open In Browser : files from library jars cannot be opened
[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   if (token != null && tokens.getIfPresent(token) != null) {
258     tokens.invalidate(token)
259     return true
260   }
261   else {
262     return false
263   }
264 }
265
266 @JvmOverloads
267 internal fun validateToken(request: HttpRequest, channel: Channel, isSignedRequest: Boolean = request.isSignedRequest()): HttpHeaders? {
268   request.headers().get(HttpHeaderNames.COOKIE)?.let {
269     for (cookie in ServerCookieDecoder.STRICT.decode(it)) {
270       if (cookie.name() == STANDARD_COOKIE.name()) {
271         if (cookie.value() == STANDARD_COOKIE.value()) {
272           return EmptyHttpHeaders.INSTANCE
273         }
274         break
275       }
276     }
277   }
278
279   if (isSignedRequest) {
280     return DefaultHttpHeaders().set(HttpHeaderNames.SET_COOKIE, ServerCookieEncoder.STRICT.encode(STANDARD_COOKIE) + "; SameSite=strict")
281   }
282
283   val urlDecoder = QueryStringDecoder(request.uri())
284   if (!urlDecoder.path().endsWith("/favicon.ico")) {
285     val url = "${channel.uriScheme}://${request.host!!}${urlDecoder.path()}"
286     SwingUtilities.invokeAndWait {
287       ProjectUtil.focusProjectWindow(null, true)
288
289       if (MessageDialogBuilder
290           .yesNo("", "Page '" + StringUtil.trimMiddle(url, 50) + "' requested without authorization, " +
291               "\nyou can copy URL and open it in browser to trust it.")
292           .icon(Messages.getWarningIcon())
293           .yesText("Copy authorization URL to clipboard")
294           .show() == Messages.YES) {
295         CopyPasteManager.getInstance().setContents(StringSelection(url + "?" + TOKEN_PARAM_NAME + "=" + acquireToken()))
296       }
297     }
298   }
299
300   HttpResponseStatus.UNAUTHORIZED.orInSafeMode(HttpResponseStatus.NOT_FOUND).send(channel, request)
301   return null
302 }
303
304 private fun toIdeaPath(decodedPath: String, offset: Int): String? {
305   // must be absolute path (relative to DOCUMENT_ROOT, i.e. scheme://authority/) to properly canonicalize
306   val path = decodedPath.substring(offset)
307   if (!path.startsWith('/')) {
308     return null
309   }
310   return FileUtil.toCanonicalPath(path, '/').substring(1)
311 }
312
313 fun compareNameAndProjectBasePath(projectName: String, project: Project): Boolean {
314   val basePath = project.basePath
315   return basePath != null && endsWithName(basePath, projectName)
316 }
317
318 fun findIndexFile(basedir: VirtualFile): VirtualFile? {
319   val children = basedir.children
320   if (children == null || children.isEmpty()) {
321     return null
322   }
323
324   for (indexNamePrefix in arrayOf("index.", "default.")) {
325     var index: VirtualFile? = null
326     val preferredName = indexNamePrefix + "html"
327     for (child in children) {
328       if (!child.isDirectory) {
329         val name = child.name
330         //noinspection IfStatementWithIdenticalBranches
331         if (name == preferredName) {
332           return child
333         }
334         else if (index == null && name.startsWith(indexNamePrefix)) {
335           index = child
336         }
337       }
338     }
339     if (index != null) {
340       return index
341     }
342   }
343   return null
344 }
345
346 fun findIndexFile(basedir: Path): Path? {
347   val children = basedir.directoryStreamIfExists({
348     val name = it.fileName.toString()
349     name.startsWith("index.") || name.startsWith("default.")
350   }) { it.toList() } ?: return null
351
352   for (indexNamePrefix in arrayOf("index.", "default.")) {
353     var index: Path? = null
354     val preferredName = "${indexNamePrefix}html"
355     for (child in children) {
356       if (!child.isDirectory()) {
357         val name = child.fileName.toString()
358         if (name == preferredName) {
359           return child
360         }
361         else if (index == null && name.startsWith(indexNamePrefix)) {
362           index = child
363         }
364       }
365     }
366     if (index != null) {
367       return index
368     }
369   }
370   return null
371 }
372
373 // is host loopback/any or network interface address (i.e. not custom domain)
374 // must be not used to check is host on local machine
375 internal fun isOwnHostName(host: String): Boolean {
376   if (NetUtils.isLocalhost(host)) {
377     return true
378   }
379
380   try {
381     val address = InetAddress.getByName(host)
382     if (host == address.hostAddress || host.equals(address.canonicalHostName, ignoreCase = true)) {
383       return true
384     }
385
386     val localHostName = InetAddress.getLocalHost().hostName
387     // WEB-8889
388     // develar.local is own host name: develar. equals to "develar.labs.intellij.net" (canonical host name)
389     return localHostName.equals(host, ignoreCase = true) || (host.endsWith(".local") && localHostName.regionMatches(0, host, 0, host.length - ".local".length, true))
390   }
391   catch (ignored: IOException) {
392     return false
393   }
394 }
395
396 internal fun canBeAccessedDirectly(path: String): Boolean {
397   for (fileHandler in WebServerFileHandler.EP_NAME.extensions) {
398     for (ext in fileHandler.pageFileExtensions) {
399       if (FileUtilRt.extensionEquals(path, ext)) {
400         return true
401       }
402     }
403   }
404   return false
405 }