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 {
id("org.springframework.boot") version "3.3.0"
id("io.spring.dependency-management") version "1.1.5"
kotlin("plugin.jpa") version "1.9.24"
kotlin("jvm") version "1.9.24"
kotlin("plugin.spring") version "1.9.24"
kotlin("plugin.allopen") version "1.9.22"
kotlin("kapt") version "1.9.10"
id("org.springframework.boot") version "3.5.12"
id("io.spring.dependency-management") version "1.1.7"
kotlin("plugin.jpa") version "2.3.20"
kotlin("jvm") version "2.3.20"
kotlin("plugin.spring") version "2.3.20"
kotlin("plugin.allopen") version "2.3.20"
kotlin("kapt") version "2.3.20"
}
group = "at.eisibaer"
@ -28,20 +28,19 @@ repositories {
}
val bcVersion: String = "1.78.1"
val mapstructVersion: String = "1.6.0"
val mapstructVersion: String = "1.7.0.Beta1"
dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-websocket")
implementation("org.springframework.boot:spring-boot-docker-compose")
implementation("org.springframework.boot:spring-boot-starter-data-redis")
implementation("org.springframework.session:spring-session-data-redis")
implementation("org.springframework.session:spring-session-jdbc")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.bouncycastle:bcprov-jdk18on:$bcVersion")
implementation("org.mapstruct:mapstruct:$mapstructVersion")
implementation("org.jetbrains.kotlin:kotlin-metadata-jvm:2.3.20")
compileOnly("org.projectlombok:lombok")
developmentOnly("org.springframework.boot:spring-boot-devtools")
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.springframework.http.ResponseEntity
import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.authentication.BadCredentialsException
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.crypto.password.PasswordEncoder
import org.springframework.stereotype.Controller
@ -31,24 +28,24 @@ class AuthEndpoint(
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 strAlreadyLoggedIn: String = "User already logged in";
val strResponseSuccess: String = "Sending back success response"
val strAlreadyLoggedIn: String = "User already logged in"
@PostMapping("/signup")
fun signupUser(@RequestBody loginDto: LoginDto, session: HttpSession): ResponseEntity<*>{
log.info("Endpoint signupUser called");
log.debug("signup Request with username: {}", loginDto.username);
log.info("Endpoint signupUser called")
log.debug("signup Request with username: {}", loginDto.username)
if( userRepository.existsByUsername(loginDto.username)){
log.info("Username was already taken");
return ResponseEntity.badRequest().body("Username already taken");
log.info("Username was 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(
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);
return ResponseEntity.ok().body(LoginResponseDto(userDetails.username, userDetails.getProfilePictureFilename()));
log.info(strResponseSuccess)
return ResponseEntity.ok().body(LoginResponseDto(userDetails.username, userDetails.getProfilePictureFilename()))
}
@PostMapping("/login")
fun loginUser(@RequestBody loginDto: LoginDto, session: HttpSession): ResponseEntity<*>{
log.info("Endpoint loginUser called");
log.debug("login Request with username: {}", loginDto.username);
log.info("Endpoint loginUser called")
log.debug("login Request with username: {}", loginDto.username)
if( session.getAttribute(STR_SESSION_USER_KEY) != null ){
log.info(strAlreadyLoggedIn);
return ResponseEntity.badRequest().body(strAlreadyLoggedIn);
log.info(strAlreadyLoggedIn)
return ResponseEntity.badRequest().body(strAlreadyLoggedIn)
}
val authentication: Authentication;
try{
authentication = authenticationManager.authenticate(
val authentication = authenticationManager.authenticate(
UsernamePasswordAuthenticationToken(
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()
.body( LoginResponseDto(userDetails.username, userDetails.getProfilePictureFilename()))
}
@PostMapping("/logout")
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()
.body("Logged out");
.body("Logged out")
}
@GetMapping("/status")
fun checkStatus(session: HttpSession): ResponseEntity<*>{
log.info("Endpoint checkStatus called");
log.info("Endpoint checkStatus called")
return if( session.getAttribute(STR_SESSION_USER_KEY) != null ){
log.info(strAlreadyLoggedIn);
val sessionUser: UserDetailsImpl = session.getAttribute(STR_SESSION_USER_KEY) as UserDetailsImpl;
log.info(strAlreadyLoggedIn)
val sessionUser: UserDetailsImpl = session.getAttribute(STR_SESSION_USER_KEY) as UserDetailsImpl
ResponseEntity
.ok()
.body( LoginResponseDto( sessionUser.username, sessionUser.getProfilePictureFilename() ) );
.body( LoginResponseDto( sessionUser.username, sessionUser.getProfilePictureFilename() ) )
} else {
log.debug("No user logged in");
log.info(strResponseSuccess);
log.debug("No user logged in")
log.info(strResponseSuccess)
ResponseEntity
.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.userdetails.UsernameNotFoundException
import org.springframework.web.bind.annotation.*
import org.springframework.web.multipart.MultipartFile
@RestController
@RequestMapping("/api/user")
@ -21,65 +22,65 @@ class UserEndpoint(
val fileRepository: FileRepository,
) {
val log: Logger = LoggerFactory.getLogger(UserEndpoint::class.java);
val log: Logger = LoggerFactory.getLogger(UserEndpoint::class.java)
@GetMapping("/test/{pathVar}")
fun testEndpoint(@PathVariable pathVar: String, @RequestParam param1: String): ResponseEntity<String>{
log.info("test Endpoint!");
return ResponseEntity.ok(param1);
log.info("test Endpoint!")
return ResponseEntity.ok(param1)
}
@GetMapping("/boards")
fun getBoards(): ResponseEntity<List<BoardDto>?>{
log.info("Get all Board Endpoint");
val userDetails = SecurityContextHolder.getContext().authentication.principal as UserDetailsImpl;
val user: User = userRepository.findUserByUsername(userDetails.username) ?: throw UsernameNotFoundException("Username ${userDetails.username} not found in DB");
log.info("Get all Board Endpoint")
val userDetails = SecurityContextHolder.getContext().authentication.principal as UserDetailsImpl
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}")
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 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)
}
@PostMapping("/boards")
fun saveNewBoard(@RequestBody boardDto: BoardDto): ResponseEntity<*> {
log.info("Post Board Endpoint");
fun saveNewBoard(@RequestPart("files") files: List<MultipartFile>, @RequestPart("board") boardDto: BoardDto): ResponseEntity<*> {
log.info("Post Board Endpoint")
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 ){
return ResponseEntity.badRequest().body(Unit);
return ResponseEntity.badRequest().body(Unit)
}
return boardService.saveBoardToUser( user, boardDto );
return boardService.saveBoardToUser( user, boardDto, files )
}
@PutMapping("/boards/{boardId}")
fun saveExistingBoard(@RequestBody boardDto: BoardDto, @PathVariable boardId: Long): ResponseEntity<*> {
log.info("Put Board Endpoint with id {}", boardId);
fun saveExistingBoard(@RequestPart("files") files: List<MultipartFile>, @RequestPart("board") boardDto: BoardDto, @PathVariable boardId: Long): ResponseEntity<*> {
log.info("Put Board Endpoint with id {}", boardId)
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 ){
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}")
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 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
import at.eisibaer.jbear2.dto.board.QuestionDto
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.Player
import at.eisibaer.jbear2.model.User
@ -20,18 +17,13 @@ import at.eisibaer.jbear2.util.RandomString
import jakarta.transaction.Transactional
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.http.ResponseEntity
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.MessageMapping
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.annotation.SendToUser
import org.springframework.messaging.simp.annotation.SubscribeMapping
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.core.userdetails.UsernameNotFoundException
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.RequestBody
import java.lang.Exception
import java.security.Principal
import java.util.*
import kotlin.NoSuchElementException
import kotlin.collections.ArrayList
@Controller
class GameEndpoint(
@ -59,16 +49,16 @@ class GameEndpoint(
val messagingTemplate: SimpMessagingTemplate,
) {
private val log: Logger = LoggerFactory.getLogger(GameEndpoint::class.java);
private val log: Logger = LoggerFactory.getLogger(GameEndpoint::class.java)
@Transactional
@GetMapping("/api/games/{id}")
fun getGameByInviteCode(@PathVariable id: String): ResponseEntity<*>{
val game: Game;
val game: Game
if( id.length == INVITE_CODE_LENGTH){
game = gameRepository.findByInviteCode(id) ?: return ResponseEntity.status(404).body("Game with invide code $id not found")
} else {
var uuid: UUID;
var uuid: UUID
try{
uuid = UUID.fromString(id)
}catch (exception: IllegalArgumentException){
@ -76,24 +66,24 @@ class GameEndpoint(
}
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
@PostMapping("/api/games")
fun hostNewGame(@RequestBody body: String?): ResponseEntity<*>{
log.info("Start new game");
val userDetails = SecurityContextHolder.getContext().authentication.principal as UserDetailsImpl;
val user: User = userRepository.findUserByUsername(userDetails.username) ?: throw UsernameNotFoundException("Username ${userDetails.username} not found in DB");
log.info("Start new game")
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 firstAvailableBoard = boardRepository.findFirstByOwnerOrderByBoardName(user) ?: return ResponseEntity.status(404).body("No board found for user")
gameRepository.deleteAll();
gameRepository.deleteAll()
gameRepository.flush()
val game = Game(RandomString.ofLength(8), UUID.randomUUID(), firstAvailableBoard, user, emptyList(), emptyList(), null )
val savedGame = gameRepository.save(game);
return ResponseEntity.ok().body(gameMapper.toDto(savedGame));
val savedGame = gameRepository.save(game)
return ResponseEntity.ok().body(gameMapper.toDto(savedGame))
}
// @MessageMapping("/join")
@ -138,11 +128,11 @@ class GameEndpoint(
@MessageMapping("/join/{id}")
fun playerJoining(@Payload joinMessage: GenericMessage, @DestinationVariable id: String){
val game: Game;
val game: Game
if( id.length == INVITE_CODE_LENGTH){
game = gameRepository.findByInviteCode(id) ?: throw NoSuchElementException("Game with invide code $id not found")
} else {
var uuid: UUID;
var uuid: UUID
try{
uuid = UUID.fromString(id)
}catch (exception: IllegalArgumentException){
@ -150,22 +140,22 @@ class GameEndpoint(
}
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 payload = playerMapper.toDto(savedPlayer);
messagingTemplate.convertAndSend("/topic/game/${game.uuid}/joined", payload);
val payload = playerMapper.toDto(savedPlayer)
messagingTemplate.convertAndSend("/topic/game/${game.uuid}/joined", payload)
}
@MessageMapping("/host/game/{uuid}/start")
fun startGame(@Payload startMessage: GenericMessage, @DestinationVariable uuid: UUID){
// 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")
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")
@ -176,9 +166,9 @@ class GameEndpoint(
targetGameState.boardEntryIndex,
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")
@ -189,19 +179,19 @@ class GameEndpoint(
targetGameState.boardEntryIndex,
targetGameState.questionIndex,
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")
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")
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")
@ -211,21 +201,21 @@ class GameEndpoint(
gameState.boardEntryIndex == 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 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);
messagingTemplate.convertAndSend("/topic/game/$uuid/question/revealed", questionDto);
val questionDto = questionMapper.toDto(question)
messagingTemplate.convertAndSend("/topic/game/$uuid/question/revealed", questionDto)
}
@MessageExceptionHandler
@SendToUser("/error")
fun handleException(exception: Exception): GenericMessage{
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
@JoinColumn(name = "fk_image_file", referencedColumnName = "id")
val image: File?,
var image: File?,
@OneToOne
@JoinColumn(name="fk_board_entry", referencedColumnName = "id")

View File

@ -9,20 +9,19 @@ import jakarta.persistence.*
data class Board(
@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)
val boardName: String,
var boardName: String = "New Board",
@ManyToOne
@JoinColumn(name="fk_owned_by", referencedColumnName = "id")
val owner: User,
var owner: User,
@Column(name = "points_are_title", nullable = false, unique = false)
val pointsAreTitle: Boolean = false,
var pointsAreTitle: Boolean = false,
@Id
@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(
@Column(name = "name", nullable = false, unique = false)
val name: String,
var name: String,
@Column(name = "points", nullable = false, unique = false)
val points: Long,
var points: Long,
@OneToMany(mappedBy = "boardEntry", cascade = [CascadeType.ALL], orphanRemoval = true)
val questions: List<Question>,
val questions: List<Question> = ArrayList(),
@OneToOne(cascade = [CascadeType.ALL], orphanRemoval = true)
val answer: Answer,
var answer: Answer? = null,
@ManyToOne
@JoinColumn(name = "fk_category", referencedColumnName = "id")
var category: Category?,
var category: Category? = null,
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sequenceGenerator")

View File

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

View File

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

View File

@ -12,18 +12,18 @@ import jakarta.persistence.*
data class Question(
@Column(name = "text", nullable = false, unique = false)
val text: String,
var text: String,
@Enumerated
@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)
val fontScaling: Int,
var fontScaling: Int,
@OneToOne
@JoinColumn(name = "fk_image", referencedColumnName = "id")
val image: File?,
var image: File?,
@ManyToOne
@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
import at.eisibaer.jbear2.config.ApplicationProperties
import at.eisibaer.jbear2.repository.UserRepository
import jakarta.servlet.FilterChain
import jakarta.servlet.ServletException
import jakarta.servlet.http.HttpServletRequest
@ -10,8 +11,8 @@ import org.slf4j.LoggerFactory
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.authentication.ProviderManager
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.web.builders.HttpSecurity
import org.springframework.security.core.userdetails.UserDetailsService
@ -29,12 +30,12 @@ import java.util.function.Supplier
@Configuration
@EnableMethodSecurity
class SecurityConfiguration(
private val userDetailService: UserDetailsService,
private val userRepository: UserRepository,
private val unauthorizedHandler: AuthFilter,
private val applicationProperties: ApplicationProperties
) {
final val log: Logger = LoggerFactory.getLogger(SecurityConfiguration::class.java);
final val log: Logger = LoggerFactory.getLogger(SecurityConfiguration::class.java)
@Bean
fun securityFilterChain(httpSecurity: HttpSecurity): SecurityFilterChain {
@ -44,14 +45,13 @@ class SecurityConfiguration(
.requestMatchers("/api/user/**").authenticated()
.requestMatchers("/**").permitAll()
}
.authenticationProvider(authenticationProvider())
.addFilterBefore(unauthorizedHandler, UsernamePasswordAuthenticationFilter::class.java)
.build()
}
private fun addCsrfConfig(httpSecurity: HttpSecurity): HttpSecurity{
if( applicationProperties.test ){
httpSecurity.csrf{ config -> config.disable()};
httpSecurity.csrf{ config -> config.disable()}
} else {
httpSecurity.csrf { config ->
config
@ -60,7 +60,7 @@ class SecurityConfiguration(
}
.addFilterAfter(CsrfCookieFilter(), BasicAuthenticationFilter::class.java)
}
return httpSecurity;
return httpSecurity
}
class SpaCsrfTokenRequestHandler : CsrfTokenRequestAttributeHandler() {
@ -107,21 +107,18 @@ class SecurityConfiguration(
@Bean
fun passwordEncoder() : PasswordEncoder {
return Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8();
return Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8()
}
@Bean
fun authenticationProvider(): DaoAuthenticationProvider{
val authProvider: DaoAuthenticationProvider = DaoAuthenticationProvider()
authProvider.setUserDetailsService(userDetailService)
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
fun authenticationProvider(): AuthenticationManager {
val authProvider = DaoAuthenticationProvider(userDetailService())
authProvider.setPasswordEncoder(passwordEncoder())
return ProviderManager(authProvider)
}
@Bean
fun authenticationManager(authConfig: AuthenticationConfiguration): AuthenticationManager{
return authConfig.authenticationManager;
fun userDetailService(): UserDetailsService {
return UserDetailServiceImpl(userRepository)
}
}

View File

@ -16,17 +16,17 @@ class UserDetailServiceImpl(
): UserDetailsService {
override fun loadUserByUsername(username: String?): UserDetailsImpl {
val user: User? = userRepository.findUserByUsername( username ?: "" )
if( user == null ){
throw UsernameNotFoundException("User not found by username \"$username\"");
if( username == 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(
user.id!!,
user.username,
user.password,
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.model.User
import org.springframework.http.ResponseEntity
import org.springframework.web.multipart.MultipartFile
interface BoardService {
@ -10,6 +11,6 @@ interface BoardService {
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>
}

View File

@ -9,54 +9,92 @@ import at.eisibaer.jbear2.repository.UserRepository
import at.eisibaer.jbear2.service.mapper.BoardMapper
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile
@Service
class BoardServiceImpl (
private val fileService: FileService,
private val boardRepository: BoardRepository,
private val userRepository: UserRepository,
private val fileRepository: FileRepository,
private val boardMapper: BoardMapper,
) : BoardService {
val log: Logger = LoggerFactory.getLogger(BoardServiceImpl::class.java);
val log: Logger = LoggerFactory.getLogger(BoardServiceImpl::class.java)
@Transactional
override fun getBoardsByUser(user: User): ResponseEntity<List<BoardDto>?> {
val boards = boardRepository.findAllByOwner(user)
return ResponseEntity.ok(boardMapper.toDto(boards));
return ResponseEntity.ok(boardMapper.toDto(boards))
}
@Transactional
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))
}
@Transactional
override fun saveBoardToUser( user: User, boardDto: BoardDto): ResponseEntity<BoardDto> {
val board: Board = boardMapper.toEntity(boardDto, user, userRepository, fileRepository);
override fun saveBoardToUser( user: User, boardDto: BoardDto, files: List<MultipartFile>): ResponseEntity<BoardDto> {
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 {
savedBoard = boardRepository.save(board);
savedBoard = boardRepository.save(board)
} catch (ex: Exception) {
log.error(ex.message, ex);
return ResponseEntity.badRequest().build();
log.error(ex.message, ex)
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))
}
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
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
import at.eisibaer.jbear2.model.Board
import at.eisibaer.jbear2.model.File
import at.eisibaer.jbear2.model.User
import org.springframework.core.io.Resource
import org.springframework.web.multipart.MultipartFile
import java.util.UUID
interface FileService {
fun saveFiles(files: List<MultipartFile>): List<File>;
fun saveFile(file: MultipartFile): File;
fun saveFiles(files: List<MultipartFile>, owner: User, board: Board)
fun saveFile(file: MultipartFile, owner: User, board: Board): File
fun getFile(file: File): Resource;
fun getFile(fileUUID: UUID): Resource;
fun getFile(file: File): Resource
fun getFile(fileUUID: UUID): Resource
fun deleteFile(file: File): Resource;
fun deleteFile(fileUUID: UUID): Resource;
fun deleteFile(file: File): Resource
fun deleteFile(fileUUID: UUID): Resource
}

View File

@ -1,36 +1,96 @@
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.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.stereotype.Service
import org.springframework.util.InvalidMimeTypeException
import org.springframework.web.multipart.MultipartFile
import java.nio.file.Files
import java.security.MessageDigest
import java.util.*
@Slf4j
@Service
class FileServiceImpl(
class FileServiceImpl (
val fileRepository: FileRepository,
val answerRepository: AnswerRepository,
val questionRepository: QuestionRepository,
val storageService: StorageService,
) : 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")
}
override fun saveFile(file: MultipartFile): File {
TODO("Not yet implemented")
private fun getExtension(file: MultipartFile): String {
return when (file.contentType) {
"image/jpeg" -> ".jpg"
"image/png" -> ".png"
"image/avif" -> ".avif"
"image/webp" -> ".webp"
else -> throw InvalidMimeTypeException(file.contentType ?: "unknown", "MIME Type not accepted")
}
private fun saveMultipartFile(file: MultipartFile): File{
val uuid = UUID.randomUUID();
val filename = file.originalFilename;
var hash: String;
// val file = File(
// UUID.randomUUID(),
// uuidextension,
// hash,
// )
TODO();
}
override fun getFile(file: File): Resource {
@ -42,6 +102,7 @@ class FileServiceImpl(
}
override fun deleteFile(file: File): Resource {
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.StorageFileNotFoundException
import jakarta.annotation.PostConstruct
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.core.io.Resource
import org.springframework.core.io.UrlResource
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.web.multipart.MultipartFile
import java.io.IOException
@ -20,29 +20,30 @@ import java.nio.file.StandardCopyOption
@Service
@Transactional(propagation = Propagation.NEVER)
class FileSystemStorageService(
val applicationProperties: ApplicationProperties,
) : StorageService {
val log: Logger = LoggerFactory.getLogger(FileSystemStorageService::class.java)
lateinit var rootLocation: Path
@PostConstruct
fun init(){
val location = applicationProperties.storage.fs.location;
val location = applicationProperties.storage.fs.location
if( location.trim().isEmpty() ){
throw StorageException("File upload location can not be Empty", null)
}
rootLocation = Paths.get(location);
rootLocation = Paths.get(location)
}
override fun storeFile(file: MultipartFile) {
storeMultipartFile(file);
override fun storeFile(file: MultipartFile, filename: String) {
storeMultipartFile(file, filename)
}
override fun storeFiles(files: List<MultipartFile>) {
override fun storeFiles(files: List<Pair<MultipartFile, String>>) {
for( file in files ){
storeMultipartFile(file)
storeMultipartFile(file.first, file.second)
}
}
@ -64,16 +65,16 @@ class FileSystemStorageService(
FileSystemUtils.deleteRecursively(rootLocation.resolve(filename))
}
private fun storeMultipartFile(file: MultipartFile){
private fun storeMultipartFile(file: MultipartFile, filename: String){
try {
if (file.isEmpty) {
throw StorageException("Failed to store empty file.", null)
}
val destinationFile: Path = rootLocation
.resolve(Paths.get(file.originalFilename))
.resolve(Paths.get(filename))
.normalize()
.toAbsolutePath()
if (destinationFile.getParent() != rootLocation.toAbsolutePath()) {
if (destinationFile.parent != rootLocation.toAbsolutePath()) {
// This is a security check
throw StorageException("Cannot store file outside current directory.", null)
}

View File

@ -5,9 +5,9 @@ import org.springframework.web.multipart.MultipartFile
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

View File

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

View File

@ -7,16 +7,28 @@ import at.eisibaer.jbear2.repository.FileRepository
import at.eisibaer.jbear2.repository.UserRepository
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 fun toDto(e: Board): BoardDto;
abstract fun toDto(e: List<Board>): List<BoardDto>;
abstract fun toDto(e: Board): BoardDto
abstract fun toDto(e: List<Board>): List<BoardDto>
@Mapping(target = "owner", source = "owner")
@Mapping(target = "id", source = "d.id")
abstract fun toEntity(d: BoardDto, owner: User, @Context userRepository: UserRepository, @Context fileRepository: FileRepository): Board;
abstract fun toEntity(d: List<BoardDto>, @Context owner: User, @Context userRepository: UserRepository, @Context fileRepository: FileRepository): List<Board>;
@Mappings(value = [
Mapping(target = "owner", source = "ownerUser"),
Mapping(target = "id", source = "d.id"),
])
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
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])
abstract class CategoryMapper {
abstract fun toDto(e: Category): CategoryDto;
abstract fun toDto(e: List<Category>): List<CategoryDto>;
abstract fun toDto(e: Category): CategoryDto
abstract fun toDto(e: List<Category>): List<CategoryDto>
abstract fun toEntity(d: CategoryDto, @Context fileRepository: FileRepository): Category;
abstract fun toEntity(d: List<CategoryDto>, @Context fileRepository: FileRepository): List<Category>;
abstract fun toEntity(d: CategoryDto, @Context fileRepository: FileRepository, @Context boardEntryMapper: BoardEntryMapper): Category
abstract fun toEntity(d: List<CategoryDto>, @Context fileRepository: FileRepository, @Context boardEntryMapper: BoardEntryMapper): List<Category>
fun map(file: File): String{
return file.uuid.toString()

View File

@ -1,20 +1,8 @@
package at.eisibaer.jbear2.service.mapper
import at.eisibaer.jbear2.dto.game.GameDto
import at.eisibaer.jbear2.model.BoardEntry
import at.eisibaer.jbear2.model.Game
import org.mapstruct.Mapper
@Mapper
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()
// }
}
@Mapper(uses = [PlayerMapper::class])
interface GameMapper : EntityMapper<GameDto, Game>

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
default-schema: jeobeardy-app
open-in-view: false
docker:
compose:
lifecycle-management: start-only
servlet:
multipart:
max-file-size: 5MB
max-request-size: 50MB
# session:
max-file-size: 1MB
max-request-size: 40MB
session:
store-type: jdbc
jdbc:
initialize-schema: always
# redis:
# flush-mode: on_save
# namespace: spring:session

View File

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

View File

@ -1,11 +1,9 @@
<script setup lang="ts">
import { AnswerType } from '@/models/board/AnswerType';
import type { BoardEntry } from '@/models/board/BoardEntry';
import { QuestionType } from '@/models/board/QuestionType';
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<{
boardEntry: BoardEntry,
selectedQuestionIndex: number,
@ -70,14 +68,24 @@ function stopAudio(){
<div class="col h-100 mx-3 overflow-y-auto">
<div class="ratio ratio-16x9">
<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">
No Question to show
</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 }}
</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">
<span class="text-center preserve-breaks" :style="`font-size: ${question.fontScaling}em`">
{{ question.text }}
@ -92,7 +100,7 @@ function stopAudio(){
</div>
</div>
</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">
<span class="text-center preserve-breaks" :style="`font-size: ${question.fontScaling}em`">
{{ question.text }}
@ -121,21 +129,27 @@ function stopAudio(){
</span>
</div>
<!-- Points -->
<div class="position-absolute top-0 end-0">
<div class="position-absolute bottom-0 end-0">
<span class="fs-2">
{{ boardEntry.points }}
</span>
</div>
<!-- title -->
<div class="position-absolute top-0 start-50 translate-middle-x">
<span class="fs-2">
{{ boardEntry.name }}
</span>
</div>
<!-- Back to Board -->
<div class="position-absolute bottom-0 end-0">
<div class="position-absolute top-0 start-0">
<div>
<button class="btn btn-outline-primary" @click="backToBoard">
<button class="btn btn-outline-primary mt-2" @click="backToBoard">
Back to Board
</button>
</div>
</div>
<!-- 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">
<button v-if="props.isQuestionShown" class="btn btn-outline-primary me-3" @click="hideQuestion">
Hide Question
@ -151,9 +165,9 @@ function stopAudio(){
</button>
</div>
</div>
<!-- Answer -->
<!-- Answer text -->
<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 }}
</div>
</div>

View File

@ -13,13 +13,13 @@ const router = useRouter();
const boards = ref<Array<Board>>([]);
function editBoard(board: Board){
function editBoard(board: Board) {
router.push({
name: 'create',
params: {
boardId: board.id,
}
})
},
});
}
function createNewBoard() {
@ -53,18 +53,28 @@ userService
<template v-for="board in boards" :key="board.id">
<div class="col-4 mb-3">
<div class="card">
<div class="card-header">
<div class="card-header position-relative">
{{ 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 class="card-body">
<div class="row">
<div class="col">
<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']" />
Edit
</button>
<button class="btn btn-sm btn-primary">
<!-- TODO -->
<FontAwesomeIcon :icon="['fas', 'play']" />
Play
</button>
@ -89,8 +99,17 @@ userService
</template>
<style scoped>
.spinny-size{
.spinny-size {
max-width: 6.66em;
max-height: 6.66em;
}
.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;
}
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() {
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>
<textarea id="answer-text" v-model="boardEntry.answer.text" class="form-control mb-2"
: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>

View File

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

View File

@ -11,6 +11,7 @@ import CreatePanel from '@/components/blocks/CreatePanel.vue';
import BoardEntryView from '@/components/blocks/BoardEntryView.vue';
import { userService } from '@/services/UserService';
import { useRoute, useRouter } from 'vue-router';
import { useFileStore } from '@/stores/FileStore';
const navbar = inject<Ref<InstanceType<typeof NavBar> | null>>(navbarKey);
const navbarHeight = computed(() => {
@ -65,14 +66,20 @@ function hideAnswer() {
isAnswerShown.value = false;
}
const fileStore = useFileStore();
const savingBoardInProgress = ref(false);
function saveBoard() {
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;
if( board.value.id ){
savePromise = userService.updateBoard(board.value);
savePromise = userService.updateBoard(board.value, formData);
} else {
savePromise = userService.saveNewBoard(board.value);
savePromise = userService.saveNewBoard(formData);
}
savePromise
.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) => {
axios
.post(`${ENV.API_BASE_URL}/user/boards`, board, {
.post(`${ENV.API_BASE_URL}/user/boards`, formData, {
withCredentials: true,
})
.then((response) => {
@ -62,13 +62,13 @@ class UserService {
});
});
}
updateBoard(board: Board): Promise<Board> {
updateBoard(board: Board, formData: FormData): Promise<Board> {
if( board.id === undefined ){
throw new Error("New board cant be updated");
}
return new Promise((resolve, reject) => {
axios
.put(`${ENV.API_BASE_URL}/user/boards/${board.id}`, board, {
.put(`${ENV.API_BASE_URL}/user/boards/${board.id}`, formData, {
withCredentials: true,
})
.then((response) => {

View File

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