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 if (BuiltInServerOptions.getInstance().allowUnsignedRequests) {
256 // we must check referrer - if html cached, browser will send request without query
257 val token = headers().get(TOKEN_HEADER_NAME)
258 ?: QueryStringDecoder(uri()).parameters().get(TOKEN_PARAM_NAME)?.firstOrNull()
259 ?: referrer?.let { QueryStringDecoder(it).parameters().get(TOKEN_PARAM_NAME)?.firstOrNull() }
261 // we don't invalidate token — allow to make subsequent requests using it (it is required for our javadoc DocumentationComponent)
262 return token != null && tokens.getIfPresent(token) != null
266 internal fun validateToken(request: HttpRequest, channel: Channel, isSignedRequest: Boolean = request.isSignedRequest()): HttpHeaders? {
267 if (BuiltInServerOptions.getInstance().allowUnsignedRequests) {
268 return EmptyHttpHeaders.INSTANCE
271 request.headers().get(HttpHeaderNames.COOKIE)?.let {
272 for (cookie in ServerCookieDecoder.STRICT.decode(it)) {
273 if (cookie.name() == STANDARD_COOKIE.name()) {
274 if (cookie.value() == STANDARD_COOKIE.value()) {
275 return EmptyHttpHeaders.INSTANCE
282 if (isSignedRequest) {
283 return DefaultHttpHeaders().set(HttpHeaderNames.SET_COOKIE, ServerCookieEncoder.STRICT.encode(STANDARD_COOKIE) + "; SameSite=strict")
286 val urlDecoder = QueryStringDecoder(request.uri())
287 if (!urlDecoder.path().endsWith("/favicon.ico")) {
288 val url = "${channel.uriScheme}://${request.host!!}${urlDecoder.path()}"
289 SwingUtilities.invokeAndWait {
290 ProjectUtil.focusProjectWindow(null, true)
292 if (MessageDialogBuilder
293 .yesNo("", "Page '" + StringUtil.trimMiddle(url, 50) + "' requested without authorization, " +
294 "\nyou can copy URL and open it in browser to trust it.")
295 .icon(Messages.getWarningIcon())
296 .yesText("Copy authorization URL to clipboard")
297 .show() == Messages.YES) {
298 CopyPasteManager.getInstance().setContents(StringSelection(url + "?" + TOKEN_PARAM_NAME + "=" + acquireToken()))
303 HttpResponseStatus.UNAUTHORIZED.orInSafeMode(HttpResponseStatus.NOT_FOUND).send(channel, request)
307 private fun toIdeaPath(decodedPath: String, offset: Int): String? {
308 // must be absolute path (relative to DOCUMENT_ROOT, i.e. scheme://authority/) to properly canonicalize
309 val path = decodedPath.substring(offset)
310 if (!path.startsWith('/')) {
313 return FileUtil.toCanonicalPath(path, '/').substring(1)
316 fun compareNameAndProjectBasePath(projectName: String, project: Project): Boolean {
317 val basePath = project.basePath
318 return basePath != null && endsWithName(basePath, projectName)
321 fun findIndexFile(basedir: VirtualFile): VirtualFile? {
322 val children = basedir.children
323 if (children == null || children.isEmpty()) {
327 for (indexNamePrefix in arrayOf("index.", "default.")) {
328 var index: VirtualFile? = null
329 val preferredName = indexNamePrefix + "html"
330 for (child in children) {
331 if (!child.isDirectory) {
332 val name = child.name
333 //noinspection IfStatementWithIdenticalBranches
334 if (name == preferredName) {
337 else if (index == null && name.startsWith(indexNamePrefix)) {
349 fun findIndexFile(basedir: Path): Path? {
350 val children = basedir.directoryStreamIfExists({
351 val name = it.fileName.toString()
352 name.startsWith("index.") || name.startsWith("default.")
353 }) { it.toList() } ?: return null
355 for (indexNamePrefix in arrayOf("index.", "default.")) {
356 var index: Path? = null
357 val preferredName = "${indexNamePrefix}html"
358 for (child in children) {
359 if (!child.isDirectory()) {
360 val name = child.fileName.toString()
361 if (name == preferredName) {
364 else if (index == null && name.startsWith(indexNamePrefix)) {
376 // is host loopback/any or network interface address (i.e. not custom domain)
377 // must be not used to check is host on local machine
378 internal fun isOwnHostName(host: String): Boolean {
379 if (NetUtils.isLocalhost(host)) {
384 val address = InetAddress.getByName(host)
385 if (host == address.hostAddress || host.equals(address.canonicalHostName, ignoreCase = true)) {
389 val localHostName = InetAddress.getLocalHost().hostName
391 // develar.local is own host name: develar. equals to "develar.labs.intellij.net" (canonical host name)
392 return localHostName.equals(host, ignoreCase = true) || (host.endsWith(".local") && localHostName.regionMatches(0, host, 0, host.length - ".local".length, true))
394 catch (ignored: IOException) {
399 internal fun canBeAccessedDirectly(path: String): Boolean {
400 for (fileHandler in WebServerFileHandler.EP_NAME.extensions) {
401 for (ext in fileHandler.pageFileExtensions) {
402 if (FileUtilRt.extensionEquals(path, ext)) {