Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -19,20 +20,25 @@
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
@RequiredArgsConstructor
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;
Expand Down Expand Up @@ -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);
Expand All @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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/**",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.");

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.nextdocs.api.common.response;

import java.util.List;
import org.springframework.data.domain.Page;

public record PagedResponse<T>(
List<T> content, long totalElements, int totalPages, int size, int number, boolean first, boolean last) {

public static <T> PagedResponse<T> from(Page<T> page) {
return new PagedResponse<>(
page.getContent(),
page.getTotalElements(),
page.getTotalPages(),
page.getSize(),
page.getNumber(),
page.isFirst(),
page.isLast());
}
}
Original file line number Diff line number Diff line change
@@ -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 * * *";
}
Original file line number Diff line number Diff line change
@@ -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<ApiResponse<DocumentResponse>> 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<ApiResponse<PagedResponse<DocumentResponse>>> list(
@AuthenticationPrincipal UserPrincipal principal,
@RequestParam(required = false) Boolean trashed,
@PageableDefault(size = 20) Pageable pageable) {
boolean trashedOnly = Boolean.TRUE.equals(trashed);
Page<DocumentResponse> 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<ApiResponse<DocumentResponse>> 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<ApiResponse<DocumentResponse>> 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<ApiResponse<DocumentResponse>> 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<Void> 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<ApiResponse<DocumentResponse>> 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<ApiResponse<BulkImportResponse>> 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."));
}
}
Loading
Loading