Added users, authentication API, tokens, and more content access rule models.
This commit is contained in:
parent
be880e312f
commit
19c7f35abc
|
@ -34,3 +34,6 @@ build/
|
|||
|
||||
*.mv.db
|
||||
*.db
|
||||
|
||||
*.der
|
||||
*.pem
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.1.4</version>
|
||||
<version>3.1.5</version>
|
||||
<relativePath/> <!-- lookup parent from repository -->
|
||||
</parent>
|
||||
<groupId>com.andrewlalis</groupId>
|
||||
|
@ -56,6 +56,25 @@
|
|||
<artifactId>spring-security-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- JWT dependencies -->
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>0.11.5</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>0.11.5</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if Gson is preferred -->
|
||||
<version>0.11.5</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
package com.andrewlalis.onyx.auth.api;
|
||||
|
||||
public record LoginRequest(String username, String password) {}
|
|
@ -0,0 +1,3 @@
|
|||
package com.andrewlalis.onyx.auth.api;
|
||||
|
||||
public record TokenPair(String refreshToken, String accessToken) {}
|
|
@ -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<Claims> 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);
|
||||
}
|
||||
}
|
|
@ -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<Claims> jws) implements Authentication {
|
||||
@Override
|
||||
public Collection<? extends GrantedAuthority> 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();
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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<RefreshToken, String> {
|
||||
Iterable<RefreshToken> findAllByTokenSuffix(String suffix);
|
||||
void deleteAllByUserId(long userId);
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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<User, Long> {
|
||||
Optional<User> findByUsername(String username);
|
||||
}
|
|
@ -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<User> 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<Claims> 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;
|
||||
}
|
||||
}
|
|
@ -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<User> findById(long id) {
|
||||
return userRepository.findById(id);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<ContentNode, Long> {
|
||||
boolean existsByName(String name);
|
||||
}
|
|
@ -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<ContentNode> children;
|
||||
|
||||
public ContentContainerNode(String name, ContentContainerNode parentContainer) {
|
||||
super(name, Type.CONTAINER, parentContainer);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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 <em>containers</em> and <em>documents</em>.
|
||||
*/
|
||||
@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);
|
||||
}
|
|
@ -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.
|
|
@ -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<UserContentAccessRule> userAccessRules;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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)
|
|
@ -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 {
|
|
@ -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 {
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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 {
|
Loading…
Reference in New Issue