Add FileUpload; Fix Mappers

This commit is contained in:
Baer 2026-04-23 20:56:57 +02:00
parent afd2345cac
commit 31a8532411
39 changed files with 655 additions and 454 deletions

View File

@ -1,11 +1,11 @@
plugins { plugins {
id("org.springframework.boot") version "3.3.0" id("org.springframework.boot") version "3.5.12"
id("io.spring.dependency-management") version "1.1.5" id("io.spring.dependency-management") version "1.1.7"
kotlin("plugin.jpa") version "1.9.24" kotlin("plugin.jpa") version "2.3.20"
kotlin("jvm") version "1.9.24" kotlin("jvm") version "2.3.20"
kotlin("plugin.spring") version "1.9.24" kotlin("plugin.spring") version "2.3.20"
kotlin("plugin.allopen") version "1.9.22" kotlin("plugin.allopen") version "2.3.20"
kotlin("kapt") version "1.9.10" kotlin("kapt") version "2.3.20"
} }
group = "at.eisibaer" group = "at.eisibaer"
@ -28,20 +28,19 @@ repositories {
} }
val bcVersion: String = "1.78.1" val bcVersion: String = "1.78.1"
val mapstructVersion: String = "1.6.0" val mapstructVersion: String = "1.7.0.Beta1"
dependencies { dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-websocket") implementation("org.springframework.boot:spring-boot-starter-websocket")
implementation("org.springframework.boot:spring-boot-docker-compose") implementation("org.springframework.session:spring-session-jdbc")
implementation("org.springframework.boot:spring-boot-starter-data-redis")
implementation("org.springframework.session:spring-session-data-redis")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.bouncycastle:bcprov-jdk18on:$bcVersion") implementation("org.bouncycastle:bcprov-jdk18on:$bcVersion")
implementation("org.mapstruct:mapstruct:$mapstructVersion") implementation("org.mapstruct:mapstruct:$mapstructVersion")
implementation("org.jetbrains.kotlin:kotlin-metadata-jvm:2.3.20")
compileOnly("org.projectlombok:lombok") compileOnly("org.projectlombok:lombok")
developmentOnly("org.springframework.boot:spring-boot-devtools") developmentOnly("org.springframework.boot:spring-boot-devtools")
runtimeOnly("org.postgresql:postgresql") runtimeOnly("org.postgresql:postgresql")

View File

@ -1,7 +0,0 @@
version: "3.1"
services:
redis:
image: "redis:alpine"
ports:
- "6379:6379"

View File

@ -11,10 +11,7 @@ import org.slf4j.Logger
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.security.authentication.AuthenticationManager import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.authentication.BadCredentialsException
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.Authentication
import org.springframework.security.core.AuthenticationException
import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Controller import org.springframework.stereotype.Controller
@ -31,24 +28,24 @@ class AuthEndpoint(
val encoder: PasswordEncoder, val encoder: PasswordEncoder,
) { ) {
private val log: Logger = LoggerFactory.getLogger(AuthEndpoint::class.java); private val log: Logger = LoggerFactory.getLogger(AuthEndpoint::class.java)
val strResponseSuccess: String = "Sending back success response"; val strResponseSuccess: String = "Sending back success response"
val strAlreadyLoggedIn: String = "User already logged in"; val strAlreadyLoggedIn: String = "User already logged in"
@PostMapping("/signup") @PostMapping("/signup")
fun signupUser(@RequestBody loginDto: LoginDto, session: HttpSession): ResponseEntity<*>{ fun signupUser(@RequestBody loginDto: LoginDto, session: HttpSession): ResponseEntity<*>{
log.info("Endpoint signupUser called"); log.info("Endpoint signupUser called")
log.debug("signup Request with username: {}", loginDto.username); log.debug("signup Request with username: {}", loginDto.username)
if( userRepository.existsByUsername(loginDto.username)){ if( userRepository.existsByUsername(loginDto.username)){
log.info("Username was already taken"); log.info("Username was already taken")
return ResponseEntity.badRequest().body("Username already taken"); return ResponseEntity.badRequest().body("Username already taken")
} }
val user = User(loginDto.username, encoder.encode( loginDto.password), ArrayList(), null, null ); val user = User(loginDto.username, encoder.encode( loginDto.password), ArrayList(), null, null )
userRepository.save(user); userRepository.save(user)
val authentication = authenticationManager.authenticate( val authentication = authenticationManager.authenticate(
UsernamePasswordAuthenticationToken( UsernamePasswordAuthenticationToken(
@ -57,81 +54,69 @@ class AuthEndpoint(
) )
) )
SecurityContextHolder.getContext().authentication = authentication; SecurityContextHolder.getContext().authentication = authentication
val userDetails: UserDetailsImpl = authentication.principal as UserDetailsImpl; val userDetails: UserDetailsImpl = authentication.principal as UserDetailsImpl
session.setAttribute(STR_SESSION_USER_KEY, userDetails); session.setAttribute(STR_SESSION_USER_KEY, userDetails)
log.info(strResponseSuccess); log.info(strResponseSuccess)
return ResponseEntity.ok().body(LoginResponseDto(userDetails.username, userDetails.getProfilePictureFilename())); return ResponseEntity.ok().body(LoginResponseDto(userDetails.username, userDetails.getProfilePictureFilename()))
} }
@PostMapping("/login") @PostMapping("/login")
fun loginUser(@RequestBody loginDto: LoginDto, session: HttpSession): ResponseEntity<*>{ fun loginUser(@RequestBody loginDto: LoginDto, session: HttpSession): ResponseEntity<*>{
log.info("Endpoint loginUser called"); log.info("Endpoint loginUser called")
log.debug("login Request with username: {}", loginDto.username); log.debug("login Request with username: {}", loginDto.username)
if( session.getAttribute(STR_SESSION_USER_KEY) != null ){ if( session.getAttribute(STR_SESSION_USER_KEY) != null ){
log.info(strAlreadyLoggedIn); log.info(strAlreadyLoggedIn)
return ResponseEntity.badRequest().body(strAlreadyLoggedIn); return ResponseEntity.badRequest().body(strAlreadyLoggedIn)
} }
val authentication: Authentication; val authentication = authenticationManager.authenticate(
try{ UsernamePasswordAuthenticationToken(
authentication = authenticationManager.authenticate( loginDto.username,
UsernamePasswordAuthenticationToken( loginDto.password
loginDto.username,
loginDto.password
)
) )
} catch (authenticationException: AuthenticationException ){ )
if( authenticationException is BadCredentialsException ){
log.debug("Login attempt with bad credentials for username: {}", loginDto.username);
return ResponseEntity.status(401).body(4011);
}
log.error("Error during authentication", authenticationException);
return ResponseEntity.status(401).body(4010);
}
SecurityContextHolder.getContext().authentication = authentication; val userDetails: UserDetailsImpl = authentication.principal as UserDetailsImpl
val userDetails: UserDetailsImpl = authentication.principal as UserDetailsImpl; session.setAttribute(STR_SESSION_USER_KEY, userDetails)
session.setAttribute(STR_SESSION_USER_KEY, userDetails); log.info(strResponseSuccess)
log.info(strResponseSuccess);
return ResponseEntity.ok() return ResponseEntity.ok()
.body( LoginResponseDto(userDetails.username, userDetails.getProfilePictureFilename())) .body( LoginResponseDto(userDetails.username, userDetails.getProfilePictureFilename()))
} }
@PostMapping("/logout") @PostMapping("/logout")
fun logoutUser(session: HttpSession): ResponseEntity<String>{ fun logoutUser(session: HttpSession): ResponseEntity<String>{
log.info("Endpoint logoutUser called"); log.info("Endpoint logoutUser called")
session.invalidate(); session.invalidate()
log.info(strResponseSuccess); log.info(strResponseSuccess)
return ResponseEntity.ok() return ResponseEntity.ok()
.body("Logged out"); .body("Logged out")
} }
@GetMapping("/status") @GetMapping("/status")
fun checkStatus(session: HttpSession): ResponseEntity<*>{ fun checkStatus(session: HttpSession): ResponseEntity<*>{
log.info("Endpoint checkStatus called"); log.info("Endpoint checkStatus called")
return if( session.getAttribute(STR_SESSION_USER_KEY) != null ){ return if( session.getAttribute(STR_SESSION_USER_KEY) != null ){
log.info(strAlreadyLoggedIn); log.info(strAlreadyLoggedIn)
val sessionUser: UserDetailsImpl = session.getAttribute(STR_SESSION_USER_KEY) as UserDetailsImpl; val sessionUser: UserDetailsImpl = session.getAttribute(STR_SESSION_USER_KEY) as UserDetailsImpl
ResponseEntity ResponseEntity
.ok() .ok()
.body( LoginResponseDto( sessionUser.username, sessionUser.getProfilePictureFilename() ) ); .body( LoginResponseDto( sessionUser.username, sessionUser.getProfilePictureFilename() ) )
} else { } else {
log.debug("No user logged in"); log.debug("No user logged in")
log.info(strResponseSuccess); log.info(strResponseSuccess)
ResponseEntity ResponseEntity
.status(401) .status(401)
.body("No user logged in"); .body("No user logged in")
} }
} }
} }

View File

@ -1,31 +0,0 @@
package at.eisibaer.jbear2.endpoint
import at.eisibaer.jbear2.exception.StorageFileNotFoundException
import at.eisibaer.jbear2.service.FileService
import at.eisibaer.jbear2.service.StorageService
import org.springframework.http.ResponseEntity
import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.multipart.MultipartFile
@Controller
class FileEndpoint(
val storageService: StorageService,
val fileService: FileService,
) {
@PostMapping("/")
fun handleFileUpload( @RequestParam("files") files: List<MultipartFile> ): ResponseEntity<*> {
fileService.saveFiles(files);
return ResponseEntity.ok().build<Any>()
}
@ExceptionHandler(StorageFileNotFoundException::class)
fun handleStorageFileNotFound(exc: StorageFileNotFoundException?): ResponseEntity<*> {
return ResponseEntity.notFound().build<Any>()
}
}

View File

@ -12,6 +12,7 @@ import org.springframework.http.ResponseEntity
import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.core.userdetails.UsernameNotFoundException import org.springframework.security.core.userdetails.UsernameNotFoundException
import org.springframework.web.bind.annotation.* import org.springframework.web.bind.annotation.*
import org.springframework.web.multipart.MultipartFile
@RestController @RestController
@RequestMapping("/api/user") @RequestMapping("/api/user")
@ -21,65 +22,65 @@ class UserEndpoint(
val fileRepository: FileRepository, val fileRepository: FileRepository,
) { ) {
val log: Logger = LoggerFactory.getLogger(UserEndpoint::class.java); val log: Logger = LoggerFactory.getLogger(UserEndpoint::class.java)
@GetMapping("/test/{pathVar}") @GetMapping("/test/{pathVar}")
fun testEndpoint(@PathVariable pathVar: String, @RequestParam param1: String): ResponseEntity<String>{ fun testEndpoint(@PathVariable pathVar: String, @RequestParam param1: String): ResponseEntity<String>{
log.info("test Endpoint!"); log.info("test Endpoint!")
return ResponseEntity.ok(param1); return ResponseEntity.ok(param1)
} }
@GetMapping("/boards") @GetMapping("/boards")
fun getBoards(): ResponseEntity<List<BoardDto>?>{ fun getBoards(): ResponseEntity<List<BoardDto>?>{
log.info("Get all Board Endpoint"); log.info("Get all Board Endpoint")
val userDetails = SecurityContextHolder.getContext().authentication.principal as UserDetailsImpl; val userDetails = SecurityContextHolder.getContext().authentication.principal as UserDetailsImpl
val user: User = userRepository.findUserByUsername(userDetails.username) ?: throw UsernameNotFoundException("Username ${userDetails.username} not found in DB"); val user: User = userRepository.findUserByUsername(userDetails.username) ?: throw UsernameNotFoundException("Username ${userDetails.username} not found in DB")
return boardService.getBoardsByUser( user ); return boardService.getBoardsByUser( user )
} }
@GetMapping("/boards/{boardId}") @GetMapping("/boards/{boardId}")
fun getBoardById(@PathVariable boardId: Long): ResponseEntity<BoardDto?>{ fun getBoardById(@PathVariable boardId: Long): ResponseEntity<BoardDto?>{
log.info("Get Board Endpoint with id {}", boardId); log.info("Get Board Endpoint with id {}", boardId)
val userDetails: UserDetailsImpl = SecurityContextHolder.getContext().authentication.principal as UserDetailsImpl val userDetails: UserDetailsImpl = SecurityContextHolder.getContext().authentication.principal as UserDetailsImpl
val user: User = userRepository.findUserByUsername(userDetails.username) ?: throw UsernameNotFoundException("Username ${userDetails.username} not found in DB"); val user: User = userRepository.findUserByUsername(userDetails.username) ?: throw UsernameNotFoundException("Username ${userDetails.username} not found in DB")
return boardService.getBoardByUserAndId( user, boardId) return boardService.getBoardByUserAndId( user, boardId)
} }
@PostMapping("/boards") @PostMapping("/boards")
fun saveNewBoard(@RequestBody boardDto: BoardDto): ResponseEntity<*> { fun saveNewBoard(@RequestPart("files") files: List<MultipartFile>, @RequestPart("board") boardDto: BoardDto): ResponseEntity<*> {
log.info("Post Board Endpoint"); log.info("Post Board Endpoint")
val userDetails: UserDetailsImpl = SecurityContextHolder.getContext().authentication.principal as UserDetailsImpl val userDetails: UserDetailsImpl = SecurityContextHolder.getContext().authentication.principal as UserDetailsImpl
val user: User = userRepository.findUserByUsername(userDetails.username) ?: throw UsernameNotFoundException("Username ${userDetails.username} not found in DB"); val user: User = userRepository.findUserByUsername(userDetails.username) ?: throw UsernameNotFoundException("Username ${userDetails.username} not found in DB")
if( boardDto.id != null ){ if( boardDto.id != null ){
return ResponseEntity.badRequest().body(Unit); return ResponseEntity.badRequest().body(Unit)
} }
return boardService.saveBoardToUser( user, boardDto ); return boardService.saveBoardToUser( user, boardDto, files )
} }
@PutMapping("/boards/{boardId}") @PutMapping("/boards/{boardId}")
fun saveExistingBoard(@RequestBody boardDto: BoardDto, @PathVariable boardId: Long): ResponseEntity<*> { fun saveExistingBoard(@RequestPart("files") files: List<MultipartFile>, @RequestPart("board") boardDto: BoardDto, @PathVariable boardId: Long): ResponseEntity<*> {
log.info("Put Board Endpoint with id {}", boardId); log.info("Put Board Endpoint with id {}", boardId)
val userDetails: UserDetailsImpl = SecurityContextHolder.getContext().authentication.principal as UserDetailsImpl val userDetails: UserDetailsImpl = SecurityContextHolder.getContext().authentication.principal as UserDetailsImpl
val user: User = userRepository.findUserByUsername(userDetails.username) ?: throw UsernameNotFoundException("Username ${userDetails.username} not found in DB"); val user: User = userRepository.findUserByUsername(userDetails.username) ?: throw UsernameNotFoundException("Username ${userDetails.username} not found in DB")
if( boardDto.id == null || boardDto.id != boardId ){ if( boardDto.id == null || boardDto.id != boardId ){
return ResponseEntity.badRequest().body("ID is either null or does not match") return ResponseEntity.badRequest().body("ID is either null or does not match")
} }
return boardService.saveBoardToUser( user, boardDto ); return boardService.saveBoardToUser( user, boardDto, files )
} }
@DeleteMapping("/boards/{boardId}") @DeleteMapping("/boards/{boardId}")
fun saveNewBoard(@PathVariable boardId: Long): ResponseEntity<*> { fun saveNewBoard(@PathVariable boardId: Long): ResponseEntity<*> {
log.info("Delete Board Endpoint with id {}", boardId); log.info("Delete Board Endpoint with id {}", boardId)
val userDetails: UserDetailsImpl = SecurityContextHolder.getContext().authentication.principal as UserDetailsImpl val userDetails: UserDetailsImpl = SecurityContextHolder.getContext().authentication.principal as UserDetailsImpl
val user: User = userRepository.findUserByUsername(userDetails.username) ?: throw UsernameNotFoundException("Username ${userDetails.username} not found in DB"); val user: User = userRepository.findUserByUsername(userDetails.username) ?: throw UsernameNotFoundException("Username ${userDetails.username} not found in DB")
return boardService.deleteBoardOfUser( user, boardId ); return boardService.deleteBoardOfUser( user, boardId )
} }
} }

View File

@ -1,9 +1,6 @@
package at.eisibaer.jbear2.endpoint.ws package at.eisibaer.jbear2.endpoint.ws
import at.eisibaer.jbear2.dto.board.QuestionDto
import at.eisibaer.jbear2.dto.message.* import at.eisibaer.jbear2.dto.message.*
import at.eisibaer.jbear2.exception.NoMessageException
import at.eisibaer.jbear2.model.BoardEntry
import at.eisibaer.jbear2.model.Game import at.eisibaer.jbear2.model.Game
import at.eisibaer.jbear2.model.Player import at.eisibaer.jbear2.model.Player
import at.eisibaer.jbear2.model.User import at.eisibaer.jbear2.model.User
@ -20,18 +17,13 @@ import at.eisibaer.jbear2.util.RandomString
import jakarta.transaction.Transactional import jakarta.transaction.Transactional
import org.slf4j.Logger import org.slf4j.Logger
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.messaging.handler.annotation.DestinationVariable import org.springframework.messaging.handler.annotation.DestinationVariable
import org.springframework.messaging.handler.annotation.Header
import org.springframework.messaging.handler.annotation.Headers
import org.springframework.messaging.handler.annotation.MessageExceptionHandler import org.springframework.messaging.handler.annotation.MessageExceptionHandler
import org.springframework.messaging.handler.annotation.MessageMapping import org.springframework.messaging.handler.annotation.MessageMapping
import org.springframework.messaging.handler.annotation.Payload import org.springframework.messaging.handler.annotation.Payload
import org.springframework.messaging.handler.annotation.SendTo
import org.springframework.messaging.simp.SimpMessagingTemplate import org.springframework.messaging.simp.SimpMessagingTemplate
import org.springframework.messaging.simp.annotation.SendToUser import org.springframework.messaging.simp.annotation.SendToUser
import org.springframework.messaging.simp.annotation.SubscribeMapping
import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.core.userdetails.UsernameNotFoundException import org.springframework.security.core.userdetails.UsernameNotFoundException
import org.springframework.stereotype.Controller import org.springframework.stereotype.Controller
@ -40,10 +32,8 @@ import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestBody
import java.lang.Exception import java.lang.Exception
import java.security.Principal
import java.util.* import java.util.*
import kotlin.NoSuchElementException import kotlin.NoSuchElementException
import kotlin.collections.ArrayList
@Controller @Controller
class GameEndpoint( class GameEndpoint(
@ -59,16 +49,16 @@ class GameEndpoint(
val messagingTemplate: SimpMessagingTemplate, val messagingTemplate: SimpMessagingTemplate,
) { ) {
private val log: Logger = LoggerFactory.getLogger(GameEndpoint::class.java); private val log: Logger = LoggerFactory.getLogger(GameEndpoint::class.java)
@Transactional @Transactional
@GetMapping("/api/games/{id}") @GetMapping("/api/games/{id}")
fun getGameByInviteCode(@PathVariable id: String): ResponseEntity<*>{ fun getGameByInviteCode(@PathVariable id: String): ResponseEntity<*>{
val game: Game; val game: Game
if( id.length == INVITE_CODE_LENGTH){ if( id.length == INVITE_CODE_LENGTH){
game = gameRepository.findByInviteCode(id) ?: return ResponseEntity.status(404).body("Game with invide code $id not found") game = gameRepository.findByInviteCode(id) ?: return ResponseEntity.status(404).body("Game with invide code $id not found")
} else { } else {
var uuid: UUID; var uuid: UUID
try{ try{
uuid = UUID.fromString(id) uuid = UUID.fromString(id)
}catch (exception: IllegalArgumentException){ }catch (exception: IllegalArgumentException){
@ -76,24 +66,24 @@ class GameEndpoint(
} }
game = gameRepository.findByUuid(uuid) ?: return ResponseEntity.status(404).body("Game with UUID $uuid not found") game = gameRepository.findByUuid(uuid) ?: return ResponseEntity.status(404).body("Game with UUID $uuid not found")
} }
return ResponseEntity.ok().body(gameMapper.toDto(game)); return ResponseEntity.ok().body(gameMapper.toDto(game))
} }
@Transactional @Transactional
@PostMapping("/api/games") @PostMapping("/api/games")
fun hostNewGame(@RequestBody body: String?): ResponseEntity<*>{ fun hostNewGame(@RequestBody body: String?): ResponseEntity<*>{
log.info("Start new game"); log.info("Start new game")
val userDetails = SecurityContextHolder.getContext().authentication.principal as UserDetailsImpl; val userDetails = SecurityContextHolder.getContext().authentication.principal as UserDetailsImpl
val user: User = userRepository.findUserByUsername(userDetails.username) ?: throw UsernameNotFoundException("Username ${userDetails.username} not found in DB"); val user: User = userRepository.findUserByUsername(userDetails.username) ?: throw UsernameNotFoundException("Username ${userDetails.username} not found in DB")
val firstAvailableBoard = boardRepository.findFirstByOwnerOrderByBoardName(user) ?: return ResponseEntity.status(404).body("No board found for user") val firstAvailableBoard = boardRepository.findFirstByOwnerOrderByBoardName(user) ?: return ResponseEntity.status(404).body("No board found for user")
gameRepository.deleteAll(); gameRepository.deleteAll()
gameRepository.flush() gameRepository.flush()
val game = Game(RandomString.ofLength(8), UUID.randomUUID(), firstAvailableBoard, user, emptyList(), emptyList(), null ) val game = Game(RandomString.ofLength(8), UUID.randomUUID(), firstAvailableBoard, user, emptyList(), emptyList(), null )
val savedGame = gameRepository.save(game); val savedGame = gameRepository.save(game)
return ResponseEntity.ok().body(gameMapper.toDto(savedGame)); return ResponseEntity.ok().body(gameMapper.toDto(savedGame))
} }
// @MessageMapping("/join") // @MessageMapping("/join")
@ -138,11 +128,11 @@ class GameEndpoint(
@MessageMapping("/join/{id}") @MessageMapping("/join/{id}")
fun playerJoining(@Payload joinMessage: GenericMessage, @DestinationVariable id: String){ fun playerJoining(@Payload joinMessage: GenericMessage, @DestinationVariable id: String){
val game: Game; val game: Game
if( id.length == INVITE_CODE_LENGTH){ if( id.length == INVITE_CODE_LENGTH){
game = gameRepository.findByInviteCode(id) ?: throw NoSuchElementException("Game with invide code $id not found") game = gameRepository.findByInviteCode(id) ?: throw NoSuchElementException("Game with invide code $id not found")
} else { } else {
var uuid: UUID; var uuid: UUID
try{ try{
uuid = UUID.fromString(id) uuid = UUID.fromString(id)
}catch (exception: IllegalArgumentException){ }catch (exception: IllegalArgumentException){
@ -150,22 +140,22 @@ class GameEndpoint(
} }
game = gameRepository.findByUuid(uuid) ?: throw NoSuchElementException("Game with UUID $uuid not found") game = gameRepository.findByUuid(uuid) ?: throw NoSuchElementException("Game with UUID $uuid not found")
} }
val player = Player(joinMessage.content, game); val player = Player(joinMessage.content, game)
val savedPlayer = playerRepository.save(player) val savedPlayer = playerRepository.save(player)
val payload = playerMapper.toDto(savedPlayer); val payload = playerMapper.toDto(savedPlayer)
messagingTemplate.convertAndSend("/topic/game/${game.uuid}/joined", payload); messagingTemplate.convertAndSend("/topic/game/${game.uuid}/joined", payload)
} }
@MessageMapping("/host/game/{uuid}/start") @MessageMapping("/host/game/{uuid}/start")
fun startGame(@Payload startMessage: GenericMessage, @DestinationVariable uuid: UUID){ fun startGame(@Payload startMessage: GenericMessage, @DestinationVariable uuid: UUID){
// val game = gameRepository.findByUuid(uuid) ?: throw NoSuchElementException("Game with UUID $uuid not found") // val game = gameRepository.findByUuid(uuid) ?: throw NoSuchElementException("Game with UUID $uuid not found")
messagingTemplate.convertAndSend("/topic/game/$uuid/started", GameMessage(uuid, "The game has been started")); messagingTemplate.convertAndSend("/topic/game/$uuid/started", GameMessage(uuid, "The game has been started"))
} }
@MessageMapping("/host/game/{uuid}/board/select") @MessageMapping("/host/game/{uuid}/board/select")
fun selectBoard(@Payload boardMessage: GenericMessage, @DestinationVariable uuid: UUID){ fun selectBoard(@Payload boardMessage: GenericMessage, @DestinationVariable uuid: UUID){
messagingTemplate.convertAndSend("/topic/game/$uuid/board/selected", GameMessage(uuid, "The board is being shown")); messagingTemplate.convertAndSend("/topic/game/$uuid/board/selected", GameMessage(uuid, "The board is being shown"))
} }
@MessageMapping("/host/game/{uuid}/boardentry/select") @MessageMapping("/host/game/{uuid}/boardentry/select")
@ -176,9 +166,9 @@ class GameEndpoint(
targetGameState.boardEntryIndex, targetGameState.boardEntryIndex,
null, null,
null, null,
); )
messagingTemplate.convertAndSend("/topic/game/$uuid/boardentry/selected", gameState); messagingTemplate.convertAndSend("/topic/game/$uuid/boardentry/selected", gameState)
} }
@MessageMapping("/host/game/{uuid}/question/layer/select") @MessageMapping("/host/game/{uuid}/question/layer/select")
@ -189,19 +179,19 @@ class GameEndpoint(
targetGameState.boardEntryIndex, targetGameState.boardEntryIndex,
targetGameState.questionIndex, targetGameState.questionIndex,
targetGameState.questionLayerIndex, targetGameState.questionLayerIndex,
); )
messagingTemplate.convertAndSend("/topic/game/$uuid/questionlayer/selected", gameState); messagingTemplate.convertAndSend("/topic/game/$uuid/questionlayer/selected", gameState)
} }
@MessageMapping("/host/game/{uuid}/question/audio/play") @MessageMapping("/host/game/{uuid}/question/audio/play")
fun playAudio(@Payload audioMessage: GenericMessage, @DestinationVariable uuid: UUID){ fun playAudio(@Payload audioMessage: GenericMessage, @DestinationVariable uuid: UUID){
messagingTemplate.convertAndSend("/topic/game/$uuid/question/audio/playing", GenericMessage("Playing audio")); messagingTemplate.convertAndSend("/topic/game/$uuid/question/audio/playing", GenericMessage("Playing audio"))
} }
@MessageMapping("/host/game/{uuid}/question/audio/stop") @MessageMapping("/host/game/{uuid}/question/audio/stop")
fun stopAudio(@Payload audioMessage: GenericMessage, @DestinationVariable uuid: UUID){ fun stopAudio(@Payload audioMessage: GenericMessage, @DestinationVariable uuid: UUID){
messagingTemplate.convertAndSend("/topic/game/$uuid/question/audio/stopped", GenericMessage("Playing audio")); messagingTemplate.convertAndSend("/topic/game/$uuid/question/audio/stopped", GenericMessage("Playing audio"))
} }
@MessageMapping("/host/game/{uuid}/question/reveal") @MessageMapping("/host/game/{uuid}/question/reveal")
@ -211,21 +201,21 @@ class GameEndpoint(
gameState.boardEntryIndex == null || gameState.boardEntryIndex == null ||
gameState.questionIndex == null gameState.questionIndex == null
){ ){
throw InvalidPropertiesFormatException("Invalid gameSate to reveal question"); throw InvalidPropertiesFormatException("Invalid gameSate to reveal question")
} }
val game = gameRepository.findByUuid(uuid) ?: throw NoSuchElementException("Game with UUID $uuid not found") val game = gameRepository.findByUuid(uuid) ?: throw NoSuchElementException("Game with UUID $uuid not found")
val board = game.board; val board = game.board
val question = board.categories[gameState.categoryIndex].boardEntries[gameState.boardEntryIndex].questions[gameState.questionIndex]; val question = board.categories[gameState.categoryIndex].boardEntries[gameState.boardEntryIndex].questions[gameState.questionIndex]
val questionDto = questionMapper.toDto(question); val questionDto = questionMapper.toDto(question)
messagingTemplate.convertAndSend("/topic/game/$uuid/question/revealed", questionDto); messagingTemplate.convertAndSend("/topic/game/$uuid/question/revealed", questionDto)
} }
@MessageExceptionHandler @MessageExceptionHandler
@SendToUser("/error") @SendToUser("/error")
fun handleException(exception: Exception): GenericMessage{ fun handleException(exception: Exception): GenericMessage{
log.error("Exception in GameEndpoint", exception) log.error("Exception in GameEndpoint", exception)
return GenericMessage(exception.message ?: "Unknown error"); return GenericMessage(exception.message ?: "Unknown error")
} }
} }

View File

@ -18,7 +18,7 @@ data class Answer(
@OneToOne @OneToOne
@JoinColumn(name = "fk_image_file", referencedColumnName = "id") @JoinColumn(name = "fk_image_file", referencedColumnName = "id")
val image: File?, var image: File?,
@OneToOne @OneToOne
@JoinColumn(name="fk_board_entry", referencedColumnName = "id") @JoinColumn(name="fk_board_entry", referencedColumnName = "id")

View File

@ -9,20 +9,19 @@ import jakarta.persistence.*
data class Board( data class Board(
@OneToMany(mappedBy = "board", cascade = [CascadeType.ALL], orphanRemoval = true) @OneToMany(mappedBy = "board", cascade = [CascadeType.ALL], orphanRemoval = true)
val categories: List<Category>, val categories: List<Category> = ArrayList(),
@Column(name = "board_name", nullable = false, unique = false) @Column(name = "board_name", nullable = false, unique = false)
val boardName: String, var boardName: String = "New Board",
@ManyToOne @ManyToOne
@JoinColumn(name="fk_owned_by", referencedColumnName = "id") @JoinColumn(name="fk_owned_by", referencedColumnName = "id")
val owner: User, var owner: User,
@Column(name = "points_are_title", nullable = false, unique = false) @Column(name = "points_are_title", nullable = false, unique = false)
val pointsAreTitle: Boolean = false, var pointsAreTitle: Boolean = false,
@Id @Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sequenceGenerator") @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sequenceGenerator")
val id: Long? = null, var id: Long? = null,
) )

View File

@ -9,20 +9,20 @@ import jakarta.persistence.*
data class BoardEntry( data class BoardEntry(
@Column(name = "name", nullable = false, unique = false) @Column(name = "name", nullable = false, unique = false)
val name: String, var name: String,
@Column(name = "points", nullable = false, unique = false) @Column(name = "points", nullable = false, unique = false)
val points: Long, var points: Long,
@OneToMany(mappedBy = "boardEntry", cascade = [CascadeType.ALL], orphanRemoval = true) @OneToMany(mappedBy = "boardEntry", cascade = [CascadeType.ALL], orphanRemoval = true)
val questions: List<Question>, val questions: List<Question> = ArrayList(),
@OneToOne(cascade = [CascadeType.ALL], orphanRemoval = true) @OneToOne(cascade = [CascadeType.ALL], orphanRemoval = true)
val answer: Answer, var answer: Answer? = null,
@ManyToOne @ManyToOne
@JoinColumn(name = "fk_category", referencedColumnName = "id") @JoinColumn(name = "fk_category", referencedColumnName = "id")
var category: Category?, var category: Category? = null,
@Id @Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sequenceGenerator") @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sequenceGenerator")

View File

@ -9,13 +9,13 @@ import jakarta.persistence.*
data class Category ( data class Category (
@Column(name = "name", nullable = false, unique = false) @Column(name = "name", nullable = false, unique = false)
val name: String, var name: String,
@Column(name = "description", nullable = false, unique = false) @Column(name = "description", nullable = false, unique = false)
val description: String, var description: String,
@OneToMany(mappedBy = "category", cascade = [CascadeType.ALL], orphanRemoval = true) @OneToMany(mappedBy = "category", cascade = [CascadeType.ALL], orphanRemoval = true)
val boardEntries: List<BoardEntry>, val boardEntries: List<BoardEntry> = ArrayList(),
@ManyToOne @ManyToOne
@JoinColumn(name = "fk_board", referencedColumnName = "id") @JoinColumn(name = "fk_board", referencedColumnName = "id")

View File

@ -1,5 +1,6 @@
package at.eisibaer.jbear2.model package at.eisibaer.jbear2.model
import at.eisibaer.jbear2.model.enums.FileType
import jakarta.persistence.* import jakarta.persistence.*
import java.util.UUID import java.util.UUID
@ -34,10 +35,4 @@ data class File (
@Id @Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sequenceGenerator") @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sequenceGenerator")
val id: Long? = null, val id: Long? = null,
) )
enum class FileType{
IMAGE,
AUDIO,
PROFILE_PICTURE,
}

View File

@ -12,18 +12,18 @@ import jakarta.persistence.*
data class Question( data class Question(
@Column(name = "text", nullable = false, unique = false) @Column(name = "text", nullable = false, unique = false)
val text: String, var text: String,
@Enumerated @Enumerated
@Column(name = "question_type", nullable = false, unique = false) @Column(name = "question_type", nullable = false, unique = false)
val questionType: QuestionType = QuestionType.TEXT, var questionType: QuestionType = QuestionType.TEXT,
@Column(name = "font_scaling", nullable = false, unique = false) @Column(name = "font_scaling", nullable = false, unique = false)
val fontScaling: Int, var fontScaling: Int,
@OneToOne @OneToOne
@JoinColumn(name = "fk_image", referencedColumnName = "id") @JoinColumn(name = "fk_image", referencedColumnName = "id")
val image: File?, var image: File?,
@ManyToOne @ManyToOne
@JoinColumn(name = "fk_board_entry", referencedColumnName = "id") @JoinColumn(name = "fk_board_entry", referencedColumnName = "id")

View File

@ -0,0 +1,6 @@
package at.eisibaer.jbear2.model.enums
enum class FileType{
IMAGE,
AUDIO,
}

View File

@ -0,0 +1,6 @@
package at.eisibaer.jbear2.repository
import at.eisibaer.jbear2.model.Answer
import org.springframework.data.jpa.repository.JpaRepository
interface AnswerRepository : JpaRepository<Answer, Long>

View File

@ -0,0 +1,6 @@
package at.eisibaer.jbear2.repository
import at.eisibaer.jbear2.model.Question
import org.springframework.data.jpa.repository.JpaRepository
interface QuestionRepository : JpaRepository<Question, Long>

View File

@ -1,6 +1,7 @@
package at.eisibaer.jbear2.security package at.eisibaer.jbear2.security
import at.eisibaer.jbear2.config.ApplicationProperties import at.eisibaer.jbear2.config.ApplicationProperties
import at.eisibaer.jbear2.repository.UserRepository
import jakarta.servlet.FilterChain import jakarta.servlet.FilterChain
import jakarta.servlet.ServletException import jakarta.servlet.ServletException
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
@ -10,8 +11,8 @@ import org.slf4j.LoggerFactory
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import org.springframework.security.authentication.AuthenticationManager import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.authentication.ProviderManager
import org.springframework.security.authentication.dao.DaoAuthenticationProvider import org.springframework.security.authentication.dao.DaoAuthenticationProvider
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.core.userdetails.UserDetailsService import org.springframework.security.core.userdetails.UserDetailsService
@ -29,12 +30,12 @@ import java.util.function.Supplier
@Configuration @Configuration
@EnableMethodSecurity @EnableMethodSecurity
class SecurityConfiguration( class SecurityConfiguration(
private val userDetailService: UserDetailsService, private val userRepository: UserRepository,
private val unauthorizedHandler: AuthFilter, private val unauthorizedHandler: AuthFilter,
private val applicationProperties: ApplicationProperties private val applicationProperties: ApplicationProperties
) { ) {
final val log: Logger = LoggerFactory.getLogger(SecurityConfiguration::class.java); final val log: Logger = LoggerFactory.getLogger(SecurityConfiguration::class.java)
@Bean @Bean
fun securityFilterChain(httpSecurity: HttpSecurity): SecurityFilterChain { fun securityFilterChain(httpSecurity: HttpSecurity): SecurityFilterChain {
@ -44,14 +45,13 @@ class SecurityConfiguration(
.requestMatchers("/api/user/**").authenticated() .requestMatchers("/api/user/**").authenticated()
.requestMatchers("/**").permitAll() .requestMatchers("/**").permitAll()
} }
.authenticationProvider(authenticationProvider())
.addFilterBefore(unauthorizedHandler, UsernamePasswordAuthenticationFilter::class.java) .addFilterBefore(unauthorizedHandler, UsernamePasswordAuthenticationFilter::class.java)
.build() .build()
} }
private fun addCsrfConfig(httpSecurity: HttpSecurity): HttpSecurity{ private fun addCsrfConfig(httpSecurity: HttpSecurity): HttpSecurity{
if( applicationProperties.test ){ if( applicationProperties.test ){
httpSecurity.csrf{ config -> config.disable()}; httpSecurity.csrf{ config -> config.disable()}
} else { } else {
httpSecurity.csrf { config -> httpSecurity.csrf { config ->
config config
@ -60,7 +60,7 @@ class SecurityConfiguration(
} }
.addFilterAfter(CsrfCookieFilter(), BasicAuthenticationFilter::class.java) .addFilterAfter(CsrfCookieFilter(), BasicAuthenticationFilter::class.java)
} }
return httpSecurity; return httpSecurity
} }
class SpaCsrfTokenRequestHandler : CsrfTokenRequestAttributeHandler() { class SpaCsrfTokenRequestHandler : CsrfTokenRequestAttributeHandler() {
@ -107,21 +107,18 @@ class SecurityConfiguration(
@Bean @Bean
fun passwordEncoder() : PasswordEncoder { fun passwordEncoder() : PasswordEncoder {
return Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8(); return Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8()
} }
@Bean @Bean
fun authenticationProvider(): DaoAuthenticationProvider{ fun authenticationProvider(): AuthenticationManager {
val authProvider: DaoAuthenticationProvider = DaoAuthenticationProvider() val authProvider = DaoAuthenticationProvider(userDetailService())
authProvider.setPasswordEncoder(passwordEncoder())
authProvider.setUserDetailsService(userDetailService) return ProviderManager(authProvider)
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
} }
@Bean @Bean
fun authenticationManager(authConfig: AuthenticationConfiguration): AuthenticationManager{ fun userDetailService(): UserDetailsService {
return authConfig.authenticationManager; return UserDetailServiceImpl(userRepository)
} }
} }

View File

@ -16,17 +16,17 @@ class UserDetailServiceImpl(
): UserDetailsService { ): UserDetailsService {
override fun loadUserByUsername(username: String?): UserDetailsImpl { override fun loadUserByUsername(username: String?): UserDetailsImpl {
val user: User? = userRepository.findUserByUsername( username ?: "" ) if( username == null ){
throw UsernameNotFoundException("User not found by username \"$username\"")
if( user == null ){
throw UsernameNotFoundException("User not found by username \"$username\"");
} }
val user: User = userRepository.findUserByUsername( username )
?: throw UsernameNotFoundException("User not found by username \"$username\"")
return UserDetailsImpl( return UserDetailsImpl(
user.id!!, user.id!!,
user.username, user.username,
user.password, user.password,
user.profilePicture?.filename, user.profilePicture?.filename,
); )
} }
} }

View File

@ -3,6 +3,7 @@ package at.eisibaer.jbear2.service
import at.eisibaer.jbear2.dto.board.BoardDto import at.eisibaer.jbear2.dto.board.BoardDto
import at.eisibaer.jbear2.model.User import at.eisibaer.jbear2.model.User
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.multipart.MultipartFile
interface BoardService { interface BoardService {
@ -10,6 +11,6 @@ interface BoardService {
fun getBoardByUserAndId( user: User, boardId: Long ): ResponseEntity<BoardDto?> fun getBoardByUserAndId( user: User, boardId: Long ): ResponseEntity<BoardDto?>
fun saveBoardToUser( user: User, boardDto: BoardDto ): ResponseEntity<BoardDto> fun saveBoardToUser( user: User, boardDto: BoardDto, files: List<MultipartFile> ): ResponseEntity<BoardDto>
fun deleteBoardOfUser( user: User, boardId: Long ): ResponseEntity<Unit> fun deleteBoardOfUser( user: User, boardId: Long ): ResponseEntity<Unit>
} }

View File

@ -9,54 +9,92 @@ import at.eisibaer.jbear2.repository.UserRepository
import at.eisibaer.jbear2.service.mapper.BoardMapper import at.eisibaer.jbear2.service.mapper.BoardMapper
import org.slf4j.Logger import org.slf4j.Logger
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile
@Service @Service
class BoardServiceImpl ( class BoardServiceImpl (
private val fileService: FileService,
private val boardRepository: BoardRepository, private val boardRepository: BoardRepository,
private val userRepository: UserRepository, private val userRepository: UserRepository,
private val fileRepository: FileRepository, private val fileRepository: FileRepository,
private val boardMapper: BoardMapper, private val boardMapper: BoardMapper,
) : BoardService { ) : BoardService {
val log: Logger = LoggerFactory.getLogger(BoardServiceImpl::class.java); val log: Logger = LoggerFactory.getLogger(BoardServiceImpl::class.java)
@Transactional @Transactional
override fun getBoardsByUser(user: User): ResponseEntity<List<BoardDto>?> { override fun getBoardsByUser(user: User): ResponseEntity<List<BoardDto>?> {
val boards = boardRepository.findAllByOwner(user) val boards = boardRepository.findAllByOwner(user)
return ResponseEntity.ok(boardMapper.toDto(boards)); return ResponseEntity.ok(boardMapper.toDto(boards))
} }
@Transactional @Transactional
override fun getBoardByUserAndId(user: User, boardId: Long): ResponseEntity<BoardDto?> { override fun getBoardByUserAndId(user: User, boardId: Long): ResponseEntity<BoardDto?> {
val board = boardRepository.findByIdAndOwner(boardId, user) ?: return ResponseEntity.status(404).build(); val board = boardRepository.findByIdAndOwner(boardId, user) ?: return ResponseEntity.status(404).build()
return ResponseEntity.ok(boardMapper.toDto(board)) return ResponseEntity.ok(boardMapper.toDto(board))
} }
@Transactional @Transactional
override fun saveBoardToUser( user: User, boardDto: BoardDto): ResponseEntity<BoardDto> { override fun saveBoardToUser( user: User, boardDto: BoardDto, files: List<MultipartFile>): ResponseEntity<BoardDto> {
val board: Board = boardMapper.toEntity(boardDto, user, userRepository, fileRepository); val board = if( boardDto.id == null ){
Board(owner = user)
} else {
boardRepository.findByIdAndOwner(boardDto.id, user) ?: throw NoSuchElementException("Board not found for user " + user.username)
}
boardMapper.mapToEntity(board, boardDto, user, userRepository, fileRepository)
val savedBoard: Board; val savedBoard: Board
try { try {
savedBoard = boardRepository.save(board); savedBoard = boardRepository.save(board)
} catch (ex: Exception) { } catch (ex: Exception) {
log.error(ex.message, ex); log.error(ex.message, ex)
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build()
}
try{
fileService.saveFiles(files, user, savedBoard)
} catch (ex: Exception) {
log.error(ex.message, ex)
return ResponseEntity.badRequest().build()
} }
return ResponseEntity.ok(boardMapper.toDto(savedBoard)) return ResponseEntity.ok(boardMapper.toDto(savedBoard))
} }
override fun deleteBoardOfUser(user: User, boardId: Long): ResponseEntity<Unit> { override fun deleteBoardOfUser(user: User, boardId: Long): ResponseEntity<Unit> {
boardRepository.deleteById( boardId ); val board = boardRepository.findByIdAndOwner(boardId, user) ?: return ResponseEntity.status(HttpStatus.NOT_FOUND).build()
boardRepository.deleteById( boardId )
//TODO Delete Images //TODO Delete Images
return ResponseEntity.ok().build(); return ResponseEntity.ok().build()
}
private fun deleteFilesOfBoard(board: Board) {
try {
for (category in board.categories) {
for (boardEntry in category.boardEntries) {
if (boardEntry.answer != null && boardEntry.answer!!.image != null) {
fileService.deleteFile(boardEntry.answer!!.image!!)
}
for (question in boardEntry.questions) {
if (question.image != null) {
fileService.deleteFile(question.image!!)
}
}
}
}
} catch (exception: Exception) {
log.error("Error occured",exception) //TODO
}
} }
} }

View File

@ -1,18 +1,20 @@
package at.eisibaer.jbear2.service package at.eisibaer.jbear2.service
import at.eisibaer.jbear2.model.Board
import at.eisibaer.jbear2.model.File import at.eisibaer.jbear2.model.File
import at.eisibaer.jbear2.model.User
import org.springframework.core.io.Resource import org.springframework.core.io.Resource
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import java.util.UUID import java.util.UUID
interface FileService { interface FileService {
fun saveFiles(files: List<MultipartFile>): List<File>; fun saveFiles(files: List<MultipartFile>, owner: User, board: Board)
fun saveFile(file: MultipartFile): File; fun saveFile(file: MultipartFile, owner: User, board: Board): File
fun getFile(file: File): Resource; fun getFile(file: File): Resource
fun getFile(fileUUID: UUID): Resource; fun getFile(fileUUID: UUID): Resource
fun deleteFile(file: File): Resource; fun deleteFile(file: File): Resource
fun deleteFile(fileUUID: UUID): Resource; fun deleteFile(fileUUID: UUID): Resource
} }

View File

@ -1,36 +1,96 @@
package at.eisibaer.jbear2.service package at.eisibaer.jbear2.service
import at.eisibaer.jbear2.model.File import at.eisibaer.jbear2.model.*
import at.eisibaer.jbear2.model.enums.FileType
import at.eisibaer.jbear2.repository.AnswerRepository
import at.eisibaer.jbear2.repository.FileRepository import at.eisibaer.jbear2.repository.FileRepository
import at.eisibaer.jbear2.repository.QuestionRepository
import lombok.extern.slf4j.Slf4j
import org.apache.tomcat.util.http.fileupload.InvalidFileNameException
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.core.io.Resource import org.springframework.core.io.Resource
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.util.InvalidMimeTypeException
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import java.nio.file.Files import java.security.MessageDigest
import java.util.* import java.util.*
@Slf4j
@Service @Service
class FileServiceImpl( class FileServiceImpl (
val fileRepository: FileRepository, val fileRepository: FileRepository,
val answerRepository: AnswerRepository,
val questionRepository: QuestionRepository,
val storageService: StorageService,
) : FileService { ) : FileService {
override fun saveFiles(files: List<MultipartFile>): List<File> { val log: Logger = LoggerFactory.getLogger(FileServiceImpl::class.java)
override fun saveFiles(files: List<MultipartFile>, owner: User, board: Board) {
val digest: MessageDigest = MessageDigest.getInstance("SHA-256")
for(file in files) {
if( file.originalFilename == null ){
throw InvalidFileNameException(file.originalFilename, "No filename given")
}
val associatedEntries = file.originalFilename!!.split("-")
if( associatedEntries.size != 3) {
throw InvalidFileNameException(file.name, "Invalid index name")
}
val boardEntry = board.categories[associatedEntries[0].toInt()].boardEntries[associatedEntries[1].toInt()]
var question: Question? = null
var answer: Answer? = null
if(associatedEntries[2] == "answer"){
answer = boardEntry.answer
} else {
question = boardEntry.questions[associatedEntries[2].toInt()]
}
val uuid = UUID.randomUUID()
val filename = uuid.toString() + getExtension(file)
storageService.storeFile(file, filename)
log.debug("File stored")
val newFile = File(
uuid,
filename,
Base64.getEncoder().encodeToString(digest.digest(file.bytes)),
owner,
if (file.contentType.orEmpty().startsWith("image")) FileType.IMAGE else FileType.AUDIO,
question,
answer,
)
val savedFile: File = fileRepository.save(newFile)
if(associatedEntries[2] == "answer"){
if( answer != null ){
if( savedFile.fileType == FileType.IMAGE ) {
answer.image = savedFile
}
answerRepository.save(answer)
}
} else if( question != null ){
if( savedFile.fileType == FileType.IMAGE ) {
question.image = savedFile
}
questionRepository.save(question)
}
}
return
}
override fun saveFile(file: MultipartFile, owner: User, board: Board): File {
TODO("Not yet implemented") TODO("Not yet implemented")
} }
override fun saveFile(file: MultipartFile): File { private fun getExtension(file: MultipartFile): String {
TODO("Not yet implemented") return when (file.contentType) {
} "image/jpeg" -> ".jpg"
"image/png" -> ".png"
private fun saveMultipartFile(file: MultipartFile): File{ "image/avif" -> ".avif"
val uuid = UUID.randomUUID(); "image/webp" -> ".webp"
val filename = file.originalFilename; else -> throw InvalidMimeTypeException(file.contentType ?: "unknown", "MIME Type not accepted")
var hash: String; }
// val file = File(
// UUID.randomUUID(),
// uuidextension,
// hash,
// )
TODO();
} }
override fun getFile(file: File): Resource { override fun getFile(file: File): Resource {
@ -42,6 +102,7 @@ class FileServiceImpl(
} }
override fun deleteFile(file: File): Resource { override fun deleteFile(file: File): Resource {
TODO("Not yet implemented") TODO("Not yet implemented")
} }

View File

@ -4,11 +4,11 @@ import at.eisibaer.jbear2.config.ApplicationProperties
import at.eisibaer.jbear2.exception.StorageException import at.eisibaer.jbear2.exception.StorageException
import at.eisibaer.jbear2.exception.StorageFileNotFoundException import at.eisibaer.jbear2.exception.StorageFileNotFoundException
import jakarta.annotation.PostConstruct import jakarta.annotation.PostConstruct
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.core.io.Resource import org.springframework.core.io.Resource
import org.springframework.core.io.UrlResource import org.springframework.core.io.UrlResource
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional
import org.springframework.util.FileSystemUtils import org.springframework.util.FileSystemUtils
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import java.io.IOException import java.io.IOException
@ -20,29 +20,30 @@ import java.nio.file.StandardCopyOption
@Service @Service
@Transactional(propagation = Propagation.NEVER)
class FileSystemStorageService( class FileSystemStorageService(
val applicationProperties: ApplicationProperties, val applicationProperties: ApplicationProperties,
) : StorageService { ) : StorageService {
val log: Logger = LoggerFactory.getLogger(FileSystemStorageService::class.java)
lateinit var rootLocation: Path lateinit var rootLocation: Path
@PostConstruct @PostConstruct
fun init(){ fun init(){
val location = applicationProperties.storage.fs.location; val location = applicationProperties.storage.fs.location
if( location.trim().isEmpty() ){ if( location.trim().isEmpty() ){
throw StorageException("File upload location can not be Empty", null) throw StorageException("File upload location can not be Empty", null)
} }
rootLocation = Paths.get(location); rootLocation = Paths.get(location)
} }
override fun storeFile(file: MultipartFile) { override fun storeFile(file: MultipartFile, filename: String) {
storeMultipartFile(file); storeMultipartFile(file, filename)
} }
override fun storeFiles(files: List<MultipartFile>) { override fun storeFiles(files: List<Pair<MultipartFile, String>>) {
for( file in files ){ for( file in files ){
storeMultipartFile(file) storeMultipartFile(file.first, file.second)
} }
} }
@ -64,16 +65,16 @@ class FileSystemStorageService(
FileSystemUtils.deleteRecursively(rootLocation.resolve(filename)) FileSystemUtils.deleteRecursively(rootLocation.resolve(filename))
} }
private fun storeMultipartFile(file: MultipartFile){ private fun storeMultipartFile(file: MultipartFile, filename: String){
try { try {
if (file.isEmpty) { if (file.isEmpty) {
throw StorageException("Failed to store empty file.", null) throw StorageException("Failed to store empty file.", null)
} }
val destinationFile: Path = rootLocation val destinationFile: Path = rootLocation
.resolve(Paths.get(file.originalFilename)) .resolve(Paths.get(filename))
.normalize() .normalize()
.toAbsolutePath() .toAbsolutePath()
if (destinationFile.getParent() != rootLocation.toAbsolutePath()) { if (destinationFile.parent != rootLocation.toAbsolutePath()) {
// This is a security check // This is a security check
throw StorageException("Cannot store file outside current directory.", null) throw StorageException("Cannot store file outside current directory.", null)
} }

View File

@ -5,9 +5,9 @@ import org.springframework.web.multipart.MultipartFile
interface StorageService { interface StorageService {
fun storeFile(file: MultipartFile) fun storeFile(file: MultipartFile, filename: String)
fun storeFiles(files: List<MultipartFile>) fun storeFiles(files: List<Pair<MultipartFile, String>>)
fun getFile(filename: String): Resource fun getFile(filename: String): Resource

View File

@ -13,11 +13,13 @@ import java.util.*
@Mapper(uses = [QuestionMapper::class,AnswerMapper::class]) @Mapper(uses = [QuestionMapper::class,AnswerMapper::class])
abstract class BoardEntryMapper { abstract class BoardEntryMapper {
abstract fun toDto(e: BoardEntry): BoardEntryDto; abstract fun toDto(e: BoardEntry): BoardEntryDto
abstract fun toDto(e: List<BoardEntry>): List<BoardEntryDto>; abstract fun toDto(e: List<BoardEntry>): List<BoardEntryDto>
abstract fun toEntity(d: BoardEntryDto, @Context fileRepository: FileRepository): BoardEntry; abstract fun toEntity(d: BoardEntryDto, @Context fileRepository: FileRepository): BoardEntry
abstract fun toEntity(d: List<BoardEntryDto>, @Context fileRepository: FileRepository): List<BoardEntry>; abstract fun toEntity(d: List<BoardEntryDto>, @Context fileRepository: FileRepository): List<BoardEntry>
abstract fun mapToEntity(d: BoardEntryDto, @MappingTarget e: BoardEntry, @Context fileRepository: FileRepository)
fun map(file: File): String{ fun map(file: File): String{
return file.uuid.toString() return file.uuid.toString()

View File

@ -7,16 +7,28 @@ import at.eisibaer.jbear2.repository.FileRepository
import at.eisibaer.jbear2.repository.UserRepository import at.eisibaer.jbear2.repository.UserRepository
import org.mapstruct.* import org.mapstruct.*
@Mapper(uses = [UserMapper::class,QuestionMapper::class,AnswerMapper::class,BoardEntryMapper::class,CategoryMapper::class]) @Mapper(uses = [QuestionMapper::class,AnswerMapper::class,BoardEntryMapper::class,CategoryMapper::class])
abstract class BoardMapper { abstract class BoardMapper {
abstract fun toDto(e: Board): BoardDto; abstract fun toDto(e: Board): BoardDto
abstract fun toDto(e: List<Board>): List<BoardDto>; abstract fun toDto(e: List<Board>): List<BoardDto>
@Mapping(target = "owner", source = "owner") @Mappings(value = [
@Mapping(target = "id", source = "d.id") Mapping(target = "owner", source = "ownerUser"),
abstract fun toEntity(d: BoardDto, owner: User, @Context userRepository: UserRepository, @Context fileRepository: FileRepository): Board; Mapping(target = "id", source = "d.id"),
abstract fun toEntity(d: List<BoardDto>, @Context owner: User, @Context userRepository: UserRepository, @Context fileRepository: FileRepository): List<Board>; ])
abstract fun toEntity(d: BoardDto, ownerUser: User, @Context userRepository: UserRepository, @Context fileRepository: FileRepository): Board
abstract fun toEntity(d: List<BoardDto>, ownerUser: User, @Context userRepository: UserRepository, @Context fileRepository: FileRepository): ArrayList<Board>
@Mappings(value = [
Mapping(target = "owner", source = "ownerUser"),
Mapping(target = "id", source = "d.id"),
])
abstract fun mapToEntity(@MappingTarget board: Board, d: BoardDto, ownerUser: User, @Context userRepository: UserRepository, @Context fileRepository: FileRepository): Board
fun map(owner: User): String{
return owner.username
}
@AfterMapping @AfterMapping
fun addBoardToCategory(source: BoardDto, @MappingTarget target: Board ): Board{ fun addBoardToCategory(source: BoardDto, @MappingTarget target: Board ): Board{

View File

@ -13,11 +13,11 @@ import java.util.*
@Mapper(uses = [QuestionMapper::class,AnswerMapper::class,BoardEntryMapper::class]) @Mapper(uses = [QuestionMapper::class,AnswerMapper::class,BoardEntryMapper::class])
abstract class CategoryMapper { abstract class CategoryMapper {
abstract fun toDto(e: Category): CategoryDto; abstract fun toDto(e: Category): CategoryDto
abstract fun toDto(e: List<Category>): List<CategoryDto>; abstract fun toDto(e: List<Category>): List<CategoryDto>
abstract fun toEntity(d: CategoryDto, @Context fileRepository: FileRepository): Category; abstract fun toEntity(d: CategoryDto, @Context fileRepository: FileRepository, @Context boardEntryMapper: BoardEntryMapper): Category
abstract fun toEntity(d: List<CategoryDto>, @Context fileRepository: FileRepository): List<Category>; abstract fun toEntity(d: List<CategoryDto>, @Context fileRepository: FileRepository, @Context boardEntryMapper: BoardEntryMapper): List<Category>
fun map(file: File): String{ fun map(file: File): String{
return file.uuid.toString() return file.uuid.toString()

View File

@ -1,20 +1,8 @@
package at.eisibaer.jbear2.service.mapper package at.eisibaer.jbear2.service.mapper
import at.eisibaer.jbear2.dto.game.GameDto import at.eisibaer.jbear2.dto.game.GameDto
import at.eisibaer.jbear2.model.BoardEntry
import at.eisibaer.jbear2.model.Game import at.eisibaer.jbear2.model.Game
import org.mapstruct.Mapper import org.mapstruct.Mapper
@Mapper @Mapper(uses = [PlayerMapper::class])
interface GameMapper : EntityMapper<GameDto, Game> { interface GameMapper : EntityMapper<GameDto, Game>
//
// fun map(boardEntry: BoardEntry): Long{
// return boardEntry.id!!
// }
// fun map(boardEntries: List<BoardEntry>): List<Long>{
// return boardEntries.map { it.id!! }
// }
// fun map(ids: List<Long>): List<BoardEntry>{
// return emptyList()
// }
}

View File

@ -1,29 +0,0 @@
package at.eisibaer.jbear2.service.mapper
import at.eisibaer.jbear2.dto.user.UserDto
import at.eisibaer.jbear2.model.User
import at.eisibaer.jbear2.repository.FileRepository
import at.eisibaer.jbear2.repository.UserRepository
import org.mapstruct.Context
import org.mapstruct.Mapper
import org.springframework.security.core.userdetails.UsernameNotFoundException
@Mapper(uses = [ImageMapper::class, UserMapper::class])
interface UserMapper{
fun toDto(e: User): UserDto;
fun toDto(e: List<User>): List<UserDto>;
fun toEntity(d: UserDto, @Context userRepository: UserRepository, @Context fileRepository: FileRepository): User;
fun toEntity(d: List<UserDto>, @Context userRepository: UserRepository, @Context fileRepository: FileRepository): List<User>;
fun map(e: User): String{
return e.username;
}
fun map(d: String, @Context userRepository: UserRepository): User{
val user = userRepository.findUserByUsername(d)
?: throw UsernameNotFoundException("User not found by username \"$d\"")
return user;
}
}

View File

@ -11,14 +11,14 @@ spring:
dialect: org.hibernate.dialect.PostgreSQLDialect dialect: org.hibernate.dialect.PostgreSQLDialect
default-schema: jeobeardy-app default-schema: jeobeardy-app
open-in-view: false open-in-view: false
docker:
compose:
lifecycle-management: start-only
servlet: servlet:
multipart: multipart:
max-file-size: 5MB max-file-size: 1MB
max-request-size: 50MB max-request-size: 40MB
# session: session:
store-type: jdbc
jdbc:
initialize-schema: always
# redis: # redis:
# flush-mode: on_save # flush-mode: on_save
# namespace: spring:session # namespace: spring:session

View File

@ -48,6 +48,8 @@ $dropdown-link-hover-bg: $dark-accented;
$modal-fade-transform: scale(.75); $modal-fade-transform: scale(.75);
$breadcrumb-divider: quote(">"); $breadcrumb-divider: quote(">");
$border-radius: 0;
// 3. Include remainder of required Bootstrap stylesheets (including any separate color mode stylesheets) // 3. Include remainder of required Bootstrap stylesheets (including any separate color mode stylesheets)
@import "bootstrap/scss/variables"; @import "bootstrap/scss/variables";
@import "bootstrap/scss/variables-dark"; @import "bootstrap/scss/variables-dark";
@ -55,9 +57,6 @@ $breadcrumb-divider: quote(">");
$form-file-button-bg: var(--#{$prefix}secondary-bg); $form-file-button-bg: var(--#{$prefix}secondary-bg);
$form-file-button-hover-bg: var(--#{$prefix}tertiary-bg); $form-file-button-hover-bg: var(--#{$prefix}tertiary-bg);
// $btn-border-radius: 0;
// $card-border-radius: 0;
/* Bootstrap Color Map adjustments */ /* Bootstrap Color Map adjustments */
$custom-colors: ( $custom-colors: (
"gray": $gray-500, "gray": $gray-500,

View File

@ -1,11 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { AnswerType } from '@/models/board/AnswerType';
import type { BoardEntry } from '@/models/board/BoardEntry'; import type { BoardEntry } from '@/models/board/BoardEntry';
import { QuestionType } from '@/models/board/QuestionType';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
const QUESTION_TYPE_SIMPLE_TEXT_ID = 1;
const QUESTION_TYPE_IMAGE_ID = 2;
const QUESTION_TYPE_AUDIO_ID = 3;
const props = defineProps<{ const props = defineProps<{
boardEntry: BoardEntry, boardEntry: BoardEntry,
selectedQuestionIndex: number, selectedQuestionIndex: number,
@ -70,14 +68,24 @@ function stopAudio(){
<div class="col h-100 mx-3 overflow-y-auto"> <div class="col h-100 mx-3 overflow-y-auto">
<div class="ratio ratio-16x9"> <div class="ratio ratio-16x9">
<div class="w-100 h-100 d-flex justify-content-center align-items-center"> <div class="w-100 h-100 d-flex justify-content-center align-items-center">
<template v-if="isQuestionShown"> <template v-if="isAnswerShown && boardEntry.answer.answerType === AnswerType.IMAGE">
<div class="h-75 w-100 d-flex justify-content-center align-items-center">
<img
v-if="boardEntry.answer.image"
:src="boardEntry.answer.image"
alt="User uploaded - No caption available"
class="h-100 w-100 object-contain"
>
</div>
</template>
<template v-else-if="isQuestionShown">
<span v-if="boardEntry.questions.length === 0" class="fs-1"> <span v-if="boardEntry.questions.length === 0" class="fs-1">
No Question to show No Question to show
</span> </span>
<span v-else-if="question.questionType.id === QUESTION_TYPE_SIMPLE_TEXT_ID" class="text-center preserve-breaks" :style="`font-size: ${question.fontScaling}em`"> <span v-else-if="question.questionType === QuestionType.TEXT" class="text-center preserve-breaks" :style="`font-size: ${question.fontScaling}em`">
{{ question.text }} {{ question.text }}
</span> </span>
<template v-else-if="question.questionType.id === QUESTION_TYPE_IMAGE_ID"> <template v-else-if="question.questionType === QuestionType.IMAGE">
<div class="d-flex flex-column justify-content-center align-items-center h-100 w-100"> <div class="d-flex flex-column justify-content-center align-items-center h-100 w-100">
<span class="text-center preserve-breaks" :style="`font-size: ${question.fontScaling}em`"> <span class="text-center preserve-breaks" :style="`font-size: ${question.fontScaling}em`">
{{ question.text }} {{ question.text }}
@ -92,7 +100,7 @@ function stopAudio(){
</div> </div>
</div> </div>
</template> </template>
<template v-else-if="question.questionType.id === QUESTION_TYPE_AUDIO_ID"> <template v-else-if="question.questionType === QuestionType.AUDIO">
<div class="d-flex flex-column justify-content-center align-items-center h-100 w-100"> <div class="d-flex flex-column justify-content-center align-items-center h-100 w-100">
<span class="text-center preserve-breaks" :style="`font-size: ${question.fontScaling}em`"> <span class="text-center preserve-breaks" :style="`font-size: ${question.fontScaling}em`">
{{ question.text }} {{ question.text }}
@ -121,21 +129,27 @@ function stopAudio(){
</span> </span>
</div> </div>
<!-- Points --> <!-- Points -->
<div class="position-absolute top-0 end-0"> <div class="position-absolute bottom-0 end-0">
<span class="fs-2"> <span class="fs-2">
{{ boardEntry.points }} {{ boardEntry.points }}
</span> </span>
</div> </div>
<!-- title -->
<div class="position-absolute top-0 start-50 translate-middle-x">
<span class="fs-2">
{{ boardEntry.name }}
</span>
</div>
<!-- Back to Board --> <!-- Back to Board -->
<div class="position-absolute bottom-0 end-0"> <div class="position-absolute top-0 start-0">
<div> <div>
<button class="btn btn-outline-primary" @click="backToBoard"> <button class="btn btn-outline-primary mt-2" @click="backToBoard">
Back to Board Back to Board
</button> </button>
</div> </div>
</div> </div>
<!-- Back to Board --> <!-- Back to Board -->
<div class="position-absolute top-0 start-50 translate-middle-x"> <div class="position-absolute top-0 end-0">
<div class="mt-2"> <div class="mt-2">
<button v-if="props.isQuestionShown" class="btn btn-outline-primary me-3" @click="hideQuestion"> <button v-if="props.isQuestionShown" class="btn btn-outline-primary me-3" @click="hideQuestion">
Hide Question Hide Question
@ -151,9 +165,9 @@ function stopAudio(){
</button> </button>
</div> </div>
</div> </div>
<!-- Answer --> <!-- Answer text -->
<div v-if="isAnswerShown" class="position-absolute bottom-0 start-50 translate-middle-x mb-2"> <div v-if="isAnswerShown" class="position-absolute bottom-0 start-50 translate-middle-x mb-2">
<div class="bg-primary p-2 rounded bg-opacity-50 fs-4 text-center"> <div class="bg-primary p-2 rounded fs-4 text-center">
Answer:<br>{{ boardEntry.answer.text }} Answer:<br>{{ boardEntry.answer.text }}
</div> </div>
</div> </div>

View File

@ -13,13 +13,13 @@ const router = useRouter();
const boards = ref<Array<Board>>([]); const boards = ref<Array<Board>>([]);
function editBoard(board: Board){ function editBoard(board: Board) {
router.push({ router.push({
name: 'create', name: 'create',
params: { params: {
boardId: board.id, boardId: board.id,
} },
}) });
} }
function createNewBoard() { function createNewBoard() {
@ -53,18 +53,28 @@ userService
<template v-for="board in boards" :key="board.id"> <template v-for="board in boards" :key="board.id">
<div class="col-4 mb-3"> <div class="col-4 mb-3">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header position-relative">
{{ board.boardName }} {{ board.boardName }}
<button
class="d-hover position-absolute top-50 translate-middle-y end-0 me-1 btn btn-sm btn-outline-danger"
>
<!-- TODO -->
<FontAwesomeIcon :icon="['fas', 'trash']" />
</button>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<div class="d-flex justify-content-around align-items-center"> <div class="d-flex justify-content-around align-items-center">
<button class="btn btn-sm btn-primary" @click="editBoard(board)"> <button
class="btn btn-sm btn-primary"
@click="editBoard(board)"
>
<FontAwesomeIcon :icon="['fas', 'edit']" /> <FontAwesomeIcon :icon="['fas', 'edit']" />
Edit Edit
</button> </button>
<button class="btn btn-sm btn-primary"> <button class="btn btn-sm btn-primary">
<!-- TODO -->
<FontAwesomeIcon :icon="['fas', 'play']" /> <FontAwesomeIcon :icon="['fas', 'play']" />
Play Play
</button> </button>
@ -89,8 +99,17 @@ userService
</template> </template>
<style scoped> <style scoped>
.spinny-size{ .spinny-size {
max-width: 6.66em; max-width: 6.66em;
max-height: 6.66em; max-height: 6.66em;
} }
</style> .d-hover {
display: initial;
opacity: 0%;
transition: opacity ease 0.33s;
}
div:hover > div > .d-hover {
opacity: 100%;
display: initial;
}
</style>

View File

@ -59,6 +59,15 @@ function moveQuestionDown( index: number ) {
boardEntry.value.questions[index + 1] = tmp; boardEntry.value.questions[index + 1] = tmp;
} }
function newImageUploaded( event: Event ) {
const element = event.currentTarget as HTMLInputElement;
let files = element.files;
if( files === null || files.length === 0 ) {
return;
}
boardEntry.value.answer.image = URL.createObjectURL(files[0]);
}
function openBoard() { function openBoard() {
emit("editBoard"); emit("editBoard");
} }
@ -191,6 +200,16 @@ function openQuestion(categoryIndex: number, boardEntryIndex: number, questionIn
<label class="mt-2" for="answer-text">{{ t( 'board.answer.text' ) }}</label> <label class="mt-2" for="answer-text">{{ t( 'board.answer.text' ) }}</label>
<textarea id="answer-text" v-model="boardEntry.answer.text" class="form-control mb-2" <textarea id="answer-text" v-model="boardEntry.answer.text" class="form-control mb-2"
:placeholder="t( 'board.answer.text' )"></textarea> :placeholder="t( 'board.answer.text' )"></textarea>
<template v-if="boardEntry.answer.answerType === AnswerType.IMAGE">
<label for="answer-image-input">{{ t( 'board.question.upload.image' ) }}</label>
<input
id="answer-image-input"
type="file"
class="form-control mb-2"
@change="newImageUploaded"
accept="image/png, image/jpeg"
>
</template>
</div> </div>
</div> </div>
</div> </div>

View File

@ -3,6 +3,8 @@ import type { Board } from '@/models/board/Board';
import { computed } from 'vue'; import { computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { QuestionType } from '@/models/board/QuestionType'; import { QuestionType } from '@/models/board/QuestionType';
import { useFileStore } from '@/stores/FileStore';
import { getFileKeyForQuestion } from '@/services/UtilService';
const { t } = useI18n(); const { t } = useI18n();
@ -34,12 +36,14 @@ function openBoardEntry( categoryIndex: number, boardEntryIndex: number ) {
emit( "editBoardEntry", categoryIndex, boardEntryIndex ); emit( "editBoardEntry", categoryIndex, boardEntryIndex );
} }
const fileStore = useFileStore();
function newImageUploaded( event: Event ) { function newImageUploaded( event: Event ) {
const element = event.currentTarget as HTMLInputElement; const element = event.currentTarget as HTMLInputElement;
let files = element.files; let files = element.files;
if( files === null || files.length === 0 ) { if( files === null || files.length === 0 ) {
return; return;
} }
fileStore.addFile(files[0], getFileKeyForQuestion(props.categoryIndex, props.boardEntryIndex, props.questionIndex));
question.value.image = URL.createObjectURL(files[0]); question.value.image = URL.createObjectURL(files[0]);
} }
function newAudioUploaded( event: Event ) { function newAudioUploaded( event: Event ) {
@ -48,6 +52,7 @@ function newAudioUploaded( event: Event ) {
if( files === null || files.length === 0 ) { if( files === null || files.length === 0 ) {
return; return;
} }
fileStore.addFile(files[0], getFileKeyForQuestion(props.categoryIndex, props.boardEntryIndex, props.questionIndex));
question.value.audio = URL.createObjectURL(files[0]); question.value.audio = URL.createObjectURL(files[0]);
} }
</script> </script>

View File

@ -11,6 +11,7 @@ import CreatePanel from '@/components/blocks/CreatePanel.vue';
import BoardEntryView from '@/components/blocks/BoardEntryView.vue'; import BoardEntryView from '@/components/blocks/BoardEntryView.vue';
import { userService } from '@/services/UserService'; import { userService } from '@/services/UserService';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { useFileStore } from '@/stores/FileStore';
const navbar = inject<Ref<InstanceType<typeof NavBar> | null>>(navbarKey); const navbar = inject<Ref<InstanceType<typeof NavBar> | null>>(navbarKey);
const navbarHeight = computed(() => { const navbarHeight = computed(() => {
@ -65,14 +66,20 @@ function hideAnswer() {
isAnswerShown.value = false; isAnswerShown.value = false;
} }
const fileStore = useFileStore();
const savingBoardInProgress = ref(false); const savingBoardInProgress = ref(false);
function saveBoard() { function saveBoard() {
savingBoardInProgress.value = true; savingBoardInProgress.value = true;
const formData: FormData = new FormData();
formData.set('board', new Blob([JSON.stringify(board.value)], { 'type': 'application/json' } ));
for( const file of fileStore.files ){
formData.append('files', file.data, `${file.category}-${file.boardEntry}-${file.answer ? 'answer' : file.question!}`);
}
let savePromise; let savePromise;
if( board.value.id ){ if( board.value.id ){
savePromise = userService.updateBoard(board.value); savePromise = userService.updateBoard(board.value, formData);
} else { } else {
savePromise = userService.saveNewBoard(board.value); savePromise = userService.saveNewBoard(formData);
} }
savePromise savePromise
.then((savedBoard) => { .then((savedBoard) => {

View File

@ -48,10 +48,10 @@ class UserService {
}); });
}); });
} }
saveNewBoard(board: Board): Promise<Board> { saveNewBoard(formData: FormData): Promise<Board> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
axios axios
.post(`${ENV.API_BASE_URL}/user/boards`, board, { .post(`${ENV.API_BASE_URL}/user/boards`, formData, {
withCredentials: true, withCredentials: true,
}) })
.then((response) => { .then((response) => {
@ -62,13 +62,13 @@ class UserService {
}); });
}); });
} }
updateBoard(board: Board): Promise<Board> { updateBoard(board: Board, formData: FormData): Promise<Board> {
if( board.id === undefined ){ if( board.id === undefined ){
throw new Error("New board cant be updated"); throw new Error("New board cant be updated");
} }
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
axios axios
.put(`${ENV.API_BASE_URL}/user/boards/${board.id}`, board, { .put(`${ENV.API_BASE_URL}/user/boards/${board.id}`, formData, {
withCredentials: true, withCredentials: true,
}) })
.then((response) => { .then((response) => {

View File

@ -2,4 +2,30 @@ import type { InjectionKey, Ref } from 'vue';
import type NavBar from '@/components/blocks/NavBar.vue'; import type NavBar from '@/components/blocks/NavBar.vue';
export const infoModalShowFnKey = Symbol() as InjectionKey<Function>; export const infoModalShowFnKey = Symbol() as InjectionKey<Function>;
export const navbarKey = Symbol() as InjectionKey<Ref<InstanceType<typeof NavBar> | undefined>>; export const navbarKey = Symbol() as InjectionKey<Ref<InstanceType<typeof NavBar> | undefined>>;
export interface FileAssociation {
category: number,
boardEntry: number,
answer: boolean,
question?: number,
}
export interface FileAssociationWFile extends FileAssociation {
data: File
}
export function getFileKeyForQuestion(categoryIndex: number, boardEntryIndex: number, questionIndex: number): FileAssociation {
return {
category: categoryIndex,
boardEntry: boardEntryIndex,
answer: false,
question: questionIndex,
};
}
export function getFileKeyForAnswer(categoryIndex: number, boardEntryIndex: number): FileAssociation {
return {
category: categoryIndex,
boardEntry: boardEntryIndex,
answer: true,
};
}

View File

@ -0,0 +1,45 @@
import { ref } from 'vue';
import { defineStore } from 'pinia';
import type { FileAssociation, FileAssociationWFile } from '@/services/UtilService';
export const useFileStore = defineStore('file', () => {
const files = ref<Array<FileAssociationWFile>>([]);
function addFile(file: File, fileAssociation: FileAssociation) {
const fileWData = {
...fileAssociation,
data: file,
};
const idx = files.value.findIndex(x =>
x.category === fileAssociation.category &&
x.boardEntry === fileAssociation.boardEntry &&
x.answer === fileAssociation.answer &&
x.question === x.question
);
if( idx === -1 ){
files.value.splice(idx, 1, fileWData);
} else {
files.value.push(fileWData);
}
}
function deleteFile(fileAssociation: FileAssociation) {
const idxToDel = files.value.findIndex(x =>
x.category === fileAssociation.category &&
x.boardEntry === fileAssociation.boardEntry &&
x.answer === fileAssociation.answer &&
x.question === x.question
);
if(idxToDel !== -1){
files.value.splice(idxToDel, 1);
}
}
return {
// state
files,
// functions
addFile,
deleteFile,
}
});

View File

@ -5,7 +5,11 @@ import type { Board } from '@/models/board/Board';
import { userService } from '@/services/UserService'; import { userService } from '@/services/UserService';
import { Player } from '@/models/game/Player'; import { Player } from '@/models/game/Player';
import { Client as StompClient } from '@stomp/stompjs'; import { Client as StompClient } from '@stomp/stompjs';
import { GAME_STATUS_CONST, gameService, SESSION_GAME_KEY } from '@/services/GameService'; import {
GAME_STATUS_CONST,
gameService,
SESSION_GAME_KEY,
} from '@/services/GameService';
import type { Game } from '@/models/game/Game'; import type { Game } from '@/models/game/Game';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import type { GameMessage } from '@/models/dto/messages/GameMessage'; import type { GameMessage } from '@/models/dto/messages/GameMessage';
@ -16,9 +20,9 @@ import type { PlayerChoice } from '@/models/game/PlayerChoice';
export const useGameStore = defineStore('game', () => { export const useGameStore = defineStore('game', () => {
const board = ref<Board | null>(null); const board = ref<Board | null>(null);
const players = ref<Array<Player>>([]); const players = ref<Array<Player>>([]);
const playerCount = computed( () => { const playerCount = computed(() => {
return players.value.length; return players.value.length;
}) });
const hostUsername = ref(''); const hostUsername = ref('');
const isHost = ref(false); const isHost = ref(false);
const self = ref<Player | null>(null); const self = ref<Player | null>(null);
@ -54,7 +58,11 @@ export const useGameStore = defineStore('game', () => {
board.value = b; board.value = b;
} }
function setGameProperties(game: Game, isHostAttr: boolean, playerSelf: Player | null){ function setGameProperties(
game: Game,
isHostAttr: boolean,
playerSelf: Player | null,
) {
gameUuid = game.uuid; gameUuid = game.uuid;
gameInviteCode.value = game.inviteCode; gameInviteCode.value = game.inviteCode;
players.value = game.players; players.value = game.players;
@ -75,7 +83,7 @@ export const useGameStore = defineStore('game', () => {
setGameProperties(game, true, null); setGameProperties(game, true, null);
return connect(); return connect();
}) })
.then( () => { .then(() => {
resolve(retGame); resolve(retGame);
}) })
.catch((error: AxiosError) => { .catch((error: AxiosError) => {
@ -85,9 +93,9 @@ export const useGameStore = defineStore('game', () => {
}); });
} }
function checkForGame(): Game | null{ function checkForGame(): Game | null {
const gameJson = sessionStorage.getItem(SESSION_GAME_KEY) const gameJson = sessionStorage.getItem(SESSION_GAME_KEY);
if( gameJson === null ){ if (gameJson === null) {
return null; return null;
} else { } else {
return JSON.parse(gameJson) as Game; return JSON.parse(gameJson) as Game;
@ -100,12 +108,12 @@ export const useGameStore = defineStore('game', () => {
gameService gameService
.getGameByInviteCodeOrUuid(inviteCode) .getGameByInviteCodeOrUuid(inviteCode)
.then((game) => { .then((game) => {
retGame = game retGame = game;
sessionStorage.setItem(SESSION_GAME_KEY, JSON.stringify(game)) sessionStorage.setItem(SESSION_GAME_KEY, JSON.stringify(game));
setGameProperties(game, false, null); setGameProperties(game, false, null);
return connect(); return connect();
}) })
.then( () => { .then(() => {
resolve(retGame); resolve(retGame);
}) })
.catch((error: AxiosError) => { .catch((error: AxiosError) => {
@ -115,7 +123,7 @@ export const useGameStore = defineStore('game', () => {
}); });
} }
function joinGame(playerName: string): Promise<void> { function joinGame(playerName: string): Promise<void> {
if( gameStatus.value === GAME_STATUS_CONST.nA ){ if (gameStatus.value === GAME_STATUS_CONST.nA) {
return Promise.reject(); return Promise.reject();
} }
return new Promise<void>((resolve, _reject) => { return new Promise<void>((resolve, _reject) => {
@ -134,9 +142,9 @@ export const useGameStore = defineStore('game', () => {
const stompClient = new StompClient({ const stompClient = new StompClient({
brokerURL: 'ws://localhost:8008/ws', brokerURL: 'ws://localhost:8008/ws',
}); });
const isConnected = computed( () => { const isConnected = computed(() => {
return stompClient.connected; return stompClient.connected;
}) });
stompClient.onStompError = (frame) => { stompClient.onStompError = (frame) => {
console.error('Broker reported error: ' + frame.headers['message']); console.error('Broker reported error: ' + frame.headers['message']);
@ -144,7 +152,7 @@ export const useGameStore = defineStore('game', () => {
}; };
function connect(): Promise<void> { function connect(): Promise<void> {
return new Promise<void>(( resolve, reject ) => { return new Promise<void>((resolve, reject) => {
stompClient.onConnect = (_frame) => { stompClient.onConnect = (_frame) => {
connected.value = true; connected.value = true;
subscribeToCommonRoutes(); subscribeToCommonRoutes();
@ -152,10 +160,10 @@ export const useGameStore = defineStore('game', () => {
}; };
stompClient.onWebSocketError = (error) => { stompClient.onWebSocketError = (error) => {
console.error('Error with websocket', error); console.error('Error with websocket', error);
reject() reject();
}; };
stompClient.activate(); stompClient.activate();
}) });
} }
function subscribeToCommonRoutes() { function subscribeToCommonRoutes() {
@ -170,112 +178,149 @@ export const useGameStore = defineStore('game', () => {
players.value.push(JSON.parse(message.body) as Player); players.value.push(JSON.parse(message.body) as Player);
}); });
stompClient.subscribe(`/topic/game/${gameUuid}/started`, (message) => { stompClient.subscribe(`/topic/game/${gameUuid}/started`, (message) => {
const gameMessage = JSON.parse(message.body) as GameMessage const gameMessage = JSON.parse(message.body) as GameMessage;
if( gameMessage.uuid !== gameUuid ){ if (gameMessage.uuid !== gameUuid) {
throw new Error("Got event for different Game!"); throw new Error('Got event for different Game!');
} }
router.push({name: 'game', params: { uuid: gameUuid }}) router.push({ name: 'game', params: { uuid: gameUuid } });
}); });
stompClient.subscribe(`/topic/game/${gameUuid}/boardentry/selected`, (message) => { stompClient.subscribe(
const messageBody = JSON.parse(message.body); `/topic/game/${gameUuid}/boardentry/selected`,
if( !isHost.value ){ (message) => {
showingAnswer.value = false; const messageBody = JSON.parse(message.body);
showingQuestion.value = false; if (!isHost.value) {
acceptAnswers.value = true; showingAnswer.value = false;
} else { showingQuestion.value = false;
currentQuestion.value = messageBody.question; acceptAnswers.value = true;
}
});
stompClient.subscribe(`/topic/game/${gameUuid}/board/selected`, (message) => {
if( !isHost.value ){
showingAnswer.value = false;
showingQuestion.value = false;
acceptAnswers.value = false;
}
for( const i in players.value ){
players.value[i].answerText = '';
}
categoryIndex.value = null;
boardEntryIndex.value = null;
questionIndex.value = null;
});
stompClient.subscribe(`/topic/game/${gameUuid}/points/adjusted`, (message) => {
const messageBody = JSON.parse(message.body);
const playerIndex = players.value.findIndex( playerEntry => playerEntry.uuid === messageBody.playerUuid );
players.value[playerIndex].points = messageBody.adjustedPoints;
});
stompClient.subscribe(`/topic/game/${gameUuid}/player/answer/revealed`, (message) => {
currentAnswer.value = JSON.parse(message.body) as Answer;
});
stompClient.subscribe(`/topic/game/${gameUuid}/player/answer/update`, (message) => {
const messageBody = JSON.parse(message.body);
const playerIndex = players.value.findIndex( playerEntry => playerEntry.uuid === messageBody.playerUuid );
players.value[playerIndex].answerText = messageBody.answerText;
});
stompClient.subscribe(`/topic/game/${gameUuid}/player/buzzered`, (message) => {
const messageBody = JSON.parse(message.body);
for( const i in players.value ){
if( players.value[i].uuid === messageBody.playerUuid ){
players.value[i].isAnswering = true;
//TODO Play Buzzer sound!
} else { } else {
players.value[i].isAnswering = false; currentQuestion.value = messageBody.question;
} }
} },
acceptAnswers.value = false; );
//TODO if( audioInstance ) If audio was playing - stop! stompClient.subscribe(
}); `/topic/game/${gameUuid}/board/selected`,
(message) => {
if (!isHost.value) {
showingAnswer.value = false;
showingQuestion.value = false;
acceptAnswers.value = false;
}
for (const i in players.value) {
players.value[i].answerText = '';
}
categoryIndex.value = null;
boardEntryIndex.value = null;
questionIndex.value = null;
},
);
stompClient.subscribe(
`/topic/game/${gameUuid}/points/adjusted`,
(message) => {
const messageBody = JSON.parse(message.body);
const playerIndex = players.value.findIndex(
(playerEntry) => playerEntry.uuid === messageBody.playerUuid,
);
players.value[playerIndex].points = messageBody.adjustedPoints;
},
);
stompClient.subscribe(
`/topic/game/${gameUuid}/player/answer/revealed`,
(message) => {
currentAnswer.value = JSON.parse(message.body) as Answer;
},
);
stompClient.subscribe(
`/topic/game/${gameUuid}/player/answer/update`,
(message) => {
const messageBody = JSON.parse(message.body);
const playerIndex = players.value.findIndex(
(playerEntry) => playerEntry.uuid === messageBody.playerUuid,
);
players.value[playerIndex].answerText = messageBody.answerText;
},
);
stompClient.subscribe(
`/topic/game/${gameUuid}/player/buzzered`,
(message) => {
const messageBody = JSON.parse(message.body);
for (const i in players.value) {
if (players.value[i].uuid === messageBody.playerUuid) {
players.value[i].isAnswering = true;
//TODO Play Buzzer sound!
} else {
players.value[i].isAnswering = false;
}
}
acceptAnswers.value = false;
//TODO if( audioInstance ) If audio was playing - stop!
},
);
stompClient.subscribe(`/topic/game/${gameUuid}/player/chose`, (message) => { stompClient.subscribe(`/topic/game/${gameUuid}/player/chose`, (message) => {
const messageBody = JSON.parse(message.body) as null | PlayerChoice; const messageBody = JSON.parse(message.body) as null | PlayerChoice;
currentPlayerChoice.value = messageBody; currentPlayerChoice.value = messageBody;
}); });
stompClient.subscribe(`/topic/game/${gameUuid}/player/canchoose`, (message) => { stompClient.subscribe(
}); `/topic/game/${gameUuid}/player/canchoose`,
(message) => {},
);
stompClient.subscribe(`/topic/game/${gameUuid}/player/left`, (message) => { stompClient.subscribe(`/topic/game/${gameUuid}/player/left`, (message) => {
const messageBody = JSON.parse(message.body); const messageBody = JSON.parse(message.body);
const playerIndex = players.value.findIndex( playerEntry => playerEntry.uuid === messageBody.playerUuid ); const playerIndex = players.value.findIndex(
(playerEntry) => playerEntry.uuid === messageBody.playerUuid,
);
players.value.splice(playerIndex, 1); players.value.splice(playerIndex, 1);
}); });
stompClient.subscribe(`/topic/game/${gameUuid}/question/audio/playing`, (message) => { stompClient.subscribe(
`/topic/game/${gameUuid}/question/audio/playing`,
}); (message) => {},
stompClient.subscribe(`/topic/game/${gameUuid}/question/audio/stopped`, (message) => { );
stompClient.subscribe(
}); `/topic/game/${gameUuid}/question/audio/stopped`,
stompClient.subscribe(`/topic/game/${gameUuid}/question/revealed`, (message) => { (message) => {},
currentQuestion.value = JSON.parse(message.body) as Question; );
}); stompClient.subscribe(
stompClient.subscribe(`/topic/game/${gameUuid}/question/locked`, (message) => { `/topic/game/${gameUuid}/question/revealed`,
(message) => {
}); currentQuestion.value = JSON.parse(message.body) as Question;
stompClient.subscribe(`/topic/game/${gameUuid}/question/status/update`, (message) => { },
);
}); stompClient.subscribe(
stompClient.subscribe(`/topic/game/${gameUuid}/question/hidden`, (message) => { `/topic/game/${gameUuid}/question/locked`,
(message) => {},
}); );
stompClient.subscribe(`/topic/game/${gameUuid}/questionlayer/selected`, (message) => { stompClient.subscribe(
`/topic/game/${gameUuid}/question/status/update`,
}); (message) => {},
stompClient.subscribe(`/topic/game/${gameUuid}/answer/revealed`, (message) => { );
stompClient.subscribe(
}); `/topic/game/${gameUuid}/question/hidden`,
stompClient.subscribe(`/topic/game/${gameUuid}/answer/hidden`, (message) => { (message) => {},
);
}); stompClient.subscribe(
`/topic/game/${gameUuid}/questionlayer/selected`,
(message) => {},
);
stompClient.subscribe(
`/topic/game/${gameUuid}/answer/revealed`,
(message) => {},
);
stompClient.subscribe(
`/topic/game/${gameUuid}/answer/hidden`,
(message) => {},
);
} }
function disconnect() { function disconnect() {
stompClient.deactivate(); stompClient.deactivate();
connected.value = false; connected.value = false;
} }
function startGame() { function startGame() {
if( !isHost.value ){ if (!isHost.value) {
return; return;
} }
const message = { const message = {
content: 'start' content: 'start',
}; };
stompClient.publish({ stompClient.publish({
destination: `/app/host/game/${gameUuid}/start`, destination: `/app/host/game/${gameUuid}/start`,