register, login, create and manage sites and pages
This commit is contained in:
12
backend/src/main/java/com/indie/IndieApplication.java
Normal file
12
backend/src/main/java/com/indie/IndieApplication.java
Normal file
@@ -0,0 +1,12 @@
|
||||
package com.indie;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class IndieApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(IndieApplication.class, args);
|
||||
}
|
||||
}
|
||||
47
backend/src/main/java/com/indie/config/GitConfig.java
Normal file
47
backend/src/main/java/com/indie/config/GitConfig.java
Normal file
@@ -0,0 +1,47 @@
|
||||
package com.indie.config;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
@Component
|
||||
public class GitConfig {
|
||||
|
||||
@Value("${indie.git.repos-path:/home/krrish/git/sites/}")
|
||||
private String reposPath;
|
||||
|
||||
@Value("${indie.git.author-name:Indie Platform}")
|
||||
private String authorName;
|
||||
|
||||
@Value("${indie.git.author-email:git@indie.local}")
|
||||
private String authorEmail;
|
||||
|
||||
@PostConstruct
|
||||
public void init() throws IOException {
|
||||
Path path = Paths.get(reposPath);
|
||||
if (!Files.exists(path)) {
|
||||
Files.createDirectories(path);
|
||||
}
|
||||
}
|
||||
|
||||
public Path getReposPath() {
|
||||
return Paths.get(reposPath);
|
||||
}
|
||||
|
||||
public String getAuthorName() {
|
||||
return authorName;
|
||||
}
|
||||
|
||||
public String getAuthorEmail() {
|
||||
return authorEmail;
|
||||
}
|
||||
|
||||
public Path getSiteRepoPath(String siteId) {
|
||||
return getReposPath().resolve(siteId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.indie.config;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ProblemDetail;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ProblemDetail handleBadRequest(IllegalArgumentException ex) {
|
||||
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
|
||||
String message = ex.getBindingResult().getFieldErrors().stream()
|
||||
.map(FieldError::getDefaultMessage)
|
||||
.collect(Collectors.joining(", "));
|
||||
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, message);
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ProblemDetail handleGeneral(Exception ex) {
|
||||
return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
"An unexpected error occurred");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.indie.config;
|
||||
|
||||
import org.springframework.security.authentication.AbstractAuthenticationToken;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class JwtAuthenticationToken extends AbstractAuthenticationToken {
|
||||
|
||||
private final UserPrincipal principal;
|
||||
private final String token;
|
||||
|
||||
public JwtAuthenticationToken(UserPrincipal principal, String token) {
|
||||
super(List.of(() -> "ROLE_USER"));
|
||||
this.principal = principal;
|
||||
this.token = token;
|
||||
setAuthenticated(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getCredentials() {
|
||||
return token;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getPrincipal() {
|
||||
return principal;
|
||||
}
|
||||
}
|
||||
128
backend/src/main/java/com/indie/config/JwtConfig.java
Normal file
128
backend/src/main/java/com/indie/config/JwtConfig.java
Normal file
@@ -0,0 +1,128 @@
|
||||
package com.indie.config;
|
||||
|
||||
import com.indie.model.User;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.KeyPair;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.util.Date;
|
||||
|
||||
import static io.jsonwebtoken.Jwts.SIG.RS256;
|
||||
|
||||
@Component
|
||||
public class JwtConfig implements TokenService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(JwtConfig.class);
|
||||
|
||||
@Value("${indie.jwt.access-token-expiration:900000}")
|
||||
private long accessTokenExpiration;
|
||||
|
||||
@Value("${indie.jwt.refresh-token-expiration:604800000}")
|
||||
private long refreshTokenExpiration;
|
||||
|
||||
@Value("${indie.jwt.private-key-path:}")
|
||||
private String privateKeyPath;
|
||||
|
||||
@Value("${indie.jwt.public-key-path:}")
|
||||
private String publicKeyPath;
|
||||
|
||||
private PrivateKey privateKey;
|
||||
private PublicKey publicKey;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
if (privateKeyPath != null && !privateKeyPath.isBlank()
|
||||
&& publicKeyPath != null && !publicKeyPath.isBlank()) {
|
||||
loadOrGenerateKeys();
|
||||
} else {
|
||||
log.info("No key paths configured, generating ephemeral keypair (tokens invalidated on restart)");
|
||||
KeyPair keyPair = RS256.keyPair().build();
|
||||
this.privateKey = keyPair.getPrivate();
|
||||
this.publicKey = keyPair.getPublic();
|
||||
}
|
||||
}
|
||||
|
||||
private void loadOrGenerateKeys() {
|
||||
Path privPath = Path.of(privateKeyPath);
|
||||
Path pubPath = Path.of(publicKeyPath);
|
||||
|
||||
if (Files.exists(privPath) && Files.exists(pubPath)) {
|
||||
try {
|
||||
byte[] privBytes = Files.readAllBytes(privPath);
|
||||
byte[] pubBytes = Files.readAllBytes(pubPath);
|
||||
KeyFactory kf = KeyFactory.getInstance("RSA");
|
||||
this.privateKey = kf.generatePrivate(new PKCS8EncodedKeySpec(privBytes));
|
||||
this.publicKey = kf.generatePublic(new X509EncodedKeySpec(pubBytes));
|
||||
log.info("Loaded JWT keypair from {} and {}", privateKeyPath, publicKeyPath);
|
||||
return;
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to load JWT keys from files, generating new ones: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
KeyPair keyPair = RS256.keyPair().build();
|
||||
this.privateKey = keyPair.getPrivate();
|
||||
this.publicKey = keyPair.getPublic();
|
||||
|
||||
Files.createDirectories(privPath.getParent());
|
||||
Files.write(privPath, keyPair.getPrivate().getEncoded());
|
||||
Files.write(pubPath, keyPair.getPublic().getEncoded());
|
||||
log.info("Generated and saved JWT keypair to {} and {}", privateKeyPath, publicKeyPath);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to generate JWT keypair", e);
|
||||
}
|
||||
}
|
||||
|
||||
public String generateAccessToken(User user) {
|
||||
return generateToken(user, accessTokenExpiration);
|
||||
}
|
||||
|
||||
public String generateRefreshToken(User user) {
|
||||
return generateToken(user, refreshTokenExpiration);
|
||||
}
|
||||
|
||||
private String generateToken(User user, long expiration) {
|
||||
Date now = new Date();
|
||||
return Jwts.builder()
|
||||
.subject(user.getId().toString())
|
||||
.claim("email", user.getEmail())
|
||||
.claim("name", user.getName())
|
||||
.issuedAt(now)
|
||||
.expiration(new Date(now.getTime() + expiration))
|
||||
.signWith(privateKey, RS256)
|
||||
.compact();
|
||||
}
|
||||
|
||||
public Claims validateToken(String token) {
|
||||
return Jwts.parser()
|
||||
.verifyWith(publicKey)
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
}
|
||||
|
||||
public long getAccessTokenExpiration() {
|
||||
return accessTokenExpiration;
|
||||
}
|
||||
|
||||
public long getRefreshTokenExpiration() {
|
||||
return refreshTokenExpiration;
|
||||
}
|
||||
|
||||
public PublicKey getPublicKey() {
|
||||
return publicKey;
|
||||
}
|
||||
}
|
||||
138
backend/src/main/java/com/indie/config/SecurityConfig.java
Normal file
138
backend/src/main/java/com/indie/config/SecurityConfig.java
Normal file
@@ -0,0 +1,138 @@
|
||||
package com.indie.config;
|
||||
|
||||
import com.indie.service.OAuth2SuccessHandler;
|
||||
import com.indie.service.OAuth2UserService;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
private final JwtConfig jwtConfig;
|
||||
private final OAuth2UserService oAuth2UserService;
|
||||
private final OAuth2SuccessHandler oAuth2SuccessHandler;
|
||||
|
||||
@Value("${indie.cors.allowed-origins:http://localhost:4200}")
|
||||
private String[] allowedOrigins;
|
||||
|
||||
@Value("${spring.security.oauth2.client.registration.google.client-id:}")
|
||||
private String googleClientId;
|
||||
|
||||
@Value("${spring.security.oauth2.client.registration.github.client-id:}")
|
||||
private String githubClientId;
|
||||
|
||||
public SecurityConfig(JwtConfig jwtConfig, OAuth2UserService oAuth2UserService,
|
||||
OAuth2SuccessHandler oAuth2SuccessHandler) {
|
||||
this.jwtConfig = jwtConfig;
|
||||
this.oAuth2UserService = oAuth2UserService;
|
||||
this.oAuth2SuccessHandler = oAuth2SuccessHandler;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/api/auth/**").permitAll()
|
||||
.requestMatchers("/swagger-ui/**", "/api-docs/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/sites/**").authenticated()
|
||||
.anyRequest().authenticated()
|
||||
);
|
||||
|
||||
if (isOAuth2Configured()) {
|
||||
http.oauth2Login(oauth2 -> oauth2
|
||||
.userInfoEndpoint(userInfo -> userInfo
|
||||
.userService(oAuth2UserService)
|
||||
)
|
||||
.successHandler(oAuth2SuccessHandler)
|
||||
);
|
||||
}
|
||||
|
||||
http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
private boolean isOAuth2Configured() {
|
||||
return (googleClientId != null && !googleClientId.isBlank())
|
||||
|| (githubClientId != null && !githubClientId.isBlank());
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration config = new CorsConfiguration();
|
||||
config.setAllowedOrigins(List.of(allowedOrigins));
|
||||
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
|
||||
config.setAllowedHeaders(List.of("*"));
|
||||
config.setAllowCredentials(true);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", config);
|
||||
return source;
|
||||
}
|
||||
|
||||
private OncePerRequestFilter jwtAuthenticationFilter() {
|
||||
return new OncePerRequestFilter() {
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
String header = request.getHeader("Authorization");
|
||||
|
||||
if (header != null && header.startsWith("Bearer ")) {
|
||||
String token = header.substring(7);
|
||||
try {
|
||||
Claims claims = jwtConfig.validateToken(token);
|
||||
UUID userId = UUID.fromString(claims.getSubject());
|
||||
String email = claims.get("email", String.class);
|
||||
String name = claims.get("name", String.class);
|
||||
|
||||
UserPrincipal principal = new UserPrincipal(userId, email, name);
|
||||
JwtAuthenticationToken authentication =
|
||||
new JwtAuthenticationToken(principal, token);
|
||||
org.springframework.security.core.context.SecurityContextHolder
|
||||
.getContext().setAuthentication(authentication);
|
||||
} catch (Exception e) {
|
||||
org.springframework.security.core.context.SecurityContextHolder
|
||||
.clearContext();
|
||||
}
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
13
backend/src/main/java/com/indie/config/TokenService.java
Normal file
13
backend/src/main/java/com/indie/config/TokenService.java
Normal file
@@ -0,0 +1,13 @@
|
||||
package com.indie.config;
|
||||
|
||||
import com.indie.model.User;
|
||||
import io.jsonwebtoken.Claims;
|
||||
|
||||
public interface TokenService {
|
||||
|
||||
String generateAccessToken(User user);
|
||||
|
||||
String generateRefreshToken(User user);
|
||||
|
||||
Claims validateToken(String token);
|
||||
}
|
||||
47
backend/src/main/java/com/indie/config/UserPrincipal.java
Normal file
47
backend/src/main/java/com/indie/config/UserPrincipal.java
Normal file
@@ -0,0 +1,47 @@
|
||||
package com.indie.config;
|
||||
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public record UserPrincipal(UUID id, String email, String name) implements UserDetails {
|
||||
|
||||
@Override
|
||||
public Collection<? extends GrantedAuthority> getAuthorities() {
|
||||
return List.of(new SimpleGrantedAuthority("ROLE_USER"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPassword() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getUsername() {
|
||||
return email;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAccountNonExpired() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAccountNonLocked() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isCredentialsNonExpired() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEnabled() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.indie.controller;
|
||||
|
||||
import com.indie.dto.AuthResponse;
|
||||
import com.indie.dto.LoginRequest;
|
||||
import com.indie.dto.RefreshTokenRequest;
|
||||
import com.indie.dto.RegisterRequest;
|
||||
import com.indie.service.AuthService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
public class AuthController {
|
||||
|
||||
private final AuthService authService;
|
||||
|
||||
public AuthController(AuthService authService) {
|
||||
this.authService = authService;
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
|
||||
AuthResponse response = authService.register(request);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<AuthResponse> login(@Valid @RequestBody LoginRequest request) {
|
||||
AuthResponse response = authService.login(request);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@PostMapping("/refresh")
|
||||
public ResponseEntity<AuthResponse> refresh(@Valid @RequestBody RefreshTokenRequest request) {
|
||||
AuthResponse response = authService.refreshToken(request);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@PostMapping("/logout")
|
||||
public ResponseEntity<Void> logout() {
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
124
backend/src/main/java/com/indie/controller/PageController.java
Normal file
124
backend/src/main/java/com/indie/controller/PageController.java
Normal file
@@ -0,0 +1,124 @@
|
||||
package com.indie.controller;
|
||||
|
||||
import com.indie.config.UserPrincipal;
|
||||
import com.indie.dto.PageRequest;
|
||||
import com.indie.dto.PageResponse;
|
||||
import com.indie.model.Page;
|
||||
import com.indie.model.Site;
|
||||
import com.indie.repository.PageRepository;
|
||||
import com.indie.repository.SiteRepository;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/sites/{siteId}/pages")
|
||||
public class PageController {
|
||||
|
||||
private final PageRepository pageRepository;
|
||||
private final SiteRepository siteRepository;
|
||||
|
||||
public PageController(PageRepository pageRepository, SiteRepository siteRepository) {
|
||||
this.pageRepository = pageRepository;
|
||||
this.siteRepository = siteRepository;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<PageResponse>> listPages(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable UUID siteId) {
|
||||
Site site = findSiteForUser(siteId, principal.id());
|
||||
List<Page> pages = pageRepository.findBySiteIdOrderByOrderIndexAsc(site.getId());
|
||||
List<PageResponse> response = pages.stream()
|
||||
.map(PageResponse::from)
|
||||
.toList();
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<PageResponse> createPage(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable UUID siteId,
|
||||
@Valid @RequestBody PageRequest request) {
|
||||
Site site = findSiteForUser(siteId, principal.id());
|
||||
|
||||
if (pageRepository.existsBySiteIdAndSlug(site.getId(), request.getSlug())) {
|
||||
throw new IllegalArgumentException("A page with this slug already exists");
|
||||
}
|
||||
|
||||
Page page = Page.builder()
|
||||
.siteId(site.getId())
|
||||
.title(request.getTitle())
|
||||
.slug(request.getSlug())
|
||||
.seoTitle(request.getSeoTitle())
|
||||
.seoDescription(request.getSeoDescription())
|
||||
.orderIndex(request.getOrderIndex())
|
||||
.build();
|
||||
|
||||
page = pageRepository.save(page);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(PageResponse.from(page));
|
||||
}
|
||||
|
||||
@GetMapping("/{pageId}")
|
||||
public ResponseEntity<PageResponse> getPage(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable UUID siteId,
|
||||
@PathVariable UUID pageId) {
|
||||
Site site = findSiteForUser(siteId, principal.id());
|
||||
Page page = findPageForSite(pageId, site.getId());
|
||||
return ResponseEntity.ok(PageResponse.from(page));
|
||||
}
|
||||
|
||||
@PutMapping("/{pageId}")
|
||||
public ResponseEntity<PageResponse> updatePage(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable UUID siteId,
|
||||
@PathVariable UUID pageId,
|
||||
@Valid @RequestBody PageRequest request) {
|
||||
Site site = findSiteForUser(siteId, principal.id());
|
||||
Page page = findPageForSite(pageId, site.getId());
|
||||
|
||||
if (!page.getSlug().equals(request.getSlug())
|
||||
&& pageRepository.existsBySiteIdAndSlug(site.getId(), request.getSlug())) {
|
||||
throw new IllegalArgumentException("A page with this slug already exists");
|
||||
}
|
||||
|
||||
page.setTitle(request.getTitle());
|
||||
page.setSlug(request.getSlug());
|
||||
page.setSeoTitle(request.getSeoTitle());
|
||||
page.setSeoDescription(request.getSeoDescription());
|
||||
page.setOrderIndex(request.getOrderIndex());
|
||||
|
||||
page = pageRepository.save(page);
|
||||
return ResponseEntity.ok(PageResponse.from(page));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{pageId}")
|
||||
public ResponseEntity<Void> deletePage(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable UUID siteId,
|
||||
@PathVariable UUID pageId) {
|
||||
Site site = findSiteForUser(siteId, principal.id());
|
||||
Page page = findPageForSite(pageId, site.getId());
|
||||
pageRepository.delete(page);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
private Site findSiteForUser(UUID siteId, UUID userId) {
|
||||
Site site = siteRepository.findById(siteId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Site not found"));
|
||||
if (!site.getUserId().equals(userId)) {
|
||||
throw new IllegalArgumentException("Site not found");
|
||||
}
|
||||
return site;
|
||||
}
|
||||
|
||||
private Page findPageForSite(UUID pageId, UUID siteId) {
|
||||
Page page = pageRepository.findById(pageId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Page not found"));
|
||||
if (!page.getSiteId().equals(siteId)) {
|
||||
throw new IllegalArgumentException("Page not found");
|
||||
}
|
||||
return page;
|
||||
}
|
||||
}
|
||||
114
backend/src/main/java/com/indie/controller/SiteController.java
Normal file
114
backend/src/main/java/com/indie/controller/SiteController.java
Normal file
@@ -0,0 +1,114 @@
|
||||
package com.indie.controller;
|
||||
|
||||
import com.indie.config.UserPrincipal;
|
||||
import com.indie.dto.SiteRequest;
|
||||
import com.indie.dto.SiteResponse;
|
||||
import com.indie.model.Site;
|
||||
import com.indie.repository.SiteRepository;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/sites")
|
||||
public class SiteController {
|
||||
|
||||
private final SiteRepository siteRepository;
|
||||
|
||||
public SiteController(SiteRepository siteRepository) {
|
||||
this.siteRepository = siteRepository;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<SiteResponse>> listSites(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
List<Site> sites = siteRepository.findByUserIdOrderByCreatedAtDesc(principal.id());
|
||||
List<SiteResponse> response = sites.stream()
|
||||
.map(SiteResponse::from)
|
||||
.toList();
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<SiteResponse> createSite(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@Valid @RequestBody SiteRequest request) {
|
||||
String subdomain = sanitizeSubdomain(request.getSubdomain());
|
||||
if (subdomain != null && siteRepository.existsBySubdomain(subdomain)) {
|
||||
throw new IllegalArgumentException("Subdomain already taken");
|
||||
}
|
||||
|
||||
Site site = Site.builder()
|
||||
.userId(principal.id())
|
||||
.name(request.getName())
|
||||
.description(request.getDescription())
|
||||
.subdomain(subdomain)
|
||||
.themeConfig(request.getThemeConfig())
|
||||
.build();
|
||||
|
||||
site = siteRepository.save(site);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(SiteResponse.from(site));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<SiteResponse> getSite(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable UUID id) {
|
||||
Site site = findSiteForUser(id, principal.id());
|
||||
return ResponseEntity.ok(SiteResponse.from(site));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<SiteResponse> updateSite(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable UUID id,
|
||||
@Valid @RequestBody SiteRequest request) {
|
||||
Site site = findSiteForUser(id, principal.id());
|
||||
|
||||
String newSubdomain = sanitizeSubdomain(request.getSubdomain());
|
||||
boolean subdomainChanged = (site.getSubdomain() != null && !site.getSubdomain().equals(newSubdomain))
|
||||
|| (site.getSubdomain() == null && newSubdomain != null);
|
||||
if (subdomainChanged && newSubdomain != null && siteRepository.existsBySubdomain(newSubdomain)) {
|
||||
throw new IllegalArgumentException("Subdomain already taken");
|
||||
}
|
||||
|
||||
site.setName(request.getName());
|
||||
site.setDescription(request.getDescription());
|
||||
site.setSubdomain(newSubdomain);
|
||||
if (request.getThemeConfig() != null) {
|
||||
site.setThemeConfig(request.getThemeConfig());
|
||||
}
|
||||
|
||||
site = siteRepository.save(site);
|
||||
return ResponseEntity.ok(SiteResponse.from(site));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> deleteSite(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable UUID id) {
|
||||
Site site = findSiteForUser(id, principal.id());
|
||||
siteRepository.delete(site);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
private Site findSiteForUser(UUID siteId, UUID userId) {
|
||||
Site site = siteRepository.findById(siteId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Site not found"));
|
||||
if (!site.getUserId().equals(userId)) {
|
||||
throw new IllegalArgumentException("Site not found");
|
||||
}
|
||||
return site;
|
||||
}
|
||||
|
||||
private String sanitizeSubdomain(String subdomain) {
|
||||
if (subdomain == null || subdomain.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String cleaned = subdomain.strip().toLowerCase();
|
||||
if (!cleaned.matches("^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$")) {
|
||||
throw new IllegalArgumentException("Subdomain must be 3-63 characters, lowercase alphanumeric with hyphens");
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
}
|
||||
37
backend/src/main/java/com/indie/dto/AuthResponse.java
Normal file
37
backend/src/main/java/com/indie/dto/AuthResponse.java
Normal file
@@ -0,0 +1,37 @@
|
||||
package com.indie.dto;
|
||||
|
||||
import com.indie.model.AuthProvider;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class AuthResponse {
|
||||
|
||||
private String accessToken;
|
||||
private String refreshToken;
|
||||
private String tokenType;
|
||||
private UserInfo user;
|
||||
|
||||
public AuthResponse(String accessToken, String refreshToken, UserInfo user) {
|
||||
this.accessToken = accessToken;
|
||||
this.refreshToken = refreshToken;
|
||||
this.tokenType = "Bearer";
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public static class UserInfo {
|
||||
private UUID id;
|
||||
private String email;
|
||||
private String name;
|
||||
private String avatarUrl;
|
||||
private AuthProvider provider;
|
||||
}
|
||||
}
|
||||
16
backend/src/main/java/com/indie/dto/LoginRequest.java
Normal file
16
backend/src/main/java/com/indie/dto/LoginRequest.java
Normal file
@@ -0,0 +1,16 @@
|
||||
package com.indie.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class LoginRequest {
|
||||
|
||||
@NotBlank(message = "Email is required")
|
||||
@Email(message = "Invalid email format")
|
||||
private String email;
|
||||
|
||||
@NotBlank(message = "Password is required")
|
||||
private String password;
|
||||
}
|
||||
22
backend/src/main/java/com/indie/dto/PageRequest.java
Normal file
22
backend/src/main/java/com/indie/dto/PageRequest.java
Normal file
@@ -0,0 +1,22 @@
|
||||
package com.indie.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class PageRequest {
|
||||
|
||||
@NotBlank(message = "Page title is required")
|
||||
private String title;
|
||||
|
||||
@NotBlank(message = "Page slug is required")
|
||||
@Pattern(regexp = "^[a-z0-9-]+$", message = "Slug must be lowercase alphanumeric with hyphens")
|
||||
private String slug;
|
||||
|
||||
private String seoTitle;
|
||||
|
||||
private String seoDescription;
|
||||
|
||||
private int orderIndex;
|
||||
}
|
||||
36
backend/src/main/java/com/indie/dto/PageResponse.java
Normal file
36
backend/src/main/java/com/indie/dto/PageResponse.java
Normal file
@@ -0,0 +1,36 @@
|
||||
package com.indie.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class PageResponse {
|
||||
|
||||
private UUID id;
|
||||
private UUID siteId;
|
||||
private String title;
|
||||
private String slug;
|
||||
private String seoTitle;
|
||||
private String seoDescription;
|
||||
private int orderIndex;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
public static PageResponse from(com.indie.model.Page page) {
|
||||
return PageResponse.builder()
|
||||
.id(page.getId())
|
||||
.siteId(page.getSiteId())
|
||||
.title(page.getTitle())
|
||||
.slug(page.getSlug())
|
||||
.seoTitle(page.getSeoTitle())
|
||||
.seoDescription(page.getSeoDescription())
|
||||
.orderIndex(page.getOrderIndex())
|
||||
.createdAt(page.getCreatedAt())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
11
backend/src/main/java/com/indie/dto/RefreshTokenRequest.java
Normal file
11
backend/src/main/java/com/indie/dto/RefreshTokenRequest.java
Normal file
@@ -0,0 +1,11 @@
|
||||
package com.indie.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class RefreshTokenRequest {
|
||||
|
||||
@NotBlank(message = "Refresh token is required")
|
||||
private String refreshToken;
|
||||
}
|
||||
21
backend/src/main/java/com/indie/dto/RegisterRequest.java
Normal file
21
backend/src/main/java/com/indie/dto/RegisterRequest.java
Normal file
@@ -0,0 +1,21 @@
|
||||
package com.indie.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class RegisterRequest {
|
||||
|
||||
@NotBlank(message = "Email is required")
|
||||
@Email(message = "Invalid email format")
|
||||
private String email;
|
||||
|
||||
@NotBlank(message = "Password is required")
|
||||
@Size(min = 8, max = 100, message = "Password must be between 8 and 100 characters")
|
||||
private String password;
|
||||
|
||||
@NotBlank(message = "Name is required")
|
||||
private String name;
|
||||
}
|
||||
19
backend/src/main/java/com/indie/dto/SiteRequest.java
Normal file
19
backend/src/main/java/com/indie/dto/SiteRequest.java
Normal file
@@ -0,0 +1,19 @@
|
||||
package com.indie.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
public class SiteRequest {
|
||||
|
||||
@NotBlank(message = "Site name is required")
|
||||
private String name;
|
||||
|
||||
private String description;
|
||||
|
||||
private String subdomain;
|
||||
|
||||
private Map<String, Object> themeConfig;
|
||||
}
|
||||
42
backend/src/main/java/com/indie/dto/SiteResponse.java
Normal file
42
backend/src/main/java/com/indie/dto/SiteResponse.java
Normal file
@@ -0,0 +1,42 @@
|
||||
package com.indie.dto;
|
||||
|
||||
import com.indie.model.SiteStatus;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class SiteResponse {
|
||||
|
||||
private UUID id;
|
||||
private UUID userId;
|
||||
private String name;
|
||||
private String description;
|
||||
private String subdomain;
|
||||
private Map<String, Object> themeConfig;
|
||||
private SiteStatus status;
|
||||
private LocalDateTime publishedAt;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public static SiteResponse from(com.indie.model.Site site) {
|
||||
return SiteResponse.builder()
|
||||
.id(site.getId())
|
||||
.userId(site.getUserId())
|
||||
.name(site.getName())
|
||||
.description(site.getDescription())
|
||||
.subdomain(site.getSubdomain())
|
||||
.themeConfig(site.getThemeConfig())
|
||||
.status(site.getStatus())
|
||||
.publishedAt(site.getPublishedAt())
|
||||
.createdAt(site.getCreatedAt())
|
||||
.updatedAt(site.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
5
backend/src/main/java/com/indie/model/AuthProvider.java
Normal file
5
backend/src/main/java/com/indie/model/AuthProvider.java
Normal file
@@ -0,0 +1,5 @@
|
||||
package com.indie.model;
|
||||
|
||||
public enum AuthProvider {
|
||||
LOCAL, GOOGLE, GITHUB, OPENID
|
||||
}
|
||||
47
backend/src/main/java/com/indie/model/Component.java
Normal file
47
backend/src/main/java/com/indie/model/Component.java
Normal file
@@ -0,0 +1,47 @@
|
||||
package com.indie.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "components")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class Component {
|
||||
|
||||
@Id
|
||||
private UUID id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private UUID pageId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
private ComponentType type;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
private Map<String, Object> config;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
private Map<String, Object> styles;
|
||||
|
||||
@Column(nullable = false)
|
||||
private int orderIndex;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (id == null) {
|
||||
id = UUID.randomUUID();
|
||||
}
|
||||
}
|
||||
}
|
||||
5
backend/src/main/java/com/indie/model/ComponentType.java
Normal file
5
backend/src/main/java/com/indie/model/ComponentType.java
Normal file
@@ -0,0 +1,5 @@
|
||||
package com.indie.model;
|
||||
|
||||
public enum ComponentType {
|
||||
HEADING, TEXT, IMAGE, BUTTON, DIVIDER, GALLERY, VIDEO, CONTACT_FORM, MAP, CUSTOM_HTML
|
||||
}
|
||||
50
backend/src/main/java/com/indie/model/Page.java
Normal file
50
backend/src/main/java/com/indie/model/Page.java
Normal file
@@ -0,0 +1,50 @@
|
||||
package com.indie.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "pages")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class Page {
|
||||
|
||||
@Id
|
||||
private UUID id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private UUID siteId;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String title;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String slug;
|
||||
|
||||
private String seoTitle;
|
||||
|
||||
@Column(length = 500)
|
||||
private String seoDescription;
|
||||
|
||||
@Column(nullable = false)
|
||||
private int orderIndex;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (id == null) {
|
||||
id = UUID.randomUUID();
|
||||
}
|
||||
createdAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
67
backend/src/main/java/com/indie/model/Site.java
Normal file
67
backend/src/main/java/com/indie/model/Site.java
Normal file
@@ -0,0 +1,67 @@
|
||||
package com.indie.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "sites")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class Site {
|
||||
|
||||
@Id
|
||||
private UUID id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private UUID userId;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(length = 1000)
|
||||
private String description;
|
||||
|
||||
@Column(unique = true)
|
||||
private String subdomain;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
private Map<String, Object> themeConfig;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
@Builder.Default
|
||||
private SiteStatus status = SiteStatus.DRAFT;
|
||||
|
||||
private LocalDateTime publishedAt;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (id == null) {
|
||||
id = UUID.randomUUID();
|
||||
}
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
5
backend/src/main/java/com/indie/model/SiteStatus.java
Normal file
5
backend/src/main/java/com/indie/model/SiteStatus.java
Normal file
@@ -0,0 +1,5 @@
|
||||
package com.indie.model;
|
||||
|
||||
public enum SiteStatus {
|
||||
DRAFT, PUBLISHED
|
||||
}
|
||||
61
backend/src/main/java/com/indie/model/User.java
Normal file
61
backend/src/main/java/com/indie/model/User.java
Normal file
@@ -0,0 +1,61 @@
|
||||
package com.indie.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "users")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class User {
|
||||
|
||||
@Id
|
||||
private UUID id;
|
||||
|
||||
@Column(nullable = false, unique = true)
|
||||
private String email;
|
||||
|
||||
private String password;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
private String avatarUrl;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
private AuthProvider provider;
|
||||
|
||||
private String providerId;
|
||||
|
||||
@Builder.Default
|
||||
private boolean emailVerified = false;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (id == null) {
|
||||
id = UUID.randomUUID();
|
||||
}
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.indie.repository;
|
||||
|
||||
import com.indie.model.Component;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface ComponentRepository extends JpaRepository<Component, UUID> {
|
||||
|
||||
List<Component> findByPageIdOrderByOrderIndexAsc(UUID pageId);
|
||||
|
||||
void deleteByPageId(UUID pageId);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.indie.repository;
|
||||
|
||||
import com.indie.model.Page;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface PageRepository extends JpaRepository<Page, UUID> {
|
||||
|
||||
List<Page> findBySiteIdOrderByOrderIndexAsc(UUID siteId);
|
||||
|
||||
Optional<Page> findBySiteIdAndSlug(UUID siteId, String slug);
|
||||
|
||||
boolean existsBySiteIdAndSlug(UUID siteId, String slug);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.indie.repository;
|
||||
|
||||
import com.indie.model.Site;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface SiteRepository extends JpaRepository<Site, UUID> {
|
||||
|
||||
List<Site> findByUserIdOrderByCreatedAtDesc(UUID userId);
|
||||
|
||||
Optional<Site> findBySubdomain(String subdomain);
|
||||
|
||||
boolean existsBySubdomain(String subdomain);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.indie.repository;
|
||||
|
||||
import com.indie.model.AuthProvider;
|
||||
import com.indie.model.User;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface UserRepository extends JpaRepository<User, UUID> {
|
||||
|
||||
Optional<User> findByEmail(String email);
|
||||
|
||||
Optional<User> findByProviderAndProviderId(AuthProvider provider, String providerId);
|
||||
|
||||
boolean existsByEmail(String email);
|
||||
}
|
||||
96
backend/src/main/java/com/indie/service/AuthService.java
Normal file
96
backend/src/main/java/com/indie/service/AuthService.java
Normal file
@@ -0,0 +1,96 @@
|
||||
package com.indie.service;
|
||||
|
||||
import com.indie.config.TokenService;
|
||||
import com.indie.dto.AuthResponse;
|
||||
import com.indie.dto.LoginRequest;
|
||||
import com.indie.dto.RefreshTokenRequest;
|
||||
import com.indie.dto.RegisterRequest;
|
||||
import com.indie.model.AuthProvider;
|
||||
import com.indie.model.User;
|
||||
import com.indie.repository.UserRepository;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AuthService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final TokenService jwtConfig;
|
||||
|
||||
@Transactional
|
||||
public AuthResponse register(RegisterRequest request) {
|
||||
String email = request.getEmail().toLowerCase().strip();
|
||||
|
||||
if (userRepository.existsByEmail(email)) {
|
||||
throw new IllegalArgumentException("Email already registered");
|
||||
}
|
||||
|
||||
User user = User.builder()
|
||||
.email(email)
|
||||
.password(passwordEncoder.encode(request.getPassword()))
|
||||
.name(request.getName())
|
||||
.provider(AuthProvider.LOCAL)
|
||||
.emailVerified(false)
|
||||
.build();
|
||||
|
||||
user = userRepository.save(user);
|
||||
return generateAuthResponse(user);
|
||||
}
|
||||
|
||||
public AuthResponse login(LoginRequest request) {
|
||||
String email = request.getEmail().toLowerCase().strip();
|
||||
User user = userRepository.findByEmail(email)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Invalid email or password"));
|
||||
|
||||
if (user.getProvider() != AuthProvider.LOCAL) {
|
||||
throw new IllegalArgumentException("Please sign in with " + user.getProvider().name().toLowerCase());
|
||||
}
|
||||
|
||||
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
|
||||
throw new IllegalArgumentException("Invalid email or password");
|
||||
}
|
||||
|
||||
return generateAuthResponse(user);
|
||||
}
|
||||
|
||||
public AuthResponse refreshToken(RefreshTokenRequest request) {
|
||||
try {
|
||||
Claims claims = jwtConfig.validateToken(request.getRefreshToken());
|
||||
|
||||
UUID userId = UUID.fromString(claims.getSubject());
|
||||
User user = userRepository.findById(userId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||
|
||||
return generateAuthResponse(user);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException("Invalid or expired refresh token");
|
||||
}
|
||||
}
|
||||
|
||||
private AuthResponse generateAuthResponse(User user) {
|
||||
String accessToken = jwtConfig.generateAccessToken(user);
|
||||
String refreshToken = jwtConfig.generateRefreshToken(user);
|
||||
|
||||
AuthResponse.UserInfo userInfo = AuthResponse.UserInfo.builder()
|
||||
.id(user.getId())
|
||||
.email(user.getEmail())
|
||||
.name(user.getName())
|
||||
.avatarUrl(user.getAvatarUrl())
|
||||
.provider(user.getProvider())
|
||||
.build();
|
||||
|
||||
return AuthResponse.builder()
|
||||
.accessToken(accessToken)
|
||||
.refreshToken(refreshToken)
|
||||
.tokenType("Bearer")
|
||||
.user(userInfo)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.indie.service;
|
||||
|
||||
import com.indie.model.User;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class CustomOAuth2User implements OAuth2User {
|
||||
|
||||
private final User user;
|
||||
private final Map<String, Object> attributes;
|
||||
|
||||
public CustomOAuth2User(User user, Map<String, Object> attributes) {
|
||||
this.user = user;
|
||||
this.attributes = attributes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> getAttributes() {
|
||||
return attributes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<? extends GrantedAuthority> getAuthorities() {
|
||||
return List.of(new SimpleGrantedAuthority("ROLE_USER"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return user.getId().toString();
|
||||
}
|
||||
|
||||
public User getUser() {
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.indie.service;
|
||||
|
||||
import com.indie.config.JwtConfig;
|
||||
import com.indie.model.User;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@Component
|
||||
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
|
||||
|
||||
private final JwtConfig jwtConfig;
|
||||
|
||||
public OAuth2SuccessHandler(JwtConfig jwtConfig) {
|
||||
this.jwtConfig = jwtConfig;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAuthenticationSuccess(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
Authentication authentication) throws IOException, ServletException {
|
||||
if (authentication.getPrincipal() instanceof CustomOAuth2User oAuth2User) {
|
||||
User user = oAuth2User.getUser();
|
||||
|
||||
String accessToken = jwtConfig.generateAccessToken(user);
|
||||
String refreshToken = jwtConfig.generateRefreshToken(user);
|
||||
|
||||
String redirectUrl = "http://localhost:4200/oauth2/callback"
|
||||
+ "?accessToken=" + accessToken
|
||||
+ "&refreshToken=" + refreshToken;
|
||||
|
||||
getRedirectStrategy().sendRedirect(request, response, redirectUrl);
|
||||
} else {
|
||||
super.onAuthenticationSuccess(request, response, authentication);
|
||||
}
|
||||
}
|
||||
}
|
||||
110
backend/src/main/java/com/indie/service/OAuth2UserService.java
Normal file
110
backend/src/main/java/com/indie/service/OAuth2UserService.java
Normal file
@@ -0,0 +1,110 @@
|
||||
package com.indie.service;
|
||||
|
||||
import com.indie.model.AuthProvider;
|
||||
import com.indie.model.User;
|
||||
import com.indie.repository.UserRepository;
|
||||
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class OAuth2UserService extends DefaultOAuth2UserService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public OAuth2UserService(UserRepository userRepository) {
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
|
||||
OAuth2User oAuth2User = super.loadUser(userRequest);
|
||||
|
||||
String registrationId = userRequest.getClientRegistration().getRegistrationId();
|
||||
AuthProvider provider = switch (registrationId) {
|
||||
case "google" -> AuthProvider.GOOGLE;
|
||||
case "github" -> AuthProvider.GITHUB;
|
||||
default -> AuthProvider.OPENID;
|
||||
};
|
||||
|
||||
String providerId = getProviderId(oAuth2User, provider);
|
||||
String email = getAttribute(oAuth2User, "email");
|
||||
if (email != null) {
|
||||
email = email.toLowerCase().strip();
|
||||
}
|
||||
String name = getAttribute(oAuth2User, "name");
|
||||
String avatarUrl = getAvatarUrl(oAuth2User, provider);
|
||||
|
||||
User user = findOrCreateUser(provider, providerId, email, name, avatarUrl);
|
||||
return new CustomOAuth2User(user, oAuth2User.getAttributes());
|
||||
}
|
||||
|
||||
String getProviderId(OAuth2User oAuth2User, AuthProvider provider) {
|
||||
return switch (provider) {
|
||||
case GOOGLE -> oAuth2User.getAttribute("sub");
|
||||
case GITHUB -> oAuth2User.getAttribute("id").toString();
|
||||
default -> oAuth2User.getName();
|
||||
};
|
||||
}
|
||||
|
||||
String getAttribute(OAuth2User oAuth2User, String key) {
|
||||
Object value = oAuth2User.getAttribute(key);
|
||||
return value != null ? value.toString() : null;
|
||||
}
|
||||
|
||||
String getAvatarUrl(OAuth2User oAuth2User, AuthProvider provider) {
|
||||
return switch (provider) {
|
||||
case GOOGLE -> oAuth2User.getAttribute("picture");
|
||||
case GITHUB -> oAuth2User.getAttribute("avatar_url");
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
User findOrCreateUser(AuthProvider provider, String providerId,
|
||||
String email, String name, String avatarUrl) {
|
||||
Optional<User> existing = userRepository.findByProviderAndProviderId(provider, providerId);
|
||||
|
||||
if (existing.isPresent()) {
|
||||
User user = existing.get();
|
||||
user.setName(name);
|
||||
if (avatarUrl != null) {
|
||||
user.setAvatarUrl(avatarUrl);
|
||||
}
|
||||
if (email != null && !email.equals(user.getEmail())) {
|
||||
user.setEmail(email);
|
||||
}
|
||||
return userRepository.save(user);
|
||||
}
|
||||
|
||||
if (email != null) {
|
||||
Optional<User> emailUser = userRepository.findByEmail(email);
|
||||
if (emailUser.isPresent()) {
|
||||
User user = emailUser.get();
|
||||
user.setProvider(provider);
|
||||
user.setProviderId(providerId);
|
||||
user.setEmailVerified(true);
|
||||
if (avatarUrl != null) {
|
||||
user.setAvatarUrl(avatarUrl);
|
||||
}
|
||||
return userRepository.save(user);
|
||||
}
|
||||
}
|
||||
|
||||
User newUser = User.builder()
|
||||
.email(email != null ? email : providerId + "@" + provider.name().toLowerCase() + ".indie")
|
||||
.name(name != null ? name : "User")
|
||||
.avatarUrl(avatarUrl)
|
||||
.provider(provider)
|
||||
.providerId(providerId)
|
||||
.emailVerified(true)
|
||||
.build();
|
||||
|
||||
return userRepository.save(newUser);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user