비빔밥 1차 업데이트

This commit is contained in:
김판돌 2026-04-06 14:47:56 +09:00
parent 4ec638090a
commit 6ebf26feb3
39 changed files with 1231 additions and 1 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
# 로컬 전용 설정 (DB 비밀번호 등)
src/main/resources/application.properties
# Maven
target/

19
.idea/compiler.xml Normal file
View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<annotationProcessing>
<profile default="true" name="Default" enabled="true" />
<profile name="Maven default annotation processors profile" enabled="true">
<sourceOutputDir name="target/generated-sources/annotations" />
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
<outputRelativeToContentRoot value="true" />
<module name="bibimbap" />
</profile>
</annotationProcessing>
</component>
<component name="JavacSettings">
<option name="ADDITIONAL_OPTIONS_OVERRIDE">
<module name="bibimbap" options="-parameters" />
</option>
</component>
</project>

6
.idea/encodings.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
</component>
</project>

20
.idea/jarRepositories.xml Normal file
View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RemoteRepositoriesConfiguration">
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Central Repository" />
<option name="url" value="https://repo.maven.apache.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Maven Central repository" />
<option name="url" value="https://repo1.maven.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="jboss.community" />
<option name="name" value="JBoss Community repository" />
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
</remote-repository>
</component>
</project>

12
.idea/misc.xml Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="MavenProjectsManager">
<option name="originalFiles">
<list>
<option value="$PROJECT_DIR$/pom.xml" />
</list>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_X" default="true" project-jdk-name="azul-21" project-jdk-type="JavaSDK" />
</project>

6
.idea/vcs.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

75
.idea/workspace.xml Normal file
View File

@ -0,0 +1,75 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="456871ed-2311-473a-9fbb-03116c5eefd8" name="변경" comment="">
<change beforePath="$PROJECT_DIR$/pom.xml" beforeDir="false" afterPath="$PROJECT_DIR$/pom.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/main/resources/application.properties" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java" beforeDir="false" afterPath="$PROJECT_DIR$/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="MavenImportPreferences">
<option name="generalSettings">
<MavenGeneralSettings>
<option name="mavenHome" value="Use Maven wrapper" />
<option name="useMavenConfig" value="true" />
</MavenGeneralSettings>
</option>
</component>
<component name="ProjectId" id="3ByDB50J5l71ukqbWIRodHBj64Z" />
<component name="ProjectViewState">
<option name="autoscrollFromSource" value="true" />
<option name="autoscrollToSource" value="true" />
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"RequestMappingsPanelOrder0": "0",
"RequestMappingsPanelOrder1": "1",
"RequestMappingsPanelWidth0": "75",
"RequestMappingsPanelWidth1": "75",
"RunOnceActivity.ShowReadmeOnStart": "true",
"WebServerToolWindowFactoryState": "false",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
"settings.editor.selected.configurable": "preferences.pluginManager",
"vue.rearranger.settings.migration": "true"
}
}]]></component>
<component name="RunManager">
<configuration name="BibimbapApplication" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot" nameIsGenerated="true">
<module name="bibimbap" />
<option name="SPRING_BOOT_MAIN_CLASS" value="com.pandoli365.bibimbap.BibimbapApplication" />
<method v="2">
<option name="Make" enabled="true" />
</method>
</configuration>
</component>
<component name="SpellCheckerSettings" RuntimeDictionaries="0" Folders="0" CustomDictionaries="0" DefaultDictionary="애플리케이션 수준" UseSingleDictionary="true" transferred="true" />
<component name="TaskManager">
<task active="true" id="Default" summary="디폴트 작업">
<changelist id="456871ed-2311-473a-9fbb-03116c5eefd8" name="변경" comment="" />
<created>1775452282718</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1775452282718</updated>
<workItem from="1775452285034" duration="2034000" />
</task>
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
</project>

4
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,4 @@
{
"java.compile.nullAnalysis.mode": "automatic",
"java.configuration.updateBuildConfiguration": "interactive"
}

15
docker-compose.yml Normal file
View File

@ -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:

41
pom.xml
View File

@ -35,6 +35,47 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId> <artifactId>spring-boot-starter-webmvc</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-jasper</artifactId>
</dependency>
<!-- JSTL 구현체 -->
<dependency>
<groupId>org.glassfish.web</groupId>
<artifactId>jakarta.servlet.jsp.jstl</artifactId>
</dependency>
<!-- Glassfish JSTL 3.0.1은 API를 provided로만 전이시켜, 임베디드 Tomcat JSP 컴파일 시
jakarta.servlet.jsp.jstl.core.ConditionalTagSupport 등이 classpath에 없을 수 있음 -->
<dependency>
<groupId>jakarta.servlet.jsp.jstl</groupId>
<artifactId>jakarta.servlet.jsp.jstl-api</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-taglibs</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency> <dependency>
<groupId>org.projectlombok</groupId> <groupId>org.projectlombok</groupId>

View File

@ -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);
}
}

View File

@ -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();
}

View File

@ -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();
}

View File

@ -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<Game, Long> {
Optional<Game> 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<Game> 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<Game> searchPublishedWithOwner(@Param("q") String q);
}

View File

@ -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<UserAccount, Long> {
Optional<UserAccount> findByUsername(String username);
boolean existsByUsername(String username);
}

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -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
}
});
}
}
}

View File

@ -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<Game> 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("게임을 찾을 수 없습니다."));
}
}

View File

@ -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);
}
}

View File

@ -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";
}
}
}

View File

@ -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";
}
}

View File

@ -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";
}
}

View File

@ -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";
}
}

View File

@ -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";
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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

View File

@ -1 +0,0 @@
spring.application.name=bibimbap

View File

@ -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}

View File

@ -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

View File

@ -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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@ -0,0 +1,37 @@
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>게임 올리기 — 비빔밥</title>
<link rel="stylesheet" href="${pageContext.request.contextPath}/css/main.css"/>
</head>
<body>
<main class="container">
<h1>WebGL 게임 올리기</h1>
<p class="lead">Unity WebGL 빌드 폴더 내용을 zip으로 묶어 업로드하세요. 압축 해제 후 최상위에 <code>index.html</code>이 있어야 합니다.</p>
<form:form modelAttribute="form" method="post" action="${pageContext.request.contextPath}/games"
enctype="multipart/form-data">
<form:errors path="*" element="div"/>
<label>제목
<form:input path="title" required="true"/>
</label>
<form:errors path="title" element="p"/>
<label>슬러그 (URL, 소문자·숫자·하이픈)
<form:input path="slug" placeholder="my-cool-game" required="true"/>
</label>
<form:errors path="slug" element="p"/>
<label>설명 (선택)
<form:textarea path="description" rows="4"/>
</label>
<label>빌드 zip
<input type="file" name="file" accept=".zip,application/zip" required/>
</label>
<button type="submit">업로드</button>
</form:form>
<p><a href="${pageContext.request.contextPath}/">홈</a></p>
</main>
</body>
</html>

View File

@ -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" %>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>비빔밥 — WebGL 게임</title>
<link rel="stylesheet" href="${pageContext.request.contextPath}/css/main.css"/>
</head>
<body>
<header class="site-header">
<div class="site-header__inner">
<a class="logo" href="${pageContext.request.contextPath}/">비빔밥</a>
<form class="search" method="get" action="${pageContext.request.contextPath}/">
<input type="search" name="q" value="<c:out value='${q}'/>" placeholder="게임 이름 또는 제작자 검색" aria-label="검색"/>
<button type="submit">검색</button>
</form>
<nav class="auth">
<sec:authorize access="isAuthenticated()">
<a href="${pageContext.request.contextPath}/games/new">게임 올리기</a>
<form action="${pageContext.request.contextPath}/logout" method="post" class="logout-form">
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}"/>
<button type="submit" class="link-btn">로그아웃</button>
</form>
</sec:authorize>
<sec:authorize access="!isAuthenticated()">
<a href="${pageContext.request.contextPath}/login">로그인</a>
<a class="btn-primary" href="${pageContext.request.contextPath}/register">회원가입</a>
</sec:authorize>
</nav>
</div>
</header>
<main class="container">
<p class="lead">로그인 없이 둘러볼 수 있습니다. 플레이는 각 카드에서 이동합니다.</p>
<c:if test="${empty games}">
<p class="empty">등록된 게임이 없거나 검색 결과가 없습니다.</p>
</c:if>
<ul class="card-grid">
<c:forEach var="g" items="${games}">
<li>
<article class="card">
<a class="card__media" href="${pageContext.request.contextPath}/play/${g.slug}/">
<c:choose>
<c:when test="${not empty g.thumbnailUrl}">
<img src="<c:out value='${g.thumbnailUrl}'/>" alt="" loading="lazy"
onerror="this.onerror=null;this.src='${pageContext.request.contextPath}/img/placeholder-game.svg';"/>
</c:when>
<c:otherwise>
<img src="${pageContext.request.contextPath}/img/placeholder-game.svg" alt="" loading="lazy"/>
</c:otherwise>
</c:choose>
</a>
<div class="card__body">
<h2 class="card__title">
<a href="${pageContext.request.contextPath}/play/${g.slug}/"><c:out value="${g.title}"/></a>
</h2>
<p class="card__author">제작자 <span class="card__author-name"><c:out value="${g.owner.username}"/></span></p>
</div>
</article>
</li>
</c:forEach>
</ul>
</main>
</body>
</html>

View File

@ -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" %>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>로그인 — 비빔밥</title>
<link rel="stylesheet" href="${pageContext.request.contextPath}/css/main.css"/>
</head>
<body>
<main class="container">
<h1>로그인</h1>
<c:if test="${param.registered != null}">
<p class="lead">가입이 완료되었습니다. 로그인해 주세요.</p>
</c:if>
<c:if test="${param.error != null}">
<p class="lead" style="color:#f88;">아이디 또는 비밀번호가 올바르지 않습니다.</p>
</c:if>
<form action="${pageContext.request.contextPath}/login" method="post">
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}"/>
<label>아이디
<input type="text" name="username" autocomplete="username" required/>
</label>
<label>비밀번호
<input type="password" name="password" autocomplete="current-password" required/>
</label>
<button type="submit">로그인</button>
</form>
<p><a href="${pageContext.request.contextPath}/register">회원가입</a> · <a href="${pageContext.request.contextPath}/">홈</a></p>
</main>
</body>
</html>

View File

@ -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" %>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>회원가입 — 비빔밥</title>
<link rel="stylesheet" href="${pageContext.request.contextPath}/css/main.css"/>
</head>
<body>
<main class="container">
<h1>회원가입</h1>
<form:form modelAttribute="form" method="post" action="${pageContext.request.contextPath}/register">
<form:errors path="*" cssClass="lead" element="div"/>
<label>아이디
<form:input path="username" autocomplete="username" required="true"/>
</label>
<form:errors path="username" cssClass="lead" element="p"/>
<label>비밀번호
<form:password path="password" autocomplete="new-password" required="true"/>
</label>
<form:errors path="password" cssClass="lead" element="p"/>
<button type="submit">가입</button>
</form:form>
<p><a href="${pageContext.request.contextPath}/login">로그인</a> · <a href="${pageContext.request.contextPath}/">홈</a></p>
</main>
</body>
</html>

View File

@ -2,8 +2,10 @@ package com.pandoli365.bibimbap;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
@SpringBootTest @SpringBootTest
@ActiveProfiles("test")
class BibimbapApplicationTests { class BibimbapApplicationTests {
@Test @Test

View File

@ -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