Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/backend/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-redis")
implementation("org.springframework.boot:spring-boot-starter-flyway")
implementation("org.flywaydb:flyway-database-postgresql")
implementation("org.springframework.security:spring-security-crypto")
implementation("org.bouncycastle:bcprov-jdk18on:1.83")

developmentOnly("org.springframework.boot:spring-boot-devtools")

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package org.tobynguyen.solitar.config

import org.slf4j.LoggerFactory
import org.springframework.boot.CommandLineRunner
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Profile
import org.springframework.data.redis.connection.RedisConnectionFactory

@Configuration
@Profile("dev")
class RedisDevConfig {

companion object {
private val LOGGER = LoggerFactory.getLogger(RedisDevConfig::class.java)
}

@Bean
fun clearRedisCache(connectionFactory: RedisConnectionFactory): CommandLineRunner {
return CommandLineRunner {
try {
connectionFactory.connection.serverCommands().flushDb()
LOGGER.info("Redis got flushed successfully")
} catch (e: Exception) {
LOGGER.error("Failed to clear Redis: ${e.message}")
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
package org.tobynguyen.solitar.controller

import jakarta.validation.Valid
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.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import org.tobynguyen.solitar.model.dto.UrlForwardDto
import org.tobynguyen.solitar.model.dto.UrlForwardResponseDto
import org.tobynguyen.solitar.service.UrlService

@RestController
@RequestMapping("/forward")
class ForwardController(private val urlService: UrlService) {
@GetMapping("/{shortCode}")
fun forwardUrl(@PathVariable shortCode: String): ResponseEntity<UrlForwardResponseDto> {
val originalUrl = urlService.getOriginalUrl(shortCode)
@PostMapping
fun forwardUrl(@RequestBody @Valid body: UrlForwardDto): ResponseEntity<UrlForwardResponseDto> {
val result = urlService.getOriginalUrl(body)

return ResponseEntity.ok(UrlForwardResponseDto(originalUrl))
return ResponseEntity.ok(result)
}
}
Original file line number Diff line number Diff line change
@@ -1,66 +1,53 @@
package org.tobynguyen.solitar.exception

import jakarta.servlet.http.HttpServletRequest
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.HttpStatusCode
import org.springframework.http.ProblemDetail
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestControllerAdvice
import org.tobynguyen.solitar.model.dto.ErrorResponse
import org.springframework.web.context.request.WebRequest
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler

@RestControllerAdvice
class UrlExceptionHandler {

@ExceptionHandler(MethodArgumentNotValidException::class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
fun onValidationFailed(e: MethodArgumentNotValidException): ResponseEntity<Map<String, Any>> {
val map = buildMap {
e.bindingResult.fieldErrors.forEach {
put(it.field, it.defaultMessage ?: "Validation failed")
class UrlExceptionHandler : ResponseEntityExceptionHandler() {

override fun handleMethodArgumentNotValid(
ex: MethodArgumentNotValidException,
headers: HttpHeaders,
status: HttpStatusCode,
request: WebRequest,
): ResponseEntity<Any> {
val invalidParams =
ex.bindingResult.fieldErrors.map {
mapOf("name" to it.field, "reason" to (it.defaultMessage ?: "Validation failed"))
}
}

return ResponseEntity.badRequest().body(map)
val problemDetail =
ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST,
"The request contained invalid data. Please check the 'invalid_params' array.",
)
.apply { setProperty("invalid_params", invalidParams) }

return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(problemDetail)
}

@ExceptionHandler(UrlNotFoundException::class)
@ResponseStatus(HttpStatus.NOT_FOUND)
fun onUrlNotFound(e: UrlNotFoundException, request: HttpServletRequest) =
ErrorResponse(
status = HttpStatus.NOT_FOUND.value(),
error = HttpStatus.NOT_FOUND.reasonPhrase,
message = e.message,
path = request.requestURI,
)
fun onUrlNotFound(e: UrlNotFoundException) =
ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.message)

@ExceptionHandler(UrlExpiredException::class)
@ResponseStatus(HttpStatus.GONE)
fun onUrlExpired(e: UrlExpiredException, request: HttpServletRequest) =
ErrorResponse(
status = HttpStatus.GONE.value(),
error = HttpStatus.GONE.reasonPhrase,
message = e.message,
path = request.requestURI,
)
fun onUrlExpired(e: UrlExpiredException) =
ProblemDetail.forStatusAndDetail(HttpStatus.GONE, e.message)

@ExceptionHandler(UrlDisabledException::class)
@ResponseStatus(HttpStatus.FORBIDDEN)
fun onUrlDisabled(e: UrlDisabledException, request: HttpServletRequest) =
ErrorResponse(
status = HttpStatus.FORBIDDEN.value(),
error = HttpStatus.FORBIDDEN.reasonPhrase,
message = e.message,
path = request.requestURI,
)
fun onUrlDisabled(e: UrlDisabledException) =
ProblemDetail.forStatusAndDetail(HttpStatus.FORBIDDEN, e.message)

@ExceptionHandler(UrlShortCodeConflictedException::class)
@ResponseStatus(HttpStatus.CONFLICT)
fun onUrlShortCodeConflicted(e: UrlShortCodeConflictedException, request: HttpServletRequest) =
ErrorResponse(
status = HttpStatus.CONFLICT.value(),
error = HttpStatus.CONFLICT.reasonPhrase,
message = e.message,
path = request.requestURI,
)
fun onUrlShortCodeConflicted(e: UrlShortCodeConflictedException) =
ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, e.message)
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,18 @@ data class UrlCreateDto(
regexp = "^[a-zA-Z0-9_-]*$",
)
val alias: String?,
) {}
@field:Size(message = "Password must be at least 3 characters", min = 3)
@field:Size(message = "Password cannot exceed 255 characters", max = 255)
val password: String?,
)

data class UrlResponseDto(val originalUrl: String, val shortCode: String) {}
data class UrlResponseDto(val originalUrl: String, val shortCode: String)

data class UrlForwardResponseDto(val originalUrl: String) {}
data class UrlForwardResponseDto(val originalUrl: String)

data class UrlForwardDto(
@field:NotBlank(message = "Short code is required") val shortCode: String,
@field:Size(message = "Password must be at least 3 characters", min = 3)
@field:Size(message = "Password cannot exceed 255 characters", max = 255)
val password: String?,
)
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,13 @@ class UrlEntity(
@Column(nullable = false, unique = true) @field:Size(min = 7) var shortCode: String = "",
@Column(nullable = false) var hasAlias: Boolean = false,
@Column(nullable = false, columnDefinition = "TEXT") var originalUrl: String = "",
@Column(nullable = true, columnDefinition = "TEXT") var password: String? = null,
@Column(nullable = false, name = "click_count") var clickCount: Long = 0,
@Column(nullable = false, name = "is_disabled")
@field:JsonProperty("isDisabled")
var isDisabled: Boolean = false,
@Column(nullable = true, name = "expires_at") var expiresAt: Instant? = null,
@Column(nullable = false, name = "created_at")
@CreationTimestamp
var createdAt: Instant = Instant.now(),
@Column(nullable = true, name = "expires_at") var expiresAt: Instant? = null,
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ interface UrlRepository : JpaRepository<UrlEntity, Long> {
fun findByOriginalUrlAndExpiresAtAndHasAliasFalse(
originalUrl: String,
expiresAt: Instant,
): UrlEntity?
): List<UrlEntity>

fun findByOriginalUrlAndExpiresAtIsNullAndHasAliasFalse(originalUrl: String): UrlEntity?
fun findByOriginalUrlAndExpiresAtIsNullAndHasAliasFalse(originalUrl: String): List<UrlEntity>

@Modifying
@Query("UPDATE UrlEntity u SET u.clickCount = u.clickCount + 1 WHERE u.id = :id")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.tobynguyen.solitar.service

import java.time.Instant
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.sqids.Sqids
Expand All @@ -10,14 +11,22 @@ import org.tobynguyen.solitar.exception.UrlNotFoundException
import org.tobynguyen.solitar.exception.UrlShortCodeConflictedException
import org.tobynguyen.solitar.mapper.toResponseDto
import org.tobynguyen.solitar.model.dto.UrlCreateDto
import org.tobynguyen.solitar.model.dto.UrlForwardDto
import org.tobynguyen.solitar.model.dto.UrlForwardResponseDto
import org.tobynguyen.solitar.model.entity.UrlEntity
import org.tobynguyen.solitar.repository.UrlRepository

@Service
class UrlService(private val urlRepository: UrlRepository, private val sqids: Sqids) {
class UrlService(
private val urlRepository: UrlRepository,
private val sqids: Sqids,
private val argon2Encoder: Argon2PasswordEncoder,
) {

@Transactional
fun getOriginalUrl(shortCode: String): String {
fun getOriginalUrl(data: UrlForwardDto): UrlForwardResponseDto {
val (shortCode, password) = data

val urlEntity =
urlRepository.findByShortCode(shortCode)
?: throw UrlNotFoundException("Short URL '$shortCode' does not exist.")
Expand All @@ -30,53 +39,111 @@ class UrlService(private val urlRepository: UrlRepository, private val sqids: Sq

urlRepository.incrementClickCount(urlEntity.id)

return urlEntity.toResponseDto().originalUrl
return if (urlEntity.password == null) {
UrlForwardResponseDto(urlEntity.toResponseDto().originalUrl)
} else {
if (password == null)
throw UrlDisabledException("Please provide a valid password to unlock this URL.")

if (argon2Encoder.matches(password, urlEntity.password)) {
UrlForwardResponseDto(urlEntity.toResponseDto().originalUrl)
} else {
throw UrlDisabledException("Incorrect password")
}
}
}

@Transactional
fun createUrl(data: UrlCreateDto): UrlEntity {
val (url, expireTime, alias) = data
val (url, expireTime, alias, password) = data

val hashedPassword =
if (password != null) {
argon2Encoder.encode(password)
} else {
null
}

if (alias != null) {
val existing = urlRepository.findByShortCode(alias)

return if (existing != null) {
if (existing.originalUrl == url && existing.expiresAt == expireTime) {
if (
existing.originalUrl == url &&
existing.expiresAt == expireTime &&
existing.password == null
) {
existing
} else {
throw UrlShortCodeConflictedException("This alias already exists.")
}
} else {
createAndSaveUrl(url, alias, expireTime)
createAndSaveUrl(url, alias, expireTime, hashedPassword)
}
} else {
if (expireTime == null) {
val existing =
urlRepository.findByOriginalUrlAndExpiresAtIsNullAndHasAliasFalse(url)

return existing ?: createAndSaveUrl(url, null, null)
if (existing.isEmpty()) {
return createAndSaveUrl(url, alias, expireTime, hashedPassword)
}

existing.forEach {
if (
(password == null && it.password == null) ||
argon2Encoder.matches(password, it.password)
)
return it
}

return createAndSaveUrl(url, null, null, hashedPassword)
} else {
val existing =
urlRepository.findByOriginalUrlAndExpiresAtAndHasAliasFalse(url, expireTime)

return existing ?: createAndSaveUrl(url, null, expireTime)
if (existing.isEmpty()) {
return createAndSaveUrl(url, alias, expireTime, hashedPassword)
}

existing.forEach {
if (
(password == null && it.password == null) ||
argon2Encoder.matches(password, it.password)
)
return it
}

return createAndSaveUrl(url, null, expireTime, hashedPassword)
}
}
}

fun createAndSaveUrl(url: String, alias: String?, expireTime: Instant?): UrlEntity {
fun createAndSaveUrl(
url: String,
alias: String?,
expireTime: Instant?,
hashedPassword: String?,
): UrlEntity {
if (alias != null) {
val entity =
UrlEntity(
shortCode = alias,
originalUrl = url,
expiresAt = expireTime,
hasAlias = true,
password = hashedPassword,
)

return urlRepository.saveAndFlush(entity)
} else {
val entity = UrlEntity(shortCode = "", originalUrl = url, expiresAt = expireTime)
val entity =
UrlEntity(
shortCode = "",
originalUrl = url,
expiresAt = expireTime,
password = hashedPassword,
)

val savedEntity = urlRepository.save(entity)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package org.tobynguyen.solitar.util

import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder

@Configuration
class CryptoUtil {

@Bean fun argon2Encoder() = Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8()
}
Loading