diff --git a/.github/workflows/build-containers.yml b/.github/workflows/build-containers.yml new file mode 100644 index 0000000..0a17341 --- /dev/null +++ b/.github/workflows/build-containers.yml @@ -0,0 +1,66 @@ +name: Build Containers + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - name: Clone source code + uses: actions/checkout@v3 + with: + ref: 'main' + submodules: true + token: ${{ secrets.GH_PAT }} + + - name: Log in to Docker Registry + uses: docker/login-action@v2 + with: + registry: ${{ secrets.DOCKER_REGISTRY_URL }} + username: ${{ secrets.DOCKER_REGISTRY_USERNAME }} + password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }} + + - name: Set up JDK 19 + uses: actions/setup-java@v3 + with: + java-version: '19' + distribution: 'temurin' + + - name: Set up Gradle + uses: gradle/gradle-build-action@v2 + + - name: Build discovery container + run: ./gradlew :discovery:bootBuildImage --imageName=discovery:latest + + - name: Build gateway container + run: ./gradlew :gateway:bootBuildImage --imageName=gateway:latest + + - name: Build core container + run: ./gradlew :core:bootBuildImage --imageName=core:latest + + - name: Build url shortener container + run: ./gradlew :url-shortener:bootBuildImage --imageName=url-shortener:latest + + - name: Push containers to Docker Registry + run: | + docker tag discovery:latest ${{ secrets.DOCKER_REGISTRY_URL }}/osable/discovery:latest + docker push ${{ secrets.DOCKER_REGISTRY_URL }}/osable/discovery:latest + + docker tag gateway:latest ${{ secrets.DOCKER_REGISTRY_URL }}/osable/gateway:latest + docker push ${{ secrets.DOCKER_REGISTRY_URL }}/osable/gateway:latest + + docker tag core:latest ${{ secrets.DOCKER_REGISTRY_URL }}/osable/core:latest + docker push ${{ secrets.DOCKER_REGISTRY_URL }}/osable/core:latest + + docker tag url-shortener:latest ${{ secrets.DOCKER_REGISTRY_URL }}/osable/url-shortener:latest + docker push ${{ secrets.DOCKER_REGISTRY_URL }}/osable/url-shortener:latest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..26f4564 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Ignore .gradle, gradle, .idea and build directories +/.gradle/ +/gradle/ +/.idea/ +/build/ + +/build-logic/.gradle/ +/build-logic/build/ + +/core/.gradle/ +/core/build/ + +/gateway/.gradle/ +/gateway/build/ + +/discovery/build/ +/discovery/.gradle/ + +/url-shortener/build/ +/url-shortener/.gradle/ + +# Avoid leaking secrets, please +secrets.yaml + +# Don't include any local test blogs +/blogs/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..9519ae6 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "core/src/main/resources/frontend"] + path = core/src/main/resources/frontend + url = https://github.com/osable/frontend.git diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts new file mode 100644 index 0000000..8156a89 --- /dev/null +++ b/build-logic/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + `kotlin-dsl` +} + +repositories { + gradlePluginPortal() +} \ No newline at end of file diff --git a/build-logic/src/main/kotlin/default-module-config.gradle.kts b/build-logic/src/main/kotlin/default-module-config.gradle.kts new file mode 100644 index 0000000..be7b125 --- /dev/null +++ b/build-logic/src/main/kotlin/default-module-config.gradle.kts @@ -0,0 +1,13 @@ +plugins { + kotlin("jvm") + java + id("application") +} + +group = "net.osable" +version = "1.0-SNAPSHOT" + +repositories { + mavenCentral() + gradlePluginPortal() +} \ No newline at end of file diff --git a/build-logic/src/main/kotlin/default-spring-config.gradle.kts b/build-logic/src/main/kotlin/default-spring-config.gradle.kts new file mode 100644 index 0000000..e69de29 diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..32f69b4 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,40 @@ +import org.jetbrains.kotlin.gradle.plugin.extraProperties + +plugins { + kotlin("jvm") version "1.7.22" + java + id("org.springframework.boot") version "3.0.3" + id("io.spring.dependency-management") version "1.1.0" + kotlin("plugin.spring") version "1.7.22" +} + +group = "net.osable" +version = "1.0-SNAPSHOT" + +repositories { + mavenCentral() +} + +dependencies { + implementation(kotlin("stdlib")) + + // Ktor server + implementation("io.ktor:ktor-server-core-jvm:2.2.1") + implementation("io.ktor:ktor-server-netty-jvm:2.2.1") + implementation("io.ktor:ktor-server-content-negotiation:2.2.1") + + //Ktor client + implementation("io.ktor:ktor-client-core:2.2.1") + implementation("io.ktor:ktor-client-cio:2.2.1") + implementation("io.ktor:ktor-client-content-negotiation:2.2.1") + + // Coroutines + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4") + + testImplementation("org.junit.jupiter:junit-jupiter-api:5.6.0") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") +} + +tasks.getByName("test") { + useJUnitPlatform() +} \ No newline at end of file diff --git a/core/build.gradle.kts b/core/build.gradle.kts new file mode 100644 index 0000000..f5d24f6 --- /dev/null +++ b/core/build.gradle.kts @@ -0,0 +1,50 @@ +plugins { + id("default-module-config") + id("org.springframework.boot") version "3.0.3" + id("io.spring.dependency-management") version "1.1.0" + kotlin("plugin.spring") version "1.7.22" +} + +java.sourceCompatibility = JavaVersion.VERSION_17 +extra["springCloudVersion"] = "2022.0.1" + +dependencies { + implementation("org.springframework.boot:spring-boot-starter") + + // Spring MVC + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-thymeleaf") + implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server") + + implementation("org.springframework.boot:spring-boot-starter-webflux") + + // Metrics + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("io.micrometer:micrometer-registry-prometheus") + + // Eureka Discovery + implementation("org.springframework.cloud:spring-cloud-starter-netflix-eureka-client") + + // Production Cache - replaces default in-memory Spring cache + implementation("org.springframework.boot:spring-boot-starter-cache") + implementation("com.github.ben-manes.caffeine:caffeine:3.1.5") + + // Flexmark Markdown -> HTML + implementation("com.vladsch.flexmark:flexmark-all:0.64.0") + + testImplementation("org.springframework.boot:spring-boot-starter-test") +} + +// handle spring cloud dependency versions +dependencyManagement { + imports { + mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("springCloudVersion")}") + } +} + +tasks.withType { + kotlinOptions { + freeCompilerArgs = listOf("-Xjsr305=strict") + jvmTarget = "11" + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/net/osable/core/CaffeineConfiguration.kt b/core/src/main/kotlin/net/osable/core/CaffeineConfiguration.kt new file mode 100644 index 0000000..95bdbef --- /dev/null +++ b/core/src/main/kotlin/net/osable/core/CaffeineConfiguration.kt @@ -0,0 +1,16 @@ +package net.osable.core + +import com.github.benmanes.caffeine.cache.Caffeine +import org.springframework.cache.caffeine.CaffeineCacheManager +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import java.util.concurrent.TimeUnit + +@Configuration +class CaffeineConfiguration { + + @Bean fun caffeineConfig() = Caffeine.newBuilder().expireAfterWrite(60, TimeUnit.MINUTES) + + @Bean fun cacheManager(caffeine: Caffeine) = CaffeineCacheManager("blog").apply { setCaffeine(caffeine) } + +} \ No newline at end of file diff --git a/core/src/main/kotlin/net/osable/core/CoreApplication.kt b/core/src/main/kotlin/net/osable/core/CoreApplication.kt new file mode 100644 index 0000000..67be5a6 --- /dev/null +++ b/core/src/main/kotlin/net/osable/core/CoreApplication.kt @@ -0,0 +1,9 @@ +package net.osable.core + +import org.springframework.boot.SpringApplication +import org.springframework.boot.autoconfigure.SpringBootApplication + +fun main(args: Array) { SpringApplication.run(CoreApplication::class.java, *args) } + +@SpringBootApplication +class CoreApplication \ No newline at end of file diff --git a/core/src/main/kotlin/net/osable/core/Error.kt b/core/src/main/kotlin/net/osable/core/Error.kt new file mode 100644 index 0000000..9c8325d --- /dev/null +++ b/core/src/main/kotlin/net/osable/core/Error.kt @@ -0,0 +1,11 @@ +package net.osable.core + +import jakarta.servlet.RequestDispatcher +import jakarta.servlet.http.HttpServletRequest +import org.springframework.http.HttpStatus + +data class Error(val code: Int, val info: String) + +fun HttpServletRequest.getErrorCode() = getAttribute(RequestDispatcher.ERROR_STATUS_CODE) as Int + +fun getHttpStatusMessage(code: Int) = HttpStatus.valueOf(code).reasonPhrase \ No newline at end of file diff --git a/core/src/main/kotlin/net/osable/core/GitHubReactiveOpaqueTokenIntrospector.kt b/core/src/main/kotlin/net/osable/core/GitHubReactiveOpaqueTokenIntrospector.kt new file mode 100644 index 0000000..bac7b56 --- /dev/null +++ b/core/src/main/kotlin/net/osable/core/GitHubReactiveOpaqueTokenIntrospector.kt @@ -0,0 +1,14 @@ +package net.osable.core + +import org.springframework.security.oauth2.core.DefaultOAuth2AuthenticatedPrincipal +import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal +import org.springframework.security.oauth2.server.resource.introspection.ReactiveOpaqueTokenIntrospector +import reactor.core.publisher.Mono + +class GitHubReactiveOpaqueTokenIntrospector : ReactiveOpaqueTokenIntrospector { + override fun introspect(token: String): Mono { + println("Token: $token") + return Mono.just(DefaultOAuth2AuthenticatedPrincipal(mapOf(), null)) + } + +} \ No newline at end of file diff --git a/core/src/main/kotlin/net/osable/core/RouteMetrics.kt b/core/src/main/kotlin/net/osable/core/RouteMetrics.kt new file mode 100644 index 0000000..368955a --- /dev/null +++ b/core/src/main/kotlin/net/osable/core/RouteMetrics.kt @@ -0,0 +1,36 @@ +package net.osable.core + +import io.micrometer.core.instrument.Statistic +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.actuate.metrics.MetricsEndpoint +import org.springframework.stereotype.Component + +enum class RouteMetrics(private val endpoint: String, val displayName: String) { + + HOME("/", "Homepage"), + ADMIN("/admin", "Admin Panel"), + PRIVACY("/privacy", "Privacy Policy"), + TOOL("/tool", "Tool"), + SECURITY("/security", "Security Policy"), + OPEN_SOURCE("/open-source", "Open Source"), + ABOUT_US("/about-us", "About Us"), + ABOUT_SERPENTINE("/about/serpentine", "About Serpentine"); + + companion object { + fun getTotalRequests(metricsEndpoint: MetricsEndpoint) = values().sumOf { it.getRequestsToURL(metricsEndpoint) } + fun getRequestsPerRoute(metricsEndpoint: MetricsEndpoint) = mutableMapOf().apply { + values().forEach { this[it.displayName] = it.getRequestsToURL(metricsEndpoint) } + } + } + + fun getRequestsToURL(metricsEndpoint: MetricsEndpoint) = metricsEndpoint.requestsToURL(endpoint) + +} + +fun MetricsEndpoint.requestsToURL(url: String): Int { + val metric = metric("http.server.requests", listOf("uri:$url")) + metric ?: return 0 + val count = metric.measurements.find { it.statistic == Statistic.COUNT } + count ?: return 0 + return count.value.toInt() +} \ No newline at end of file diff --git a/core/src/main/kotlin/net/osable/core/SecurityConfiguration.kt b/core/src/main/kotlin/net/osable/core/SecurityConfiguration.kt new file mode 100644 index 0000000..97a4c27 --- /dev/null +++ b/core/src/main/kotlin/net/osable/core/SecurityConfiguration.kt @@ -0,0 +1,39 @@ +package net.osable.core + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.HttpStatus +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.HttpStatusEntryPoint +import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler +import org.springframework.security.web.csrf.CookieCsrfTokenRepository +import org.springframework.web.bind.annotation.RestController + +@Configuration +class SecurityConfiguration { + + @Bean fun filterChain(http: HttpSecurity): SecurityFilterChain { + http.authorizeHttpRequests { + + // Configure access to /admin + it.requestMatchers("/admin").fullyAuthenticated() + + // Configure access to blog upload + it.requestMatchers("/blogs", "/blogs/upload").fullyAuthenticated() + + // Configure access to the rest of the site + it.requestMatchers("/**").permitAll() + .anyRequest().authenticated() + + }.csrf { + // Configure CSRF token + it.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) + }.oauth2ResourceServer() + + return http.build() + } + +} \ No newline at end of file diff --git a/core/src/main/kotlin/net/osable/core/blogs/DefaultStorageService.kt b/core/src/main/kotlin/net/osable/core/blogs/DefaultStorageService.kt new file mode 100644 index 0000000..7bf9e25 --- /dev/null +++ b/core/src/main/kotlin/net/osable/core/blogs/DefaultStorageService.kt @@ -0,0 +1,47 @@ +package net.osable.core.blogs + +import com.vladsch.flexmark.ext.autolink.AutolinkExtension +import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension +import com.vladsch.flexmark.html.HtmlRenderer +import com.vladsch.flexmark.parser.Parser +import com.vladsch.flexmark.util.data.MutableDataSet +import org.apache.logging.log4j.spi.CopyOnWrite +import org.springframework.cache.annotation.Cacheable +import org.springframework.stereotype.Service +import org.springframework.web.multipart.MultipartFile +import java.io.File +import java.nio.file.CopyOption +import java.nio.file.Files +import java.nio.file.StandardCopyOption + +@Service +class DefaultStorageService : StorageService { + + companion object { + private val options = MutableDataSet().apply { + set(Parser.EXTENSIONS, listOf(StrikethroughExtension.create(), AutolinkExtension.create())) + } + private val parser = Parser.builder(options).build() + private val renderer = HtmlRenderer.builder(options).build() + } + + init { + val file = File("./blogs") + if (! file.exists()) { file.mkdir() } + } + + override fun save(file: MultipartFile) { + file.originalFilename ?: return + Files.copy(file.inputStream, File("./blogs/${file.originalFilename}").toPath(), StandardCopyOption.REPLACE_EXISTING) + } + + @Cacheable("blog") + override fun load(filename: String): String? { + val file = File("./blogs/$filename") + if (! file.exists()) return null + return renderMarkdownAsHTML(file.readText()) + } + + private fun renderMarkdownAsHTML(markdown: String) = renderer.render(parser.parse(markdown)) + +} \ No newline at end of file diff --git a/core/src/main/kotlin/net/osable/core/blogs/StorageService.kt b/core/src/main/kotlin/net/osable/core/blogs/StorageService.kt new file mode 100644 index 0000000..8413c16 --- /dev/null +++ b/core/src/main/kotlin/net/osable/core/blogs/StorageService.kt @@ -0,0 +1,11 @@ +package net.osable.core.blogs + +import org.springframework.web.multipart.MultipartFile + +interface StorageService { + + fun save(file: MultipartFile) + + fun load(filename: String): String? + +} \ No newline at end of file diff --git a/core/src/main/kotlin/net/osable/core/web/AdminController.kt b/core/src/main/kotlin/net/osable/core/web/AdminController.kt new file mode 100644 index 0000000..9e1e307 --- /dev/null +++ b/core/src/main/kotlin/net/osable/core/web/AdminController.kt @@ -0,0 +1,24 @@ +package net.osable.core.web + +import net.osable.core.RouteMetrics +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.actuate.metrics.MetricsEndpoint +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.security.oauth2.core.user.OAuth2User +import org.springframework.stereotype.Controller +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.servlet.ModelAndView + +@Controller +class AdminController { + + @Autowired private lateinit var metricsEndpoint: MetricsEndpoint + + @RequestMapping("/admin") + fun adminPanel(@AuthenticationPrincipal principal: OAuth2User) = ModelAndView("staff/admin").apply { + addObject("username", principal.attributes["login"]) + addObject("visitCount", RouteMetrics.ADMIN.getRequestsToURL(metricsEndpoint)) + addObject("routeMetrics", RouteMetrics.getRequestsPerRoute(metricsEndpoint)) + } + +} \ No newline at end of file diff --git a/core/src/main/kotlin/net/osable/core/web/BlogsController.kt b/core/src/main/kotlin/net/osable/core/web/BlogsController.kt new file mode 100644 index 0000000..7d61ddd --- /dev/null +++ b/core/src/main/kotlin/net/osable/core/web/BlogsController.kt @@ -0,0 +1,44 @@ +package net.osable.core.web + +import net.osable.core.blogs.DefaultStorageService +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.http.server.ServerHttpResponse +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.security.oauth2.core.user.OAuth2User +import org.springframework.stereotype.Controller +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.multipart.MultipartFile +import org.springframework.web.servlet.mvc.support.RedirectAttributes +import org.springframework.web.servlet.view.RedirectView +import reactor.core.publisher.Mono +import java.net.URI + +@Controller +class BlogsController { + + @Autowired private lateinit var storageService: DefaultStorageService + + @RequestMapping("/blogs") + fun blogsPanel(@AuthenticationPrincipal principal: OAuth2User) = "staff/blogs" + + @PostMapping("/blogs/upload") + fun blogsUpload(@AuthenticationPrincipal principal: OAuth2User, @RequestParam("file") file: MultipartFile, attributes: RedirectAttributes): RedirectView { + storageService.save(file) + + attributes.addFlashAttribute("flashAttribute", "redirectWithRedirectView") + return RedirectView("/blogs/${file.originalFilename?.removeSuffix(".md")}") + } + + @RequestMapping("/blogs/{blogName}") + fun loadBlog(@AuthenticationPrincipal principal: OAuth2User, @PathVariable("blogName") blogName: String): ResponseEntity { + val blog = storageService.load("$blogName.md") + blog ?: return ResponseEntity.status(HttpStatus.NOT_FOUND).build() + return ResponseEntity.ok(blog) + } + +} \ No newline at end of file diff --git a/core/src/main/kotlin/net/osable/core/web/ErrorController.kt b/core/src/main/kotlin/net/osable/core/web/ErrorController.kt new file mode 100644 index 0000000..1c1061f --- /dev/null +++ b/core/src/main/kotlin/net/osable/core/web/ErrorController.kt @@ -0,0 +1,18 @@ +package net.osable.core.web + +import jakarta.servlet.http.HttpServletRequest +import net.osable.core.Error +import net.osable.core.getErrorCode +import net.osable.core.getHttpStatusMessage +import org.springframework.stereotype.Controller +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.servlet.ModelAndView + +@Controller +class ErrorController : org.springframework.boot.web.servlet.error.ErrorController { + + @RequestMapping("/error") fun defaultErrorMapping(request: HttpServletRequest) = ModelAndView("templates/error").apply { + addObject("error", Error(request.getErrorCode(), getHttpStatusMessage(request.getErrorCode()))) + } + +} \ No newline at end of file diff --git a/core/src/main/kotlin/net/osable/core/web/StaticResourceRoutesConfiguration.kt b/core/src/main/kotlin/net/osable/core/web/StaticResourceRoutesConfiguration.kt new file mode 100644 index 0000000..7d84179 --- /dev/null +++ b/core/src/main/kotlin/net/osable/core/web/StaticResourceRoutesConfiguration.kt @@ -0,0 +1,36 @@ +package net.osable.core.web + +import org.springframework.context.annotation.Configuration +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer +import org.springframework.web.servlet.resource.PathResourceResolver + +@Configuration +class StaticResourceRoutesConfiguration : WebMvcConfigurer { + + override fun addResourceHandlers(registry: ResourceHandlerRegistry) { + + // Configure static CSS routes + registry.addResourceHandler("/assets/css/**") + .addResourceLocations("classpath:/frontend/css/") + .setCachePeriod(3600) + .resourceChain(true) + .addResolver(PathResourceResolver()) + + // Configure static image routes + registry.addResourceHandler("/assets/images/**") + .addResourceLocations("classpath:/frontend/images/") + .setCachePeriod(3600) + .resourceChain(true) + .addResolver(PathResourceResolver()) + + // Configure static js routes + registry.addResourceHandler("/assets/js/**") + .addResourceLocations("classpath:/frontend/js/") + .setCachePeriod(3600) + .resourceChain(true) + .addResolver(PathResourceResolver()) + + } + +} \ No newline at end of file diff --git a/core/src/main/kotlin/net/osable/core/web/StaticRoutesController.kt b/core/src/main/kotlin/net/osable/core/web/StaticRoutesController.kt new file mode 100644 index 0000000..24cc6bb --- /dev/null +++ b/core/src/main/kotlin/net/osable/core/web/StaticRoutesController.kt @@ -0,0 +1,23 @@ +package net.osable.core.web + +import org.springframework.stereotype.Controller +import org.springframework.web.bind.annotation.RequestMapping + +@Controller +class StaticRoutesController { + + @RequestMapping("/") fun rootMapping() = "index" + + @RequestMapping("/privacy") fun privacyMapping() = "privacy" + + @RequestMapping("/tool") fun toolMapping() = "tool" + + @RequestMapping("/security") fun securityMapping() = "security" + + @RequestMapping("open-source") fun openSourceMapping() = "open-source" + + @RequestMapping("/about-us") fun aboutUsMapping() = "about-us" + + @RequestMapping("/about/serpentine") fun serpentineMapping() = "about/serpentine" + +} \ No newline at end of file diff --git a/core/src/main/resources/application-development.yaml b/core/src/main/resources/application-development.yaml new file mode 100644 index 0000000..a4b7090 --- /dev/null +++ b/core/src/main/resources/application-development.yaml @@ -0,0 +1,10 @@ +spring: + profiles: development + + config: + import: optional:secrets.yaml + +eureka: + client: + service-url: + defaultZone: http://localhost:8761/eureka \ No newline at end of file diff --git a/core/src/main/resources/application-production.yaml b/core/src/main/resources/application-production.yaml new file mode 100644 index 0000000..0e57eab --- /dev/null +++ b/core/src/main/resources/application-production.yaml @@ -0,0 +1,13 @@ +spring: + profiles: production + +eureka: + client: + service-url: + defaultZone: http\://discovery.osable.svc.cluster.local\:8761/eureka + instance: + hostname: core.osable.svc.cluster.local + +# env: +# spring.security.oauth2.client.registration.github.client-id +# spring.security.oauth2.client.registration.github.client-secret \ No newline at end of file diff --git a/core/src/main/resources/application.yaml b/core/src/main/resources/application.yaml new file mode 100644 index 0000000..b4c373d --- /dev/null +++ b/core/src/main/resources/application.yaml @@ -0,0 +1,27 @@ +spring: + profiles: development, production + + application: + name: core + + thymeleaf: + prefix: classpath:/frontend/ + suffix: .html + + cache: + type: caffeine + cache-names: blogs + +server: + port: 9091 + + error: + whitelabel: + enabled: false + path: /error + +management: + endpoints: + web: + exposure: + include: health, metrics, prometheus \ No newline at end of file diff --git a/core/src/main/resources/frontend b/core/src/main/resources/frontend new file mode 160000 index 0000000..17e0f1d --- /dev/null +++ b/core/src/main/resources/frontend @@ -0,0 +1 @@ +Subproject commit 17e0f1d0e809c3f6cf592c1788777d9e042056d7 diff --git a/discovery/build.gradle.kts b/discovery/build.gradle.kts new file mode 100644 index 0000000..55bf81f --- /dev/null +++ b/discovery/build.gradle.kts @@ -0,0 +1,42 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + //id("default-module-config") + //id("default-spring-config") + java + id("org.springframework.boot") version "3.0.3" + id("io.spring.dependency-management") version "1.1.0" + kotlin("plugin.spring") version "1.7.22" + kotlin("jvm") version "1.7.22" +} + +java.sourceCompatibility = JavaVersion.VERSION_17 + +extra["springCloudVersion"] = "2022.0.1" + +repositories { + mavenCentral() +} + +dependencies { + implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation("org.springframework.cloud:spring-cloud-starter-netflix-eureka-server") + testImplementation("org.springframework.boot:spring-boot-starter-test") +} + +dependencyManagement { + imports { + mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("springCloudVersion")}") + } +} + +tasks.withType { + kotlinOptions { + freeCompilerArgs = listOf("-Xjsr305=strict") + jvmTarget = "17" + } +} + +tasks.withType { + useJUnitPlatform() +} diff --git a/discovery/settings.gradle.kts b/discovery/settings.gradle.kts new file mode 100644 index 0000000..3febf9d --- /dev/null +++ b/discovery/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "discovery" diff --git a/discovery/src/main/kotlin/net/osable/discovery/DiscoveryApplication.kt b/discovery/src/main/kotlin/net/osable/discovery/DiscoveryApplication.kt new file mode 100644 index 0000000..22e0634 --- /dev/null +++ b/discovery/src/main/kotlin/net/osable/discovery/DiscoveryApplication.kt @@ -0,0 +1,13 @@ +package net.osable.discovery + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication +import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer + +@SpringBootApplication +@EnableEurekaServer +class DiscoveryApplication + +fun main(args: Array) { + runApplication(*args) +} diff --git a/discovery/src/main/resources/application.yaml b/discovery/src/main/resources/application.yaml new file mode 100644 index 0000000..63050ad --- /dev/null +++ b/discovery/src/main/resources/application.yaml @@ -0,0 +1,10 @@ +server: + port: 8761 + +eureka: + client: + register-with-eureka: false + fetch-registry: false + + datacenter: osable + environment: production \ No newline at end of file diff --git a/gateway/build.gradle.kts b/gateway/build.gradle.kts new file mode 100644 index 0000000..890e5e4 --- /dev/null +++ b/gateway/build.gradle.kts @@ -0,0 +1,47 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + id("default-module-config") + id("org.springframework.boot") version "3.0.3" + id("io.spring.dependency-management") version "1.1.0" + kotlin("plugin.spring") version "1.7.22" +} + +java.sourceCompatibility = JavaVersion.VERSION_17 +extra["springCloudVersion"] = "2022.0.1" + +dependencies { + implementation("org.springframework.cloud:spring-cloud-starter-gateway") + implementation("org.springframework.boot:spring-boot-starter") + implementation("org.springframework.cloud:spring-cloud-starter-contract-stub-runner") + + // Failover + implementation("org.springframework.cloud:spring-cloud-starter-circuitbreaker-reactor-resilience4j") + + // Load Balancing + implementation("org.springframework.cloud:spring-cloud-starter-loadbalancer") + implementation("org.springframework.cloud:spring-cloud-starter-netflix-eureka-client") + + // Production Cache - replaces default in-memory Spring cache + implementation("org.springframework.boot:spring-boot-starter-cache") + implementation("com.github.ben-manes.caffeine:caffeine:3.1.5") + + // Redis for rate limiting cache + implementation("org.springframework.boot:spring-boot-starter-data-redis-reactive") + + testImplementation("org.springframework.boot:spring-boot-starter-test") +} + +// handle spring cloud dependency versions +dependencyManagement { + imports { + mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("springCloudVersion")}") + } +} + +tasks.withType { + kotlinOptions { + freeCompilerArgs = listOf("-Xjsr305=strict") + jvmTarget = "11" + } +} \ No newline at end of file diff --git a/gateway/src/main/kotlin/net/osable/gateway/FallbackController.kt b/gateway/src/main/kotlin/net/osable/gateway/FallbackController.kt new file mode 100644 index 0000000..4c93429 --- /dev/null +++ b/gateway/src/main/kotlin/net/osable/gateway/FallbackController.kt @@ -0,0 +1,13 @@ +package net.osable.gateway + +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import reactor.core.publisher.Mono + +@RestController +class FallbackController { + + @RequestMapping("/requestsFallback") + fun `Services Requests Fallback`() = Mono.just("The tea making pipelines that bring you this service from ol’ Blighty are down at the moment but normal service should be resumed as soon possible, Regrettably, OSable Administration") + +} \ No newline at end of file diff --git a/gateway/src/main/kotlin/net/osable/gateway/GatewayApplication.kt b/gateway/src/main/kotlin/net/osable/gateway/GatewayApplication.kt new file mode 100644 index 0000000..acce8be --- /dev/null +++ b/gateway/src/main/kotlin/net/osable/gateway/GatewayApplication.kt @@ -0,0 +1,75 @@ +package net.osable.gateway + +import net.osable.gateway.resolvers.ProxiedClientAddressResolver +import org.springframework.boot.SpringApplication +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.cloud.gateway.filter.ratelimit.RedisRateLimiter +import org.springframework.cloud.gateway.route.RouteLocator +import org.springframework.cloud.gateway.route.builder.GatewayFilterSpec +import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder +import org.springframework.context.annotation.Bean +import org.springframework.http.HttpStatus + +fun main(args: Array) { SpringApplication.run(GatewayApplication::class.java, *args) } + +@SpringBootApplication +class GatewayApplication { + + /* + * Ignore this warning, the program runs fine, there is no issue. IntelliJ's inspection is wrong. + * Tried @SuppressWarnings("SpringJavaAutowiringInspection", "SpringJavaInjectionPointsAutowiringInspection") + * "Incorrect injection point autowiring in Spring bean components" is the inspection in IJ settings + * You can turn that from an error to a warning to not go insane :) + */ + @Bean fun declareGatewayRoutes(builder: RouteLocatorBuilder): RouteLocator = builder.routes() + // Block external access to actuator endpoints while still having them accessible locally + .route { route -> + route.path("/actuator/**").filters { it.setStatus(HttpStatus.FORBIDDEN) }.uri("no://op") + } +// // URL Shortener route creation configuration + .route { route -> + route.path("/url") + .filters { filters -> + filters.circuitBreaker { fallbackConfig -> + fallbackConfig.name = "urlShortenerCreationFallback" + fallbackConfig.setFallbackUri("forward:/requestsFallback") + } + + filters.configureRedisRateLimiterFilter(1) + }.uri("lb://url-shortener/url") + } +// // URL Shortener routes configuration + .route { route -> + route.path("/url/**") + .filters { filters -> + filters.circuitBreaker { fallbackConfig -> + fallbackConfig.name = "urlShortenerRequestsFallback" + fallbackConfig.setFallbackUri("forward:/requestsFallback") + } + + filters.configureRedisRateLimiterFilter(5) + }.uri("lb://url-shortener/url/") + } + // All other routes should go to core + .route { route -> + route.path("/**") + .filters { filters -> + filters.circuitBreaker { fallbackConfig -> + fallbackConfig.name = "coreRequestsFallback" + fallbackConfig.setFallbackUri("forward:/requestsFallback") + } + + filters.configureRedisRateLimiterFilter(10) + }.uri("lb://core/") + } + .build() + + fun GatewayFilterSpec.configureRedisRateLimiterFilter(requestsPerSecond: Int) = requestRateLimiter().rateLimiter(RedisRateLimiter::class.java) { + it.replenishRate = requestsPerSecond + it.burstCapacity = requestsPerSecond + it.requestedTokens = 1 + }.configure { + it.keyResolver = ProxiedClientAddressResolver() + } + +} \ No newline at end of file diff --git a/gateway/src/main/kotlin/net/osable/gateway/resolvers/ProxiedClientAddressResolver.kt b/gateway/src/main/kotlin/net/osable/gateway/resolvers/ProxiedClientAddressResolver.kt new file mode 100644 index 0000000..112ce4f --- /dev/null +++ b/gateway/src/main/kotlin/net/osable/gateway/resolvers/ProxiedClientAddressResolver.kt @@ -0,0 +1,26 @@ +package net.osable.gateway.resolvers + +import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver +import org.springframework.cloud.gateway.support.ipresolver.XForwardedRemoteAddressResolver +import org.springframework.stereotype.Component +import org.springframework.web.server.ServerWebExchange +import reactor.core.publisher.Mono +import java.util.* + + +/** + * Gets the requesting client's IP address so that the rate limiter + * can use it as the key to decide if to reject a request. + * CF-Connecting-IP -> X-Forwarded-For at Caddy reverse proxy + */ +@Component +class ProxiedClientAddressResolver : KeyResolver { + + override fun resolve(exchange: ServerWebExchange): Mono { + // maxTrustedIndex represents the number of proxies we control that the requests pass through + val resolver = XForwardedRemoteAddressResolver.maxTrustedIndex(1) + val socket = resolver.resolve(exchange) + return Mono.just(socket.address.hostAddress) + } + +} \ No newline at end of file diff --git a/gateway/src/main/resources/application-development.yaml b/gateway/src/main/resources/application-development.yaml new file mode 100644 index 0000000..be54b68 --- /dev/null +++ b/gateway/src/main/resources/application-development.yaml @@ -0,0 +1,12 @@ +spring: + profiles: development + + data: + redis: + host: 192.168.1.120 + port: 6379 + +eureka: + client: + service-url: + defaultZone: http\://localhost\:8761/eureka \ No newline at end of file diff --git a/gateway/src/main/resources/application-production.yaml b/gateway/src/main/resources/application-production.yaml new file mode 100644 index 0000000..5256446 --- /dev/null +++ b/gateway/src/main/resources/application-production.yaml @@ -0,0 +1,14 @@ +spring: + profiles: production + + data: + redis: + host: redis.osable.svc.cluster.local + port: 6379 + +eureka: + client: + service-url: + defaultZone: http\://discovery.osable.svc.cluster.local\:8761/eureka + instance: + hostname: gateway.osable.svc.cluster.local \ No newline at end of file diff --git a/gateway/src/main/resources/application.yaml b/gateway/src/main/resources/application.yaml new file mode 100644 index 0000000..64a86d8 --- /dev/null +++ b/gateway/src/main/resources/application.yaml @@ -0,0 +1,17 @@ +spring: + profiles: development, production + + application: + name: gateway + + cloud: + gateway: + enabled: true + + discovery: + locator: + enabled: true + lower-case-service-id: true + +server: + port: 8081 \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..8378314 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,2 @@ +kotlin.code.style=official +org.gradle.caching=false \ No newline at end of file diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..744e882 --- /dev/null +++ b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MSYS* | MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..22032eb --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,10 @@ +rootProject.name = "osable-services" +include("core") +include("gateway") +include("discovery") +include("url-shortener") + +dependencyResolutionManagement { + includeBuild("build-logic") + repositories.gradlePluginPortal() +} diff --git a/url-shortener/build.gradle.kts b/url-shortener/build.gradle.kts new file mode 100644 index 0000000..6de3aba --- /dev/null +++ b/url-shortener/build.gradle.kts @@ -0,0 +1,59 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + id("org.springframework.boot") version "3.0.3" + id("io.spring.dependency-management") version "1.1.0" + kotlin("jvm") version "1.7.22" + kotlin("plugin.spring") version "1.7.22" + id("org.jetbrains.kotlin.plugin.jpa") version "1.7.22" +} + +group = "net.osable" +version = "0.0.1-SNAPSHOT" +java.sourceCompatibility = JavaVersion.VERSION_17 + +repositories { + mavenCentral() +} + +extra["springCloudVersion"] = "2022.0.1" + +dependencies { + implementation("org.springframework.boot:spring-boot-starter-webflux") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("io.projectreactor.kotlin:reactor-kotlin-extensions") + implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor") + implementation("org.springframework.cloud:spring-cloud-starter-loadbalancer") + implementation("org.springframework.cloud:spring-cloud-starter-netflix-eureka-client") + + // Cache + implementation("org.springframework.boot:spring-boot-starter-cache") + implementation("com.github.ben-manes.caffeine:caffeine:3.1.5") + + // DB + // transitively includes HikariCP, Hibernate, Spring JPA & ORM + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("com.mysql:mysql-connector-j:8.0.32") + + // Test + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("io.projectreactor:reactor-test") +} + +dependencyManagement { + imports { + mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("springCloudVersion")}") + } +} + +tasks.withType { + kotlinOptions { + freeCompilerArgs = listOf("-Xjsr305=strict") + jvmTarget = "17" + } +} + +tasks.withType { + useJUnitPlatform() +} diff --git a/url-shortener/gradle/wrapper/gradle-wrapper.jar b/url-shortener/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..249e583 Binary files /dev/null and b/url-shortener/gradle/wrapper/gradle-wrapper.jar differ diff --git a/url-shortener/gradle/wrapper/gradle-wrapper.properties b/url-shortener/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..774fae8 --- /dev/null +++ b/url-shortener/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/url-shortener/settings.gradle.kts b/url-shortener/settings.gradle.kts new file mode 100644 index 0000000..9b7b36c --- /dev/null +++ b/url-shortener/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "url-shortener" diff --git a/url-shortener/src/main/kotlin/net/osable/urlshortener/UrlShortenerApplication.kt b/url-shortener/src/main/kotlin/net/osable/urlshortener/UrlShortenerApplication.kt new file mode 100644 index 0000000..d5584c5 --- /dev/null +++ b/url-shortener/src/main/kotlin/net/osable/urlshortener/UrlShortenerApplication.kt @@ -0,0 +1,23 @@ +package net.osable.urlshortener + +import com.github.benmanes.caffeine.cache.Caffeine +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication +import org.springframework.cache.annotation.EnableCaching +import org.springframework.cache.caffeine.CaffeineCacheManager +import org.springframework.context.annotation.Bean +import java.util.concurrent.TimeUnit + +@SpringBootApplication +@EnableCaching +class UrlShortenerApplication { + + @Bean fun caffeineConfig() = Caffeine.newBuilder().expireAfterWrite(60, TimeUnit.MINUTES) + + @Bean fun cacheManager(caffeine: Caffeine) = CaffeineCacheManager("url").apply { setCaffeine(caffeine) } + +} + +fun main(args: Array) { + runApplication(*args) +} diff --git a/url-shortener/src/main/kotlin/net/osable/urlshortener/data/URLEntity.kt b/url-shortener/src/main/kotlin/net/osable/urlshortener/data/URLEntity.kt new file mode 100644 index 0000000..7839f63 --- /dev/null +++ b/url-shortener/src/main/kotlin/net/osable/urlshortener/data/URLEntity.kt @@ -0,0 +1,14 @@ +package net.osable.urlshortener.data + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id + +// Don't use data classes for JPA Entities +// https://www.baeldung.com/kotlin/jpa + +@Entity +class URLEntity( + @Id val shortenedURL: String, + @Column val originalURL: String + ) : java.io.Serializable diff --git a/url-shortener/src/main/kotlin/net/osable/urlshortener/data/URLRepository.kt b/url-shortener/src/main/kotlin/net/osable/urlshortener/data/URLRepository.kt new file mode 100644 index 0000000..6201cb5 --- /dev/null +++ b/url-shortener/src/main/kotlin/net/osable/urlshortener/data/URLRepository.kt @@ -0,0 +1,11 @@ +package net.osable.urlshortener.data + +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.CrudRepository + +interface URLRepository : CrudRepository { + + @Query("SELECT u.originalURL FROM URLEntity u WHERE u.shortenedURL = ?1") + fun findByShortenedURL(shortenedURL: String): String + +} \ No newline at end of file diff --git a/url-shortener/src/main/kotlin/net/osable/urlshortener/service/DefaultShortenerService.kt b/url-shortener/src/main/kotlin/net/osable/urlshortener/service/DefaultShortenerService.kt new file mode 100644 index 0000000..d05ad5b --- /dev/null +++ b/url-shortener/src/main/kotlin/net/osable/urlshortener/service/DefaultShortenerService.kt @@ -0,0 +1,35 @@ +package net.osable.urlshortener.service + +import net.osable.urlshortener.data.URLEntity +import net.osable.urlshortener.data.URLRepository +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.cache.annotation.Cacheable +import org.springframework.stereotype.Service +import java.math.BigInteger +import java.security.MessageDigest + +@Service +class DefaultShortenerService : ShortenerService { + + @Autowired private lateinit var urlRepository: URLRepository + + /** + * @param url url to shorten + * @return Shortened hash of the url provided + */ + override fun shorten(url: String): String { + val hashBytes = MessageDigest.getInstance("MD5").digest(url.toByteArray(Charsets.UTF_8)) + val hashString = String.format("%032x", BigInteger(1, hashBytes)) + val shortened = "https://osable.net/url/${hashString.take(6)}" + urlRepository.save(URLEntity(shortened, url)) + return shortened + } + + /** + * @param short Shortened hash to get the original url from + * @return Original url of the shortened hash provided + */ + @Cacheable("url") + override fun getURL(short: String) = urlRepository.findByShortenedURL("https://osable.net/url/$short") + +} \ No newline at end of file diff --git a/url-shortener/src/main/kotlin/net/osable/urlshortener/service/ShortenerService.kt b/url-shortener/src/main/kotlin/net/osable/urlshortener/service/ShortenerService.kt new file mode 100644 index 0000000..23d1e5a --- /dev/null +++ b/url-shortener/src/main/kotlin/net/osable/urlshortener/service/ShortenerService.kt @@ -0,0 +1,8 @@ +package net.osable.urlshortener.service + +interface ShortenerService { + + fun getURL(short: String): String + fun shorten(url: String): String + +} \ No newline at end of file diff --git a/url-shortener/src/main/kotlin/net/osable/urlshortener/web/ShortenerRoutes.kt b/url-shortener/src/main/kotlin/net/osable/urlshortener/web/ShortenerRoutes.kt new file mode 100644 index 0000000..d6ef225 --- /dev/null +++ b/url-shortener/src/main/kotlin/net/osable/urlshortener/web/ShortenerRoutes.kt @@ -0,0 +1,26 @@ +package net.osable.urlshortener.web + +import net.osable.urlshortener.service.DefaultShortenerService +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.HttpStatus +import org.springframework.http.server.reactive.ServerHttpResponse +import org.springframework.web.bind.annotation.* +import reactor.core.publisher.Mono +import java.net.URI + +@RestController +class ShortenerRoutes { + + @Autowired private lateinit var shortenerService: DefaultShortenerService + + @GetMapping("/url/{id}") + fun redirectToContent(@PathVariable("id") id: String, response: ServerHttpResponse): Mono { + response.statusCode = HttpStatus.PERMANENT_REDIRECT + response.headers.location = URI.create(shortenerService.getURL(id)) + return response.setComplete() + } + + @PostMapping("/url") + fun createShortenedURL(@RequestBody url: URL) = URL(shortenerService.shorten(url.url)) + +} \ No newline at end of file diff --git a/url-shortener/src/main/kotlin/net/osable/urlshortener/web/URL.kt b/url-shortener/src/main/kotlin/net/osable/urlshortener/web/URL.kt new file mode 100644 index 0000000..68cb795 --- /dev/null +++ b/url-shortener/src/main/kotlin/net/osable/urlshortener/web/URL.kt @@ -0,0 +1,3 @@ +package net.osable.urlshortener.web + +data class URL(val url: String) diff --git a/url-shortener/src/main/resources/application-development.yaml b/url-shortener/src/main/resources/application-development.yaml new file mode 100644 index 0000000..643d11c --- /dev/null +++ b/url-shortener/src/main/resources/application-development.yaml @@ -0,0 +1,13 @@ +spring: + profiles: development + + config: + import: optional:secrets.yaml + + datasource: + url: jdbc:mysql://localhost:3306/osable + +eureka: + client: + service-url: + defaultZone: http\://localhost:8761/eureka \ No newline at end of file diff --git a/url-shortener/src/main/resources/application-production.yaml b/url-shortener/src/main/resources/application-production.yaml new file mode 100644 index 0000000..b2a16e5 --- /dev/null +++ b/url-shortener/src/main/resources/application-production.yaml @@ -0,0 +1,16 @@ +spring: + profiles: production + + datasource: + url: jdbc:mysql://mysql.osable.svc.cluster.local:3306/osable + +eureka: + client: + service-url: + defaultZone: http\://discovery.osable.svc.cluster.local\:8761/eureka + instance: + hostname: url-shortener.osable.svc.cluster.local + +# env: +# spring.datasource.username +# spring.datasource.password \ No newline at end of file diff --git a/url-shortener/src/main/resources/application.yaml b/url-shortener/src/main/resources/application.yaml new file mode 100644 index 0000000..cfde41b --- /dev/null +++ b/url-shortener/src/main/resources/application.yaml @@ -0,0 +1,24 @@ +spring: + profiles: development, production + + application: + name: url-shortener + + cloud: + loadbalancer: + ribbon: + enabled: false + + cache: + type: caffeine + cache-names: url + + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + + jpa: + hibernate: + ddl-auto: update + +server: + port: 10101 \ No newline at end of file diff --git a/url-shortener/src/test/kotlin/net/osable/urlshortener/UrlShortenerApplicationTests.kt b/url-shortener/src/test/kotlin/net/osable/urlshortener/UrlShortenerApplicationTests.kt new file mode 100644 index 0000000..027e2a8 --- /dev/null +++ b/url-shortener/src/test/kotlin/net/osable/urlshortener/UrlShortenerApplicationTests.kt @@ -0,0 +1,13 @@ +package net.osable.urlshortener + +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest + +@SpringBootTest +class UrlShortenerApplicationTests { + + @Test + fun contextLoads() { + } + +}