2 * Copyright 2000-2015 JetBrains s.r.o.
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
8 * http://www.apache.org/licenses/LICENSE-2.0
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.
16 package org.jetbrains.builtInWebServer
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
63 import java.util.concurrent.TimeUnit
64 import javax.swing.SwingUtilities
66 internal val LOG = Logger.getInstance(BuiltInWebServer::class.java)
68 // name is duplicated in the ConfigImportHelper
69 private const val IDE_TOKEN_FILE = "user.web.token"
71 private val notificationManager by lazy {
72 SingletonNotificationManager(BuiltInServerManagerImpl.NOTIFICATION_GROUP.value, NotificationType.INFORMATION, null)
75 class BuiltInWebServer : HttpRequestHandler() {
76 override fun isAccessible(request: HttpRequest) = request.isLocalOrigin(onlyAnyOrLoopback = false, hostsOnly = true)
78 override fun isSupported(request: FullHttpRequest) = super.isSupported(request) || request.method() == HttpMethod.POST
80 override fun process(urlDecoder: QueryStringDecoder, request: FullHttpRequest, context: ChannelHandlerContext): Boolean {
81 var host = request.host
82 if (host.isNullOrEmpty()) {
86 val portIndex = host!!.indexOf(':')
88 host = host.substring(0, portIndex)
91 val projectName: String?
92 val isIpv6 = host[0] == '[' && host.length > 2 && host[host.length - 1] == ']'
94 host = host.substring(1, host.length - 1)
97 if (isIpv6 || InetAddresses.isInetAddress(host) || isOwnHostName(host) || host.endsWith(".ngrok.io")) {
98 if (urlDecoder.path().length < 2) {
106 return doProcess(urlDecoder, request, context, projectName)
110 internal fun isActivatable() = Registry.`is`("ide.built.in.web.server.activatable", false)
112 internal const val TOKEN_PARAM_NAME = "_ijt"
113 const val TOKEN_HEADER_NAME = "x-ijt"
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
122 token = UUID.fromString(file.readText()).toString()
124 catch (e: Exception) {
129 token = UUID.randomUUID().toString()
131 val view = Files.getFileAttributeView(file, PosixFileAttributeView::class.java)
134 view.setPermissions(setOf(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE))
136 catch (e: IOException) {
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))
151 // expire after access because we reuse tokens
152 private val tokens = CacheBuilder.newBuilder().expireAfterAccess(1, TimeUnit.MINUTES).build<String, Boolean>()
154 fun acquireToken(): String {
155 var token = tokens.asMap().keys.firstOrNull()
157 token = TokenGenerator.generate()
158 tokens.put(token, java.lang.Boolean.TRUE)
163 // http://stackoverflow.com/a/41156 - shorter than UUID, but secure
164 private object TokenGenerator {
165 private val random = SecureRandom()
167 fun generate(): String = BigInteger(130, random).toString(32)
170 private fun doProcess(urlDecoder: QueryStringDecoder, request: FullHttpRequest, context: ChannelHandlerContext, projectNameAsHost: String?): Boolean {
171 val decodedPath = URLUtil.unescapePercentSequences(urlDecoder.path())
173 var isEmptyPath: Boolean
174 val isCustomHost = projectNameAsHost != null
175 var projectName: String
177 projectName = projectNameAsHost!!
180 isEmptyPath = decodedPath.isEmpty()
183 offset = decodedPath.indexOf('/', 1)
184 projectName = decodedPath.substring(1, if (offset == -1) decodedPath.length else offset)
185 isEmptyPath = offset == -1
188 var candidateByDirectoryName: Project? = null
189 val project = ProjectManager.getInstance().openProjects.firstOrNull(fun(project: Project): Boolean {
190 if (project.isDisposed) {
194 val name = project.name
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
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] == '/') {
211 offset = name.length + 1
212 isEmptyPath = isEmptyPathCandidate
218 if (candidateByDirectoryName == null && compareNameAndProjectBasePath(projectName, project)) {
219 candidateByDirectoryName = project
222 }) ?: candidateByDirectoryName ?: return false
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)
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)
235 val path = toIdeaPath(decodedPath, offset)
237 HttpResponseStatus.BAD_REQUEST.orInSafeMode(HttpResponseStatus.NOT_FOUND).send(context.channel(), request)
241 for (pathHandler in WebServerPathHandler.EP_NAME.extensions) {
243 if (pathHandler.process(path, project, request, context, projectName, decodedPath, isCustomHost)) {
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() }
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
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
274 if (isSignedRequest) {
275 return DefaultHttpHeaders().set(HttpHeaderNames.SET_COOKIE, ServerCookieEncoder.STRICT.encode(STANDARD_COOKIE) + "; SameSite=strict")
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)
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()))
295 HttpResponseStatus.UNAUTHORIZED.orInSafeMode(HttpResponseStatus.NOT_FOUND).send(channel, request)
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('/')) {
305 return FileUtil.toCanonicalPath(path, '/').substring(1)
308 fun compareNameAndProjectBasePath(projectName: String, project: Project): Boolean {
309 val basePath = project.basePath
310 return basePath != null && endsWithName(basePath, projectName)
313 fun findIndexFile(basedir: VirtualFile): VirtualFile? {
314 val children = basedir.children
315 if (children == null || children.isEmpty()) {
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) {
329 else if (index == null && name.startsWith(indexNamePrefix)) {
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
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) {
356 else if (index == null && name.startsWith(indexNamePrefix)) {
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)) {
376 val address = InetAddress.getByName(host)
377 if (host == address.hostAddress || host.equals(address.canonicalHostName, ignoreCase = true)) {
381 val localHostName = InetAddress.getLocalHost().hostName
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))
386 catch (ignored: IOException) {
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)) {