비빔밥 1차 업데이트
This commit is contained in:
parent
4ec638090a
commit
6ebf26feb3
|
|
@ -0,0 +1,5 @@
|
||||||
|
# 로컬 전용 설정 (DB 비밀번호 등)
|
||||||
|
src/main/resources/application.properties
|
||||||
|
|
||||||
|
# Maven
|
||||||
|
target/
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"java.compile.nullAnalysis.mode": "automatic",
|
||||||
|
"java.configuration.updateBuildConfiguration": "interactive"
|
||||||
|
}
|
||||||
|
|
@ -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
41
pom.xml
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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("게임을 찾을 수 없습니다."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
spring.application.name=bibimbap
|
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 |
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue