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