register, login, create and manage sites and pages

This commit is contained in:
Krrish Ghimire
2026-06-30 11:13:55 +05:45
commit 65f38d68c5
110 changed files with 28049 additions and 0 deletions

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

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

View File

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

View File

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

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

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,5 @@
package com.indie.model;
public enum AuthProvider {
LOCAL, GOOGLE, GITHUB, OPENID
}

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

View File

@@ -0,0 +1,5 @@
package com.indie.model;
public enum ComponentType {
HEADING, TEXT, IMAGE, BUTTON, DIVIDER, GALLERY, VIDEO, CONTACT_FORM, MAP, CUSTOM_HTML
}

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

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

View File

@@ -0,0 +1,5 @@
package com.indie.model;
public enum SiteStatus {
DRAFT, PUBLISHED
}

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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