diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..70262bd --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# 로컬 전용 설정 (DB 비밀번호 등) +src/main/resources/application.properties + +# Maven +target/ diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..bedcc84 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..63e9001 --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml new file mode 100644 index 0000000..712ab9d --- /dev/null +++ b/.idea/jarRepositories.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..741242e --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 0000000..77c199e --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1775452282718 + + + + + + \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..e012065 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "java.compile.nullAnalysis.mode": "automatic", + "java.configuration.updateBuildConfiguration": "interactive" +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4ed9437 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +# 로컬 개발용 PostgreSQL (Redis 없음) +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: bibimbap + POSTGRES_PASSWORD: bibimbap + POSTGRES_DB: bibimbap + ports: + - "5432:5432" + volumes: + - bibimbap_pgdata:/var/lib/postgresql/data + +volumes: + bibimbap_pgdata: diff --git a/pom.xml b/pom.xml index 90d8a25..ffc12d4 100644 --- a/pom.xml +++ b/pom.xml @@ -35,6 +35,47 @@ org.springframework.boot spring-boot-starter-webmvc + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-security + + + org.apache.tomcat.embed + tomcat-embed-jasper + + + + org.glassfish.web + jakarta.servlet.jsp.jstl + + + + jakarta.servlet.jsp.jstl + jakarta.servlet.jsp.jstl-api + + + org.springframework.security + spring-security-taglibs + + + org.postgresql + postgresql + runtime + + + com.h2database + h2 + runtime + org.projectlombok diff --git a/src/main/java/com/pandoli365/bibimbap/config/WebConfig.java b/src/main/java/com/pandoli365/bibimbap/config/WebConfig.java new file mode 100644 index 0000000..336e486 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/config/WebConfig.java @@ -0,0 +1,24 @@ +package com.pandoli365.bibimbap.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.nio.file.Path; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Value("${bibimbap.storage.root}") + private String storageRoot; + + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + String location = Path.of(storageRoot).toAbsolutePath().normalize().toUri().toString(); + if (!location.endsWith("/")) { + location = location + "/"; + } + registry.addResourceHandler("/play/**").addResourceLocations(location); + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/domain/Game.java b/src/main/java/com/pandoli365/bibimbap/domain/Game.java new file mode 100644 index 0000000..007a80e --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/domain/Game.java @@ -0,0 +1,56 @@ +package com.pandoli365.bibimbap.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; + +@Entity +@Table(name = "games") +@Getter +@Setter +@NoArgsConstructor +public class Game { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "owner_id", nullable = false) + private UserAccount owner; + + @Column(nullable = false, length = 200) + private String title; + + /** URL 경로용: 소문자, 숫자, 하이픈만 (예: my-webgl-demo) */ + @Column(nullable = false, unique = true, length = 120) + private String slug; + + @Column(length = 4000) + private String description; + + /** 카드 썸네일 — http(s) URL 또는 / 로 시작하는 경로. 비어 있으면 기본 이미지 */ + @Column(length = 2000) + private String thumbnailUrl; + + /** 저장소 내 상대 디렉터리 (보통 slug와 동일) */ + @Column(nullable = false, length = 200) + private String storageKey; + + @Column(nullable = false) + private boolean published = true; + + @Column(nullable = false) + private Instant createdAt = Instant.now(); +} diff --git a/src/main/java/com/pandoli365/bibimbap/domain/UserAccount.java b/src/main/java/com/pandoli365/bibimbap/domain/UserAccount.java new file mode 100644 index 0000000..6514e81 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/domain/UserAccount.java @@ -0,0 +1,34 @@ +package com.pandoli365.bibimbap.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; + +@Entity +@Table(name = "users") +@Getter +@Setter +@NoArgsConstructor +public class UserAccount { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 64) + private String username; + + @Column(nullable = false) + private String passwordHash; + + @Column(nullable = false) + private Instant createdAt = Instant.now(); +} diff --git a/src/main/java/com/pandoli365/bibimbap/repository/GameRepository.java b/src/main/java/com/pandoli365/bibimbap/repository/GameRepository.java new file mode 100644 index 0000000..2aa8db0 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/repository/GameRepository.java @@ -0,0 +1,32 @@ +package com.pandoli365.bibimbap.repository; + +import com.pandoli365.bibimbap.domain.Game; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface GameRepository extends JpaRepository { + + Optional findBySlug(String slug); + + boolean existsBySlug(String slug); + + @Query( + "SELECT DISTINCT g FROM Game g " + + "JOIN FETCH g.owner o " + + "WHERE g.published = true " + + "ORDER BY g.createdAt DESC") + List findAllPublishedWithOwner(); + + @Query( + "SELECT DISTINCT g FROM Game g " + + "JOIN FETCH g.owner o " + + "WHERE g.published = true " + + "AND (LOWER(g.title) LIKE LOWER(CONCAT('%', :q, '%')) " + + "OR LOWER(o.username) LIKE LOWER(CONCAT('%', :q, '%'))) " + + "ORDER BY g.createdAt DESC") + List searchPublishedWithOwner(@Param("q") String q); +} diff --git a/src/main/java/com/pandoli365/bibimbap/repository/UserAccountRepository.java b/src/main/java/com/pandoli365/bibimbap/repository/UserAccountRepository.java new file mode 100644 index 0000000..1f83fa9 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/repository/UserAccountRepository.java @@ -0,0 +1,13 @@ +package com.pandoli365.bibimbap.repository; + +import com.pandoli365.bibimbap.domain.UserAccount; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserAccountRepository extends JpaRepository { + + Optional findByUsername(String username); + + boolean existsByUsername(String username); +} diff --git a/src/main/java/com/pandoli365/bibimbap/security/AccountUserDetailsService.java b/src/main/java/com/pandoli365/bibimbap/security/AccountUserDetailsService.java new file mode 100644 index 0000000..1a87f5d --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/security/AccountUserDetailsService.java @@ -0,0 +1,27 @@ +package com.pandoli365.bibimbap.security; + +import com.pandoli365.bibimbap.repository.UserAccountRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AccountUserDetailsService implements UserDetailsService { + + private final UserAccountRepository userAccountRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + var account = userAccountRepository + .findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException(username)); + return User.withUsername(account.getUsername()) + .password(account.getPasswordHash()) + .roles("USER") + .build(); + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/security/SecurityConfig.java b/src/main/java/com/pandoli365/bibimbap/security/SecurityConfig.java new file mode 100644 index 0000000..86721d8 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/security/SecurityConfig.java @@ -0,0 +1,48 @@ +package com.pandoli365.bibimbap.security; + +import jakarta.servlet.DispatcherType; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + // Spring Security 6+: 인증 필터가 FORWARD/INCLUDE에도 적용됨. JSP 렌더링 등 내부 포워드가 + // 다시 검사되어 루프(/) <-> (/login)가 날 수 있어, 내부 디스패치는 먼저 허용한다. + // @see https://docs.spring.io/spring-security/reference/migration-7/web.html + http.authorizeHttpRequests( + auth -> auth.dispatcherTypeMatchers( + DispatcherType.FORWARD, DispatcherType.INCLUDE, DispatcherType.ERROR) + .permitAll() + .requestMatchers( + "/", + "/home", + "/login", + "/register", + "/play/**", + "/error", + "/css/**", + "/img/**") + .permitAll() + .anyRequest() + .authenticated()) + .formLogin(form -> form.loginPage("/login").permitAll().defaultSuccessUrl("/", true)) + .logout(logout -> logout.logoutSuccessUrl("/")); + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/service/FileStorageService.java b/src/main/java/com/pandoli365/bibimbap/service/FileStorageService.java new file mode 100644 index 0000000..5da8d1d --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/service/FileStorageService.java @@ -0,0 +1,74 @@ +package com.pandoli365.bibimbap.service; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +@Service +public class FileStorageService { + + private final Path root; + + public FileStorageService(@Value("${bibimbap.storage.root}") String rootPath) throws IOException { + this.root = Path.of(rootPath).toAbsolutePath().normalize(); + Files.createDirectories(this.root); + } + + public Path root() { + return root; + } + + /** + * Unity WebGL 빌드 폴더를 zip으로 묶어 올린 경우 압축을 풀어 {@code storageKey} 하위에 둡니다. + * Zip slip 방지를 위해 엔트리 경로를 검증합니다. + */ + public void extractWebGlZip(String storageKey, MultipartFile zipFile) throws IOException { + Path destDir = root.resolve(storageKey).normalize(); + if (!destDir.startsWith(root)) { + throw new IllegalArgumentException("Invalid storage key"); + } + Files.createDirectories(destDir); + try (InputStream in = zipFile.getInputStream(); ZipInputStream zis = new ZipInputStream(in)) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + if (entry.isDirectory()) { + continue; + } + String name = entry.getName(); + if (name.startsWith("/") || name.contains("..")) { + throw new IllegalArgumentException("Invalid zip entry: " + name); + } + Path target = destDir.resolve(name).normalize(); + if (!target.startsWith(destDir)) { + throw new IllegalArgumentException("Zip entry escapes target directory: " + name); + } + Files.createDirectories(target.getParent()); + Files.copy(zis, target); + } + } + } + + public void deleteGameDirectory(String storageKey) throws IOException { + Path dir = root.resolve(storageKey).normalize(); + if (!dir.startsWith(root) || !Files.isDirectory(dir)) { + return; + } + try (var walk = Files.walk(dir)) { + walk.sorted((a, b) -> b.getNameCount() - a.getNameCount()) + .forEach(p -> { + try { + Files.deleteIfExists(p); + } catch (IOException ignored) { + // best effort + } + }); + } + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/service/GameService.java b/src/main/java/com/pandoli365/bibimbap/service/GameService.java new file mode 100644 index 0000000..13056ca --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/service/GameService.java @@ -0,0 +1,53 @@ +package com.pandoli365.bibimbap.service; + +import com.pandoli365.bibimbap.domain.Game; +import com.pandoli365.bibimbap.domain.UserAccount; +import com.pandoli365.bibimbap.repository.GameRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class GameService { + + private final GameRepository gameRepository; + private final FileStorageService fileStorageService; + + @Transactional(readOnly = true) + public List listPublished(String query) { + String q = query == null ? "" : query.trim(); + if (q.isEmpty()) { + return gameRepository.findAllPublishedWithOwner(); + } + return gameRepository.searchPublishedWithOwner(q); + } + + @Transactional + public Game create(UserAccount owner, String title, String slug, String description, MultipartFile zip) + throws IOException { + String normalized = slug.trim().toLowerCase(); + if (gameRepository.existsBySlug(normalized)) { + throw new IllegalArgumentException("이미 사용 중인 슬러그입니다."); + } + fileStorageService.extractWebGlZip(normalized, zip); + + Game g = new Game(); + g.setOwner(owner); + g.setTitle(title.trim()); + g.setSlug(normalized); + g.setDescription(description != null ? description.trim() : null); + g.setStorageKey(normalized); + g.setPublished(true); + return gameRepository.save(g); + } + + @Transactional(readOnly = true) + public Game getBySlug(String slug) { + return gameRepository.findBySlug(slug).orElseThrow(() -> new IllegalArgumentException("게임을 찾을 수 없습니다.")); + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/service/RegistrationService.java b/src/main/java/com/pandoli365/bibimbap/service/RegistrationService.java new file mode 100644 index 0000000..b7d1d93 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/service/RegistrationService.java @@ -0,0 +1,27 @@ +package com.pandoli365.bibimbap.service; + +import com.pandoli365.bibimbap.domain.UserAccount; +import com.pandoli365.bibimbap.repository.UserAccountRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class RegistrationService { + + private final UserAccountRepository userAccountRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional + public UserAccount register(String username, String rawPassword) { + if (userAccountRepository.existsByUsername(username)) { + throw new IllegalArgumentException("이미 사용 중인 아이디입니다."); + } + UserAccount u = new UserAccount(); + u.setUsername(username); + u.setPasswordHash(passwordEncoder.encode(rawPassword)); + return userAccountRepository.save(u); + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/web/GameController.java b/src/main/java/com/pandoli365/bibimbap/web/GameController.java new file mode 100644 index 0000000..70f0215 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/web/GameController.java @@ -0,0 +1,60 @@ +package com.pandoli365.bibimbap.web; + +import com.pandoli365.bibimbap.repository.UserAccountRepository; +import com.pandoli365.bibimbap.service.GameService; +import com.pandoli365.bibimbap.web.dto.GameCreateForm; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; + +@Controller +@RequiredArgsConstructor +public class GameController { + + private final GameService gameService; + private final UserAccountRepository userAccountRepository; + + @GetMapping("/games/new") + public String newForm(Model model) { + model.addAttribute("form", new GameCreateForm()); + return "games/new"; + } + + @PostMapping("/games") + public String create( + @AuthenticationPrincipal UserDetails principal, + @Valid @ModelAttribute("form") GameCreateForm form, + BindingResult bindingResult, + @RequestParam("file") MultipartFile file, + Model model) { + if (file == null || file.isEmpty()) { + bindingResult.reject("game.file.required", "WebGL 빌드 zip 파일을 선택하세요."); + } + if (bindingResult.hasErrors()) { + return "games/new"; + } + var owner = + userAccountRepository.findByUsername(principal.getUsername()).orElseThrow(); + try { + var game = gameService.create(owner, form.getTitle(), form.getSlug(), form.getDescription(), file); + return "redirect:/play/" + game.getSlug() + "/"; + } catch (IllegalArgumentException e) { + bindingResult.reject("game.invalid", e.getMessage()); + return "games/new"; + } catch (IOException e) { + bindingResult.reject("game.io", "파일 저장에 실패했습니다: " + e.getMessage()); + return "games/new"; + } + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/web/HomeController.java b/src/main/java/com/pandoli365/bibimbap/web/HomeController.java new file mode 100644 index 0000000..0044f4e --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/web/HomeController.java @@ -0,0 +1,23 @@ +package com.pandoli365.bibimbap.web; + +import com.pandoli365.bibimbap.service.GameService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +@Controller +@RequiredArgsConstructor +public class HomeController { + + private final GameService gameService; + + @GetMapping({"/", "/home"}) + public String home( + @RequestParam(name = "q", required = false) String q, Model model) { + model.addAttribute("games", gameService.listPublished(q)); + model.addAttribute("q", q != null ? q : ""); + return "home"; + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/web/LoginController.java b/src/main/java/com/pandoli365/bibimbap/web/LoginController.java new file mode 100644 index 0000000..962ecf8 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/web/LoginController.java @@ -0,0 +1,13 @@ +package com.pandoli365.bibimbap.web; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +public class LoginController { + + @GetMapping("/login") + public String login() { + return "login"; + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/web/PlayController.java b/src/main/java/com/pandoli365/bibimbap/web/PlayController.java new file mode 100644 index 0000000..e566d44 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/web/PlayController.java @@ -0,0 +1,23 @@ +package com.pandoli365.bibimbap.web; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; + +/** + * WebGL 플레이 URL을 {@code /play/{slug}/} 형태로만 노출하고, 실제 Unity 빌드의 {@code index.html}은 + * 내부 forward로 처리합니다. + */ +@Controller +public class PlayController { + + @GetMapping("/play/{slug}") + public String redirectToTrailingSlash(@PathVariable String slug) { + return "redirect:/play/" + slug + "/"; + } + + @GetMapping("/play/{slug}/") + public String forwardToWebGlIndex(@PathVariable String slug) { + return "forward:/play/" + slug + "/index.html"; + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/web/RegisterController.java b/src/main/java/com/pandoli365/bibimbap/web/RegisterController.java new file mode 100644 index 0000000..2cf59f3 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/web/RegisterController.java @@ -0,0 +1,40 @@ +package com.pandoli365.bibimbap.web; + +import com.pandoli365.bibimbap.service.RegistrationService; +import com.pandoli365.bibimbap.web.dto.RegisterForm; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PostMapping; + +@Controller +@RequiredArgsConstructor +public class RegisterController { + + private final RegistrationService registrationService; + + @GetMapping("/register") + public String form(Model model) { + model.addAttribute("form", new RegisterForm()); + return "register"; + } + + @PostMapping("/register") + public String submit( + @Valid @ModelAttribute("form") RegisterForm form, BindingResult bindingResult) { + if (bindingResult.hasErrors()) { + return "register"; + } + try { + registrationService.register(form.getUsername(), form.getPassword()); + } catch (IllegalArgumentException e) { + bindingResult.reject("register.duplicate", e.getMessage()); + return "register"; + } + return "redirect:/login?registered"; + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/web/dto/GameCreateForm.java b/src/main/java/com/pandoli365/bibimbap/web/dto/GameCreateForm.java new file mode 100644 index 0000000..8ac9915 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/web/dto/GameCreateForm.java @@ -0,0 +1,27 @@ +package com.pandoli365.bibimbap.web.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class GameCreateForm { + + @NotBlank(message = "제목을 입력하세요.") + @Size(max = 200) + private String title; + + /** + * URL에 쓰이는 식별자 (예: my-game). Unity 빌드 루트 폴더명과 맞추면 관리가 쉽습니다. + */ + @NotBlank(message = "슬러그를 입력하세요.") + @Pattern( + regexp = "[a-z0-9]+(?:-[a-z0-9]+)*", + message = "슬러그는 소문자, 숫자, 하이픈만 사용할 수 있습니다.") + @Size(max = 120) + private String slug; + + @Size(max = 4000) + private String description; +} diff --git a/src/main/java/com/pandoli365/bibimbap/web/dto/RegisterForm.java b/src/main/java/com/pandoli365/bibimbap/web/dto/RegisterForm.java new file mode 100644 index 0000000..0447f32 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/web/dto/RegisterForm.java @@ -0,0 +1,17 @@ +package com.pandoli365.bibimbap.web.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class RegisterForm { + + @NotBlank(message = "아이디를 입력하세요.") + @Size(min = 3, max = 64, message = "아이디는 3~64자여야 합니다.") + private String username; + + @NotBlank(message = "비밀번호를 입력하세요.") + @Size(min = 8, max = 128, message = "비밀번호는 8자 이상이어야 합니다.") + private String password; +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 0000000..0e313ac --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,12 @@ +# 원격 PostgreSQL에 닿지 않을 때 로컬에서 앱만 띄워볼 때 사용 +# 실행: --spring.profiles.active=dev (또는 SPRING_PROFILES_ACTIVE=dev) +spring: + datasource: + url: jdbc:h2:mem:bibimbap;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + driver-class-name: org.h2.Driver + username: sa + password: "" + jpa: + database-platform: org.hibernate.dialect.H2Dialect + hibernate: + ddl-auto: update diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index cbec0c3..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=bibimbap diff --git a/src/main/resources/application.properties.example b/src/main/resources/application.properties.example new file mode 100644 index 0000000..0a0c3be --- /dev/null +++ b/src/main/resources/application.properties.example @@ -0,0 +1,23 @@ +spring.application.name=bibimbap + +# PostgreSQL — 이 파일을 application.properties 로 복사한 뒤 값을 채우세요. +# cp src/main/resources/application.properties.example src/main/resources/application.properties +# +# 연결 실패(Connect timed out) 시: PC와 DB 서버가 같은 네트워크인지, 방화벽 5432, +# postgresql.conf listen_addresses, pg_hba.conf 허용을 확인하세요. +# 원격 DB 없이 로컬만: IntelliJ VM 옵션 또는 실행 인자에 +# --spring.profiles.active=dev +# (H2 인메모리 — application-dev.yml) +spring.datasource.url=jdbc:postgresql://localhost:5432/bibimbap +spring.datasource.username=bibimbap +spring.datasource.password=changeme +spring.datasource.hikari.maximum-pool-size=10 + +spring.jpa.hibernate.ddl-auto=update +spring.jpa.open-in-view=false +spring.jpa.properties.hibernate.jdbc.time_zone=UTC + +spring.servlet.multipart.max-file-size=512MB +spring.servlet.multipart.max-request-size=512MB + +bibimbap.storage.root=${BIBIMBAP_STORAGE_ROOT:./data/webgl} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..06ee994 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,11 @@ +spring: + mvc: + view: + prefix: /WEB-INF/jsp/ + suffix: .jsp + jpa: + # JDBC 메타데이터 없이도 방언 고정 (연결 실패 시 보조 메시지 완화) + database-platform: org.hibernate.dialect.PostgreSQLDialect + thymeleaf: + # 화면은 전부 JSP(/WEB-INF/jsp). Thymeleaf는 사용하지 않음. + enabled: false diff --git a/src/main/resources/static/css/main.css b/src/main/resources/static/css/main.css new file mode 100644 index 0000000..20f1444 --- /dev/null +++ b/src/main/resources/static/css/main.css @@ -0,0 +1,213 @@ +:root { + --bg: #0f1115; + --surface: #1a1d24; + --border: #2a2f3a; + --text: #e8eaed; + --muted: #9aa0a6; + --accent: #7c9cff; + --accent-hover: #a8bfff; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans KR", sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.5; + min-height: 100vh; +} + +a { + color: var(--accent); + text-decoration: none; +} + +a:hover { + color: var(--accent-hover); + text-decoration: underline; +} + +.site-header { + background: var(--surface); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 10; +} + +.site-header__inner { + max-width: 1200px; + margin: 0 auto; + padding: 0.75rem 1rem; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 1rem; +} + +.logo { + font-weight: 700; + font-size: 1.25rem; + color: var(--text); + text-decoration: none; +} + +.logo:hover { + color: var(--accent); + text-decoration: none; +} + +.search { + flex: 1 1 240px; + display: flex; + gap: 0.5rem; + min-width: 0; +} + +.search input[type="search"] { + flex: 1; + min-width: 0; + padding: 0.5rem 0.75rem; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); + color: var(--text); + font: inherit; +} + +.search button { + padding: 0.5rem 1rem; + border: none; + border-radius: 8px; + background: var(--accent); + color: #111; + font: inherit; + font-weight: 600; + cursor: pointer; +} + +.search button:hover { + filter: brightness(1.08); +} + +.auth { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.btn-primary { + padding: 0.4rem 0.85rem; + border-radius: 8px; + background: var(--accent); + color: #111 !important; + font-weight: 600; + text-decoration: none !important; +} + +.btn-primary:hover { + filter: brightness(1.08); + color: #111 !important; +} + +.logout-form { + display: inline; + margin: 0; +} + +.link-btn { + background: none; + border: none; + color: var(--accent); + font: inherit; + cursor: pointer; + padding: 0; + text-decoration: underline; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 1.5rem 1rem 3rem; +} + +.lead { + color: var(--muted); + margin: 0 0 1.5rem; +} + +.empty { + color: var(--muted); +} + +.card-grid { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 1.25rem; +} + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + overflow: hidden; + height: 100%; + display: flex; + flex-direction: column; +} + +.card__media { + display: block; + aspect-ratio: 16 / 9; + background: #111; + overflow: hidden; +} + +.card__media img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.card__body { + padding: 1rem; + flex: 1; + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.card__title { + margin: 0; + font-size: 1.05rem; + line-height: 1.35; +} + +.card__title a { + color: var(--text); + text-decoration: none; +} + +.card__title a:hover { + color: var(--accent); +} + +.card__author { + margin: 0; + font-size: 0.9rem; + color: var(--muted); +} + +.card__author-name { + color: var(--text); + font-weight: 500; +} diff --git a/src/main/resources/static/img/logo.png b/src/main/resources/static/img/logo.png new file mode 100644 index 0000000..d5a720d Binary files /dev/null and b/src/main/resources/static/img/logo.png differ diff --git a/src/main/webapp/WEB-INF/jsp/games/new.jsp b/src/main/webapp/WEB-INF/jsp/games/new.jsp new file mode 100644 index 0000000..234e3f7 --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/games/new.jsp @@ -0,0 +1,37 @@ +<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %> +<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %> + + + + + + 게임 올리기 — 비빔밥 + + + +
+

WebGL 게임 올리기

+

Unity WebGL 빌드 폴더 내용을 zip으로 묶어 업로드하세요. 압축 해제 후 최상위에 index.html이 있어야 합니다.

+ + + + + + + + + + +

+
+ + diff --git a/src/main/webapp/WEB-INF/jsp/home.jsp b/src/main/webapp/WEB-INF/jsp/home.jsp new file mode 100644 index 0000000..3530c77 --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/home.jsp @@ -0,0 +1,70 @@ +<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %> +<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> +<%@ taglib prefix="sec" uri="http://www.springframework.org/security/tags" %> + + + + + + 비빔밥 — WebGL 게임 + + + + + +
+

로그인 없이 둘러볼 수 있습니다. 플레이는 각 카드에서 이동합니다.

+ + +

등록된 게임이 없거나 검색 결과가 없습니다.

+
+ + +
+ + diff --git a/src/main/webapp/WEB-INF/jsp/login.jsp b/src/main/webapp/WEB-INF/jsp/login.jsp new file mode 100644 index 0000000..351da94 --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/login.jsp @@ -0,0 +1,33 @@ +<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %> +<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> + + + + + + 로그인 — 비빔밥 + + + +
+

로그인

+ +

가입이 완료되었습니다. 로그인해 주세요.

+
+ +

아이디 또는 비밀번호가 올바르지 않습니다.

+
+
+ + + + +
+

회원가입 ·

+
+ + diff --git a/src/main/webapp/WEB-INF/jsp/register.jsp b/src/main/webapp/WEB-INF/jsp/register.jsp new file mode 100644 index 0000000..4343865 --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/register.jsp @@ -0,0 +1,30 @@ +<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %> +<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> +<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %> + + + + + + 회원가입 — 비빔밥 + + + +
+

회원가입

+ + + + + + + + +

로그인 ·

+
+ + diff --git a/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java b/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java index d6e198d..5133b05 100644 --- a/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java +++ b/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java @@ -2,8 +2,10 @@ package com.pandoli365.bibimbap; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; @SpringBootTest +@ActiveProfiles("test") class BibimbapApplicationTests { @Test diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties new file mode 100644 index 0000000..89009c8 --- /dev/null +++ b/src/test/resources/application-test.properties @@ -0,0 +1,6 @@ +spring.datasource.url=jdbc:h2:mem:bibimbap;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE +spring.datasource.username=sa +spring.datasource.password= +spring.datasource.driver-class-name=org.h2.Driver +spring.jpa.hibernate.ddl-auto=create-drop +bibimbap.storage.root=${java.io.tmpdir}/bibimbap-test-webgl