Skip to content

Commit 77062ae

Browse files
authored
Merge pull request #4 from solitar-dev/feat/password
Add lockable link
2 parents 2f7d475 + e4448e3 commit 77062ae

17 files changed

Lines changed: 249 additions & 82 deletions

File tree

apps/backend/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ dependencies {
3333
implementation("org.springframework.boot:spring-boot-starter-data-redis")
3434
implementation("org.springframework.boot:spring-boot-starter-flyway")
3535
implementation("org.flywaydb:flyway-database-postgresql")
36+
implementation("org.springframework.security:spring-security-crypto")
37+
implementation("org.bouncycastle:bcprov-jdk18on:1.83")
3638

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package org.tobynguyen.solitar.config
2+
3+
import org.slf4j.LoggerFactory
4+
import org.springframework.boot.CommandLineRunner
5+
import org.springframework.context.annotation.Bean
6+
import org.springframework.context.annotation.Configuration
7+
import org.springframework.context.annotation.Profile
8+
import org.springframework.data.redis.connection.RedisConnectionFactory
9+
10+
@Configuration
11+
@Profile("dev")
12+
class RedisDevConfig {
13+
14+
companion object {
15+
private val LOGGER = LoggerFactory.getLogger(RedisDevConfig::class.java)
16+
}
17+
18+
@Bean
19+
fun clearRedisCache(connectionFactory: RedisConnectionFactory): CommandLineRunner {
20+
return CommandLineRunner {
21+
try {
22+
connectionFactory.connection.serverCommands().flushDb()
23+
LOGGER.info("Redis got flushed successfully")
24+
} catch (e: Exception) {
25+
LOGGER.error("Failed to clear Redis: ${e.message}")
26+
}
27+
}
28+
}
29+
}
Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,22 @@
11
package org.tobynguyen.solitar.controller
22

3+
import jakarta.validation.Valid
34
import org.springframework.http.ResponseEntity
4-
import org.springframework.web.bind.annotation.GetMapping
5-
import org.springframework.web.bind.annotation.PathVariable
5+
import org.springframework.web.bind.annotation.PostMapping
6+
import org.springframework.web.bind.annotation.RequestBody
67
import org.springframework.web.bind.annotation.RequestMapping
78
import org.springframework.web.bind.annotation.RestController
9+
import org.tobynguyen.solitar.model.dto.UrlForwardDto
810
import org.tobynguyen.solitar.model.dto.UrlForwardResponseDto
911
import org.tobynguyen.solitar.service.UrlService
1012

1113
@RestController
1214
@RequestMapping("/forward")
1315
class ForwardController(private val urlService: UrlService) {
14-
@GetMapping("/{shortCode}")
15-
fun forwardUrl(@PathVariable shortCode: String): ResponseEntity<UrlForwardResponseDto> {
16-
val originalUrl = urlService.getOriginalUrl(shortCode)
16+
@PostMapping
17+
fun forwardUrl(@RequestBody @Valid body: UrlForwardDto): ResponseEntity<UrlForwardResponseDto> {
18+
val result = urlService.getOriginalUrl(body)
1719

18-
return ResponseEntity.ok(UrlForwardResponseDto(originalUrl))
20+
return ResponseEntity.ok(result)
1921
}
2022
}
Lines changed: 32 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,53 @@
11
package org.tobynguyen.solitar.exception
22

3-
import jakarta.servlet.http.HttpServletRequest
3+
import org.springframework.http.HttpHeaders
44
import org.springframework.http.HttpStatus
5+
import org.springframework.http.HttpStatusCode
6+
import org.springframework.http.ProblemDetail
57
import org.springframework.http.ResponseEntity
68
import org.springframework.web.bind.MethodArgumentNotValidException
79
import org.springframework.web.bind.annotation.ExceptionHandler
8-
import org.springframework.web.bind.annotation.ResponseStatus
910
import org.springframework.web.bind.annotation.RestControllerAdvice
10-
import org.tobynguyen.solitar.model.dto.ErrorResponse
11+
import org.springframework.web.context.request.WebRequest
12+
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler
1113

1214
@RestControllerAdvice
13-
class UrlExceptionHandler {
14-
15-
@ExceptionHandler(MethodArgumentNotValidException::class)
16-
@ResponseStatus(HttpStatus.BAD_REQUEST)
17-
fun onValidationFailed(e: MethodArgumentNotValidException): ResponseEntity<Map<String, Any>> {
18-
val map = buildMap {
19-
e.bindingResult.fieldErrors.forEach {
20-
put(it.field, it.defaultMessage ?: "Validation failed")
15+
class UrlExceptionHandler : ResponseEntityExceptionHandler() {
16+
17+
override fun handleMethodArgumentNotValid(
18+
ex: MethodArgumentNotValidException,
19+
headers: HttpHeaders,
20+
status: HttpStatusCode,
21+
request: WebRequest,
22+
): ResponseEntity<Any> {
23+
val invalidParams =
24+
ex.bindingResult.fieldErrors.map {
25+
mapOf("name" to it.field, "reason" to (it.defaultMessage ?: "Validation failed"))
2126
}
22-
}
2327

24-
return ResponseEntity.badRequest().body(map)
28+
val problemDetail =
29+
ProblemDetail.forStatusAndDetail(
30+
HttpStatus.BAD_REQUEST,
31+
"The request contained invalid data. Please check the 'invalid_params' array.",
32+
)
33+
.apply { setProperty("invalid_params", invalidParams) }
34+
35+
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(problemDetail)
2536
}
2637

2738
@ExceptionHandler(UrlNotFoundException::class)
28-
@ResponseStatus(HttpStatus.NOT_FOUND)
29-
fun onUrlNotFound(e: UrlNotFoundException, request: HttpServletRequest) =
30-
ErrorResponse(
31-
status = HttpStatus.NOT_FOUND.value(),
32-
error = HttpStatus.NOT_FOUND.reasonPhrase,
33-
message = e.message,
34-
path = request.requestURI,
35-
)
39+
fun onUrlNotFound(e: UrlNotFoundException) =
40+
ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.message)
3641

3742
@ExceptionHandler(UrlExpiredException::class)
38-
@ResponseStatus(HttpStatus.GONE)
39-
fun onUrlExpired(e: UrlExpiredException, request: HttpServletRequest) =
40-
ErrorResponse(
41-
status = HttpStatus.GONE.value(),
42-
error = HttpStatus.GONE.reasonPhrase,
43-
message = e.message,
44-
path = request.requestURI,
45-
)
43+
fun onUrlExpired(e: UrlExpiredException) =
44+
ProblemDetail.forStatusAndDetail(HttpStatus.GONE, e.message)
4645

4746
@ExceptionHandler(UrlDisabledException::class)
48-
@ResponseStatus(HttpStatus.FORBIDDEN)
49-
fun onUrlDisabled(e: UrlDisabledException, request: HttpServletRequest) =
50-
ErrorResponse(
51-
status = HttpStatus.FORBIDDEN.value(),
52-
error = HttpStatus.FORBIDDEN.reasonPhrase,
53-
message = e.message,
54-
path = request.requestURI,
55-
)
47+
fun onUrlDisabled(e: UrlDisabledException) =
48+
ProblemDetail.forStatusAndDetail(HttpStatus.FORBIDDEN, e.message)
5649

5750
@ExceptionHandler(UrlShortCodeConflictedException::class)
58-
@ResponseStatus(HttpStatus.CONFLICT)
59-
fun onUrlShortCodeConflicted(e: UrlShortCodeConflictedException, request: HttpServletRequest) =
60-
ErrorResponse(
61-
status = HttpStatus.CONFLICT.value(),
62-
error = HttpStatus.CONFLICT.reasonPhrase,
63-
message = e.message,
64-
path = request.requestURI,
65-
)
51+
fun onUrlShortCodeConflicted(e: UrlShortCodeConflictedException) =
52+
ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, e.message)
6653
}

apps/backend/src/main/kotlin/org/tobynguyen/solitar/model/dto/ErrorResponse.kt

Lines changed: 0 additions & 11 deletions
This file was deleted.

apps/backend/src/main/kotlin/org/tobynguyen/solitar/model/dto/UrlDto.kt

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,18 @@ data class UrlCreateDto(
2222
regexp = "^[a-zA-Z0-9_-]*$",
2323
)
2424
val alias: String?,
25-
) {}
25+
@field:Size(message = "Password must be at least 3 characters", min = 3)
26+
@field:Size(message = "Password cannot exceed 255 characters", max = 255)
27+
val password: String?,
28+
)
2629

27-
data class UrlResponseDto(val originalUrl: String, val shortCode: String) {}
30+
data class UrlResponseDto(val originalUrl: String, val shortCode: String)
2831

29-
data class UrlForwardResponseDto(val originalUrl: String) {}
32+
data class UrlForwardResponseDto(val originalUrl: String)
33+
34+
data class UrlForwardDto(
35+
@field:NotBlank(message = "Short code is required") val shortCode: String,
36+
@field:Size(message = "Password must be at least 3 characters", min = 3)
37+
@field:Size(message = "Password cannot exceed 255 characters", max = 255)
38+
val password: String?,
39+
)

apps/backend/src/main/kotlin/org/tobynguyen/solitar/model/entity/UrlEntity.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,13 @@ class UrlEntity(
1616
@Column(nullable = false, unique = true) @field:Size(min = 7) var shortCode: String = "",
1717
@Column(nullable = false) var hasAlias: Boolean = false,
1818
@Column(nullable = false, columnDefinition = "TEXT") var originalUrl: String = "",
19+
@Column(nullable = true, columnDefinition = "TEXT") var password: String? = null,
1920
@Column(nullable = false, name = "click_count") var clickCount: Long = 0,
2021
@Column(nullable = false, name = "is_disabled")
2122
@field:JsonProperty("isDisabled")
2223
var isDisabled: Boolean = false,
24+
@Column(nullable = true, name = "expires_at") var expiresAt: Instant? = null,
2325
@Column(nullable = false, name = "created_at")
2426
@CreationTimestamp
2527
var createdAt: Instant = Instant.now(),
26-
@Column(nullable = true, name = "expires_at") var expiresAt: Instant? = null,
2728
) {}

apps/backend/src/main/kotlin/org/tobynguyen/solitar/repository/UrlRepository.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@ interface UrlRepository : JpaRepository<UrlEntity, Long> {
1414
fun findByOriginalUrlAndExpiresAtAndHasAliasFalse(
1515
originalUrl: String,
1616
expiresAt: Instant,
17-
): UrlEntity?
17+
): List<UrlEntity>
1818

19-
fun findByOriginalUrlAndExpiresAtIsNullAndHasAliasFalse(originalUrl: String): UrlEntity?
19+
fun findByOriginalUrlAndExpiresAtIsNullAndHasAliasFalse(originalUrl: String): List<UrlEntity>
2020

2121
@Modifying
2222
@Query("UPDATE UrlEntity u SET u.clickCount = u.clickCount + 1 WHERE u.id = :id")

apps/backend/src/main/kotlin/org/tobynguyen/solitar/service/UrlService.kt

Lines changed: 77 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package org.tobynguyen.solitar.service
22

33
import java.time.Instant
4+
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder
45
import org.springframework.stereotype.Service
56
import org.springframework.transaction.annotation.Transactional
67
import org.sqids.Sqids
@@ -10,14 +11,22 @@ import org.tobynguyen.solitar.exception.UrlNotFoundException
1011
import org.tobynguyen.solitar.exception.UrlShortCodeConflictedException
1112
import org.tobynguyen.solitar.mapper.toResponseDto
1213
import org.tobynguyen.solitar.model.dto.UrlCreateDto
14+
import org.tobynguyen.solitar.model.dto.UrlForwardDto
15+
import org.tobynguyen.solitar.model.dto.UrlForwardResponseDto
1316
import org.tobynguyen.solitar.model.entity.UrlEntity
1417
import org.tobynguyen.solitar.repository.UrlRepository
1518

1619
@Service
17-
class UrlService(private val urlRepository: UrlRepository, private val sqids: Sqids) {
20+
class UrlService(
21+
private val urlRepository: UrlRepository,
22+
private val sqids: Sqids,
23+
private val argon2Encoder: Argon2PasswordEncoder,
24+
) {
1825

1926
@Transactional
20-
fun getOriginalUrl(shortCode: String): String {
27+
fun getOriginalUrl(data: UrlForwardDto): UrlForwardResponseDto {
28+
val (shortCode, password) = data
29+
2130
val urlEntity =
2231
urlRepository.findByShortCode(shortCode)
2332
?: throw UrlNotFoundException("Short URL '$shortCode' does not exist.")
@@ -30,53 +39,111 @@ class UrlService(private val urlRepository: UrlRepository, private val sqids: Sq
3039

3140
urlRepository.incrementClickCount(urlEntity.id)
3241

33-
return urlEntity.toResponseDto().originalUrl
42+
return if (urlEntity.password == null) {
43+
UrlForwardResponseDto(urlEntity.toResponseDto().originalUrl)
44+
} else {
45+
if (password == null)
46+
throw UrlDisabledException("Please provide a valid password to unlock this URL.")
47+
48+
if (argon2Encoder.matches(password, urlEntity.password)) {
49+
UrlForwardResponseDto(urlEntity.toResponseDto().originalUrl)
50+
} else {
51+
throw UrlDisabledException("Incorrect password")
52+
}
53+
}
3454
}
3555

3656
@Transactional
3757
fun createUrl(data: UrlCreateDto): UrlEntity {
38-
val (url, expireTime, alias) = data
58+
val (url, expireTime, alias, password) = data
59+
60+
val hashedPassword =
61+
if (password != null) {
62+
argon2Encoder.encode(password)
63+
} else {
64+
null
65+
}
3966

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

4370
return if (existing != null) {
44-
if (existing.originalUrl == url && existing.expiresAt == expireTime) {
71+
if (
72+
existing.originalUrl == url &&
73+
existing.expiresAt == expireTime &&
74+
existing.password == null
75+
) {
4576
existing
4677
} else {
4778
throw UrlShortCodeConflictedException("This alias already exists.")
4879
}
4980
} else {
50-
createAndSaveUrl(url, alias, expireTime)
81+
createAndSaveUrl(url, alias, expireTime, hashedPassword)
5182
}
5283
} else {
5384
if (expireTime == null) {
5485
val existing =
5586
urlRepository.findByOriginalUrlAndExpiresAtIsNullAndHasAliasFalse(url)
5687

57-
return existing ?: createAndSaveUrl(url, null, null)
88+
if (existing.isEmpty()) {
89+
return createAndSaveUrl(url, alias, expireTime, hashedPassword)
90+
}
91+
92+
existing.forEach {
93+
if (
94+
(password == null && it.password == null) ||
95+
argon2Encoder.matches(password, it.password)
96+
)
97+
return it
98+
}
99+
100+
return createAndSaveUrl(url, null, null, hashedPassword)
58101
} else {
59102
val existing =
60103
urlRepository.findByOriginalUrlAndExpiresAtAndHasAliasFalse(url, expireTime)
61104

62-
return existing ?: createAndSaveUrl(url, null, expireTime)
105+
if (existing.isEmpty()) {
106+
return createAndSaveUrl(url, alias, expireTime, hashedPassword)
107+
}
108+
109+
existing.forEach {
110+
if (
111+
(password == null && it.password == null) ||
112+
argon2Encoder.matches(password, it.password)
113+
)
114+
return it
115+
}
116+
117+
return createAndSaveUrl(url, null, expireTime, hashedPassword)
63118
}
64119
}
65120
}
66121

67-
fun createAndSaveUrl(url: String, alias: String?, expireTime: Instant?): UrlEntity {
122+
fun createAndSaveUrl(
123+
url: String,
124+
alias: String?,
125+
expireTime: Instant?,
126+
hashedPassword: String?,
127+
): UrlEntity {
68128
if (alias != null) {
69129
val entity =
70130
UrlEntity(
71131
shortCode = alias,
72132
originalUrl = url,
73133
expiresAt = expireTime,
74134
hasAlias = true,
135+
password = hashedPassword,
75136
)
76137

77138
return urlRepository.saveAndFlush(entity)
78139
} else {
79-
val entity = UrlEntity(shortCode = "", originalUrl = url, expiresAt = expireTime)
140+
val entity =
141+
UrlEntity(
142+
shortCode = "",
143+
originalUrl = url,
144+
expiresAt = expireTime,
145+
password = hashedPassword,
146+
)
80147

81148
val savedEntity = urlRepository.save(entity)
82149

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package org.tobynguyen.solitar.util
2+
3+
import org.springframework.context.annotation.Bean
4+
import org.springframework.context.annotation.Configuration
5+
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder
6+
7+
@Configuration
8+
class CryptoUtil {
9+
10+
@Bean fun argon2Encoder() = Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8()
11+
}

0 commit comments

Comments
 (0)