From cde346002a671fb6e80f449dda65a2da600066a6 Mon Sep 17 00:00:00 2001 From: santhoshh-kumar Date: Sat, 21 Mar 2026 09:15:00 +0530 Subject: [PATCH 01/10] api/document: Add document schema, entities, and repositories. Establishes the core persistence model for documents and collaborators. Adds migrations for base documents, source-local-id idempotency, trash metadata, and sharing tables. Introduces entity and repository layers with indexes optimized for listing, lookup, and purge flows. --- .../api/document/entity/Document.java | 69 +++++++++++++++++++ .../document/entity/DocumentAccessLevel.java | 20 ++++++ .../document/entity/DocumentCollaborator.java | 57 +++++++++++++++ .../entity/DocumentGeneralAccessMode.java | 6 ++ .../DocumentCollaboratorRepository.java | 20 ++++++ .../repository/DocumentRepository.java | 44 ++++++++++++ .../migration/V2__create_documents_table.sql | 12 ++++ .../V3__add_source_local_id_for_documents.sql | 9 +++ .../db/migration/V4__document_trash.sql | 13 ++++ .../db/migration/V5__document_sharing.sql | 29 ++++++++ 10 files changed, 279 insertions(+) create mode 100644 api/src/main/java/com/nextdocs/api/document/entity/Document.java create mode 100644 api/src/main/java/com/nextdocs/api/document/entity/DocumentAccessLevel.java create mode 100644 api/src/main/java/com/nextdocs/api/document/entity/DocumentCollaborator.java create mode 100644 api/src/main/java/com/nextdocs/api/document/entity/DocumentGeneralAccessMode.java create mode 100644 api/src/main/java/com/nextdocs/api/document/repository/DocumentCollaboratorRepository.java create mode 100644 api/src/main/java/com/nextdocs/api/document/repository/DocumentRepository.java create mode 100644 api/src/main/resources/db/migration/V2__create_documents_table.sql create mode 100644 api/src/main/resources/db/migration/V3__add_source_local_id_for_documents.sql create mode 100644 api/src/main/resources/db/migration/V4__document_trash.sql create mode 100644 api/src/main/resources/db/migration/V5__document_sharing.sql 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/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); From 9c664bb29133474bfb163631aa18730d21cf415d Mon Sep 17 00:00:00 2001 From: santhoshh-kumar Date: Sat, 21 Mar 2026 16:40:00 +0530 Subject: [PATCH 02/10] api/document: Add DTO contracts, pagination response, and sharing validation. Introduces request/response DTOs for document CRUD, bulk import, collaborator management, and sharing settings. Adds a generic paged response wrapper for consistent list endpoint payloads and cross-field validation for general-access/link-access combinations. --- .../api/common/response/PagedResponse.java | 19 +++++ .../dto/request/BulkImportItemRequest.java | 22 ++++++ .../dto/request/BulkImportRequest.java | 12 +++ .../CollaboratorAccessUpdateRequest.java | 10 +++ .../request/CollaboratorUpsertRequest.java | 19 +++++ .../dto/request/DocumentCreateRequest.java | 25 +++++++ .../dto/request/DocumentUpdateRequest.java | 15 ++++ .../request/SharingSettingsUpdateRequest.java | 17 +++++ .../dto/response/BulkImportItemResponse.java | 10 +++ .../dto/response/BulkImportResponse.java | 8 ++ .../dto/response/CollaboratorResponse.java | 16 ++++ .../dto/response/DocumentAccessResponse.java | 14 ++++ .../dto/response/DocumentResponse.java | 23 ++++++ .../dto/response/SharingSettingsResponse.java | 15 ++++ .../validation/SharingSettingsValidator.java | 45 +++++++++++ .../validation/ValidSharingSettings.java | 22 ++++++ .../SharingSettingsValidatorTest.java | 74 +++++++++++++++++++ 17 files changed, 366 insertions(+) create mode 100644 api/src/main/java/com/nextdocs/api/common/response/PagedResponse.java create mode 100644 api/src/main/java/com/nextdocs/api/document/dto/request/BulkImportItemRequest.java create mode 100644 api/src/main/java/com/nextdocs/api/document/dto/request/BulkImportRequest.java create mode 100644 api/src/main/java/com/nextdocs/api/document/dto/request/CollaboratorAccessUpdateRequest.java create mode 100644 api/src/main/java/com/nextdocs/api/document/dto/request/CollaboratorUpsertRequest.java create mode 100644 api/src/main/java/com/nextdocs/api/document/dto/request/DocumentCreateRequest.java create mode 100644 api/src/main/java/com/nextdocs/api/document/dto/request/DocumentUpdateRequest.java create mode 100644 api/src/main/java/com/nextdocs/api/document/dto/request/SharingSettingsUpdateRequest.java create mode 100644 api/src/main/java/com/nextdocs/api/document/dto/response/BulkImportItemResponse.java create mode 100644 api/src/main/java/com/nextdocs/api/document/dto/response/BulkImportResponse.java create mode 100644 api/src/main/java/com/nextdocs/api/document/dto/response/CollaboratorResponse.java create mode 100644 api/src/main/java/com/nextdocs/api/document/dto/response/DocumentAccessResponse.java create mode 100644 api/src/main/java/com/nextdocs/api/document/dto/response/DocumentResponse.java create mode 100644 api/src/main/java/com/nextdocs/api/document/dto/response/SharingSettingsResponse.java create mode 100644 api/src/main/java/com/nextdocs/api/document/validation/SharingSettingsValidator.java create mode 100644 api/src/main/java/com/nextdocs/api/document/validation/ValidSharingSettings.java create mode 100644 api/src/test/java/com/nextdocs/api/document/validation/SharingSettingsValidatorTest.java 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/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/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/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()); + } +} From de402bbcef2a21fb0dfb534ba0973eeff1fecc29 Mon Sep 17 00:00:00 2001 From: santhoshh-kumar Date: Sun, 22 Mar 2026 10:05:00 +0530 Subject: [PATCH 03/10] api/document: Wire document properties and scheduling configuration. Registers document properties and scheduled execution support in application bootstrap. Adds configurable trash retention and purge cron settings with environment overrides for runtime flexibility. --- .../nextdocs/api/NextdocsApiApplication.java | 5 +++++ .../api/document/config/DocumentProperties.java | 17 +++++++++++++++++ api/src/main/resources/application.properties | 4 ++++ 3 files changed, 26 insertions(+) create mode 100644 api/src/main/java/com/nextdocs/api/document/config/DocumentProperties.java 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/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/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. From 9eb2850daa7c680375a56f34e6897b8ed4187887 Mon Sep 17 00:00:00 2001 From: santhoshh-kumar Date: Sun, 22 Mar 2026 18:20:00 +0530 Subject: [PATCH 04/10] api/document: Implement DocumentService with trash purge and bulk import. Implements document create/list/get/update/delete, restore, and permanent-delete flows. Adds bulk import with source-local-id upsert semantics and retention-based trash purge handling, with service-level tests. --- .../api/common/exception/ErrorCode.java | 2 + .../schedule/DocumentTrashPurgeScheduler.java | 25 ++ .../api/document/service/DocumentService.java | 386 ++++++++++++++++++ .../document/service/DocumentServiceTest.java | 170 ++++++++ 4 files changed, 583 insertions(+) create mode 100644 api/src/main/java/com/nextdocs/api/document/schedule/DocumentTrashPurgeScheduler.java create mode 100644 api/src/main/java/com/nextdocs/api/document/service/DocumentService.java create mode 100644 api/src/test/java/com/nextdocs/api/document/service/DocumentServiceTest.java 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/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/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(); + } +} From e0f06080d1a25cdf52f1b9b897ca725f4b2a94f4 Mon Sep 17 00:00:00 2001 From: santhoshh-kumar Date: Mon, 23 Mar 2026 09:50:00 +0530 Subject: [PATCH 05/10] api/document: Add DocumentController endpoints for CRUD, trash, and bulk import. Adds controller endpoints for document CRUD, public fetch, trash listing, restore, and bulk import workflows. Uses consistent API envelopes and paged responses, and includes controller tests for auth and primary success paths. --- .../controller/DocumentController.java | 220 ++++++++++++++++++ .../controller/DocumentControllerTest.java | 220 ++++++++++++++++++ 2 files changed, 440 insertions(+) create mode 100644 api/src/main/java/com/nextdocs/api/document/controller/DocumentController.java create mode 100644 api/src/test/java/com/nextdocs/api/document/controller/DocumentControllerTest.java 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/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()); + } +} From 693d0436e7e15192d7430a361835c896b97c4486 Mon Sep 17 00:00:00 2001 From: santhoshh-kumar Date: Mon, 23 Mar 2026 17:35:00 +0530 Subject: [PATCH 06/10] api/document: Implement DocumentSharingService collaborator and access logic. Implements collaborator add/update/remove and owner-controlled sharing operations. Computes effective access across owner, collaborator, and general-link permissions, with service tests for precedence and constraints. --- .../service/DocumentSharingService.java | 282 ++++++++++++++++++ .../service/DocumentSharingServiceTest.java | 173 +++++++++++ 2 files changed, 455 insertions(+) create mode 100644 api/src/main/java/com/nextdocs/api/document/service/DocumentSharingService.java create mode 100644 api/src/test/java/com/nextdocs/api/document/service/DocumentSharingServiceTest.java 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/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(); + } +} From 0689ac474ae5965cd6d9b427f46523c5e5b29db8 Mon Sep 17 00:00:00 2001 From: santhoshh-kumar Date: Tue, 24 Mar 2026 10:25:00 +0530 Subject: [PATCH 07/10] api/document: Add DocumentSharingController collaboration endpoints. Adds sharing endpoints for collaborators, sharing settings, shared-with-me listing, and access checks. Wires validation and consistent API envelopes, and adds controller tests for success and auth enforcement. --- .../controller/DocumentSharingController.java | 256 +++++++++++++++++ .../DocumentSharingControllerTest.java | 267 ++++++++++++++++++ 2 files changed, 523 insertions(+) create mode 100644 api/src/main/java/com/nextdocs/api/document/controller/DocumentSharingController.java create mode 100644 api/src/test/java/com/nextdocs/api/document/controller/DocumentSharingControllerTest.java 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/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()); + } +} From 91f7d32cd68a4af92a12344ecf7ca55b3c3d8696 Mon Sep 17 00:00:00 2001 From: santhoshh-kumar Date: Tue, 24 Mar 2026 19:10:00 +0530 Subject: [PATCH 08/10] api/security: Permit and rate-limit public document read endpoints. Updates security rules to allow controlled public document reads on the public route. Extends rate limiting to include public reads with scoped keys and adds filter tests for acceptance, rejection, and path edge cases. --- .../api/auth/security/RateLimitFilter.java | 52 +++++++++++++++++-- .../api/auth/security/SecurityConfig.java | 1 + .../auth/security/RateLimitFilterTest.java | 43 +++++++++++++-- 3 files changed, 88 insertions(+), 8 deletions(-) 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/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"); + } } } From 508fa2fc84df1be99e4224fc61fdac4d88eeb511 Mon Sep 17 00:00:00 2001 From: santhoshh-kumar Date: Wed, 25 Mar 2026 11:00:00 +0530 Subject: [PATCH 09/10] realtime/auth: Enforce token-based access checks on websocket connect. Adds websocket authentication by validating room access against the API before connection setup. Introduces periodic access revalidation, fetch timeout handling, and config/test updates for authenticated connection scenarios. --- realtime/README.md | 22 +- realtime/src/config.ts | 40 ++- realtime/src/server.ts | 454 ++++++++++++++++++++--------- realtime/tests/unit/config.test.ts | 11 + realtime/tests/unit/server.test.ts | 91 +++++- 5 files changed, 449 insertions(+), 169 deletions(-) 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/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(); }); }); From a6897c083f680935b3bb7bbada01dea1865aba81 Mon Sep 17 00:00:00 2001 From: santhoshh-kumar Date: Wed, 25 Mar 2026 18:45:00 +0530 Subject: [PATCH 10/10] realtime/yjs: Enforce access-level sync rules and realtime metadata fields. Adds per-connection access-level state and blocks disallowed sync/awareness messages for restricted users. Adds access update signaling and tests for blocked writes, allowed sync behavior, and permission upgrades. --- realtime/src/types/blocks.ts | 2 + realtime/src/yjs-utils.ts | 151 +++++++++++++++++++-- realtime/tests/unit/yjs-utils.test.ts | 181 +++++++++++++++++++++++++- 3 files changed, 324 insertions(+), 10 deletions(-) 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/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', () => {