diff --git a/README.MD b/README.MD new file mode 100644 index 0000000..df8fa64 --- /dev/null +++ b/README.MD @@ -0,0 +1,3 @@ +# surl +A simple URL shortener written in kotlin + diff --git a/build.gradle b/build.gradle index b252d22..b9c4c25 100644 --- a/build.gradle +++ b/build.gradle @@ -14,6 +14,12 @@ java { } } +kotlin { + jvmToolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + configurations { compileOnly { extendsFrom annotationProcessor @@ -29,14 +35,22 @@ allprojects { maven { url 'https://maven.aliyun.com/repository/gradle-plugin' } maven { url 'https://maven.aliyun.com/repository/public' } maven { url 'https://maven.aliyun.com/repository/jcenter' } + maven { url "https://maven.noelware.org" } } } dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' + implementation("org.jetbrains.exposed:exposed-spring-boot-starter:0.52.0") + implementation 'commons-codec:commons-codec' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation "org.noelware.charted.snowflake:snowflake:0.1-beta" + implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.security:spring-security-test' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' + runtimeOnly 'org.postgresql:postgresql' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } diff --git a/src/main/java/dev/surl/surl/cfg/BaseConfiguration.kt b/src/main/java/dev/surl/surl/cfg/BaseConfiguration.kt new file mode 100644 index 0000000..ff99b4c --- /dev/null +++ b/src/main/java/dev/surl/surl/cfg/BaseConfiguration.kt @@ -0,0 +1,6 @@ +package dev.surl.surl.cfg + +import org.springframework.context.annotation.Configuration + +@Configuration +open class BaseConfiguration(val site: String = "https://surl.org") diff --git a/src/main/java/dev/surl/surl/cfg/security/WebSecurityConfig.kt b/src/main/java/dev/surl/surl/cfg/security/WebSecurityConfig.kt new file mode 100644 index 0000000..2d26029 --- /dev/null +++ b/src/main/java/dev/surl/surl/cfg/security/WebSecurityConfig.kt @@ -0,0 +1,27 @@ +package dev.surl.surl.cfg.security + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.config.annotation.web.invoke + +@Configuration +@EnableWebSecurity +open class WebSecurityConfig { + @Bean + open fun filterChain(http: HttpSecurity): SecurityFilterChain { + + println("loading") + http { + authorizeHttpRequests { + authorize(anyRequest, authenticated) + } + formLogin { } + httpBasic { + } + } + return http.build() + } +} diff --git a/src/main/java/dev/surl/surl/common/Msg.kt b/src/main/java/dev/surl/surl/common/Msg.kt new file mode 100644 index 0000000..753c107 --- /dev/null +++ b/src/main/java/dev/surl/surl/common/Msg.kt @@ -0,0 +1,7 @@ +package dev.surl.surl.common + +data class Msg( + val code: Int = 0, + val msg: String? = null, + val value: Any? = null +) \ No newline at end of file diff --git a/src/main/java/dev/surl/surl/controller/DefaultFcontroller.kt b/src/main/java/dev/surl/surl/controller/DefaultFcontroller.kt index 30539f1..f67c085 100644 --- a/src/main/java/dev/surl/surl/controller/DefaultFcontroller.kt +++ b/src/main/java/dev/surl/surl/controller/DefaultFcontroller.kt @@ -6,5 +6,5 @@ import org.springframework.web.bind.annotation.RestController @RestController(value = "/") class DefaultFcontroller { @RequestMapping - fun defaultMessage() = "Hello World!" + fun defaultMessage() = 123 } \ No newline at end of file diff --git a/src/main/java/dev/surl/surl/controller/RedirectController.kt b/src/main/java/dev/surl/surl/controller/RedirectController.kt new file mode 100644 index 0000000..2dd3370 --- /dev/null +++ b/src/main/java/dev/surl/surl/controller/RedirectController.kt @@ -0,0 +1,29 @@ +package dev.surl.surl.controller + +import dev.surl.surl.cfg.BaseConfiguration +import dev.surl.surl.service.SurlService +import jakarta.validation.Valid +import org.hibernate.validator.constraints.Length +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RestController +import java.net.URI + +@RestController +class RedirectController { + @GetMapping("/{key}") + fun redirect( + @PathVariable @Valid @Length(min = 11, max = 11, message = "Key must be 11 characters long") key: String, + @Autowired service: SurlService, + @Autowired cfg: BaseConfiguration + ): ResponseEntity { + val redirectUrl = service.getUrlByKey(key) + return if(redirectUrl.isBlank()) { + ResponseEntity.status(302).location(URI.create(cfg.site)).build() + } else { + ResponseEntity.status(302).location(URI.create(redirectUrl)).build() + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/surl/surl/controller/SurlAddController.kt b/src/main/java/dev/surl/surl/controller/SurlAddController.kt new file mode 100644 index 0000000..5c9adb2 --- /dev/null +++ b/src/main/java/dev/surl/surl/controller/SurlAddController.kt @@ -0,0 +1,31 @@ +package dev.surl.surl.controller + +import dev.surl.surl.cfg.BaseConfiguration +import dev.surl.surl.common.Msg +import dev.surl.surl.dto.Surl +import dev.surl.surl.service.SurlService +import jakarta.validation.Valid +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RestController + +@RestController +@EnableWebSecurity +class SurlAddController { + @PostMapping("/surl/add") + fun addSurl( + @Valid @RequestBody body: Surl, @Autowired service: SurlService, @Autowired cfg: BaseConfiguration + ): Any { + return ResponseEntity(Msg(code = 0, value = "${cfg.site}/${service.addSurl(body.url ?: "")}"), HttpStatus.OK) + } + + @GetMapping("/login") + fun login():String { + return "loged in" + } +} \ No newline at end of file diff --git a/src/main/java/dev/surl/surl/dao/Surl.kt b/src/main/java/dev/surl/surl/dao/Surl.kt new file mode 100644 index 0000000..5d93fb8 --- /dev/null +++ b/src/main/java/dev/surl/surl/dao/Surl.kt @@ -0,0 +1,12 @@ +package dev.surl.surl.dao + +import dev.surl.surl.dsl.Surls +import org.jetbrains.exposed.dao.Entity +import org.jetbrains.exposed.dao.EntityClass +import org.jetbrains.exposed.dao.id.EntityID + +class Surl(id: EntityID): Entity(id) { + companion object: EntityClass(Surls) + var url by Surls.url + var user by User optionalReferencedOn Surls.user +} \ No newline at end of file diff --git a/src/main/java/dev/surl/surl/dao/User.kt b/src/main/java/dev/surl/surl/dao/User.kt new file mode 100644 index 0000000..879b9a1 --- /dev/null +++ b/src/main/java/dev/surl/surl/dao/User.kt @@ -0,0 +1,12 @@ +package dev.surl.surl.dao + +import dev.surl.surl.dsl.Users +import org.jetbrains.exposed.dao.LongEntity +import org.jetbrains.exposed.dao.LongEntityClass +import org.jetbrains.exposed.dao.id.EntityID + +class User(id: EntityID): LongEntity(id) { + companion object EntityClass: LongEntityClass(Users) + var username by Users.username + var passwd by Users.passwd +} \ No newline at end of file diff --git a/src/main/java/dev/surl/surl/dsl/Surls.kt b/src/main/java/dev/surl/surl/dsl/Surls.kt new file mode 100644 index 0000000..b0afdd7 --- /dev/null +++ b/src/main/java/dev/surl/surl/dsl/Surls.kt @@ -0,0 +1,10 @@ +package dev.surl.surl.dsl + +import org.jetbrains.exposed.dao.id.IdTable + +object Surls: IdTable("surl") { + override val id = long("id").entityId() + val url = varchar("url", 2048).uniqueIndex() + val user = reference("user", Users).nullable() + override val primaryKey = PrimaryKey(id, name = "PK_surl_id") +} \ No newline at end of file diff --git a/src/main/java/dev/surl/surl/dsl/Users.kt b/src/main/java/dev/surl/surl/dsl/Users.kt new file mode 100644 index 0000000..c0e431a --- /dev/null +++ b/src/main/java/dev/surl/surl/dsl/Users.kt @@ -0,0 +1,10 @@ +package dev.surl.surl.dsl + +import org.jetbrains.exposed.dao.id.IdTable + +object Users: IdTable("users") { + override val id = long("id").entityId() + val username = varchar("username", 256).uniqueIndex() + val passwd = varchar("passwd", 256) + override val primaryKey = PrimaryKey(id, name = "PK_user_id") +} \ No newline at end of file diff --git a/src/main/java/dev/surl/surl/dto/SurlDto.kt b/src/main/java/dev/surl/surl/dto/SurlDto.kt new file mode 100644 index 0000000..799942d --- /dev/null +++ b/src/main/java/dev/surl/surl/dto/SurlDto.kt @@ -0,0 +1,12 @@ +package dev.surl.surl.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import jakarta.validation.constraints.NotNull +import org.hibernate.validator.constraints.Length + +data class Surl( + @JsonProperty("url") + @get:NotNull(message = "url cannot be empty") + @get:Length(min = 11, max = 2048, message = "url length invalid") + val url: String? +) diff --git a/src/main/java/dev/surl/surl/handler/DefaultExceptionHandler.kt b/src/main/java/dev/surl/surl/handler/DefaultExceptionHandler.kt new file mode 100644 index 0000000..7c32277 --- /dev/null +++ b/src/main/java/dev/surl/surl/handler/DefaultExceptionHandler.kt @@ -0,0 +1,39 @@ +package dev.surl.surl.handler + +import dev.surl.surl.common.Msg +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.HttpStatusCode +import org.springframework.http.ResponseEntity +import org.springframework.validation.method.MethodValidationException +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.annotation.ControllerAdvice +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.context.request.WebRequest +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler + +@ControllerAdvice +class DefaultExceptionHandler : ResponseEntityExceptionHandler() { + override fun handleMethodValidationException( + ex: MethodValidationException, headers: HttpHeaders, status: HttpStatus, request: WebRequest + ): ResponseEntity { + return ResponseEntity(Msg(code = -1, msg = ex.allValidationResults.joinToString(";")), status) + } + + override fun handleMethodArgumentNotValid( + ex: MethodArgumentNotValidException, headers: HttpHeaders, status: HttpStatusCode, request: WebRequest + ): ResponseEntity { + return ResponseEntity( + Msg(code = -1, msg = ex.allErrors.joinToString(";") { + it.defaultMessage?.toString() ?: "unknown invalid method argument" + }), status + ) + } + + @ExceptionHandler(value = [Exception::class]) + fun handleException( + ex: Exception, headers: HttpHeaders, status: HttpStatusCode, request: WebRequest + ): ResponseEntity { + return ResponseEntity(Msg(code = -1, msg = ex.message ?: "unknown error"), status) + } +} \ No newline at end of file diff --git a/src/main/java/dev/surl/surl/service/SurlService.kt b/src/main/java/dev/surl/surl/service/SurlService.kt new file mode 100644 index 0000000..3809681 --- /dev/null +++ b/src/main/java/dev/surl/surl/service/SurlService.kt @@ -0,0 +1,33 @@ +package dev.surl.surl.service + +import dev.surl.surl.dao.Surl +import dev.surl.surl.dsl.Surls +import dev.surl.surl.util.* +import kotlinx.coroutines.runBlocking +import org.jetbrains.exposed.sql.transactions.transaction +import org.springframework.stereotype.Service + +@Service +class SurlService { + fun addSurl(baseurl: String): String = runBlocking { + val id = genSnowflakeUID() + Surl.new { + this.id._value = id + url = baseurl + } + return@runBlocking numberToKey(id) + } + + fun getUrlByKey(key: String): String { + return transaction { + val results = Surls.select(Surls.url).where { + Surls.id eq keyToNumber(key) + }.toList() + if (results.isNotEmpty()) { + return@transaction results.first()[Surls.url] + } else { + return@transaction "" + } + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/surl/surl/service/UserService.kt b/src/main/java/dev/surl/surl/service/UserService.kt new file mode 100644 index 0000000..fc32f37 --- /dev/null +++ b/src/main/java/dev/surl/surl/service/UserService.kt @@ -0,0 +1,3 @@ +package dev.surl.surl.service + +class UserService \ No newline at end of file diff --git a/src/main/java/dev/surl/surl/util/CheckSumUtil.kt b/src/main/java/dev/surl/surl/util/CheckSumUtil.kt index 96ff05e..017a223 100644 --- a/src/main/java/dev/surl/surl/util/CheckSumUtil.kt +++ b/src/main/java/dev/surl/surl/util/CheckSumUtil.kt @@ -1,8 +1,11 @@ package dev.surl.surl.util -import org.springframework.util.DigestUtils +import org.apache.commons.codec.digest.DigestUtils fun md5(str: String): String { - val cksum = DigestUtils.md5Digest(str.toByteArray()) - return cksum.joinToString("") { "%02x".format(it) } + return DigestUtils.md5Hex(str) +} + +fun sha1(str: String): String { + return DigestUtils.sha1Hex(str) } \ No newline at end of file diff --git a/src/main/java/dev/surl/surl/util/UIDUtil.kt b/src/main/java/dev/surl/surl/util/UIDUtil.kt index b5b8e4c..dc2b32f 100644 --- a/src/main/java/dev/surl/surl/util/UIDUtil.kt +++ b/src/main/java/dev/surl/surl/util/UIDUtil.kt @@ -1,15 +1,36 @@ package dev.surl.surl.util -import java.util.concurrent.ThreadLocalRandom +import org.noelware.charted.snowflake.Snowflake +import kotlin.math.pow -private val CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toCharArray() -private const val LENGTH = 16 +private val CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!*().-_~".toCharArray() +private const val LENGTH = 11 +private val snowflake = Snowflake() -fun genSurlUID(): String { - return genSurlUID(LENGTH) +fun numberToKey(number: Long): String { + var num = number + val sb = StringBuilder() + for(i in LENGTH - 1 downTo 0) { + val remainder = num % CHARS.size + sb.append(CHARS[remainder.toInt()]) + num /= CHARS.size + if(num == 0L) { + break + } + } + return sb.reverse().toString() } -fun genSurlUID(length: Int): String { - val random = ThreadLocalRandom.current() - return CharArray(length) { CHARS[random.nextInt(CHARS.size)] }.concatToString() -} \ No newline at end of file +fun keyToNumber(key: String): Long { + var sum = 0L + for(i in key.indices) { + val char = key[i] + val index = CHARS.indexOf(char) + sum += index * (CHARS.size.toDouble().pow(key.length - i - 1).toLong()) + } + return sum +} + +suspend fun genSnowflakeUID(): Long { + return snowflake.generate().value +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 639b0c8..295b28e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,7 +1,37 @@ server: - port: 18888 #服务端口 + port: 18888 spring: + profiles: + active: default application: - name: surl # 应用名称 + name: surl main: - banner-mode: off # 关闭banner + banner-mode: off + datasource: + name: postgres + driver-class-name: org.postgresql.Driver + url: jdbc:postgresql://localhost:5432/postgres?reWriteBatchedInserts=true + username: postgres + password: f6059e64-889b-4f31-97a7-deaeb1a36d58 + hikari: + connection-test-query: SELECT 1 + connection-timeout: 30000 + maximum-pool-size: 10 + jackson: + deserialization: + use-big-decimal-for-floats: true + use-big-integer-for-ints: true + time-zone: Asia/Shanghai + serialization: + indent-output: true + exposed: + show-sql: false + generate-ddl: true + security: + user: + name: admin + password: 123 +logging: + level: + root: info + Exposed: warn \ No newline at end of file diff --git a/src/test/java/dev/surl/surl/Benchmark.kt b/src/test/java/dev/surl/surl/Benchmark.kt new file mode 100644 index 0000000..fe956d5 --- /dev/null +++ b/src/test/java/dev/surl/surl/Benchmark.kt @@ -0,0 +1,28 @@ +package dev.surl.surl + +import dev.surl.surl.service.SurlService +import dev.surl.surl.util.genSnowflakeUID +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest + +@SpringBootTest +class Benchmark { + @Test + fun tpsTest(@Autowired service: SurlService) { + val now = System.currentTimeMillis() + runBlocking { + for(i in 1..128) { + launch { + for (j in 1..100) { + service.addSurl("https://surl.org/${genSnowflakeUID()}") + } + } + } + } + val timeEllapsed = System.currentTimeMillis() - now + println("TPS: ${128 * 100.0 * 1000 / timeEllapsed}") + } +} \ No newline at end of file diff --git a/src/test/java/dev/surl/surl/SecurityTests.kt b/src/test/java/dev/surl/surl/SecurityTests.kt new file mode 100644 index 0000000..4e02bd2 --- /dev/null +++ b/src/test/java/dev/surl/surl/SecurityTests.kt @@ -0,0 +1,18 @@ +package dev.surl.surl + +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.web.servlet.MockMvc +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.* +import org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated + +@SpringBootTest +@AutoConfigureMockMvc +class SecurityTests(@Autowired val mvc: MockMvc) { + @Test + fun `surl add page`() { + mvc.perform(formLogin("/login").user("user").password("1233")).andExpect(authenticated()) + } +} \ No newline at end of file diff --git a/src/test/java/dev/surl/surl/UtilTests.kt b/src/test/java/dev/surl/surl/UtilTests.kt index a40a09a..cdc9d78 100644 --- a/src/test/java/dev/surl/surl/UtilTests.kt +++ b/src/test/java/dev/surl/surl/UtilTests.kt @@ -1,7 +1,8 @@ package dev.surl.surl -import dev.surl.surl.util.genSurlUID +import dev.surl.surl.util.keyToNumber import dev.surl.surl.util.md5 +import dev.surl.surl.util.numberToKey import org.junit.jupiter.api.Test class UtilTests { @@ -11,8 +12,8 @@ class UtilTests { } @Test - fun randomUIDTest() { - println(genSurlUID()) - println(genSurlUID(8)) + fun numberToUIDTest() { + println(numberToKey(1810519439309799440)) + println(keyToNumber("0IYJbl*DiYE")) } } \ No newline at end of file