Deduplicate logic across controllers, auth, and schema fixes
All checks were successful
build-and-deploy / build-deploy (push) Successful in 33s

This commit is contained in:
androidlover5842
2026-01-28 23:03:48 +05:30
parent f8bdb8e759
commit 9b64b34ab9
26 changed files with 412 additions and 510 deletions

View File

@@ -8,7 +8,6 @@ import org.apache.pdfbox.pdmodel.font.PDType1Font
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths import java.nio.file.Paths
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.util.UUID import java.util.UUID
@@ -112,12 +111,4 @@ class EmailStorage(
atomicMove(tmp, path) atomicMove(tmp, path)
return path.toString() return path.toString()
} }
private fun atomicMove(tmp: Path, target: Path) {
try {
Files.move(tmp, target, java.nio.file.StandardCopyOption.ATOMIC_MOVE, java.nio.file.StandardCopyOption.REPLACE_EXISTING)
} catch (_: Exception) {
Files.move(tmp, target, java.nio.file.StandardCopyOption.REPLACE_EXISTING)
}
}
} }

View File

@@ -0,0 +1,17 @@
package com.android.trisolarisserver.component
import java.nio.file.Files
import java.nio.file.Path
internal fun atomicMove(tmp: Path, target: Path) {
try {
Files.move(
tmp,
target,
java.nio.file.StandardCopyOption.ATOMIC_MOVE,
java.nio.file.StandardCopyOption.REPLACE_EXISTING
)
} catch (_: Exception) {
Files.move(tmp, target, java.nio.file.StandardCopyOption.REPLACE_EXISTING)
}
}

View File

@@ -38,13 +38,7 @@ class LlamaClient(
) )
) )
) )
val headers = HttpHeaders() return post(payload)
headers.contentType = MediaType.APPLICATION_JSON
val entity = HttpEntity(payload, headers)
val response = restTemplate.postForEntity(baseUrl, entity, String::class.java)
val body = response.body ?: return ""
val node = objectMapper.readTree(body)
return node.path("choices").path(0).path("message").path("content").asText()
} }
fun askText(content: String, question: String): String { fun askText(content: String, question: String): String {
@@ -61,6 +55,10 @@ class LlamaClient(
) )
) )
) )
return post(payload)
}
private fun post(payload: Map<String, Any>): String {
val headers = HttpHeaders() val headers = HttpHeaders()
headers.contentType = MediaType.APPLICATION_JSON headers.contentType = MediaType.APPLICATION_JSON
val entity = HttpEntity(payload, headers) val entity = HttpEntity(payload, headers)

View File

@@ -6,7 +6,6 @@ import org.springframework.web.multipart.MultipartFile
import java.awt.image.BufferedImage import java.awt.image.BufferedImage
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths import java.nio.file.Paths
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.util.UUID import java.util.UUID
@@ -102,13 +101,6 @@ class RoomImageStorage(
} }
} }
private fun atomicMove(tmp: Path, target: Path) {
try {
Files.move(tmp, target, java.nio.file.StandardCopyOption.ATOMIC_MOVE, java.nio.file.StandardCopyOption.REPLACE_EXISTING)
} catch (_: Exception) {
Files.move(tmp, target, java.nio.file.StandardCopyOption.REPLACE_EXISTING)
}
}
} }
data class StoredRoomImage( data class StoredRoomImage(

View File

@@ -1,24 +1,14 @@
package com.android.trisolarisserver.config package com.android.trisolarisserver.config
import org.slf4j.LoggerFactory
import org.springframework.boot.ApplicationArguments
import org.springframework.boot.ApplicationRunner
import org.springframework.jdbc.core.JdbcTemplate import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
@Component @Component
class IssuedCardSchemaFix( class IssuedCardSchemaFix(
private val jdbcTemplate: JdbcTemplate private val jdbcTemplate: JdbcTemplate
) : ApplicationRunner { ) : PostgresSchemaFix(jdbcTemplate) {
private val logger = LoggerFactory.getLogger(IssuedCardSchemaFix::class.java)
override fun run(args: ApplicationArguments) {
val version = jdbcTemplate.queryForObject("select version()", String::class.java) ?: return
if (!version.contains("PostgreSQL", ignoreCase = true)) {
return
}
override fun runPostgres(jdbcTemplate: JdbcTemplate) {
val isNullable = jdbcTemplate.queryForObject( val isNullable = jdbcTemplate.queryForObject(
""" """
select count(*) select count(*)

View File

@@ -0,0 +1,23 @@
package com.android.trisolarisserver.config
import org.slf4j.LoggerFactory
import org.springframework.boot.ApplicationArguments
import org.springframework.boot.ApplicationRunner
import org.springframework.jdbc.core.JdbcTemplate
abstract class PostgresSchemaFix(
private val jdbcTemplate: JdbcTemplate
) : ApplicationRunner {
protected val logger = LoggerFactory.getLogger(this::class.java)
override fun run(args: ApplicationArguments) {
val version = jdbcTemplate.queryForObject("select version()", String::class.java) ?: return
if (!version.contains("PostgreSQL", ignoreCase = true)) {
return
}
runPostgres(jdbcTemplate)
}
protected abstract fun runPostgres(jdbcTemplate: JdbcTemplate)
}

View File

@@ -1,24 +1,14 @@
package com.android.trisolarisserver.config package com.android.trisolarisserver.config
import org.slf4j.LoggerFactory
import org.springframework.boot.ApplicationArguments
import org.springframework.boot.ApplicationRunner
import org.springframework.jdbc.core.JdbcTemplate import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
@Component @Component
class RoomImageSchemaFix( class RoomImageSchemaFix(
private val jdbcTemplate: JdbcTemplate private val jdbcTemplate: JdbcTemplate
) : ApplicationRunner { ) : PostgresSchemaFix(jdbcTemplate) {
private val logger = LoggerFactory.getLogger(RoomImageSchemaFix::class.java)
override fun run(args: ApplicationArguments) {
val version = jdbcTemplate.queryForObject("select version()", String::class.java) ?: return
if (!version.contains("PostgreSQL", ignoreCase = true)) {
return
}
override fun runPostgres(jdbcTemplate: JdbcTemplate) {
val hasContentHash = jdbcTemplate.queryForObject( val hasContentHash = jdbcTemplate.queryForObject(
""" """
select count(*) select count(*)

View File

@@ -1,24 +1,14 @@
package com.android.trisolarisserver.config package com.android.trisolarisserver.config
import org.slf4j.LoggerFactory
import org.springframework.boot.ApplicationArguments
import org.springframework.boot.ApplicationRunner
import org.springframework.jdbc.core.JdbcTemplate import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
@Component @Component
class RoomImageTagSchemaFix( class RoomImageTagSchemaFix(
private val jdbcTemplate: JdbcTemplate private val jdbcTemplate: JdbcTemplate
) : ApplicationRunner { ) : PostgresSchemaFix(jdbcTemplate) {
private val logger = LoggerFactory.getLogger(RoomImageTagSchemaFix::class.java)
override fun run(args: ApplicationArguments) {
val version = jdbcTemplate.queryForObject("select version()", String::class.java) ?: return
if (!version.contains("PostgreSQL", ignoreCase = true)) {
return
}
override fun runPostgres(jdbcTemplate: JdbcTemplate) {
val hasOldRoomImageId = jdbcTemplate.queryForObject( val hasOldRoomImageId = jdbcTemplate.queryForObject(
""" """
select count(*) select count(*)

View File

@@ -1,24 +1,14 @@
package com.android.trisolarisserver.config package com.android.trisolarisserver.config
import org.slf4j.LoggerFactory
import org.springframework.boot.ApplicationArguments
import org.springframework.boot.ApplicationRunner
import org.springframework.jdbc.core.JdbcTemplate import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
@Component @Component
class RoomTypeSchemaFix( class RoomTypeSchemaFix(
private val jdbcTemplate: JdbcTemplate private val jdbcTemplate: JdbcTemplate
) : ApplicationRunner { ) : PostgresSchemaFix(jdbcTemplate) {
private val logger = LoggerFactory.getLogger(RoomTypeSchemaFix::class.java)
override fun run(args: ApplicationArguments) {
val version = jdbcTemplate.queryForObject("select version()", String::class.java) ?: return
if (!version.contains("PostgreSQL", ignoreCase = true)) {
return
}
override fun runPostgres(jdbcTemplate: JdbcTemplate) {
val hasActive = jdbcTemplate.queryForObject( val hasActive = jdbcTemplate.queryForObject(
""" """
select count(*) select count(*)

View File

@@ -5,7 +5,6 @@ import com.android.trisolarisserver.controller.dto.UserResponse
import com.android.trisolarisserver.repo.AppUserRepo import com.android.trisolarisserver.repo.AppUserRepo
import com.android.trisolarisserver.repo.PropertyUserRepo import com.android.trisolarisserver.repo.PropertyUserRepo
import com.android.trisolarisserver.security.MyPrincipal import com.android.trisolarisserver.security.MyPrincipal
import com.google.firebase.auth.FirebaseAuth
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.core.annotation.AuthenticationPrincipal
@@ -19,12 +18,14 @@ import org.springframework.web.server.ResponseStatusException
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import java.util.UUID import java.util.UUID
import com.android.trisolarisserver.security.AuthResolver
@RestController @RestController
@RequestMapping("/auth") @RequestMapping("/auth")
class Auth( class Auth(
private val appUserRepo: AppUserRepo, private val appUserRepo: AppUserRepo,
private val propertyUserRepo: PropertyUserRepo private val propertyUserRepo: PropertyUserRepo,
private val authResolver: AuthResolver
) { ) {
private val logger = LoggerFactory.getLogger(Auth::class.java) private val logger = LoggerFactory.getLogger(Auth::class.java)
@@ -34,7 +35,8 @@ class Auth(
request: HttpServletRequest request: HttpServletRequest
): ResponseEntity<AuthResponse> { ): ResponseEntity<AuthResponse> {
logger.info("Auth verify hit, principalPresent={}", principal != null) logger.info("Auth verify hit, principalPresent={}", principal != null)
val resolved = principal?.let { ResolveResult(it) } ?: resolvePrincipalFromHeader(request) val resolved = principal?.let { ResolveResult(it) }
?: ResolveResult(authResolver.resolveFromRequest(request, createIfMissing = true))
return resolved.toResponseEntity() return resolved.toResponseEntity()
} }
@@ -43,7 +45,8 @@ class Auth(
@AuthenticationPrincipal principal: MyPrincipal?, @AuthenticationPrincipal principal: MyPrincipal?,
request: HttpServletRequest request: HttpServletRequest
): ResponseEntity<AuthResponse> { ): ResponseEntity<AuthResponse> {
val resolved = principal?.let { ResolveResult(it) } ?: resolvePrincipalFromHeader(request) val resolved = principal?.let { ResolveResult(it) }
?: ResolveResult(authResolver.resolveFromRequest(request, createIfMissing = true))
return resolved.toResponseEntity() return resolved.toResponseEntity()
} }
@@ -53,7 +56,8 @@ class Auth(
request: HttpServletRequest, request: HttpServletRequest,
@RequestBody body: UpdateMeRequest @RequestBody body: UpdateMeRequest
): ResponseEntity<AuthResponse> { ): ResponseEntity<AuthResponse> {
val resolved = principal?.let { ResolveResult(it) } ?: resolvePrincipalFromHeader(request) val resolved = principal?.let { ResolveResult(it) }
?: ResolveResult(authResolver.resolveFromRequest(request, createIfMissing = true))
if (resolved.principal == null) { if (resolved.principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED) return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(AuthResponse(status = "UNAUTHORIZED")) .body(AuthResponse(status = "UNAUTHORIZED"))
@@ -98,46 +102,6 @@ class Auth(
) )
} }
private fun resolvePrincipalFromHeader(request: HttpServletRequest): ResolveResult {
val header = request.getHeader("Authorization") ?: throw ResponseStatusException(
HttpStatus.UNAUTHORIZED,
"Missing Authorization token"
)
if (!header.startsWith("Bearer ")) {
logger.warn("Auth verify invalid Authorization header")
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid Authorization header")
}
val token = header.removePrefix("Bearer ").trim()
val decoded = try {
FirebaseAuth.getInstance().verifyIdToken(token)
} catch (ex: Exception) {
logger.warn("Auth verify failed: {}", ex.message)
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid token")
}
val user = appUserRepo.findByFirebaseUid(decoded.uid) ?: run {
val phone = decoded.claims["phone_number"] as? String
val name = decoded.claims["name"] as? String
val makeSuperAdmin = appUserRepo.count() == 0L
val created = appUserRepo.save(
com.android.trisolarisserver.models.property.AppUser(
firebaseUid = decoded.uid,
phoneE164 = phone,
name = name,
superAdmin = makeSuperAdmin
)
)
logger.warn("Auth verify auto-created user uid={}, userId={}", decoded.uid, created.id)
created
}
logger.warn("Auth verify resolved uid={}, userId={}", decoded.uid, user.id)
return ResolveResult(
MyPrincipal(
userId = user.id ?: throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "User id missing"),
firebaseUid = decoded.uid
)
)
}
private fun ResolveResult.toResponseEntity(): ResponseEntity<AuthResponse> { private fun ResolveResult.toResponseEntity(): ResponseEntity<AuthResponse> {
return if (principal == null) { return if (principal == null) {
ResponseEntity.status(HttpStatus.UNAUTHORIZED) ResponseEntity.status(HttpStatus.UNAUTHORIZED)

View File

@@ -232,23 +232,12 @@ class BookingFlow(
} }
private fun requireActor(propertyId: UUID, principal: MyPrincipal?): com.android.trisolarisserver.models.property.AppUser { private fun requireActor(propertyId: UUID, principal: MyPrincipal?): com.android.trisolarisserver.models.property.AppUser {
requirePrincipal(principal) val resolved = requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF)
propertyAccess.requireMember(propertyId, principal!!.userId) return appUserRepo.findById(resolved.userId).orElseThrow {
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER, Role.STAFF)
return appUserRepo.findById(principal.userId).orElseThrow {
ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found") ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
} }
} }
private fun parseOffset(value: String?): OffsetDateTime? {
if (value.isNullOrBlank()) return null
return try {
OffsetDateTime.parse(value.trim())
} catch (_: Exception) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid timestamp")
}
}
private fun parseTransportMode(value: String): TransportMode { private fun parseTransportMode(value: String): TransportMode {
return try { return try {
TransportMode.valueOf(value) TransportMode.valueOf(value)
@@ -269,9 +258,4 @@ class BookingFlow(
return allowed.contains(mode) return allowed.contains(mode)
} }
private fun requirePrincipal(principal: MyPrincipal?) {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
}
} }

View File

@@ -0,0 +1,63 @@
package com.android.trisolarisserver.controller
import java.time.OffsetDateTime
internal fun encodeBlock(value: String?): String {
val raw = (value ?: "").padEnd(16).take(16)
val bytes = raw.toByteArray(Charsets.UTF_8)
val sb = StringBuilder(bytes.size * 2)
for (b in bytes) {
sb.append(String.format("%02X", b))
}
return sb.toString()
}
internal fun buildSector0Block2(roomNumber: Int, cardID: Int): String {
val guestID = cardID + 1
val cardIdStr = cardID.toString().padStart(6, '0')
val guestIdStr = guestID.toString().padStart(6, '0')
val finalRoom = roomNumber.toString().padStart(2, '0')
return "472F${cardIdStr}2F${guestIdStr}00010000${finalRoom}0000"
}
internal fun formatDateComponents(time: OffsetDateTime): String {
val minute = time.minute.toString().padStart(2, '0')
val hour = time.hour.toString().padStart(2, '0')
val day = time.dayOfMonth.toString().padStart(2, '0')
val month = time.monthValue.toString().padStart(2, '0')
val year = time.year.toString().takeLast(2)
return "${minute}${hour}${day}${month}${year}"
}
internal fun calculateChecksum(dataHex: String): String {
val data = hexStringToByteArray(dataHex)
var checksum = 0
for (byte in data) {
checksum = calculateByteChecksum(byte, checksum)
}
return String.format("%02X", checksum)
}
private fun calculateByteChecksum(byte: Byte, currentChecksum: Int): Int {
var checksum = currentChecksum
var b = byte.toInt()
for (i in 0 until 8) {
checksum = if ((checksum xor b) and 1 != 0) {
(checksum xor 0x18) shr 1 or 0x80
} else {
checksum shr 1
}
b = b shr 1
}
return checksum
}
private fun hexStringToByteArray(hexString: String): ByteArray {
val len = hexString.length
val data = ByteArray(len / 2)
for (i in 0 until len step 2) {
data[i / 2] = ((Character.digit(hexString[i], 16) shl 4)
+ Character.digit(hexString[i + 1], 16)).toByte()
}
return data
}

View File

@@ -0,0 +1,42 @@
package com.android.trisolarisserver.controller
import com.android.trisolarisserver.component.PropertyAccess
import com.android.trisolarisserver.models.property.AppUser
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.repo.AppUserRepo
import com.android.trisolarisserver.security.MyPrincipal
import org.springframework.http.HttpStatus
import org.springframework.web.server.ResponseStatusException
import java.util.UUID
internal fun requirePrincipal(principal: MyPrincipal?): MyPrincipal {
return principal ?: throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
internal fun requireUser(appUserRepo: AppUserRepo, principal: MyPrincipal?): AppUser {
val resolved = requirePrincipal(principal)
return appUserRepo.findById(resolved.userId).orElseThrow {
ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
}
}
internal fun requireMember(
propertyAccess: PropertyAccess,
propertyId: UUID,
principal: MyPrincipal?
): MyPrincipal {
val resolved = requirePrincipal(principal)
propertyAccess.requireMember(propertyId, resolved.userId)
return resolved
}
internal fun requireRole(
propertyAccess: PropertyAccess,
propertyId: UUID,
principal: MyPrincipal?,
vararg roles: Role
): MyPrincipal {
val resolved = requireMember(propertyAccess, propertyId, principal)
propertyAccess.requireAnyRole(propertyId, resolved.userId, *roles)
return resolved
}

View File

@@ -0,0 +1,85 @@
package com.android.trisolarisserver.controller
import com.android.trisolarisserver.db.repo.GuestRepo
import com.android.trisolarisserver.models.booking.Guest
import com.android.trisolarisserver.models.property.Property
import com.android.trisolarisserver.models.room.RoomStay
import com.android.trisolarisserver.repo.PropertyRepo
import com.android.trisolarisserver.repo.RoomStayRepo
import org.springframework.http.HttpStatus
import org.springframework.web.server.ResponseStatusException
import java.time.OffsetDateTime
import java.time.ZoneId
import java.util.UUID
internal data class PropertyGuest(
val property: Property,
val guest: Guest
)
internal fun requireProperty(propertyRepo: PropertyRepo, propertyId: UUID): Property {
return propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
}
internal fun requirePropertyGuest(
propertyRepo: PropertyRepo,
guestRepo: GuestRepo,
propertyId: UUID,
guestId: UUID
): PropertyGuest {
val property = requireProperty(propertyRepo, propertyId)
val guest = guestRepo.findById(guestId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Guest not found")
}
if (guest.property.id != property.id) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Guest not in property")
}
return PropertyGuest(property, guest)
}
internal fun requireRoomStayForProperty(
roomStayRepo: RoomStayRepo,
propertyId: UUID,
roomStayId: UUID
): RoomStay {
val stay = roomStayRepo.findById(roomStayId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found")
}
if (stay.property.id != propertyId) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found for property")
}
return stay
}
internal fun requireOpenRoomStayForProperty(
roomStayRepo: RoomStayRepo,
propertyId: UUID,
roomStayId: UUID,
closedMessage: String
): RoomStay {
val stay = requireRoomStayForProperty(roomStayRepo, propertyId, roomStayId)
if (stay.toAt != null) {
throw ResponseStatusException(HttpStatus.CONFLICT, closedMessage)
}
return stay
}
internal fun parseOffset(value: String?): OffsetDateTime? {
if (value.isNullOrBlank()) return null
return try {
OffsetDateTime.parse(value.trim())
} catch (_: Exception) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid timestamp")
}
}
internal fun nowForProperty(timezone: String?): OffsetDateTime {
val zone = try {
if (timezone.isNullOrBlank()) ZoneId.of("Asia/Kolkata") else ZoneId.of(timezone)
} catch (_: Exception) {
ZoneId.of("Asia/Kolkata")
}
return OffsetDateTime.now(zone)
}

View File

@@ -53,7 +53,7 @@ class GuestDocuments(
@RequestParam("bookingId") bookingId: UUID, @RequestParam("bookingId") bookingId: UUID,
@RequestPart("file") file: MultipartFile @RequestPart("file") file: MultipartFile
): GuestDocumentResponse { ): GuestDocumentResponse {
val user = requireUser(principal) val user = requireUser(appUserRepo, principal)
propertyAccess.requireMember(propertyId, user.id!!) propertyAccess.requireMember(propertyId, user.id!!)
propertyAccess.requireAnyRole(propertyId, user.id!!, Role.ADMIN, Role.MANAGER) propertyAccess.requireAnyRole(propertyId, user.id!!, Role.ADMIN, Role.MANAGER)
@@ -65,15 +65,7 @@ class GuestDocuments(
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Video files are not allowed") throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Video files are not allowed")
} }
val property = propertyRepo.findById(propertyId).orElseThrow { val (property, guest) = requirePropertyGuest(propertyRepo, guestRepo, propertyId, guestId)
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
val guest = guestRepo.findById(guestId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Guest not found")
}
if (guest.property.id != property.id) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Guest not in property")
}
val booking = bookingRepo.findById(bookingId).orElseThrow { val booking = bookingRepo.findById(bookingId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found") ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found")
} }
@@ -106,9 +98,7 @@ class GuestDocuments(
@PathVariable guestId: UUID, @PathVariable guestId: UUID,
@AuthenticationPrincipal principal: MyPrincipal? @AuthenticationPrincipal principal: MyPrincipal?
): List<GuestDocumentResponse> { ): List<GuestDocumentResponse> {
requirePrincipal(principal) requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER)
propertyAccess.requireMember(propertyId, principal!!.userId)
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER)
return guestDocumentRepo return guestDocumentRepo
.findByPropertyIdAndGuestIdOrderByUploadedAtDesc(propertyId, guestId) .findByPropertyIdAndGuestIdOrderByUploadedAtDesc(propertyId, guestId)
@@ -124,9 +114,7 @@ class GuestDocuments(
@AuthenticationPrincipal principal: MyPrincipal? @AuthenticationPrincipal principal: MyPrincipal?
): ResponseEntity<FileSystemResource> { ): ResponseEntity<FileSystemResource> {
if (token == null) { if (token == null) {
requirePrincipal(principal) requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER)
propertyAccess.requireMember(propertyId, principal!!.userId)
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER)
} else if (!tokenService.validateToken(token, documentId.toString())) { } else if (!tokenService.validateToken(token, documentId.toString())) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid token") throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid token")
} }
@@ -207,20 +195,6 @@ class GuestDocuments(
} }
} }
private fun requirePrincipal(principal: MyPrincipal?) {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
}
private fun requireUser(principal: MyPrincipal?): com.android.trisolarisserver.models.property.AppUser {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
return appUserRepo.findById(principal.userId).orElseThrow {
ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
}
}
} }
data class GuestDocumentResponse( data class GuestDocumentResponse(

View File

@@ -42,18 +42,9 @@ class GuestRatings(
@AuthenticationPrincipal principal: MyPrincipal?, @AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: GuestRatingCreateRequest @RequestBody request: GuestRatingCreateRequest
): GuestRatingResponse { ): GuestRatingResponse {
requirePrincipal(principal) val resolved = requireMember(propertyAccess, propertyId, principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
val property = propertyRepo.findById(propertyId).orElseThrow { val (property, guest) = requirePropertyGuest(propertyRepo, guestRepo, propertyId, guestId)
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
val guest = guestRepo.findById(guestId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Guest not found")
}
if (guest.property.id != property.id) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Guest not in property")
}
val booking = bookingRepo.findById(request.bookingId).orElseThrow { val booking = bookingRepo.findById(request.bookingId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found") ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found")
@@ -75,7 +66,7 @@ class GuestRatings(
booking = booking, booking = booking,
score = score, score = score,
notes = request.notes?.trim(), notes = request.notes?.trim(),
createdBy = appUserRepo.findById(principal.userId).orElse(null) createdBy = appUserRepo.findById(resolved.userId).orElse(null)
) )
guestRatingRepo.save(rating) guestRatingRepo.save(rating)
return rating.toResponse() return rating.toResponse()
@@ -87,18 +78,9 @@ class GuestRatings(
@PathVariable guestId: UUID, @PathVariable guestId: UUID,
@AuthenticationPrincipal principal: MyPrincipal? @AuthenticationPrincipal principal: MyPrincipal?
): List<GuestRatingResponse> { ): List<GuestRatingResponse> {
requirePrincipal(principal) requireMember(propertyAccess, propertyId, principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
val property = propertyRepo.findById(propertyId).orElseThrow { val (_, guest) = requirePropertyGuest(propertyRepo, guestRepo, propertyId, guestId)
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
val guest = guestRepo.findById(guestId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Guest not found")
}
if (guest.property.id != property.id) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Guest not in property")
}
return guestRatingRepo.findByGuestIdOrderByCreatedAtDesc(guestId).map { it.toResponse() } return guestRatingRepo.findByGuestIdOrderByCreatedAtDesc(guestId).map { it.toResponse() }
} }
@@ -126,9 +108,4 @@ class GuestRatings(
) )
} }
private fun requirePrincipal(principal: MyPrincipal?) {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
}
} }

View File

@@ -33,16 +33,13 @@ class Guests(
@RequestParam(required = false) phone: String?, @RequestParam(required = false) phone: String?,
@RequestParam(required = false) vehicleNumber: String? @RequestParam(required = false) vehicleNumber: String?
): List<GuestResponse> { ): List<GuestResponse> {
requirePrincipal(principal) requireMember(propertyAccess, propertyId, principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
if (phone.isNullOrBlank() && vehicleNumber.isNullOrBlank()) { if (phone.isNullOrBlank() && vehicleNumber.isNullOrBlank()) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "phone or vehicleNumber required") throw ResponseStatusException(HttpStatus.BAD_REQUEST, "phone or vehicleNumber required")
} }
val property = propertyRepo.findById(propertyId).orElseThrow { requireProperty(propertyRepo, propertyId)
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
val guests = mutableSetOf<Guest>() val guests = mutableSetOf<Guest>()
if (!phone.isNullOrBlank()) { if (!phone.isNullOrBlank()) {
@@ -64,18 +61,9 @@ class Guests(
@AuthenticationPrincipal principal: MyPrincipal?, @AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: GuestVehicleRequest @RequestBody request: GuestVehicleRequest
): GuestResponse { ): GuestResponse {
requirePrincipal(principal) requireMember(propertyAccess, propertyId, principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
val property = propertyRepo.findById(propertyId).orElseThrow { val (property, guest) = requirePropertyGuest(propertyRepo, guestRepo, propertyId, guestId)
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
val guest = guestRepo.findById(guestId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Guest not found")
}
if (guest.property.id != property.id) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Guest not in property")
}
if (guestVehicleRepo.existsByPropertyIdAndVehicleNumberIgnoreCase(property.id!!, request.vehicleNumber)) { if (guestVehicleRepo.existsByPropertyIdAndVehicleNumberIgnoreCase(property.id!!, request.vehicleNumber)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Vehicle number already exists") throw ResponseStatusException(HttpStatus.CONFLICT, "Vehicle number already exists")
} }
@@ -89,11 +77,6 @@ class Guests(
return setOf(guest).toResponse(guestVehicleRepo, guestRatingRepo).first() return setOf(guest).toResponse(guestVehicleRepo, guestRatingRepo).first()
} }
private fun requirePrincipal(principal: MyPrincipal?) {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
}
} }
private fun Set<Guest>.toResponse( private fun Set<Guest>.toResponse(

View File

@@ -0,0 +1,19 @@
package com.android.trisolarisserver.controller
import com.android.trisolarisserver.controller.dto.IssuedCardResponse
import com.android.trisolarisserver.models.room.IssuedCard
internal fun IssuedCard.toResponse(): IssuedCardResponse {
return IssuedCardResponse(
id = id!!,
propertyId = property.id!!,
roomId = room.id!!,
roomStayId = roomStay?.id,
cardId = cardId,
cardIndex = cardIndex,
issuedAt = issuedAt.toString(),
expiresAt = expiresAt.toString(),
issuedByUserId = issuedBy?.id,
revokedAt = revokedAt?.toString()
)
}

View File

@@ -26,7 +26,6 @@ import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException import org.springframework.web.server.ResponseStatusException
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.ZoneId
import java.util.UUID import java.util.UUID
@RestController @RestController
@@ -50,15 +49,7 @@ class IssuedCards(
@RequestBody request: CardPrepareRequest @RequestBody request: CardPrepareRequest
): CardPrepareResponse { ): CardPrepareResponse {
val actor = requireIssueActor(propertyId, principal) val actor = requireIssueActor(propertyId, principal)
val stay = roomStayRepo.findById(roomStayId).orElseThrow { val stay = requireOpenRoomStayForProperty(roomStayRepo, propertyId, roomStayId, "Room stay closed")
ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found")
}
if (stay.property.id != propertyId) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found for property")
}
if (stay.toAt != null) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Room stay closed")
}
val issuedAt = nowForProperty(stay.property.timezone) val issuedAt = nowForProperty(stay.property.timezone)
val expiresAt = request.expiresAt?.let { parseOffset(it) } ?: stay.toAt val expiresAt = request.expiresAt?.let { parseOffset(it) } ?: stay.toAt
@@ -97,15 +88,7 @@ class IssuedCards(
if (request.cardIndex <= 0) { if (request.cardIndex <= 0) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "cardIndex required") throw ResponseStatusException(HttpStatus.BAD_REQUEST, "cardIndex required")
} }
val stay = roomStayRepo.findById(roomStayId).orElseThrow { val stay = requireOpenRoomStayForProperty(roomStayRepo, propertyId, roomStayId, "Room stay closed")
ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found")
}
if (stay.property.id != propertyId) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found for property")
}
if (stay.toAt != null) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Room stay closed")
}
val issuedAt = parseOffset(request.issuedAt) ?: nowForProperty(stay.property.timezone) val issuedAt = parseOffset(request.issuedAt) ?: nowForProperty(stay.property.timezone)
val expiresAt = parseOffset(request.expiresAt) val expiresAt = parseOffset(request.expiresAt)
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "expiresAt required") ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "expiresAt required")
@@ -140,12 +123,7 @@ class IssuedCards(
@AuthenticationPrincipal principal: MyPrincipal? @AuthenticationPrincipal principal: MyPrincipal?
): List<IssuedCardResponse> { ): List<IssuedCardResponse> {
requireViewActor(propertyId, principal) requireViewActor(propertyId, principal)
val stay = roomStayRepo.findById(roomStayId).orElseThrow { val stay = requireRoomStayForProperty(roomStayRepo, propertyId, roomStayId)
ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found")
}
if (stay.property.id != propertyId) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found for property")
}
return issuedCardRepo.findByRoomStayIdOrderByIssuedAtDesc(roomStayId) return issuedCardRepo.findByRoomStayIdOrderByIssuedAtDesc(roomStayId)
.map { it.toResponse() } .map { it.toResponse() }
} }
@@ -183,34 +161,6 @@ class IssuedCards(
return card.toResponse() return card.toResponse()
} }
private fun parseOffset(value: String?): OffsetDateTime? {
if (value.isNullOrBlank()) return null
return try {
OffsetDateTime.parse(value.trim())
} catch (_: Exception) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid timestamp")
}
}
private fun nowForProperty(timezone: String?): OffsetDateTime {
val zone = try {
if (timezone.isNullOrBlank()) ZoneId.of("Asia/Kolkata") else ZoneId.of(timezone)
} catch (_: Exception) {
ZoneId.of("Asia/Kolkata")
}
return OffsetDateTime.now(zone)
}
private fun encodeBlock(value: String?): String {
val raw = (value ?: "").padEnd(16).take(16)
val bytes = raw.toByteArray(Charsets.UTF_8)
val sb = StringBuilder(bytes.size * 2)
for (b in bytes) {
sb.append(String.format("%02X", b))
}
return sb.toString()
}
private fun requireMember(propertyId: UUID, principal: MyPrincipal?) { private fun requireMember(propertyId: UUID, principal: MyPrincipal?) {
if (principal == null) { if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal") throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
@@ -303,73 +253,9 @@ class IssuedCards(
val checkSum = calculateChecksum((key ?: "") + newData) val checkSum = calculateChecksum((key ?: "") + newData)
return newData + checkSum return newData + checkSum
} }
private fun buildSector0Block2(roomNumber: Int, cardID: Int): String {
val guestID = cardID + 1
val cardIdStr = cardID.toString().padStart(6, '0')
val guestIdStr = guestID.toString().padStart(6, '0')
val finalRoom = roomNumber.toString().padStart(2, '0')
return "472F${cardIdStr}2F${guestIdStr}00010000${finalRoom}0000"
}
private fun formatDateComponents(time: OffsetDateTime): String {
val minute = time.minute.toString().padStart(2, '0')
val hour = time.hour.toString().padStart(2, '0')
val day = time.dayOfMonth.toString().padStart(2, '0')
val month = time.monthValue.toString().padStart(2, '0')
val year = time.year.toString().takeLast(2)
return "${minute}${hour}${day}${month}${year}"
}
private fun calculateChecksum(dataHex: String): String {
val data = hexStringToByteArray(dataHex)
var checksum = 0
for (byte in data) {
checksum = calculateByteChecksum(byte, checksum)
}
return String.format("%02X", checksum)
}
private fun calculateByteChecksum(byte: Byte, currentChecksum: Int): Int {
var checksum = currentChecksum
var b = byte.toInt()
for (i in 0 until 8) {
checksum = if ((checksum xor b) and 1 != 0) {
(checksum xor 0x18) shr 1 or 0x80
} else {
checksum shr 1
}
b = b shr 1
}
return checksum
}
private fun hexStringToByteArray(hexString: String): ByteArray {
val len = hexString.length
val data = ByteArray(len / 2)
for (i in 0 until len step 2) {
data[i / 2] = ((Character.digit(hexString[i], 16) shl 4)
+ Character.digit(hexString[i + 1], 16)).toByte()
}
return data
}
} }
private data class Sector0Payload( private data class Sector0Payload(
val key: String, val key: String,
val timeData: String val timeData: String
) )
private fun IssuedCard.toResponse(): IssuedCardResponse {
return IssuedCardResponse(
id = id!!,
propertyId = property.id!!,
roomId = room.id!!,
roomStayId = roomStay?.id,
cardId = cardId,
cardIndex = cardIndex,
issuedAt = issuedAt.toString(),
expiresAt = expiresAt.toString(),
issuedByUserId = issuedBy?.id,
revokedAt = revokedAt?.toString()
)
}

View File

@@ -86,10 +86,7 @@ class RoomImages(
@RequestParam("file") file: MultipartFile, @RequestParam("file") file: MultipartFile,
@RequestParam(required = false) tagIds: List<UUID>? @RequestParam(required = false) tagIds: List<UUID>?
): RoomImageResponse { ): RoomImageResponse {
requirePrincipal(principal) val room = requireRoomAdmin(propertyId, roomId, principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER)
val room = ensureRoom(propertyId, roomId)
if (file.isEmpty) { if (file.isEmpty) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "File is empty") throw ResponseStatusException(HttpStatus.BAD_REQUEST, "File is empty")
@@ -132,10 +129,7 @@ class RoomImages(
@PathVariable imageId: UUID, @PathVariable imageId: UUID,
@AuthenticationPrincipal principal: MyPrincipal? @AuthenticationPrincipal principal: MyPrincipal?
) { ) {
requirePrincipal(principal) requireRoomAdmin(propertyId, roomId, principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER)
ensureRoom(propertyId, roomId)
val image = roomImageRepo.findByIdAndRoomIdAndPropertyId(imageId, roomId, propertyId) val image = roomImageRepo.findByIdAndRoomIdAndPropertyId(imageId, roomId, propertyId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Image not found") ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Image not found")
@@ -187,10 +181,7 @@ class RoomImages(
@AuthenticationPrincipal principal: MyPrincipal?, @AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: RoomImageTagUpdateRequest @RequestBody request: RoomImageTagUpdateRequest
) { ) {
requirePrincipal(principal) requireRoomAdmin(propertyId, roomId, principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER)
ensureRoom(propertyId, roomId)
val image = roomImageRepo.findByIdAndRoomIdAndPropertyId(imageId, roomId, propertyId) val image = roomImageRepo.findByIdAndRoomIdAndPropertyId(imageId, roomId, propertyId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Image not found") ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Image not found")
@@ -208,10 +199,7 @@ class RoomImages(
@AuthenticationPrincipal principal: MyPrincipal?, @AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: RoomImageReorderRequest @RequestBody request: RoomImageReorderRequest
) { ) {
requirePrincipal(principal) requireRoomAdmin(propertyId, roomId, principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER)
ensureRoom(propertyId, roomId)
if (request.imageIds.isEmpty()) { if (request.imageIds.isEmpty()) {
return return
@@ -239,10 +227,7 @@ class RoomImages(
@AuthenticationPrincipal principal: MyPrincipal?, @AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: RoomImageReorderRequest @RequestBody request: RoomImageReorderRequest
) { ) {
requirePrincipal(principal) val room = requireRoomAdmin(propertyId, roomId, principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER)
val room = ensureRoom(propertyId, roomId)
if (request.imageIds.isEmpty()) { if (request.imageIds.isEmpty()) {
return return
@@ -299,10 +284,9 @@ class RoomImages(
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room not found") ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room not found")
} }
private fun requirePrincipal(principal: MyPrincipal?) { private fun requireRoomAdmin(propertyId: UUID, roomId: UUID, principal: MyPrincipal?): com.android.trisolarisserver.models.room.Room {
if (principal == null) { requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER)
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal") return ensureRoom(propertyId, roomId)
}
} }
private fun resolveTags(tagIds: List<UUID>?): Set<RoomImageTag> { private fun resolveTags(tagIds: List<UUID>?): Set<RoomImageTag> {

View File

@@ -47,15 +47,12 @@ class RoomStayFlow(
): RoomChangeResponse { ): RoomChangeResponse {
val actor = requireActor(propertyId, principal) val actor = requireActor(propertyId, principal)
val stay = roomStayRepo.findById(roomStayId).orElseThrow { val stay = requireOpenRoomStayForProperty(
ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found") roomStayRepo,
} propertyId,
if (stay.property.id != propertyId) { roomStayId,
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found for property") "Room stay already closed"
} )
if (stay.toAt != null) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Room stay already closed")
}
if (request.idempotencyKey.isBlank()) { if (request.idempotencyKey.isBlank()) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "idempotencyKey required") throw ResponseStatusException(HttpStatus.BAD_REQUEST, "idempotencyKey required")
} }
@@ -114,22 +111,9 @@ class RoomStayFlow(
) )
} }
private fun parseOffset(value: String?): OffsetDateTime? {
if (value.isNullOrBlank()) return null
return try {
OffsetDateTime.parse(value.trim())
} catch (_: Exception) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid timestamp")
}
}
private fun requireActor(propertyId: UUID, principal: MyPrincipal?): com.android.trisolarisserver.models.property.AppUser { private fun requireActor(propertyId: UUID, principal: MyPrincipal?): com.android.trisolarisserver.models.property.AppUser {
if (principal == null) { val resolved = requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF)
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal") return appUserRepo.findById(resolved.userId).orElseThrow {
}
propertyAccess.requireMember(propertyId, principal.userId)
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER, Role.STAFF)
return appUserRepo.findById(principal.userId).orElseThrow {
ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found") ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
} }
} }

View File

@@ -23,7 +23,6 @@ import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException import org.springframework.web.server.ResponseStatusException
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.ZoneId
import java.util.UUID import java.util.UUID
@RestController @RestController
@@ -105,34 +104,6 @@ class TemporaryRoomCards(
return issuedCardRepo.save(card).toResponse() return issuedCardRepo.save(card).toResponse()
} }
private fun parseOffset(value: String?): OffsetDateTime? {
if (value.isNullOrBlank()) return null
return try {
OffsetDateTime.parse(value.trim())
} catch (_: Exception) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid timestamp")
}
}
private fun encodeBlock(value: String?): String {
val raw = (value ?: "").padEnd(16).take(16)
val bytes = raw.toByteArray(Charsets.UTF_8)
val sb = StringBuilder(bytes.size * 2)
for (b in bytes) {
sb.append(String.format("%02X", b))
}
return sb.toString()
}
private fun nowForProperty(timezone: String?): OffsetDateTime {
val zone = try {
if (timezone.isNullOrBlank()) ZoneId.of("Asia/Kolkata") else ZoneId.of(timezone)
} catch (_: Exception) {
ZoneId.of("Asia/Kolkata")
}
return OffsetDateTime.now(zone)
}
private fun requireIssueActor(propertyId: UUID, principal: MyPrincipal?): com.android.trisolarisserver.models.property.AppUser { private fun requireIssueActor(propertyId: UUID, principal: MyPrincipal?): com.android.trisolarisserver.models.property.AppUser {
if (principal == null) { if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal") throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
@@ -175,74 +146,9 @@ class TemporaryRoomCards(
val finalData = newData + checkSum val finalData = newData + checkSum
return TempSector0Payload(key, finalData) return TempSector0Payload(key, finalData)
} }
private fun buildSector0Block2(roomNumber: Int, cardID: Int): String {
val guestID = cardID + 1
val cardIdStr = cardID.toString().padStart(6, '0')
val guestIdStr = guestID.toString().padStart(6, '0')
val finalRoom = roomNumber.toString().padStart(2, '0')
return "472F${cardIdStr}2F${guestIdStr}00010000${finalRoom}0000"
}
private fun formatDateComponents(time: OffsetDateTime): String {
val minute = time.minute.toString().padStart(2, '0')
val hour = time.hour.toString().padStart(2, '0')
val day = time.dayOfMonth.toString().padStart(2, '0')
val month = time.monthValue.toString().padStart(2, '0')
val year = time.year.toString().takeLast(2)
return "${minute}${hour}${day}${month}${year}"
}
private fun calculateChecksum(dataHex: String): String {
val data = hexStringToByteArray(dataHex)
var checksum = 0
for (byte in data) {
checksum = calculateByteChecksum(byte, checksum)
}
return String.format("%02X", checksum)
}
private fun calculateByteChecksum(byte: Byte, currentChecksum: Int): Int {
var checksum = currentChecksum
var b = byte.toInt()
for (i in 0 until 8) {
checksum = if ((checksum xor b) and 1 != 0) {
(checksum xor 0x18) shr 1 or 0x80
} else {
checksum shr 1
}
b = b shr 1
}
return checksum
}
private fun hexStringToByteArray(hexString: String): ByteArray {
val len = hexString.length
val data = ByteArray(len / 2)
for (i in 0 until len step 2) {
data[i / 2] = ((Character.digit(hexString[i], 16) shl 4)
+ Character.digit(hexString[i + 1], 16)).toByte()
}
return data
}
} }
private data class TempSector0Payload( private data class TempSector0Payload(
val key: String, val key: String,
val timeData: String val timeData: String
) )
private fun IssuedCard.toResponse(): IssuedCardResponse {
return IssuedCardResponse(
id = id!!,
propertyId = property.id!!,
roomId = room.id!!,
roomStayId = roomStay?.id,
cardId = cardId,
cardIndex = cardIndex,
issuedAt = issuedAt.toString(),
expiresAt = expiresAt.toString(),
issuedByUserId = issuedBy?.id,
revokedAt = revokedAt?.toString()
)
}

View File

@@ -0,0 +1,69 @@
package com.android.trisolarisserver.security
import com.android.trisolarisserver.models.property.AppUser
import com.android.trisolarisserver.repo.AppUserRepo
import com.google.firebase.auth.FirebaseAuth
import jakarta.servlet.http.HttpServletRequest
import org.slf4j.LoggerFactory
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component
import org.springframework.web.server.ResponseStatusException
@Component
class AuthResolver(
private val appUserRepo: AppUserRepo
) {
private val logger = LoggerFactory.getLogger(AuthResolver::class.java)
fun resolveFromRequest(request: HttpServletRequest, createIfMissing: Boolean): MyPrincipal {
val header = request.getHeader(HttpHeaders.AUTHORIZATION)
return resolveFromHeader(header, createIfMissing)
}
fun resolveFromHeader(header: String?, createIfMissing: Boolean): MyPrincipal {
if (header.isNullOrBlank()) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing Authorization token")
}
if (!header.startsWith("Bearer ")) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid Authorization header")
}
val token = header.removePrefix("Bearer ").trim()
return resolveFromToken(token, createIfMissing)
}
fun resolveFromToken(token: String, createIfMissing: Boolean): MyPrincipal {
val decoded = try {
FirebaseAuth.getInstance().verifyIdToken(token)
} catch (ex: Exception) {
logger.warn("Auth verify failed: {}", ex.message)
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid token")
}
val user = resolveUser(decoded.uid, decoded.claims, createIfMissing)
return MyPrincipal(
userId = user.id ?: throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "User id missing"),
firebaseUid = decoded.uid
)
}
private fun resolveUser(firebaseUid: String, claims: Map<String, Any>, createIfMissing: Boolean): AppUser {
val existing = appUserRepo.findByFirebaseUid(firebaseUid)
if (existing != null) return existing
if (!createIfMissing) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
}
val phone = claims["phone_number"] as? String
val name = claims["name"] as? String
val makeSuperAdmin = appUserRepo.count() == 0L
val created = appUserRepo.save(
AppUser(
firebaseUid = firebaseUid,
phoneE164 = phone,
name = name,
superAdmin = makeSuperAdmin
)
)
logger.warn("Auth verify auto-created user uid={}, userId={}", firebaseUid, created.id)
return created
}
}

View File

@@ -1,7 +1,6 @@
package com.android.trisolarisserver.security package com.android.trisolarisserver.security
import com.android.trisolarisserver.repo.AppUserRepo import com.android.trisolarisserver.repo.AppUserRepo
import com.google.firebase.auth.FirebaseAuth
import jakarta.servlet.FilterChain import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse import jakarta.servlet.http.HttpServletResponse
@@ -11,29 +10,17 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticatio
import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import org.springframework.web.filter.OncePerRequestFilter import org.springframework.web.filter.OncePerRequestFilter
import org.springframework.web.server.ResponseStatusException
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
@Component @Component
class FirebaseAuthFilter( class FirebaseAuthFilter(
private val appUserRepo: AppUserRepo private val appUserRepo: AppUserRepo,
private val authResolver: AuthResolver
) : OncePerRequestFilter() { ) : OncePerRequestFilter() {
private val logger = LoggerFactory.getLogger(FirebaseAuthFilter::class.java) private val logger = LoggerFactory.getLogger(FirebaseAuthFilter::class.java)
override fun shouldNotFilter(request: HttpServletRequest): Boolean { override fun shouldNotFilter(request: HttpServletRequest): Boolean {
val path = request.requestURI return PublicEndpoints.isPublic(request)
if (path == "/" || path == "/health" || path.startsWith("/auth/")) {
return true
}
return path.matches(Regex("^/properties/[^/]+/rooms/[^/]+/images/[^/]+/file$"))
|| (path.matches(Regex("^/properties/[^/]+/rooms/[^/]+/images$")) && request.method.equals("GET", true))
|| (path.matches(Regex("^/properties/[^/]+/rooms/available$")) && request.method.equals("GET", true))
|| (path.matches(Regex("^/properties/[^/]+/rooms/by-type/[^/]+$")) && request.method.equals("GET", true))
|| (path.matches(Regex("^/properties/[^/]+/room-types$")) && request.method.equals("GET", true))
|| path.matches(Regex("^/properties/[^/]+/room-types/[^/]+/images$"))
|| (path == "/image-tags" && request.method.equals("GET", true))
|| path == "/icons/png"
|| path.matches(Regex("^/icons/png/[^/]+$"))
} }
override fun doFilterInternal( override fun doFilterInternal(
@@ -49,16 +36,9 @@ class FirebaseAuthFilter(
} }
val token = header.removePrefix("Bearer ").trim() val token = header.removePrefix("Bearer ").trim()
try { try {
val decoded = FirebaseAuth.getInstance().verifyIdToken(token) val principal = authResolver.resolveFromToken(token, createIfMissing = false)
val firebaseUid = decoded.uid val user = appUserRepo.findById(principal.userId).orElse(null)
val user = appUserRepo.findByFirebaseUid(firebaseUid) logger.debug("Auth verified uid={}, userId={}", principal.firebaseUid, user?.id)
?: throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
logger.debug("Auth verified uid={}, userId={}", firebaseUid, user.id)
val principal = MyPrincipal(
userId = user.id ?: throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "User id missing"),
firebaseUid = firebaseUid
)
val auth = UsernamePasswordAuthenticationToken(principal, token, emptyList()) val auth = UsernamePasswordAuthenticationToken(principal, token, emptyList())
SecurityContextHolder.getContext().authentication = auth SecurityContextHolder.getContext().authentication = auth
filterChain.doFilter(request, response) filterChain.doFilter(request, response)

View File

@@ -0,0 +1,30 @@
package com.android.trisolarisserver.security
import jakarta.servlet.http.HttpServletRequest
internal object PublicEndpoints {
private val roomImageFile = Regex("^/properties/[^/]+/rooms/[^/]+/images/[^/]+/file$")
private val roomImages = Regex("^/properties/[^/]+/rooms/[^/]+/images$")
private val roomsAvailable = Regex("^/properties/[^/]+/rooms/available$")
private val roomsByType = Regex("^/properties/[^/]+/rooms/by-type/[^/]+$")
private val roomTypes = Regex("^/properties/[^/]+/room-types$")
private val roomTypeImages = Regex("^/properties/[^/]+/room-types/[^/]+/images$")
private val iconPngFile = Regex("^/icons/png/[^/]+$")
fun isPublic(request: HttpServletRequest): Boolean {
val path = request.requestURI
if (path == "/" || path == "/health" || path.startsWith("/auth/")) {
return true
}
val method = request.method.uppercase()
return roomImageFile.matches(path)
|| (roomImages.matches(path) && method == "GET")
|| (roomsAvailable.matches(path) && method == "GET")
|| (roomsByType.matches(path) && method == "GET")
|| (roomTypes.matches(path) && method == "GET")
|| roomTypeImages.matches(path)
|| (path == "/image-tags" && method == "GET")
|| path == "/icons/png"
|| iconPngFile.matches(path)
}
}

View File

@@ -6,8 +6,8 @@ import org.springframework.security.config.annotation.method.configuration.Enabl
import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.http.SessionCreationPolicy import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.web.SecurityFilterChain import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.util.matcher.RequestMatcher
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
import org.springframework.security.web.authentication.HttpStatusEntryPoint
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
@@ -25,16 +25,7 @@ class SecurityConfig(
.csrf { it.disable() } .csrf { it.disable() }
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.authorizeHttpRequests { .authorizeHttpRequests {
it.requestMatchers("/", "/health", "/auth/**").permitAll() it.requestMatchers(RequestMatcher { request -> PublicEndpoints.isPublic(request) }).permitAll()
it.requestMatchers("/properties/*/rooms/*/images/*/file").permitAll()
it.requestMatchers(org.springframework.http.HttpMethod.GET, "/properties/*/rooms/*/images").permitAll()
it.requestMatchers(org.springframework.http.HttpMethod.GET, "/properties/*/rooms/available").permitAll()
it.requestMatchers(org.springframework.http.HttpMethod.GET, "/properties/*/rooms/by-type/*").permitAll()
it.requestMatchers(org.springframework.http.HttpMethod.GET, "/properties/*/room-types").permitAll()
it.requestMatchers("/properties/*/room-types/*/images").permitAll()
it.requestMatchers(org.springframework.http.HttpMethod.GET, "/image-tags").permitAll()
it.requestMatchers("/icons/png").permitAll()
it.requestMatchers("/icons/png/*").permitAll()
it.anyRequest().authenticated() it.anyRequest().authenticated()
} }
.exceptionHandling { .exceptionHandling {