diff --git a/api/src/main/java/com/nextdocs/api/NextdocsApiApplication.java b/api/src/main/java/com/nextdocs/api/NextdocsApiApplication.java index 07f6235..a5de435 100644 --- a/api/src/main/java/com/nextdocs/api/NextdocsApiApplication.java +++ b/api/src/main/java/com/nextdocs/api/NextdocsApiApplication.java @@ -1,9 +1,14 @@ package com.nextdocs.api; +import com.nextdocs.api.document.config.DocumentProperties; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EnableScheduling +@EnableConfigurationProperties(DocumentProperties.class) public class NextdocsApiApplication { public static void main(String[] args) { diff --git a/api/src/main/java/com/nextdocs/api/auth/security/RateLimitFilter.java b/api/src/main/java/com/nextdocs/api/auth/security/RateLimitFilter.java index c85ed19..c4edead 100644 --- a/api/src/main/java/com/nextdocs/api/auth/security/RateLimitFilter.java +++ b/api/src/main/java/com/nextdocs/api/auth/security/RateLimitFilter.java @@ -9,6 +9,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.List; import java.util.stream.Collectors; @@ -19,13 +20,16 @@ import org.springframework.security.web.util.matcher.IpAddressMatcher; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.UriUtils; import tools.jackson.databind.ObjectMapper; /** - * Auth endpoint rate limiter. + * Public unauthenticated endpoint rate limiter. * - * Limits each IP address within a one-minute window on the via the - * configured {@link RateLimiter} implementation. + * Limits each IP address within a one-minute window via the configured + * {@link RateLimiter} implementation for: + * - /api/v1/auth/* + * - GET /api/v1/documents/{id}/public */ @Slf4j @Component @@ -33,6 +37,8 @@ public class RateLimitFilter extends OncePerRequestFilter { private static final String AUTH_PATH_PREFIX = "/api/v1/auth/"; + private static final String PUBLIC_DOCUMENT_PATH_PREFIX = "/api/v1/documents/"; + private static final String PUBLIC_DOCUMENT_PATH_SUFFIX = "/public"; private final RateLimiter rateLimiter; private final ObjectMapper objectMapper; @@ -64,14 +70,15 @@ void initTrustedProxies() { protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - if (!request.getRequestURI().startsWith(AUTH_PATH_PREFIX)) { + if (!isRateLimitedPath(request)) { filterChain.doFilter(request, response); return; } String ip = resolveClientIp(request); + String key = buildRateLimitKey(request, ip); - if (!rateLimiter.allowRequest(ip)) { + if (!rateLimiter.allowRequest(key)) { String maskedIp = maskIp(ip); log.warn("Rate limit exceeded for IP: {}", maskedIp); response.setStatus(429); @@ -85,6 +92,41 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse filterChain.doFilter(request, response); } + private boolean isRateLimitedPath(HttpServletRequest request) { + String uri = request.getRequestURI(); + if (uri.startsWith(AUTH_PATH_PREFIX)) { + return true; + } + + if (!"GET".equalsIgnoreCase(request.getMethod())) { + return false; + } + + if (!uri.startsWith(PUBLIC_DOCUMENT_PATH_PREFIX) || !uri.endsWith(PUBLIC_DOCUMENT_PATH_SUFFIX)) { + return false; + } + + String middle = uri.substring( + PUBLIC_DOCUMENT_PATH_PREFIX.length(), uri.length() - PUBLIC_DOCUMENT_PATH_SUFFIX.length()); + + String decodedMiddle; + try { + decodedMiddle = UriUtils.decode(middle, StandardCharsets.UTF_8); + } catch (IllegalArgumentException ignored) { + return false; + } + + // Match exactly /api/v1/documents/{id}/public and avoid nested paths. + return !decodedMiddle.isBlank() && !decodedMiddle.contains("/") && !decodedMiddle.contains("%"); + } + + private String buildRateLimitKey(HttpServletRequest request, String ip) { + if (request.getRequestURI().startsWith(AUTH_PATH_PREFIX)) { + return "auth:" + ip; + } + return "public-doc:" + ip; + } + private String resolveClientIp(HttpServletRequest request) { String remoteAddr = request.getRemoteAddr(); String forwarded = request.getHeader("X-Forwarded-For"); diff --git a/api/src/main/java/com/nextdocs/api/auth/security/SecurityConfig.java b/api/src/main/java/com/nextdocs/api/auth/security/SecurityConfig.java index b268662..b93fb3b 100644 --- a/api/src/main/java/com/nextdocs/api/auth/security/SecurityConfig.java +++ b/api/src/main/java/com/nextdocs/api/auth/security/SecurityConfig.java @@ -39,6 +39,7 @@ public class SecurityConfig { "/api/v1/auth/register", "/api/v1/auth/login", "/api/v1/auth/refresh", + "/api/v1/documents/*/public", // OpenAPI / Swagger UI "/v3/api-docs/**", "/swagger-ui/**", diff --git a/api/src/main/java/com/nextdocs/api/common/exception/ErrorCode.java b/api/src/main/java/com/nextdocs/api/common/exception/ErrorCode.java index 9b5e2a3..20606e4 100644 --- a/api/src/main/java/com/nextdocs/api/common/exception/ErrorCode.java +++ b/api/src/main/java/com/nextdocs/api/common/exception/ErrorCode.java @@ -15,6 +15,8 @@ public enum ErrorCode { // General VALIDATION_FAILED(HttpStatus.BAD_REQUEST, "Request validation failed."), NOT_FOUND(HttpStatus.NOT_FOUND, "The requested resource was not found."), + FORBIDDEN(HttpStatus.FORBIDDEN, "You do not have permission to perform this action."), + CONFLICT(HttpStatus.CONFLICT, "The request conflicts with the current state of the resource."), INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred."), RATE_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "Too many requests. Please try again later."); diff --git a/api/src/main/java/com/nextdocs/api/common/response/PagedResponse.java b/api/src/main/java/com/nextdocs/api/common/response/PagedResponse.java new file mode 100644 index 0000000..22579f7 --- /dev/null +++ b/api/src/main/java/com/nextdocs/api/common/response/PagedResponse.java @@ -0,0 +1,19 @@ +package com.nextdocs.api.common.response; + +import java.util.List; +import org.springframework.data.domain.Page; + +public record PagedResponse( + List content, long totalElements, int totalPages, int size, int number, boolean first, boolean last) { + + public static PagedResponse from(Page page) { + return new PagedResponse<>( + page.getContent(), + page.getTotalElements(), + page.getTotalPages(), + page.getSize(), + page.getNumber(), + page.isFirst(), + page.isLast()); + } +} diff --git a/api/src/main/java/com/nextdocs/api/document/config/DocumentProperties.java b/api/src/main/java/com/nextdocs/api/document/config/DocumentProperties.java new file mode 100644 index 0000000..c403e43 --- /dev/null +++ b/api/src/main/java/com/nextdocs/api/document/config/DocumentProperties.java @@ -0,0 +1,17 @@ +package com.nextdocs.api.document.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "app.documents") +@Getter +@Setter +public class DocumentProperties { + + /** Days a document may remain in trash before the purge job deletes it permanently. */ + private int trashRetentionDays = 30; + + /** Spring @Scheduled cron expression for the trash purge job. */ + private String purgeCron = "0 0 3 * * *"; +} diff --git a/api/src/main/java/com/nextdocs/api/document/controller/DocumentController.java b/api/src/main/java/com/nextdocs/api/document/controller/DocumentController.java new file mode 100644 index 0000000..d1a2645 --- /dev/null +++ b/api/src/main/java/com/nextdocs/api/document/controller/DocumentController.java @@ -0,0 +1,220 @@ +package com.nextdocs.api.document.controller; + +import com.nextdocs.api.auth.security.UserPrincipal; +import com.nextdocs.api.common.response.ApiResponse; +import com.nextdocs.api.common.response.PagedResponse; +import com.nextdocs.api.document.dto.request.BulkImportRequest; +import com.nextdocs.api.document.dto.request.DocumentCreateRequest; +import com.nextdocs.api.document.dto.request.DocumentUpdateRequest; +import com.nextdocs.api.document.dto.response.BulkImportResponse; +import com.nextdocs.api.document.dto.response.DocumentResponse; +import com.nextdocs.api.document.service.DocumentService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.security.SecurityRequirements; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "Documents", description = "Document persistence endpoints") +@RestController +@RequestMapping("/api/v1/documents") +@RequiredArgsConstructor +@SecurityRequirement(name = "bearerAuth") +public class DocumentController { + + private final DocumentService documentService; + + @Operation( + summary = "Create a document", + description = "Creates a new document owned by the authenticated user.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "201", + description = "Document created"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "Invalid request payload"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "Authentication required") + }) + @PostMapping + public ResponseEntity> create( + @AuthenticationPrincipal UserPrincipal principal, @Valid @RequestBody DocumentCreateRequest request) { + DocumentResponse response = documentService.create(principal.getId(), request); + return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.ok(response, "Document created.")); + } + + @Operation( + summary = "List current user's documents", + description = "Returns a paged list of documents owned by the authenticated user. " + + "By default only active documents are returned (ordered by last update). " + + "Use trashed=true to list documents in trash (ordered by time moved to trash).", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "Documents returned"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "Authentication required") + }) + @GetMapping + public ResponseEntity>> list( + @AuthenticationPrincipal UserPrincipal principal, + @RequestParam(required = false) Boolean trashed, + @PageableDefault(size = 20) Pageable pageable) { + boolean trashedOnly = Boolean.TRUE.equals(trashed); + Page page = documentService.list(principal.getId(), pageable, trashedOnly); + return ResponseEntity.ok(ApiResponse.ok(PagedResponse.from(page))); + } + + @Operation( + summary = "Get a single document", + description = "Returns one document if it exists and belongs to the authenticated user. " + + "Trashed documents are omitted by default (404) so realtime access checks stay strict. " + + "Pass includeTrashed=true to load a trashed document (e.g. trash UI or restore).", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "Document returned"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "Authentication required"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "Document not found") + }) + @GetMapping("/{id}") + public ResponseEntity> get( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable UUID id, + @RequestParam(required = false, defaultValue = "false") boolean includeTrashed) { + return ResponseEntity.ok(ApiResponse.ok(documentService.get(principal.getId(), id, includeTrashed))); + } + + @Operation( + summary = "Get a document publicly (general access)", + description = + "Returns a document if its general access mode is ANYONE_WITH_LINK. No authentication required.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "Public document returned"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "Document not found or not shared as ANYONE_WITH_LINK") + }) + @SecurityRequirements({}) + @GetMapping("/{id}/public") + public ResponseEntity> getPublic(@PathVariable UUID id) { + return ResponseEntity.ok(ApiResponse.ok(documentService.getPublic(id))); + } + + @Operation( + summary = "Update a document", + description = "Updates metadata and/or Yjs state for an active document owned by the authenticated user. " + + "Documents in trash cannot be updated.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "Document updated"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "Invalid request payload"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "Authentication required"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "Document not found"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "409", + description = "Document is in trash") + }) + @PatchMapping("/{id}") + public ResponseEntity> update( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable UUID id, + @Valid @RequestBody DocumentUpdateRequest request) { + return ResponseEntity.ok(ApiResponse.ok(documentService.update(principal.getId(), id, request))); + } + + @Operation( + summary = "Move a document to trash or delete permanently", + description = "By default moves the document to trash (soft delete). " + + "Use permanent=true to permanently delete a document that is already in trash.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "204", + description = "Document deleted or moved to trash"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "Invalid request (e.g. permanent delete while not in trash)"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "Authentication required"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "Document not found") + }) + @DeleteMapping("/{id}") + public ResponseEntity delete( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable UUID id, + @RequestParam(required = false, defaultValue = "false") boolean permanent) { + documentService.delete(principal.getId(), id, permanent); + return ResponseEntity.noContent().build(); + } + + @Operation( + summary = "Restore a document from trash", + description = "Clears trash state for a document owned by the authenticated user.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "Document restored"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "Authentication required"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "Document not found or not in trash") + }) + @PostMapping("/{id}/restore") + public ResponseEntity> restore( + @AuthenticationPrincipal UserPrincipal principal, @PathVariable UUID id) { + DocumentResponse response = documentService.restore(principal.getId(), id); + return ResponseEntity.ok(ApiResponse.ok(response, "Document restored.")); + } + + @Operation( + summary = "Bulk import local documents", + description = "Imports local documents into the authenticated user's account. " + + "Operation is transactional: if one document fails validation, none are imported.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "201", + description = "Documents imported"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "Invalid request payload"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "Authentication required") + }) + @PostMapping("/bulk-import") + public ResponseEntity> bulkImport( + @AuthenticationPrincipal UserPrincipal principal, @Valid @RequestBody BulkImportRequest request) { + BulkImportResponse response = documentService.bulkImport(principal.getId(), request); + return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.ok(response, "Documents imported.")); + } +} diff --git a/api/src/main/java/com/nextdocs/api/document/controller/DocumentSharingController.java b/api/src/main/java/com/nextdocs/api/document/controller/DocumentSharingController.java new file mode 100644 index 0000000..ddb072c --- /dev/null +++ b/api/src/main/java/com/nextdocs/api/document/controller/DocumentSharingController.java @@ -0,0 +1,256 @@ +package com.nextdocs.api.document.controller; + +import com.nextdocs.api.auth.security.UserPrincipal; +import com.nextdocs.api.common.response.ApiResponse; +import com.nextdocs.api.common.response.PagedResponse; +import com.nextdocs.api.document.dto.request.CollaboratorAccessUpdateRequest; +import com.nextdocs.api.document.dto.request.CollaboratorUpsertRequest; +import com.nextdocs.api.document.dto.request.SharingSettingsUpdateRequest; +import com.nextdocs.api.document.dto.response.CollaboratorResponse; +import com.nextdocs.api.document.dto.response.DocumentAccessResponse; +import com.nextdocs.api.document.dto.response.DocumentResponse; +import com.nextdocs.api.document.dto.response.SharingSettingsResponse; +import com.nextdocs.api.document.service.DocumentSharingService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Document Sharing", description = "Document collaboration and sharing endpoints") +@RestController +@RequestMapping("/api/v1/documents") +@RequiredArgsConstructor +@SecurityRequirement(name = "bearerAuth") +public class DocumentSharingController { + + private final DocumentSharingService sharingService; + + @Operation( + summary = "List document collaborators", + description = "Returns the owner and all collaborators for the specified document.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "Collaborators returned"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "Authentication required"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "Document not found") + }) + @GetMapping("/{id}/collaborators") + public ResponseEntity>> listCollaborators( + @AuthenticationPrincipal UserPrincipal principal, @PathVariable UUID id) { + return ResponseEntity.ok(ApiResponse.ok(sharingService.listCollaborators(principal.getId(), id))); + } + + @Operation( + summary = "Add or update a collaborator", + description = "Creates or updates collaborator access for the specified document by email.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "201", + description = "Collaborator saved"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "Invalid request payload"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "Authentication required"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "Document or user not found") + }) + @PostMapping("/{id}/collaborators") + public ResponseEntity> upsertCollaborator( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable UUID id, + @Valid @RequestBody CollaboratorUpsertRequest request) { + CollaboratorResponse response = sharingService.upsertCollaborator(principal.getId(), id, request); + return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.ok(response, "Collaborator saved.")); + } + + @Operation( + summary = "Update collaborator access level", + description = "Updates an existing collaborator's access level for the specified document.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "Collaborator access updated"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "Invalid request payload"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "Authentication required"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "Document or collaborator not found") + }) + @PatchMapping("/{id}/collaborators/{userId}") + public ResponseEntity> updateCollaboratorAccess( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable UUID id, + @PathVariable UUID userId, + @Valid @RequestBody CollaboratorAccessUpdateRequest request) { + return ResponseEntity.ok( + ApiResponse.ok(sharingService.updateCollaboratorAccess(principal.getId(), id, userId, request))); + } + + @Operation( + summary = "Remove a collaborator", + description = "Removes collaborator access from the specified document.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "204", + description = "Collaborator removed"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "Authentication required"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "Document or collaborator not found") + }) + @DeleteMapping("/{id}/collaborators/{userId}") + public ResponseEntity removeCollaborator( + @AuthenticationPrincipal UserPrincipal principal, @PathVariable UUID id, @PathVariable UUID userId) { + sharingService.removeCollaborator(principal.getId(), id, userId); + return ResponseEntity.noContent().build(); + } + + @Operation( + summary = "Leave a shared document", + description = "Removes the authenticated user from the collaborator list of the specified shared document.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "204", + description = "Left shared document"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "Authentication required"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "Document or collaborator entry not found") + }) + @DeleteMapping("/{id}/collaborators/me") + public ResponseEntity leaveSharedDocument( + @AuthenticationPrincipal UserPrincipal principal, @PathVariable UUID id) { + sharingService.leaveSharedDocument(principal.getId(), id); + return ResponseEntity.noContent().build(); + } + + @Operation( + summary = "Get sharing settings", + description = "Returns general access and link permissions for the specified document.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "Sharing settings returned"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "Authentication required"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "Document not found") + }) + @GetMapping("/{id}/sharing") + public ResponseEntity> getSharingSettings( + @AuthenticationPrincipal UserPrincipal principal, @PathVariable UUID id) { + return ResponseEntity.ok(ApiResponse.ok(sharingService.getSharingSettings(principal.getId(), id))); + } + + @Operation( + summary = "Update sharing settings", + description = "Updates general access mode and link access level for the specified document.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "Sharing settings updated"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "Invalid request payload"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "Authentication required"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "Document not found") + }) + @PatchMapping("/{id}/sharing") + public ResponseEntity> updateSharingSettings( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable UUID id, + @Valid @RequestBody SharingSettingsUpdateRequest request) { + return ResponseEntity.ok(ApiResponse.ok(sharingService.updateSharingSettings(principal.getId(), id, request))); + } + + @Operation( + summary = "List documents shared with me", + description = "Returns a paged list of active documents shared with the authenticated user.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "Shared documents returned"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "Authentication required") + }) + @GetMapping("/shared-with-me") + public ResponseEntity>> listSharedWithMe( + @AuthenticationPrincipal UserPrincipal principal, @PageableDefault(size = 20) Pageable pageable) { + Page page = sharingService.listSharedWithMe(principal.getId(), pageable); + return ResponseEntity.ok(ApiResponse.ok(PagedResponse.from(page))); + } + + @Operation( + summary = "Get my effective access", + description = "Returns the authenticated user's effective access level for the specified document.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "Access returned"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "Authentication required") + }) + @GetMapping("/{id}/my-access") + public ResponseEntity> myAccess( + @AuthenticationPrincipal UserPrincipal principal, @PathVariable UUID id) { + return ResponseEntity.ok(ApiResponse.ok(sharingService.getMyAccess(principal.getId(), id))); + } + + @Operation( + summary = "Check effective access", + description = "Returns whether the authenticated user can access the specified document and at what level.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "Access check returned"), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "Authentication required") + }) + @GetMapping("/{id}/access-check") + public ResponseEntity> accessCheck( + @AuthenticationPrincipal UserPrincipal principal, @PathVariable UUID id) { + return ResponseEntity.ok(ApiResponse.ok(sharingService.accessCheck(principal.getId(), id))); + } +} diff --git a/api/src/main/java/com/nextdocs/api/document/dto/request/BulkImportItemRequest.java b/api/src/main/java/com/nextdocs/api/document/dto/request/BulkImportItemRequest.java new file mode 100644 index 0000000..6d2b7b5 --- /dev/null +++ b/api/src/main/java/com/nextdocs/api/document/dto/request/BulkImportItemRequest.java @@ -0,0 +1,22 @@ +package com.nextdocs.api.document.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +@Schema(description = "Single document payload for bulk import") +public record BulkImportItemRequest( + @Schema(description = "Client-side local document ID", example = "local-123") + String localId, + + @Schema(description = "Document title", example = "Imported Doc") + @NotBlank(message = "Title is required") + @Size(max = 255, message = "Title must be at most 255 characters") + String title, + + @Schema(description = "Base64-encoded Yjs state") @NotBlank(message = "yjsState is required") + String yjsState, + + @Schema(description = "Optional creator label") + @Size(max = 255, message = "createdBy must be at most 255 characters") + String createdBy) {} diff --git a/api/src/main/java/com/nextdocs/api/document/dto/request/BulkImportRequest.java b/api/src/main/java/com/nextdocs/api/document/dto/request/BulkImportRequest.java new file mode 100644 index 0000000..eec17a4 --- /dev/null +++ b/api/src/main/java/com/nextdocs/api/document/dto/request/BulkImportRequest.java @@ -0,0 +1,12 @@ +package com.nextdocs.api.document.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.util.List; + +@Schema(description = "Bulk import request for local documents") +public record BulkImportRequest( + @Schema(description = "Documents to import") @NotEmpty(message = "docs must not be empty") + List<@NotNull @Valid BulkImportItemRequest> docs) {} diff --git a/api/src/main/java/com/nextdocs/api/document/dto/request/CollaboratorAccessUpdateRequest.java b/api/src/main/java/com/nextdocs/api/document/dto/request/CollaboratorAccessUpdateRequest.java new file mode 100644 index 0000000..e300de0 --- /dev/null +++ b/api/src/main/java/com/nextdocs/api/document/dto/request/CollaboratorAccessUpdateRequest.java @@ -0,0 +1,10 @@ +package com.nextdocs.api.document.dto.request; + +import com.nextdocs.api.document.entity.DocumentAccessLevel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "Update collaborator access level") +public record CollaboratorAccessUpdateRequest( + @Schema(description = "Access level", example = "VIEW") @NotNull(message = "accessLevel is required") + DocumentAccessLevel accessLevel) {} diff --git a/api/src/main/java/com/nextdocs/api/document/dto/request/CollaboratorUpsertRequest.java b/api/src/main/java/com/nextdocs/api/document/dto/request/CollaboratorUpsertRequest.java new file mode 100644 index 0000000..621c113 --- /dev/null +++ b/api/src/main/java/com/nextdocs/api/document/dto/request/CollaboratorUpsertRequest.java @@ -0,0 +1,19 @@ +package com.nextdocs.api.document.dto.request; + +import com.nextdocs.api.document.entity.DocumentAccessLevel; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +@Schema(description = "Add or update collaborator by email") +public record CollaboratorUpsertRequest( + @Schema(description = "Collaborator email", example = "alice@example.com") + @Email(message = "Email must be valid") + @NotBlank(message = "Email is required") + @Size(max = 255, message = "Email must be at most 255 characters") + String email, + + @Schema(description = "Access level", example = "EDIT") @NotNull(message = "accessLevel is required") + DocumentAccessLevel accessLevel) {} diff --git a/api/src/main/java/com/nextdocs/api/document/dto/request/DocumentCreateRequest.java b/api/src/main/java/com/nextdocs/api/document/dto/request/DocumentCreateRequest.java new file mode 100644 index 0000000..b65de77 --- /dev/null +++ b/api/src/main/java/com/nextdocs/api/document/dto/request/DocumentCreateRequest.java @@ -0,0 +1,25 @@ +package com.nextdocs.api.document.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +@Schema(description = "Request body for creating a document") +public record DocumentCreateRequest( + @Schema(description = "Document title", example = "My First Doc") + @NotBlank(message = "Title is required") + @Size(max = 255, message = "Title must be at most 255 characters") + String title, + + @Schema(description = "Base64-encoded Yjs state") + @NotBlank(message = "yjsState is required") + @Size(max = 10_485_760, message = "yjsState must be at most 10485760 characters (~10 MB Base64 payload)") + String yjsState, + + @Schema(description = "Optional creator label", example = "Anonymous") + @Size(max = 255, message = "createdBy must be at most 255 characters") + String createdBy, + + @Schema(description = "Optional local source ID for idempotent import/promotion", example = "local-123") + @Size(max = 128, message = "sourceLocalId must be at most 128 characters") + String sourceLocalId) {} diff --git a/api/src/main/java/com/nextdocs/api/document/dto/request/DocumentUpdateRequest.java b/api/src/main/java/com/nextdocs/api/document/dto/request/DocumentUpdateRequest.java new file mode 100644 index 0000000..70ff989 --- /dev/null +++ b/api/src/main/java/com/nextdocs/api/document/dto/request/DocumentUpdateRequest.java @@ -0,0 +1,15 @@ +package com.nextdocs.api.document.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; + +@Schema(description = "Request body for updating a document") +public record DocumentUpdateRequest( + @Schema(description = "Document title") @Size(max = 255, message = "Title must be at most 255 characters") + String title, + + @Schema(description = "Base64-encoded Yjs state") String yjsState, + + @Schema(description = "Optional creator label") + @Size(max = 255, message = "createdBy must be at most 255 characters") + String createdBy) {} diff --git a/api/src/main/java/com/nextdocs/api/document/dto/request/SharingSettingsUpdateRequest.java b/api/src/main/java/com/nextdocs/api/document/dto/request/SharingSettingsUpdateRequest.java new file mode 100644 index 0000000..88da178 --- /dev/null +++ b/api/src/main/java/com/nextdocs/api/document/dto/request/SharingSettingsUpdateRequest.java @@ -0,0 +1,17 @@ +package com.nextdocs.api.document.dto.request; + +import com.nextdocs.api.document.entity.DocumentAccessLevel; +import com.nextdocs.api.document.entity.DocumentGeneralAccessMode; +import com.nextdocs.api.document.validation.ValidSharingSettings; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "Update document general sharing settings") +@ValidSharingSettings +public record SharingSettingsUpdateRequest( + @Schema(description = "General access mode", example = "ANYONE_WITH_LINK") + @NotNull(message = "generalAccessMode is required") + DocumentGeneralAccessMode generalAccessMode, + + @Schema(description = "Default access level for anyone-with-link mode", example = "VIEW") + DocumentAccessLevel linkAccessLevel) {} diff --git a/api/src/main/java/com/nextdocs/api/document/dto/response/BulkImportItemResponse.java b/api/src/main/java/com/nextdocs/api/document/dto/response/BulkImportItemResponse.java new file mode 100644 index 0000000..79e7fa2 --- /dev/null +++ b/api/src/main/java/com/nextdocs/api/document/dto/response/BulkImportItemResponse.java @@ -0,0 +1,10 @@ +package com.nextdocs.api.document.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.UUID; + +@Schema(description = "Single imported document mapping") +public record BulkImportItemResponse( + @Schema(description = "Client local ID") String localId, + @Schema(description = "Server document ID") UUID documentId, + @Schema(description = "Imported title") String title) {} diff --git a/api/src/main/java/com/nextdocs/api/document/dto/response/BulkImportResponse.java b/api/src/main/java/com/nextdocs/api/document/dto/response/BulkImportResponse.java new file mode 100644 index 0000000..4049a01 --- /dev/null +++ b/api/src/main/java/com/nextdocs/api/document/dto/response/BulkImportResponse.java @@ -0,0 +1,8 @@ +package com.nextdocs.api.document.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@Schema(description = "Bulk import result") +public record BulkImportResponse( + @Schema(description = "Imported documents") List imported) {} diff --git a/api/src/main/java/com/nextdocs/api/document/dto/response/CollaboratorResponse.java b/api/src/main/java/com/nextdocs/api/document/dto/response/CollaboratorResponse.java new file mode 100644 index 0000000..2ef6243 --- /dev/null +++ b/api/src/main/java/com/nextdocs/api/document/dto/response/CollaboratorResponse.java @@ -0,0 +1,16 @@ +package com.nextdocs.api.document.dto.response; + +import com.nextdocs.api.document.entity.DocumentAccessLevel; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.OffsetDateTime; +import java.util.UUID; + +@Schema(description = "Collaborator entry") +public record CollaboratorResponse( + @Schema(description = "User ID") UUID userId, + @Schema(description = "User email") String email, + @Schema(description = "Display name") String displayName, + @Schema(description = "Access level") DocumentAccessLevel accessLevel, + + @Schema(description = "Collaborator grant timestamp") + OffsetDateTime addedAt) {} diff --git a/api/src/main/java/com/nextdocs/api/document/dto/response/DocumentAccessResponse.java b/api/src/main/java/com/nextdocs/api/document/dto/response/DocumentAccessResponse.java new file mode 100644 index 0000000..c99b680 --- /dev/null +++ b/api/src/main/java/com/nextdocs/api/document/dto/response/DocumentAccessResponse.java @@ -0,0 +1,14 @@ +package com.nextdocs.api.document.dto.response; + +import com.nextdocs.api.document.entity.DocumentAccessLevel; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.UUID; + +@Schema(description = "Effective access for a specific document") +public record DocumentAccessResponse( + @Schema(description = "Document ID") UUID documentId, + @Schema(description = "Whether access is granted") boolean allowed, + @Schema(description = "Effective access level") DocumentAccessLevel accessLevel, + + @Schema(description = "Whether current user is owner") + boolean owner) {} diff --git a/api/src/main/java/com/nextdocs/api/document/dto/response/DocumentResponse.java b/api/src/main/java/com/nextdocs/api/document/dto/response/DocumentResponse.java new file mode 100644 index 0000000..b68c08d --- /dev/null +++ b/api/src/main/java/com/nextdocs/api/document/dto/response/DocumentResponse.java @@ -0,0 +1,23 @@ +package com.nextdocs.api.document.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.OffsetDateTime; +import java.util.UUID; + +@Schema(description = "Document response") +public record DocumentResponse( + @Schema(description = "Document ID") UUID id, + @Schema(description = "Document title") String title, + + @Schema(description = "Base64-encoded Yjs state when requested") + String yjsState, + + @Schema(description = "Creator label") String createdBy, + @Schema(description = "Creation timestamp") OffsetDateTime createdAt, + @Schema(description = "Last update timestamp") OffsetDateTime updatedAt, + + @Schema(description = "When the document was moved to trash, if applicable") + OffsetDateTime deletedAt, + + @Schema(description = "When the document will be permanently removed (trash retention)") + OffsetDateTime purgeAt) {} diff --git a/api/src/main/java/com/nextdocs/api/document/dto/response/SharingSettingsResponse.java b/api/src/main/java/com/nextdocs/api/document/dto/response/SharingSettingsResponse.java new file mode 100644 index 0000000..9b8de54 --- /dev/null +++ b/api/src/main/java/com/nextdocs/api/document/dto/response/SharingSettingsResponse.java @@ -0,0 +1,15 @@ +package com.nextdocs.api.document.dto.response; + +import com.nextdocs.api.document.entity.DocumentAccessLevel; +import com.nextdocs.api.document.entity.DocumentGeneralAccessMode; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Document sharing settings") +public record SharingSettingsResponse( + @Schema(description = "General access mode") DocumentGeneralAccessMode generalAccessMode, + + @Schema(description = "Access level for share links") + DocumentAccessLevel linkAccessLevel, + + @Schema(description = "Whether an active share link exists") + boolean hasActiveLink) {} diff --git a/api/src/main/java/com/nextdocs/api/document/entity/Document.java b/api/src/main/java/com/nextdocs/api/document/entity/Document.java new file mode 100644 index 0000000..b5e0845 --- /dev/null +++ b/api/src/main/java/com/nextdocs/api/document/entity/Document.java @@ -0,0 +1,69 @@ +package com.nextdocs.api.document.entity; + +import com.nextdocs.api.auth.entity.User; +import jakarta.persistence.*; +import java.time.OffsetDateTime; +import java.util.UUID; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.annotations.UpdateTimestamp; +import org.hibernate.type.SqlTypes; + +@Entity +@Table( + name = "documents", + indexes = { + @Index(name = "idx_documents_user_created", columnList = "user_id,created_at"), + @Index(name = "idx_documents_user_updated", columnList = "user_id,updated_at"), + @Index(name = "idx_documents_user_source_local", columnList = "user_id,source_local_id") + }) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Document { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(nullable = false, length = 255) + private String title; + + @JdbcTypeCode(SqlTypes.VARBINARY) + @Column(name = "yjs_state", nullable = false, columnDefinition = "bytea") + private byte[] yjsState; + + @Column(name = "created_by", length = 255) + private String createdBy; + + @Column(name = "source_local_id", length = 128) + private String sourceLocalId; + + @Column(name = "deleted_at") + private OffsetDateTime deletedAt; + + @Enumerated(EnumType.STRING) + @Column(name = "general_access_mode", nullable = false, length = 32) + @Builder.Default + private DocumentGeneralAccessMode generalAccessMode = DocumentGeneralAccessMode.RESTRICTED; + + @Enumerated(EnumType.STRING) + @Column(name = "link_access_level", nullable = false, length = 16) + @Builder.Default + private DocumentAccessLevel linkAccessLevel = DocumentAccessLevel.VIEW; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private OffsetDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; +} diff --git a/api/src/main/java/com/nextdocs/api/document/entity/DocumentAccessLevel.java b/api/src/main/java/com/nextdocs/api/document/entity/DocumentAccessLevel.java new file mode 100644 index 0000000..c0ce31e --- /dev/null +++ b/api/src/main/java/com/nextdocs/api/document/entity/DocumentAccessLevel.java @@ -0,0 +1,20 @@ +package com.nextdocs.api.document.entity; + +public enum DocumentAccessLevel { + VIEW, + COMMENT, + EDIT, + OWNER; + + public boolean allowsEdit() { + return this == EDIT || this == OWNER; + } + + public boolean allowsComment() { + return this == COMMENT || this == EDIT || this == OWNER; + } + + public boolean allowsRead() { + return true; + } +} diff --git a/api/src/main/java/com/nextdocs/api/document/entity/DocumentCollaborator.java b/api/src/main/java/com/nextdocs/api/document/entity/DocumentCollaborator.java new file mode 100644 index 0000000..4106201 --- /dev/null +++ b/api/src/main/java/com/nextdocs/api/document/entity/DocumentCollaborator.java @@ -0,0 +1,57 @@ +package com.nextdocs.api.document.entity; + +import com.nextdocs.api.auth.entity.User; +import jakarta.persistence.*; +import java.time.OffsetDateTime; +import java.util.UUID; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +@Entity +@Table( + name = "document_collaborators", + uniqueConstraints = { + @UniqueConstraint( + name = "uq_document_collaborators_doc_user", + columnNames = {"document_id", "user_id"}) + }, + indexes = { + @Index(name = "idx_document_collaborators_doc", columnList = "document_id"), + @Index(name = "idx_document_collaborators_user", columnList = "user_id,updated_at") + }) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class DocumentCollaborator { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "document_id", nullable = false) + private Document document; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(name = "access_level", nullable = false, length = 16) + private DocumentAccessLevel accessLevel; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "granted_by_user_id") + private User grantedBy; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private OffsetDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; +} diff --git a/api/src/main/java/com/nextdocs/api/document/entity/DocumentGeneralAccessMode.java b/api/src/main/java/com/nextdocs/api/document/entity/DocumentGeneralAccessMode.java new file mode 100644 index 0000000..9c484d1 --- /dev/null +++ b/api/src/main/java/com/nextdocs/api/document/entity/DocumentGeneralAccessMode.java @@ -0,0 +1,6 @@ +package com.nextdocs.api.document.entity; + +public enum DocumentGeneralAccessMode { + RESTRICTED, + ANYONE_WITH_LINK +} diff --git a/api/src/main/java/com/nextdocs/api/document/repository/DocumentCollaboratorRepository.java b/api/src/main/java/com/nextdocs/api/document/repository/DocumentCollaboratorRepository.java new file mode 100644 index 0000000..3bbf1c9 --- /dev/null +++ b/api/src/main/java/com/nextdocs/api/document/repository/DocumentCollaboratorRepository.java @@ -0,0 +1,20 @@ +package com.nextdocs.api.document.repository; + +import com.nextdocs.api.document.entity.DocumentCollaborator; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface DocumentCollaboratorRepository extends JpaRepository { + + List findAllByDocument_Id(UUID documentId); + + Optional findByDocument_IdAndUser_Id(UUID documentId, UUID userId); + + boolean existsByDocument_IdAndUser_Id(UUID documentId, UUID userId); + + void deleteByDocument_IdAndUser_Id(UUID documentId, UUID userId); +} diff --git a/api/src/main/java/com/nextdocs/api/document/repository/DocumentRepository.java b/api/src/main/java/com/nextdocs/api/document/repository/DocumentRepository.java new file mode 100644 index 0000000..9c9d9e6 --- /dev/null +++ b/api/src/main/java/com/nextdocs/api/document/repository/DocumentRepository.java @@ -0,0 +1,44 @@ +package com.nextdocs.api.document.repository; + +import com.nextdocs.api.document.entity.Document; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface DocumentRepository extends JpaRepository { + + Page findAllByUser_IdAndDeletedAtIsNull(UUID userId, Pageable pageable); + + Page findAllByUser_IdAndDeletedAtIsNotNull(UUID userId, Pageable pageable); + + List findAllByUser_IdAndSourceLocalIdInAndDeletedAtIsNull(UUID userId, List sourceLocalIds); + + Optional findByUser_IdAndSourceLocalIdAndDeletedAtIsNull(UUID userId, String sourceLocalId); + + Optional findByIdAndUser_IdAndDeletedAtIsNull(UUID id, UUID userId); + + Optional findByIdAndUser_Id(UUID id, UUID userId); + + Optional findByIdAndUser_IdAndDeletedAtIsNotNull(UUID id, UUID userId); + + Optional findByIdAndDeletedAtIsNull(UUID id); + + @Query("SELECT d FROM Document d " + + "JOIN DocumentCollaborator c ON c.document.id = d.id " + + "WHERE c.user.id = :userId AND d.deletedAt IS NULL " + + "ORDER BY d.updatedAt DESC, d.createdAt DESC, d.id ASC") + Page findSharedWithUserId(@Param("userId") UUID userId, Pageable pageable); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("DELETE FROM Document d WHERE d.deletedAt IS NOT NULL AND d.deletedAt < :cutoff") + int deleteExpiredTrash(@Param("cutoff") OffsetDateTime cutoff); +} diff --git a/api/src/main/java/com/nextdocs/api/document/schedule/DocumentTrashPurgeScheduler.java b/api/src/main/java/com/nextdocs/api/document/schedule/DocumentTrashPurgeScheduler.java new file mode 100644 index 0000000..3fd4d34 --- /dev/null +++ b/api/src/main/java/com/nextdocs/api/document/schedule/DocumentTrashPurgeScheduler.java @@ -0,0 +1,25 @@ +package com.nextdocs.api.document.schedule; + +import com.nextdocs.api.document.service.DocumentService; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class DocumentTrashPurgeScheduler { + + private static final Logger log = LoggerFactory.getLogger(DocumentTrashPurgeScheduler.class); + + private final DocumentService documentService; + + @Scheduled(cron = "${app.documents.purge-cron:0 0 3 * * *}") + public void purgeExpiredTrash() { + int purged = documentService.purgeExpiredTrash(); + if (purged > 0) { + log.info("Purged {} document(s) past trash retention.", purged); + } + } +} diff --git a/api/src/main/java/com/nextdocs/api/document/service/DocumentService.java b/api/src/main/java/com/nextdocs/api/document/service/DocumentService.java new file mode 100644 index 0000000..2ef3422 --- /dev/null +++ b/api/src/main/java/com/nextdocs/api/document/service/DocumentService.java @@ -0,0 +1,386 @@ +package com.nextdocs.api.document.service; + +import com.nextdocs.api.auth.entity.User; +import com.nextdocs.api.auth.repository.UserRepository; +import com.nextdocs.api.common.exception.ApiException; +import com.nextdocs.api.common.exception.ErrorCode; +import com.nextdocs.api.document.config.DocumentProperties; +import com.nextdocs.api.document.dto.request.*; +import com.nextdocs.api.document.dto.response.*; +import com.nextdocs.api.document.entity.Document; +import com.nextdocs.api.document.entity.DocumentAccessLevel; +import com.nextdocs.api.document.entity.DocumentCollaborator; +import com.nextdocs.api.document.entity.DocumentGeneralAccessMode; +import com.nextdocs.api.document.repository.DocumentCollaboratorRepository; +import com.nextdocs.api.document.repository.DocumentRepository; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class DocumentService { + + private final DocumentRepository documentRepository; + private final DocumentCollaboratorRepository collaboratorRepository; + private final UserRepository userRepository; + private final DocumentProperties documentProperties; + + @Autowired + @Lazy + private DocumentService selfProxy; + + @Transactional + public DocumentResponse create(UUID userId, DocumentCreateRequest request) { + User user = userRepository.findById(userId).orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND)); + String sourceLocalId = normalizeSourceLocalId(request.sourceLocalId()); + String yjsState = request.yjsState(); + + if (yjsState == null) { + throw new ApiException(ErrorCode.VALIDATION_FAILED, "yjsState is required."); + } + + if (sourceLocalId != null) { + Document existing = documentRepository + .findByUser_IdAndSourceLocalIdAndDeletedAtIsNull(userId, sourceLocalId) + .orElse(null); + + if (existing != null) { + applyFields(existing, user, request.title(), yjsState, request.createdBy(), sourceLocalId); + return toResponse(documentRepository.save(existing), true); + } + } + + Document document = Document.builder() + .user(user) + .title(normalizeTitle(request.title())) + .yjsState(decodeBase64State(yjsState)) + .createdBy(request.createdBy()) + .sourceLocalId(sourceLocalId) + .build(); + + if (sourceLocalId != null) { + try { + return toResponse(documentRepository.saveAndFlush(document), true); + } catch (DataIntegrityViolationException ex) { + Document existing = documentRepository + .findByUser_IdAndSourceLocalIdAndDeletedAtIsNull(userId, sourceLocalId) + .orElseThrow(() -> ex); + + applyFields(existing, user, request.title(), yjsState, request.createdBy(), sourceLocalId); + return toResponse(documentRepository.save(existing), true); + } + } + + return toResponse(documentRepository.save(document), true); + } + + @Transactional(readOnly = true) + public Page list(UUID userId, Pageable pageable, boolean trashedOnly) { + Pageable effectivePageable = pageable; + if (effectivePageable == null) { + effectivePageable = PageRequest.of(0, 20); + } + + if (effectivePageable.getSort().isUnsorted()) { + Sort sort = trashedOnly + ? Sort.by(Sort.Order.desc("deletedAt"), Sort.Order.asc("id")) + : Sort.by(Sort.Order.desc("updatedAt"), Sort.Order.desc("createdAt"), Sort.Order.asc("id")); + effectivePageable = + PageRequest.of(effectivePageable.getPageNumber(), effectivePageable.getPageSize(), sort); + } + + Page page = trashedOnly + ? documentRepository.findAllByUser_IdAndDeletedAtIsNotNull(userId, effectivePageable) + : documentRepository.findAllByUser_IdAndDeletedAtIsNull(userId, effectivePageable); + + return page.map(document -> toResponse(document, false)); + } + + @Transactional(readOnly = true) + public DocumentResponse get(UUID userId, UUID documentId, boolean includeTrashed) { + Document document; + if (includeTrashed) { + document = documentRepository + .findByIdAndUser_Id(documentId, userId) + .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND)); + } else { + document = findAccessibleActiveDocument(userId, documentId, false); + } + return toResponse(document, true); + } + + @Transactional(readOnly = true) + public DocumentResponse getPublic(UUID documentId) { + Document document = documentRepository + .findByIdAndDeletedAtIsNull(documentId) + .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND)); + + if (resolveGeneralAccessLevel(document) == null) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + + return toResponse(document, true); + } + + @Transactional + public DocumentResponse update(UUID userId, UUID documentId, DocumentUpdateRequest request) { + Document document = + documentRepository.findByIdAndDeletedAtIsNull(documentId).orElse(null); + if (document == null) { + if (documentRepository.findByIdAndUser_Id(documentId, userId).isPresent()) { + throw new ApiException(ErrorCode.CONFLICT, "Cannot update a document in trash. Restore it first."); + } + throw new ApiException(ErrorCode.NOT_FOUND); + } + + if (!document.getUser().getId().equals(userId)) { + DocumentAccessLevel effectiveAccess = resolveEffectiveNonOwnerAccess(userId, document); + if (effectiveAccess == null) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + + if (!effectiveAccess.allowsEdit()) { + throw new ApiException(ErrorCode.FORBIDDEN); + } + } + + if (request.title() != null) { + document.setTitle(normalizeTitle(request.title())); + } + + if (request.yjsState() != null) { + document.setYjsState(decodeBase64State(request.yjsState())); + } + + if (request.createdBy() != null) { + document.setCreatedBy(request.createdBy()); + } + + return toResponse(documentRepository.save(document), true); + } + + @Transactional + public void delete(UUID userId, UUID documentId, boolean permanent) { + if (permanent) { + Document document = documentRepository + .findByIdAndUser_Id(documentId, userId) + .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND)); + if (document.getDeletedAt() == null) { + throw new ApiException( + ErrorCode.VALIDATION_FAILED, + "Permanent delete is only allowed for documents already in trash."); + } + documentRepository.delete(document); + return; + } + + Document document = documentRepository + .findByIdAndUser_IdAndDeletedAtIsNull(documentId, userId) + .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND)); + document.setDeletedAt(OffsetDateTime.now(ZoneOffset.UTC)); + documentRepository.save(document); + } + + @Transactional + public DocumentResponse restore(UUID userId, UUID documentId) { + Document document = documentRepository + .findByIdAndUser_IdAndDeletedAtIsNotNull(documentId, userId) + .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND)); + document.setDeletedAt(null); + return toResponse(documentRepository.save(document), true); + } + + @Transactional + public int purgeExpiredTrash(OffsetDateTime asOfUtc) { + int days = documentProperties.getTrashRetentionDays(); + OffsetDateTime cutoff = asOfUtc.minusDays(days); + return documentRepository.deleteExpiredTrash(cutoff); + } + + public int purgeExpiredTrash() { + OffsetDateTime nowUtc = OffsetDateTime.now(ZoneOffset.UTC); + if (selfProxy != null) { + return selfProxy.purgeExpiredTrash(nowUtc); + } + return purgeExpiredTrash(nowUtc); + } + + @Transactional(propagation = Propagation.REQUIRED, rollbackFor = Exception.class) + public BulkImportResponse bulkImport(UUID userId, BulkImportRequest request) { + User user = userRepository.findById(userId).orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND)); + + List sourceLocalIds = request.docs().stream() + .map(BulkImportItemRequest::localId) + .map(DocumentService::normalizeSourceLocalId) + .filter(id -> id != null) + .toList(); + + Map existingByLocalId = new HashMap<>(); + if (!sourceLocalIds.isEmpty()) { + existingByLocalId = + documentRepository + .findAllByUser_IdAndSourceLocalIdInAndDeletedAtIsNull(userId, sourceLocalIds) + .stream() + .collect(java.util.stream.Collectors.toMap(Document::getSourceLocalId, doc -> doc)); + } + + List imported = new java.util.ArrayList<>(); + for (BulkImportItemRequest item : request.docs()) { + String localId = normalizeSourceLocalId(item.localId()); + Document existing = localId == null ? null : existingByLocalId.get(localId); + Document target = existing == null ? new Document() : existing; + + applyFields(target, user, item.title(), item.yjsState(), item.createdBy(), localId); + + target = saveWithSourceLocalIdRetry(userId, localId, target, user, item); + + if (localId != null) { + existingByLocalId.put(localId, target); + } + + imported.add(new BulkImportItemResponse(item.localId(), target.getId(), target.getTitle())); + } + + return new BulkImportResponse(imported); + } + + private Document saveWithSourceLocalIdRetry( + UUID userId, String localId, Document target, User user, BulkImportItemRequest item) { + if (target.getId() != null) { + return documentRepository.save(target); + } + + try { + return documentRepository.saveAndFlush(target); + } catch (DataIntegrityViolationException ex) { + if (localId == null) { + throw ex; + } + + Document latest = documentRepository + .findByUser_IdAndSourceLocalIdAndDeletedAtIsNull(userId, localId) + .orElseThrow(() -> ex); + + applyFields(latest, user, item.title(), item.yjsState(), item.createdBy(), localId); + return documentRepository.save(latest); + } + } + + private static String normalizeTitle(String title) { + String value = title == null ? "" : title.strip(); + if (value.isBlank()) { + throw new ApiException(ErrorCode.VALIDATION_FAILED, "Title must not be blank."); + } + return value; + } + + private static String normalizeSourceLocalId(String sourceLocalId) { + if (sourceLocalId == null) { + return null; + } + + String value = sourceLocalId.strip(); + return value.isBlank() ? null : value; + } + + private static void applyFields( + Document target, User user, String title, String yjsState, String createdBy, String sourceLocalId) { + target.setUser(user); + target.setTitle(normalizeTitle(title)); + target.setYjsState(decodeBase64State(yjsState)); + target.setCreatedBy(createdBy); + target.setSourceLocalId(sourceLocalId); + } + + private static byte[] decodeBase64State(String yjsState) { + if (yjsState == null) { + return null; + } + + try { + return Base64.getDecoder().decode(yjsState); + } catch (IllegalArgumentException ex) { + throw new ApiException(ErrorCode.VALIDATION_FAILED, "yjsState must be valid base64."); + } + } + + private DocumentResponse toResponse(Document document, boolean includeState) { + OffsetDateTime deletedAt = document.getDeletedAt(); + OffsetDateTime purgeAt = null; + if (deletedAt != null) { + purgeAt = deletedAt.plusDays(documentProperties.getTrashRetentionDays()); + } + return new DocumentResponse( + document.getId(), + document.getTitle(), + includeState + ? (document.getYjsState() != null + ? Base64.getEncoder().encodeToString(document.getYjsState()) + : null) + : null, + document.getCreatedBy(), + document.getCreatedAt(), + document.getUpdatedAt(), + deletedAt, + purgeAt); + } + + private Document findAccessibleActiveDocument(UUID userId, UUID documentId, boolean requireEdit) { + Document document = documentRepository + .findByIdAndDeletedAtIsNull(documentId) + .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND)); + + if (document.getUser().getId().equals(userId)) { + return document; + } + + DocumentAccessLevel effectiveAccess = resolveEffectiveNonOwnerAccess(userId, document); + if (effectiveAccess == null) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + + if (requireEdit && !effectiveAccess.allowsEdit()) { + throw new ApiException(ErrorCode.FORBIDDEN); + } + + return document; + } + + private DocumentAccessLevel resolveEffectiveNonOwnerAccess(UUID userId, Document document) { + DocumentAccessLevel collaboratorAccess = collaboratorRepository + .findByDocument_IdAndUser_Id(document.getId(), userId) + .map(DocumentCollaborator::getAccessLevel) + .orElse(null); + + // Explicit collaborator access takes precedence over general link access. + if (collaboratorAccess != null) { + return collaboratorAccess; + } + + return resolveGeneralAccessLevel(document); + } + + private DocumentAccessLevel resolveGeneralAccessLevel(Document document) { + if (document.getGeneralAccessMode() != DocumentGeneralAccessMode.ANYONE_WITH_LINK) { + return null; + } + + return document.getLinkAccessLevel(); + } +} diff --git a/api/src/main/java/com/nextdocs/api/document/service/DocumentSharingService.java b/api/src/main/java/com/nextdocs/api/document/service/DocumentSharingService.java new file mode 100644 index 0000000..f1ca795 --- /dev/null +++ b/api/src/main/java/com/nextdocs/api/document/service/DocumentSharingService.java @@ -0,0 +1,282 @@ +package com.nextdocs.api.document.service; + +import com.nextdocs.api.auth.entity.User; +import com.nextdocs.api.auth.repository.UserRepository; +import com.nextdocs.api.common.exception.ApiException; +import com.nextdocs.api.common.exception.ErrorCode; +import com.nextdocs.api.document.dto.request.CollaboratorAccessUpdateRequest; +import com.nextdocs.api.document.dto.request.CollaboratorUpsertRequest; +import com.nextdocs.api.document.dto.request.SharingSettingsUpdateRequest; +import com.nextdocs.api.document.dto.response.CollaboratorResponse; +import com.nextdocs.api.document.dto.response.DocumentAccessResponse; +import com.nextdocs.api.document.dto.response.DocumentResponse; +import com.nextdocs.api.document.dto.response.SharingSettingsResponse; +import com.nextdocs.api.document.entity.Document; +import com.nextdocs.api.document.entity.DocumentAccessLevel; +import com.nextdocs.api.document.entity.DocumentCollaborator; +import com.nextdocs.api.document.entity.DocumentGeneralAccessMode; +import com.nextdocs.api.document.repository.DocumentCollaboratorRepository; +import com.nextdocs.api.document.repository.DocumentRepository; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class DocumentSharingService { + + private final DocumentRepository documentRepository; + private final DocumentCollaboratorRepository collaboratorRepository; + private final UserRepository userRepository; + + @Transactional(readOnly = true) + public List listCollaborators(UUID requesterId, UUID documentId) { + Document doc = requireAccessibleActiveDocument(requesterId, documentId); + + CollaboratorResponse owner = new CollaboratorResponse( + doc.getUser().getId(), + doc.getUser().getEmail(), + doc.getUser().getDisplayName(), + DocumentAccessLevel.OWNER, + doc.getCreatedAt()); + + List collaborators = collaboratorRepository.findAllByDocument_Id(documentId).stream() + .map(c -> new CollaboratorResponse( + c.getUser().getId(), + c.getUser().getEmail(), + c.getUser().getDisplayName(), + c.getAccessLevel(), + c.getCreatedAt())) + .toList(); + + return java.util.stream.Stream.concat(java.util.stream.Stream.of(owner), collaborators.stream()) + .toList(); + } + + @Transactional + public CollaboratorResponse upsertCollaborator(UUID ownerId, UUID documentId, CollaboratorUpsertRequest request) { + Document doc = requireOwnedActiveDocument(ownerId, documentId); + DocumentAccessLevel requestedLevel = normalizeCollaboratorAccess(request.accessLevel()); + + User targetUser = userRepository + .findByEmail(request.email().strip().toLowerCase()) + .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND, "User not found for the provided email.")); + + if (targetUser.getId().equals(ownerId)) { + throw new ApiException(ErrorCode.CONFLICT, "Document owner already has owner access."); + } + + DocumentCollaborator collaborator = collaboratorRepository + .findByDocument_IdAndUser_Id(documentId, targetUser.getId()) + .orElseGet(() -> DocumentCollaborator.builder() + .document(doc) + .user(targetUser) + .build()); + + collaborator.setAccessLevel(requestedLevel); + collaborator.setGrantedBy(doc.getUser()); + + DocumentCollaborator saved = collaboratorRepository.save(collaborator); + + return new CollaboratorResponse( + saved.getUser().getId(), + saved.getUser().getEmail(), + saved.getUser().getDisplayName(), + saved.getAccessLevel(), + saved.getCreatedAt()); + } + + @Transactional + public CollaboratorResponse updateCollaboratorAccess( + UUID ownerId, UUID documentId, UUID collaboratorUserId, CollaboratorAccessUpdateRequest request) { + requireOwnedActiveDocument(ownerId, documentId); + + if (ownerId.equals(collaboratorUserId)) { + throw new ApiException(ErrorCode.CONFLICT, "Owner access cannot be changed."); + } + + DocumentCollaborator collaborator = collaboratorRepository + .findByDocument_IdAndUser_Id(documentId, collaboratorUserId) + .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND)); + + collaborator.setAccessLevel(normalizeCollaboratorAccess(request.accessLevel())); + DocumentCollaborator saved = collaboratorRepository.save(collaborator); + + return new CollaboratorResponse( + saved.getUser().getId(), + saved.getUser().getEmail(), + saved.getUser().getDisplayName(), + saved.getAccessLevel(), + saved.getCreatedAt()); + } + + @Transactional + public void removeCollaborator(UUID ownerId, UUID documentId, UUID collaboratorUserId) { + requireOwnedActiveDocument(ownerId, documentId); + + if (ownerId.equals(collaboratorUserId)) { + throw new ApiException(ErrorCode.CONFLICT, "Owner cannot be removed from collaborators."); + } + + boolean exists = collaboratorRepository.existsByDocument_IdAndUser_Id(documentId, collaboratorUserId); + if (!exists) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + + collaboratorRepository.deleteByDocument_IdAndUser_Id(documentId, collaboratorUserId); + } + + @Transactional + public void leaveSharedDocument(UUID userId, UUID documentId) { + Document doc = requireAccessibleActiveDocument(userId, documentId); + + if (doc.getUser().getId().equals(userId)) { + throw new ApiException(ErrorCode.CONFLICT, "Owners cannot leave their own documents."); + } + + boolean exists = collaboratorRepository.existsByDocument_IdAndUser_Id(documentId, userId); + if (!exists) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + + collaboratorRepository.deleteByDocument_IdAndUser_Id(documentId, userId); + } + + @Transactional(readOnly = true) + public SharingSettingsResponse getSharingSettings(UUID ownerId, UUID documentId) { + Document doc = requireOwnedActiveDocument(ownerId, documentId); + boolean hasActiveLink = doc.getGeneralAccessMode() == DocumentGeneralAccessMode.ANYONE_WITH_LINK; + + return new SharingSettingsResponse(doc.getGeneralAccessMode(), doc.getLinkAccessLevel(), hasActiveLink); + } + + @Transactional + public SharingSettingsResponse updateSharingSettings( + UUID ownerId, UUID documentId, SharingSettingsUpdateRequest request) { + Document doc = requireOwnedActiveDocument(ownerId, documentId); + + DocumentGeneralAccessMode mode = request.generalAccessMode(); + if (mode == null) { + throw new ApiException(ErrorCode.VALIDATION_FAILED, "generalAccessMode is required."); + } + + doc.setGeneralAccessMode(mode); + if (request.linkAccessLevel() != null) { + doc.setLinkAccessLevel(normalizeLinkAccess(request.linkAccessLevel())); + } + + documentRepository.save(doc); + boolean hasActiveLink = doc.getGeneralAccessMode() == DocumentGeneralAccessMode.ANYONE_WITH_LINK; + + return new SharingSettingsResponse(doc.getGeneralAccessMode(), doc.getLinkAccessLevel(), hasActiveLink); + } + + @Transactional(readOnly = true) + public Page listSharedWithMe(UUID userId, Pageable pageable) { + return documentRepository.findSharedWithUserId(userId, pageable).map(this::toDocumentSummaryResponse); + } + + @Transactional(readOnly = true) + public DocumentAccessResponse getMyAccess(UUID userId, UUID documentId) { + return computeAccess(userId, documentId); + } + + @Transactional(readOnly = true) + public DocumentAccessResponse accessCheck(UUID userId, UUID documentId) { + return computeAccess(userId, documentId); + } + + private DocumentAccessResponse computeAccess(UUID userId, UUID documentId) { + Document doc = documentRepository.findByIdAndDeletedAtIsNull(documentId).orElse(null); + if (doc == null) { + return new DocumentAccessResponse(documentId, false, null, false); + } + + if (doc.getUser().getId().equals(userId)) { + return new DocumentAccessResponse(documentId, true, DocumentAccessLevel.OWNER, true); + } + + DocumentCollaborator collaborator = collaboratorRepository + .findByDocument_IdAndUser_Id(documentId, userId) + .orElse(null); + DocumentAccessLevel collaboratorAccess = collaborator == null ? null : collaborator.getAccessLevel(); + if (collaboratorAccess != null) { + return new DocumentAccessResponse(documentId, true, collaboratorAccess, false); + } + + DocumentAccessLevel effectiveAccess = resolveGeneralAccessLevel(doc); + + if (effectiveAccess != null) { + return new DocumentAccessResponse(documentId, true, effectiveAccess, false); + } + + return new DocumentAccessResponse(documentId, false, null, false); + } + + private DocumentAccessLevel resolveGeneralAccessLevel(Document document) { + if (document.getGeneralAccessMode() != DocumentGeneralAccessMode.ANYONE_WITH_LINK) { + return null; + } + + return document.getLinkAccessLevel(); + } + + private Document requireOwnedActiveDocument(UUID ownerId, UUID documentId) { + return documentRepository + .findByIdAndUser_IdAndDeletedAtIsNull(documentId, ownerId) + .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND)); + } + + private Document requireAccessibleActiveDocument(UUID userId, UUID documentId) { + Document doc = documentRepository + .findByIdAndDeletedAtIsNull(documentId) + .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND)); + + if (doc.getUser().getId().equals(userId)) { + return doc; + } + + boolean isCollaborator = collaboratorRepository.existsByDocument_IdAndUser_Id(documentId, userId); + if (!isCollaborator) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + + return doc; + } + + private static DocumentAccessLevel normalizeCollaboratorAccess(DocumentAccessLevel accessLevel) { + if (accessLevel == null) { + throw new ApiException(ErrorCode.VALIDATION_FAILED, "accessLevel is required."); + } + if (accessLevel == DocumentAccessLevel.OWNER) { + throw new ApiException(ErrorCode.VALIDATION_FAILED, "OWNER is not allowed for collaborators."); + } + return accessLevel; + } + + private static DocumentAccessLevel normalizeLinkAccess(DocumentAccessLevel accessLevel) { + if (accessLevel == null) { + throw new ApiException(ErrorCode.VALIDATION_FAILED, "accessLevel is required."); + } + if (accessLevel == DocumentAccessLevel.OWNER) { + throw new ApiException(ErrorCode.VALIDATION_FAILED, "OWNER is not allowed for share links."); + } + return accessLevel; + } + + private DocumentResponse toDocumentSummaryResponse(Document document) { + return new DocumentResponse( + document.getId(), + document.getTitle(), + null, + document.getCreatedBy(), + document.getCreatedAt(), + document.getUpdatedAt(), + document.getDeletedAt(), + null); + } +} diff --git a/api/src/main/java/com/nextdocs/api/document/validation/SharingSettingsValidator.java b/api/src/main/java/com/nextdocs/api/document/validation/SharingSettingsValidator.java new file mode 100644 index 0000000..9282631 --- /dev/null +++ b/api/src/main/java/com/nextdocs/api/document/validation/SharingSettingsValidator.java @@ -0,0 +1,45 @@ +package com.nextdocs.api.document.validation; + +import com.nextdocs.api.document.dto.request.SharingSettingsUpdateRequest; +import com.nextdocs.api.document.entity.DocumentGeneralAccessMode; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class SharingSettingsValidator + implements ConstraintValidator { + + private static final String REQUIRED_FOR_ANYONE_MSG = + "linkAccessLevel is required when generalAccessMode is ANYONE_WITH_LINK."; + + private static final String MUST_BE_OMITTED_MSG = + "linkAccessLevel must be omitted unless generalAccessMode is ANYONE_WITH_LINK."; + + @Override + public boolean isValid(SharingSettingsUpdateRequest request, ConstraintValidatorContext context) { + if (request == null) { + return true; + } + + DocumentGeneralAccessMode mode = request.generalAccessMode(); + boolean isAnyoneWithLink = mode == DocumentGeneralAccessMode.ANYONE_WITH_LINK; + boolean hasLinkAccessLevel = request.linkAccessLevel() != null; + + if (isAnyoneWithLink && !hasLinkAccessLevel) { + context.disableDefaultConstraintViolation(); + context.buildConstraintViolationWithTemplate(REQUIRED_FOR_ANYONE_MSG) + .addPropertyNode("linkAccessLevel") + .addConstraintViolation(); + return false; + } + + if (!isAnyoneWithLink && hasLinkAccessLevel) { + context.disableDefaultConstraintViolation(); + context.buildConstraintViolationWithTemplate(MUST_BE_OMITTED_MSG) + .addPropertyNode("linkAccessLevel") + .addConstraintViolation(); + return false; + } + + return true; + } +} diff --git a/api/src/main/java/com/nextdocs/api/document/validation/ValidSharingSettings.java b/api/src/main/java/com/nextdocs/api/document/validation/ValidSharingSettings.java new file mode 100644 index 0000000..98f1094 --- /dev/null +++ b/api/src/main/java/com/nextdocs/api/document/validation/ValidSharingSettings.java @@ -0,0 +1,22 @@ +package com.nextdocs.api.document.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = SharingSettingsValidator.class) +@Documented +public @interface ValidSharingSettings { + + String message() default "Invalid sharing settings combination."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/api/src/main/resources/application.properties b/api/src/main/resources/application.properties index 341d0f3..72051e9 100644 --- a/api/src/main/resources/application.properties +++ b/api/src/main/resources/application.properties @@ -38,6 +38,10 @@ app.cors.allowed-origins=${CORS_ALLOWED_ORIGINS:http://localhost:3000} app.cookie.secure=${COOKIE_SECURE:false} app.cookie.domain=${COOKIE_DOMAIN:} +# --- Documents / trash --- +app.documents.trash-retention-days=${APP_DOCUMENTS_TRASH_RETENTION_DAYS:30} +app.documents.purge-cron=${APP_DOCUMENTS_PURGE_CRON:0 0 3 * * *} + # --- Rate Limiting --- # Comma-separated list of trusted proxy IPs or CIDRs whose X-Forwarded-For header is accepted. # Only the direct caller's remoteAddr is validated against this list. diff --git a/api/src/main/resources/db/migration/V2__create_documents_table.sql b/api/src/main/resources/db/migration/V2__create_documents_table.sql new file mode 100644 index 0000000..ecc13d3 --- /dev/null +++ b/api/src/main/resources/db/migration/V2__create_documents_table.sql @@ -0,0 +1,12 @@ +CREATE TABLE documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + yjs_state BYTEA NOT NULL, + created_by VARCHAR(255), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_documents_user_created ON documents(user_id, created_at DESC); +CREATE INDEX idx_documents_user_updated ON documents(user_id, updated_at DESC); diff --git a/api/src/main/resources/db/migration/V3__add_source_local_id_for_documents.sql b/api/src/main/resources/db/migration/V3__add_source_local_id_for_documents.sql new file mode 100644 index 0000000..b56fef2 --- /dev/null +++ b/api/src/main/resources/db/migration/V3__add_source_local_id_for_documents.sql @@ -0,0 +1,9 @@ +ALTER TABLE documents + ADD COLUMN source_local_id VARCHAR(128); + +CREATE INDEX IF NOT EXISTS idx_documents_user_source_local + ON documents(user_id, source_local_id); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_documents_user_source_local + ON documents(user_id, source_local_id) + WHERE source_local_id IS NOT NULL; diff --git a/api/src/main/resources/db/migration/V4__document_trash.sql b/api/src/main/resources/db/migration/V4__document_trash.sql new file mode 100644 index 0000000..aff9a83 --- /dev/null +++ b/api/src/main/resources/db/migration/V4__document_trash.sql @@ -0,0 +1,13 @@ +ALTER TABLE documents + ADD COLUMN deleted_at TIMESTAMPTZ NULL; + +DROP INDEX IF EXISTS uq_documents_user_source_local; +DROP INDEX IF EXISTS idx_documents_user_source_local; + +CREATE UNIQUE INDEX uq_documents_user_source_local + ON documents(user_id, source_local_id) + WHERE source_local_id IS NOT NULL AND deleted_at IS NULL; + +CREATE INDEX idx_documents_trash_purge + ON documents(deleted_at) + WHERE deleted_at IS NOT NULL; diff --git a/api/src/main/resources/db/migration/V5__document_sharing.sql b/api/src/main/resources/db/migration/V5__document_sharing.sql new file mode 100644 index 0000000..9636caf --- /dev/null +++ b/api/src/main/resources/db/migration/V5__document_sharing.sql @@ -0,0 +1,29 @@ +ALTER TABLE documents + ADD COLUMN general_access_mode VARCHAR(32) NOT NULL DEFAULT 'RESTRICTED', + ADD COLUMN link_access_level VARCHAR(16) NOT NULL DEFAULT 'VIEW'; + +ALTER TABLE documents + ADD CONSTRAINT chk_documents_general_access_mode + CHECK (general_access_mode IN ('RESTRICTED', 'ANYONE_WITH_LINK')), + ADD CONSTRAINT chk_documents_link_access_level + CHECK (link_access_level IN ('VIEW', 'COMMENT', 'EDIT')); + +CREATE TABLE document_collaborators ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + access_level VARCHAR(16) NOT NULL, + granted_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT uq_document_collaborators_doc_user UNIQUE (document_id, user_id), + CONSTRAINT chk_document_collaborators_access_level + CHECK (access_level IN ('VIEW', 'COMMENT', 'EDIT')) +); + +CREATE INDEX idx_document_collaborators_user + ON document_collaborators(user_id, updated_at DESC); + +CREATE INDEX idx_document_collaborators_doc + ON document_collaborators(document_id); diff --git a/api/src/test/java/com/nextdocs/api/auth/security/RateLimitFilterTest.java b/api/src/test/java/com/nextdocs/api/auth/security/RateLimitFilterTest.java index 6245751..2c0c05b 100644 --- a/api/src/test/java/com/nextdocs/api/auth/security/RateLimitFilterTest.java +++ b/api/src/test/java/com/nextdocs/api/auth/security/RateLimitFilterTest.java @@ -1,6 +1,7 @@ package com.nextdocs.api.auth.security; import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -12,6 +13,8 @@ import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RestController; import tools.jackson.databind.ObjectMapper; @@ -36,7 +39,7 @@ void authPath_whenAllowed_isPassedThrough() throws Exception { mockMvc.perform(post("/api/v1/auth/login").remoteAddress("10.0.0.1")).andExpect(status().isOk()); assertThat(rateLimiter.invocationCount).isEqualTo(1); - assertThat(rateLimiter.lastKey).isEqualTo("10.0.0.1"); + assertThat(rateLimiter.lastKey).isEqualTo("auth:10.0.0.1"); } @Test @@ -55,6 +58,35 @@ void nonAuthPath_isNotRateLimited() throws Exception { assertThat(rateLimiter.invocationCount).isZero(); } + @Test + void publicDocumentPath_whenAllowed_isPassedThrough() throws Exception { + mockMvc.perform(get("/api/v1/documents/{id}/public", "11111111-1111-1111-1111-111111111111") + .remoteAddress("10.0.0.4")) + .andExpect(status().isOk()); + + assertThat(rateLimiter.invocationCount).isEqualTo(1); + assertThat(rateLimiter.lastKey).isEqualTo("public-doc:10.0.0.4"); + } + + @Test + void publicDocumentPath_whenRejected_returnsTooManyRequests() throws Exception { + rateLimiter.allowed = false; + + mockMvc.perform(get("/api/v1/documents/{id}/public", "11111111-1111-1111-1111-111111111111") + .remoteAddress("10.0.0.5")) + .andExpect(status().isTooManyRequests()) + .andExpect(jsonPath("$.success").value(false)); + } + + @Test + void publicDocumentPath_withEncodedSlashInId_isNotRateLimited() throws Exception { + mockMvc.perform(get("/api/v1/documents/11111111-1111-1111-1111-111111111111%2Fchild/public") + .remoteAddress("10.0.0.6")) + .andReturn(); + + assertThat(rateLimiter.invocationCount).isZero(); + } + @Test void xForwardedForHeader_isIgnored_whenRemoteAddressIsNotTrustedProxy() throws Exception { // No trusted proxies configured (default) — X-Forwarded-For must not be trusted @@ -63,7 +95,7 @@ void xForwardedForHeader_isIgnored_whenRemoteAddressIsNotTrustedProxy() throws E .remoteAddress("10.0.0.1")) .andExpect(status().isOk()); - assertThat(rateLimiter.lastKey).isEqualTo("10.0.0.1"); + assertThat(rateLimiter.lastKey).isEqualTo("auth:10.0.0.1"); } @Test @@ -77,7 +109,7 @@ void xForwardedForHeader_usesFirstIp_whenRemoteAddressIsTrustedProxy() throws Ex .remoteAddress("10.0.0.1")) .andExpect(status().isOk()); - assertThat(rateLimiter.lastKey).isEqualTo("203.0.113.5"); + assertThat(rateLimiter.lastKey).isEqualTo("auth:203.0.113.5"); } private static final class StubRateLimiter implements RateLimiter { @@ -104,5 +136,10 @@ ResponseEntity login() { ResponseEntity other() { return ResponseEntity.ok("ok"); } + + @GetMapping("/api/v1/documents/{id}/public") + ResponseEntity getPublic(@PathVariable String id) { + return ResponseEntity.ok("ok"); + } } } diff --git a/api/src/test/java/com/nextdocs/api/document/controller/DocumentControllerTest.java b/api/src/test/java/com/nextdocs/api/document/controller/DocumentControllerTest.java new file mode 100644 index 0000000..bf98b42 --- /dev/null +++ b/api/src/test/java/com/nextdocs/api/document/controller/DocumentControllerTest.java @@ -0,0 +1,220 @@ +package com.nextdocs.api.document.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import com.nextdocs.api.auth.entity.User; +import com.nextdocs.api.auth.repository.UserRepository; +import com.nextdocs.api.auth.security.JwtTokenProvider; +import com.nextdocs.api.auth.security.UserPrincipal; +import com.nextdocs.api.common.exception.ApiException; +import com.nextdocs.api.common.exception.ErrorCode; +import com.nextdocs.api.document.dto.response.BulkImportItemResponse; +import com.nextdocs.api.document.dto.response.BulkImportResponse; +import com.nextdocs.api.document.dto.response.DocumentResponse; +import com.nextdocs.api.document.service.DocumentService; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(DocumentController.class) +@Import({ + com.nextdocs.api.auth.security.SecurityConfig.class, + com.nextdocs.api.common.cache.CaffeineCacheStore.class, + com.nextdocs.api.auth.security.ratelimit.InMemoryRateLimiter.class +}) +class DocumentControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private DocumentService documentService; + + @MockitoBean + private JwtTokenProvider jwtTokenProvider; + + @MockitoBean + private UserRepository userRepository; + + private UserPrincipal principal; + private UUID userId; + private UUID documentId; + + @BeforeEach + void setUp() { + User user = User.builder() + .email("alice@example.com") + .displayName("Alice") + .passwordHash("$2a$12$hash") + .build(); + userId = UUID.randomUUID(); + user.setId(userId); + principal = UserPrincipal.from(user); + documentId = UUID.randomUUID(); + } + + @Test + void create_success_returns201() throws Exception { + DocumentResponse response = new DocumentResponse( + documentId, "My Doc", "AQID", "Alice", OffsetDateTime.now(), OffsetDateTime.now(), null, null); + + when(documentService.create(eq(userId), any())).thenReturn(response); + + mockMvc.perform(post("/api/v1/documents") + .with(user(principal)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "title": "My Doc", + "yjsState": "AQID" + } + """)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.id").value(documentId.toString())) + .andExpect(jsonPath("$.message").value("Document created.")); + } + + @Test + void list_success_returns200() throws Exception { + DocumentResponse response = new DocumentResponse( + documentId, "My Doc", null, "Alice", OffsetDateTime.now(), OffsetDateTime.now(), null, null); + + Page page = new PageImpl<>(List.of(response), PageRequest.of(0, 20), 1); + when(documentService.list(eq(userId), any(), eq(false))).thenReturn(page); + + mockMvc.perform(get("/api/v1/documents").with(user(principal))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.content[0].id").value(documentId.toString())); + } + + @Test + void get_notFound_returns404() throws Exception { + when(documentService.get(eq(userId), eq(documentId), eq(false))) + .thenThrow(new ApiException(ErrorCode.NOT_FOUND)); + + mockMvc.perform(get("/api/v1/documents/{id}", documentId).with(user(principal))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)); + } + + @Test + void update_success_returns200() throws Exception { + DocumentResponse response = new DocumentResponse( + documentId, "Updated", "AQID", "Alice", OffsetDateTime.now(), OffsetDateTime.now(), null, null); + + when(documentService.update(eq(userId), eq(documentId), any())).thenReturn(response); + + mockMvc.perform(patch("/api/v1/documents/{id}", documentId) + .with(user(principal)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "title": "Updated" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.title").value("Updated")); + } + + @Test + void delete_success_returns204() throws Exception { + doNothing().when(documentService).delete(userId, documentId, false); + + mockMvc.perform(delete("/api/v1/documents/{id}", documentId).with(user(principal))) + .andExpect(status().isNoContent()); + } + + @Test + void list_trashed_success_returns200() throws Exception { + OffsetDateTime deleted = OffsetDateTime.parse("2025-01-01T00:00:00Z"); + DocumentResponse response = new DocumentResponse( + documentId, + "Trashed", + null, + "Alice", + OffsetDateTime.now(), + OffsetDateTime.now(), + deleted, + deleted.plusDays(30)); + + Page page = new PageImpl<>(List.of(response), PageRequest.of(0, 20), 1); + when(documentService.list(eq(userId), any(), eq(true))).thenReturn(page); + + mockMvc.perform(get("/api/v1/documents").param("trashed", "true").with(user(principal))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.content[0].deletedAt").exists()); + } + + @Test + void restore_success_returns200() throws Exception { + DocumentResponse response = new DocumentResponse( + documentId, "Restored", null, "Alice", OffsetDateTime.now(), OffsetDateTime.now(), null, null); + + when(documentService.restore(eq(userId), eq(documentId))).thenReturn(response); + + mockMvc.perform(post("/api/v1/documents/{id}/restore", documentId).with(user(principal))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.title").value("Restored")); + } + + @Test + void bulkImport_success_returns201() throws Exception { + BulkImportResponse response = + new BulkImportResponse(List.of(new BulkImportItemResponse("local-1", documentId, "Imported"))); + when(documentService.bulkImport(eq(userId), any())).thenReturn(response); + + mockMvc.perform(post("/api/v1/documents/bulk-import") + .with(user(principal)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "docs": [ + { + "localId": "local-1", + "title": "Imported", + "yjsState": "AQID" + } + ] + } + """)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.imported[0].localId").value("local-1")); + } + + @Test + void endpoints_withoutAuthentication_return401() throws Exception { + mockMvc.perform(get("/api/v1/documents")).andExpect(status().isUnauthorized()); + mockMvc.perform(post("/api/v1/documents") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "title": "My Doc", + "yjsState": "AQID" + } + """)) + .andExpect(status().isUnauthorized()); + } +} diff --git a/api/src/test/java/com/nextdocs/api/document/controller/DocumentSharingControllerTest.java b/api/src/test/java/com/nextdocs/api/document/controller/DocumentSharingControllerTest.java new file mode 100644 index 0000000..f1ab262 --- /dev/null +++ b/api/src/test/java/com/nextdocs/api/document/controller/DocumentSharingControllerTest.java @@ -0,0 +1,267 @@ +package com.nextdocs.api.document.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.nextdocs.api.auth.entity.User; +import com.nextdocs.api.auth.repository.UserRepository; +import com.nextdocs.api.auth.security.JwtTokenProvider; +import com.nextdocs.api.auth.security.UserPrincipal; +import com.nextdocs.api.document.dto.response.CollaboratorResponse; +import com.nextdocs.api.document.dto.response.DocumentAccessResponse; +import com.nextdocs.api.document.dto.response.DocumentResponse; +import com.nextdocs.api.document.dto.response.SharingSettingsResponse; +import com.nextdocs.api.document.entity.DocumentAccessLevel; +import com.nextdocs.api.document.entity.DocumentGeneralAccessMode; +import com.nextdocs.api.document.service.DocumentSharingService; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(DocumentSharingController.class) +@Import({ + com.nextdocs.api.auth.security.SecurityConfig.class, + com.nextdocs.api.common.cache.CaffeineCacheStore.class, + com.nextdocs.api.auth.security.ratelimit.InMemoryRateLimiter.class +}) +class DocumentSharingControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private DocumentSharingService sharingService; + + @MockitoBean + private JwtTokenProvider jwtTokenProvider; + + @MockitoBean + private UserRepository userRepository; + + private UserPrincipal principal; + private UUID userId; + private UUID documentId; + private UUID collaboratorUserId; + + @BeforeEach + void setUp() { + User user = User.builder() + .email("owner@example.com") + .displayName("Owner") + .passwordHash("$2a$12$hash") + .build(); + userId = UUID.randomUUID(); + user.setId(userId); + principal = UserPrincipal.from(user); + documentId = UUID.randomUUID(); + collaboratorUserId = UUID.randomUUID(); + } + + @Test + void listCollaborators_success_returns200() throws Exception { + List response = List.of(new CollaboratorResponse( + userId, "owner@example.com", "Owner", DocumentAccessLevel.OWNER, OffsetDateTime.now())); + + when(sharingService.listCollaborators(userId, documentId)).thenReturn(response); + + mockMvc.perform(get("/api/v1/documents/{id}/collaborators", documentId).with(user(principal))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data[0].accessLevel").value("OWNER")); + } + + @Test + void upsertCollaborator_success_returns201() throws Exception { + CollaboratorResponse response = new CollaboratorResponse( + collaboratorUserId, "alice@example.com", "Alice", DocumentAccessLevel.EDIT, OffsetDateTime.now()); + + when(sharingService.upsertCollaborator(eq(userId), eq(documentId), any())) + .thenReturn(response); + + mockMvc.perform(post("/api/v1/documents/{id}/collaborators", documentId) + .with(user(principal)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "email": "alice@example.com", + "accessLevel": "EDIT" + } + """)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.email").value("alice@example.com")); + } + + @Test + void updateCollaboratorAccess_success_returns200() throws Exception { + CollaboratorResponse response = new CollaboratorResponse( + collaboratorUserId, "alice@example.com", "Alice", DocumentAccessLevel.VIEW, OffsetDateTime.now()); + + when(sharingService.updateCollaboratorAccess(eq(userId), eq(documentId), eq(collaboratorUserId), any())) + .thenReturn(response); + + mockMvc.perform(patch( + "/api/v1/documents/{id}/collaborators/{collaboratorUserId}", + documentId, + collaboratorUserId) + .with(user(principal)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "accessLevel": "VIEW" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.accessLevel").value("VIEW")); + } + + @Test + void removeCollaborator_success_returns204() throws Exception { + doNothing().when(sharingService).removeCollaborator(userId, documentId, collaboratorUserId); + + mockMvc.perform(delete( + "/api/v1/documents/{id}/collaborators/{collaboratorUserId}", + documentId, + collaboratorUserId) + .with(user(principal))) + .andExpect(status().isNoContent()); + } + + @Test + void leaveSharedDocument_success_returns204() throws Exception { + doNothing().when(sharingService).leaveSharedDocument(userId, documentId); + + mockMvc.perform(delete("/api/v1/documents/{id}/collaborators/me", documentId) + .with(user(principal))) + .andExpect(status().isNoContent()); + } + + @Test + void getSharingSettings_success_returns200() throws Exception { + SharingSettingsResponse response = + new SharingSettingsResponse(DocumentGeneralAccessMode.RESTRICTED, DocumentAccessLevel.VIEW, false); + + when(sharingService.getSharingSettings(userId, documentId)).thenReturn(response); + + mockMvc.perform(get("/api/v1/documents/{id}/sharing", documentId).with(user(principal))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.generalAccessMode").value("RESTRICTED")); + } + + @Test + void updateSharingSettings_success_returns200() throws Exception { + SharingSettingsResponse response = + new SharingSettingsResponse(DocumentGeneralAccessMode.ANYONE_WITH_LINK, DocumentAccessLevel.EDIT, true); + + when(sharingService.updateSharingSettings(eq(userId), eq(documentId), any())) + .thenReturn(response); + + mockMvc.perform(patch("/api/v1/documents/{id}/sharing", documentId) + .with(user(principal)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "generalAccessMode": "ANYONE_WITH_LINK", + "linkAccessLevel": "EDIT" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.linkAccessLevel").value("EDIT")); + } + + @Test + void updateSharingSettings_anyoneWithLink_withoutLinkAccessLevel_returns400() throws Exception { + mockMvc.perform(patch("/api/v1/documents/{id}/sharing", documentId) + .with(user(principal)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "generalAccessMode": "ANYONE_WITH_LINK" + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message") + .value(org.hamcrest.Matchers.containsString( + "linkAccessLevel is required when generalAccessMode is ANYONE_WITH_LINK."))); + + verifyNoInteractions(sharingService); + } + + @Test + void updateSharingSettings_restricted_withLinkAccessLevel_returns400() throws Exception { + mockMvc.perform(patch("/api/v1/documents/{id}/sharing", documentId) + .with(user(principal)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "generalAccessMode": "RESTRICTED", + "linkAccessLevel": "VIEW" + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message") + .value(org.hamcrest.Matchers.containsString( + "linkAccessLevel must be omitted unless generalAccessMode is ANYONE_WITH_LINK."))); + + verifyNoInteractions(sharingService); + } + + @Test + void listSharedWithMe_success_returns200() throws Exception { + DocumentResponse doc = new DocumentResponse( + documentId, "Shared Doc", null, "Owner", OffsetDateTime.now(), OffsetDateTime.now(), null, null); + + Page page = new PageImpl<>(List.of(doc), PageRequest.of(0, 20), 1); + when(sharingService.listSharedWithMe(eq(userId), any())).thenReturn(page); + + mockMvc.perform(get("/api/v1/documents/shared-with-me").with(user(principal))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.content[0].id").value(documentId.toString())); + } + + @Test + void accessCheck_success_returns200() throws Exception { + DocumentAccessResponse response = new DocumentAccessResponse(documentId, true, DocumentAccessLevel.EDIT, false); + + when(sharingService.accessCheck(userId, documentId)).thenReturn(response); + + mockMvc.perform(get("/api/v1/documents/{id}/access-check", documentId).with(user(principal))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.allowed").value(true)) + .andExpect(jsonPath("$.data.accessLevel").value("EDIT")); + } + + @Test + void endpoints_withoutAuthentication_return401() throws Exception { + mockMvc.perform(get("/api/v1/documents/{id}/collaborators", documentId)).andExpect(status().isUnauthorized()); + mockMvc.perform(get("/api/v1/documents/{id}/sharing", documentId)).andExpect(status().isUnauthorized()); + } +} diff --git a/api/src/test/java/com/nextdocs/api/document/service/DocumentServiceTest.java b/api/src/test/java/com/nextdocs/api/document/service/DocumentServiceTest.java new file mode 100644 index 0000000..6c70ebe --- /dev/null +++ b/api/src/test/java/com/nextdocs/api/document/service/DocumentServiceTest.java @@ -0,0 +1,170 @@ +package com.nextdocs.api.document.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.nextdocs.api.auth.entity.User; +import com.nextdocs.api.auth.repository.UserRepository; +import com.nextdocs.api.common.exception.ApiException; +import com.nextdocs.api.common.exception.ErrorCode; +import com.nextdocs.api.document.config.DocumentProperties; +import com.nextdocs.api.document.dto.request.DocumentUpdateRequest; +import com.nextdocs.api.document.entity.Document; +import com.nextdocs.api.document.entity.DocumentAccessLevel; +import com.nextdocs.api.document.entity.DocumentCollaborator; +import com.nextdocs.api.document.entity.DocumentGeneralAccessMode; +import com.nextdocs.api.document.repository.DocumentCollaboratorRepository; +import com.nextdocs.api.document.repository.DocumentRepository; +import java.nio.charset.StandardCharsets; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class DocumentServiceTest { + + @Mock + private DocumentRepository documentRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private DocumentCollaboratorRepository collaboratorRepository; + + private DocumentProperties documentProperties; + + private DocumentService documentService; + + @BeforeEach + void setUp() { + documentProperties = new DocumentProperties(); + documentProperties.setTrashRetentionDays(30); + documentService = + new DocumentService(documentRepository, collaboratorRepository, userRepository, documentProperties); + } + + @Test + void purgeExpiredTrash_deletesRowsOlderThanRetentionCutoff() { + OffsetDateTime asOf = OffsetDateTime.of(2025, 6, 15, 12, 0, 0, 0, ZoneOffset.UTC); + when(documentRepository.deleteExpiredTrash(any())).thenReturn(2); + + int purged = documentService.purgeExpiredTrash(asOf); + + assertEquals(2, purged); + ArgumentCaptor cutoff = ArgumentCaptor.forClass(OffsetDateTime.class); + verify(documentRepository).deleteExpiredTrash(cutoff.capture()); + assertEquals(OffsetDateTime.of(2025, 5, 16, 12, 0, 0, 0, ZoneOffset.UTC), cutoff.getValue()); + } + + @Test + void get_allowsGeneralAccessWhenActiveLinkExists() { + UUID requesterId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + Document document = createSharedDocument(documentId, DocumentAccessLevel.VIEW); + + when(documentRepository.findByIdAndDeletedAtIsNull(documentId)).thenReturn(Optional.of(document)); + when(collaboratorRepository.findByDocument_IdAndUser_Id(documentId, requesterId)) + .thenReturn(Optional.empty()); + + var response = documentService.get(requesterId, documentId, false); + + assertEquals(documentId, response.id()); + assertEquals("Shared doc", response.title()); + } + + @Test + void update_allowsEditWhenGeneralAccessIsEdit() { + UUID requesterId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + Document document = createSharedDocument(documentId, DocumentAccessLevel.EDIT); + + when(documentRepository.findByIdAndDeletedAtIsNull(documentId)).thenReturn(Optional.of(document)); + when(collaboratorRepository.findByDocument_IdAndUser_Id(documentId, requesterId)) + .thenReturn(Optional.empty()); + when(documentRepository.save(any(Document.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + var response = + documentService.update(requesterId, documentId, new DocumentUpdateRequest("Updated title", null, null)); + + assertEquals("Updated title", response.title()); + } + + @Test + void update_returnsForbiddenWhenGeneralAccessIsReadOnly() { + UUID requesterId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + Document document = createSharedDocument(documentId, DocumentAccessLevel.VIEW); + + when(documentRepository.findByIdAndDeletedAtIsNull(documentId)).thenReturn(Optional.of(document)); + when(collaboratorRepository.findByDocument_IdAndUser_Id(documentId, requesterId)) + .thenReturn(Optional.empty()); + + ApiException exception = assertThrows( + ApiException.class, + () -> documentService.update( + requesterId, documentId, new DocumentUpdateRequest("Updated title", null, null))); + + assertEquals(ErrorCode.FORBIDDEN, exception.getErrorCode()); + verify(documentRepository, never()).save(any(Document.class)); + } + + @Test + void update_prefersCollaboratorReadOnlyOverGeneralEditAccess() { + UUID requesterId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + Document document = createSharedDocument(documentId, DocumentAccessLevel.EDIT); + + DocumentCollaborator collaborator = DocumentCollaborator.builder() + .document(document) + .user(User.builder() + .id(requesterId) + .email("viewer@example.com") + .displayName("Viewer") + .build()) + .accessLevel(DocumentAccessLevel.VIEW) + .build(); + + when(documentRepository.findByIdAndDeletedAtIsNull(documentId)).thenReturn(Optional.of(document)); + when(collaboratorRepository.findByDocument_IdAndUser_Id(documentId, requesterId)) + .thenReturn(Optional.of(collaborator)); + + ApiException exception = assertThrows( + ApiException.class, + () -> documentService.update( + requesterId, documentId, new DocumentUpdateRequest("Updated title", null, null))); + + assertEquals(ErrorCode.FORBIDDEN, exception.getErrorCode()); + verify(documentRepository, never()).save(any(Document.class)); + } + + private static Document createSharedDocument(UUID documentId, DocumentAccessLevel linkAccessLevel) { + User owner = User.builder() + .id(UUID.randomUUID()) + .email("owner@example.com") + .displayName("Owner") + .build(); + + return Document.builder() + .id(documentId) + .user(owner) + .title("Shared doc") + .yjsState("seed".getBytes(StandardCharsets.UTF_8)) + .generalAccessMode(DocumentGeneralAccessMode.ANYONE_WITH_LINK) + .linkAccessLevel(linkAccessLevel) + .createdAt(OffsetDateTime.now(ZoneOffset.UTC)) + .updatedAt(OffsetDateTime.now(ZoneOffset.UTC)) + .build(); + } +} diff --git a/api/src/test/java/com/nextdocs/api/document/service/DocumentSharingServiceTest.java b/api/src/test/java/com/nextdocs/api/document/service/DocumentSharingServiceTest.java new file mode 100644 index 0000000..96e0f1f --- /dev/null +++ b/api/src/test/java/com/nextdocs/api/document/service/DocumentSharingServiceTest.java @@ -0,0 +1,173 @@ +package com.nextdocs.api.document.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.nextdocs.api.auth.entity.User; +import com.nextdocs.api.auth.repository.UserRepository; +import com.nextdocs.api.document.dto.request.CollaboratorUpsertRequest; +import com.nextdocs.api.document.dto.response.CollaboratorResponse; +import com.nextdocs.api.document.dto.response.DocumentAccessResponse; +import com.nextdocs.api.document.entity.Document; +import com.nextdocs.api.document.entity.DocumentAccessLevel; +import com.nextdocs.api.document.entity.DocumentCollaborator; +import com.nextdocs.api.document.entity.DocumentGeneralAccessMode; +import com.nextdocs.api.document.repository.DocumentCollaboratorRepository; +import com.nextdocs.api.document.repository.DocumentRepository; +import java.nio.charset.StandardCharsets; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class DocumentSharingServiceTest { + + @Mock + private DocumentRepository documentRepository; + + @Mock + private DocumentCollaboratorRepository collaboratorRepository; + + @Mock + private UserRepository userRepository; + + private DocumentSharingService sharingService; + + @BeforeEach + void setUp() { + sharingService = new DocumentSharingService(documentRepository, collaboratorRepository, userRepository); + } + + @Test + void getMyAccess_allowsAnyoneWithLinkWhenGeneralAccessEnabled() { + UUID requesterId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + Document document = createSharedDocument(documentId, DocumentAccessLevel.VIEW); + + when(documentRepository.findByIdAndDeletedAtIsNull(documentId)).thenReturn(Optional.of(document)); + when(collaboratorRepository.findByDocument_IdAndUser_Id(documentId, requesterId)) + .thenReturn(Optional.empty()); + + DocumentAccessResponse response = sharingService.getMyAccess(requesterId, documentId); + + assertTrue(response.allowed()); + assertEquals(DocumentAccessLevel.VIEW, response.accessLevel()); + assertFalse(response.owner()); + } + + @Test + void getMyAccess_prefersCollaboratorAccessOverGeneralAccess() { + UUID requesterId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + Document document = createSharedDocument(documentId, DocumentAccessLevel.EDIT); + + DocumentCollaborator collaborator = DocumentCollaborator.builder() + .document(document) + .user(User.builder() + .id(requesterId) + .email("viewer@example.com") + .displayName("Viewer") + .build()) + .accessLevel(DocumentAccessLevel.VIEW) + .build(); + + when(documentRepository.findByIdAndDeletedAtIsNull(documentId)).thenReturn(Optional.of(document)); + when(collaboratorRepository.findByDocument_IdAndUser_Id(documentId, requesterId)) + .thenReturn(Optional.of(collaborator)); + + DocumentAccessResponse response = sharingService.getMyAccess(requesterId, documentId); + + assertTrue(response.allowed()); + assertEquals(DocumentAccessLevel.VIEW, response.accessLevel()); + assertFalse(response.owner()); + } + + @Test + void upsertCollaborator_usesPersistedCreatedAtWithoutManualOverride() { + UUID ownerId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + + User owner = User.builder() + .id(ownerId) + .email("owner@example.com") + .displayName("Owner") + .build(); + + Document document = Document.builder() + .id(documentId) + .user(owner) + .title("Shared doc") + .yjsState("seed".getBytes(StandardCharsets.UTF_8)) + .generalAccessMode(DocumentGeneralAccessMode.ANYONE_WITH_LINK) + .linkAccessLevel(DocumentAccessLevel.VIEW) + .createdAt(OffsetDateTime.now(ZoneOffset.UTC)) + .updatedAt(OffsetDateTime.now(ZoneOffset.UTC)) + .build(); + + User targetUser = User.builder() + .id(UUID.randomUUID()) + .email("alice@example.com") + .displayName("Alice") + .build(); + + when(documentRepository.findByIdAndUser_IdAndDeletedAtIsNull(documentId, ownerId)) + .thenReturn(Optional.of(document)); + when(userRepository.findByEmail("alice@example.com")).thenReturn(Optional.of(targetUser)); + when(collaboratorRepository.findByDocument_IdAndUser_Id(documentId, targetUser.getId())) + .thenReturn(Optional.empty()); + OffsetDateTime persistedCreatedAt = OffsetDateTime.of(2026, 3, 1, 10, 0, 0, 0, ZoneOffset.UTC); + when(collaboratorRepository.save(any(DocumentCollaborator.class))).thenAnswer(invocation -> { + DocumentCollaborator input = invocation.getArgument(0); + return DocumentCollaborator.builder() + .id(UUID.randomUUID()) + .document(input.getDocument()) + .user(input.getUser()) + .accessLevel(input.getAccessLevel()) + .grantedBy(input.getGrantedBy()) + .createdAt(persistedCreatedAt) + .updatedAt(persistedCreatedAt) + .build(); + }); + + CollaboratorResponse response = sharingService.upsertCollaborator( + ownerId, documentId, new CollaboratorUpsertRequest("alice@example.com", DocumentAccessLevel.EDIT)); + + ArgumentCaptor collaboratorCaptor = ArgumentCaptor.forClass(DocumentCollaborator.class); + verify(collaboratorRepository).save(collaboratorCaptor.capture()); + + assertEquals(targetUser.getId(), response.userId()); + assertNull(collaboratorCaptor.getValue().getCreatedAt()); + assertEquals(persistedCreatedAt, response.addedAt()); + } + + private static Document createSharedDocument(UUID documentId, DocumentAccessLevel linkAccessLevel) { + User owner = User.builder() + .id(UUID.randomUUID()) + .email("owner@example.com") + .displayName("Owner") + .build(); + + return Document.builder() + .id(documentId) + .user(owner) + .title("Shared doc") + .yjsState("seed".getBytes(StandardCharsets.UTF_8)) + .generalAccessMode(DocumentGeneralAccessMode.ANYONE_WITH_LINK) + .linkAccessLevel(linkAccessLevel) + .createdAt(OffsetDateTime.now(ZoneOffset.UTC)) + .updatedAt(OffsetDateTime.now(ZoneOffset.UTC)) + .build(); + } +} diff --git a/api/src/test/java/com/nextdocs/api/document/validation/SharingSettingsValidatorTest.java b/api/src/test/java/com/nextdocs/api/document/validation/SharingSettingsValidatorTest.java new file mode 100644 index 0000000..f43a420 --- /dev/null +++ b/api/src/test/java/com/nextdocs/api/document/validation/SharingSettingsValidatorTest.java @@ -0,0 +1,74 @@ +package com.nextdocs.api.document.validation; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.nextdocs.api.document.dto.request.SharingSettingsUpdateRequest; +import com.nextdocs.api.document.entity.DocumentAccessLevel; +import com.nextdocs.api.document.entity.DocumentGeneralAccessMode; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class SharingSettingsValidatorTest { + + private Validator validator; + + @BeforeEach + void setUp() { + validator = Validation.buildDefaultValidatorFactory().getValidator(); + } + + @Test + void isValid_returnsTrue_forNullRequest() { + SharingSettingsValidator validatorInstance = new SharingSettingsValidator(); + assertTrue(validatorInstance.isValid(null, null)); + } + + @Test + void validate_returnsViolation_whenAnyoneWithLinkWithoutLinkAccessLevel() { + SharingSettingsUpdateRequest request = + new SharingSettingsUpdateRequest(DocumentGeneralAccessMode.ANYONE_WITH_LINK, null); + + Set> violations = validator.validate(request); + + assertFalse(violations.isEmpty()); + assertTrue(violations.stream() + .anyMatch(v -> "linkAccessLevel".equals(v.getPropertyPath().toString()))); + } + + @Test + void validate_returnsViolation_whenRestrictedWithLinkAccessLevel() { + SharingSettingsUpdateRequest request = + new SharingSettingsUpdateRequest(DocumentGeneralAccessMode.RESTRICTED, DocumentAccessLevel.VIEW); + + Set> violations = validator.validate(request); + + assertFalse(violations.isEmpty()); + assertTrue(violations.stream() + .anyMatch(v -> "linkAccessLevel".equals(v.getPropertyPath().toString()))); + } + + @Test + void validate_passes_whenAnyoneWithLinkWithLinkAccessLevel() { + SharingSettingsUpdateRequest request = + new SharingSettingsUpdateRequest(DocumentGeneralAccessMode.ANYONE_WITH_LINK, DocumentAccessLevel.EDIT); + + Set> violations = validator.validate(request); + + assertTrue(violations.isEmpty()); + } + + @Test + void validate_passes_whenRestrictedWithoutLinkAccessLevel() { + SharingSettingsUpdateRequest request = + new SharingSettingsUpdateRequest(DocumentGeneralAccessMode.RESTRICTED, null); + + Set> violations = validator.validate(request); + + assertTrue(violations.isEmpty()); + } +} diff --git a/realtime/README.md b/realtime/README.md index 18016e9..b968b65 100644 --- a/realtime/README.md +++ b/realtime/README.md @@ -23,23 +23,27 @@ Server runs on `http://localhost:1234`. Edit `.env`: -| Variable | Default | Description | -| ----------------------- | ----------------------- | ------------------------------- | -| `PORT` | `1234` | Server port | -| `HOST` | `0.0.0.0` | Server host | -| `CORS_ORIGINS` | `http://localhost:3000` | Comma-separated allowed origins | -| `LOG_LEVEL` | `info` | error/warn/info/debug | -| `ROOM_CLEANUP_INTERVAL` | `300000` | Cleanup interval (ms) | -| `ROOM_INACTIVE_TIMEOUT` | `3600000` | Inactive room timeout (ms) | +| Variable | Default | Description | +| ----------------------- | ----------------------- | ----------------------------------- | +| `PORT` | `1234` | Server port | +| `HOST` | `0.0.0.0` | Server host | +| `API_BASE_URL` | `http://localhost:8080` | API base URL for auth/access checks | +| `CORS_ORIGINS` | `http://localhost:3000` | Comma-separated allowed origins | +| `LOG_LEVEL` | `info` | error/warn/info/debug | +| `ROOM_CLEANUP_INTERVAL` | `300000` | Cleanup interval (ms) | +| `ROOM_INACTIVE_TIMEOUT` | `3600000` | Inactive room timeout (ms) | ## API ### WebSocket ``` -ws://localhost:1234/{roomId} +ws://localhost:1234/{roomId}?token= ``` +Connections are authenticated. The realtime server validates that the JWT is valid +and that the user can access `/{roomId}` by calling the API. + ### Health Check ```bash diff --git a/realtime/src/config.ts b/realtime/src/config.ts index 712e509..434b230 100644 --- a/realtime/src/config.ts +++ b/realtime/src/config.ts @@ -23,16 +23,39 @@ interface Limits { interface Config { port: number; host: string; + apiBaseUrl: string; corsOrigins: string[]; logLevel: LogLevel; roomCleanupInterval: number; roomInactiveTimeout: number; + accessRevalidationIntervalMs: number; + fetchTimeoutMs: number; + enforceMemoryThreshold: boolean; limits: Limits; } +function parseBoolean(value: string | undefined, fallback: boolean): boolean { + if (value === undefined) { + return fallback; + } + + const normalized = value.trim().toLowerCase(); + if (normalized === 'true' || normalized === '1' || normalized === 'yes') { + return true; + } + if (normalized === 'false' || normalized === '0' || normalized === 'no') { + return false; + } + + return fallback; +} + +const isProd = process.env.NODE_ENV === 'production'; + const config: Config = { port: parseInt(process.env.PORT || '1234', 10), host: process.env.HOST || '0.0.0.0', + apiBaseUrl: process.env.API_BASE_URL || 'http://localhost:8080', corsOrigins: process.env.CORS_ORIGINS ? process.env.CORS_ORIGINS.split(',') @@ -44,6 +67,9 @@ const config: Config = { roomCleanupInterval: parseInt(process.env.ROOM_CLEANUP_INTERVAL || '300000', 10), roomInactiveTimeout: parseInt(process.env.ROOM_INACTIVE_TIMEOUT || '3600000', 10), + accessRevalidationIntervalMs: parseInt(process.env.ACCESS_REVALIDATION_INTERVAL_MS || '5000', 10), + fetchTimeoutMs: parseInt(process.env.FETCH_TIMEOUT_MS || '5000', 10), + enforceMemoryThreshold: parseBoolean(process.env.ENFORCE_MEMORY_THRESHOLD, isProd), limits: { maxPayload: parseInt(process.env.MAX_PAYLOAD || '5242880', 10), @@ -51,7 +77,7 @@ const config: Config = { maxGlobalConns: parseInt(process.env.MAX_GLOBAL_CONNS || '10000', 10), maxConnRatePerMin: parseInt(process.env.MAX_CONN_RATE_PER_MIN || '100', 10), maxMsgRatePerSec: parseInt(process.env.MAX_MSG_RATE_PER_SEC || '100', 10), - memoryThreshold: parseFloat(process.env.MEMORY_THRESHOLD || '0.8'), + memoryThreshold: parseFloat(process.env.MEMORY_THRESHOLD || (isProd ? '0.8' : '0.95')), }, }; @@ -71,6 +97,18 @@ if (Number.isNaN(config.roomInactiveTimeout) || config.roomInactiveTimeout <= 0) ); } +if (Number.isNaN(config.accessRevalidationIntervalMs) || config.accessRevalidationIntervalMs <= 0) { + throw new Error( + `Invalid ACCESS_REVALIDATION_INTERVAL_MS: ${process.env.ACCESS_REVALIDATION_INTERVAL_MS}. Must be a positive number (milliseconds).` + ); +} + +if (Number.isNaN(config.fetchTimeoutMs) || config.fetchTimeoutMs <= 0) { + throw new Error( + `Invalid FETCH_TIMEOUT_MS: ${process.env.FETCH_TIMEOUT_MS}. Must be a positive number (milliseconds).` + ); +} + if (config.limits.maxPayload <= 0 || Number.isNaN(config.limits.maxPayload)) { throw new Error(`Invalid MAX_PAYLOAD: ${process.env.MAX_PAYLOAD}. Must be a positive number.`); } diff --git a/realtime/src/server.ts b/realtime/src/server.ts index e9335c1..001426e 100644 --- a/realtime/src/server.ts +++ b/realtime/src/server.ts @@ -1,6 +1,10 @@ import http from 'http'; import { WebSocketServer, WebSocket } from 'ws'; -import { setupWSConnection } from './yjs-utils'; +import { + setupWSConnection, + updateConnectionAccessLevel, + type RealtimeAccessLevel, +} from './yjs-utils'; import config from './config'; import logger from './logger'; @@ -89,6 +93,18 @@ export const wss = new WebSocketServer({ const ipConnections = new Map(); const ipConnectionTimestamps = new Map(); +interface AccessCheckData { + allowed: boolean; + accessLevel: RealtimeAccessLevel | null; + owner: boolean; +} + +interface ApiEnvelope { + success: boolean; + data: T | null; + error: string | null; +} + function getClientIp(req: http.IncomingMessage): string { const xForwardedFor = req.headers['x-forwarded-for']; if (typeof xForwardedFor === 'string') { @@ -98,194 +114,344 @@ function getClientIp(req: http.IncomingMessage): string { return req.socket?.remoteAddress || 'unknown'; } -wss.on('connection', (conn: WebSocket, req: http.IncomingMessage) => { - const clientIp = getClientIp(req); - - const currentGlobalConns = wss.clients.size; - if (currentGlobalConns > config.limits.maxGlobalConns) { - logger.warn('Connection rejected: Global connection limit reached', { - ip: clientIp, - current: currentGlobalConns, - max: config.limits.maxGlobalConns, - }); - conn.close(1008, 'Server busy'); - return; - } - - const memoryUsage = process.memoryUsage(); - const heapUsedRatio = memoryUsage.heapUsed / memoryUsage.heapTotal; - if (heapUsedRatio > config.limits.memoryThreshold) { - logger.warn('Connection rejected: Memory threshold exceeded', { - ip: clientIp, - heapUsedRatio, - threshold: config.limits.memoryThreshold, - }); - conn.close(1008, 'Server busy'); - return; - } - - const currentIpConns = ipConnections.get(clientIp) || 0; - if (currentIpConns >= config.limits.maxConnsPerIp) { - logger.warn('Connection rejected: IP connection limit reached', { - ip: clientIp, - current: currentIpConns, - max: config.limits.maxConnsPerIp, - }); - conn.close(1008, 'Too many connections'); - return; +// Prefer Authorization headers to reduce token leakage in logs/proxies. +// Query-string fallback exists because browser WebSocket upgrades cannot set custom auth headers, +// and query tokens may be logged by intermediaries. +function extractToken(req: http.IncomingMessage, url: URL): string | null { + const authHeader = req.headers.authorization; + if (typeof authHeader === 'string') { + const match = authHeader.match(/^Bearer\s+(.+)$/i); + if (match && match[1].trim().length > 0) { + return match[1].trim(); + } } - const now = Date.now(); - const timestamps = ipConnectionTimestamps.get(clientIp) || []; - - const windowStart = now - 60000; - while (timestamps.length > 0 && timestamps[0] < windowStart) { - timestamps.shift(); + const queryToken = url.searchParams.get('token'); + if (queryToken) { + return queryToken; } - if (timestamps.length >= config.limits.maxConnRatePerMin) { - logger.warn('Connection rejected: IP connection rate limit exceeded', { - ip: clientIp, - rate: timestamps.length, - max: config.limits.maxConnRatePerMin, - }); - conn.close(1008, 'Rate limit exceeded'); - return; - } + return null; +} - timestamps.push(now); - if (!ipConnectionTimestamps.has(clientIp)) { - ipConnectionTimestamps.set(clientIp, timestamps); - } +async function fetchAccess(token: string, roomId: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, config.fetchTimeoutMs); - let roomId: string; try { - const rawUrl = req.url ?? '/'; - const baseUrl = `http://${req.headers.host ?? 'localhost'}`; - const url = new URL(rawUrl, baseUrl); - roomId = url.pathname.slice(1); - } catch (err) { - logger.warn('Connection rejected: failed to parse URL', { - ip: clientIp, - url: req.url, - error: (err as Error).message, - }); - conn.close(1008, 'Invalid request URL'); - - return; - } - - if (!roomId) { - logger.warn('Connection rejected: missing room ID', { - ip: clientIp, - url: req.url, - }); - conn.close(1008, 'Room ID required'); + const res = await fetch( + `${config.apiBaseUrl}/api/v1/documents/${encodeURIComponent(roomId)}/access-check`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + }, + signal: controller.signal, + } + ); - return; - } + if (!res.ok) { + return null; + } - ipConnections.set(clientIp, currentIpConns + 1); + const body = (await res.json()) as ApiEnvelope; + if (!body.success || !body.data) { + return null; + } - logger.info('Client connected', { roomId, ip: clientIp }); + return body.data; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + logger.error('Document access check timed out', { + roomId, + error: error.message, + }); + return null; + } - if (!rooms.has(roomId)) { - rooms.set(roomId, { - lastActivity: Date.now(), - connections: 0, + logger.error('Document access check failed', { + roomId, + error: error instanceof Error ? error.message : String(error), }); - logger.info('Room created', { roomId }); + return null; + } finally { + clearTimeout(timeout); } +} - const room = rooms.get(roomId)!; - room.connections += 1; - room.lastActivity = Date.now(); +wss.on('connection', async (conn: WebSocket, req: http.IncomingMessage) => { + const clientIp = getClientIp(req); + let revalidateAccessInterval: NodeJS.Timeout | undefined; try { - setupWSConnection(conn, roomId); - } catch (error) { - logger.error('Error setting up Yjs connection', { - roomId, - error: (error as Error).message, - }); - room.connections -= 1; - if (room.connections <= 0) { - logger.info('Room empty after setup failure, marking for cleanup', { - roomId, + const currentGlobalConns = wss.clients.size; + if (currentGlobalConns > config.limits.maxGlobalConns) { + logger.warn('Connection rejected: Global connection limit reached', { + ip: clientIp, + current: currentGlobalConns, + max: config.limits.maxGlobalConns, }); - room.lastActivity = Date.now(); + conn.close(1008, 'Server busy'); + return; } - conn.close(1011, 'Internal server error'); // 1011: Internal Error - return; - } - let messageCount = 0; - let lastMessageReset = Date.now(); + const memoryUsage = process.memoryUsage(); + const heapUsedRatio = memoryUsage.heapUsed / memoryUsage.heapTotal; + if (heapUsedRatio > config.limits.memoryThreshold) { + const payload = { + ip: clientIp, + heapUsedRatio, + threshold: config.limits.memoryThreshold, + heapUsed: memoryUsage.heapUsed, + heapTotal: memoryUsage.heapTotal, + enforce: config.enforceMemoryThreshold, + }; + + if (config.enforceMemoryThreshold) { + logger.warn('Connection rejected: Memory threshold exceeded', payload); + conn.close(1008, 'Server busy'); + return; + } + + logger.warn('Memory threshold exceeded, continuing in warn-only mode', payload); + } + + const currentIpConns = ipConnections.get(clientIp) || 0; + if (currentIpConns >= config.limits.maxConnsPerIp) { + logger.warn('Connection rejected: IP connection limit reached', { + ip: clientIp, + current: currentIpConns, + max: config.limits.maxConnsPerIp, + }); + conn.close(1008, 'Too many connections'); + return; + } - conn.on('message', (data: Buffer | ArrayBuffer | Buffer[]) => { const now = Date.now(); + const timestamps = ipConnectionTimestamps.get(clientIp) || []; + + const windowStart = now - 60000; + while (timestamps.length > 0 && timestamps[0] < windowStart) { + timestamps.shift(); + } - if (now - lastMessageReset > 1000) { - messageCount = 0; - lastMessageReset = now; + // Clean up empty IP entries to prevent memory leak + if (timestamps.length === 0) { + ipConnectionTimestamps.delete(clientIp); } - messageCount++; - if (messageCount > config.limits.maxMsgRatePerSec) { - logger.warn('Client disconnected: Message rate limit exceeded', { + if (timestamps.length >= config.limits.maxConnRatePerMin) { + logger.warn('Connection rejected: IP connection rate limit exceeded', { ip: clientIp, - roomId, - rate: messageCount, + rate: timestamps.length, + max: config.limits.maxConnRatePerMin, }); - conn.close(1008, 'Message rate limit exceeded'); + conn.close(1008, 'Rate limit exceeded'); return; } - // We rely on ws configuration for the hard payload limit (maxPayload), - // but double-check here to allow for potentially finer application control in the future - // and better logging context. - let size = 0; - if (Buffer.isBuffer(data)) { - size = data.length; - } else if (data instanceof ArrayBuffer) { - size = data.byteLength; - } else if (Array.isArray(data)) { - size = data.reduce((acc, buf) => acc + buf.length, 0); + timestamps.push(now); + if (!ipConnectionTimestamps.has(clientIp)) { + ipConnectionTimestamps.set(clientIp, timestamps); } - if (size > config.limits.maxPayload) { - logger.warn('Client disconnected: Max payload size exceeded', { + let roomId: string; + let parsedUrl: URL; + try { + const rawUrl = req.url ?? '/'; + const baseUrl = `http://${req.headers.host ?? 'localhost'}`; + parsedUrl = new URL(rawUrl, baseUrl); + roomId = parsedUrl.pathname.slice(1); + } catch (err) { + logger.warn('Connection rejected: failed to parse URL', { ip: clientIp, - size, - max: config.limits.maxPayload, + url: req.url, + error: (err as Error).message, }); - conn.close(1009, 'Payload too large'); + conn.close(1008, 'Invalid request URL'); + return; } - }); - conn.on('close', () => { - logger.info('Client disconnected', { roomId, ip: clientIp }); + if (!roomId) { + logger.warn('Connection rejected: missing room ID', { + ip: clientIp, + url: req.url, + }); + conn.close(1008, 'Room ID required'); - const current = ipConnections.get(clientIp); - if (current && current > 0) { - ipConnections.set(clientIp, current - 1); + return; } - // We don't clear timestamps immediately to enforce rate limit even after disconnection - if (rooms.has(roomId)) { - room.connections -= 1; + const token = extractToken(req, parsedUrl); + if (!token) { + logger.warn('Connection rejected: missing access token', { roomId, ip: clientIp }); + conn.close(1008, 'Authentication required'); + return; + } + + const access = await fetchAccess(token, roomId); + if (!access?.allowed || !access.accessLevel) { + logger.warn('Connection rejected: unauthorized document access', { roomId, ip: clientIp }); + conn.close(1008, 'Access denied'); + return; + } + + ipConnections.set(clientIp, currentIpConns + 1); + + logger.info('Client connected', { roomId, ip: clientIp }); + + if (!rooms.has(roomId)) { + rooms.set(roomId, { + lastActivity: Date.now(), + connections: 0, + }); + logger.info('Room created', { roomId }); + } + const room = rooms.get(roomId)!; + room.connections += 1; + room.lastActivity = Date.now(); + + try { + setupWSConnection(conn, roomId, access.accessLevel); + } catch (error) { + logger.error('Error setting up Yjs connection', { + roomId, + error: (error as Error).message, + }); + room.connections -= 1; if (room.connections <= 0) { - logger.info('Room empty, marking for cleanup', { roomId }); + logger.info('Room empty after setup failure, marking for cleanup', { + roomId, + }); room.lastActivity = Date.now(); } + conn.close(1011, 'Internal server error'); // 1011: Internal Error + return; } - }); - conn.on('error', (error: Error) => { - logger.error('WebSocket error', { roomId, error: error.message }); - }); + let isRevalidating = false; + revalidateAccessInterval = setInterval(async () => { + if (conn.readyState !== WebSocket.OPEN || isRevalidating) { + return; + } + + isRevalidating = true; + try { + const latestAccess = await fetchAccess(token, roomId); + if (!latestAccess?.allowed || !latestAccess.accessLevel) { + logger.info('Connection closed after access revalidation failure', { + roomId, + ip: clientIp, + }); + conn.close(1008, 'Access revoked'); + return; + } + + updateConnectionAccessLevel(conn, roomId, latestAccess.accessLevel); + } finally { + isRevalidating = false; + } + }, config.accessRevalidationIntervalMs); + revalidateAccessInterval.unref(); + + let messageCount = 0; + let lastMessageReset = Date.now(); + + conn.on('message', (data: Buffer | ArrayBuffer | Buffer[]) => { + const now = Date.now(); + + if (now - lastMessageReset > 1000) { + messageCount = 0; + lastMessageReset = now; + } + + messageCount++; + if (messageCount > config.limits.maxMsgRatePerSec) { + logger.warn('Client disconnected: Message rate limit exceeded', { + ip: clientIp, + roomId, + rate: messageCount, + }); + conn.close(1008, 'Message rate limit exceeded'); + return; + } + + // We rely on ws configuration for the hard payload limit (maxPayload), + // but double-check here to allow for potentially finer application control in the future + // and better logging context. + let size = 0; + if (Buffer.isBuffer(data)) { + size = data.length; + } else if (data instanceof ArrayBuffer) { + size = data.byteLength; + } else if (Array.isArray(data)) { + size = data.reduce((acc, buf) => acc + buf.length, 0); + } + + if (size > config.limits.maxPayload) { + logger.warn('Client disconnected: Max payload size exceeded', { + ip: clientIp, + size, + max: config.limits.maxPayload, + }); + conn.close(1009, 'Payload too large'); + return; + } + }); + + conn.on('close', () => { + if (revalidateAccessInterval) { + clearInterval(revalidateAccessInterval); + revalidateAccessInterval = undefined; + } + + logger.info('Client disconnected', { roomId, ip: clientIp }); + + const current = ipConnections.get(clientIp); + if (current && current > 0) { + ipConnections.set(clientIp, current - 1); + } + // We don't clear timestamps immediately to enforce rate limit even after disconnection + + if (rooms.has(roomId)) { + room.connections -= 1; + + if (room.connections <= 0) { + logger.info('Room empty, marking for cleanup', { roomId }); + room.lastActivity = Date.now(); + } + } + }); + + conn.on('error', (error: Error) => { + if (revalidateAccessInterval) { + clearInterval(revalidateAccessInterval); + revalidateAccessInterval = undefined; + } + logger.error('WebSocket error', { roomId, error: error.message }); + }); + } catch (err) { + if (revalidateAccessInterval) { + clearInterval(revalidateAccessInterval); + revalidateAccessInterval = undefined; + } + + const errorMessage = err instanceof Error ? err.message : String(err); + logger.error('Unexpected error in connection handler', { + ip: clientIp, + error: errorMessage, + }); + + if (conn.readyState === WebSocket.OPEN || conn.readyState === WebSocket.CONNECTING) { + conn.close(1011, 'Internal server error'); + } else { + conn.terminate(); + } + } }); export function cleanupInactiveRooms(): void { diff --git a/realtime/src/types/blocks.ts b/realtime/src/types/blocks.ts index e147a65..3f5c58c 100644 --- a/realtime/src/types/blocks.ts +++ b/realtime/src/types/blocks.ts @@ -191,6 +191,8 @@ export interface DocumentMeta { createdAt: string; updatedAt: string; createdBy?: string; + deletedAt?: string; + purgeAt?: string; } export interface Document { diff --git a/realtime/src/yjs-utils.ts b/realtime/src/yjs-utils.ts index b22f8f3..e155ed5 100644 --- a/realtime/src/yjs-utils.ts +++ b/realtime/src/yjs-utils.ts @@ -10,12 +10,36 @@ import logger from './logger'; const MESSAGE_SYNC = 0; const MESSAGE_AWARENESS = 1; +const MESSAGE_ACCESS_LEVEL = 2; + +const SYNC_MESSAGE_STEP_2 = syncProtocol.messageYjsSyncStep2; +const SYNC_MESSAGE_UPDATE = syncProtocol.messageYjsUpdate; const docs = new Map(); +export type RealtimeAccessLevel = 'VIEW' | 'COMMENT' | 'EDIT' | 'OWNER'; + +// Levels that cannot write to document content +const DOCUMENT_WRITE_BLOCKED = new Set(['VIEW', 'COMMENT']); +// Levels that cannot send awareness (cursor presence) +const NO_AWARENESS_LEVELS = new Set(['VIEW']); + +interface ConnectionState { + clientIds: Set; + accessLevel: RealtimeAccessLevel; +} + +function canWriteDocument(level: RealtimeAccessLevel): boolean { + return !DOCUMENT_WRITE_BLOCKED.has(level); +} + +function canSendAwareness(level: RealtimeAccessLevel): boolean { + return !NO_AWARENESS_LEVELS.has(level); +} + class WSSharedDoc extends Y.Doc { name: string; - conns: Map>; + conns: Map; awareness: awarenessProtocol.Awareness; constructor(name: string) { @@ -51,14 +75,25 @@ class WSSharedDoc extends Y.Doc { { added, updated, removed }: { added: number[]; updated: number[]; removed: number[] }, origin: unknown ): void { + if (origin instanceof WebSocket) { + const originState = this.conns.get(origin); + if (originState && !canSendAwareness(originState.accessLevel)) { + logger.warn('Blocked awareness update from read-only connection', { + docName: this.name, + accessLevel: originState.accessLevel, + }); + return; + } + } + const changedClients = added.concat(updated).concat(removed); // Track which client IDs each connection controls if (origin instanceof WebSocket) { - const clientIds = this.conns.get(origin); - if (clientIds) { - added.forEach((id) => clientIds.add(id)); - removed.forEach((id) => clientIds.delete(id)); + const state = this.conns.get(origin); + if (state) { + added.forEach((id) => state.clientIds.add(id)); + removed.forEach((id) => state.clientIds.delete(id)); } } @@ -97,8 +132,70 @@ function getYDoc(docName: string): WSSharedDoc { }); } +function shouldRejectSyncMessage( + doc: WSSharedDoc, + conn: WebSocket, + syncMessageType: number +): boolean { + const state = doc.conns.get(conn); + if (!state) { + return true; + } + + // Only EDIT/OWNER can write document content + if (!canWriteDocument(state.accessLevel)) { + // COMMENT connections may only complete initial sync acknowledgement. + // Update messages require explicit server-side validation before acceptance. + if (state.accessLevel === 'COMMENT') { + if (syncMessageType === SYNC_MESSAGE_STEP_2) { + return false; + } + + if (syncMessageType === SYNC_MESSAGE_UPDATE) { + return true; + } + + return true; + } + + // VIEW users cannot send any sync messages + if (state.accessLevel === 'VIEW') { + return true; + } + } + + return false; +} + function handleMessage(conn: WebSocket, doc: WSSharedDoc, message: Uint8Array): void { try { + const inspectionDecoder = decoding.createDecoder(message); + const inspectionMessageType = decoding.readVarUint(inspectionDecoder); + + if (inspectionMessageType === MESSAGE_SYNC) { + const syncMessageType = decoding.readVarUint(inspectionDecoder); + if (shouldRejectSyncMessage(doc, conn, syncMessageType)) { + const state = doc.conns.get(conn); + logger.warn('Blocked sync write message from read-only connection', { + docName: doc.name, + accessLevel: state?.accessLevel, + syncMessageType, + }); + return; + } + } + + if (inspectionMessageType === MESSAGE_AWARENESS) { + const state = doc.conns.get(conn); + if (!state || !canSendAwareness(state.accessLevel)) { + logger.warn('Blocked awareness message from view-only connection', { + docName: doc.name, + accessLevel: state?.accessLevel, + }); + return; + } + } + const encoder = encoding.createEncoder(); const decoder = decoding.createDecoder(message); const messageType = decoding.readVarUint(decoder); @@ -132,10 +229,14 @@ function handleMessage(conn: WebSocket, doc: WSSharedDoc, message: Uint8Array): } } -export function setupWSConnection(conn: WebSocket, docName: string): void { +export function setupWSConnection( + conn: WebSocket, + docName: string, + accessLevel: RealtimeAccessLevel = 'EDIT' +): void { const doc = getYDoc(docName); - doc.conns.set(conn, new Set()); + doc.conns.set(conn, { clientIds: new Set(), accessLevel }); // Send sync step 1 (full document state) const encoder = encoding.createEncoder(); @@ -160,7 +261,7 @@ export function setupWSConnection(conn: WebSocket, docName: string): void { }); conn.on('close', () => { - const controlledIds = doc.conns.get(conn); + const controlledIds = doc.conns.get(conn)?.clientIds; doc.conns.delete(conn); if (controlledIds) { @@ -176,6 +277,38 @@ export function setupWSConnection(conn: WebSocket, docName: string): void { }); } +export function updateConnectionAccessLevel( + conn: WebSocket, + docName: string, + accessLevel: RealtimeAccessLevel +): void { + const doc = docs.get(docName); + if (!doc) { + return; + } + + const state = doc.conns.get(conn); + if (!state) { + return; + } + + const oldAccessLevel = state.accessLevel; + state.accessLevel = accessLevel; + + // Notify client of access level change so they can update permissions immediately + if (oldAccessLevel !== accessLevel && conn.readyState === WebSocket.OPEN) { + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, MESSAGE_ACCESS_LEVEL); + encoding.writeVarString(encoder, accessLevel); + conn.send(encoding.toUint8Array(encoder), { binary: true }); + logger.info('Sent access level update to client', { + docName, + oldLevel: oldAccessLevel, + newLevel: accessLevel, + }); + } +} + export function getDocsStats(): Array<{ name: string; connections: number }> { return Array.from(docs.entries()).map(([name, doc]) => ({ name, @@ -183,4 +316,4 @@ export function getDocsStats(): Array<{ name: string; connections: number }> { })); } -export { docs }; +export { docs, MESSAGE_ACCESS_LEVEL }; diff --git a/realtime/tests/unit/config.test.ts b/realtime/tests/unit/config.test.ts index 8c0a826..695087e 100644 --- a/realtime/tests/unit/config.test.ts +++ b/realtime/tests/unit/config.test.ts @@ -19,6 +19,7 @@ describe('Config', () => { delete process.env.LOG_LEVEL; delete process.env.ROOM_CLEANUP_INTERVAL; delete process.env.ROOM_INACTIVE_TIMEOUT; + delete process.env.ACCESS_REVALIDATION_INTERVAL_MS; const config = (await import('../../src/config')).default; @@ -28,6 +29,7 @@ describe('Config', () => { expect(config.logLevel).toBe('info'); expect(config.roomCleanupInterval).toBe(300000); expect(config.roomInactiveTimeout).toBe(3600000); + expect(config.accessRevalidationIntervalMs).toBe(5000); }); it('should parse environment variables correctly', async () => { @@ -37,6 +39,7 @@ describe('Config', () => { process.env.LOG_LEVEL = 'debug'; process.env.ROOM_CLEANUP_INTERVAL = '60000'; process.env.ROOM_INACTIVE_TIMEOUT = '120000'; + process.env.ACCESS_REVALIDATION_INTERVAL_MS = '15000'; const config = (await import('../../src/config')).default; @@ -46,6 +49,7 @@ describe('Config', () => { expect(config.logLevel).toBe('debug'); expect(config.roomCleanupInterval).toBe(60000); expect(config.roomInactiveTimeout).toBe(120000); + expect(config.accessRevalidationIntervalMs).toBe(15000); }); it('should trim CORS origins', async () => { @@ -89,6 +93,13 @@ describe('Config', () => { await expect(import('../../src/config')).rejects.toThrow('Invalid ROOM_INACTIVE_TIMEOUT'); }); + it('should throw error for invalid ACCESS_REVALIDATION_INTERVAL_MS', async () => { + process.env.ACCESS_REVALIDATION_INTERVAL_MS = '0'; + await expect(import('../../src/config')).rejects.toThrow( + 'Invalid ACCESS_REVALIDATION_INTERVAL_MS' + ); + }); + it('should throw error for invalid MAX_PAYLOAD', async () => { process.env.MAX_PAYLOAD = '-1'; await expect(import('../../src/config')).rejects.toThrow('Invalid MAX_PAYLOAD'); diff --git a/realtime/tests/unit/server.test.ts b/realtime/tests/unit/server.test.ts index 96ed594..b1221a3 100644 --- a/realtime/tests/unit/server.test.ts +++ b/realtime/tests/unit/server.test.ts @@ -37,10 +37,12 @@ jest.mock('../../src/config', () => ({ default: { port: 1234, host: '0.0.0.0', + apiBaseUrl: 'http://localhost:8080', corsOrigins: ['*'], logLevel: 'info', roomCleanupInterval: 300000, roomInactiveTimeout: 3600000, + accessRevalidationIntervalMs: 5000, limits: { maxPayload: 5 * 1024 * 1024, maxConnsPerIp: 200, @@ -54,15 +56,36 @@ jest.mock('../../src/config', () => ({ import { WebSocket } from 'ws'; +const waitForConnectionProcessing = async () => { + await Promise.resolve(); + await Promise.resolve(); +}; + describe('Server', () => { let server: any; let wss: any; let setupWSConnectionMock: any; let memoryUsageSpy: any; + let fetchMock: jest.MockedFunction; beforeEach(async () => { jest.resetModules(); + fetchMock = jest.fn() as jest.MockedFunction; + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + success: true, + data: { + allowed: true, + accessLevel: 'EDIT', + owner: false, + }, + error: null, + }), + } as Response); + (globalThis as typeof globalThis & { fetch: typeof fetch }).fetch = fetchMock as typeof fetch; + memoryUsageSpy = jest.spyOn(process, 'memoryUsage').mockReturnValue({ rss: 100, heapTotal: 100, @@ -113,7 +136,7 @@ describe('Server', () => { beforeEach(() => { mockReq = { - url: '/room1', + url: '/room1?token=test-token', headers: { host: 'localhost:1234' }, socket: { remoteAddress: '127.0.0.1' }, }; @@ -123,15 +146,38 @@ describe('Server', () => { wss.clients.size = 0; }); - it('should accept connection with valid room ID', () => { + it('should accept connection with valid room ID', async () => { wss.emit('connection', mockConn, mockReq); - expect(setupWSConnectionMock).toHaveBeenCalledWith(mockConn, 'room1'); + await waitForConnectionProcessing(); + expect(setupWSConnectionMock).toHaveBeenCalledWith(mockConn, 'room1', 'EDIT'); expect(mockConn.close).not.toHaveBeenCalled(); }); - it('should reject connection with missing room ID', () => { + it('should reject connection when access-check denies access', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + success: true, + data: { + allowed: false, + accessLevel: null, + owner: false, + }, + error: null, + }), + } as Response); + + wss.emit('connection', mockConn, mockReq); + await waitForConnectionProcessing(); + + expect(mockConn.close).toHaveBeenCalledWith(1008, 'Access denied'); + expect(setupWSConnectionMock).not.toHaveBeenCalled(); + }); + + it('should reject connection with missing room ID', async () => { mockReq.url = '/'; wss.emit('connection', mockConn, mockReq); + await waitForConnectionProcessing(); expect(mockConn.close).toHaveBeenCalledWith( 1008, expect.stringContaining('Room ID required') @@ -145,8 +191,9 @@ describe('Server', () => { }); wss.emit('connection', mockConn, mockReq); + await waitForConnectionProcessing(); - expect(setupWSConnectionMock).toHaveBeenCalledWith(mockConn, 'room1'); + expect(setupWSConnectionMock).toHaveBeenCalledWith(mockConn, 'room1', 'EDIT'); // valid connection rejected due to internal error expect(mockConn.close).toHaveBeenCalledWith(1011, 'Internal server error'); @@ -170,7 +217,7 @@ describe('Server', () => { beforeEach(() => { mockReq = { - url: '/room1', + url: '/room1?token=test-token', headers: { host: 'localhost:1234' }, socket: { remoteAddress: '127.0.0.1' }, }; @@ -181,13 +228,14 @@ describe('Server', () => { wss.clients.size = 0; }); - it('should reject when global connection limit is reached', () => { + it('should reject when global connection limit is reached', async () => { wss.clients.size = 10001; wss.emit('connection', mockConn, mockReq); + await waitForConnectionProcessing(); expect(mockConn.close).toHaveBeenCalledWith(1008, 'Server busy'); }); - it('should reject when IP connection limit is reached', () => { + it('should reject when IP connection limit is reached', async () => { jest.useFakeTimers(); try { const ip = '10.0.0.1'; @@ -199,6 +247,7 @@ describe('Server', () => { const conn: any = new EventEmitter(); conn.close = jest.fn(); wss.emit('connection', conn, mockReq); + await waitForConnectionProcessing(); } // Advance time by 1 minute to clear rate limit window @@ -209,20 +258,23 @@ describe('Server', () => { const conn: any = new EventEmitter(); conn.close = jest.fn(); wss.emit('connection', conn, mockReq); + await waitForConnectionProcessing(); } // 201st connection (should hit IP limit, not rate limit) const rejectedConn: any = new EventEmitter(); rejectedConn.close = jest.fn(); wss.emit('connection', rejectedConn, mockReq); + await waitForConnectionProcessing(); + expect(rejectedConn.close).toHaveBeenCalledTimes(1); expect(rejectedConn.close).toHaveBeenCalledWith(1008, 'Too many connections'); } finally { jest.useRealTimers(); } }); - it('should reject when IP connection rate limit is exceeded', () => { + it('should reject when IP connection rate limit is exceeded', async () => { const ip = '10.0.0.2'; mockReq.socket.remoteAddress = ip; @@ -239,14 +291,16 @@ describe('Server', () => { const rejectedConn: any = new EventEmitter(); rejectedConn.close = jest.fn(); wss.emit('connection', rejectedConn, mockReq); + await waitForConnectionProcessing(); expect(rejectedConn.close).toHaveBeenCalledWith(1008, 'Rate limit exceeded'); }); - it('should enforce message rate limits', () => { + it('should enforce message rate limits', async () => { const ip = '10.0.0.3'; mockReq.socket.remoteAddress = ip; wss.emit('connection', mockConn, mockReq); + await waitForConnectionProcessing(); // Since wss is a mock event emitter, wss.emit call runs synchronously. // The 'connection' handler in server.ts calls conn.on('message', ...). @@ -263,10 +317,11 @@ describe('Server', () => { expect(mockConn.close).toHaveBeenCalledWith(1008, 'Message rate limit exceeded'); }); - it('should enforce payload size limits', () => { + it('should enforce payload size limits', async () => { const ip = '10.0.0.4'; mockReq.socket.remoteAddress = ip; wss.emit('connection', mockConn, mockReq); + await waitForConnectionProcessing(); const largeBuffer = Buffer.alloc(5 * 1024 * 1024 + 1); mockConn.emit('message', largeBuffer, false); @@ -280,7 +335,7 @@ describe('Server', () => { beforeEach(() => { mockReq = { - url: '/room1', + url: '/room1?token=test-token', headers: { host: 'localhost:1234' }, socket: { remoteAddress: '127.0.0.1' }, }; @@ -290,7 +345,7 @@ describe('Server', () => { wss.clients.size = 0; }); - it('should use X-Forwarded-For header if present', () => { + it('should use X-Forwarded-For header if present', async () => { // Test that rate limits are applied per-IP extracted from header const ip1 = '10.0.0.5'; @@ -308,6 +363,7 @@ describe('Server', () => { const rejectedConn: any = new EventEmitter(); rejectedConn.close = jest.fn(); wss.emit('connection', rejectedConn, mockReq); + await waitForConnectionProcessing(); expect(rejectedConn.close).toHaveBeenCalledWith(1008, 'Rate limit exceeded'); // Connection from ip2 should succeed (different IP) @@ -315,10 +371,11 @@ describe('Server', () => { allowedConn.close = jest.fn(); mockReq.headers['x-forwarded-for'] = ip2; wss.emit('connection', allowedConn, mockReq); + await waitForConnectionProcessing(); expect(allowedConn.close).not.toHaveBeenCalled(); }); - it('should use first IP in comma-separated X-Forwarded-For header', () => { + it('should use first IP in comma-separated X-Forwarded-For header', async () => { const realIp1 = '10.0.0.7'; const realIp2 = '10.0.0.8'; const proxyIp = '192.168.1.1'; @@ -335,6 +392,7 @@ describe('Server', () => { const rejectedConn: any = new EventEmitter(); rejectedConn.close = jest.fn(); wss.emit('connection', rejectedConn, mockReq); + await waitForConnectionProcessing(); expect(rejectedConn.close).toHaveBeenCalledWith(1008, 'Rate limit exceeded'); // Connection from realIp2 should succeed even with same proxy IP suffix @@ -342,10 +400,11 @@ describe('Server', () => { allowedConn.close = jest.fn(); mockReq.headers['x-forwarded-for'] = `${realIp2}, ${proxyIp}`; wss.emit('connection', allowedConn, mockReq); + await waitForConnectionProcessing(); expect(allowedConn.close).not.toHaveBeenCalled(); }); - it('should fallback to socket remoteAddress if header is missing', () => { + it('should fallback to socket remoteAddress if header is missing', async () => { delete mockReq.headers['x-forwarded-for']; const ip1 = '10.0.0.9'; const ip2 = '10.0.0.10'; @@ -362,6 +421,7 @@ describe('Server', () => { const rejectedConn: any = new EventEmitter(); rejectedConn.close = jest.fn(); wss.emit('connection', rejectedConn, mockReq); + await waitForConnectionProcessing(); expect(rejectedConn.close).toHaveBeenCalledWith(1008, 'Rate limit exceeded'); // Connection from ip2 should succeed @@ -369,6 +429,7 @@ describe('Server', () => { allowedConn.close = jest.fn(); mockReq.socket.remoteAddress = ip2; wss.emit('connection', allowedConn, mockReq); + await waitForConnectionProcessing(); expect(allowedConn.close).not.toHaveBeenCalled(); }); }); diff --git a/realtime/tests/unit/yjs-utils.test.ts b/realtime/tests/unit/yjs-utils.test.ts index 37b7912..166c427 100644 --- a/realtime/tests/unit/yjs-utils.test.ts +++ b/realtime/tests/unit/yjs-utils.test.ts @@ -2,7 +2,9 @@ import { jest } from '@jest/globals'; import * as syncing from 'y-protocols/sync'; import * as encoding from 'lib0/encoding'; import * as decoding from 'lib0/decoding'; +import * as Y from 'yjs'; import { WebSocket } from 'ws'; +import logger from '../../src/logger'; // Mock logger to avoid console output during tests jest.mock('../../src/logger', () => ({ @@ -15,7 +17,12 @@ jest.mock('../../src/logger', () => ({ }, })); -import { setupWSConnection, docs, getDocsStats } from '../../src/yjs-utils'; +import { + setupWSConnection, + updateConnectionAccessLevel, + docs, + getDocsStats, +} from '../../src/yjs-utils'; describe('Yjs Utils', () => { let mockConn: any; @@ -145,6 +152,178 @@ describe('Yjs Utils', () => { expect(docs.has(docName)).toBe(true); expect(docs.get(docName)?.conns.size).toBe(1); }); + + it('should block sync update writes for read-only connections', () => { + const readOnlyConn: any = { + send: jest.fn(), + on: jest.fn(), + close: jest.fn(), + readyState: WebSocket.OPEN, + }; + + const writableConn: any = { + send: jest.fn(), + on: jest.fn(), + close: jest.fn(), + readyState: WebSocket.OPEN, + }; + + setupWSConnection(readOnlyConn, docName, 'VIEW'); + setupWSConnection(writableConn, docName, 'EDIT'); + + readOnlyConn.send.mockClear(); + writableConn.send.mockClear(); + + const readOnlyMessageHandlerCall = readOnlyConn.on.mock.calls.find( + (call: any) => call[0] === 'message' + ); + if (!readOnlyMessageHandlerCall) { + throw new Error('message handler not found for read-only connection'); + } + + const readOnlyMessageHandler = readOnlyMessageHandlerCall[1]; + const sourceDoc = new Y.Doc(); + sourceDoc.getText('t').insert(0, 'hello'); + const update = Y.encodeStateAsUpdate(sourceDoc); + + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, 0); // MESSAGE_SYNC + syncing.writeUpdate(encoder, update); + const message = encoding.toUint8Array(encoder); + + readOnlyMessageHandler(Buffer.from(message)); + + expect(writableConn.send).not.toHaveBeenCalled(); + }); + + it('should block sync update writes for comment connections', () => { + const commentConn: any = { + send: jest.fn(), + on: jest.fn(), + close: jest.fn(), + readyState: WebSocket.OPEN, + }; + + const writableConn: any = { + send: jest.fn(), + on: jest.fn(), + close: jest.fn(), + readyState: WebSocket.OPEN, + }; + + setupWSConnection(commentConn, docName, 'COMMENT'); + setupWSConnection(writableConn, docName, 'EDIT'); + + commentConn.send.mockClear(); + writableConn.send.mockClear(); + + const commentMessageHandlerCall = commentConn.on.mock.calls.find( + (call: any) => call[0] === 'message' + ); + if (!commentMessageHandlerCall) { + throw new Error('message handler not found for comment connection'); + } + + const commentMessageHandler = commentMessageHandlerCall[1]; + const sourceDoc = new Y.Doc(); + sourceDoc.getText('t').insert(0, 'hello'); + const update = Y.encodeStateAsUpdate(sourceDoc); + + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, 0); // MESSAGE_SYNC + syncing.writeUpdate(encoder, update); + const message = encoding.toUint8Array(encoder); + + commentMessageHandler(Buffer.from(message)); + + expect(writableConn.send).not.toHaveBeenCalled(); + + const blockedWarnings = (logger.warn as jest.Mock).mock.calls.filter( + ([warningMessage]) => + warningMessage === 'Blocked sync write message from read-only connection' + ); + expect(blockedWarnings.length).toBeGreaterThan(0); + }); + + it('should allow sync step 2 for comment connections', () => { + const commentConn: any = { + send: jest.fn(), + on: jest.fn(), + close: jest.fn(), + readyState: WebSocket.OPEN, + }; + + setupWSConnection(commentConn, docName, 'COMMENT'); + commentConn.send.mockClear(); + + const commentMessageHandlerCall = commentConn.on.mock.calls.find( + (call: any) => call[0] === 'message' + ); + if (!commentMessageHandlerCall) { + throw new Error('message handler not found for comment connection'); + } + + const commentMessageHandler = commentMessageHandlerCall[1]; + const doc = docs.get(docName); + if (!doc) { + throw new Error(`Document ${docName} not found`); + } + + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, 0); // MESSAGE_SYNC + syncing.writeSyncStep2(encoder, doc); + const message = encoding.toUint8Array(encoder); + + commentMessageHandler(Buffer.from(message)); + + const blockedWarnings = (logger.warn as jest.Mock).mock.calls.filter( + ([warningMessage]) => + warningMessage === 'Blocked sync write message from read-only connection' + ); + expect(blockedWarnings).toHaveLength(0); + }); + + it('should allow write updates after permission upgrade', () => { + const upgradedConn: any = { + send: jest.fn(), + on: jest.fn(), + close: jest.fn(), + readyState: WebSocket.OPEN, + }; + + setupWSConnection(upgradedConn, docName, 'VIEW'); + setupWSConnection(mockConn, docName, 'EDIT'); + updateConnectionAccessLevel(upgradedConn, docName, 'EDIT'); + + const messageHandlerCall = upgradedConn.on.mock.calls.find( + (call: any) => call[0] === 'message' + ); + if (!messageHandlerCall) { + throw new Error('message handler not found'); + } + const messageHandler = messageHandlerCall[1]; + + mockConn.send.mockClear(); + upgradedConn.send.mockClear(); + + const doc = docs.get(docName); + if (!doc) { + throw new Error(`Document ${docName} not found`); + } + + const sourceDoc = new Y.Doc(); + sourceDoc.getText('t').insert(0, 'hello'); + const update = Y.encodeStateAsUpdate(sourceDoc, Y.encodeStateVector(doc)); + + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, 0); // MESSAGE_SYNC + syncing.writeUpdate(encoder, update); + const message = encoding.toUint8Array(encoder); + + messageHandler(Buffer.from(message)); + + expect(mockConn.send).toHaveBeenCalled(); + }); }); describe('getDocsStats', () => {