├── .github └── workflows │ ├── publish-release.yml │ └── run-tests.yml ├── .gitignore ├── LICENSE.txt ├── README.md ├── build.sbt ├── project ├── build.properties └── plugins.sbt ├── repo.sbt ├── src ├── main │ ├── resources │ │ └── reference.conf │ └── scala │ │ └── com │ │ └── greenfossil │ │ └── thorium │ │ ├── AESUtil.scala │ │ ├── AnnotatedActionMacroSupport.scala │ │ ├── AppException.scala │ │ ├── Configuration.scala │ │ ├── Conversions.scala │ │ ├── CookieUtil.scala │ │ ├── Endpoint.scala │ │ ├── EndpointMcr.scala │ │ ├── Environment.scala │ │ ├── EssentialAction.scala │ │ ├── Extensions.scala │ │ ├── Flash.scala │ │ ├── FormUrlEncoded.scala │ │ ├── FormUrlEncodedParser.scala │ │ ├── HMACUtil.scala │ │ ├── HttpConfiguration.scala │ │ ├── HttpResponseConverter.scala │ │ ├── MacroSupport.scala │ │ ├── MultipartFormData.scala │ │ ├── MultipartRequest.scala │ │ ├── Recaptcha.scala │ │ ├── Request.scala │ │ ├── RequestAttrs.scala │ │ ├── ResponseHeader.scala │ │ ├── Result.scala │ │ ├── Resultable.scala │ │ ├── Server.scala │ │ ├── Session.scala │ │ └── decorators │ │ ├── CSRFGuardModule.scala │ │ ├── FirstResponderDecoratingFunction.scala │ │ ├── RecaptchaGuardModule.scala │ │ ├── ThreatGuardModule.scala │ │ └── ThreatGuardModuleDecoratingFunction.scala └── test │ ├── resources │ ├── application.conf │ ├── favicon.png │ ├── image-no-ext │ └── logback-test.xml │ └── scala │ └── com │ └── greenfossil │ └── thorium │ ├── AESUtilSuite.scala │ ├── AccessLoggerSuite.scala │ ├── AddAnnotatedServiceSuite.scala │ ├── AddHttpServiceSuite.scala │ ├── AddRouteSuite.scala │ ├── CSRFGuardModule_API_Suite.scala │ ├── CSRFGuardModule_Post_FormData_Fail_Suite.scala │ ├── CSRFGuardModule_Post_FormData_Pass_Suite.scala │ ├── CSRFGuardModule_Post_Multipart_Fail_Suite.scala │ ├── CSRFGuardModule_Post_Multipart_Pass_Suite.scala │ ├── CSRFMainTestService.scala │ ├── CSRFServices.scala │ ├── ContextPathSuite.scala │ ├── DocServiceMain.scala │ ├── EndpointMacro1Suite.scala │ ├── EndpointMacro2Suite.scala │ ├── EndpointMacro3Suite.scala │ ├── EndpointMacroParameterizedServicesSuite.scala │ ├── HeadersSuite.scala │ ├── InheritedAnnotatedPathSuite.scala │ ├── MultiPartFormSuite.scala │ ├── MultipartFileDataBindingSuite.scala │ ├── MultipartFileSuite.scala │ ├── NestedRedirectMacroSuite.scala │ ├── RecaptchaFormPage.scala │ ├── RecaptchaMainTestService.scala │ ├── RecaptchaPlaywright_Stress_Suite.scala │ ├── RecaptchaServices.scala │ ├── RecaptchaSuite.scala │ ├── Recaptcha_Post_Formdata_Stress_Suite.scala │ ├── Recaptcha_Post_Multipart_Stress_Suite.scala │ ├── Recaptcha_Post_Suite.scala │ ├── RegexEndpointSuite.scala │ ├── ResponseHeaderSuite.scala │ ├── SessionSuite.scala │ ├── ThreatGuardModule_Stress_Suite.scala │ ├── ThreatGuardServices.scala │ ├── ThreatGuardTestModule.scala │ ├── UrlPrefixSuite.scala │ ├── VirtualHostSuite.scala │ ├── contextPath │ └── ContextPathSuite.scala │ └── macros │ ├── AnnotatedActionMacroSupportSuite.scala │ └── AnnotatedActionMacroSupportTestImpl.scala └── thorium-buildinfo.sbt /.github/workflows/publish-release.yml: -------------------------------------------------------------------------------- 1 | name: Publish to Maven repository 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | publish: 10 | uses: Greenfossil/.github/.github/workflows/publish-release.yml@main 11 | secrets: inherit 12 | 13 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: 4 | push: 5 | branches: [ "main", "scala-3.5.x" ] 6 | pull_request: 7 | branches: [ "main", "scala-3.5.x" ] 8 | 9 | jobs: 10 | build: 11 | uses: Greenfossil/.github/.github/workflows/run-tests.yml@main 12 | secrets: inherit 13 | 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | logs/ 3 | target/ 4 | .metals 5 | .vscode 6 | .bloop 7 | metals.sbt 8 | .bsp 9 | .lh/ 10 | src/test/scala/experiments/ 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Thorium framework 2 | 3 | ![](https://img.shields.io/github/actions/workflow/status/Greenfossil/thorium/run-tests.yml?branch=master) 4 | ![](https://img.shields.io/github/license/Greenfossil/thorium) 5 | ![](https://img.shields.io/github/v/tag/Greenfossil/thorium) 6 | ![Maven Central](https://img.shields.io/maven-central/v/com.greenfossil/thorium_3) 7 | [![javadoc](https://javadoc.io/badge2/com.greenfossil/thorium_3/javadoc.svg)](https://javadoc.io/doc/com.greenfossil/thorium_3) 8 | 9 | Thorium Framework is a modern microservices framework built on top of Armeria, Scala 3 and Java 17. 10 | 11 | For more information, visit [our official website](https://thoriumframework.dev). 12 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | val scala3Version = "3.7.0" 2 | 3 | scalacOptions ++= Seq( 4 | // "-feature", "-deprecation", "-Wunused:all" , 5 | // "-Xprint:postInlining", "-Xmax-inlines:100000", 6 | ) 7 | 8 | lazy val thorium = project 9 | .in(file(".")) 10 | .settings( 11 | name := "thorium", 12 | organization := "com.greenfossil", 13 | version := "0.10.0-RC1", 14 | 15 | scalaVersion := scala3Version, 16 | 17 | libraryDependencies ++= Seq( 18 | "com.greenfossil" %% "htmltags" % "1.3.0-RC1", 19 | "com.greenfossil" %% "data-mapping" % "1.3.0-RC1", 20 | "com.greenfossil" %% "commons-i18n" % "1.3.0-RC1", 21 | "com.greenfossil" %% "typesafe-config-ext" % "1.3.0-RC3", 22 | "io.projectreactor" % "reactor-core" % "3.7.3", 23 | "com.linecorp.armeria" % "armeria" % "1.32.5", 24 | "com.linecorp.armeria" % "armeria-logback" % "1.32.5", 25 | "org.overviewproject" % "mime-types" % "2.0.0", 26 | "io.github.yskszk63" % "jnhttp-multipartformdata-bodypublisher" % "0.0.1", 27 | "org.slf4j" % "slf4j-api" % "2.0.16", 28 | "com.microsoft.playwright" % "playwright" % "1.51.0" % Test, 29 | "ch.qos.logback" % "logback-classic" % "1.5.16" % Test, 30 | "org.scalameta" %% "munit" % "1.1.1" % Test, 31 | "org.scala-lang" %% "scala3-compiler" % scalaVersion.value % Test 32 | 33 | ) 34 | ) 35 | 36 | //This is required for testcases that submits header content-length explicitly 37 | javacOptions += "-Djdk.httpclient.allowRestrictedHeaders=content-length" 38 | 39 | //https://www.scala-sbt.org/1.x/docs/Publishing.html 40 | ThisBuild / versionScheme := Some("early-semver") 41 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.11.0-RC2 -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("com.github.sbt" % "sbt-pgp" % "2.3.1") -------------------------------------------------------------------------------- /repo.sbt: -------------------------------------------------------------------------------- 1 | ThisBuild / organizationName := "Greenfossil Pte Ltd" 2 | ThisBuild / organizationHomepage := Some(url("https://greenfossil.com/")) 3 | 4 | ThisBuild / scmInfo := Some( 5 | ScmInfo( 6 | url("https://github.com/greenfossil/thorium"), 7 | "scm:git@github.com:greenfossil/thorium.git" 8 | ) 9 | ) 10 | 11 | ThisBuild / homepage := Some(url("https://github.com/Greenfossil/thorium")) 12 | 13 | ThisBuild / description := "Microservices Framework built on top of Armeria, Java 17 and Scala 3" 14 | 15 | ThisBuild / licenses := List( 16 | "Apache 2" -> url("https://www.apache.org/licenses/LICENSE-2.0.txt") 17 | ) 18 | 19 | // Remove all additional repository other than Maven Central from POM 20 | ThisBuild / pomIncludeRepository := { _ => false } 21 | ThisBuild / publishMavenStyle := true 22 | 23 | ThisBuild / developers := List( 24 | Developer( 25 | "greenfossil", 26 | "Greenfossil Pte Ltd", 27 | "devadmin@greenfossil.com", 28 | url("https://github.com/Greenfossil") 29 | ) 30 | ) 31 | 32 | ThisBuild / publishTo := { 33 | val centralSnapshots = "https://central.sonatype.com/repository/maven-snapshots/" 34 | if (isSnapshot.value) Some("central-snapshots" at centralSnapshots) 35 | else localStaging.value 36 | } 37 | 38 | ThisBuild / credentials += { 39 | val ghCredentials = for { 40 | user <- sys.env.get("PUBLISH_USER").filter(_.nonEmpty) 41 | pass <- sys.env.get("PUBLISH_PASSWORD").filter(_.nonEmpty) 42 | } yield Credentials("Sonatype Nexus Repository Manager", "central.sonatype.com", user, pass) 43 | 44 | val fileCredentials = { 45 | val credFile = Path.userHome / ".sbt" / "sonatype_central_credentials" 46 | if (credFile.exists) Some(Credentials(credFile)) else None 47 | } 48 | 49 | ghCredentials 50 | .orElse(fileCredentials) 51 | .getOrElse { 52 | Credentials("Sonatype Nexus Repository Manager", "central.sonatype.com", "", "") 53 | } 54 | } 55 | 56 | 57 | 58 | lazy val sonatypeStaging = "https://s01.oss.sonatype.org/content/groups/staging/" 59 | 60 | ThisBuild / resolvers ++= Seq( 61 | "Sonatype Staging" at sonatypeStaging 62 | ) -------------------------------------------------------------------------------- /src/main/scala/com/greenfossil/thorium/AppException.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | 20 | /** A UsefulException is something useful to display in the User browser. */ 21 | trait UsefulException extends RuntimeException: 22 | /** Exception title. */ 23 | val title: String 24 | 25 | /** Exception description. */ 26 | val description: String 27 | 28 | /** Exception cause if defined. */ 29 | val cause: Throwable 30 | 31 | /** Unique id for this exception. */ 32 | val id: String 33 | 34 | override def toString: String = "@" + id + ": " + getMessage 35 | 36 | private val generator = new java.util.concurrent.atomic.AtomicLong(System.currentTimeMillis()) 37 | 38 | def nextId = java.lang.Long.toString(generator.incrementAndGet(), 26) 39 | 40 | 41 | trait AppException extends UsefulException 42 | 43 | trait ExceptionSource extends AppException -------------------------------------------------------------------------------- /src/main/scala/com/greenfossil/thorium/Configuration.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | import com.typesafe.config.{Config, ConfigFactory} 20 | 21 | object Configuration: 22 | 23 | def apply(): Configuration = from(getClass.getClassLoader) 24 | 25 | def usingPort(port: Int): Configuration = 26 | val configuration = from(getClass.getClassLoader) 27 | configuration.copy(httpConfiguration = configuration.httpConfiguration.copy(httpPort = port)) 28 | 29 | def from(classLoader: ClassLoader): Configuration = 30 | from(ConfigFactory.load(classLoader)) 31 | 32 | def from(config: Config): Configuration = 33 | val env = Environment.from(config) 34 | new Configuration(config, env, HttpConfiguration.from(config, env)) 35 | 36 | case class Configuration(config: Config, environment: Environment, httpConfiguration: HttpConfiguration) : 37 | def httpPort: Int = httpConfiguration.httpPort 38 | 39 | def maxRequestLength = httpConfiguration.maxRequestLength 40 | 41 | def maxNumConnectionOpt = httpConfiguration.maxNumConnectionOpt 42 | 43 | def requestTimeout = httpConfiguration.requestTimeout 44 | 45 | def isProd: Boolean = environment.isProd 46 | 47 | def isDev: Boolean = environment.isDev 48 | 49 | def isDemo: Boolean = environment.isDemo 50 | 51 | def isTest: Boolean = environment.isTest 52 | 53 | def mode: Mode = environment.mode -------------------------------------------------------------------------------- /src/main/scala/com/greenfossil/thorium/Conversions.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | import com.greenfossil.commons.json.JsValue 20 | import com.greenfossil.htmltags.Tag 21 | import com.linecorp.armeria.common.{HttpResponse, HttpStatus, MediaType} 22 | 23 | given Conversion[HttpResponse, Result] = Result(_) 24 | 25 | given Conversion[JsValue, Result] with 26 | def apply(jsValue: JsValue): Result = Result.of(HttpStatus.OK, jsValue.stringify, MediaType.JSON) 27 | 28 | given Conversion[Tag, Result] with 29 | def apply(tag: Tag): Result = Result.of(HttpStatus.OK, tag.render, MediaType.HTML_UTF_8) -------------------------------------------------------------------------------- /src/main/scala/com/greenfossil/thorium/Endpoint.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | import com.linecorp.armeria.server.ServiceConfig 20 | 21 | import java.nio.charset.StandardCharsets 22 | import java.util.regex.Pattern 23 | 24 | case class Endpoint(path: String, method: String, queryParams: List[(String, Any)], pathPatternOpt: Option[String] = None): 25 | 26 | def url: String = 27 | //Query Param is expected to be UrlEncoded 28 | queryParams match 29 | case Nil => path 30 | case _ => path + 31 | queryParams 32 | .map(kv => s"${Endpoint.paramKeyValue(kv._1, kv._2)}") 33 | .mkString("?", "&", "") 34 | 35 | def absoluteUrl(authority: String, secure: Boolean): String = 36 | val protocol = if secure then "https" else "http" 37 | s"$protocol://$authority$url" 38 | 39 | def absoluteUrl(using request: Request): String = 40 | absoluteUrl(request.uriAuthority, request.secure) 41 | 42 | def prefixedUrl(using request: Request): String = 43 | import scala.jdk.CollectionConverters.* 44 | prefixedUrl(request.path, request.requestContext.config().server().serviceConfigs().asScala.toList) 45 | 46 | def prefixedUrl(requestPath: String, serviceConfigs: Seq[ServiceConfig]): String = 47 | Endpoint 48 | .getPrefix(requestPath, serviceConfigs, pathPatternOpt.getOrElse(requestPath)) 49 | .map(prefix => prefix + url) 50 | .getOrElse(url) 51 | 52 | /** 53 | * 54 | * @param params - replace the existing queryParams with params 55 | * @return 56 | */ 57 | def setQueryParams(params: (String, Any)*): Endpoint = 58 | copy(queryParams = params.toList) 59 | 60 | /** 61 | * 62 | * @param params - append params to existing queryParams 63 | * @return 64 | */ 65 | def addQueryParams(params: (String, Any)*): Endpoint = 66 | copy(queryParams = queryParams ++ params) 67 | 68 | override def toString: String = url 69 | 70 | object Endpoint: 71 | 72 | def apply(path: String): Endpoint = new Endpoint(path, "GET", Nil) 73 | 74 | def apply(path: String, pathPattern: String): Endpoint = 75 | new Endpoint(path, "GET", Nil, Option(pathPattern)) 76 | 77 | def paramKeyValue(name: String, value: Any): String = 78 | (name, value) match 79 | case (name, x :: Nil) => paramKeyValue(name, x) 80 | case (name, xs: Seq[Any]) => xs.map(x => s"${name}[]=${x.toString}").mkString("&") 81 | case (name, x: Any) => s"$name=${x.toString}" 82 | 83 | def paramKeyValueUrlEncoded(name: String, value: Any): String = 84 | (name, value) match 85 | case (name, x :: Nil) => paramKeyValueUrlEncoded(name, x) 86 | case (name, xs: Seq[Any]) => xs.map(x => s"${urlencode(name)}[]=${urlencode(x.toString)}").mkString("&") 87 | case (name, x: Any) => s"${urlencode(name)}=${urlencode(x.toString)}" 88 | 89 | def urlencode(value: String): String = 90 | java.net.URLEncoder.encode(value, StandardCharsets.UTF_8.toString) 91 | 92 | def getPrefix(serviceConfigs: Seq[ServiceConfig], rawPathPattern: String): Option[String] = 93 | getPrefix("", serviceConfigs, rawPathPattern) 94 | 95 | def getPrefix(requestPath: String, serviceConfigs: Seq[ServiceConfig], rawPathPattern: String): Option[String] = 96 | //1. Compute Redirect Endpoint prefix that matches incoming Request 97 | val isPrefix = rawPathPattern.startsWith("prefix:") 98 | val epPathPattern = //This would be forwardEndpoint Annotated path pattern 99 | if isPrefix 100 | then rawPathPattern.replaceAll("prefix:", "") + "/*" 101 | else rawPathPattern.replaceAll("regex:|glob:", "") 102 | 103 | val matchedConfigs = serviceConfigs 104 | .filter { serviceConfig => 105 | val configRoutePattern = serviceConfig.route().patternString() 106 | val isConfigRoute = configRoutePattern.endsWith(epPathPattern) 107 | val configRoutePrefix = 108 | configRoutePattern.replaceAll(Pattern.quote(epPathPattern), "") 109 | val matchedRequestPrefix = 110 | //if requestPath is empty then matchedRequestPrefix is set as true 111 | val prefix = if isPrefix then configRoutePrefix + "/" else configRoutePrefix 112 | requestPath.isEmpty || 113 | configRoutePrefix.nonEmpty && requestPath.startsWith(prefix) 114 | 115 | isConfigRoute && matchedRequestPrefix 116 | } 117 | matchedConfigs.lastOption.map(serviceConfig => { 118 | //Convert the matched serviceConfig to the prefix 119 | val configRoutePattern = serviceConfig.route().patternString() 120 | val prefix = configRoutePattern.replaceAll(Pattern.quote(epPathPattern), "") 121 | if prefix.lastOption.contains('/') then prefix.init else prefix 122 | }) 123 | 124 | -------------------------------------------------------------------------------- /src/main/scala/com/greenfossil/thorium/EndpointMcr.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | inline def EndpointMcr[A](inline ep: A): Endpoint = 20 | ${ EndpointMcrImpl( '{ep} ) } 21 | 22 | import scala.quoted.* 23 | def EndpointMcrImpl[A : Type](epExpr: Expr[A])(using Quotes): Expr[Endpoint] = 24 | import AnnotatedActionMacroSupport.* 25 | 26 | computeActionAnnotatedPath( 27 | epExpr, 28 | (exprMethod, exprPathPartList, exprQueryParamKeys, exprQueryParamValues, exprPathPattern) => 29 | '{ 30 | Endpoint( 31 | ${exprPathPartList}.mkString("/"), 32 | ${exprMethod}, 33 | ${exprQueryParamKeys}.zip(${exprQueryParamValues}), 34 | Some(${exprPathPattern}) 35 | ) 36 | } 37 | ) -------------------------------------------------------------------------------- /src/main/scala/com/greenfossil/thorium/Environment.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | import com.typesafe.config.{Config, ConfigFactory} 20 | 21 | import java.io.{File, InputStream} 22 | 23 | enum Mode extends Enum[Mode]: 24 | case Dev, Test, Prod, Demo 25 | 26 | object Environment: 27 | 28 | def from(classLoader: ClassLoader): Environment = 29 | from(ConfigFactory.load(classLoader)) 30 | 31 | /** 32 | * A simple environment. 33 | * 34 | * Uses the same classloader that the environment classloader is defined in, and the current working directory as the 35 | * path. 36 | */ 37 | def from(config: Config): Environment = 38 | val mode = config.getEnum(classOf[Mode], "app.env") 39 | Environment(config.getClass.getClassLoader, new File("."), mode) 40 | 41 | 42 | case class Environment(classLoader: ClassLoader, rootPath: File, mode: Mode): 43 | 44 | /** 45 | * Retrieves a file relative to the application root path. 46 | * 47 | * Note that it is up to you to manage the files in the application root path in production. By default, there will 48 | * be nothing available in the application root path. 49 | * 50 | * For example, to retrieve some deployment specific data file: 51 | * {{{ 52 | * val myDataFile = application.getFile("data/data.xml") 53 | * }}} 54 | * 55 | * @param relativePath relative path of the file to fetch 56 | * @return a file instance; it is not guaranteed that the file exists 57 | */ 58 | def getFile(relativePath: String): File = new File(rootPath, relativePath) 59 | 60 | /** 61 | * Retrieves a file relative to the application root path. 62 | * This method returns an Option[File], using None if the file was not found. 63 | * 64 | * Note that it is up to you to manage the files in the application root path in production. By default, there will 65 | * be nothing available in the application root path. 66 | * 67 | * For example, to retrieve some deployment specific data file: 68 | * {{{ 69 | * val myDataFile = application.getExistingFile("data/data.xml") 70 | * }}} 71 | * 72 | * @param relativePath the relative path of the file to fetch 73 | * @return an existing file 74 | */ 75 | def getExistingFile(relativePath: String): Option[File] = Some(getFile(relativePath)).filter(_.exists) 76 | 77 | /** 78 | * Scans the application classloader to retrieve a resource. 79 | * 80 | * The conf directory is included on the classpath, so this may be used to look up resources, relative to the conf 81 | * directory. 82 | * 83 | * For example, to retrieve the conf/logback.xml configuration file: 84 | * {{{ 85 | * val maybeConf = application.resource("logback.xml") 86 | * }}} 87 | * 88 | * @param name the absolute name of the resource (from the classpath root) 89 | * @return the resource URL, if found 90 | */ 91 | def resource(name: String): Option[java.net.URL] = 92 | val n = name.stripPrefix("/") 93 | Option(classLoader.getResource(n)) 94 | 95 | /** 96 | * Scans the application classloader to retrieve a resource’s contents as a stream. 97 | * 98 | * The conf directory is included on the classpath, so this may be used to look up resources, relative to the conf 99 | * directory. 100 | * 101 | * For example, to retrieve the conf/logback.xml configuration file: 102 | * {{{ 103 | * val maybeConf = application.resourceAsStream("logback.xml") 104 | * }}} 105 | * 106 | * @param name the absolute name of the resource (from the classpath root) 107 | * @return a stream, if found 108 | */ 109 | def resourceAsStream(name: String): Option[InputStream] = 110 | val n = name.stripPrefix("/") 111 | Option(classLoader.getResourceAsStream(n)) 112 | 113 | def isProd: Boolean = mode == Mode.Prod 114 | 115 | def isTest: Boolean = mode == Mode.Test 116 | 117 | def isDemo: Boolean = mode == Mode.Demo 118 | 119 | def isDev: Boolean = mode == Mode.Dev 120 | 121 | def modeName: String = mode.toString.toLowerCase 122 | 123 | -------------------------------------------------------------------------------- /src/main/scala/com/greenfossil/thorium/EssentialAction.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | import com.linecorp.armeria.common.{HttpRequest, HttpResponse} 20 | import com.linecorp.armeria.server.{HttpService, ServiceRequestContext} 21 | import org.slf4j.LoggerFactory 22 | 23 | import java.io.InputStream 24 | import java.util.concurrent.CompletableFuture 25 | 26 | type SimpleResponse = String | Array[Byte] | InputStream | HttpResponse 27 | 28 | type ActionResponse = SimpleResponse | Result 29 | 30 | private[thorium] val actionLogger = LoggerFactory.getLogger("com.greenfossil.thorium.action") 31 | 32 | trait EssentialAction extends HttpService : 33 | 34 | /** 35 | * An EssentialAction is an Request => ActionResponse 36 | * All subclasses must implements this function signature 37 | * 38 | * This method will be invoked by EssentialAction.serve 39 | * 40 | * @param request 41 | * @return 42 | */ 43 | protected def apply(request: Request): ActionResponse 44 | 45 | /** 46 | * Armeria invocation during an incoming request 47 | * 48 | * @param svcRequestContext 49 | * @param httpRequest 50 | * @return 51 | */ 52 | override def serve(svcRequestContext: ServiceRequestContext, httpRequest: HttpRequest): HttpResponse = 53 | actionLogger.debug(s"Processing EssentialAction.serve - method:${svcRequestContext.method()}, content-type:${httpRequest.contentType()}, path:${httpRequest.uri}") 54 | val futureResp = new CompletableFuture[HttpResponse]() 55 | svcRequestContext 56 | .request() 57 | .aggregate() 58 | .thenAccept { aggregateRequest => 59 | actionLogger.debug("Setting up blockingTaskExecutor()") 60 | svcRequestContext.blockingTaskExecutor().execute(() => { 61 | //Invoke EssentialAction 62 | val ctxCl = Thread.currentThread().getContextClassLoader 63 | if ctxCl == null then { 64 | val cl = this.getClass.getClassLoader 65 | actionLogger.debug(s"Async setContextClassloader:${cl}") 66 | Thread.currentThread().setContextClassLoader(cl) 67 | } 68 | val httpResp = 69 | try 70 | val req = new Request(svcRequestContext, aggregateRequest) {} 71 | actionLogger.debug(s"Invoke EssentialAction.apply. cl:$ctxCl, req:${req.hashCode()}") 72 | val resp = apply(req) 73 | actionLogger.debug("Response from EssentialAction.apply") 74 | HttpResponseConverter.convertActionResponseToHttpResponse(req, resp) 75 | catch 76 | case t => 77 | actionLogger.debug(s"Exception raised in EssentialAction.apply.", t) 78 | HttpResponse.ofFailure(t) 79 | futureResp.complete(httpResp) 80 | }) 81 | } 82 | HttpResponse.of(futureResp) 83 | 84 | end EssentialAction 85 | 86 | trait Action extends EssentialAction 87 | 88 | object Action: 89 | 90 | /** 91 | * AnyContent request 92 | * 93 | * @param actionResponder 94 | * @return 95 | */ 96 | def apply(fn: Request => ActionResponse): Action = 97 | actionLogger.debug(s"Processing Action...") 98 | (request: Request) => fn(request) 99 | 100 | /** 101 | * Multipart form request 102 | * 103 | * @param actionResponder 104 | * @return 105 | */ 106 | def multipart(fn: MultipartRequest => ActionResponse): Action = 107 | actionLogger.debug("Processing Multipart Action...") 108 | (request: Request) => request.asMultipartFormData { form => 109 | fn(MultipartRequest(form, request.requestContext, request.aggregatedHttpRequest)) 110 | } 111 | -------------------------------------------------------------------------------- /src/main/scala/com/greenfossil/thorium/Extensions.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | import com.greenfossil.data.mapping.Mapping 20 | import com.linecorp.armeria.common.multipart.MultipartFile 21 | import com.linecorp.armeria.common.{HttpMethod, MediaType} 22 | import org.overviewproject.mime_types.MimeTypeDetector 23 | 24 | import java.io.InputStream 25 | import java.net.URI 26 | 27 | 28 | extension (inline action: EssentialAction) 29 | 30 | inline def absoluteUrl(authority: String, secure: Boolean) = 31 | EndpointMcr(action).absoluteUrl(authority, secure) 32 | 33 | inline def absoluteUrl(using request: Request): String = 34 | EndpointMcr(action).absoluteUrl(using request) 35 | 36 | inline def endpoint: Endpoint = EndpointMcr(action) 37 | 38 | extension[A](field: Mapping[A]) 39 | def bindFromRequest()(using request: Request): Mapping[A] = 40 | request match 41 | case req if Option(req.contentType).exists(_.isJson) => 42 | field.bind(request.asJson) 43 | 44 | case req => 45 | //QueryParams for POST,PUT,PATCH will be not be used for binding 46 | val queryParams: List[(String, String)] = 47 | request.method match { 48 | case HttpMethod.POST | HttpMethod.PUT | HttpMethod.PATCH => Nil 49 | case _ => request.queryParamsList 50 | } 51 | field.bind(req.asFormUrlEncoded ++ queryParams.groupMap(_._1)(_._2)) 52 | 53 | val mimeTypeDetector = new MimeTypeDetector() 54 | 55 | extension(mpFile: MultipartFile) 56 | def sizeInGB: Long = mpFile.file().length() / 1024 / 1024 / 1024 57 | def sizeInMB: Long = mpFile.file().length() / 1024 / 1024 58 | def sizeInKB: Long = mpFile.file().length() / 1024 59 | def sizeInBytes: Long = mpFile.file().length() 60 | 61 | def contentType: MediaType = MediaType.parse(mimeTypeDetector.detectMimeType(mpFile.path())) 62 | 63 | def inputStream: InputStream = mpFile.path().toUri.toURL.openStream() 64 | 65 | extension(cs: java.net.CookieStore) 66 | 67 | /** 68 | * Add an Armeria cookie to java.net.CookieStore 69 | * This is meant for client testing only. Do not suse for production. 70 | * @param host 71 | * @param cookie 72 | */ 73 | def add(host: String, cookie: com.linecorp.armeria.common.Cookie) = 74 | val uri = URI.create(host) 75 | val httpCookie = java.net.HttpCookie(cookie.name(), cookie.value()) 76 | httpCookie.setSecure(cookie.isSecure) 77 | httpCookie.setPath(cookie.path()) 78 | httpCookie.setHttpOnly(cookie.isHttpOnly) 79 | httpCookie.setMaxAge(httpCookie.getMaxAge) 80 | if cookie.isHostOnly then httpCookie.setHttpOnly(true) 81 | if cookie.domain() != null && !cookie.domain().isBlank then httpCookie.setDomain(cookie.domain()) 82 | 83 | cs.add(uri, httpCookie) 84 | 85 | extension (l: Long) def humanize: String = 86 | if l < 1024 then s"$l B" 87 | else 88 | val z = (63 - java.lang.Long.numberOfLeadingZeros(l)) / 10 89 | f"${(l * 1.0) / (1L << (z * 10))}%.1f ${" KMGTPE".charAt(z)}%sB" -------------------------------------------------------------------------------- /src/main/scala/com/greenfossil/thorium/Flash.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | import scala.annotation.targetName 20 | 21 | object Flash: 22 | def apply(): Flash = new Flash(Map.empty.withDefaultValue("")) 23 | 24 | case class Flash(data: Map[String, String]): 25 | export data.{+ as _, *} 26 | 27 | @targetName("add") 28 | def +(tup: (String, String)): Flash = 29 | copy(data = data + tup ) 30 | 31 | def ++(newFlash: Flash): Flash = 32 | copy(data = data ++ newFlash.data ) -------------------------------------------------------------------------------- /src/main/scala/com/greenfossil/thorium/FormUrlEncoded.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | import com.linecorp.armeria.common.{AggregatedHttpRequest, HttpMethod, MediaType} 20 | import com.linecorp.armeria.server.ServiceRequestContext 21 | import com.linecorp.armeria.server.annotation.RequestConverterFunction 22 | 23 | import java.lang.reflect.ParameterizedType 24 | 25 | case class FormUrlEndcoded(form: Map[String, Seq[String]]): 26 | export form.* 27 | def getFirst(fieldName: String):Option[String] = 28 | get(fieldName).map(_.headOption.orNull) 29 | 30 | object FormUrlEncodedRequestConverterFunction extends RequestConverterFunction: 31 | override def convertRequest(ctx: ServiceRequestContext, 32 | request: AggregatedHttpRequest, 33 | expectedResultType: Class[?], 34 | expectedParameterizedResultType: ParameterizedType): AnyRef = 35 | if expectedResultType == classOf[FormUrlEndcoded] 36 | && MediaType.FORM_DATA == request.contentType() && HttpMethod.POST == request.method() then 37 | FormUrlEncodedParser.parse(request.contentUtf8()) 38 | else RequestConverterFunction.fallthrough() -------------------------------------------------------------------------------- /src/main/scala/com/greenfossil/thorium/FormUrlEncodedParser.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | import java.net.URLDecoder 20 | import scala.collection.immutable.ListMap 21 | import scala.collection.mutable 22 | 23 | /** An object for parsing application/x-www-form-urlencoded data */ 24 | object FormUrlEncodedParser: 25 | 26 | /** 27 | * Parse the content type "application/x-www-form-urlencoded" which consists of a bunch of & separated key=value 28 | * pairs, both of which are URL encoded. This parser to maintain the original order of the 29 | * keys by using groupBy as some applications depend on the original browser ordering. 30 | * @param data The body content of the request, or whatever needs to be so parsed 31 | * @param encoding The character encoding of data 32 | * @return A ListMap of keys to the sequence of values for that key 33 | */ 34 | def parse(data: String, encoding: String = "utf-8"): FormUrlEndcoded = 35 | 36 | // Generate the pairs of values from the string. 37 | val pairs: Seq[(String, String)] = parseToPairs(data, encoding) 38 | 39 | // Group the pairs by the key (first item of the pair) being sure to preserve insertion order 40 | FormUrlEndcoded(toMap(pairs)) 41 | 42 | private val parameterDelimiter = "[&;]".r 43 | 44 | /** 45 | * Do the basic parsing into a sequence of key/value pairs 46 | * @param data The data to parse 47 | * @param encoding The encoding to use for interpreting the data 48 | * @return The sequence of key/value pairs 49 | */ 50 | private def parseToPairs(data: String, encoding: String): Seq[(String, String)] = 51 | val split = parameterDelimiter.split(data) 52 | if split.length == 1 && split(0).isEmpty then Seq.empty 53 | else 54 | split.toIndexedSeq.map { param => 55 | val parts = param.split("=", -1) 56 | val key = URLDecoder.decode(parts(0), encoding) 57 | val value = URLDecoder.decode(parts.lift(1).getOrElse(""), encoding) 58 | key -> value 59 | } 60 | 61 | private def toMap[K, V](seq: Seq[(K, V)]): Map[K, Seq[V]] = 62 | // This mutable map will not retain insertion order for the seq, but it is fast for retrieval. The value is 63 | // a builder for the desired Seq[String] in the final result. 64 | val m = mutable.Map.empty[K, mutable.Builder[V, Seq[V]]] 65 | 66 | // Run through the seq and create builders for each unique key, effectively doing the grouping 67 | for ((key, value) <- seq) m.getOrElseUpdate(key, Seq.newBuilder[V]) += value 68 | 69 | // Create a builder for the resulting ListMap. Note that this one is immutable and will retain insertion order 70 | val b = ListMap.newBuilder[K, Seq[V]] 71 | 72 | // Note that we are NOT going through m (didn't retain order) but we are iterating over the original seq 73 | // just to get the keys so we can look up the values in m with them. This is how order is maintained. 74 | for ((k, v) <- seq.iterator) b += k -> m.getOrElse(k, Seq.newBuilder[V]).result 75 | 76 | // Get the builder to produce the final result 77 | b.result -------------------------------------------------------------------------------- /src/main/scala/com/greenfossil/thorium/HMACUtil.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | import java.security.{MessageDigest, SecureRandom} 20 | import javax.crypto.Mac 21 | import javax.crypto.spec.SecretKeySpec 22 | 23 | object HMACUtil: 24 | 25 | /** 26 | * 27 | * @param message 28 | * @param key - 29 | * @param algorithm - HmacSHA256, HmacSHA384, HmacSHA512 or the equivalent HS256, HS384, HS512 30 | * @param converter 31 | * @tparam A 32 | * @return 33 | */ 34 | def hmac[A](message: Array[Byte], key: Array[Byte], algorithm: String, converter: Array[Byte] => A): A = 35 | val alg = algorithm.replaceAll("^HS", "HmacSHA") 36 | val keySpec = new SecretKeySpec(key, alg) 37 | val mac = Mac.getInstance(alg) 38 | mac.init(keySpec) 39 | converter(mac.doFinal(message)) 40 | 41 | def digest(bytes: Array[Byte], algorithm: String): Array[Byte] = 42 | digest(bytes, algorithm, identity) 43 | 44 | def digest[A](bytes: Array[Byte], algorithm: String, converter: Array[Byte] => A): A = 45 | digest(Seq(bytes), algorithm, converter) 46 | 47 | def digest[A](bytesSeq: Seq[Array[Byte]], algorithm: String, converter: Array[Byte] => A): A = 48 | val md = MessageDigest.getInstance(algorithm) 49 | bytesSeq.foreach(v => md.update(v)) 50 | converter(md.digest) 51 | 52 | /** 53 | * 54 | * @param byteLength 55 | * @return 56 | */ 57 | def randomBytes(byteLength: Int): Array[Byte] = 58 | randomBytes(byteLength, "NativePRNG", identity) 59 | 60 | def randomBytes[A](byteLength: Int, converter: Array[Byte] => A): A = 61 | randomBytes(byteLength, "NativePRNG", converter) 62 | 63 | 64 | /** 65 | * 66 | * @param byteLength 67 | * @param algorithm - e.g. NativePRNG 68 | * @return 69 | */ 70 | def randomBytes[A](byteLength: Int, algorithm: String, converter: Array[Byte] => A): A = 71 | val random = SecureRandom.getInstance(algorithm) 72 | val bytes = new Array[Byte](byteLength) 73 | random.nextBytes(bytes) 74 | converter(bytes) 75 | 76 | /** 77 | * 78 | * @param bytes 79 | * @return 80 | */ 81 | def hex(bytes: Array[Byte]): String = 82 | bytes.map(byte => f"${byte & 0xFF}%02x").mkString("") 83 | 84 | /** 85 | * 86 | * @param length 87 | * @return 88 | */ 89 | def randomAlphaNumericString(length: Int): String = 90 | randomAlphaNumericString(length, "NativePRNG") 91 | 92 | inline private val ALPHA_NUMERIC_STRING = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" 93 | inline private val NUMERIC_STRING = "0123456789" 94 | 95 | /** 96 | * 97 | * @param length 98 | * @param algorithm 99 | * @return 100 | */ 101 | def randomAlphaNumericString(length: Int, algorithm: String): String = 102 | randomStringBuilder(ALPHA_NUMERIC_STRING, length, algorithm) 103 | 104 | /** 105 | * 106 | * @param length 107 | * @return 108 | */ 109 | def randomNumericString(length: Int): String = 110 | randomNumericString(length, "NativePRNG") 111 | 112 | /** 113 | * Generate a random numeric string 114 | * 115 | * @param length 116 | * @param algorithm 117 | * @return 118 | */ 119 | def randomNumericString(length: Int, algorithm: String): String = 120 | randomStringBuilder(NUMERIC_STRING, length, algorithm) 121 | 122 | /** 123 | * 124 | * @param str 125 | * @param length 126 | * @param rand 127 | * @return 128 | */ 129 | private def randomStringBuilder(str: String, length: Int, algorithm: String): String = 130 | if length < 1 then 131 | throw new IllegalArgumentException("The length must be a positive integer") 132 | 133 | val random = SecureRandom.getInstance(algorithm) 134 | val alphaLength = str.length 135 | val builder = new StringBuilder() 136 | 0 until length foreach { i => 137 | builder.append(str.charAt(random.nextInt(alphaLength))) 138 | } 139 | builder.toString 140 | 141 | 142 | /** 143 | * Constant time equals method. 144 | * 145 | * Given a length that both Arrays are equal to, this method will always run in constant time. 146 | * This prevents timing attacks. 147 | */ 148 | def constantTimeEquals(array1: Array[Byte], array2: Array[Byte]): Boolean = 149 | // Check if the arrays have the same length 150 | if array1.length != array2.length then false 151 | else 152 | // Perform a bitwise comparison of each byte 153 | // If the arrays are equal, the result will be 0 154 | var result = 0 155 | for (i <- array1.indices) { 156 | result |= array1(i) ^ array2(i) 157 | } 158 | result == 0 -------------------------------------------------------------------------------- /src/main/scala/com/greenfossil/thorium/HttpResponseConverter.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | import com.linecorp.armeria.common.{Cookie, HttpData, HttpHeaderNames, HttpMethod, HttpResponse, HttpStatus, MediaType, ResponseHeaders} 20 | import com.linecorp.armeria.common.stream.StreamMessage 21 | 22 | import java.io.InputStream 23 | import scala.util.{Try, Using} 24 | 25 | object HttpResponseConverter: 26 | 27 | private def addCookiesToHttpResponse(cookies: Seq[Cookie], resp: HttpResponse): HttpResponse = 28 | if cookies.isEmpty then resp 29 | else resp.mapHeaders(_.toBuilder.cookies(cookies *).build()) 30 | 31 | private def addHeadersToHttpResponse(responseHeader: ResponseHeader, resp: HttpResponse): HttpResponse = 32 | if responseHeader == null || responseHeader.headers.isEmpty then resp 33 | else resp.mapHeaders(_.withMutations { builder => 34 | responseHeader.headers.map { header => 35 | builder.set(header._1, header._2) 36 | } 37 | }) 38 | 39 | private def addContentTypeToHttpResponse(contextTypeOpt: Option[MediaType], resp: HttpResponse): HttpResponse = 40 | contextTypeOpt match 41 | case Some(contentType) => 42 | resp.mapHeaders(_.withMutations { builder => 43 | builder.contentType(contentType) 44 | }) 45 | case None => resp 46 | 47 | private def getCSRFCookie(req: Request): Option[Cookie] = 48 | val token = req.requestContext.attr(RequestAttrs.CSRFToken) 49 | if token == null then None 50 | else 51 | val config = req.requestContext.attr(RequestAttrs.Config) 52 | serverLogger.debug(s"CSRFToken added to header:${token}") 53 | Option(CookieUtil 54 | .csrfCookieBuilder(config.httpConfiguration.csrfConfig, token) 55 | .build()) 56 | 57 | 58 | private def getAllCookies(req: Request, actionResp: ActionResponse): Seq[Cookie] = 59 | val (newCookies, newSessionOpt, newFlashOpt) = 60 | actionResp match 61 | case result: Result => (result.newCookies, result.newSessionOpt, result.newFlashOpt) 62 | case _ => (Nil, None, None) 63 | (getNewSessionCookie(req, newSessionOpt) ++ getNewFlashCookie(req, newFlashOpt) ++ getCSRFCookie(req)).toList ++ newCookies 64 | 65 | private def responseHeader(actionResp: ActionResponse): ResponseHeader = 66 | actionResp match 67 | case result: Result => result.header 68 | case _ => null 69 | 70 | private def contentTypeOpt(actionResp: ActionResponse): Option[MediaType] = 71 | actionResp match 72 | case result: Result => result.contentTypeOpt 73 | case _ => None 74 | 75 | /* 76 | * Keep current request Session, if newSessionOpt is None 77 | * If newSessionOpt is defined, discard session if newSession isEmpty or create a new Session with values 78 | */ 79 | private def getNewSessionCookie(req: Request, newSessionOpt: Option[Session]): Option[Cookie] = 80 | newSessionOpt.map { newSession => 81 | //If newSession isEmtpy, expire session cookie 82 | if newSession.isEmpty then 83 | CookieUtil.bakeDiscardCookie(req.httpConfiguration.sessionConfig.cookieName)(using req) 84 | else 85 | //Bake a new session cookie 86 | CookieUtil.bakeSessionCookie(newSession)(using req).orNull 87 | } 88 | 89 | private def getNewFlashCookie(req: Request, newFlashOpt: Option[Flash]): Option[Cookie] = 90 | /* 91 | * Flash cookie could survive for 1 request. 92 | * However, if there is X-THORIUM-FLASH:Again header, flash will survive 1 more request 93 | */ 94 | val hasThoriumFlashHeader = 95 | req.getHeader("X-THORIUM-FLASH").exists("AGAIN".equalsIgnoreCase) 96 | && HttpMethod.GET == req.method 97 | val flashOpt = if !hasThoriumFlashHeader then newFlashOpt else newFlashOpt.map(newFlash => req.flash ++ newFlash).orElse(Option(req.flash)) 98 | flashOpt.flatMap { newFlash => 99 | CookieUtil.bakeFlashCookie(newFlash)(using req) //Create a new flash cookie 100 | }.orElse { 101 | //Expire the current flash cookie 102 | if req.flash == null || req.flash.isEmpty then None 103 | else Some(CookieUtil.bakeDiscardCookie(req.httpConfiguration.flashConfig.cookieName)(using req)) 104 | } 105 | 106 | private def _addHttpResponseHeaders(req: com.greenfossil.thorium.Request, 107 | actionResp: ActionResponse, 108 | httpResp: HttpResponse 109 | ): Try[HttpResponse] = 110 | for { 111 | respWithCookies <- Try(addCookiesToHttpResponse(getAllCookies(req, actionResp), httpResp)) 112 | respWithHeaders <- Try(addHeadersToHttpResponse(responseHeader(actionResp), respWithCookies)) 113 | httpResponse <- Try(addContentTypeToHttpResponse(contentTypeOpt(actionResp), respWithHeaders)) 114 | } yield httpResponse 115 | 116 | private def _toHttpResponse(req: com.greenfossil.thorium.Request, status: HttpStatus, actionResponse: ActionResponse): Try[HttpResponse] = 117 | Try: 118 | actionResponse match 119 | case null => throw new Exception(s"Null response in request [${req.uri}]") 120 | case hr: HttpResponse => hr 121 | case s: String => HttpResponse.of(status, MediaType.PLAIN_TEXT_UTF_8, s) 122 | case bytes: Array[Byte] => HttpResponse.of(status, Option(req.contentType).getOrElse(MediaType.ANY_TYPE), HttpData.wrap(bytes)) 123 | case is: InputStream => 124 | HttpResponse.of( 125 | ResponseHeaders.of(status, HttpHeaderNames.CONTENT_TYPE, Option(req.contentType).getOrElse(MediaType.ANY_TYPE)), 126 | StreamMessage.fromOutputStream(os => Using.resources(is, os) { (is, os) => is.transferTo(os) }) 127 | ) 128 | case result: Result => 129 | if Result.isRedirect(result.status.code()) then HttpResponse.ofRedirect(result.status, result.body.asInstanceOf[String]) 130 | else _toHttpResponse(req, result.status, result.body).get 131 | 132 | def convertActionResponseToHttpResponse(req: com.greenfossil.thorium.Request, actionResp: ActionResponse): HttpResponse = 133 | serverLogger.debug(s"Convert ActionResponse to HttpResponse.") 134 | (for { 135 | httpResp <- _toHttpResponse(req, HttpStatus.OK, actionResp) 136 | httpRespWithHeaders <- _addHttpResponseHeaders(req, actionResp, httpResp) 137 | } yield httpRespWithHeaders) 138 | .fold( 139 | ex => { 140 | serverLogger.error("Invoke Action error", ex) 141 | HttpResponse.ofFailure(ex) // allow exceptionHandlerFunctions and serverErrorHandler to kick in 142 | }, 143 | identity 144 | ) -------------------------------------------------------------------------------- /src/main/scala/com/greenfossil/thorium/MacroSupport.scala: -------------------------------------------------------------------------------- 1 | ///* 2 | // * Copyright 2022 Greenfossil Pte Ltd 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 | // 17 | //package com.greenfossil.thorium 18 | // 19 | //import scala.quoted.Quotes 20 | // 21 | //trait MacroSupport(globalDebug: Boolean): 22 | // 23 | // def findEnclosingTerm(using quotes: Quotes)(sym: quotes.reflect.Symbol): quotes.reflect.Symbol = 24 | // import quotes.reflect.* 25 | // sym match 26 | // case sym if sym.flags.is(Flags.Macro) => findEnclosingTerm(sym.owner) 27 | // case sym if !sym.isTerm => findEnclosingTerm(sym.owner) 28 | // case _ => sym 29 | // 30 | // def showStructure(using quotes:Quotes)(msg: String, x: quotes.reflect.Tree | List[quotes.reflect.Tree], debug: Boolean = globalDebug): Unit = 31 | // import quotes.reflect.* 32 | // if debug 33 | // then 34 | // x match 35 | // case xs: List[Tree @unchecked] => 36 | // println(s"$msg: ${xs.map(_.show(using Printer.TreeStructure))}") 37 | // 38 | // case term: Tree @unchecked => 39 | // println(s"$msg: ${term.show(using Printer.TreeStructure)}") 40 | // 41 | // def showCode(using quotes:Quotes)(msg: String, x: quotes.reflect.Tree | List[quotes.reflect.Tree], debug: Boolean = globalDebug): Unit = 42 | // import quotes.reflect.* 43 | // if debug 44 | // then 45 | // x match 46 | // case xs: List[Tree @unchecked] => 47 | // println(s"$msg: ${xs.map(_.show(using quotes.reflect.Printer.TreeAnsiCode))}") 48 | // 49 | // case term: Tree @unchecked => 50 | // println(s"$msg: ${term.show(using quotes.reflect.Printer.TreeAnsiCode)}") 51 | // 52 | // def show(using quotes:Quotes)(msg: String, x: quotes.reflect.Tree | List[quotes.reflect.Tree], debug: Boolean = globalDebug): Unit = 53 | // import quotes.reflect.* 54 | // if debug 55 | // then 56 | // x match 57 | // case xs: List[Tree @unchecked] => 58 | // println(s"===> [List] ${msg}") 59 | // println(s"Code - Size:${xs.size}") 60 | // println(" " + xs.map(_.show(using quotes.reflect.Printer.TreeAnsiCode))) 61 | // 62 | // println(s"Structure - Size:${xs.size}") 63 | // println(" " + xs.map(_.show(using Printer.TreeStructure))) 64 | // 65 | // case term: Tree @unchecked => 66 | // println(s"===> [Tree] ${msg}") 67 | // println(s"Symbol: ${term.symbol.flags.show}") 68 | // println(s"Code: ${term.show(using quotes.reflect.Printer.TreeAnsiCode)}") 69 | // println(s"Struct: ${term.show(using Printer.TreeStructure)}") 70 | -------------------------------------------------------------------------------- /src/main/scala/com/greenfossil/thorium/MultipartFormData.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | import com.linecorp.armeria.common.MediaType 20 | import com.linecorp.armeria.common.multipart.{AggregatedBodyPart, AggregatedMultipart, MultipartFile} 21 | 22 | import java.io.{File, InputStream} 23 | import java.nio.charset.Charset 24 | import java.nio.file.* 25 | import scala.util.Try 26 | 27 | case class MultipartFormData(aggMultipart: AggregatedMultipart, multipartUploadLocation: Path): 28 | import scala.jdk.CollectionConverters.* 29 | 30 | lazy val bodyPart: Seq[AggregatedBodyPart] = aggMultipart.bodyParts().asScala.toSeq 31 | 32 | lazy val names: List[String] = aggMultipart.names().asScala.toList 33 | 34 | lazy val asFormUrlEncoded: FormUrlEndcoded = 35 | val xs = for { 36 | name <- names 37 | part <- aggMultipart.fields(name).asScala 38 | content = 39 | if part.contentType().is(MediaType.PLAIN_TEXT) then 40 | part.content(Option(part.contentType().charset()).getOrElse(Charset.forName("UTF-8"))) 41 | else if part.filename() != null then 42 | part.filename() 43 | else null 44 | if content != null 45 | } yield 46 | (name, content) 47 | FormUrlEndcoded(xs.groupMap(_._1)(_._2)) 48 | 49 | private def saveFileTo( part: AggregatedBodyPart): Option[File] = 50 | Try { 51 | if !Files.exists(multipartUploadLocation) then multipartUploadLocation.toFile.mkdirs() 52 | val filePath = multipartUploadLocation.resolve(part.filename()) 53 | val is: InputStream = part.content().toInputStream 54 | Files.copy(is, filePath, StandardCopyOption.REPLACE_EXISTING) 55 | is.close() 56 | filePath.toFile 57 | }.toOption 58 | 59 | lazy val files: List[MultipartFile] = 60 | for { 61 | name <- names 62 | part <- aggMultipart.fields(name).asScala 63 | if part.filename() != null && !part.content().isEmpty 64 | file <- saveFileTo(part) 65 | } yield MultipartFile.of(name, part.filename(), file) 66 | 67 | /** 68 | * Find a file using the form name 69 | * @param formNameRegex - this is the alias of findFileOfFormName 70 | * @return 71 | */ 72 | def findFile(formNameRegex: String): Option[MultipartFile] = 73 | findFileOfFormName(formNameRegex) 74 | 75 | /** 76 | * Find a file using the form name 77 | * @param formNameRegex 78 | * @return 79 | */ 80 | def findFileOfFormName(formNameRegex: String): Option[MultipartFile] = 81 | files.find(file => file.name.matches(formNameRegex) && file.file().length() > 0) 82 | 83 | /** 84 | * Find a file using the actual loaded filename 85 | * @param fileNameRegex 86 | * @return 87 | */ 88 | def findFileOfFileName(fileNameRegex: String): Option[MultipartFile] = 89 | files.find(file => file.filename().matches(fileNameRegex) && file.file().length() > 0) -------------------------------------------------------------------------------- /src/main/scala/com/greenfossil/thorium/MultipartRequest.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | import com.linecorp.armeria.common.AggregatedHttpRequest 20 | import com.linecorp.armeria.server.ServiceRequestContext 21 | 22 | final class MultipartRequest(val multipartFormData: MultipartFormData, 23 | requestContext: ServiceRequestContext, 24 | aggregatedHttpRequest: AggregatedHttpRequest) extends Request(requestContext, aggregatedHttpRequest): 25 | 26 | export multipartFormData.{asFormUrlEncoded as _, *} 27 | 28 | override def asFormUrlEncoded: FormUrlEndcoded = multipartFormData.asFormUrlEncoded 29 | 30 | -------------------------------------------------------------------------------- /src/main/scala/com/greenfossil/thorium/Recaptcha.scala: -------------------------------------------------------------------------------- 1 | package com.greenfossil.thorium 2 | 3 | import com.greenfossil.commons.json.* 4 | import com.greenfossil.data.mapping.Mapping 5 | import com.greenfossil.data.mapping.Mapping.* 6 | import com.linecorp.armeria.common.MediaType 7 | import org.slf4j.LoggerFactory 8 | 9 | import java.net.http.{HttpClient, HttpRequest, HttpResponse} 10 | import java.net.{URI, URLEncoder} 11 | import java.nio.charset.StandardCharsets 12 | import java.time.{Duration, Instant} 13 | import scala.util.Try 14 | 15 | object Recaptcha: 16 | private val logger = LoggerFactory.getLogger("com.greenfossil.thorium.recaptcha") 17 | 18 | def onVerified(using request: Request)(predicate: Recaptcha => Boolean)(result: => ActionResponse): ActionResponse = 19 | verify.fold( 20 | ex => Unauthorized("Access denied."), 21 | recaptcha => 22 | if !predicate(recaptcha) then 23 | logger.warn(s"Unauthorized access. recaptcha:${recaptcha}, method:${request.method}, uri:${request.uri} path:${request.path} content-type:${request.contentType}") 24 | Unauthorized("Access denied.") 25 | else 26 | request.requestContext.setAttr(RequestAttrs.RecaptchaResponse, recaptcha) 27 | result 28 | ) 29 | 30 | 31 | def verify(using request: Request): Try[Recaptcha] = 32 | verify(request.config.httpConfiguration.recaptchaConfig.secretKey) 33 | 34 | def verify(secretKey: String)(using request: Request): Try[Recaptcha] = 35 | Mapping("g-recaptcha-response", seq[String]) 36 | .bindFromRequest() 37 | .fold( 38 | ex => Try(throw new IllegalArgumentException(s"Binding exception - ${ex.errors.map(_.message).mkString(",")}")), 39 | xs => siteVerify(xs, secretKey, request.httpConfiguration.recaptchaConfig.timeout) 40 | ) 41 | 42 | def siteVerify(captchaValues: Seq[String], recaptchaSecret: String, timeoutMSec: Int): Try[Recaptcha] = 43 | captchaValues.find(_.nonEmpty) match 44 | case Some(token) => siteVerify(token, recaptchaSecret, timeoutMSec) 45 | case None => Try(throw new IllegalArgumentException("No recaptcha token found")) 46 | 47 | 48 | /** 49 | * https://developers.google.com/recaptcha/docs/verify 50 | * 51 | * @param recaptchaToken 52 | * @param recaptchaSecret 53 | * @return - json 54 | * Recaptcha V2 response format 55 | * { 56 | * "success": true|false, 57 | * "action": string // the action name for this request (important to verify) 58 | * "challenge_ts": timestamp, // timestamp of the challenge load (ISO format yyyy-MM-dd'T'HH:mm:ssZZ) 59 | * "hostname": string, // the hostname of the site where the reCAPTCHA was solved 60 | * "error-codes": [...] // optional 61 | * } 62 | * 63 | * Recaptcha V3 response format 64 | * { 65 | * "success": true|false, // whether this request was a valid reCAPTCHA token for your site 66 | * "score": number // the score for this request (0.0 - 1.0) 67 | * "action": string // the action name for this request (important to verify) 68 | * "challenge_ts": timestamp, // timestamp of the challenge load (ISO format yyyy-MM-dd'T'HH:mm:ssZZ) 69 | * "hostname": string, // the hostname of the site where the reCAPTCHA was solved 70 | * "error-codes": [...] // optional 71 | * } 72 | */ 73 | def siteVerify(recaptchaToken: String, recaptchaSecret: String, timeoutMSec: Int): Try[Recaptcha] = 74 | //POST to googleVerifyURL using query-params 75 | Try: 76 | if recaptchaToken == null || recaptchaToken.isBlank then throw new IllegalArgumentException("recaptchaToken missing") 77 | else 78 | val content = s"secret=${URLEncoder.encode(recaptchaSecret, StandardCharsets.UTF_8)}&response=${URLEncoder.encode(recaptchaToken, StandardCharsets.UTF_8)}" 79 | logger.debug(s"siteVerify content:$content") 80 | 81 | val resp = HttpClient.newBuilder().connectTimeout(Duration.ofMillis(timeoutMSec)).build() 82 | .send( 83 | HttpRequest.newBuilder(URI.create("https://www.google.com/recaptcha/api/siteverify")) 84 | .POST(HttpRequest.BodyPublishers.ofString(content)) 85 | .header("content-type", MediaType.FORM_DATA.toString) 86 | .build, 87 | HttpResponse.BodyHandlers.ofString() 88 | ) 89 | 90 | /* 91 | * Json format - success, challenge_ts, hostname, score, action 92 | */ 93 | if resp.statusCode() != 200 then 94 | val msg = s"Recaptcha response error ${resp.statusCode()} -${resp.body}" 95 | logger.error(msg) 96 | throw IllegalStateException(msg) 97 | else 98 | logger.debug(s"siteVerify response - status:${resp.statusCode()}, content-type:${resp.headers().firstValue("content-type")}, content:${resp.body()}") 99 | new Recaptcha(Json.parse(resp.body())) 100 | 101 | case class Recaptcha(jsValue: JsValue): 102 | def success:Boolean = 103 | (jsValue \ "success").asOpt[Boolean] 104 | .getOrElse(false) 105 | 106 | def fail: Boolean = !success 107 | 108 | /** 109 | * Only for V3, for V2 is None 110 | * @return 111 | */ 112 | def scoreOpt:Option[Double] = 113 | (jsValue \ "score").asOpt[Double] 114 | 115 | def score: Double = scoreOpt.getOrElse(if success then 1 else 0) 116 | 117 | def actionOpt: Option[String] = 118 | (jsValue \ "action").asOpt[String] 119 | 120 | def challengeTS: Option[Instant] = 121 | (jsValue \ "challenge_ts").asOpt[String] 122 | .flatMap(ts => Try(Instant.parse(ts)).toOption) 123 | 124 | def hostname: String = 125 | (jsValue \ "hostname").asOpt[String].getOrElse("") 126 | 127 | def errorCodes: List[String] = 128 | (jsValue \ "error-codes").asOpt[List[String]] 129 | .getOrElse(Nil) 130 | -------------------------------------------------------------------------------- /src/main/scala/com/greenfossil/thorium/RequestAttrs.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | import java.time.ZoneId 20 | 21 | object RequestAttrs: 22 | import io.netty.util.AttributeKey 23 | val TZ = AttributeKey.valueOf[ZoneId]("tz") 24 | val Session = AttributeKey.valueOf[Session]("session") 25 | val Flash = AttributeKey.valueOf[Flash]("flash") 26 | val Config = AttributeKey.valueOf[Configuration]("config") 27 | val Request = AttributeKey.valueOf[Request]("request") 28 | val CSRFToken = AttributeKey.valueOf[String]("csrf-token") 29 | val RecaptchaResponse = AttributeKey.valueOf[Recaptcha]("recaptcha-response") -------------------------------------------------------------------------------- /src/main/scala/com/greenfossil/thorium/ResponseHeader.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | import java.time.ZoneOffset 20 | import java.time.format.DateTimeFormatter 21 | import scala.collection.immutable.TreeMap 22 | 23 | object ResponseHeader: 24 | val basicDateFormatPattern = "EEE, dd MMM yyyy HH:mm:ss" 25 | val httpDateFormat: DateTimeFormatter = 26 | DateTimeFormatter 27 | .ofPattern(basicDateFormatPattern + " 'GMT'") 28 | .withLocale(java.util.Locale.ENGLISH) 29 | .withZone(ZoneOffset.UTC) 30 | 31 | def apply(headers: Map[String, String]): ResponseHeader = apply(headers, null) 32 | 33 | private object CaseInsensitiveOrdered extends Ordering[String]: 34 | def compare(left: String, right: String): Int = 35 | left.compareToIgnoreCase(right) 36 | 37 | def apply(headers: Map[String, String], reasonPhrase: String): ResponseHeader = 38 | val ciHeaders = TreeMap[String, String]()(using CaseInsensitiveOrdered) ++ headers 39 | new ResponseHeader(ciHeaders, Option(reasonPhrase)) 40 | 41 | case class ResponseHeader(headers: TreeMap[String, String], reasonPhrase:Option[String] = None) -------------------------------------------------------------------------------- /src/main/scala/com/greenfossil/thorium/Resultable.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | import com.greenfossil.commons.json.Json 20 | import com.linecorp.armeria.common.{Cookie, HttpStatus, MediaType} 21 | import io.netty.util.AsciiString 22 | 23 | import java.io.InputStream 24 | import java.time.ZonedDateTime 25 | 26 | trait Resultable[A <: String | InputStream | Array[Byte] ]: 27 | extension (a: A) 28 | def withHeaders(headers: (String | AsciiString, String)*): Result 29 | 30 | def withDateHeaders(headers: (String, ZonedDateTime)*): Result 31 | 32 | def discardingHeader(name: String): Result 33 | 34 | def as(contentType: MediaType): Result 35 | 36 | def as(status: HttpStatus, contentType: MediaType): Result 37 | 38 | def withCookies(cookies: Cookie*): Result 39 | 40 | def discardingCookies[B <: String | Cookie](cookies: B*)(using request: Request): Result 41 | 42 | def withSession(session: Session): Result 43 | 44 | def withSession(sessions: (String, String)*): Result 45 | 46 | def withNewSession: Result 47 | 48 | def flashing(flash: Flash): Result 49 | 50 | def flashing(values: (String, String)*): Result 51 | 52 | def session(using request: Request): Session 53 | 54 | def addingToSession(values: (String, String)*)(using request: Request): Result 55 | 56 | def removingFromSession(keys: String*)(using request: Request): Result 57 | 58 | 59 | given StringResultable: Resultable[String] with 60 | extension (s: String) 61 | def withHeaders(headers: (String | AsciiString, String)*): Result = 62 | Result(s).withHeaders(headers *) 63 | 64 | def withDateHeaders(headers: (String, ZonedDateTime)*): Result = 65 | Result(s).withDateHeaders(headers *) 66 | 67 | def discardingHeader(name: String): Result = 68 | Result(s).discardingHeader(name) 69 | 70 | def as(contentType: MediaType): Result = 71 | Result(s).as(contentType) 72 | 73 | def as(status: HttpStatus, contentType: MediaType): Result = 74 | Result(s).as(status, contentType) 75 | 76 | def withCookies(cookies: Cookie*): Result = 77 | Result(s).withCookies(cookies*) 78 | 79 | def discardingCookies[A <: String | Cookie](cookies: A*)(using request: Request): Result = 80 | Result(s).discardingCookies(cookies*) 81 | 82 | def withSession(session: Session): Result = 83 | Result(s).withSession(session) 84 | 85 | def withSession(sessions: (String, String)*): Result = 86 | Result(s).withSession(sessions*) 87 | 88 | def withNewSession: Result = 89 | Result(s).withSession() 90 | 91 | def flashing(flash: Flash): Result = 92 | Result(s).flashing(flash) 93 | 94 | def flashing(values: (String, String)*): Result = 95 | Result(s).flashing(values*) 96 | 97 | def session(using request: Request): Session = 98 | Result(s).session 99 | 100 | def addingToSession(values: (String, String)*)(using request: Request): Result = 101 | Result(s).addingToSession(values *) 102 | 103 | def removingFromSession(keys: String*)(using request: Request): Result = 104 | Result(s).removingFromSession(keys *) 105 | 106 | def jsonPrettyPrint: String = 107 | Json.prettyPrint(Json.parse(s)) 108 | 109 | given InputStreamResultable: Resultable[InputStream] with 110 | 111 | extension (is: InputStream) 112 | def withHeaders(headers: (String | AsciiString, String)*): Result = 113 | Result(is).withHeaders(headers*) 114 | 115 | def withDateHeaders(headers: (String, ZonedDateTime)*): Result = 116 | Result(is).withDateHeaders(headers *) 117 | 118 | def discardingHeader(name: String): Result = 119 | Result(is).discardingHeader(name) 120 | 121 | def as(contentType: MediaType): Result = 122 | Result(is).as(contentType) 123 | 124 | def as(status: HttpStatus, contentType: MediaType): Result = 125 | Result(is).as(status, contentType) 126 | 127 | def withCookies(cookies: Cookie*): Result = 128 | Result(is).withCookies(cookies *) 129 | 130 | def discardingCookies[A <: String | Cookie](cookies: A*)(using request: Request): Result = 131 | Result(is).discardingCookies(cookies *) 132 | 133 | def withSession(session: Session): Result = 134 | Result(is).withSession(session) 135 | 136 | def withSession(sessions: (String, String)*): Result = 137 | Result(is).withSession(sessions *) 138 | 139 | def withNewSession: Result = 140 | Result(is).withSession() 141 | 142 | def flashing(flash: Flash): Result = 143 | Result(is).flashing(flash) 144 | 145 | def flashing(values: (String, String)*): Result = 146 | Result(is).flashing(values *) 147 | 148 | def session(using request: Request): Session = 149 | Result(is).session 150 | 151 | def addingToSession(values: (String, String)*)(using request: Request): Result = 152 | Result(is).addingToSession(values *) 153 | 154 | def removingFromSession(keys: String*)(using request: Request): Result = 155 | Result(is).removingFromSession(keys *) 156 | 157 | given ArrayBytesResultable: Resultable[Array[Byte]] with 158 | 159 | extension (bytes: Array[Byte]) 160 | def withHeaders(headers: (String | AsciiString, String)*): Result = 161 | Result(bytes).withHeaders(headers*) 162 | 163 | def withDateHeaders(headers: (String, ZonedDateTime)*): Result = 164 | Result(bytes).withDateHeaders(headers *) 165 | 166 | def discardingHeader(name: String): Result = 167 | Result(bytes).discardingHeader(name) 168 | 169 | def as(contentType: MediaType): Result = 170 | Result(bytes).as(contentType) 171 | 172 | def as(status: HttpStatus, contentType: MediaType): Result = 173 | Result(bytes).as(status, contentType) 174 | 175 | def withCookies(cookies: Cookie*): Result = 176 | Result(bytes).withCookies(cookies *) 177 | 178 | def discardingCookies[A <: String | Cookie](cookies: A*)(using request: Request): Result = 179 | Result(bytes).discardingCookies(cookies *) 180 | 181 | def withSession(session: Session): Result = 182 | Result(bytes).withSession(session) 183 | 184 | def withSession(sessions: (String, String)*): Result = 185 | Result(bytes).withSession(sessions *) 186 | 187 | def withNewSession: Result = 188 | Result(bytes).withSession() 189 | 190 | def flashing(flash: Flash): Result = 191 | Result(bytes).flashing(flash) 192 | 193 | def flashing(values: (String, String)*): Result = 194 | Result(bytes).flashing(values *) 195 | 196 | def session(using request: Request): Session = 197 | Result(bytes).session 198 | 199 | def addingToSession(values: (String, String)*)(using request: Request): Result = 200 | Result(bytes).addingToSession(values *) 201 | 202 | def removingFromSession(keys: String*)(using request: Request): Result = 203 | Result(bytes).removingFromSession(keys *) -------------------------------------------------------------------------------- /src/main/scala/com/greenfossil/thorium/Session.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | import java.time.Instant 20 | import scala.annotation.targetName 21 | 22 | inline val SessionID = "sessionID" 23 | 24 | object Session: 25 | def apply(): Session = 26 | Session(Map.empty.withDefaultValue(""), Instant.now) 27 | 28 | def apply(data: Map[String, String]): Session = 29 | Session(data.withDefaultValue(""), Instant.now) 30 | 31 | 32 | /** 33 | * 34 | * HTTP Session. 35 | * Session data are encoded into an HTTP cookie, and can only contain simple String values. 36 | * @param data 37 | */ 38 | case class Session(data: Map[String, String], created: Instant): 39 | export data.{- as _, apply as _, *} 40 | 41 | def idOpt: Option[String] = data.get(SessionID) 42 | 43 | def apply(key: String): String = data(key) 44 | 45 | /** 46 | * Returns a new session with the given key-value pair added. 47 | * 48 | * For example: 49 | * {{{ 50 | * session + ("username" -> "bob") 51 | * }}} 52 | * 53 | * @param kv the key-value pair to add 54 | * @return the modified session 55 | */ 56 | @targetName("add") 57 | def +(kv: (String, String)): Session = 58 | require(kv._2 != null, s"Session value for ${kv._1} cannot be null") 59 | copy(data + kv) 60 | 61 | @targetName("add") 62 | def +(name: String, value: String): Session = 63 | this.+((name, value)) 64 | 65 | @targetName("add") 66 | def +(newSession: Session): Session = 67 | copy(data = data ++ newSession.data) 68 | 69 | @targetName("add") 70 | def +(newSession: Map[String, String]): Session = 71 | copy(data = data ++ newSession) 72 | 73 | /** 74 | * Returns a new session with elements added from the given `Iterable`. 75 | * 76 | * @param kvs an `Iterable` containing key-value pairs to add. 77 | */ 78 | @targetName("concat") 79 | def ++(kvs: Iterable[(String, String)]): Session = 80 | for ((k, v) <- kvs) require(v != null, s"Session value for $k cannot be null") 81 | copy(data ++ kvs) 82 | 83 | /** 84 | * Returns a new session with the given key removed. 85 | * 86 | * For example: 87 | * {{{ 88 | * session - "username" 89 | * }}} 90 | * 91 | * @param key the key to remove 92 | * @return the modified session 93 | */ 94 | @targetName("minus") 95 | def -(key: String): Session = copy(data - key) 96 | 97 | /** 98 | * Returns a new session with the given keys removed. 99 | * 100 | * For example: 101 | * {{{ 102 | * session -- Seq("username", "name") 103 | * }}} 104 | * 105 | * @param keys the keys to remove 106 | * @return the modified session 107 | */ 108 | @targetName("diff") 109 | def --(keys: Iterable[String]): Session = copy(data -- keys) -------------------------------------------------------------------------------- /src/main/scala/com/greenfossil/thorium/decorators/RecaptchaGuardModule.scala: -------------------------------------------------------------------------------- 1 | 2 | package com.greenfossil.thorium.decorators 3 | 4 | 5 | import com.greenfossil.thorium.{Recaptcha, RequestAttrs} 6 | import com.linecorp.armeria.common.{HttpMethod, HttpRequest} 7 | import com.linecorp.armeria.server.{HttpService, ServiceRequestContext} 8 | import org.slf4j.LoggerFactory 9 | 10 | import java.util.concurrent.CompletableFuture 11 | import scala.util.{Failure, Success} 12 | 13 | object RecaptchaGuardModule: 14 | 15 | private val DefaultAllPathsVerifyPredicate = 16 | (_: String, _: ServiceRequestContext) => true 17 | 18 | def apply():RecaptchaGuardModule = 19 | new RecaptchaGuardModule(DefaultAllPathsVerifyPredicate) 20 | 21 | /** 22 | * pathVerifyPredicate is true implies that the path's will be verified against Recaptcha service. 23 | * If false, implies that Recaptcha will not be verified. 24 | * @param pathVerifyPredicate 25 | * @return 26 | */ 27 | def apply(pathVerifyPredicate: (String, ServiceRequestContext) => Boolean): RecaptchaGuardModule = 28 | new RecaptchaGuardModule(pathVerifyPredicate) 29 | 30 | /** 31 | * pathVerifyPredicate is true implies that the path's will be verified against Recaptcha service. 32 | * If false, implies that Recaptcha will not be verified. 33 | * @param pathVerifyPredicate - (Request Path, ServiceRequestContext) => Boolean 34 | */ 35 | class RecaptchaGuardModule(pathVerifyPredicate: (String, ServiceRequestContext) => Boolean) extends ThreatGuardModule: 36 | 37 | protected val logger = LoggerFactory.getLogger("com.greenfossil.thorium.recaptcha") 38 | override def isSafe(delegate: HttpService, ctx: ServiceRequestContext, req: HttpRequest): CompletableFuture[Boolean] = 39 | if HttpMethod.GET.equals(req.method) || !pathVerifyPredicate(ctx.path(), ctx) then CompletableFuture.completedFuture(true) 40 | else 41 | val config = ctx.attr(RequestAttrs.Config) 42 | val recaptchaSecretKey = config.httpConfiguration.recaptchaConfig.secretKey 43 | val recaptchaTokenName = config.httpConfiguration.recaptchaConfig.tokenName 44 | logger.trace(s"Recaptcha isSafe - uri:${ctx.uri}, method:${req.method}, recaptchaTokenName:$recaptchaTokenName, ecaptchaV3SecretKey = $recaptchaSecretKey") 45 | extractTokenValue(ctx, recaptchaTokenName) 46 | .thenApply: recaptchaToken => 47 | val isSafe = Recaptcha.siteVerify(recaptchaToken, recaptchaSecretKey, config.httpConfiguration.recaptchaConfig.timeout) match 48 | case Failure(exception) => 49 | logger.warn("isSafe excception", exception) 50 | false 51 | case Success(recaptchaResponse) => 52 | logger.debug(s"recaptchaResponse = $recaptchaResponse") 53 | ctx.setAttr(RequestAttrs.RecaptchaResponse, recaptchaResponse) 54 | recaptchaResponse.success 55 | val msg = s"Request isSafe:$isSafe, method:${req.method}, uri:${req.uri} path:${req.path} content-type:${req.contentType}" 56 | if isSafe then logger.debug(msg) 57 | else 58 | logger.warn(msg) 59 | req.headers.forEach((key, value) => logger.warn(s"Header:$key value:$value")) 60 | isSafe -------------------------------------------------------------------------------- /src/main/scala/com/greenfossil/thorium/decorators/ThreatGuardModule.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | */ 17 | 18 | package com.greenfossil.thorium.decorators 19 | 20 | import com.greenfossil.thorium.FormUrlEncodedParser 21 | import com.linecorp.armeria.common.multipart.Multipart 22 | import com.linecorp.armeria.common.{HttpMethod, HttpRequest, MediaType} 23 | import com.linecorp.armeria.server.{HttpService, ServiceRequestContext} 24 | import org.slf4j.Logger 25 | 26 | import java.util.concurrent.CompletableFuture 27 | import scala.util.Using 28 | 29 | class AndThreatGuardModule(moduleA: ThreatGuardModule, moduleB: ThreatGuardModule) extends ThreatGuardModule: 30 | 31 | override protected val logger: Logger = null 32 | 33 | override def isSafe(delegate: HttpService, ctx: ServiceRequestContext, req: HttpRequest): CompletableFuture[Boolean] = 34 | moduleA.isSafe(delegate, ctx, req) 35 | .thenCombine(moduleB.isSafe(delegate, ctx, req), (isSafeA, isSafeB) => isSafeA && isSafeB) 36 | 37 | class OrThreatGuardModule(moduleA: ThreatGuardModule, moduleB: ThreatGuardModule) extends ThreatGuardModule: 38 | override protected val logger: Logger = null 39 | 40 | override def isSafe(delegate: HttpService, ctx: ServiceRequestContext, req: HttpRequest): CompletableFuture[Boolean] = 41 | moduleA.isSafe(delegate, ctx, req) 42 | .thenCombine(moduleB.isSafe(delegate, ctx, req), (isSafeA, isSafeB) => isSafeA || isSafeB) 43 | 44 | trait ThreatGuardModule: 45 | 46 | protected val logger: Logger 47 | 48 | //Need to allow, on success response callback 49 | def isSafe(delegate: HttpService, ctx: ServiceRequestContext, req: HttpRequest): CompletableFuture[Boolean] 50 | 51 | def and(nextModule: ThreatGuardModule): ThreatGuardModule = AndThreatGuardModule(this, nextModule) 52 | 53 | def or(nextModule: ThreatGuardModule): ThreatGuardModule = OrThreatGuardModule(this, nextModule) 54 | 55 | def isAssetPath(ctx: ServiceRequestContext): Boolean = 56 | ctx.request().path().startsWith("/assets") && HttpMethod.GET == ctx.method() 57 | 58 | protected def extractTokenValue(ctx: ServiceRequestContext, tokenName: String): CompletableFuture[String] = 59 | val mediaType = ctx.request().contentType() 60 | val futureResp = new CompletableFuture[String]() 61 | logger.debug(s"Extract token from request - content-type:${ctx.request().contentType()}") 62 | if mediaType == null then 63 | logger.warn("Null media type found. Treat token as null") 64 | futureResp.complete(null) 65 | else if mediaType.is(MediaType.FORM_DATA) then 66 | ctx.request() 67 | .aggregate() 68 | .thenAccept: aggReq => 69 | ctx.blockingTaskExecutor().execute(() => { 70 | //Extract token from FormData 71 | val form = FormUrlEncodedParser.parse(aggReq.contentUtf8()) 72 | val token = form.get(tokenName).flatMap(_.find(!_.isBlank)).orNull 73 | logger.trace(s"Found Token:$token, content-type:$mediaType.") 74 | futureResp.complete(token) 75 | }) 76 | else if mediaType.isMultipart then 77 | ctx.request() 78 | .aggregate() 79 | .thenAccept: aggReg => 80 | ctx.blockingTaskExecutor().execute(() => { 81 | //Extract token from Multipart 82 | Multipart.from(aggReg.toHttpRequest.toDuplicator.duplicate()) 83 | .aggregate() 84 | .thenAccept: multipart => 85 | val partOpt = Using.resource(multipart.fields(tokenName).stream())(_.filter(!_.contentUtf8().isBlank).findFirst()) 86 | val token = if partOpt.isEmpty then null else partOpt.get().contentUtf8() 87 | logger.trace(s"Found Token:$token, content-type:$mediaType.") 88 | futureResp.complete(token) 89 | }) 90 | 91 | else { 92 | logger.info(s"Token found unsupported for content-type:$mediaType.") 93 | futureResp.complete(null) 94 | } 95 | futureResp 96 | -------------------------------------------------------------------------------- /src/main/scala/com/greenfossil/thorium/decorators/ThreatGuardModuleDecoratingFunction.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | */ 17 | 18 | package com.greenfossil.thorium.decorators 19 | 20 | import com.greenfossil.thorium 21 | import com.greenfossil.thorium.{CookieUtil, *} 22 | import com.linecorp.armeria.common.{Request as _, *} 23 | import com.linecorp.armeria.server.{DecoratingHttpServiceFunction, HttpService, ServiceRequestContext} 24 | import org.slf4j.LoggerFactory 25 | 26 | object ThreatGuardModuleDecoratingFunction: 27 | 28 | private val logger = LoggerFactory.getLogger("com.greenfossil.thorium.csrf") 29 | 30 | private def accessDeniedHtml: String = 31 | s""" 32 | | 33 | | 34 | | Unauthorized Access 35 | | 36 | | 37 | |

Access Denied

38 | | 39 | | 40 | |""".stripMargin 41 | 42 | def apply(module: ThreatGuardModule): ThreatGuardModuleDecoratingFunction = 43 | new ThreatGuardModuleDecoratingFunction(module, (_, _) => accessDeniedHtml) 44 | 45 | class ThreatGuardModuleDecoratingFunction(module: ThreatGuardModule, accessDeniedFn: (Configuration, ServiceRequestContext) => String) extends DecoratingHttpServiceFunction: 46 | 47 | import ThreatGuardModuleDecoratingFunction.logger 48 | 49 | private def accessDeniedResponse(svcReqContext: ServiceRequestContext): HttpResponse = 50 | val config = svcReqContext.attr(RequestAttrs.Config) 51 | 52 | //Remove session and assume it is a CSRF attack 53 | import config.httpConfiguration.* 54 | 55 | //discard all cookies 56 | val discardCookieNames = List(sessionConfig.cookieName, csrfConfig.cookieName, flashConfig.cookieName, "tz") 57 | logger.info(s"""Discard cookies:${discardCookieNames.mkString("[",",","]")}""") 58 | 59 | val headers = 60 | ResponseHeaders.builder(HttpStatus.UNAUTHORIZED) 61 | .contentType(MediaType.HTML_UTF_8) 62 | .cookies(CookieUtil.bakeDiscardCookies(config.httpConfiguration.cookieConfig, discardCookieNames) *) 63 | .build() 64 | 65 | val html = accessDeniedFn(config, svcReqContext) 66 | HttpResponse.of(headers, HttpData.ofUtf8(html)) 67 | 68 | override def serve(delegate: HttpService, svcReqContext: ServiceRequestContext, req: HttpRequest): HttpResponse = 69 | HttpResponse.of: 70 | module.isSafe(delegate, svcReqContext, req) 71 | .handle: (isSafe, ex) => 72 | if ex != null then 73 | logger.warn("Exception raised", ex) 74 | accessDeniedResponse(svcReqContext) 75 | else if !isSafe then 76 | logger.warn("IsSafe validation fails") 77 | accessDeniedResponse(svcReqContext) 78 | else 79 | logger.debug(s"Request isSafe... forward request to delegate:${delegate}") 80 | delegate.serve(svcReqContext, req) 81 | -------------------------------------------------------------------------------- /src/test/resources/application.conf: -------------------------------------------------------------------------------- 1 | app { 2 | env = Test 3 | http { 4 | port = 0 5 | secret.key = "this is thorium!" 6 | recaptchaV2.secretKey = "" 7 | recaptchaV2.siteKey = "" 8 | recaptchaV3.secretKey = "" 9 | recaptchaV3.siteKey = "" 10 | recaptcha = ${app.http.recaptchaV2} 11 | 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/test/resources/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Greenfossil/thorium/1a068bdbf46a1d66ae34170e4e5f10f8d37da5ba/src/test/resources/favicon.png -------------------------------------------------------------------------------- /src/test/resources/image-no-ext: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Greenfossil/thorium/1a068bdbf46a1d66ae34170e4e5f10f8d37da5ba/src/test/resources/image-no-ext -------------------------------------------------------------------------------- /src/test/resources/logback-test.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 19 | 20 | 21 | %-5level %logger{36} - [%thread] - %msg%n 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /src/test/scala/com/greenfossil/thorium/AESUtilSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | import java.util.Base64 20 | 21 | @SerialVersionUID(1L) 22 | case class User(id: Long, name: String) extends Serializable 23 | 24 | class AESUtilSuite extends munit.FunSuite { 25 | 26 | val ALGO = AESUtil.AES_CTR_NOPADDING 27 | 28 | test("Base64 Encrypt then Decrypt using generateKey") { 29 | val text = "thorium 钍" 30 | val key = AESUtil.generateKey(128) 31 | val ivSpec = AESUtil.generateIV 32 | val algo = ALGO 33 | val cipherText = AESUtil.encrypt(text, key, algo, ivSpec, Base64.getEncoder) 34 | val plainText = AESUtil.decrypt(cipherText, key, algo, ivSpec, Base64.getDecoder) 35 | assertNoDiff(plainText, text) 36 | } 37 | 38 | test("Base64 Encrypt then decrypt using Password"){ 39 | val text = "Thorium 钍" 40 | val password = "thorium" 41 | val salt = "12345678" 42 | val key = AESUtil.getSaltedKeyFromPassword(password, salt) 43 | val ivSpec = AESUtil.generateIV 44 | val algo = ALGO 45 | val cipherText = AESUtil.encrypt(text, key, algo, ivSpec, Base64.getEncoder) 46 | val plainText = AESUtil.decrypt(cipherText, key, algo, ivSpec, Base64.getDecoder) 47 | assertNoDiff(plainText, text) 48 | } 49 | 50 | test("URLBase64 Encrypt then Decrypt using generateKey") { 51 | val text = "thorium 钍" 52 | val key = AESUtil.generateKey(128) 53 | val ivSpec = AESUtil.generateIV 54 | val algo = ALGO 55 | val cipherText = AESUtil.encrypt(text, key, algo, ivSpec, Base64.getUrlEncoder) 56 | val plainText = AESUtil.decrypt(cipherText, key, algo, ivSpec, Base64.getUrlDecoder) 57 | assertNoDiff(plainText, text) 58 | } 59 | 60 | test("URLBase64 Encrypt then decrypt using Password") { 61 | val input = "Thorium 钍" 62 | val password = "thorium" 63 | val salt = "12345678" 64 | val key = AESUtil.getSaltedKeyFromPassword(password, salt) 65 | val ivSpec = AESUtil.generateIV 66 | val algo = ALGO 67 | val cipherText = AESUtil.encrypt(input, key, algo, ivSpec, Base64.getUrlEncoder) 68 | val plainText = AESUtil.decrypt(cipherText, key, algo, ivSpec, Base64.getUrlDecoder) 69 | assertNoDiff(plainText, input) 70 | } 71 | 72 | test("Encrypt then decrypt of an object") { 73 | val homer = User(42, "Homer") 74 | val key = AESUtil.generateKey(128) 75 | val iv = AESUtil.generateIV 76 | val algo = ALGO 77 | val sealedObject = AESUtil.encryptObject(homer, key, algo, iv) 78 | val obj = AESUtil.decryptObject[User](sealedObject, key, algo, iv) 79 | assertEquals(obj, homer) 80 | } 81 | 82 | test("Sign and verify object") { 83 | val homer = User(42, "Homer") 84 | val algo = "DSA" 85 | val keyPair = AESUtil.generateKeyPair(algo, 1024) 86 | val signedObj = AESUtil.signObject(homer, keyPair.getPrivate, algo) 87 | assertEquals(AESUtil.verifyObject(signedObj, keyPair.getPublic, algo), true) 88 | } 89 | 90 | test("encrypt/decrypt with Derived Key") { 91 | val input = "Thorium 钍" 92 | val key = "thorium" 93 | val cipherText = AESUtil.encryptWithEmbeddedIV(input, key, Base64.getEncoder) 94 | val plainText = AESUtil.decryptWithEmbeddedIV(cipherText, key, Base64.getDecoder) 95 | assertNoDiff(plainText, input) 96 | } 97 | 98 | test("encrypt/decrypt with Derived Key") { 99 | val input = "Thorium 钍" 100 | val key = "thorium" 101 | val cipherText = AESUtil.encryptWithEmbeddedIV(input, key, AESUtil.AES_GCM_NOPADDING, Base64.getEncoder) 102 | val plainText = AESUtil.decryptWithEmbeddedIV(cipherText, key, AESUtil.AES_GCM_NOPADDING, Base64.getDecoder) 103 | assertNoDiff(plainText, input) 104 | } 105 | 106 | test("SecretKey to Base64 and back conversion") { 107 | // Generate a 256-bit key 108 | val originalKey = AESUtil.generateKey(256) 109 | 110 | // Convert SecretKey to Base64 111 | val base64Key: String = AESUtil.secretKeyToBase64(originalKey, Base64.getEncoder) 112 | 113 | // Convert Base64 back to SecretKey 114 | val decodedKey = AESUtil.base64ToSecretKey(base64Key, Base64.getDecoder) 115 | 116 | // Verify the conversion 117 | assertEquals(originalKey.getEncoded.toSeq, decodedKey.getEncoded.toSeq) 118 | } 119 | 120 | test("generateBase64Key") { 121 | val base64Key = AESUtil.generateBase64Key(256, Base64.getEncoder) 122 | println(s"base64Key.length = ${base64Key.length}") 123 | println(s"base64Key = ${base64Key}") 124 | } 125 | 126 | test("Base64Encoding with and without padding"){ 127 | val bytes = "".getBytes 128 | val paddingEncode = Base64.getEncoder.encodeToString(bytes) 129 | val withoutPaddingEncode = Base64.getEncoder.withoutPadding().encodeToString(bytes) 130 | println(s"paddingEncode = ${paddingEncode} len:${paddingEncode.length}") 131 | println(s"withoutPadding = ${withoutPaddingEncode} len:${withoutPaddingEncode.length}") 132 | } 133 | 134 | } 135 | -------------------------------------------------------------------------------- /src/test/scala/com/greenfossil/thorium/AccessLoggerSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | import ch.qos.logback.classic.spi.ILoggingEvent 20 | import ch.qos.logback.classic.{Level, Logger} 21 | import ch.qos.logback.core.read.ListAppender 22 | import com.greenfossil.commons.json.{JsObject, Json} 23 | import com.linecorp.armeria.common.MediaType 24 | import com.linecorp.armeria.server.annotation.{Get, Param} 25 | import munit.IgnoreSuite 26 | import org.slf4j.LoggerFactory 27 | 28 | import java.net.{URI, http} 29 | import java.time.Duration 30 | import java.util.Optional 31 | import scala.language.implicitConversions 32 | 33 | private object LoggerService: 34 | @Get("/log-appender") 35 | def logAppender(@Param msg:String)(req: Request) = 36 | s"content:$msg" 37 | end LoggerService 38 | 39 | @IgnoreSuite 40 | class AccessLoggerSuite extends munit.FunSuite: 41 | 42 | var server: Server = null 43 | 44 | //List appender for Logging events 45 | lazy val logger: Logger = LoggerFactory.getLogger("com.linecorp.armeria.logging.access").asInstanceOf[ch.qos.logback.classic.Logger] 46 | 47 | lazy val listAppender: ListAppender[ILoggingEvent] = new ListAppender() 48 | 49 | lazy val logs: java.util.List[ILoggingEvent] = listAppender.list 50 | 51 | override def beforeAll(): Unit = 52 | listAppender.start() 53 | logger.addAppender(listAppender) 54 | logger.setLevel(Level.INFO) 55 | server= Server(0).addServices(LoggerService).start() 56 | 57 | override def afterAll(): Unit = 58 | server.stop() 59 | listAppender.stop() 60 | 61 | 62 | test("Request Response Access Logging".flaky) { 63 | 64 | http.HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build().send( 65 | http.HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/log-appender?msg=HelloWorld!")) 66 | .header("Content-Type", MediaType.PLAIN_TEXT.toString) 67 | .build(), 68 | http.HttpResponse.BodyHandlers.ofString() 69 | ) 70 | 71 | Thread.sleep(2000) 72 | def findEvent(dir: String, method: String, path: String): Optional[ILoggingEvent] = 73 | logs.stream() 74 | .filter(x => x.getMessage.contains(dir) && x.getMessage.contains(method) && x.getMessage.contains(path)) 75 | .findFirst() 76 | 77 | import util.control.Breaks.* 78 | var cnt = 0 79 | breakable{ 80 | while(true) { 81 | println(s"cnt = ${cnt} log.size:${logs.size}") 82 | if findEvent("request", "GET", "/log-appender").isPresent && findEvent("response", "GET", "/log-appender").isPresent || cnt > 5 then 83 | println(s"Breaking out of loop. log.size:${logs.size}") 84 | break() 85 | else 86 | cnt += 1 87 | println(s"Sleeping for 2 seconds.. cnt:${cnt}, log.size:${logs.size}") 88 | Thread.sleep(2000) 89 | } 90 | } 91 | val requestMsg = findEvent("request", "GET", "/log-appender").get.getMessage 92 | val responseMsg = findEvent("response", "GET", "/log-appender").get.getMessage 93 | println(s"requestMsg = ${requestMsg}") 94 | println(s"responseMsg = ${responseMsg}") 95 | 96 | val requestJson = (Json.parse(requestMsg) \ "request").as[JsObject] 97 | val responseJson = (Json.parse(responseMsg) \ "response").as[JsObject] 98 | 99 | //checks the request data 100 | assert((requestJson \ "timestamp").as[String].nonEmpty) 101 | assert((requestJson \ "requestId").as[String].nonEmpty) 102 | assert((requestJson \ "clientIP").as[String].nonEmpty) 103 | assert((requestJson \ "remoteIP").as[String].nonEmpty) 104 | assert((requestJson \ "proxiedDestinationAddresses").toString.nonEmpty) 105 | 106 | assert((requestJson \ "headers").as[String].nonEmpty) 107 | assert((requestJson \ "method").as[String].equalsIgnoreCase("GET")) 108 | assert((requestJson \ "query").as[String].nonEmpty) 109 | assert((requestJson \ "scheme").as[String].nonEmpty) 110 | assert((requestJson \ "requestStartTimeMillis").toString.nonEmpty) 111 | println(s""" = ${(requestJson \ "path").as[String]}""") 112 | assert((requestJson \ "path").as[String].equalsIgnoreCase("/log-appender")) 113 | assert((requestJson \ "requestLength").as[Int].equals(0)) 114 | 115 | //checks the response data 116 | assert((responseJson \ "timestamp").as[String].nonEmpty) 117 | assert((responseJson \ "requestId").as[String].nonEmpty) 118 | assert((responseJson \ "clientIP").as[String].nonEmpty) 119 | assert((responseJson \ "remoteIP").as[String].nonEmpty) 120 | assert((responseJson \ "proxiedDestinationAddresses").toString.nonEmpty) 121 | 122 | assert((responseJson \ "headers").as[String].nonEmpty) 123 | assert((responseJson \ "statusCode").as[Int].equals(200)) 124 | assert((responseJson \ "method").as[String].equalsIgnoreCase("GET")) 125 | assert((responseJson \ "query").as[String].nonEmpty) 126 | assert((responseJson \ "scheme").as[String].nonEmpty) 127 | assert((responseJson \ "responseStartTimeMillis").as[String].nonEmpty) 128 | assert((responseJson \ "path").as[String].equalsIgnoreCase("/log-appender")) 129 | assert((responseJson \ "responseLength").as[Int].equals(19)) 130 | 131 | } 132 | 133 | -------------------------------------------------------------------------------- /src/test/scala/com/greenfossil/thorium/AddAnnotatedServiceSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | import com.linecorp.armeria.server.annotation.{Get, Param, Post} 20 | 21 | import java.net.{URI, http} 22 | import scala.language.implicitConversions 23 | 24 | private object Service: 25 | 26 | @Get("/foo") 27 | def foo(@Param msg:String)(req: Request) = 28 | s"${req.path} content:$msg" 29 | 30 | @Get("/bar") 31 | def bar(@Param msg:String)(implicit req: Request) = 32 | s"${req.path} content:$msg" 33 | 34 | @Get("/baz/:name") 35 | def baz(@Param name: String)(implicit req: Request) = 36 | s"${req.path} name:$name" 37 | 38 | @Post("/foobaz/:name") 39 | def foobaz(form: FormUrlEndcoded)(implicit req: Request) = 40 | s"${req.path} form:${form} content:${req.asText}" 41 | 42 | end Service 43 | 44 | class AddAnnotatedServiceSuite extends munit.FunSuite: 45 | import com.linecorp.armeria.common.* 46 | 47 | var server: Server = null 48 | 49 | override def beforeAll(): Unit = { 50 | server = Server(0) 51 | .addServices(Service) 52 | .start() 53 | } 54 | 55 | override def afterAll(): Unit = { 56 | Thread.sleep(1000) 57 | server.stop() 58 | } 59 | 60 | test("foo") { 61 | val resp = http.HttpClient.newHttpClient().send( 62 | http.HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/foo?msg=HelloWorld!")) 63 | .header("Content-Type", MediaType.PLAIN_TEXT.toString) 64 | .build(), 65 | http.HttpResponse.BodyHandlers.ofString() 66 | ) 67 | assertNoDiff(resp.body(), "/foo content:HelloWorld!") 68 | } 69 | 70 | test("bar") { 71 | val resp = http.HttpClient.newHttpClient().send( 72 | http.HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/bar?msg=HelloWorld!")) 73 | .header("Content-Type", MediaType.PLAIN_TEXT.toString) 74 | .build(), 75 | http.HttpResponse.BodyHandlers.ofString() 76 | ) 77 | assertNoDiff(resp.body(), "/bar content:HelloWorld!") 78 | } 79 | 80 | test("baz") { 81 | val resp = http.HttpClient.newHttpClient().send( 82 | http.HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/baz/homer")) 83 | .header("Content-Type", MediaType.PLAIN_TEXT.toString) 84 | .build(), 85 | http.HttpResponse.BodyHandlers.ofString() 86 | ) 87 | assertNoDiff(resp.body(), "/baz/homer name:homer") 88 | } 89 | 90 | test("foobaz") { 91 | val resp = http.HttpClient.newHttpClient().send( 92 | http.HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/foobaz/homer")) 93 | .POST(http.HttpRequest.BodyPublishers.ofString("msg[]=Hello&msg[]=World!")) 94 | .header("Content-Type", MediaType.FORM_DATA.toString) 95 | .build(), 96 | http.HttpResponse.BodyHandlers.ofString() 97 | ) 98 | assertNoDiff(resp.body(), "/foobaz/homer form:FormUrlEndcoded(ListMap(msg[] -> List(Hello, World!))) content:msg[]=Hello&msg[]=World!") 99 | } 100 | 101 | test("foobaz - wrong media-type") { 102 | val resp = http.HttpClient.newHttpClient().send( 103 | http.HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/foobaz/homer")) 104 | .POST(http.HttpRequest.BodyPublishers.ofString("msg[]=Hello&msg[]=World!")) 105 | .header("Content-Type", MediaType.JSON.toString) 106 | .build(), 107 | http.HttpResponse.BodyHandlers.ofString() 108 | ) 109 | assertEquals(resp.statusCode(), HttpStatus.BAD_REQUEST.code()) 110 | } 111 | -------------------------------------------------------------------------------- /src/test/scala/com/greenfossil/thorium/AddHttpServiceSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | import com.greenfossil.commons.json.Json 20 | import io.github.yskszk63.jnhttpmultipartformdatabodypublisher.MultipartFormDataBodyPublisher 21 | 22 | import java.io.ByteArrayInputStream 23 | import java.net.{CookieManager, URI, http} 24 | import scala.language.implicitConversions 25 | 26 | class AddHttpServiceSuite extends munit.FunSuite{ 27 | import com.linecorp.armeria.common.* 28 | 29 | var server: Server = null 30 | 31 | override def beforeAll(): Unit = { 32 | server = Server(0) 33 | .addHttpService("/text", Action { req => 34 | val method = req.method 35 | if req.asText == "Hello Armeria!" && method == HttpMethod.POST then Ok("Received Text") 36 | else BadRequest("Did not receive the right text") 37 | }) 38 | .addHttpService("/json", Action { req => 39 | val method = req.method 40 | val json = req.asJson 41 | val msgOpt = (json \ "msg").asOpt[String] 42 | if msgOpt.contains("Hello Armeria!") && method == HttpMethod.POST then Ok("Received Text") 43 | else BadRequest("Did not receive the right text") 44 | }) 45 | .addHttpService("/form", Action { req => 46 | val method = req.method 47 | val form = req.asFormUrlEncoded 48 | val msg = form.getOrElse("msg[]", Nil) 49 | if msg == Seq("Hello", "Armeria!") && method == HttpMethod.POST then Ok("Received Text") 50 | else BadRequest("Did not receive the right text") 51 | }) 52 | .addHttpService("/multipart-form", Action { req => 53 | req.asMultipartFormData(mpForm => { 54 | val form = mpForm.asFormUrlEncoded 55 | if form.nonEmpty then Ok(s"Received Text $form") 56 | else BadRequest("Did not receive the right text") 57 | }) 58 | }) 59 | .addHttpService("/cookie", Action { req => 60 | val cookie1 = Cookie.ofSecure("cookie1", "one") 61 | val cookie2 = Cookie.ofSecure("cookie2", "two") 62 | Ok("Here are your cookies").withCookies(cookie1, cookie2) 63 | }) 64 | .addHttpService("/json2", Action {req => 65 | Json.obj( 66 | "msg" -> "HelloWorld!" 67 | ) 68 | }) 69 | .start() 70 | } 71 | 72 | override def afterAll(): Unit = { 73 | Thread.sleep(1000) 74 | server.stop() 75 | } 76 | 77 | test("Text") { 78 | val resp = http.HttpClient.newHttpClient().send( 79 | http.HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/text")) 80 | .POST(http.HttpRequest.BodyPublishers.ofString("Hello Armeria!")) 81 | .header("Content-Type", MediaType.PLAIN_TEXT.toString) 82 | .build(), 83 | http.HttpResponse.BodyHandlers.ofString() 84 | ) 85 | assertNoDiff(resp.body(), "Received Text") 86 | } 87 | 88 | test("Json"){ 89 | val resp = http.HttpClient.newHttpClient().send( 90 | http.HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/json")) 91 | .POST(http.HttpRequest.BodyPublishers.ofString(Json.obj("msg" -> "Hello Armeria!").stringify)) 92 | .header("Content-Type", MediaType.JSON.toString) 93 | .build(), 94 | http.HttpResponse.BodyHandlers.ofString() 95 | ) 96 | assertNoDiff(resp.body(), "Received Text") 97 | } 98 | 99 | test("FormUrlEncoded"){ 100 | val resp = http.HttpClient.newHttpClient().send( 101 | http.HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/form")) 102 | .POST(http.HttpRequest.BodyPublishers.ofString("msg[]=Hello&msg[]=Armeria!")) 103 | .header("Content-Type", MediaType.FORM_DATA.toString) 104 | .build(), 105 | http.HttpResponse.BodyHandlers.ofString() 106 | ) 107 | assertNoDiff(resp.body(), "Received Text") 108 | } 109 | 110 | test("Multipart Form") { 111 | // val fileURI = getClass.getClassLoader.getResource("sample.png").toURI 112 | // val file = new File(fileURI) 113 | 114 | val mpPub = MultipartFormDataBodyPublisher() 115 | .add("name1", "hello1") 116 | .add("name1", "hello2") 117 | .addStream("name2", "hello.txt", () => ByteArrayInputStream("hello1".getBytes)) 118 | val resp = http.HttpClient.newHttpClient() 119 | .send( 120 | http.HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/multipart-form")) 121 | .POST(mpPub) 122 | .header("Content-Type", mpPub.contentType()) 123 | .build(), 124 | http.HttpResponse.BodyHandlers.ofString() 125 | ) 126 | 127 | assertNoDiff(resp.body(), "Received Text FormUrlEndcoded(Map(name2 -> List(hello.txt), name1 -> List(hello1, hello2)))") 128 | } 129 | 130 | test("HttpClient Cookie"){ 131 | val cm = CookieManager() 132 | val resp = http.HttpClient.newBuilder().cookieHandler(cm).build().send( 133 | http.HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/cookie")).build(), 134 | http.HttpResponse.BodyHandlers.ofString() 135 | ) 136 | assertEquals(cm.getCookieStore.getCookies.size, 2) 137 | cm.getCookieStore.getCookies.forEach{c => 138 | c.getName match 139 | case "cookie1" => assertNoDiff(c.getValue, "one") 140 | case "cookie2" => assertNoDiff(c.getValue, "two") 141 | case badCookie => fail(s"Bad cookie $badCookie") 142 | } 143 | assertNoDiff(resp.body(), "Here are your cookies") 144 | } 145 | 146 | test("Json2") { 147 | val resp = http.HttpClient.newHttpClient().send( 148 | http.HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/json2")).build(), 149 | http.HttpResponse.BodyHandlers.ofString() 150 | ) 151 | assertEquals(resp.statusCode(), HttpStatus.OK.code()) 152 | assertEquals(resp.headers().firstValue("content-type").get, MediaType.JSON.toString) 153 | assertNoDiff(resp.body(), """{"msg":"HelloWorld!"}""") 154 | } 155 | 156 | } 157 | -------------------------------------------------------------------------------- /src/test/scala/com/greenfossil/thorium/AddRouteSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | import com.greenfossil.commons.json.Json 20 | import com.linecorp.armeria.common.MediaType 21 | 22 | import scala.language.implicitConversions 23 | 24 | object AddRouteSuite: 25 | 26 | var server: Server = null 27 | 28 | // override def beforeAll(): Unit = 29 | @main 30 | def test = 31 | server = Server(8080) 32 | .addRoute: route => 33 | route.get("/route/html/:name").build: 34 | Action { req => 35 | val name = req.pathParam("name") 36 | s"

Greetings ${name}!

".as(MediaType.HTML_UTF_8) 37 | } 38 | .addRoute: route => 39 | route.post("/route/form").consumes(MediaType.FORM_DATA).build: 40 | Action { req => 41 | val form: FormUrlEndcoded = req.asFormUrlEncoded 42 | s"Howdy ${form.getFirst("name")}" 43 | } 44 | .addRoute: route => 45 | route.get("/route/html-tag/:name").build: 46 | Action { req => 47 | import com.greenfossil.htmltags.* 48 | h2(s"Welcome! ${req.pathParam("name")}") 49 | } 50 | .addRoute: route => 51 | route.get("/route/html-tag-flash/:name").build: 52 | Action { req => 53 | import com.greenfossil.htmltags.* 54 | h2(s"Howdy! ${req.pathParam("name")}") 55 | .flashing("test" -> "test") 56 | } 57 | .addRoute: route => 58 | route.get("/route/json/:name").build: 59 | Action{req => 60 | Json.obj{ 61 | "Welcome" -> req.pathParam("name") 62 | } 63 | } 64 | .addRoute: route => 65 | route.get("/route/json-flash/:name").build: 66 | Action { req => 67 | Json.obj { 68 | "Howdy" -> req.pathParam("name") 69 | }.flashing("test" -> "test") 70 | } 71 | .start() 72 | 73 | 74 | -------------------------------------------------------------------------------- /src/test/scala/com/greenfossil/thorium/CSRFGuardModule_API_Suite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | import com.greenfossil.thorium.decorators.CSRFGuardModule 20 | 21 | class CSRFGuardModule_API_Suite extends munit.FunSuite: 22 | 23 | test("csrfToken - hmac with different key") { 24 | (for { 25 | csrfToken <- CSRFGuardModule.generateCSRFToken("ABCD", "HmacSHA256", "1234") 26 | } yield 27 | val verifyResult = CSRFGuardModule.verifyHmac(csrfToken, "ABCD1", "HmacSHA256") 28 | verifyResult 29 | ) 30 | .fold( 31 | ex => fail("Fail", ex), 32 | success => assert(!success) 33 | ) 34 | } 35 | 36 | test("csrfToken - with same key") { 37 | (for { 38 | csrfToken <- CSRFGuardModule.generateCSRFToken("ABCD", "HmacSHA256", "1234") 39 | } yield 40 | val verifyResult = CSRFGuardModule.verifyHmac(csrfToken, "ABCD", "HmacSHA256") 41 | verifyResult 42 | ) 43 | .fold( 44 | ex => fail("Fail", ex), 45 | success => assert(success) 46 | ) 47 | } 48 | 49 | 50 | -------------------------------------------------------------------------------- /src/test/scala/com/greenfossil/thorium/CSRFGuardModule_Post_FormData_Fail_Suite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | import com.linecorp.armeria.common.MediaType 20 | import munit.IgnoreSuite 21 | 22 | import java.net.{URI, http} 23 | import java.time.Duration 24 | 25 | @IgnoreSuite 26 | class CSRFGuardModule_Post_FormData_Fail_Suite extends munit.FunSuite: 27 | 28 | //Important - allow content-length to be sent in the headers 29 | System.setProperty("jdk.httpclient.allowRestrictedHeaders", "content-length") 30 | 31 | test("SameOrigin POST /csrf/email/change"): 32 | doPost(identity) 33 | 34 | 35 | test("Null Origin POST /csrf/email/change"): 36 | doPost(_ => null) 37 | 38 | 39 | test("CrossOrigin POST /csrf/email/change"): 40 | doPost(_ => "http://another-site") 41 | 42 | private def doPost(originFn: String => String)(using loc:munit.Location) = 43 | val server = Server(0) 44 | .addServices(CSRFServices) 45 | .addCSRFGuard((_ /*origin*/, _ /*referer*/, _ /*ctx*/) => false) 46 | .start() 47 | 48 | val postEpPath = "/csrf/email/change" 49 | val target = s"http://localhost:${server.port}" 50 | val content = s"email=password" 51 | 52 | //Set up Request 53 | val requestBuilder = http.HttpRequest.newBuilder(URI.create(target + postEpPath)) 54 | .POST(http.HttpRequest.BodyPublishers.ofString(content)) 55 | .headers( 56 | "content-type", MediaType.FORM_DATA.toString, 57 | "content-length", content.length.toString //required to set allowRestrictedHeaders 58 | ) 59 | 60 | //Set origin 61 | Option(originFn(target)).foreach(target => requestBuilder.header("Origin", target)) 62 | 63 | val postResp = http.HttpClient.newBuilder().connectTimeout(Duration.ofHours(1)).build() 64 | .send(requestBuilder.build(), http.HttpResponse.BodyHandlers.ofString()) 65 | 66 | assertEquals(postResp.statusCode(), 401) 67 | assertNoDiff(postResp.body(), """| 68 | | 69 | | 70 | | Unauthorized Access 71 | | 72 | | 73 | |

Access Denied

74 | | 75 | | 76 | |""".stripMargin) 77 | 78 | server.stop() -------------------------------------------------------------------------------- /src/test/scala/com/greenfossil/thorium/CSRFGuardModule_Post_FormData_Pass_Suite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | import com.greenfossil.thorium.decorators.CSRFGuardModule 20 | import com.linecorp.armeria.common.* 21 | import munit.IgnoreSuite 22 | 23 | import java.net.* 24 | import java.time.Duration 25 | 26 | @IgnoreSuite 27 | class CSRFGuardModule_Post_FormData_Pass_Suite extends munit.FunSuite: 28 | 29 | //Important - allow content-length to be sent in the headers 30 | System.setProperty("jdk.httpclient.allowRestrictedHeaders", "content-length") 31 | 32 | test("SameOrigin POST /csrf/email/change"): 33 | doPost(identity) 34 | 35 | 36 | test("Null Origin POST /csrf/email/change"): 37 | doPost(_ => null) 38 | 39 | 40 | test("CrossOrigin POST /csrf/email/change"): 41 | doPost(_ => "http://another-site") 42 | 43 | private def doPost(originFn: String => String)(using loc:munit.Location) = 44 | val server = Server(0) 45 | .addServices(CSRFServices) 46 | .addCSRFGuard() 47 | .start() 48 | 49 | val postEpPath = "/csrf/email/change" 50 | val csrfCookieTokenName = Configuration().httpConfiguration.csrfConfig.cookieName 51 | val target = s"http://localhost:${server.port}" 52 | val csrfCookie: Cookie = CSRFGuardModule.generateCSRFTokenCookie(Configuration(), Some("ABC")) 53 | val content = s"email=password&${csrfCookieTokenName}=${URLEncoder.encode(csrfCookie.value(), "UTF-8")}" 54 | 55 | val cm = CookieManager() 56 | val cookie = HttpCookie(csrfCookieTokenName, csrfCookie.value()) 57 | cookie.setDomain(csrfCookie.domain()) 58 | cookie.setPath(csrfCookie.path()) 59 | cm.getCookieStore.add(URI.create(target), cookie) 60 | 61 | //Set up Request 62 | val requestBuilder = http.HttpRequest.newBuilder(URI.create(target + postEpPath)) 63 | .POST(http.HttpRequest.BodyPublishers.ofString(content)) 64 | .headers( 65 | "content-type", MediaType.FORM_DATA.toString, 66 | "content-length", content.length.toString //required to set allowRestrictedHeaders 67 | ) 68 | 69 | //Set origin 70 | Option(originFn(target)).foreach(target => requestBuilder.header("Origin", target)) 71 | 72 | val postResp = http.HttpClient.newBuilder().cookieHandler(cm).connectTimeout(Duration.ofHours(1)).build() 73 | .send(requestBuilder.build(), http.HttpResponse.BodyHandlers.ofString()) 74 | assertEquals(postResp.statusCode(), 200) 75 | assertNoDiff(postResp.body(), "Password Changed") 76 | 77 | 78 | server.stop() -------------------------------------------------------------------------------- /src/test/scala/com/greenfossil/thorium/CSRFGuardModule_Post_Multipart_Fail_Suite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | import io.github.yskszk63.jnhttpmultipartformdatabodypublisher.MultipartFormDataBodyPublisher 20 | 21 | import java.io.ByteArrayInputStream 22 | import java.net.{URI, http} 23 | 24 | class CSRFGuardModule_Post_Multipart_Fail_Suite extends munit.FunSuite: 25 | 26 | test("SameOrigin POST"): 27 | doPost(identity) 28 | 29 | test("null Origin POST"): 30 | doPost(_ => null) 31 | 32 | test("Cross Origin POST"): 33 | doPost(_ => "http://another-site") 34 | 35 | def doPost(originFn: String => String)(using loc: munit.Location) = 36 | val server = Server(0) 37 | .addServices(CSRFServices) 38 | .addCSRFGuard((_ /*origin*/, _ /*referer*/, _ /*ctx*/) => false) 39 | .start() 40 | val port = server.port 41 | 42 | val postEpPath = "/csrf/multipart-file" 43 | val target = s"http://localhost:$port" 44 | 45 | val mpPub = MultipartFormDataBodyPublisher() 46 | .add("name", "Homer") 47 | .addStream("file", "file.txt", () => ByteArrayInputStream("Hello world".getBytes)) 48 | 49 | val reqBuilder = http.HttpRequest.newBuilder(URI.create( s"$target$postEpPath")) 50 | .POST(mpPub) 51 | .header("Content-Type", mpPub.contentType()) 52 | 53 | //Set Origin 54 | Option(originFn(target)).foreach(target => reqBuilder.header("Origin", target)) 55 | 56 | val resp = http.HttpClient.newHttpClient() 57 | .send( 58 | reqBuilder 59 | .POST(mpPub) 60 | .build(), 61 | http.HttpResponse.BodyHandlers.ofString() 62 | ) 63 | 64 | assertEquals(resp.statusCode(), 401) 65 | assertNoDiff(resp.body(), """| 66 | | 67 | | 68 | | Unauthorized Access 69 | | 70 | | 71 | |

Access Denied

72 | | 73 | | 74 | |""".stripMargin) 75 | 76 | server.stop() 77 | -------------------------------------------------------------------------------- /src/test/scala/com/greenfossil/thorium/CSRFGuardModule_Post_Multipart_Pass_Suite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | import com.greenfossil.thorium.decorators.CSRFGuardModule 20 | import io.github.yskszk63.jnhttpmultipartformdatabodypublisher.MultipartFormDataBodyPublisher 21 | 22 | import java.io.ByteArrayInputStream 23 | import java.net.{CookieManager, URI, http} 24 | 25 | class CSRFGuardModule_Post_Multipart_Pass_Suite extends munit.FunSuite: 26 | 27 | test("SameOrigin POST"): 28 | doPost(identity) 29 | 30 | test("null Origin POST"): 31 | doPost(_ => null) 32 | 33 | test("Cross Origin POST"): 34 | doPost(_ => "http://another-site") 35 | 36 | def doPost(originFn: String => String)(using loc: munit.Location) = 37 | val server = Server(0) 38 | .addServices(CSRFServices) 39 | .addCSRFGuard() 40 | .start() 41 | val port = server.port 42 | 43 | val postEpPath = "/csrf/multipart-file" 44 | val target = s"http://localhost:$port" 45 | val csrfCookieTokenName = Configuration().httpConfiguration.csrfConfig.cookieName 46 | 47 | //Get the CSRFToken cookie 48 | val csrfCookie = CSRFGuardModule.generateCSRFTokenCookie(Configuration(), Some("ABC")) 49 | 50 | val cm = CookieManager() 51 | cm.getCookieStore.add(target, csrfCookie) 52 | val mpPub = MultipartFormDataBodyPublisher() 53 | .add("name", "Homer") 54 | .add(csrfCookieTokenName, csrfCookie.value()) 55 | .addStream("file", "file.txt", () => ByteArrayInputStream("Hello world".getBytes)) 56 | 57 | val reqBuilder = http.HttpRequest.newBuilder(URI.create(s"$target$postEpPath")) 58 | .POST(mpPub) 59 | .header("Content-Type", mpPub.contentType()) 60 | 61 | //Set Origin 62 | Option(originFn(target)).foreach(target => reqBuilder.header("Origin", target)) 63 | 64 | val resp = http.HttpClient.newBuilder().cookieHandler(cm).build() 65 | .send( 66 | reqBuilder 67 | .POST(mpPub) 68 | .build(), 69 | http.HttpResponse.BodyHandlers.ofString() 70 | ) 71 | assertEquals(resp.statusCode(), 200) 72 | assertNoDiff(resp.body(), s"Received multipart request with files: 1, form:FormUrlEndcoded(Map(file -> List(file.txt), name -> List(Homer), APP_CSRF_TOKEN -> List(${csrfCookie.value()})))") 73 | 74 | server.stop() 75 | -------------------------------------------------------------------------------- /src/test/scala/com/greenfossil/thorium/CSRFMainTestService.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | import java.time.Duration 20 | 21 | object CSRFMainTestService: 22 | 23 | @main 24 | def csrfMain = 25 | val server = Server(8080) 26 | .addServices(CSRFServices) 27 | .addCSRFGuard() 28 | .serverBuilderSetup(_.requestTimeout(Duration.ofHours(1))) 29 | .start() 30 | 31 | server.serviceConfigs foreach { c => 32 | println(s"c.route() = ${c.route()}") 33 | } 34 | println(s"Server started... ${Thread.currentThread()}") -------------------------------------------------------------------------------- /src/test/scala/com/greenfossil/thorium/CSRFServices.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | import com.linecorp.armeria.common.MediaType 20 | import com.linecorp.armeria.server.annotation.* 21 | import com.linecorp.armeria.server.annotation.decorator.{CorsDecorator, RequestTimeout} 22 | 23 | import java.util.concurrent.TimeUnit 24 | 25 | /* 26 | * Please ensure com.greenfossil.webserver.examples.main is started 27 | */ 28 | object CSRFServices: 29 | 30 | @Get("/csrf/do-change-email") 31 | @RequestTimeout(value = 1, unit = TimeUnit.HOURS) 32 | def startChange = Action { implicit request => 33 | s""" 34 | | 35 | | 36 | | 37 | | Change Email 38 | | 39 | | 40 | | 41 | |

Change Email

42 | |
43 | | 44 | | 45 | | 46 | |
47 | | 48 | | 49 | |""".stripMargin.as(MediaType.HTML_UTF_8) 50 | } 51 | 52 | 53 | @Post("/csrf/email/change") 54 | @RequestTimeout(value = 1, unit = TimeUnit.HOURS) 55 | def changePassword = Action : _ => 56 | "Password Changed" 57 | 58 | @Get("/csrf/do-delete") 59 | @RequestTimeout(value = 1, unit = TimeUnit.HOURS) 60 | def startDelete = Action { implicit request => 61 | s""" 62 | | 63 | | 64 | | 65 | | SameOrigin 66 | | 67 | | 68 | | 69 | |

Delete Action

70 | | 71 | | 72 | | 73 | | 85 | | 86 | |""".stripMargin.as(MediaType.HTML_UTF_8) 87 | } 88 | 89 | @Options 90 | @Delete("/csrf/delete/:id") 91 | @CorsDecorator(origins = Array("*")) 92 | @RequestTimeout(value = 1, unit = TimeUnit.HOURS) 93 | def deleteResource(@Param id: String) = Action { request => 94 | s"Deleted $id" 95 | } 96 | 97 | @Post("/csrf/multipart-file") 98 | def multipartFile: Action = Action.multipart { implicit request => 99 | val form = request.asFormUrlEncoded 100 | val files = request.files 101 | Ok(s"Received multipart request with files: ${files.size}, form:$form") 102 | } 103 | 104 | -------------------------------------------------------------------------------- /src/test/scala/com/greenfossil/thorium/ContextPathSuite.scala: -------------------------------------------------------------------------------- 1 | package com.greenfossil.thorium 2 | 3 | import com.linecorp.armeria.common 4 | 5 | import java.net.URI 6 | import java.net.http.{HttpClient, HttpRequest, HttpResponse} 7 | 8 | trait AbstractTestServices(hostname: String): 9 | import com.linecorp.armeria.server.annotation.{Get, Param} 10 | 11 | @Get("/echo/hello") 12 | def echo(using request: Request) = s"${hostname}: hello!" 13 | 14 | @Get("/hi") 15 | def redirect(using request: Request) = 16 | Redirect(sayHello("User")) //Redirect will not work as the prefix is missing. 17 | 18 | @Get("/sayHello/:name") 19 | def sayHello(@Param name: String): String = 20 | s"${hostname}: Hello $name" 21 | 22 | object ContextPathTestServices extends AbstractTestServices("local") 23 | 24 | class ContextPathSuite extends munit.FunSuite: 25 | 26 | private def httpSend(url: String): String = 27 | HttpClient.newBuilder() 28 | .followRedirects(HttpClient.Redirect.NORMAL) 29 | .build() 30 | .send( 31 | HttpRequest.newBuilder(URI.create(url)).build(), 32 | HttpResponse.BodyHandlers.ofString() 33 | ).body() 34 | 35 | test("contextPath"){ 36 | val server = Server() 37 | .serverBuilderSetup{_.contextPath("/v1") 38 | .service("/foo", (ctx, req) => common.HttpResponse.of("v1 foo")) 39 | .service("/bar", Action {req => s"v1 ${req.path}" }) 40 | .annotatedService(ContextPathTestServices) 41 | .and 42 | .contextPath("/v2") 43 | .service("/foo", (ctx, req) => common.HttpResponse.of("v2 foo")) 44 | } 45 | 46 | val started = server.start() 47 | println("Sever started") 48 | 49 | started.printRoutes 50 | 51 | //Test context-path request 52 | assertNoDiff(httpSend(s"http://localhost:${started.port}/v1/foo"), "v1 foo") 53 | assertNoDiff(httpSend(s"http://localhost:${started.port}/v1/bar"), "v1 /v1/bar") 54 | assertNoDiff(httpSend(s"http://localhost:${started.port}/v1/echo/hello"), "local: hello!") 55 | assertNoDiff(httpSend(s"http://localhost:${started.port}/v1/hi"), """Status: 404 56 | |Description: Not Found""".stripMargin) 57 | assertNoDiff(httpSend(s"http://localhost:${started.port}/v2/foo"), "v2 foo") 58 | 59 | val stopped = started.stop() 60 | println("Stopped.") 61 | } 62 | -------------------------------------------------------------------------------- /src/test/scala/com/greenfossil/thorium/DocServiceMain.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | */ 17 | 18 | package com.greenfossil.thorium 19 | 20 | import com.linecorp.armeria.server.annotation.{Get, Param} 21 | 22 | object MyAnnotatedService { 23 | 24 | @Get("/foo/:name") 25 | def foo(@Param name: String) = s"Hello ${name}" 26 | 27 | } 28 | object DocServiceMain{ 29 | 30 | def main(args: Array[String]): Unit = { 31 | Server(8080) 32 | .addServices(MyAnnotatedService) 33 | .addDocService() 34 | .start() 35 | .printRoutes 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /src/test/scala/com/greenfossil/thorium/EndpointMacro1Suite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | import com.greenfossil.thorium.* 20 | import com.linecorp.armeria.server.annotation.{Get, Param, Path, Post} 21 | 22 | import java.time.LocalDateTime 23 | 24 | class EndpointMacro1Suite extends munit.FunSuite { 25 | 26 | @Get("/endpoint1") 27 | def endpoint1 = Action { _ => "endpoint1" } 28 | 29 | @Get("/endpoint2/:name") 30 | def endpoint2(@Param name : String) = Action { _ => "endpoint2"} 31 | 32 | @Get("/endpoint3") //query string 33 | def endpoint3(@Param name: String) = Action { _ => "endpoint3"} 34 | 35 | @Get("/endpoint4/:id") 36 | def endpoint4(@Param id: Long, @Param name: String) = Action { _ => "endpoint3"} 37 | 38 | @Post("/endpoint4/:name") 39 | def endpoint4(@Param members: Seq[String], @Param name: String, @Param time: LocalDateTime) = Action { _ => "endpoint5"} 40 | 41 | @Post 42 | @Path("/endpoint5/:id/:name") 43 | def endpoint5(@Param id: Long, @Param name: String) = Action { _ => "endpoint5" } 44 | 45 | test("annotated endpoint"){ 46 | val ep1 = endpoint1.endpoint 47 | assertNoDiff(ep1.url, "/endpoint1") 48 | assertNoDiff(ep1.method, "Get") 49 | 50 | val ep2 = endpoint2("homer").endpoint 51 | assertNoDiff(ep2.url, "/endpoint2/homer") 52 | assertEquals(ep2.pathPatternOpt, Some("/endpoint2/:name")) 53 | assertNoDiff(ep2.method, "Get") 54 | 55 | val ep3 = endpoint3("homer").endpoint 56 | assertNoDiff(ep3.url, "/endpoint3?name=homer") 57 | assertNoDiff(ep3.method, "Get") 58 | assertEquals(ep3.queryParams.size, 1) 59 | 60 | val now = LocalDateTime.now 61 | val members = Seq("Marge Simpson", "Bart Simpson", "Maggie Simpson") 62 | val ep4 = endpoint4(members, "homer", now).endpoint 63 | assertNoDiff(ep4.url, "/endpoint4/homer?" + 64 | Endpoint.paramKeyValue("members", members) + "&" + 65 | Endpoint.paramKeyValue("time", now.toString)) 66 | assertNoDiff(ep4.method, "Post") 67 | assertEquals(ep4.queryParams.size, 2) 68 | } 69 | 70 | test("url-encoded param Val string"){ 71 | val valToken = "Homer/Marge Simpson" 72 | val url = endpoint2(valToken).endpoint.url 73 | 74 | val url2 = endpoint2("Homer/Marge Simpson").endpoint.url 75 | 76 | assertNoDiff(url, url2) 77 | assertNoDiff(url, "/endpoint2/Homer%2FMarge%20Simpson") 78 | } 79 | 80 | test("url-encoded param val string (with path and query param)") { 81 | val valToken = "Homer/Marge Simpson" 82 | 83 | val url = endpoint4(1, valToken).endpoint.url 84 | 85 | val url2 = endpoint4(1, "Homer/Marge Simpson").endpoint.url 86 | 87 | assertNoDiff(url, url2) 88 | assertNoDiff(url, "/endpoint4/1?name=Homer%2FMarge%20Simpson") 89 | } 90 | 91 | test("url-encoded param val string (with query param)") { 92 | val valToken = "Homer/Marge Simpson" 93 | 94 | val url = endpoint3(valToken).endpoint.url 95 | 96 | val url2 = endpoint3("Homer/Marge Simpson").endpoint.url 97 | 98 | assertNoDiff(url, url2) 99 | assertNoDiff(url, "/endpoint3?name=Homer%2FMarge%20Simpson") 100 | } 101 | 102 | 103 | test("url-encoded param Def string") { 104 | def defToken = "Homer/Marge Simpson" 105 | val url = endpoint3(defToken).endpoint.url 106 | 107 | val url2 = endpoint3("Homer/Marge Simpson").endpoint.url 108 | 109 | assertNoDiff(url, url2) 110 | assertNoDiff(url, "/endpoint3?name=Homer%2FMarge%20Simpson") 111 | } 112 | 113 | test("endpoint with Path annotation"){ 114 | val endpoint = endpoint5(1, "Homer").endpoint 115 | assertEquals(endpoint.url, "/endpoint5/1/Homer") 116 | assertEquals(endpoint.method, "Post") 117 | } 118 | 119 | } 120 | -------------------------------------------------------------------------------- /src/test/scala/com/greenfossil/thorium/EndpointMacro2Suite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | import com.linecorp.armeria.server.annotation.{Get, Param} 20 | 21 | class EndpointMacro2Suite extends munit.FunSuite { 22 | 23 | @Get("/endpoint-params/:int/:str") 24 | def endpointParams(@Param int: Int)(@Param str: String) = "endpoint1" 25 | 26 | @Get("/endpoint-using") 27 | def endpointUsing(using request: Request) = 28 | s"endpoint Int:${request.path}" 29 | 30 | @Get("/endpoint-redirect1") 31 | def endpointRedirect1(using request: Request) = 32 | Redirect(endpointRedirect2) 33 | 34 | @Get("/endpoint-redirect2") 35 | def endpointRedirect2(using request: Request) = 36 | "Howdy" 37 | 38 | @Get("/endpoint-redirect3") 39 | def endpointRedirect3(using request: Request) = 40 | Redirect(endpointRedirect4(1)) 41 | 42 | @Get("/endpoint-redirect4") 43 | def endpointRedirect4(int: Int)(using request: Request) = 44 | "Howdy2" 45 | 46 | test("multi-param-list") { 47 | val epParams = EndpointMcr(endpointParams(1)("string")) 48 | assertNoDiff(epParams.url, "/endpoint-params/1/string") 49 | } 50 | 51 | test("using"){ 52 | given Request = null 53 | val epUsing = EndpointMcr(endpointUsing) 54 | assertNoDiff(epUsing.url, "/endpoint-using") 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /src/test/scala/com/greenfossil/thorium/EndpointMacro3Suite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | import com.linecorp.armeria.server.annotation.{Get, Param} 20 | 21 | import java.net.URI 22 | import java.net.http.{HttpClient, HttpRequest, HttpResponse} 23 | 24 | object TestServices: 25 | @Get("/sayHello/:name") 26 | def sayHello(@Param name: String)(using request: Request): String = 27 | s"Hello $name" 28 | 29 | @Get("/redirect") 30 | def redirect(using request: Request): Result = 31 | Redirect(sayHello("User")) 32 | 33 | @Get("/path") 34 | def path(request: Request): Result = 35 | val url = request.refererOpt.getOrElse("/redirect") 36 | Redirect(url) 37 | 38 | class EndpointMacro3Suite extends munit.FunSuite: 39 | 40 | test("redirect") { 41 | val server = Server(0) 42 | .addServices(TestServices) 43 | .start() 44 | 45 | val resp = HttpClient.newHttpClient() 46 | .send( 47 | HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/redirect")).build(), 48 | HttpResponse.BodyHandlers.ofString() 49 | ) 50 | assertEquals(resp.statusCode(), 303) 51 | assertNoDiff(resp.headers().firstValue("location").get(), "/sayHello/User") 52 | server.stop() 53 | } 54 | 55 | test("path") { 56 | val server = Server(0) 57 | .addServices(TestServices) 58 | .start() 59 | 60 | val resp = HttpClient.newHttpClient() 61 | .send( 62 | HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/path")).build(), 63 | HttpResponse.BodyHandlers.ofString() 64 | ) 65 | assertEquals(resp.statusCode(), 303) 66 | assertNoDiff(resp.headers().firstValue("location").get(), "/redirect") 67 | server.stop() 68 | } 69 | -------------------------------------------------------------------------------- /src/test/scala/com/greenfossil/thorium/EndpointMacroParameterizedServicesSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | import com.linecorp.armeria.server.annotation.{Get, Param} 20 | 21 | /* 22 | *Test path with prefix, regex, glob 23 | */ 24 | class EndpointMacroParameterizedServicesSuite extends munit.FunSuite { 25 | object ParameterizedServices { 26 | @Get("prefix:/howdy") 27 | def prefixEndpoint = "prefix - howdy" 28 | 29 | @Get("/braced-params/{name}/{age}/:contact") 30 | def bracedParams(@Param name: String, @Param age: Int, @Param contact: String) = 31 | s"braced-param name:${name} age:${age} contact:${contact}" 32 | 33 | @Get("regex:^/string/(?[^0-9]+)$") //This path is different without ^$ "regex:/string/(?[^0-9]+)" 34 | def regexStringEndpoint(@Param name: String) = s"regexString - name:$name" 35 | 36 | @Get("regex:^/number/(?[0-9]+)$") 37 | def regexNumberEndpoint(@Param n1: Int) = s"regexNumber - $n1" 38 | 39 | @Get("regex:/string2/(?\\w+)/(?\\w+)") 40 | def regexString2Endpoint(@Param min: String, @Param max: String) = s"regexString2 - min:$min - max:$max" 41 | 42 | @Get("regex:/number2/(?\\d+)/(?\\d+)") 43 | def regexNumber2Endpoint(@Param min: Int, @Param max: Int) = s"regexNumber2 - min:$min - max:$max" 44 | 45 | @Get("regex:/mix/(?\\d+)/(?\\w+)") 46 | def regexMix1Endpoint(@Param min: Int, @Param max: String) = s"regexMix1-Int,String - min:$min - max:$max" 47 | 48 | @Get("regex:/mix/(?\\w+)/(?\\d+)") 49 | def regexMix2Endpoint(@Param min: String, @Param max: Int) = s"regexMix2-String,Int - min:$min - max:$max" 50 | } 51 | 52 | test("prefix endpoint") { 53 | val prefixEp = EndpointMcr(ParameterizedServices.prefixEndpoint) 54 | assertNoDiff(prefixEp.url, "/howdy") 55 | assertNoDiff(prefixEp.method, "Get") 56 | } 57 | 58 | test("braced param"){ 59 | val bracedEndpoint = EndpointMcr(ParameterizedServices.bracedParams("homer simpson",42, "spring/field")) 60 | assertNoDiff(bracedEndpoint.url, "/braced-params/homer%20simpson/42/spring%2Ffield") 61 | } 62 | 63 | test("regex string endpoint") { 64 | val regexStringEp = EndpointMcr(ParameterizedServices.regexStringEndpoint("homer")) 65 | assertNoDiff(regexStringEp.url, "/string/homer") 66 | assertNoDiff(regexStringEp.method, "Get") 67 | } 68 | 69 | test("regex number endpoint") { 70 | def num = 5 71 | val regexNumEp = EndpointMcr(ParameterizedServices.regexNumberEndpoint(num)) 72 | assertNoDiff(regexNumEp.url, "/number/5") 73 | assertNoDiff(regexNumEp.method, "Get") 74 | } 75 | 76 | test("regex string2 endpoint") { 77 | def minValue = "min" 78 | def maxValue = "max" 79 | val regexString2Ep = EndpointMcr(ParameterizedServices.regexString2Endpoint(minValue, maxValue)) 80 | assertNoDiff(regexString2Ep.url, "/string2/min/max") 81 | assertNoDiff(regexString2Ep.method, "Get") 82 | } 83 | 84 | test("regex number2 endpoint") { 85 | def arg1 = 10 86 | def arg2 = 20 87 | val regexNumber2Ep = EndpointMcr(ParameterizedServices.regexNumber2Endpoint(arg1, arg2)) 88 | assertNoDiff(regexNumber2Ep.url, "/number2/10/20") 89 | assertNoDiff(regexNumber2Ep.method, "Get") 90 | } 91 | 92 | test("regex mix endpoint") { 93 | def arg1 = 10 94 | def arg2 = "last" 95 | val regexMix1Ep = EndpointMcr(ParameterizedServices.regexMix1Endpoint(arg1, arg2)) 96 | assertNoDiff(regexMix1Ep.url, "/mix/10/last") 97 | assertNoDiff(regexMix1Ep.method, "Get") 98 | } 99 | 100 | test("regex mix2 endpoint") { 101 | def arg1 = "first" 102 | def arg2 = 20 103 | val regexMix2Ep = EndpointMcr(ParameterizedServices.regexMix2Endpoint(arg1, arg2)) 104 | assertNoDiff(regexMix2Ep.url, "/mix/first/20") 105 | assertNoDiff(regexMix2Ep.method, "Get") 106 | } 107 | 108 | } 109 | -------------------------------------------------------------------------------- /src/test/scala/com/greenfossil/thorium/HeadersSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | import com.linecorp.armeria.common.{Cookie, HttpStatus} 20 | import com.linecorp.armeria.server.annotation.Get 21 | 22 | import java.net.http.{HttpClient, HttpRequest, HttpResponse} 23 | import java.net.{CookieManager, HttpCookie, URI} 24 | import scala.annotation.nowarn 25 | 26 | object HeadersServices: 27 | @Get("/headers") @nowarn 28 | def headers = Action { request => 29 | Ok("Headers sent") 30 | .withHeaders( 31 | "Access-Control-Allow-Origin" -> "*", 32 | "Access-Control-Allow-Headers" -> "Origin, X-Requested-With, Content-Type, Accept") 33 | .withSession("session" -> "sessionValue") 34 | .flashing("flash" -> "flashValue") 35 | .withCookies(Cookie.of("Cookie1", "Cookie1Value"), Cookie.of("Cookie2", "Cookie2Value")) 36 | } 37 | end HeadersServices 38 | 39 | class HeadersSuite extends munit.FunSuite { 40 | test("header, session, flash"){ 41 | val server = Server(0) 42 | .addServices(HeadersServices) 43 | .start() 44 | 45 | val cm = CookieManager() 46 | val resp = HttpClient.newBuilder().cookieHandler(cm).build().send( 47 | HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/headers")).build(), 48 | HttpResponse.BodyHandlers.ofString() 49 | ) 50 | def findCookie(name: String): HttpCookie = 51 | val opt = cm.getCookieStore.getCookies.stream().filter(_.getName == name).findFirst() 52 | opt.orElseGet(() => null) 53 | 54 | assertNoDiff(resp.headers().firstValue("access-control-allow-origin").get, "*") 55 | assertNoDiff(resp.headers().firstValue("access-control-allow-headers").get, "Origin, X-Requested-With, Content-Type, Accept") 56 | assertEquals(resp.statusCode(), HttpStatus.OK.code()) 57 | assertEquals(findCookie("APP_SESSION").getSecure, false) 58 | assertEquals(findCookie("APP_FLASH").getSecure, false) 59 | assertEquals(findCookie("Cookie1").getValue, "Cookie1Value") 60 | assertEquals(findCookie("Cookie2").getValue, "Cookie2Value") 61 | assertNoDiff(resp.body(), "Headers sent") 62 | server.stop() 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /src/test/scala/com/greenfossil/thorium/InheritedAnnotatedPathSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | import com.greenfossil.thorium.Sub2.foo 20 | import com.linecorp.armeria.server.annotation.Get 21 | 22 | trait Base: 23 | @Get("/base/foo") 24 | def foo = Action: _ => 25 | "hello foo" 26 | 27 | @Get("/base/bar") 28 | def bar = Action: _ => 29 | Redirect(foo) 30 | 31 | object Sub1 extends Base: 32 | 33 | @Get("/sub1/foo") 34 | override def foo = Action: _ => 35 | "hello sub1 foo" 36 | 37 | @Get("/sub1/foobaz") 38 | def foobaz = Action: _ => 39 | Redirect(foo) 40 | 41 | def foobazRedirect = EndpointMcr(foo).url 42 | 43 | object Sub2 extends Base: 44 | 45 | @Get("/sub2/foobaz") 46 | def foobaz = Action {request => 47 | Redirect(foo) 48 | } 49 | 50 | def foobazRedirect = EndpointMcr(foo).url 51 | 52 | 53 | 54 | class InheritedAnnotatedPathSuite extends munit.FunSuite: 55 | 56 | test("inherited Impl1.foo"){ 57 | val ep = Sub1.foo.endpoint 58 | assertNoDiff(ep.url, "/sub1/foo") 59 | } 60 | 61 | test("inherited Impl1.toFoo"){ 62 | val redirect1 = Sub1.foobazRedirect 63 | assertNoDiff(redirect1, "/sub1/foo") 64 | 65 | 66 | val redirect2 = Sub2.foobazRedirect 67 | assertNoDiff(redirect2, "/base/foo") 68 | } 69 | 70 | 71 | -------------------------------------------------------------------------------- /src/test/scala/com/greenfossil/thorium/MultiPartFormSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | import com.linecorp.armeria.common.{HttpStatus, MediaType} 20 | import com.linecorp.armeria.server.annotation.Post 21 | import io.github.yskszk63.jnhttpmultipartformdatabodypublisher.MultipartFormDataBodyPublisher 22 | import munit.FunSuite 23 | 24 | import java.net.URI 25 | import java.net.http.{HttpClient, HttpRequest, HttpResponse} 26 | import java.nio.charset.StandardCharsets 27 | import java.nio.file.{Files, Paths} 28 | import scala.sys.process.* 29 | 30 | /* 31 | * Please ensure com.greenfossil.webserver.examples.main is started 32 | */ 33 | object FormServices { 34 | @Post("/multipart3") 35 | def multipartForm3: Action = Action.multipart { implicit request => 36 | val files = request.files 37 | Ok(s"Received multipart request with files: ${files.size}") 38 | } 39 | 40 | @Post("/multipart-bad-request") 41 | def multipartFormBadRequest: Action = Action.multipart { implicit request => 42 | BadRequest("Bad Request") 43 | } 44 | 45 | @Post("/multipart-bad-request-2") 46 | def multipartForm4: Action = Action.multipart { implicit request => 47 | "Bad Request 2".as(HttpStatus.BAD_REQUEST, MediaType.JSON) 48 | } 49 | 50 | @Post("/multipart-internal-server-error") 51 | def multipartFormInternalServerError: Action = Action.multipart { implicit request => 52 | InternalServerError("Internal Server Error") 53 | } 54 | 55 | } 56 | 57 | class MultiPartFormSuite extends FunSuite{ 58 | 59 | var server: Server = null 60 | 61 | override def beforeAll(): Unit = 62 | try{ 63 | server = Server(0) 64 | .addServices(FormServices) 65 | .start() 66 | }catch { 67 | case ex: Throwable => 68 | } 69 | 70 | override def afterAll(): Unit = 71 | server.stop() 72 | 73 | test("POST with file content") { 74 | Files.write(Paths.get("/tmp/file.txt"), "Hello world".getBytes(StandardCharsets.UTF_8)) 75 | 76 | val result = s"curl http://localhost:${server.port}/multipart3 -F resourceFile=@/tmp/file.txt ".!!.trim 77 | assertEquals(result, "Received multipart request with files: 1") 78 | } 79 | 80 | test("POST without file content but with form param") { 81 | val result = s"curl http://localhost:${server.port}/multipart3 -F name=homer ".!!.trim 82 | assertEquals(result, "Received multipart request with files: 0") 83 | } 84 | 85 | test("POST empty body raw data") { 86 | val result = 87 | (s"curl http://localhost:${server.port}/multipart3 -H 'Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryaDaB4MtEkj4a1pYx' " + 88 | "--data-raw $'------WebKitFormBoundaryaDaB4MtEkj4a1pYx--\r\n'").!!.trim 89 | assertEquals(result, "Received multipart request with files: 0") 90 | } 91 | 92 | test("POST without file content but with form param, using web browser's raw data") { 93 | val command = s"curl http://localhost:${server.port}/multipart3 -H 'Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryOrtvYGBXE2gxan8t' " + 94 | "--data-raw $'------WebKitFormBoundaryOrtvYGBXE2gxan8t\r\nContent-Disposition: form-data; name=\"resourceFile\"; " + 95 | "filename=\"\"\r\nContent-Type: application/octet-stream\r\n\r\n\r\n" + 96 | "------WebKitFormBoundaryOrtvYGBXE2gxan8t\r\nContent-Disposition: form-data; name=\"url\"\r\n\r\n\r\n" + 97 | "------WebKitFormBoundaryOrtvYGBXE2gxan8t\r\nContent-Disposition: form-data; name=\"status\"\r\n\r\nactive\r\n" + 98 | "------WebKitFormBoundaryOrtvYGBXE2gxan8t\r\nContent-Disposition: form-data; name=\"description\"\r\n\r\n\r\n" + 99 | "------WebKitFormBoundaryOrtvYGBXE2gxan8t\r\nContent-Disposition: form-data; name=\"tpe\"\r\n\r\n\r\n" + 100 | "------WebKitFormBoundaryOrtvYGBXE2gxan8t\r\nContent-Disposition: form-data; name=\"spaceId\"\r\n\r\n\r\n" + 101 | "------WebKitFormBoundaryOrtvYGBXE2gxan8t\r\nContent-Disposition: form-data; name=\"roleFilter\"\r\n\r\non\r\n" + 102 | "------WebKitFormBoundaryOrtvYGBXE2gxan8t\r\nContent-Disposition: form-data; name=\"dtStart\"\r\n\r\n\r\n" + 103 | "------WebKitFormBoundaryOrtvYGBXE2gxan8t\r\nContent-Disposition: form-data; name=\"dtEnd\"\r\n\r\n\r\n" + 104 | "------WebKitFormBoundaryOrtvYGBXE2gxan8t--\r\n'" 105 | val result = command.!!.trim 106 | assertEquals(result, "Received multipart request with files: 0") 107 | } 108 | 109 | test("POST with bad request") { 110 | val mpPub = MultipartFormDataBodyPublisher().add("name", "homer") 111 | val resp = HttpClient.newHttpClient() 112 | .send( 113 | HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/multipart-bad-request")) 114 | .POST(mpPub) 115 | .header("Content-Type", mpPub.contentType()) 116 | .build(), 117 | HttpResponse.BodyHandlers.ofString() 118 | ) 119 | assertEquals(resp.statusCode(), HttpStatus.BAD_REQUEST.code()) 120 | assertNoDiff(resp.body(), "Bad Request") 121 | } 122 | 123 | test("POST with bad request 2") { 124 | val mpPub = MultipartFormDataBodyPublisher().add("name", "homer") 125 | val resp = HttpClient.newHttpClient() 126 | .send( 127 | HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/multipart-bad-request-2")) 128 | .POST(mpPub) 129 | .header("Content-Type", mpPub.contentType()) 130 | .build(), 131 | HttpResponse.BodyHandlers.ofString() 132 | ) 133 | assertEquals(resp.statusCode(), HttpStatus.BAD_REQUEST.code()) 134 | assertNoDiff(resp.body(), "Bad Request 2") 135 | } 136 | 137 | test("POST with internal server error") { 138 | val mpPub = MultipartFormDataBodyPublisher() 139 | .add("name", "homer") 140 | val resp = HttpClient.newHttpClient() 141 | .send( 142 | HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/multipart-internal-server-error")) 143 | .POST(mpPub) 144 | .header("Content-Type", mpPub.contentType()) 145 | .build(), 146 | HttpResponse.BodyHandlers.ofString() 147 | ) 148 | assertEquals(resp.statusCode(), HttpStatus.INTERNAL_SERVER_ERROR.code()) 149 | assertNoDiff(resp.body(), "Internal Server Error") 150 | } 151 | 152 | } 153 | -------------------------------------------------------------------------------- /src/test/scala/com/greenfossil/thorium/MultipartFileDataBindingSuite.scala: -------------------------------------------------------------------------------- 1 | package com.greenfossil.thorium 2 | 3 | import com.greenfossil.data.mapping.Mapping.* 4 | import com.linecorp.armeria.common.multipart.MultipartFile 5 | import com.linecorp.armeria.server.annotation.Post 6 | import io.github.yskszk63.jnhttpmultipartformdatabodypublisher.MultipartFormDataBodyPublisher 7 | 8 | import java.net.URI 9 | import java.net.http.{HttpClient, HttpRequest, HttpResponse} 10 | import java.nio.file.Paths 11 | 12 | class MultipartFileDataBindingSuite extends munit.FunSuite: 13 | 14 | 15 | object FormServices: 16 | @Post("/multipart") 17 | def multipartForm: Action = Action.multipart { implicit request => 18 | //Test the Multipart File data-binding using NotEmptyText then followed by Transformation[String, MultipartFile] 19 | val nameField = nonEmptyText.name("name") 20 | val fileField = nonEmptyText.name("file") 21 | .verifying("File name must be 'logback-test.xml'", _ == "logback-test.xml2") 22 | .transform[MultipartFile](name => request.findFileOfFileName(name).orNull, file => file.filename()) 23 | .verifying("File size cannot be more than 10 bytes", f => f.sizeInBytes < 10) 24 | 25 | val nameValueOpt = nameField.bindFromRequest().typedValueOpt 26 | assertNoDiff(nameValueOpt.orNull, "homer") 27 | 28 | //file assertions 29 | val fileBindForm = fileField.bindFromRequest() 30 | assertEquals(fileBindForm.errors.size, 2) 31 | 32 | assert(fileBindForm.typedValueOpt.nonEmpty) 33 | 34 | "Received" 35 | } 36 | 37 | test("POST with file content") { 38 | //Start 39 | val server = Server(0) 40 | .addServices(FormServices) 41 | .start() 42 | 43 | val path = Paths.get("src/test/resources/logback-test.xml").toAbsolutePath 44 | val mpPub = MultipartFormDataBodyPublisher() 45 | .add("name", "homer") 46 | .addFile("file", path) 47 | val resp = HttpClient.newHttpClient() 48 | .send( 49 | HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/multipart")) 50 | .POST(mpPub) 51 | .header("Content-Type", mpPub.contentType()) 52 | .build(), 53 | HttpResponse.BodyHandlers.ofString() 54 | ) 55 | assertNoDiff(resp.body(), "Received") 56 | //Stop server 57 | server.stop() 58 | } 59 | 60 | -------------------------------------------------------------------------------- /src/test/scala/com/greenfossil/thorium/MultipartFileSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | import com.linecorp.armeria.common.MediaType 20 | import com.linecorp.armeria.common.multipart.MultipartFile 21 | 22 | import java.io.File 23 | 24 | class MultipartFileSuite extends munit.FunSuite { 25 | 26 | test("MultipartFile extensions for image-no-ext/png") { 27 | val r = this.getClass.getResource("/favicon.png") 28 | assert(r != null, "Resource not found") 29 | val mpFile = MultipartFile.of("file", "file",new File(r.getFile)) 30 | assert(mpFile.contentType.is(MediaType.ANY_IMAGE_TYPE)) 31 | assert(mpFile.contentType.is(MediaType.PNG)) 32 | assertEquals(mpFile.sizeInBytes, 3711L) 33 | assertEquals(mpFile.sizeInKB, 3L) 34 | assertEquals(mpFile.sizeInMB, 0L) 35 | assertEquals(mpFile.sizeInGB, 0L) 36 | } 37 | 38 | test("MultipartFile extensions for image-no-ext without extension") { 39 | val r = this.getClass.getResource("/image-no-ext") 40 | assert(r != null, "Resource not found") 41 | val mpFile = MultipartFile.of("file", "file", new File(r.getFile)) 42 | assert(mpFile.contentType.is(MediaType.ANY_IMAGE_TYPE)) 43 | assert(mpFile.contentType.is(MediaType.PNG)) 44 | assertEquals(mpFile.sizeInBytes, 3711L) 45 | } 46 | 47 | test("MultipartFile extensions for text/xml") { 48 | val r = this.getClass.getResource("/logback-test.xml") 49 | assert(r != null, "Resource not found") 50 | val mpFile = MultipartFile.of("file", "file", new File(r.getFile)) 51 | assert(mpFile.contentType.is(MediaType.parse("application/xml"))) 52 | } 53 | 54 | test("MultipartFile extensions for conf") { 55 | val r = this.getClass.getResource("/logback-test.xml") 56 | assert(r != null, "Resource not found") 57 | val mpFile = MultipartFile.of("file", "file", new File(r.getFile)) 58 | assert(mpFile.contentType.is(MediaType.parse("application/xml"))) 59 | } 60 | 61 | test("Multipart inputStream") { 62 | val r = this.getClass.getResource("/favicon.png") 63 | assert(r != null, "Resource not found") 64 | val mpFile = MultipartFile.of("file", "file", new File(r.getFile)) 65 | 1 to 5 foreach { i => 66 | val is = mpFile.inputStream 67 | val size = is.available() 68 | val bytes = is.readAllBytes() 69 | assertEquals(bytes.length, size) 70 | assertEquals(is.available(), 0) 71 | is.close() 72 | } 73 | } 74 | 75 | } 76 | -------------------------------------------------------------------------------- /src/test/scala/com/greenfossil/thorium/NestedRedirectMacroSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | import com.linecorp.armeria.server.annotation.Get 20 | 21 | object NestedRedirectServices { 22 | 23 | @Get("/nestedRedirect") 24 | def nestedRedirect = Action { request2 => 25 | //Reference class' action 26 | nestedRedirectFn(actionEnd.endpoint) 27 | } 28 | 29 | def nestedRedirectFn(ep: Endpoint): Result = 30 | Redirect(ep) 31 | 32 | @Get("/actionEnd") 33 | def actionEnd = Action{ request => 34 | Ok("End") 35 | } 36 | 37 | } 38 | 39 | class NestedRedirectMacroSuite extends munit.FunSuite { 40 | 41 | test("Endpoint.apply()") { 42 | val ep = NestedRedirectServices.actionEnd.endpoint 43 | assertNoDiff(ep.url, "/actionEnd") 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /src/test/scala/com/greenfossil/thorium/RecaptchaFormPage.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | */ 17 | 18 | package com.greenfossil.thorium 19 | 20 | import com.greenfossil.htmltags.* 21 | 22 | import scala.language.implicitConversions 23 | 24 | object RecaptchaFormPage: 25 | 26 | def apply(endpoint: Endpoint, isMultipart: Boolean, useRecaptcha: Boolean = false)(using request: Request): Tag = 27 | val siteKey = request.httpConfiguration.recaptchaConfig.siteKey 28 | html( 29 | head( 30 | ifTrue(useRecaptcha, script(src:="https://www.google.com/recaptcha/api.js" /*async defer ? how to add this to htmltag?*/)), 31 | ifTrue(useRecaptcha, script( 32 | s"""function onSubmit(token){ 33 | | document.getElementById('demo-form').submit(); 34 | |}""".stripMargin 35 | )) 36 | ), 37 | body( 38 | ifTrue(request.flash.data.nonEmpty, 39 | div(style:="background:lightblue;height:40px;", "Recaptcha response:" + request.flash.data.values.mkString(",")) 40 | ), 41 | hr, 42 | form(action := endpoint.url, method:="POST", id:="demo-form", ifTrue(isMultipart, enctype := "multipart/form-data"))( 43 | div( 44 | label("Blog"), 45 | input(tpe:="text", name:="blog", placeholder:="Write your blog") 46 | ), 47 | button(cls:="ui button g-recaptcha", cls:="g-recaptcha", tpe:="submit", name:="action", value:="submit", data.sitekey:=siteKey, data.callback:="onSubmit", data.action:="submit", "Submit"), 48 | button(cls:="ui button g-recaptcha", cls:="g-recaptcha", tpe:="submit", name:="action", value:="cancel", data.sitekey:=siteKey, data.callback:="onSubmit", data.action:="cancel", "Cancel") 49 | ) 50 | ) 51 | ) 52 | -------------------------------------------------------------------------------- /src/test/scala/com/greenfossil/thorium/RecaptchaMainTestService.scala: -------------------------------------------------------------------------------- 1 | package com.greenfossil.thorium 2 | 3 | import com.greenfossil.thorium.decorators.RecaptchaGuardModule 4 | import com.linecorp.armeria.server.ServiceRequestContext 5 | 6 | import java.time.Duration 7 | 8 | object RecaptchaMainTestService: 9 | 10 | def recaptchaPathVerificationFn(path: String, ctx: ServiceRequestContext):Boolean = 11 | //prone to misconfigurations and not good for refactoring 12 | path.matches("/recaptcha/guarded-form|/recaptcha/multipart-form") 13 | 14 | @main 15 | def recaptchaMain = 16 | val server = Server(8080) 17 | .addServices(RecaptchaServices) 18 | .addThreatGuardModule(RecaptchaGuardModule(recaptchaPathVerificationFn)) 19 | .serverBuilderSetup(_.requestTimeout(Duration.ofHours(1))) 20 | .start() 21 | 22 | server.serviceConfigs foreach { c => 23 | println(s"c.route() = ${c.route()}") 24 | } 25 | println(s"Server started... ${Thread.currentThread()}") -------------------------------------------------------------------------------- /src/test/scala/com/greenfossil/thorium/RecaptchaPlaywright_Stress_Suite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | */ 17 | 18 | package com.greenfossil.thorium 19 | 20 | 21 | import munit.IgnoreSuite 22 | 23 | import scala.util.Random 24 | 25 | @IgnoreSuite 26 | class RecaptchaPlaywright_Stress_Suite extends munit.FunSuite: 27 | 28 | /** 29 | * Before running this testsuite, ensures that RecaptchaMainTestService is running. 30 | */ 31 | import com.microsoft.playwright.* 32 | 33 | import java.util.concurrent.CountDownLatch 34 | import concurrent.ExecutionContext.Implicits.global 35 | import scala.concurrent.Future 36 | 37 | test("playwright test") { 38 | val browserType = BrowserType.LaunchOptions() //.setTimeout(2000).setHeadless(false).setSlowMo(2000) 39 | val browserCnt = 5 40 | val loop = 2 41 | val browsersTup2: List[(Browser, Int)] = 42 | (1 to browserCnt).map(j => (Playwright.create().chromium().launch(browserType), j)).toList 43 | 44 | val startLatch = CountDownLatch(1) 45 | val endLatch = CountDownLatch(loop * browserCnt) 46 | 47 | browsersTup2.foreach { (browser, index) => 48 | submitActionPerBrowser(browser, index, loop, startLatch, endLatch) 49 | } 50 | 51 | println(s"start all requests") 52 | startLatch.countDown() 53 | println("Waiting for all requests to finish") 54 | endLatch.await() 55 | browsersTup2.foreach(_._1.close()) 56 | println("Terminating test") 57 | } 58 | 59 | private def submitActionPerBrowser(browser: Browser, i: Int, loop: Int, startLatch: CountDownLatch, endLatch: CountDownLatch): Future[Unit] = 60 | Future: 61 | startLatch.await() 62 | println(s"Starting creating submit page for browser $i...") 63 | 1 to loop foreach { j => 64 | val index: String = s"$i-$j" 65 | try { 66 | val bc = browser.newContext() 67 | val page = bc.newPage() 68 | page.navigate("http://localhost:8080/recaptcha/form-stress-test2") 69 | println(s"page index:${index} = ${page.url()}") 70 | val tag = s"N:$index" 71 | page.querySelector("input[name=blog]").fill(tag) 72 | val action = if Random().nextBoolean() then "submit" else "cancel" 73 | println(s"action = ${action}") 74 | val submitButtonSelector = s"button[data-action='$action']" 75 | page.querySelector(submitButtonSelector).click() 76 | endLatch.countDown() 77 | } catch { 78 | case ex: Throwable => 79 | ex.printStackTrace() 80 | browser.close() 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/test/scala/com/greenfossil/thorium/RecaptchaServices.scala: -------------------------------------------------------------------------------- 1 | package com.greenfossil.thorium 2 | 3 | import com.greenfossil.thorium.{*, given} 4 | import com.linecorp.armeria.server.annotation.{Get, Post} 5 | 6 | import scala.language.implicitConversions 7 | 8 | object RecaptchaServices: 9 | /* 10 | * Note: recaptcha sign up information is in the link below, and copied here http://www.google.com/recaptcha/admin 11 | * https://developers.google.com/recaptcha 12 | * https://cloud.google.com/recaptcha-enterprise/docs/setup-overview-web 13 | * https://www.youtube.com/watch?v=dyA_Pbtbn_E - How to use Google reCaptcha v3 in your Spring Boot application 14 | * vue-recaptcha v3 15 | * Recaptcha V2 Invisible 16 | * Client side integration - https://developers.google.com/recaptcha/docs/invisible 17 | * Server side integration - https://developers.google.com/recaptcha/docs/verify 18 | */ 19 | 20 | /** 21 | * explicit Recaptcha verification Recaptcha#verify done in the post method 22 | * @return 23 | */ 24 | @Get("/recaptcha/form") 25 | def recaptchaForm: Action = Action { implicit request => 26 | RecaptchaFormPage(postRecaptchaForm.endpoint, isMultipart = false) 27 | } 28 | 29 | /** 30 | * explicit Recaptcha verification Recaptcha#verify done in the post method 31 | * @return 32 | */ 33 | @Post("/recaptcha/form") 34 | def postRecaptchaForm: Action = Action { implicit request => 35 | Recaptcha.verify 36 | .fold( 37 | ex => Unauthorized(s"Recaptcha exception - ${ex.getMessage}"), 38 | recaptcha => 39 | if recaptcha.success then Redirect(recaptchaForm).flashing("success" -> s"Recaptcha response:[$recaptcha]") 40 | else Unauthorized(s"Unauthorize access - $recaptcha") 41 | ) 42 | } 43 | 44 | /** 45 | * explicit Recaptcha verification Recaptcha#onVerify done in the post method 46 | * 47 | * @return 48 | */ 49 | @Get("/recaptcha/form2") 50 | def recaptchaForm2: Action = Action { implicit request => 51 | RecaptchaFormPage(postRecaptchaForm2.endpoint, isMultipart = false) 52 | } 53 | 54 | /** 55 | * explicit Recaptcha verification Recaptcha#onVerify done in the post method 56 | * 57 | * @return 58 | */ 59 | @Post("/recaptcha/form2") 60 | def postRecaptchaForm2: Action = Action { implicit request => 61 | Recaptcha.onVerified(r => r.success){ 62 | Redirect(recaptchaForm2).flashing("success" -> s"Recaptcha response success:${request.recaptchaResponse}") 63 | } 64 | } 65 | 66 | @Get("/recaptcha/form-stress-test") 67 | def recaptchaFormStressTest: Action = Action { implicit request => 68 | RecaptchaFormPage(postRecaptchaFormStressTest.endpoint, isMultipart = false) 69 | } 70 | 71 | /** 72 | * No Recaptcha guarding 73 | * @return 74 | */ 75 | @Post("/recaptcha/form-stress-test") 76 | def postRecaptchaFormStressTest: Action = Action { implicit request => 77 | import com.greenfossil.data.mapping.Mapping.* 78 | tuple("blog" -> text, "action" -> text).bindFromRequest() 79 | .fold( 80 | error => BadRequest("Bad Request"), 81 | (blog, action) => 82 | s"Success - request #${blog} action:$action" 83 | ) 84 | } 85 | 86 | @Get("/recaptcha/form-stress-test2") 87 | def recaptchaFormStressTest2: Action = Action { implicit request => 88 | RecaptchaFormPage(postRecaptchaFormStressTest2.endpoint, isMultipart = false, useRecaptcha = true) 89 | } 90 | 91 | /** 92 | * explicit Recaptcha verification Recaptcha#onVerify done in the post method 93 | * @return 94 | */ 95 | @Post("/recaptcha/form-stress-test2") 96 | def postRecaptchaFormStressTest2: Action = Action { implicit request => 97 | Recaptcha.onVerified(r => r.success) { 98 | import com.greenfossil.data.mapping.Mapping.* 99 | tuple("blog" -> text, "g-recaptcha-response" -> seq[String]).bindFromRequest() 100 | .fold( 101 | error => 102 | BadRequest("Bad Request " + error), 103 | (blog, xs) => 104 | s"Success - request #${blog} g-recaptcha-response:${xs.mkString}" 105 | ) 106 | } 107 | } 108 | 109 | /** 110 | * RecaptchaGuardModule 111 | * 112 | * @return 113 | */ 114 | @Get("/recaptcha/guarded-form") 115 | def recaptchaGuardedForm: Action = Action { implicit request => 116 | RecaptchaFormPage(postRecaptchaGuardedForm.endpoint, isMultipart = false) 117 | } 118 | 119 | /** 120 | * RecaptchaGuardModule 121 | * 122 | * @return 123 | */ 124 | @Post("/recaptcha/guarded-form") 125 | def postRecaptchaGuardedForm: Action = Action { implicit request => 126 | Redirect(recaptchaGuardedForm).flashing("success" -> s"Recaptcha response success:[${request.recaptchaResponse}]") 127 | } 128 | 129 | 130 | /** 131 | * RecaptchaGuardModule 132 | * 133 | * @return 134 | */ 135 | @Get("/recaptcha/multipart-form") 136 | def recaptchaMultipartForm: Action = Action { implicit request => 137 | RecaptchaFormPage(postRecaptchaMulitpartForm.endpoint, isMultipart = true) 138 | } 139 | 140 | /** 141 | * RecaptchaGuardModule 142 | * 143 | * @return 144 | */ 145 | @Post("/recaptcha/multipart-form") 146 | def postRecaptchaMulitpartForm: Action = Action.multipart { implicit request => 147 | Redirect(recaptchaMultipartForm).flashing("success" -> s"Recaptcha response success:[${request.recaptchaResponse}]") 148 | } -------------------------------------------------------------------------------- /src/test/scala/com/greenfossil/thorium/RecaptchaSuite.scala: -------------------------------------------------------------------------------- 1 | package com.greenfossil.thorium 2 | 3 | import com.greenfossil.commons.json.Json 4 | import munit.IgnoreSuite 5 | 6 | @IgnoreSuite 7 | class RecaptchaSuite extends munit.FunSuite { 8 | 9 | test("GoogleRecaptchaUtil.siteVerify"){ 10 | val v2SecretKey = "" 11 | Recaptcha.siteVerify("bad-token", v2SecretKey, 1000) 12 | .fold( 13 | ex => fail("Request should succeed", ex), 14 | recaptchaResponse => 15 | assertNoDiff(recaptchaResponse.jsValue.stringify, """{"success":false,"error-codes":["invalid-input-secret"]}""") 16 | ) 17 | } 18 | 19 | test("V2 parser"){ 20 | val json = """{"success":true,"challenge_ts":"2024-01-30T09:22:27Z","hostname":"localhost","action":"submit"}""" 21 | val r = Recaptcha(Json.parse(json)) 22 | assertEquals(r.success, true) 23 | assertEquals(r.scoreOpt, None) 24 | assertEquals(r.actionOpt, Option("submit")) 25 | assertEquals(r.challengeTS, Option(java.time.Instant.parse("2024-01-30T09:22:27Z"))) 26 | assertNoDiff(r.hostname, "localhost") 27 | } 28 | 29 | test("V3 parser") { 30 | val json = """{"success":true,"challenge_ts":"2024-01-30T09:39:46Z","hostname":"localhost","score":0.9,"action":"submit"}""" 31 | val r = Recaptcha(Json.parse(json)) 32 | assertEquals(r.success, true) 33 | assertEquals(r.scoreOpt, Option(0.9)) 34 | assertEquals(r.actionOpt, Option("submit")) 35 | assertEquals(r.challengeTS, Option(java.time.Instant.parse("2024-01-30T09:39:46Z"))) 36 | assertNoDiff(r.hostname, "localhost") 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /src/test/scala/com/greenfossil/thorium/Recaptcha_Post_Formdata_Stress_Suite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | */ 17 | 18 | package com.greenfossil.thorium 19 | 20 | import com.greenfossil.thorium.decorators.RecaptchaGuardModule 21 | import com.linecorp.armeria.common.MediaType 22 | 23 | import java.net.http.{HttpClient, HttpResponse} 24 | import java.net.{URI, http} 25 | import java.time.Duration 26 | 27 | 28 | class Recaptcha_Post_Formdata_Stress_Suite extends munit.FunSuite: 29 | 30 | private def startServer(addRecaptchaGuardModule: Boolean = true): Server = 31 | val server = Server(0) 32 | .addServices(RecaptchaServices) 33 | .addThreatGuardModule( if !addRecaptchaGuardModule then null else RecaptchaGuardModule(RecaptchaMainTestService.recaptchaPathVerificationFn)) 34 | server.start() 35 | 36 | test("/recaptcha/guarded-form with RecaptchaGuardModule"): 37 | val server = startServer(addRecaptchaGuardModule = true) 38 | 39 | val n = 5 40 | val client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build() 41 | val xs = 1 to n map {i => 42 | val req = http.HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/recaptcha/guarded-form")) 43 | .POST(http.HttpRequest.BodyPublishers.ofString("g-recaptcha-response=bad-code")) 44 | .header("content-type", MediaType.FORM_DATA.toString) 45 | .build() 46 | val resp = client.send(req, HttpResponse.BodyHandlers.ofString()) 47 | assertEquals(resp.statusCode(), 401) 48 | i 49 | } 50 | 51 | assertEquals(xs.size, n) 52 | 53 | server.stop() 54 | 55 | -------------------------------------------------------------------------------- /src/test/scala/com/greenfossil/thorium/Recaptcha_Post_Multipart_Stress_Suite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | */ 17 | 18 | package com.greenfossil.thorium 19 | 20 | import com.greenfossil.thorium.decorators.RecaptchaGuardModule 21 | import io.github.yskszk63.jnhttpmultipartformdatabodypublisher.MultipartFormDataBodyPublisher 22 | 23 | import java.net.URI 24 | import java.net.http.{HttpClient, HttpRequest, HttpResponse} 25 | 26 | class Recaptcha_Post_Multipart_Stress_Suite extends munit.FunSuite: 27 | 28 | private def startServer(addRecaptchaGuardModule: Boolean = true): Server = 29 | val server = Server(0) 30 | .addServices(RecaptchaServices) 31 | .addThreatGuardModule( if !addRecaptchaGuardModule then null else RecaptchaGuardModule(RecaptchaMainTestService.recaptchaPathVerificationFn)) 32 | server.start() 33 | 34 | test("/recaptcha/multipart-form with RecaptchaGuardModule"): 35 | val server = startServer(addRecaptchaGuardModule = true) 36 | 37 | val n = 5 38 | val xs = 1 to n map { i => 39 | val mpPub = MultipartFormDataBodyPublisher().add("g-recaptcha-response", "bad-code") 40 | val resp = HttpClient.newHttpClient() 41 | .send( 42 | HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/recaptcha/multipart-form")) 43 | .POST(mpPub) 44 | .header("Content-Type", mpPub.contentType()) 45 | .build(), 46 | HttpResponse.BodyHandlers.ofString() 47 | ) 48 | assertEquals(resp.statusCode(), 401) 49 | assertNoDiff(resp.body(), """| 50 | | 51 | | 52 | | Unauthorized Access 53 | | 54 | | 55 | |

Access Denied

56 | | 57 | | 58 | |""".stripMargin) 59 | } 60 | 61 | assertEquals(xs.size, n) 62 | 63 | server.stop() 64 | 65 | -------------------------------------------------------------------------------- /src/test/scala/com/greenfossil/thorium/Recaptcha_Post_Suite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | */ 17 | 18 | package com.greenfossil.thorium 19 | 20 | import com.greenfossil.thorium.decorators.RecaptchaGuardModule 21 | import com.linecorp.armeria.common.HttpStatus 22 | import io.github.yskszk63.jnhttpmultipartformdatabodypublisher.MultipartFormDataBodyPublisher 23 | import munit.IgnoreSuite 24 | 25 | import java.net.URI 26 | import java.net.http.{HttpClient, HttpRequest, HttpResponse} 27 | import java.time.Duration 28 | 29 | @IgnoreSuite 30 | class Recaptcha_Post_Suite extends munit.FunSuite: 31 | 32 | private def startServer(addRecaptchaGuardModule: Boolean = true): Server = 33 | val server = Server(0) 34 | .addServices(RecaptchaServices) 35 | .addThreatGuardModule( if !addRecaptchaGuardModule then null else RecaptchaGuardModule(RecaptchaMainTestService.recaptchaPathVerificationFn)) 36 | server.start() 37 | 38 | test("/recaptcha/form"): 39 | val server = startServer() 40 | 41 | val resp = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build() 42 | .send( 43 | HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/recaptcha/form")) 44 | .POST(HttpRequest.BodyPublishers.noBody()) 45 | .build(), 46 | HttpResponse.BodyHandlers.ofString() 47 | ) 48 | 49 | server.stop() 50 | assertEquals(resp.statusCode(), HttpStatus.UNAUTHORIZED.code()) 51 | assertNoDiff(resp.body(), "Recaptcha exception - No recaptcha token found") 52 | 53 | test("/recaptcha/form - with an invalid g-recaptcha-response"): 54 | val server = startServer() 55 | 56 | val resp = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build() 57 | .send( 58 | HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/recaptcha/form")) 59 | .POST(HttpRequest.BodyPublishers.ofString("g-recaptcha-response=bad-code")) 60 | .build(), 61 | HttpResponse.BodyHandlers.ofString() 62 | ) 63 | 64 | server.stop() 65 | assertEquals(resp.statusCode(), HttpStatus.UNAUTHORIZED.code()) 66 | assertNoDiff(resp.body(), """Unauthorize access - Recaptcha({"success":false,"error-codes":["invalid-input-secret"]})""") 67 | 68 | test("/recaptcha/guarded-form with RecaptchaGuardModule"): 69 | val server = startServer() 70 | 71 | val resp = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build() 72 | .send( 73 | HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/recaptcha/guarded-form")) 74 | .POST(HttpRequest.BodyPublishers.ofString("g-recaptcha-response=bad-code")) 75 | .build(), 76 | HttpResponse.BodyHandlers.ofString() 77 | ) 78 | 79 | server.stop() 80 | assertEquals(resp.statusCode(), HttpStatus.UNAUTHORIZED.code()) 81 | assertNoDiff(resp.body(), """| 82 | | 83 | | 84 | | Unauthorized Access 85 | | 86 | | 87 | |

Access Denied

88 | | 89 | | 90 | |""".stripMargin) 91 | 92 | test("/recaptcha/guarded-form with no RecaptchaGuardModule"): 93 | val server = startServer(false) 94 | 95 | val resp = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build() 96 | .send( 97 | HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/recaptcha/guarded-form")) 98 | .POST(HttpRequest.BodyPublishers.ofString("g-recaptcha-response=bad-code")) 99 | .build(), 100 | HttpResponse.BodyHandlers.ofString() 101 | ) 102 | 103 | server.stop() 104 | assertEquals(resp.statusCode(), HttpStatus.SEE_OTHER.code()) 105 | assertNoDiff(resp.body(), "") 106 | 107 | 108 | test("/recaptcha/multipart-form with RecaptchaGuardModule"): 109 | val server = startServer() 110 | 111 | val mpPub = MultipartFormDataBodyPublisher().add("g-recaptcha-response", "bad-code") 112 | val resp = HttpClient.newHttpClient() 113 | .send( 114 | HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/recaptcha/guarded-form")) 115 | .POST(mpPub) 116 | .header("Content-Type",mpPub.contentType()) 117 | .build(), 118 | HttpResponse.BodyHandlers.ofString() 119 | ) 120 | 121 | server.stop() 122 | assertEquals(resp.statusCode(), HttpStatus.UNAUTHORIZED.code) 123 | assertNoDiff(resp.body(), 124 | """| 125 | | 126 | | 127 | | Unauthorized Access 128 | | 129 | | 130 | |

Access Denied

131 | | 132 | | 133 | |""".stripMargin) 134 | 135 | 136 | test("/recaptcha/multipart-form without RecaptchaGuardModule"): 137 | val server = startServer(addRecaptchaGuardModule = false) 138 | 139 | val mpPub = MultipartFormDataBodyPublisher().add("g-recaptcha-response", "bad-code") 140 | val resp = HttpClient.newHttpClient() 141 | .send( 142 | HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/recaptcha/guarded-form")) 143 | .POST(mpPub) 144 | .header("Content-Type", mpPub.contentType()) 145 | .build(), 146 | HttpResponse.BodyHandlers.ofString() 147 | ) 148 | 149 | server.stop() 150 | assertEquals(resp.statusCode(), HttpStatus.SEE_OTHER.code()) 151 | assertNoDiff(resp.body(),"") 152 | -------------------------------------------------------------------------------- /src/test/scala/com/greenfossil/thorium/RegexEndpointSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | import com.linecorp.armeria.server.annotation.{Get, Param} 20 | 21 | class RegexEndpointSuite extends munit.FunSuite{ 22 | object ParameterizedServices { 23 | @Get("regex:^/ci/(?(?i)Homer|Marge)") 24 | def caseInsensitivity(@Param name: String) = name 25 | 26 | @Get("regex:(?i)^/ci2/(?Homer|Marge)") 27 | def caseInsensitivity2(@Param name: String) = name 28 | 29 | @Get("regex:^/(?i)ci3(?-i)/(?Homer|Marge)") 30 | def caseInsensitivity3(@Param name: String) = name 31 | 32 | @Get("regex:^/ci4/(?(?i)Homer|Marge(?-i))") 33 | def caseInsensitivity4(@Param name: String) = name 34 | } 35 | 36 | test("caseInsensitivity"){ 37 | val ep = EndpointMcr(ParameterizedServices.caseInsensitivity("homer")) 38 | assertNoDiff(ep.path, "/ci/homer") 39 | } 40 | 41 | test("caseInsensitivity2") { 42 | val ep = EndpointMcr(ParameterizedServices.caseInsensitivity2("homer")) 43 | assertNoDiff(ep.path, "/ci2/homer") 44 | } 45 | 46 | test("caseInsensitivity3") { 47 | val ep = EndpointMcr(ParameterizedServices.caseInsensitivity3("homer")) 48 | assertNoDiff(ep.path, "/ci3/homer") 49 | } 50 | 51 | test("caseInsensitivity4") { 52 | val ep = EndpointMcr(ParameterizedServices.caseInsensitivity4("homer")) 53 | assertNoDiff(ep.path, "/ci4/homer") 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /src/test/scala/com/greenfossil/thorium/ResponseHeaderSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | class ResponseHeaderSuite extends munit.FunSuite { 20 | 21 | test("ResponseHeader case insenitivity"){ 22 | val header = ResponseHeader(Map("a" -> "apple", "b" -> "basket")) 23 | assertNoDiff(header.headers("a"), "apple") 24 | assertNoDiff(header.headers("A"), "apple") 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /src/test/scala/com/greenfossil/thorium/SessionSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | import com.linecorp.armeria.common.HttpStatus 20 | import com.linecorp.armeria.server.annotation.Get 21 | 22 | import java.net.http.{HttpClient, HttpRequest, HttpResponse} 23 | import java.net.{CookieManager, URI} 24 | 25 | object SessionServices { 26 | @Get("/s0") 27 | def s0 = Action { request => 28 | Redirect("/s1", HttpStatus.SEE_OTHER).withNewSession.flashing("News Flash" ->"Flash Value") 29 | } 30 | 31 | @Get("/s1") 32 | def s1 = Action { request => 33 | Redirect("/s2").withSession(request.session + ("foo" -> "foo")) 34 | } 35 | 36 | @Get("/s2") 37 | def s2 = Action {request => 38 | Redirect("/s3").withSession(request.session + ("baz" -> "baz")) 39 | } 40 | 41 | @Get("/s3") 42 | def s3 = Action {request => 43 | Redirect("/s4").withSession(request.session + ("foobaz" -> "foobaz")) 44 | } 45 | 46 | @Get("/s4") 47 | def s4 = Action { request => 48 | assert(request.session.size == 3) 49 | Ok(s"S4 reached ${request.session}") 50 | } 51 | } 52 | 53 | class SessionSuite extends munit.FunSuite { 54 | 55 | test("Session, Flash propagation") { 56 | val server = Server(0) 57 | .addServices(SessionServices) 58 | .start() 59 | 60 | val cm = CookieManager() 61 | val req = HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/s0")).build() 62 | val resp = HttpClient.newBuilder() 63 | .followRedirects(HttpClient.Redirect.NORMAL) 64 | .cookieHandler(cm) 65 | .build() 66 | .send(req, HttpResponse.BodyHandlers.ofString()) 67 | assert(resp.body().startsWith("S4 reached")) 68 | assertEquals(cm.getCookieStore.getCookies.size(), 1) 69 | assertNoDiff(cm.getCookieStore.getCookies.stream().filter(_.getName == "APP_SESSION").findFirst().get.getName, "APP_SESSION") 70 | server.stop() 71 | } 72 | 73 | } 74 | -------------------------------------------------------------------------------- /src/test/scala/com/greenfossil/thorium/ThreatGuardModule_Stress_Suite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | */ 17 | 18 | package com.greenfossil.thorium 19 | 20 | import io.github.yskszk63.jnhttpmultipartformdatabodypublisher.MultipartFormDataBodyPublisher 21 | 22 | import java.net.URI 23 | import java.net.http.{HttpClient, HttpRequest, HttpResponse} 24 | 25 | class ThreatGuardModule_Stress_Suite extends munit.FunSuite: 26 | 27 | val tokenName = "token-name" 28 | val tokenValue = "token-value" 29 | private def startServer: Server = 30 | val server = Server(0) 31 | .addServices(ThreatGuardServices) 32 | .addThreatGuardModule(ThreatGuardTestModule(tokenName, tokenValue)) 33 | server.start() 34 | 35 | test("/recaptcha/multipart-form with RecaptchaGuardModule"): 36 | val server = startServer 37 | 38 | val n = 5 39 | val xs = 1 to n map { i => 40 | val mpPub = MultipartFormDataBodyPublisher().add(tokenName, tokenValue) 41 | val resp = HttpClient.newHttpClient() 42 | .send( 43 | HttpRequest.newBuilder(URI.create(s"http://localhost:${server.port}/threat-guard/multipart-form")) 44 | .POST(mpPub) 45 | .header("Content-Type", mpPub.contentType()) 46 | .build(), 47 | HttpResponse.BodyHandlers.ofString() 48 | ) 49 | assertEquals(resp.statusCode(), 200) 50 | assertNoDiff(resp.body(), "success FormUrlEndcoded(Map(token-name -> List(token-value)))") 51 | } 52 | 53 | assertEquals(xs.size, n) 54 | 55 | server.stop() -------------------------------------------------------------------------------- /src/test/scala/com/greenfossil/thorium/ThreatGuardServices.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | */ 17 | 18 | package com.greenfossil.thorium 19 | 20 | import com.linecorp.armeria.server.annotation.Post 21 | 22 | /* 23 | * To stress test multipart form 24 | */ 25 | object ThreatGuardServices: 26 | 27 | @Post("/threat-guard/multipart-form") 28 | def postMulitpartForm: Action = Action.multipart { implicit request => 29 | Ok(s"success ${request.asFormUrlEncoded}") 30 | } -------------------------------------------------------------------------------- /src/test/scala/com/greenfossil/thorium/ThreatGuardTestModule.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | */ 17 | 18 | package com.greenfossil.thorium 19 | 20 | import com.greenfossil.thorium.decorators.ThreatGuardModule 21 | import com.linecorp.armeria.common.HttpRequest 22 | import com.linecorp.armeria.server.{HttpService, ServiceRequestContext} 23 | import org.slf4j.{Logger, LoggerFactory} 24 | 25 | import java.util.concurrent.CompletableFuture 26 | 27 | class ThreatGuardTestModule(tokenName: String, tokenValue:String) extends ThreatGuardModule: 28 | override protected val logger: Logger = LoggerFactory.getLogger("threatguard-test-module") 29 | override def isSafe(delegate: HttpService, ctx: ServiceRequestContext, req: HttpRequest): CompletableFuture[Boolean] = 30 | extractTokenValue(ctx, tokenName) 31 | .thenApply: token => 32 | token == tokenValue 33 | -------------------------------------------------------------------------------- /src/test/scala/com/greenfossil/thorium/UrlPrefixSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Greenfossil Pte Ltd 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 | 17 | package com.greenfossil.thorium 18 | 19 | import com.linecorp.armeria.server.annotation.{Get, Param} 20 | 21 | object Service1: 22 | @Get("/redirect1") 23 | def redirect1 = Action{request => 24 | Redirect(foo) 25 | } 26 | 27 | @Get("/foo") 28 | def foo = Action{request => 29 | Ok("Foo") 30 | } 31 | 32 | object Service2: 33 | @Get("/bar") 34 | def bar = Action{request => 35 | Ok("Bar") 36 | } 37 | 38 | @Get("/site/favicon") 39 | def favicon = Action{request => 40 | Ok("favicon") 41 | } 42 | 43 | @Get("/param/:str") 44 | def param(@Param str: String) = Action{request => 45 | Ok(str) 46 | } 47 | 48 | @Get("/query") 49 | def query(@Param str: String) = Action{request => 50 | Ok(str) 51 | } 52 | 53 | @Get("regex:^/(?\\d+)$") 54 | def number(@Param num: Int) = Action{ request => 55 | Ok(num.toString) 56 | } 57 | 58 | @Get("prefix:/test") 59 | def test = Action{request => 60 | request.requestContext.mappedPath() 61 | Ok(request.uri.toString) 62 | } 63 | 64 | class UrlPrefixSuite extends munit.FunSuite { 65 | test("route patterns"){ 66 | val server = Server(0) 67 | .addServices(Service1) 68 | .addServices(Service2) 69 | .serverBuilderSetup(sb => { 70 | sb 71 | .annotatedService("/ext", Service2) 72 | .serviceUnder("/ext2", Service1.foo) 73 | }) 74 | .start() 75 | 76 | server.stop() 77 | val routePatterns = server.serviceConfigs.map(_.route().patternString()) 78 | assertEquals(routePatterns.sorted, List( 79 | // direct routes 80 | "/foo", "/redirect1", "/bar", 81 | "^/(?\\d+)$", "/query", "/test/*", "/param/:str", 82 | //annotatedService routes 83 | "/site/favicon", 84 | "/ext/bar", 85 | "/ext/^/(?\\d+)$", 86 | "/ext/query", 87 | "/ext/test/*", 88 | "/ext/param/:str", 89 | "/ext/site/favicon", 90 | // serviceUnder routes 91 | "/ext2/*" 92 | ).sorted) 93 | } 94 | 95 | test("annotatedService with path prefix"){ 96 | val server = Server(0) 97 | .addServices(Service1) 98 | .addServices(Service2) 99 | .serverBuilderSetup(sb => { 100 | sb 101 | .annotatedService("/ext", Service2) 102 | }) 103 | .start() 104 | server.stop() 105 | 106 | assertEquals(Service2.bar.endpoint.url, "/bar") 107 | assertEquals(Service2.bar.endpoint.prefixedUrl("", server.serviceConfigs), "/ext/bar") 108 | 109 | assertEquals(Service2.param("hello").endpoint.url, "/param/hello") 110 | assertEquals(Service2.param("hello").endpoint.prefixedUrl("", server.serviceConfigs), "/ext/param/hello") 111 | 112 | assertEquals(Service2.query("hello").endpoint.url, "/query?str=hello") 113 | assertEquals(Service2.query("hello").endpoint.prefixedUrl("", server.serviceConfigs), "/ext/query?str=hello") 114 | 115 | assertEquals(Service2.number(1).endpoint.url, "/1") 116 | assertEquals(Service2.number(1).endpoint.prefixedUrl("", server.serviceConfigs), "/ext/1") 117 | 118 | assertEquals(Service2.test.endpoint.url, "/test") 119 | assertEquals(Service2.test.endpoint.prefixedUrl("", server.serviceConfigs), "/ext/test") 120 | 121 | //Test non macro created Endpoint 122 | assertEquals(Endpoint("/test/path").url, "/test/path") 123 | assertEquals(Endpoint("/test/path", "prefix:/test").prefixedUrl("", server.serviceConfigs), "/ext/test/path") 124 | assertEquals(Endpoint("/test/path", Service2.test.endpoint.pathPatternOpt.get).prefixedUrl("", server.serviceConfigs), "/ext/test/path") 125 | } 126 | 127 | test("prefixUrl2 - with prefix routes"){ 128 | val server = Server(0) 129 | .addServices(Service2) 130 | .serverBuilderSetup(sb => { 131 | sb.annotatedService("/ext", Service2) 132 | sb.annotatedService("/ext2", Service2) 133 | }) 134 | .start() 135 | server.stop() 136 | //Ensures the Endpoint path has prepended with prefix '/ext' depending request path 137 | //1. without prefix 138 | assertEquals(Endpoint("/test/foo", "prefix:/test").prefixedUrl("/test/bar", server.serviceConfigs), "/test/foo") 139 | //2. with prefix /ext 140 | assertEquals(Endpoint("/test/foo", "prefix:/test").prefixedUrl("/ext/test/bar", server.serviceConfigs), "/ext/test/foo") 141 | //3. with prefix /ext2 142 | assertEquals(Endpoint("/test/foo", "prefix:/test").prefixedUrl("/ext2/test/bar", server.serviceConfigs), "/ext2/test/foo") 143 | } 144 | 145 | test("prefixUrl2 - favicon route") { 146 | val server = Server(0) 147 | .addServices(Service2) 148 | .serverBuilderSetup(sb => { 149 | sb.annotatedService("/ext", Service2) 150 | sb.annotatedService("/ext2", Service2) 151 | }) 152 | .start() 153 | server.stop() 154 | assertEquals(Service2.favicon.endpoint.prefixedUrl("/bar", server.serviceConfigs), "/site/favicon") 155 | assertEquals(Service2.favicon.endpoint.prefixedUrl("/ext/bar", server.serviceConfigs), "/ext/site/favicon") 156 | } 157 | 158 | test("prefixUrl2 - with parameters") { 159 | val server = Server(0) 160 | .addServices(Service2) 161 | .serverBuilderSetup(sb => { 162 | sb.annotatedService("/ext", Service2) 163 | sb.annotatedService("/ext2", Service2) 164 | }) 165 | .start() 166 | server.stop() 167 | 168 | assertEquals(Service2.param("hello").endpoint.prefixedUrl("/bar", server.serviceConfigs), "/param/hello") 169 | assertEquals(Service2.param("hello").endpoint.prefixedUrl("/ext/bar", server.serviceConfigs), "/ext/param/hello") 170 | assertEquals(Service2.param("hello").endpoint.prefixedUrl("/ext2/bar", server.serviceConfigs), "/ext2/param/hello") 171 | 172 | assertEquals(Service2.query("hello").endpoint.prefixedUrl("/bar", server.serviceConfigs), "/query?str=hello") 173 | assertEquals(Service2.query("hello").endpoint.prefixedUrl("/ext/bar", server.serviceConfigs), "/ext/query?str=hello") 174 | assertEquals(Service2.query("hello").endpoint.prefixedUrl("/ext2/bar", server.serviceConfigs), "/ext2/query?str=hello") 175 | 176 | assertEquals(Service2.number(1).endpoint.prefixedUrl("/bar", server.serviceConfigs), "/1") 177 | assertEquals(Service2.number(1).endpoint.prefixedUrl("/ext/bar", server.serviceConfigs), "/ext/1") 178 | assertEquals(Service2.number(1).endpoint.prefixedUrl("/ext2/bar", server.serviceConfigs), "/ext2/1") 179 | } 180 | 181 | 182 | test("serviceUnder with path prefix".ignore){ 183 | val server = Server(0) 184 | .addServices(Service1) 185 | .addServices(Service2) 186 | .serverBuilderSetup(sb => { 187 | sb 188 | .serviceUnder("/ext", Service2.bar) 189 | }) 190 | .start() 191 | server.stop() 192 | 193 | assertEquals(Service2.bar.endpoint.url, "/bar") 194 | assertEquals(Service2.bar.endpoint.prefixedUrl("", server.serviceConfigs), "/ext/bar") 195 | } 196 | 197 | } 198 | -------------------------------------------------------------------------------- /src/test/scala/com/greenfossil/thorium/VirtualHostSuite.scala: -------------------------------------------------------------------------------- 1 | package com.greenfossil.thorium 2 | 3 | import com.linecorp.armeria.common 4 | import com.linecorp.armeria.common.SessionProtocol 5 | 6 | import java.net.URI 7 | import java.net.http.{HttpClient, HttpRequest, HttpResponse} 8 | 9 | object Host1Services extends AbstractTestServices("host1") 10 | 11 | object Host2Services extends AbstractTestServices("host2") 12 | 13 | 14 | class VirtualHostSuite extends munit.FunSuite: 15 | 16 | private def httpSend(url: String): String = 17 | HttpClient.newBuilder() 18 | .followRedirects(HttpClient.Redirect.NORMAL) 19 | .build() 20 | .send( 21 | HttpRequest.newBuilder(URI.create(url)).build(), 22 | HttpResponse.BodyHandlers.ofString() 23 | ).body() 24 | 25 | test("contextPath") { 26 | val server = Server(8080) 27 | .serverBuilderSetup { 28 | _.port(8081, SessionProtocol.HTTP) 29 | .port(8082, SessionProtocol.HTTP) 30 | .virtualHost(8081) 31 | .annotatedService(Host1Services) 32 | .and 33 | .virtualHost(8082) 34 | .annotatedService(Host2Services) 35 | } 36 | 37 | val started = server.start() 38 | println("Sever started") 39 | 40 | started.printRoutes 41 | 42 | assertNoDiff(httpSend(s"http://localhost:8081/echo/hello"), "host1: hello!") 43 | assertNoDiff(httpSend(s"http://localhost:8081/hi"), "host1: Hello User") 44 | assertNoDiff(httpSend(s"http://localhost:8082/echo/hello"), "host2: hello!") 45 | assertNoDiff(httpSend(s"http://localhost:8082/hi"), "host2: Hello User") 46 | 47 | val stopped = started.stop() 48 | println("Stopped.") 49 | } 50 | -------------------------------------------------------------------------------- /src/test/scala/com/greenfossil/thorium/contextPath/ContextPathSuite.scala: -------------------------------------------------------------------------------- 1 | package com.greenfossil.thorium.contextPath 2 | 3 | class ContextPathSuite extends munit.FunSuite: 4 | 5 | test("ContextPath compilation") { 6 | import com.linecorp.armeria.common.HttpResponse 7 | import com.linecorp.armeria.server.Server 8 | 9 | /* 10 | * Inssue raised - 11 | * https://github.com/scala/scala3/issues/21797 12 | * https://github.com/line/armeria/issues/5947 13 | */ 14 | val server = Server.builder() 15 | .contextPath("/v1") 16 | .service("/", (ctx, req) => HttpResponse.of("Hello, world")) 17 | .and() 18 | .build() 19 | 20 | val started = server.start() 21 | started.get() 22 | server.stop() 23 | } 24 | -------------------------------------------------------------------------------- /src/test/scala/com/greenfossil/thorium/macros/AnnotatedActionMacroSupportSuite.scala: -------------------------------------------------------------------------------- 1 | package com.greenfossil.thorium.macros 2 | 3 | import com.greenfossil.thorium.* 4 | import com.linecorp.armeria.server.annotation.decorator.RequestTimeout 5 | import com.linecorp.armeria.server.annotation.{Get, Param, Path, Post} 6 | 7 | import java.util.concurrent.TimeUnit 8 | 9 | class AnnotatedActionMacroSupportSuite extends munit.FunSuite: 10 | 11 | @RequestTimeout(value=10, unit= TimeUnit.MINUTES) /*This is ignored, as only HttpMethod Annotation is processed*/ 12 | @Get("/action-ep") 13 | def actionEp = Action { _ => "action-endpoint" } 14 | 15 | @Get("/req-func-ep1") 16 | def reqFuncEp1(request: Request) = s"using endpoint Int:${request.path}" 17 | 18 | @Get("/req-func-ep2/:msg") 19 | def reqFuncEp2(@Param msg: String)(req: Request) = s"${req.path} content:$msg" 20 | 21 | @Get("/req-func-ep3") 22 | def reqFuncEp3(@Param msg: String) = (req: Request) => s"${req.path} content:$msg" 23 | 24 | @Post 25 | @Path("/req-func-ep4/:id/:name") 26 | def reqFuncEp4(@Param id: Long, @Param name: String) = Action { _ => "reqFuncEp4" } 27 | 28 | @Get 29 | @Path("/req-func-ep5/:id/:name") 30 | def reqFuncEp5(@Param id: Long, @Param name: String) = Action { _ => "reqFuncEp5" } 31 | 32 | @Get("/endpoint-params/:int/:str") 33 | def endpointParams(@Param int: Int)(@Param str: String) = "endpoint1" 34 | 35 | def fn = "string" 36 | 37 | val x = "abc" 38 | 39 | 40 | given Request = null 41 | 42 | test("convertPathToPathParts") { 43 | val paths = List( 44 | "/endpoint1" -> List("/endpoint1"), 45 | "/endpoint2/:name" -> List("/endpoint2", ":name"), 46 | "/endpoint3/:name/info/age/:num" -> List("/endpoint3", ":name", "info", "age", ":num"), 47 | "/endpoint5/:id/:name" -> List("/endpoint5", ":id", ":name"), 48 | "prefix:/howdy" -> List("/howdy"), 49 | "/braced-params/{name}/{age}/:contact" -> List("/braced-params", ":name", ":age", ":contact"), 50 | "regex:^/string/(?[^0-9]+)$" -> List("/string", ":name"), /*This path is different without ^$ "regex:/string/(?[^0-9]+)"*/ 51 | "regex:^/number/(?[0-9]+)$" -> List("/number", ":n1"), 52 | "regex:/string2/(?\\w+)/(?\\w+)" -> List("/string2", ":min", ":max"), 53 | "regex:/number2/(?\\d+)/(?\\d+)" -> List("/number2", ":min", ":max"), 54 | "regex:/mix/(?\\d+)/(?\\w+)" -> List("/mix", ":min", ":max"), 55 | "regex:/mix/(?\\w+)/(?\\d+)" -> List("/mix", ":min", ":max"), 56 | "regex:/mix/(?\\w+)/max/(?\\d+)" -> List("/mix", ":min", "max", ":max") 57 | ) 58 | 59 | val results = paths.map { (path, expectation) => 60 | (path, expectation, AnnotatedActionMacroSupport.convertPathToPathParts(path)) 61 | } 62 | 63 | results.foreach{ (path, expectation, result) => 64 | assertEquals(result, expectation) 65 | } 66 | } 67 | 68 | test("verifyActionTypeMcr"){ 69 | val results = List( 70 | AnnotatedActionMacroSupportTestImpl.verifyActionTypeMcr(actionEp) -> "com.greenfossil.thorium.Action", 71 | AnnotatedActionMacroSupportTestImpl.verifyActionTypeMcr(reqFuncEp1) -> "scala.Function1[com.greenfossil.thorium.Request, scala.Predef.String]", 72 | AnnotatedActionMacroSupportTestImpl.verifyActionTypeMcr(reqFuncEp2) -> "scala.Function1[scala.Predef.String, scala.Function1[com.greenfossil.thorium.Request, scala.Predef.String]]", 73 | AnnotatedActionMacroSupportTestImpl.verifyActionTypeMcr(reqFuncEp3) -> "scala.Function1[scala.Predef.String, scala.Function1[com.greenfossil.thorium.Request, scala.Predef.String]]", 74 | AnnotatedActionMacroSupportTestImpl.verifyActionTypeMcr(reqFuncEp4) -> "scala.Function2[scala.Long, scala.Predef.String, com.greenfossil.thorium.Action]", 75 | AnnotatedActionMacroSupportTestImpl.verifyActionTypeMcr(reqFuncEp5) -> "scala.Function2[scala.Long, scala.Predef.String, com.greenfossil.thorium.Action]", 76 | AnnotatedActionMacroSupportTestImpl.verifyActionTypeMcr(endpointParams) -> "scala.Function1[scala.Int, scala.Function1[scala.Predef.String, java.lang.String]]", 77 | AnnotatedActionMacroSupportTestImpl.verifyActionTypeMcr(fn) -> "java.lang.String", 78 | AnnotatedActionMacroSupportTestImpl.verifyActionTypeMcr(x) -> "java.lang.String", 79 | ) 80 | 81 | results.zipWithIndex.foreach{(x, index) => 82 | val (result, expectation) = x 83 | assertNoDiff(result, expectation) 84 | } 85 | } 86 | 87 | test("extractActionAnnotationsMcr"){ 88 | val results = List( 89 | AnnotatedActionMacroSupportTestImpl.extractActionAnnotationsMcr(actionEp) -> (2, 0), 90 | AnnotatedActionMacroSupportTestImpl.extractActionAnnotationsMcr(reqFuncEp1) -> (1, 0), 91 | AnnotatedActionMacroSupportTestImpl.extractActionAnnotationsMcr(reqFuncEp2) -> (1, 1), 92 | AnnotatedActionMacroSupportTestImpl.extractActionAnnotationsMcr(reqFuncEp3) -> (1, 1), 93 | AnnotatedActionMacroSupportTestImpl.extractActionAnnotationsMcr(reqFuncEp4) -> (2, 2), 94 | AnnotatedActionMacroSupportTestImpl.extractActionAnnotationsMcr(reqFuncEp5) -> (2, 2), 95 | AnnotatedActionMacroSupportTestImpl.extractActionAnnotationsMcr(endpointParams) -> (1, 2), 96 | ) 97 | 98 | results.zipWithIndex.foreach { (result, index) => 99 | val (obtained, expected) = result 100 | assertEquals(obtained, expected) 101 | } 102 | } 103 | 104 | test("extractHttpVerbMcr") { 105 | val results = List( 106 | AnnotatedActionMacroSupportTestImpl.extractHttpVerbMcr(actionEp) -> "Method: Get, Path: /action-ep" , 107 | AnnotatedActionMacroSupportTestImpl.extractHttpVerbMcr(reqFuncEp1) -> "Method: Get, Path: /req-func-ep1", 108 | AnnotatedActionMacroSupportTestImpl.extractHttpVerbMcr(reqFuncEp2) -> "Method: Get, Path: /req-func-ep2/:msg", 109 | AnnotatedActionMacroSupportTestImpl.extractHttpVerbMcr(reqFuncEp3) -> "Method: Get, Path: /req-func-ep3", 110 | AnnotatedActionMacroSupportTestImpl.extractHttpVerbMcr(reqFuncEp4) -> "Method: Post, Path: /req-func-ep4/:id/:name", 111 | AnnotatedActionMacroSupportTestImpl.extractHttpVerbMcr(reqFuncEp5) -> "Method: Get, Path: /req-func-ep5/:id/:name", 112 | AnnotatedActionMacroSupportTestImpl.extractHttpVerbMcr(endpointParams) -> "Method: Get, Path: /endpoint-params/:int/:str", 113 | ) 114 | 115 | results.zipWithIndex.foreach { (result, index) => 116 | val (obtained, expected) = result 117 | assertEquals(obtained, expected) 118 | } 119 | } 120 | 121 | test("computeActionAnnotatedPathMcr") { 122 | val results = List( 123 | AnnotatedActionMacroSupportTestImpl.computeActionAnnotatedPathMcr(actionEp) -> "/action-ep", 124 | AnnotatedActionMacroSupportTestImpl.computeActionAnnotatedPathMcr(reqFuncEp1) -> "/req-func-ep1", 125 | AnnotatedActionMacroSupportTestImpl.computeActionAnnotatedPathMcr(reqFuncEp2("homer!")) -> "/req-func-ep2/homer%21", 126 | AnnotatedActionMacroSupportTestImpl.computeActionAnnotatedPathMcr(reqFuncEp3("howdy!")) -> "/req-func-ep3?msg=howdy%21", 127 | AnnotatedActionMacroSupportTestImpl.computeActionAnnotatedPathMcr(reqFuncEp4(1, "hello")) -> "/req-func-ep4/1/hello", 128 | AnnotatedActionMacroSupportTestImpl.computeActionAnnotatedPathMcr(reqFuncEp5(1, "marge")) -> "/req-func-ep5/1/marge", 129 | AnnotatedActionMacroSupportTestImpl.computeActionAnnotatedPathMcr(endpointParams(1)("bart")) -> "/endpoint-params/1/bart", 130 | ) 131 | 132 | results.zipWithIndex.foreach { (result, index) => 133 | val (endpoint, expected) = result 134 | assertNoDiff(endpoint.url, expected) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/test/scala/com/greenfossil/thorium/macros/AnnotatedActionMacroSupportTestImpl.scala: -------------------------------------------------------------------------------- 1 | package com.greenfossil.thorium.macros 2 | 3 | import com.greenfossil.thorium.* 4 | 5 | object AnnotatedActionMacroSupportTestImpl: 6 | 7 | import quoted.* 8 | 9 | inline def verifyActionTypeMcr[A](inline ep: A): String = 10 | ${ verifyActionTypeImpl( '{ep} )} 11 | 12 | def verifyActionTypeImpl[A: Type](epExpr: Expr[A])(using q: Quotes): Expr[String] = 13 | import q.reflect.* 14 | AnnotatedActionMacroSupport.verifyActionType(epExpr) 15 | val _expr: String = TypeRepr.of[A].show 16 | Expr( _expr ) 17 | 18 | inline def extractActionAnnotationsMcr[A](inline fnExpr: A): (Int, Int) = 19 | ${ extractActionAnnotationsImpl( '{ fnExpr } ) } 20 | 21 | def extractActionAnnotationsImpl[A: Type](fnExpr: Expr[A])(using q: Quotes): Expr[(Int, Int)] = 22 | val (methodAnnotations, paramAnnotations) = AnnotatedActionMacroSupport.extractActionAnnotations(fnExpr) 23 | Expr( (methodAnnotations.size, paramAnnotations.size) ) 24 | 25 | inline def extractHttpVerbMcr[A](inline ep: A): String = 26 | ${ extractHttpVerbImpl( '{ep} ) } 27 | 28 | def extractHttpVerbImpl[A: Type](epExpr: Expr[A])(using q: Quotes): Expr[String] = 29 | val (methodAnnotations, paramAnnotations) = AnnotatedActionMacroSupport.extractActionAnnotations(epExpr) 30 | val (method, pathPattern) = AnnotatedActionMacroSupport.extractHttpVerb(methodAnnotations).getOrElse(("[]", "[]")) 31 | Expr(s"Method: $method, Path: $pathPattern") 32 | 33 | inline def computeActionAnnotatedPathMcr[A](inline ep:A): Endpoint = 34 | ${ computeActionAnnotatedPathImpl( '{ep} ) } 35 | 36 | def computeActionAnnotatedPathImpl[A: Type](epExpr: Expr[A])(using q: Quotes): Expr[Endpoint] = 37 | AnnotatedActionMacroSupport.computeActionAnnotatedPath( 38 | epExpr = epExpr, 39 | onSuccessCallback = (exprMethod, exprPathPartList, exprQueryParamKeys, exprQueryParamValues, exprPathPattern) => 40 | '{ 41 | Endpoint( 42 | ${exprPathPartList}.mkString("/"), 43 | ${exprMethod}, 44 | ${exprQueryParamKeys}.zip(${exprQueryParamValues}), 45 | Some(${exprPathPattern}) 46 | ) 47 | }, 48 | supportQueryStringPost = true 49 | ) 50 | 51 | -------------------------------------------------------------------------------- /thorium-buildinfo.sbt: -------------------------------------------------------------------------------- 1 | Compile / sourceGenerators += Def.task { 2 | val file = ( Compile / sourceManaged ).value / "com" / "greenfossil" / "thorium" / "ThoriumBuildInfo.scala" 3 | IO.write ( 4 | file, 5 | s"""package com.greenfossil.thorium 6 | |private [thorium] object ThoriumBuildInfo { 7 | | val version = "${version.value}" 8 | |} 9 | |""".stripMargin 10 | ) 11 | Seq(file) 12 | }.taskValue --------------------------------------------------------------------------------