Skip to content

Commit

Permalink
Migrate from Firebase: Implement uploading and viewing of image
Browse files Browse the repository at this point in the history
- Update user avatar
- Update club avatar
- Upload post images

Signed-off-by: Shashank Verma <[email protected]>
  • Loading branch information
shank03 committed Oct 11, 2023
1 parent 2821ab7 commit 77f9d58
Show file tree
Hide file tree
Showing 12 changed files with 278 additions and 24 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,4 @@ src/main/resources/*.sql
logs/
deploy.sh
sac.conf
images/
237 changes: 237 additions & 0 deletions src/main/kotlin/com/mnnit/moticlubs/controller/AvatarController.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
package com.mnnit.moticlubs.controller

import com.mnnit.moticlubs.dao.Club
import com.mnnit.moticlubs.dao.User
import com.mnnit.moticlubs.dto.response.ImageUrlDTO
import com.mnnit.moticlubs.service.ClubService
import com.mnnit.moticlubs.service.UserService
import com.mnnit.moticlubs.utils.BadRequestException
import com.mnnit.moticlubs.utils.Constants
import com.mnnit.moticlubs.utils.Constants.CLUB_AVATAR_PATH
import com.mnnit.moticlubs.utils.Constants.POST_AVATAR_PATH
import com.mnnit.moticlubs.utils.Constants.USER_AVATAR_PATH
import com.mnnit.moticlubs.utils.NotFoundException
import com.mnnit.moticlubs.utils.ResponseStamp
import com.mnnit.moticlubs.utils.ResponseStamp.invalidateStamp
import com.mnnit.moticlubs.utils.ServiceLogger
import com.mnnit.moticlubs.utils.invalidateStamp
import com.mnnit.moticlubs.utils.wrapError
import com.mnnit.moticlubs.web.security.PathAuthorization
import io.swagger.v3.oas.annotations.Hidden
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import org.springframework.core.io.UrlResource
import org.springframework.core.io.buffer.DataBuffer
import org.springframework.core.io.buffer.DataBufferUtils
import org.springframework.core.io.buffer.DefaultDataBufferFactory
import org.springframework.http.CacheControl
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.http.codec.multipart.FilePart
import org.springframework.http.server.reactive.ServerHttpRequest
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.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RequestPart
import org.springframework.web.bind.annotation.RestController
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import reactor.kotlin.core.publisher.switchIfEmpty
import reactor.util.function.Tuple2
import java.io.File
import java.nio.charset.StandardCharsets
import java.nio.file.Paths
import java.time.Duration
import java.util.*

@RestController
@RequestMapping("/${Constants.BASE_PATH}/${Constants.AVATAR_ROUTE}")
@Tag(name = "AvatarRoute")
class AvatarController(
private val pathAuthorization: PathAuthorization,
private val clubService: ClubService,
private val userService: UserService,
) {

private enum class ImageType {
USER, CLUB, POST
}

companion object {
private val LOGGER = ServiceLogger.getLogger(AvatarController::class.java)
}

// -------------------- GET AVATARS

@GetMapping("/g/user/{hash}", produces = ["image/webp"])
@Operation(summary = "Get avatar of user")
fun getUserAvatar(
@PathVariable hash: String,
): Mono<ResponseEntity<Flux<DataBuffer>>> = getImage(ImageType.USER, hash)

@GetMapping("/g/club/{hash}", produces = ["image/webp"])
@Operation(summary = "Get avatar of club")
fun getClubAvatar(
@PathVariable hash: String,
): Mono<ResponseEntity<Flux<DataBuffer>>> = getImage(ImageType.CLUB, hash)

@GetMapping("/g/post/{hash}", produces = ["image/webp"])
@Operation(summary = "Get image for post")
fun getPostImage(
@PathVariable hash: String,
): Mono<ResponseEntity<Flux<DataBuffer>>> = getImage(ImageType.POST, hash)

private fun getImage(imageType: ImageType, encodedId: String): Mono<ResponseEntity<Flux<DataBuffer>>> = Mono
.fromCallable {
val id = encodedId.decodeToId()
LOGGER.info("getImage: ${imageType.name} for id: $id")
UrlResource(
Paths
.get(
when (imageType) {
ImageType.USER -> USER_AVATAR_PATH
ImageType.CLUB -> CLUB_AVATAR_PATH
ImageType.POST -> POST_AVATAR_PATH
},
)
.resolve(id.getFileName())
.toUri(),
)
}
.flatMap { resource ->
if (resource.exists() && resource.isReadable) {
LOGGER.info("getImage: found")
Mono.just(DataBufferUtils.read(resource, DefaultDataBufferFactory(), 4096))
} else {
Mono.error(NotFoundException("Image for ${imageType.name} doesn't exists"))
}
}
.flatMap {
Mono.just(
ResponseEntity.ok()
.eTag(encodedId)
.cacheControl(CacheControl.maxAge(Duration.ZERO).cachePublic())
.body(it),
)
}
.wrapError()

// -------------------- UPDATE AVATARS

@PostMapping("/user", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
@Operation(summary = "Updates user avatar")
@Hidden
fun updateUserAvatar(
@RequestPart("file") filePart: Mono<FilePart>,
serverRequest: ServerHttpRequest,
): Mono<ResponseEntity<User>> = pathAuthorization
.userAuthorization()
.zipWith(filePart)
.saveImage(serverRequest, ImageType.USER) { uid, url -> userService.updateAvatar(uid, url) }
.invalidateStamp {
ResponseStamp.ADMIN.invalidateStamp()
ResponseStamp.USER.withKey("${it.uid}")
}
.wrapError()

@PostMapping("/club", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
@Operation(summary = "Updates club avatar")
@Hidden
fun updateClubAvatar(
@RequestPart("file") filePart: Mono<FilePart>,
@RequestParam clubId: Long,
serverRequest: ServerHttpRequest,
): Mono<ResponseEntity<Club>> = pathAuthorization
.clubAuthorization(clubId)
.map { clubId }
.zipWith(filePart)
.saveImage(serverRequest, ImageType.CLUB) { cid, url -> clubService.updateAvatar(cid, url) }
.invalidateStamp { ResponseStamp.CLUB }
.wrapError()

@PostMapping("/post", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
@Operation(summary = "Uploads image for posts")
@Hidden
fun addPostImage(
@RequestPart("file") filePart: Mono<FilePart>,
@RequestParam clubId: Long,
serverRequest: ServerHttpRequest,
): Mono<ResponseEntity<ImageUrlDTO>> = pathAuthorization
.clubAuthorization(clubId)
.map { System.currentTimeMillis() }
.zipWith(filePart)
.saveImage(serverRequest, ImageType.POST) { _, url -> Mono.just(ImageUrlDTO(url)) }
.invalidateStamp { ResponseStamp.NONE }
.wrapError()

private fun <T> Mono<Tuple2<Long, FilePart>>.saveImage(
serverRequest: ServerHttpRequest,
imageType: ImageType,
then: (Long, String) -> Mono<T>,
): Mono<T> = flatMap { tuple ->
val id = tuple.t1
val fp = tuple.t2

val validation = validateFile(serverRequest, fp)
if (validation != null) {
return@flatMap Mono.error(BadRequestException("Invalid file: $validation"))
}

val fileName = id.getFileName()
val rawUrl = "${serverRequest.uri.toURL()}"

val hashPrefix = id.encodeToUrl()
val (path, url) = when (imageType) {
ImageType.USER -> Pair(USER_AVATAR_PATH, rawUrl.replace("user.*".toRegex(), "g/user/$hashPrefix"))
ImageType.CLUB -> Pair(CLUB_AVATAR_PATH, rawUrl.replace("club.*".toRegex(), "g/club/$hashPrefix"))
ImageType.POST -> Pair(POST_AVATAR_PATH, rawUrl.replace("post.*".toRegex(), "g/post/$hashPrefix"))
}

val folder = File(path)
if (!folder.exists() && !folder.mkdirs()) {
return@flatMap Mono.error(BadRequestException("Unable to save file"))
}

LOGGER.info("saveImage: URL: $url")
fp.transferTo(Paths.get("$path/$fileName"))
.then(then(id, url))
}.switchIfEmpty { Mono.error(RuntimeException("Unable to save file")) }

private fun validateFile(serverRequest: ServerHttpRequest, filePart: FilePart): String? {
// If it's a file, it will have CONTENT_LENGTH header
val length = serverRequest.headers[HttpHeaders.CONTENT_LENGTH]?.first()?.toLong()
length ?: return "No length"

// File size <= 200KB
if (length > (200 * 1024).toLong()) {
return "File too large"
}

// File must be an image
val name = filePart.filename()
if (!name.endsWith(".png", true) &&
!name.endsWith(".jpg", true) &&
!name.endsWith(".jpeg", true) &&
!name.endsWith(".webp", true)
) {
return "Wrong format"
}
return null
}

private fun Long.getFileName(): String = "$this.webp"

private fun Long.encodeToUrl(): String = Base64
.getEncoder()
.encode("$this.${System.currentTimeMillis()}".toByteArray()).toString(StandardCharsets.UTF_8)

private fun String.decodeToId(): Long = Base64
.getDecoder()
.decode(this)
.toString(StandardCharsets.UTF_8)
.split(".")[0]
.toLongOrNull() ?: 0
}
20 changes: 2 additions & 18 deletions src/main/kotlin/com/mnnit/moticlubs/controller/UserController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package com.mnnit.moticlubs.controller
import com.mnnit.moticlubs.dao.FCM
import com.mnnit.moticlubs.dao.User
import com.mnnit.moticlubs.dto.request.FCMTokenDTO
import com.mnnit.moticlubs.dto.request.UpdateAvatarDTO
import com.mnnit.moticlubs.dto.request.UpdateContactDTO
import com.mnnit.moticlubs.dto.response.AdminUserDTO
import com.mnnit.moticlubs.service.FCMService
Expand Down Expand Up @@ -110,25 +109,10 @@ class UserController(
fun saveUser(@RequestBody user: User): Mono<ResponseEntity<User>> {
LOGGER.info("saveUser: user: ${user.regno}")
return userService.saveUser(user)
.invalidateStamp { ResponseStamp.USER }
.invalidateStamp { ResponseStamp.USER.withKey("all") }
.wrapError()
}

@PutMapping("/avatar")
@Operation(summary = "Updates user avatar")
fun updateAvatar(@RequestBody dto: UpdateAvatarDTO): Mono<ResponseEntity<User>> = pathAuthorization
.userAuthorization()
.validateRequestBody(dto)
.flatMap {
LOGGER.info("updateAvatar")
userService.updateAvatar(it, dto.avatar)
}
.invalidateStamp {
ResponseStamp.ADMIN.invalidateStamp()
ResponseStamp.USER
}
.wrapError()

@PutMapping("/contact")
@Operation(summary = "Updates user contact info")
fun updateContact(@RequestBody dto: UpdateContactDTO): Mono<ResponseEntity<User>> = pathAuthorization
Expand All @@ -140,7 +124,7 @@ class UserController(
}
.invalidateStamp {
ResponseStamp.ADMIN.invalidateStamp()
ResponseStamp.USER
ResponseStamp.USER.withKey("${it.uid}")
}
.wrapError()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,10 @@ data class UpdateClubDTO(
@JsonProperty("description")
val description: String,

@JsonProperty("avatar")
val avatar: String,

@JsonProperty("summary")
val summary: String,
) : Validator() {

override fun validate(): Boolean = description.validateClubDescription() &&
summary.validateClubSummary() &&
avatar.validateUrl()
summary.validateClubSummary()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.mnnit.moticlubs.dto.response

import com.fasterxml.jackson.annotation.JsonProperty

data class ImageUrlDTO(
@JsonProperty("url")
val url: String,
)
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,22 @@ class ClubRepository(
Update.from(
HashMap<SqlIdentifier, Any>().apply {
this[SqlIdentifier.unquoted(Club::description.name)] = dto.description
this[SqlIdentifier.unquoted(Club::avatar.name)] = dto.avatar
this[SqlIdentifier.unquoted(Club::summary.name)] = dto.summary
},
),
Club::class.java,
)
.flatMap { findById(cid) }

@Transactional
fun updateClubAvatar(cid: Long, avatar: String): Mono<Club> = db
.update(
Query.query(Criteria.where(Club::cid.name).`is`(cid)),
Update.update(Club::avatar.name, avatar),
Club::class.java,
)
.flatMap { findById(cid) }

@Transactional
fun existsById(cid: Long): Mono<Boolean> = db
.exists(
Expand Down
3 changes: 3 additions & 0 deletions src/main/kotlin/com/mnnit/moticlubs/service/ClubService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ class ClubService(
@CacheEvict("all_clubs", allEntries = true)
fun updateClub(cid: Long, dto: UpdateClubDTO): Mono<Club> = clubRepository.updateClub(cid, dto)

@CacheEvict("all_clubs", allEntries = true)
fun updateAvatar(cid: Long, avatar: String): Mono<Club> = clubRepository.updateClubAvatar(cid, avatar)

fun clubExists(cid: Long): Mono<Boolean> = clubRepository.existsById(cid)

@CacheEvict(
Expand Down
5 changes: 5 additions & 0 deletions src/main/kotlin/com/mnnit/moticlubs/utils/Constants.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ object Constants {

const val USER_ROUTE = "user"
const val CLUBS_ROUTE = "clubs"
const val AVATAR_ROUTE = "avatar"
const val POSTS_ROUTE = "posts"
const val SUPER_ADMIN_ROUTE = "admin"
const val CHANNEL_ROUTE = "channel"
Expand All @@ -18,4 +19,8 @@ object Constants {
const val CLUB_ID_CLAIM = "clubId"
const val CHANNEL_ID_CLAIM = "channelId"
const val POST_ID_CLAIM = "postId"

const val USER_AVATAR_PATH = "images/$USER_ROUTE"
const val CLUB_AVATAR_PATH = "images/$CLUBS_ROUTE"
const val POST_AVATAR_PATH = "images/$POSTS_ROUTE"
}
7 changes: 7 additions & 0 deletions src/main/kotlin/com/mnnit/moticlubs/utils/GlobalExceptions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,10 @@ class BadRequestException(
HttpStatus.BAD_REQUEST,
message,
)

class NotFoundException(
override val message: String,
) : ResponseStatusException(
HttpStatus.NOT_FOUND,
message,
)
1 change: 1 addition & 0 deletions src/main/kotlin/com/mnnit/moticlubs/utils/ResponseStamp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ object ResponseStamp {
fun getKey(): String = keyValue
}

val NONE get() = object : StampKey("NONE") {}
val ADMIN get() = object : StampKey("ADMIN") {}
val CHANNEL get() = object : StampKey("CHANNEL") {}
val CLUB get() = object : StampKey("CLUB") {}
Expand Down
1 change: 1 addition & 0 deletions src/main/kotlin/com/mnnit/moticlubs/utils/Utils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ fun <T> Mono<T>.wrapError(): Mono<T> = onErrorMap {
is UnauthorizedException,
is CachedException,
is BadRequestException,
is NotFoundException,
-> it

else -> ResponseStatusException(
Expand Down
Loading

0 comments on commit 77f9d58

Please sign in to comment.