diff --git a/onyx-api/.gitignore b/onyx-api/.gitignore index b8163f5..4679c7e 100644 --- a/onyx-api/.gitignore +++ b/onyx-api/.gitignore @@ -34,3 +34,6 @@ build/ *.mv.db *.db + +*.der +*.pem diff --git a/onyx-api/pom.xml b/onyx-api/pom.xml index 64667be..73ce859 100644 --- a/onyx-api/pom.xml +++ b/onyx-api/pom.xml @@ -5,7 +5,7 @@ org.springframework.boot spring-boot-starter-parent - 3.1.4 + 3.1.5 com.andrewlalis @@ -56,6 +56,25 @@ spring-security-test test + + + + io.jsonwebtoken + jjwt-api + 0.11.5 + + + io.jsonwebtoken + jjwt-impl + 0.11.5 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.11.5 + runtime + diff --git a/onyx-api/src/main/java/com/andrewlalis/onyx/auth/api/AuthController.java b/onyx-api/src/main/java/com/andrewlalis/onyx/auth/api/AuthController.java new file mode 100644 index 0000000..ba4dda1 --- /dev/null +++ b/onyx-api/src/main/java/com/andrewlalis/onyx/auth/api/AuthController.java @@ -0,0 +1,31 @@ +package com.andrewlalis.onyx.auth.api; + +import com.andrewlalis.onyx.auth.model.User; +import com.andrewlalis.onyx.auth.service.TokenService; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequiredArgsConstructor +public class AuthController { + private final TokenService tokenService; + + @PostMapping("/auth/login") + public TokenPair login(@RequestBody LoginRequest loginRequest) { + return tokenService.generateTokenPair(loginRequest); + } + + @GetMapping("/auth/access") + public Object getAccessToken(HttpServletRequest request) { + return Map.of("accessToken", tokenService.generateAccessToken(request)); + } + + @DeleteMapping("/auth/refresh-tokens") + public void removeAllRefreshTokens(@AuthenticationPrincipal User user) { + tokenService.removeAllRefreshTokens(user); + } +} diff --git a/onyx-api/src/main/java/com/andrewlalis/onyx/auth/api/LoginRequest.java b/onyx-api/src/main/java/com/andrewlalis/onyx/auth/api/LoginRequest.java new file mode 100644 index 0000000..313c139 --- /dev/null +++ b/onyx-api/src/main/java/com/andrewlalis/onyx/auth/api/LoginRequest.java @@ -0,0 +1,3 @@ +package com.andrewlalis.onyx.auth.api; + +public record LoginRequest(String username, String password) {} diff --git a/onyx-api/src/main/java/com/andrewlalis/onyx/auth/api/TokenPair.java b/onyx-api/src/main/java/com/andrewlalis/onyx/auth/api/TokenPair.java new file mode 100644 index 0000000..8aa3d41 --- /dev/null +++ b/onyx-api/src/main/java/com/andrewlalis/onyx/auth/api/TokenPair.java @@ -0,0 +1,3 @@ +package com.andrewlalis.onyx.auth.api; + +public record TokenPair(String refreshToken, String accessToken) {} diff --git a/onyx-api/src/main/java/com/andrewlalis/onyx/auth/components/JwtFilter.java b/onyx-api/src/main/java/com/andrewlalis/onyx/auth/components/JwtFilter.java new file mode 100644 index 0000000..6d80e55 --- /dev/null +++ b/onyx-api/src/main/java/com/andrewlalis/onyx/auth/components/JwtFilter.java @@ -0,0 +1,45 @@ +package com.andrewlalis.onyx.auth.components; + +import com.andrewlalis.onyx.auth.service.TokenService; +import com.andrewlalis.onyx.auth.service.UserService; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +@Slf4j +public class JwtFilter extends OncePerRequestFilter { + private final UserService userService; + private final TokenService tokenService; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + try { + Jws jws = tokenService.getToken(request); + if (jws != null) { + long userId = Long.parseLong(jws.getBody().getSubject()); + userService.findById(userId).ifPresent(user -> SecurityContextHolder.getContext() + .setAuthentication(new TokenAuthentication(user, jws)) + ); + } + } catch (Exception e) { + log.warn("Exception occurred in JwtFilter.", e); + } + filterChain.doFilter(request, response); + } +} diff --git a/onyx-api/src/main/java/com/andrewlalis/onyx/auth/components/TokenAuthentication.java b/onyx-api/src/main/java/com/andrewlalis/onyx/auth/components/TokenAuthentication.java new file mode 100644 index 0000000..2311e3b --- /dev/null +++ b/onyx-api/src/main/java/com/andrewlalis/onyx/auth/components/TokenAuthentication.java @@ -0,0 +1,53 @@ +package com.andrewlalis.onyx.auth.components; + +import com.andrewlalis.onyx.auth.model.User; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; + +import java.util.Collection; +import java.util.Collections; + +/** + * The authentication implementation that's used when a user logs in with an + * access token. + * @param user The user that the token belongs to. + * @param jws The raw token. + */ +public record TokenAuthentication(User user, Jws jws) implements Authentication { + @Override + public Collection getAuthorities() { + return Collections.emptyList(); + } + + @Override + public Object getCredentials() { + return this.jws; + } + + @Override + public Object getDetails() { + return null; + } + + @Override + public User getPrincipal() { + return this.user; + } + + @Override + public boolean isAuthenticated() { + return true; + } + + @Override + public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException { + throw new RuntimeException("Cannot set the authenticated status of TokenAuthentication."); + } + + @Override + public String getName() { + return user.getUsername(); + } +} diff --git a/onyx-api/src/main/java/com/andrewlalis/onyx/auth/model/RefreshToken.java b/onyx-api/src/main/java/com/andrewlalis/onyx/auth/model/RefreshToken.java new file mode 100644 index 0000000..b8cae63 --- /dev/null +++ b/onyx-api/src/main/java/com/andrewlalis/onyx/auth/model/RefreshToken.java @@ -0,0 +1,39 @@ +package com.andrewlalis.onyx.auth.model; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "onyx_auth_refresh_token") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class RefreshToken { + public static final int SUFFIX_LENGTH = 7; + + @Id + private String hash; + + @Column(nullable = false, updatable = false, length = SUFFIX_LENGTH) + private String tokenSuffix; + + @ManyToOne(optional = false, fetch = FetchType.LAZY) + private User user; + + @CreationTimestamp + private LocalDateTime createdAt; + + @Column(nullable = false, updatable = false) + private LocalDateTime expiresAt; + + public RefreshToken(String hash, String tokenSuffix, User user, LocalDateTime expiresAt) { + this.hash = hash; + this.tokenSuffix = tokenSuffix; + this.user = user; + this.expiresAt = expiresAt; + } +} diff --git a/onyx-api/src/main/java/com/andrewlalis/onyx/auth/model/RefreshTokenRepository.java b/onyx-api/src/main/java/com/andrewlalis/onyx/auth/model/RefreshTokenRepository.java new file mode 100644 index 0000000..6792991 --- /dev/null +++ b/onyx-api/src/main/java/com/andrewlalis/onyx/auth/model/RefreshTokenRepository.java @@ -0,0 +1,10 @@ +package com.andrewlalis.onyx.auth.model; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface RefreshTokenRepository extends JpaRepository { + Iterable findAllByTokenSuffix(String suffix); + void deleteAllByUserId(long userId); +} diff --git a/onyx-api/src/main/java/com/andrewlalis/onyx/auth/model/User.java b/onyx-api/src/main/java/com/andrewlalis/onyx/auth/model/User.java new file mode 100644 index 0000000..07e2042 --- /dev/null +++ b/onyx-api/src/main/java/com/andrewlalis/onyx/auth/model/User.java @@ -0,0 +1,43 @@ +package com.andrewlalis.onyx.auth.model; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; + +/** + * The main authentication principal entity, representing a single user that + * has an account on this onyx node. + */ +@Entity +@Table(name = "onyx_auth_user") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class User { + public static final int MAX_USERNAME_LENGTH = 32; + public static final int MAX_DISPLAY_NAME_LENGTH = 64; + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private long id; + + @Column(nullable = false, unique = true, length = MAX_USERNAME_LENGTH) + private String username; + + @Column(nullable = false, length = MAX_DISPLAY_NAME_LENGTH) + private String displayName; + + @Column(nullable = false, length = 60) + private String passwordHash; + + @CreationTimestamp + private LocalDateTime createdAt; + + public User(String username, String displayName, String passwordHash) { + this.username = username; + this.displayName = displayName; + this.passwordHash = passwordHash; + } +} diff --git a/onyx-api/src/main/java/com/andrewlalis/onyx/auth/model/UserRepository.java b/onyx-api/src/main/java/com/andrewlalis/onyx/auth/model/UserRepository.java new file mode 100644 index 0000000..d413a06 --- /dev/null +++ b/onyx-api/src/main/java/com/andrewlalis/onyx/auth/model/UserRepository.java @@ -0,0 +1,11 @@ +package com.andrewlalis.onyx.auth.model; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository { + Optional findByUsername(String username); +} diff --git a/onyx-api/src/main/java/com/andrewlalis/onyx/auth/service/TokenService.java b/onyx-api/src/main/java/com/andrewlalis/onyx/auth/service/TokenService.java new file mode 100644 index 0000000..29bcd91 --- /dev/null +++ b/onyx-api/src/main/java/com/andrewlalis/onyx/auth/service/TokenService.java @@ -0,0 +1,161 @@ +package com.andrewlalis.onyx.auth.service; + +import com.andrewlalis.onyx.auth.api.LoginRequest; +import com.andrewlalis.onyx.auth.api.TokenPair; +import com.andrewlalis.onyx.auth.model.RefreshToken; +import com.andrewlalis.onyx.auth.model.RefreshTokenRepository; +import com.andrewlalis.onyx.auth.model.User; +import com.andrewlalis.onyx.auth.model.UserRepository; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.JwtParserBuilder; +import io.jsonwebtoken.Jwts; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.Date; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class TokenService { + private static final String BEARER_PREFIX = "Bearer "; + private static final String ISSUER = "Onyx API"; + + private PrivateKey signingKey; + private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(10); + private final RefreshTokenRepository refreshTokenRepository; + private final UserRepository userRepository; + + @Transactional + public TokenPair generateTokenPair(LoginRequest request) { + Optional optionalUser = userRepository.findByUsername(request.username()); + if ( + optionalUser.isEmpty() || + !passwordEncoder.matches(request.password(), optionalUser.get().getPasswordHash()) + ) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials."); + } + User user = optionalUser.get(); + String refreshToken = generateRefreshToken(user); + String accessToken = generateAccessToken(refreshToken); + return new TokenPair(refreshToken, accessToken); + } + + @Transactional + public String generateRefreshToken(User user) { + Instant expiresAt = OffsetDateTime.now(ZoneOffset.UTC).plusDays(7).toInstant(); + try { + String token = Jwts.builder() + .setSubject(Long.toString(user.getId())) + .setIssuer(ISSUER) + .setAudience("Onyx App") + .setExpiration(Date.from(expiresAt)) + .claim("username", user.getUsername()) + .claim("token-type", "refresh") + .signWith(getSigningKey()) + .compact(); + refreshTokenRepository.saveAndFlush(new RefreshToken( + passwordEncoder.encode(token), + token.substring(token.length() - RefreshToken.SUFFIX_LENGTH), + user, + LocalDateTime.ofInstant(expiresAt, ZoneOffset.UTC) + )); + return token; + } catch (Exception e) { + log.error("Failed to generate refresh token.", e); + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to generate token."); + } + } + + @Transactional(readOnly = true) + public String generateAccessToken(String refreshTokenString) { + String suffix = refreshTokenString.substring(refreshTokenString.length() - RefreshToken.SUFFIX_LENGTH); + RefreshToken refreshToken = null; + for (RefreshToken possibleToken : refreshTokenRepository.findAllByTokenSuffix(suffix)) { + if (passwordEncoder.matches(refreshTokenString, possibleToken.getHash())) { + refreshToken = possibleToken; + break; + } + } + if (refreshToken == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Unknown refresh token."); + } + if (refreshToken.getExpiresAt().isBefore(LocalDateTime.now(ZoneOffset.UTC))) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Refresh token is expired."); + } + Instant expiresAt = OffsetDateTime.now(ZoneOffset.UTC).plusHours(1).toInstant(); + try { + return Jwts.builder() + .setSubject(Long.toString(refreshToken.getUser().getId())) + .setIssuer(ISSUER) + .setAudience("Onyx App") + .setExpiration(Date.from(expiresAt)) + .claim("username", refreshToken.getUser().getUsername()) + .claim("token-type", "access") + .signWith(getSigningKey()) + .compact(); + } catch (Exception e) { + log.error("Failed to generate access token.", e); + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to generate token."); + } + } + + @Transactional(readOnly = true) + public String generateAccessToken(HttpServletRequest request) { + String refreshTokenString = extractBearerToken(request); + if (refreshTokenString == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing refresh token."); + } + return generateAccessToken(refreshTokenString); + } + + @Transactional + public void removeAllRefreshTokens(User user) { + refreshTokenRepository.deleteAllByUserId(user.getId()); + } + + public Jws getToken(HttpServletRequest request) throws Exception { + String rawToken = extractBearerToken(request); + if (rawToken == null) return null; + JwtParserBuilder parserBuilder = Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .requireIssuer(ISSUER); + return parserBuilder.build().parseClaimsJws(rawToken); + } + + private String extractBearerToken(HttpServletRequest request) { + String authorizationHeader = request.getHeader("Authorization"); + if (authorizationHeader == null || !authorizationHeader.startsWith(BEARER_PREFIX)) return null; + String rawToken = authorizationHeader.substring(BEARER_PREFIX.length()); + if (rawToken.isBlank()) return null; + return rawToken; + } + + private PrivateKey getSigningKey() throws Exception { + if (signingKey == null) { + byte[] keyBytes = Files.readAllBytes(Path.of("private_key.der")); + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes); + KeyFactory kf = KeyFactory.getInstance("RSA"); + signingKey = kf.generatePrivate(spec); + } + return signingKey; + } +} diff --git a/onyx-api/src/main/java/com/andrewlalis/onyx/auth/service/UserService.java b/onyx-api/src/main/java/com/andrewlalis/onyx/auth/service/UserService.java new file mode 100644 index 0000000..35c08a8 --- /dev/null +++ b/onyx-api/src/main/java/com/andrewlalis/onyx/auth/service/UserService.java @@ -0,0 +1,18 @@ +package com.andrewlalis.onyx.auth.service; + +import com.andrewlalis.onyx.auth.model.User; +import com.andrewlalis.onyx.auth.model.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class UserService { + private final UserRepository userRepository; + + public Optional findById(long id) { + return userRepository.findById(id); + } +} diff --git a/onyx-api/src/main/java/com/andrewlalis/onyx/config/SecurityConfig.java b/onyx-api/src/main/java/com/andrewlalis/onyx/config/SecurityConfig.java new file mode 100644 index 0000000..568acc1 --- /dev/null +++ b/onyx-api/src/main/java/com/andrewlalis/onyx/config/SecurityConfig.java @@ -0,0 +1,34 @@ +package com.andrewlalis.onyx.config; + +import com.andrewlalis.onyx.auth.components.JwtFilter; +import lombok.RequiredArgsConstructor; +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.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; + +@Configuration +@RequiredArgsConstructor +public class SecurityConfig { + private final JwtFilter jwtFilter; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http.authorizeHttpRequests(registry -> { + registry.requestMatchers(AntPathRequestMatcher.antMatcher(HttpMethod.POST, "/auth/login")).permitAll(); + registry.requestMatchers(AntPathRequestMatcher.antMatcher(HttpMethod.GET, "/auth/access")).permitAll(); + registry.anyRequest().authenticated(); + }); + http.csrf(AbstractHttpConfigurer::disable); + http.sessionManagement(configurer -> configurer.sessionCreationPolicy(SessionCreationPolicy.NEVER)); + http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); + http.cors(configurer -> configurer.configure(http)); + return http.build(); + } +} diff --git a/onyx-api/src/main/java/com/andrewlalis/onyx/content/ContentDocumentNode.java b/onyx-api/src/main/java/com/andrewlalis/onyx/content/ContentDocumentNode.java deleted file mode 100644 index d8a5382..0000000 --- a/onyx-api/src/main/java/com/andrewlalis/onyx/content/ContentDocumentNode.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.andrewlalis.onyx.content; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Lob; -import jakarta.persistence.Table; - -/** - * A type of content node that contains a single document. - */ -@Entity -@Table(name = "content_node_document") -public class ContentDocumentNode extends ContentNode { - @Column(nullable = false, updatable = false, length = 127) - private String type; - - /** - * The raw file content for this document. - */ - @Lob @Column(nullable = false) - private byte[] content; -} diff --git a/onyx-api/src/main/java/com/andrewlalis/onyx/content/ContentTreeInitializer.java b/onyx-api/src/main/java/com/andrewlalis/onyx/content/ContentTreeInitializer.java new file mode 100644 index 0000000..45354a8 --- /dev/null +++ b/onyx-api/src/main/java/com/andrewlalis/onyx/content/ContentTreeInitializer.java @@ -0,0 +1,30 @@ +package com.andrewlalis.onyx.content; + +import com.andrewlalis.onyx.content.dao.ContentNodeRepository; +import com.andrewlalis.onyx.content.model.access.ContentAccessLevel; +import com.andrewlalis.onyx.content.model.ContentContainerNode; +import com.andrewlalis.onyx.content.model.ContentNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class ContentTreeInitializer implements CommandLineRunner { + private final ContentNodeRepository contentNodeRepository; + + @Override + public void run(String... args) throws Exception { + log.info("Check if content root container node exists."); + if (!contentNodeRepository.existsByName(ContentNode.ROOT_NODE_NAME)) { + ContentNode rootNode = new ContentContainerNode(ContentNode.ROOT_NODE_NAME, null); + rootNode.getAccessInfo().setPublicAccessLevel(ContentAccessLevel.NONE); + rootNode.getAccessInfo().setNetworkAccessLevel(ContentAccessLevel.NONE); + rootNode.getAccessInfo().setNodeAccessLevel(ContentAccessLevel.NONE); + contentNodeRepository.saveAndFlush(rootNode); + log.info("Created content root container node."); + } + } +} diff --git a/onyx-api/src/main/java/com/andrewlalis/onyx/content/dao/ContentNodeRepository.java b/onyx-api/src/main/java/com/andrewlalis/onyx/content/dao/ContentNodeRepository.java new file mode 100644 index 0000000..45e8b6a --- /dev/null +++ b/onyx-api/src/main/java/com/andrewlalis/onyx/content/dao/ContentNodeRepository.java @@ -0,0 +1,10 @@ +package com.andrewlalis.onyx.content.dao; + +import com.andrewlalis.onyx.content.model.ContentNode; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ContentNodeRepository extends JpaRepository { + boolean existsByName(String name); +} diff --git a/onyx-api/src/main/java/com/andrewlalis/onyx/content/ContentContainerNode.java b/onyx-api/src/main/java/com/andrewlalis/onyx/content/model/ContentContainerNode.java similarity index 58% rename from onyx-api/src/main/java/com/andrewlalis/onyx/content/ContentContainerNode.java rename to onyx-api/src/main/java/com/andrewlalis/onyx/content/model/ContentContainerNode.java index 68eea77..d69ef83 100644 --- a/onyx-api/src/main/java/com/andrewlalis/onyx/content/ContentContainerNode.java +++ b/onyx-api/src/main/java/com/andrewlalis/onyx/content/model/ContentContainerNode.java @@ -1,6 +1,8 @@ -package com.andrewlalis.onyx.content; +package com.andrewlalis.onyx.content.model; import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; import java.util.Set; @@ -9,11 +11,16 @@ import java.util.Set; * themselves be any type of content node. */ @Entity -@Table(name = "content_node_container") +@Table(name = "onyx_content_container_node") +@NoArgsConstructor(access = AccessLevel.PROTECTED) public final class ContentContainerNode extends ContentNode { /** * The set of children that belong to this container. */ @OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true, mappedBy = "parentContainer") private Set children; + + public ContentContainerNode(String name, ContentContainerNode parentContainer) { + super(name, Type.CONTAINER, parentContainer); + } } diff --git a/onyx-api/src/main/java/com/andrewlalis/onyx/content/model/ContentDocumentNode.java b/onyx-api/src/main/java/com/andrewlalis/onyx/content/model/ContentDocumentNode.java new file mode 100644 index 0000000..e5c8e29 --- /dev/null +++ b/onyx-api/src/main/java/com/andrewlalis/onyx/content/model/ContentDocumentNode.java @@ -0,0 +1,31 @@ +package com.andrewlalis.onyx.content.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Lob; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * A type of content node that contains a single document. + */ +@Entity +@Table(name = "onyx_content_document_node") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ContentDocumentNode extends ContentNode { + @Column(nullable = false, updatable = false, length = 127) + private String contentType; + + /** + * The raw file content for this document. + */ + @Lob @Column(nullable = false) @Setter + private byte[] content; + + public ContentDocumentNode(String contentType, byte[] content) { + this.contentType = contentType; + this.content = content; + } +} diff --git a/onyx-api/src/main/java/com/andrewlalis/onyx/content/ContentNode.java b/onyx-api/src/main/java/com/andrewlalis/onyx/content/model/ContentNode.java similarity index 79% rename from onyx-api/src/main/java/com/andrewlalis/onyx/content/ContentNode.java rename to onyx-api/src/main/java/com/andrewlalis/onyx/content/model/ContentNode.java index 1fc31e6..0894cf0 100644 --- a/onyx-api/src/main/java/com/andrewlalis/onyx/content/ContentNode.java +++ b/onyx-api/src/main/java/com/andrewlalis/onyx/content/model/ContentNode.java @@ -1,8 +1,10 @@ -package com.andrewlalis.onyx.content; +package com.andrewlalis.onyx.content.model; -import com.andrewlalis.onyx.content.history.ContentNodeHistory; +import com.andrewlalis.onyx.content.model.access.ContentAccessRules; +import com.andrewlalis.onyx.content.model.history.ContentNodeHistory; import jakarta.persistence.*; import lombok.AccessLevel; +import lombok.Getter; import lombok.NoArgsConstructor; /** @@ -10,11 +12,13 @@ import lombok.NoArgsConstructor; * content tree, including both containers and documents. */ @Entity -@Table(name = "content_node") +@Table(name = "onyx_content_node") @Inheritance(strategy = InheritanceType.JOINED) @NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter public abstract class ContentNode { public static final int MAX_NAME_LENGTH = 127; + public static final String ROOT_NODE_NAME = "___ROOT___"; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private long id; @@ -23,7 +27,7 @@ public abstract class ContentNode { private String name; @Enumerated(EnumType.ORDINAL) @Column(columnDefinition = "TINYINT NOT NULL") - private Type type; + private Type nodeType; @OneToOne(fetch = FetchType.LAZY, optional = false, orphanRemoval = true, cascade = CascadeType.ALL) private ContentAccessRules accessInfo; @@ -49,7 +53,8 @@ public abstract class ContentNode { public ContentNode(String name, Type type, ContentContainerNode parentContainer) { this.name = name; - this.type = type; + this.nodeType = type; + this.accessInfo = new ContentAccessRules(); this.parentContainer = parentContainer; this.history = new ContentNodeHistory(this); } diff --git a/onyx-api/src/main/java/com/andrewlalis/onyx/content/ContentAccessLevel.java b/onyx-api/src/main/java/com/andrewlalis/onyx/content/model/access/ContentAccessLevel.java similarity index 90% rename from onyx-api/src/main/java/com/andrewlalis/onyx/content/ContentAccessLevel.java rename to onyx-api/src/main/java/com/andrewlalis/onyx/content/model/access/ContentAccessLevel.java index e15983a..6b959b0 100644 --- a/onyx-api/src/main/java/com/andrewlalis/onyx/content/ContentAccessLevel.java +++ b/onyx-api/src/main/java/com/andrewlalis/onyx/content/model/access/ContentAccessLevel.java @@ -1,4 +1,4 @@ -package com.andrewlalis.onyx.content; +package com.andrewlalis.onyx.content.model.access; /** * The different levels of access that can be set for content in an onyx node. diff --git a/onyx-api/src/main/java/com/andrewlalis/onyx/content/ContentAccessRules.java b/onyx-api/src/main/java/com/andrewlalis/onyx/content/model/access/ContentAccessRules.java similarity index 66% rename from onyx-api/src/main/java/com/andrewlalis/onyx/content/ContentAccessRules.java rename to onyx-api/src/main/java/com/andrewlalis/onyx/content/model/access/ContentAccessRules.java index 14d8249..fb63599 100644 --- a/onyx-api/src/main/java/com/andrewlalis/onyx/content/ContentAccessRules.java +++ b/onyx-api/src/main/java/com/andrewlalis/onyx/content/model/access/ContentAccessRules.java @@ -1,14 +1,18 @@ -package com.andrewlalis.onyx.content; +package com.andrewlalis.onyx.content.model.access; +import com.andrewlalis.onyx.content.model.ContentNode; import jakarta.persistence.*; import lombok.Getter; +import lombok.Setter; + +import java.util.Set; /** * An entity meant to be attached to a {@link ContentNode}, which contains the * access levels for the different ways in which a content node can be accessed. */ @Entity -@Table(name = "content_access_rules") +@Table(name = "onyx_content_access_rules") @Getter public class ContentAccessRules { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -21,17 +25,20 @@ public class ContentAccessRules { private ContentNode contentNode; /** The access level that applies to users from outside this onyx network. */ - @Enumerated(EnumType.ORDINAL) @Column(nullable = false, columnDefinition = "TINYINT NOT NULL") + @Enumerated(EnumType.ORDINAL) @Column(nullable = false, columnDefinition = "TINYINT NOT NULL") @Setter private ContentAccessLevel publicAccessLevel = ContentAccessLevel.INHERIT; /** The access level that applies to users from within this onyx network. */ - @Enumerated(EnumType.ORDINAL) @Column(nullable = false, columnDefinition = "TINYINT NOT NULL") + @Enumerated(EnumType.ORDINAL) @Column(nullable = false, columnDefinition = "TINYINT NOT NULL") @Setter private ContentAccessLevel networkAccessLevel = ContentAccessLevel.INHERIT; /** The access level that applies to users within only this onyx node. */ - @Enumerated(EnumType.ORDINAL) @Column(nullable = false, columnDefinition = "TINYINT NOT NULL") + @Enumerated(EnumType.ORDINAL) @Column(nullable = false, columnDefinition = "TINYINT NOT NULL") @Setter private ContentAccessLevel nodeAccessLevel = ContentAccessLevel.INHERIT; - // TODO: Add a user allowlist. - + /** + * User-specific access rules that override other more generic rules, if present. + */ + @OneToMany(fetch = FetchType.LAZY, orphanRemoval = true, cascade = CascadeType.ALL, mappedBy = "contentAccessRules") + private Set userAccessRules; } diff --git a/onyx-api/src/main/java/com/andrewlalis/onyx/content/model/access/UserContentAccessRule.java b/onyx-api/src/main/java/com/andrewlalis/onyx/content/model/access/UserContentAccessRule.java new file mode 100644 index 0000000..12527b2 --- /dev/null +++ b/onyx-api/src/main/java/com/andrewlalis/onyx/content/model/access/UserContentAccessRule.java @@ -0,0 +1,32 @@ +package com.andrewlalis.onyx.content.model.access; + +import com.andrewlalis.onyx.auth.model.User; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "onyx_content_access_rules_user") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class UserContentAccessRule { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + private ContentAccessRules contentAccessRules; + + @Enumerated(EnumType.ORDINAL) @Column(nullable = false, columnDefinition = "TINYINT NOT NULL") @Setter + private ContentAccessLevel accessLevel; + + public UserContentAccessRule(User user, ContentAccessRules contentAccessRules, ContentAccessLevel accessLevel) { + this.user = user; + this.contentAccessRules = contentAccessRules; + this.accessLevel = accessLevel; + } +} diff --git a/onyx-api/src/main/java/com/andrewlalis/onyx/content/history/ContentNodeHistory.java b/onyx-api/src/main/java/com/andrewlalis/onyx/content/model/history/ContentNodeHistory.java similarity index 84% rename from onyx-api/src/main/java/com/andrewlalis/onyx/content/history/ContentNodeHistory.java rename to onyx-api/src/main/java/com/andrewlalis/onyx/content/model/history/ContentNodeHistory.java index dccc5da..51ebf93 100644 --- a/onyx-api/src/main/java/com/andrewlalis/onyx/content/history/ContentNodeHistory.java +++ b/onyx-api/src/main/java/com/andrewlalis/onyx/content/model/history/ContentNodeHistory.java @@ -1,8 +1,9 @@ -package com.andrewlalis.onyx.content.history; +package com.andrewlalis.onyx.content.model.history; -import com.andrewlalis.onyx.content.ContentNode; +import com.andrewlalis.onyx.content.model.ContentNode; import jakarta.persistence.*; import lombok.AccessLevel; +import lombok.Getter; import lombok.NoArgsConstructor; import org.hibernate.annotations.CreationTimestamp; @@ -15,8 +16,9 @@ import java.util.Set; * storing an ordered list of entries detailing how that node has changed. */ @Entity -@Table(name = "content_node_history") +@Table(name = "onyx_content_node_history") @NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter public class ContentNodeHistory { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private long id; diff --git a/onyx-api/src/main/java/com/andrewlalis/onyx/content/history/ContentNodeHistoryEntry.java b/onyx-api/src/main/java/com/andrewlalis/onyx/content/model/history/ContentNodeHistoryEntry.java similarity index 89% rename from onyx-api/src/main/java/com/andrewlalis/onyx/content/history/ContentNodeHistoryEntry.java rename to onyx-api/src/main/java/com/andrewlalis/onyx/content/model/history/ContentNodeHistoryEntry.java index 5f55dd9..18c2ea4 100644 --- a/onyx-api/src/main/java/com/andrewlalis/onyx/content/history/ContentNodeHistoryEntry.java +++ b/onyx-api/src/main/java/com/andrewlalis/onyx/content/model/history/ContentNodeHistoryEntry.java @@ -1,4 +1,4 @@ -package com.andrewlalis.onyx.content.history; +package com.andrewlalis.onyx.content.model.history; import jakarta.persistence.*; import lombok.AccessLevel; @@ -13,7 +13,7 @@ import java.time.LocalDateTime; * some basic properties that all entries should have. */ @Entity -@Table(name = "content_node_history_entry") +@Table(name = "onyx_content_node_history_entry") @Inheritance(strategy = InheritanceType.JOINED) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) diff --git a/onyx-api/src/main/java/com/andrewlalis/onyx/content/history/entry/AccessRulesEntry.java b/onyx-api/src/main/java/com/andrewlalis/onyx/content/model/history/entry/AccessRulesEntry.java similarity index 86% rename from onyx-api/src/main/java/com/andrewlalis/onyx/content/history/entry/AccessRulesEntry.java rename to onyx-api/src/main/java/com/andrewlalis/onyx/content/model/history/entry/AccessRulesEntry.java index 6b2768b..6ab4420 100644 --- a/onyx-api/src/main/java/com/andrewlalis/onyx/content/history/entry/AccessRulesEntry.java +++ b/onyx-api/src/main/java/com/andrewlalis/onyx/content/model/history/entry/AccessRulesEntry.java @@ -1,8 +1,8 @@ -package com.andrewlalis.onyx.content.history.entry; +package com.andrewlalis.onyx.content.model.history.entry; -import com.andrewlalis.onyx.content.ContentAccessLevel; -import com.andrewlalis.onyx.content.history.ContentNodeHistory; -import com.andrewlalis.onyx.content.history.ContentNodeHistoryEntry; +import com.andrewlalis.onyx.content.model.access.ContentAccessLevel; +import com.andrewlalis.onyx.content.model.history.ContentNodeHistory; +import com.andrewlalis.onyx.content.model.history.ContentNodeHistoryEntry; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Getter; @@ -13,7 +13,7 @@ import lombok.NoArgsConstructor; * by a user or the system. */ @Entity -@Table(name = "content_node_history_entry_access_rules") +@Table(name = "onyx_content_node_history_entry__access_rules") @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class AccessRulesEntry extends ContentNodeHistoryEntry { diff --git a/onyx-api/src/main/java/com/andrewlalis/onyx/content/history/entry/ArchivedEntry.java b/onyx-api/src/main/java/com/andrewlalis/onyx/content/model/history/entry/ArchivedEntry.java similarity index 70% rename from onyx-api/src/main/java/com/andrewlalis/onyx/content/history/entry/ArchivedEntry.java rename to onyx-api/src/main/java/com/andrewlalis/onyx/content/model/history/entry/ArchivedEntry.java index ad8875d..587ff21 100644 --- a/onyx-api/src/main/java/com/andrewlalis/onyx/content/history/entry/ArchivedEntry.java +++ b/onyx-api/src/main/java/com/andrewlalis/onyx/content/model/history/entry/ArchivedEntry.java @@ -1,7 +1,7 @@ -package com.andrewlalis.onyx.content.history.entry; +package com.andrewlalis.onyx.content.model.history.entry; -import com.andrewlalis.onyx.content.history.ContentNodeHistory; -import com.andrewlalis.onyx.content.history.ContentNodeHistoryEntry; +import com.andrewlalis.onyx.content.model.history.ContentNodeHistory; +import com.andrewlalis.onyx.content.model.history.ContentNodeHistoryEntry; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Table; @@ -13,7 +13,7 @@ import lombok.NoArgsConstructor; * History entry for tracking the state of a content node's `archived` status. */ @Entity -@Table(name = "content_node_history_entry_archived") +@Table(name = "onyx_content_node_history_entry__archived") @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class ArchivedEntry extends ContentNodeHistoryEntry { diff --git a/onyx-api/src/main/java/com/andrewlalis/onyx/content/history/entry/ContainerEditEntry.java b/onyx-api/src/main/java/com/andrewlalis/onyx/content/model/history/entry/ContainerEditEntry.java similarity index 57% rename from onyx-api/src/main/java/com/andrewlalis/onyx/content/history/entry/ContainerEditEntry.java rename to onyx-api/src/main/java/com/andrewlalis/onyx/content/model/history/entry/ContainerEditEntry.java index 000eb8b..ab403cf 100644 --- a/onyx-api/src/main/java/com/andrewlalis/onyx/content/history/entry/ContainerEditEntry.java +++ b/onyx-api/src/main/java/com/andrewlalis/onyx/content/model/history/entry/ContainerEditEntry.java @@ -1,8 +1,9 @@ -package com.andrewlalis.onyx.content.history.entry; +package com.andrewlalis.onyx.content.model.history.entry; -import com.andrewlalis.onyx.content.ContentNode; -import com.andrewlalis.onyx.content.history.ContentNodeHistory; -import com.andrewlalis.onyx.content.history.ContentNodeHistoryEntry; +import com.andrewlalis.onyx.content.model.ContentContainerNode; +import com.andrewlalis.onyx.content.model.ContentNode; +import com.andrewlalis.onyx.content.model.history.ContentNodeHistory; +import com.andrewlalis.onyx.content.model.history.ContentNodeHistoryEntry; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -11,7 +12,7 @@ import lombok.NoArgsConstructor; * History entry for tracking updates to the contents of a container node. */ @Entity -@Table(name = "content_node_history_entry_container_edit") +@Table(name = "onyx_content_node_history_entry__container_edit") @NoArgsConstructor(access = AccessLevel.PROTECTED) public class ContainerEditEntry extends ContentNodeHistoryEntry { public enum EditType { @@ -29,6 +30,9 @@ public class ContainerEditEntry extends ContentNodeHistoryEntry { public ContainerEditEntry(ContentNodeHistory history, EditType type, String affectedNodeName) { super(history); + if (!(history.getContentNode() instanceof ContentContainerNode)) { + throw new IllegalArgumentException("Only the history of a ContentContainerNode may be used to create a ContainerEditEntry."); + } this.type = type; this.affectedNodeName = affectedNodeName; } diff --git a/onyx-api/src/main/java/com/andrewlalis/onyx/content/model/history/entry/DocumentEditEntry.java b/onyx-api/src/main/java/com/andrewlalis/onyx/content/model/history/entry/DocumentEditEntry.java new file mode 100644 index 0000000..521bc8a --- /dev/null +++ b/onyx-api/src/main/java/com/andrewlalis/onyx/content/model/history/entry/DocumentEditEntry.java @@ -0,0 +1,32 @@ +package com.andrewlalis.onyx.content.model.history.entry; + +import com.andrewlalis.onyx.content.model.ContentDocumentNode; +import com.andrewlalis.onyx.content.model.history.ContentNodeHistory; +import com.andrewlalis.onyx.content.model.history.ContentNodeHistoryEntry; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Lob; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * A history entry that records an edit that was made to a document. + */ +@Entity +@Table(name = "onyx_content_node_history_entry__document_edit") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class DocumentEditEntry extends ContentNodeHistoryEntry { + @Lob @Column(nullable = false, updatable = false) + private byte[] diff; + + public DocumentEditEntry(ContentNodeHistory history, byte[] diff) { + super(history); + if (!(history.getContentNode() instanceof ContentDocumentNode)) { + throw new IllegalArgumentException("Only the history of a ContentDocumentNode may be used to create a DocumentEditEntry."); + } + this.diff = diff; + } +} diff --git a/onyx-api/src/main/java/com/andrewlalis/onyx/content/history/entry/RenameEntry.java b/onyx-api/src/main/java/com/andrewlalis/onyx/content/model/history/entry/RenameEntry.java similarity index 69% rename from onyx-api/src/main/java/com/andrewlalis/onyx/content/history/entry/RenameEntry.java rename to onyx-api/src/main/java/com/andrewlalis/onyx/content/model/history/entry/RenameEntry.java index 0854351..8a30a90 100644 --- a/onyx-api/src/main/java/com/andrewlalis/onyx/content/history/entry/RenameEntry.java +++ b/onyx-api/src/main/java/com/andrewlalis/onyx/content/model/history/entry/RenameEntry.java @@ -1,8 +1,8 @@ -package com.andrewlalis.onyx.content.history.entry; +package com.andrewlalis.onyx.content.model.history.entry; -import com.andrewlalis.onyx.content.ContentNode; -import com.andrewlalis.onyx.content.history.ContentNodeHistory; -import com.andrewlalis.onyx.content.history.ContentNodeHistoryEntry; +import com.andrewlalis.onyx.content.model.ContentNode; +import com.andrewlalis.onyx.content.model.history.ContentNodeHistory; +import com.andrewlalis.onyx.content.model.history.ContentNodeHistoryEntry; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Table; @@ -11,7 +11,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; @Entity -@Table(name = "content_node_history_entry_rename") +@Table(name = "onyx_content_node_history_entry__rename") @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class RenameEntry extends ContentNodeHistoryEntry {