diff --git a/.editorconfig b/.editorconfig index bd9e211ca..e3661aeae 100644 --- a/.editorconfig +++ b/.editorconfig @@ -190,7 +190,7 @@ ij_java_message_dd_suffix = EJB ij_java_message_eb_prefix = ij_java_message_eb_suffix = Bean ij_java_method_annotation_wrap = split_into_lines -ij_java_method_brace_style = end_of_line +ij_java_method_brace_style = next_line_if_wrapped ij_java_method_call_chain_wrap = split_into_lines ij_java_method_parameters_new_line_after_left_paren = false ij_java_method_parameters_right_paren_on_new_line = false @@ -200,7 +200,7 @@ ij_java_multi_catch_types_wrap = normal ij_java_names_count_to_use_import_on_demand = 999 ij_java_new_line_after_lparen_in_annotation = false ij_java_new_line_after_lparen_in_deconstruction_pattern = true -ij_java_new_line_after_lparen_in_record_header = false +ij_java_new_line_after_lparen_in_record_header = true ij_java_packages_to_use_import_on_demand = ij_java_parameter_annotation_wrap = on_every_item ij_java_parameter_name_prefix = diff --git a/build.gradle b/build.gradle index 3162c2bf1..5e5614179 100644 --- a/build.gradle +++ b/build.gradle @@ -33,7 +33,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-amqp' - testImplementation 'org.springframework.amqp:spring-rabbit-test' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + testImplementation 'org.springframework.amqp:spring-rabbit-test' annotationProcessor "org.hibernate:hibernate-jpamodelgen:6.5.2.Final" implementation 'io.hypersistence:hypersistence-utils-hibernate-63:3.8.1' diff --git a/src/main/java/com/sublinks/sublinksapi/SublinksApiApplication.java b/src/main/java/com/sublinks/sublinksapi/SublinksApiApplication.java index ff83b1fc2..e99948599 100644 --- a/src/main/java/com/sublinks/sublinksapi/SublinksApiApplication.java +++ b/src/main/java/com/sublinks/sublinksapi/SublinksApiApplication.java @@ -13,6 +13,8 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.web.servlet.config.annotation.PathMatchConfigurer; +import org.springframework.web.util.UrlPathHelper; /** * Application Boot. @@ -34,13 +36,17 @@ public static void main(String[] args) { @Bean public OpenAPI customOpenAPI() { - return new OpenAPI().components(new Components().addSecuritySchemes("bearerAuth", - new SecurityScheme().type(SecurityScheme.Type.HTTP).scheme("bearer").bearerFormat("JWT"))); + return new OpenAPI() + .components(new Components() + .addSecuritySchemes("bearerAuth", + new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT"))); } @Bean - public GroupedOpenApi v3OpenApi(@Value("${springdoc.version}") String appVersion, - @Value("${springdoc.pathsToMatch}") String pathsToMatch, + public GroupedOpenApi v3LemmyApi(@Value("${springdoc.version}") String appVersion, @Value("#{${springdoc.servers}}") List servers) { return GroupedOpenApi.builder() @@ -52,7 +58,30 @@ public GroupedOpenApi v3OpenApi(@Value("${springdoc.version}") String appVersion .addOpenApiCustomizer(openApi -> openApi.info( new Info().title("Lemmy OpenAPI Documentation").version(appVersion)) .servers(servers.stream().map(s -> new Server().url(s)).toList())) - .pathsToMatch(pathsToMatch) + .pathsToMatch("/api/v3/**") .build(); } + + @Bean + public GroupedOpenApi v1SublinksApi(@Value("${springdoc.version}") String appVersion, + @Value("#{${springdoc.servers}}") List servers) { + + return GroupedOpenApi.builder().group("v1") + .addOperationCustomizer((operation, handlerMethod) -> { + operation.addSecurityItem(new SecurityRequirement().addList("bearerAuth")); + return operation; + }) + .addOpenApiCustomizer(openApi -> openApi.info(new Info() + .title("OpenAPI Documentation") + .version(appVersion)) + .servers(servers.stream().map(s -> new Server().url(s)).toList())) + .pathsToMatch("/api/v1/**") + .build(); + } + + public void configurePathMatch(PathMatchConfigurer configurer) { + UrlPathHelper urlPathHelper = new UrlPathHelper(); + urlPathHelper.setUrlDecode(false); + configurer.setUrlPathHelper(urlPathHelper); + } } diff --git a/src/main/java/com/sublinks/sublinksapi/announcement/entities/Announcement.java b/src/main/java/com/sublinks/sublinksapi/announcement/entities/Announcement.java index 238b30d3a..019fdb797 100644 --- a/src/main/java/com/sublinks/sublinksapi/announcement/entities/Announcement.java +++ b/src/main/java/com/sublinks/sublinksapi/announcement/entities/Announcement.java @@ -1,10 +1,13 @@ package com.sublinks.sublinksapi.announcement.entities; +import com.sublinks.sublinksapi.person.entities.Person; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import java.util.Objects; import lombok.AllArgsConstructor; @@ -29,6 +32,13 @@ @Table(name = "announcements") public class Announcement { + /** + * Relationships. + */ + @ManyToOne + @JoinColumn(name = "creator_id") + private Person creator; + /** * Attributes. */ @@ -39,6 +49,9 @@ public class Announcement { @Column(name = "content") private String content; + @Column(name = "is_active") + private Boolean active; + @Column(name = "local_site_id") private Long localSiteId; diff --git a/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/admin/controllers/AdminController.java b/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/admin/controllers/AdminController.java index e87f73ca9..6a6a62f96 100644 --- a/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/admin/controllers/AdminController.java +++ b/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/admin/controllers/AdminController.java @@ -50,7 +50,9 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; + import java.util.List; + import lombok.AllArgsConstructor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -144,13 +146,13 @@ AddAdminResponse create(@Valid @RequestBody final AddAdmin addAdminForm, JwtPers @GetMapping("registration_application/count") GetUnreadRegistrationApplicationCountResponse registrationApplicationCount( @Valid GetUnreadRegistrationApplicationCount getUnreadRegistrationApplicationCountForm, - JwtPerson principal) { + JwtPerson principal) + { final Person person = getPersonOrThrowUnauthorized(principal); aclService.canPerson(person) .performTheAction(RolePermissionInstanceTypes.INSTANCE_REMOVE_ADMIN) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "unauthorized")); return GetUnreadRegistrationApplicationCountResponse.builder() @@ -168,7 +170,8 @@ GetUnreadRegistrationApplicationCountResponse registrationApplicationCount( @GetMapping("registration_application/list") ListRegistrationApplicationsResponse registrationApplicationList( @Valid final ListRegistrationApplications listRegistrationApplicationsForm, - JwtPerson principal) { + JwtPerson principal) + { final Person person = getPersonOrThrowUnauthorized(principal); @@ -194,7 +197,8 @@ ListRegistrationApplicationsResponse registrationApplicationList( @PutMapping("registration_application/approve") RegistrationApplicationResponse registrationApplicationApprove( @Valid final ApproveRegistrationApplication approveRegistrationApplicationForm, - final JwtPerson principal) { + final JwtPerson principal) + { final Person person = getPersonOrThrowUnauthorized(principal); @@ -231,7 +235,8 @@ RegistrationApplicationResponse registrationApplicationApprove( schema = @Schema(implementation = PurgeItemResponse.class))})}) @PostMapping("purge/person") PurgeItemResponse purgePerson(@Valid @RequestBody final PurgePerson purgePersonForm, - final JwtPerson principal) { + final JwtPerson principal) + { final Person person = getPersonOrThrowUnauthorized(principal); @@ -242,8 +247,6 @@ PurgeItemResponse purgePerson(@Valid @RequestBody final PurgePerson purgePersonF final Person personToPurge = personRepository.findById((long) purgePersonForm.person_id()) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "person_not_found")); - final int removedPostHistory = postHistoryService.deleteAllByCreator(personToPurge); - final int removedCommentHistory = commentHistoryService.deleteAllByCreator(personToPurge); // @todo: Log purged history amount? // @todo: Implement purging @@ -257,7 +260,8 @@ PurgeItemResponse purgePerson(@Valid @RequestBody final PurgePerson purgePersonF schema = @Schema(implementation = PurgeItemResponse.class))})}) @PostMapping("purge/community") PurgeItemResponse purgeCommunity(@Valid @RequestBody final PurgeCommunity purgeCommunityForm, - final JwtPerson principal) { + final JwtPerson principal) + { final Person person = getPersonOrThrowUnauthorized(principal); @@ -275,7 +279,8 @@ PurgeItemResponse purgeCommunity(@Valid @RequestBody final PurgeCommunity purgeC schema = @Schema(implementation = PurgeItemResponse.class))})}) @PostMapping("purge/post") PurgeItemResponse purgePost(@Valid @RequestBody final PurgePost purgePostForm, - final JwtPerson principal) { + final JwtPerson principal) + { final Person person = getPersonOrThrowUnauthorized(principal); @@ -302,7 +307,8 @@ PurgeItemResponse purgePost(@Valid @RequestBody final PurgePost purgePostForm, schema = @Schema(implementation = PurgeItemResponse.class))})}) @PostMapping("purge/comment") PurgeItemResponse purgeComment(@Valid @RequestBody final PurgeComment purgeCommentForm, - final JwtPerson principal) { + final JwtPerson principal) + { final Person person = getPersonOrThrowUnauthorized(principal); diff --git a/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/admin/models/AddAdmin.java b/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/admin/models/AddAdmin.java index 2bc78bf85..99d3f9bde 100644 --- a/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/admin/models/AddAdmin.java +++ b/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/admin/models/AddAdmin.java @@ -9,7 +9,6 @@ @SuppressWarnings("RecordComponentName") public record AddAdmin( Integer person_id, - Boolean added -) { + Boolean added) { } \ No newline at end of file diff --git a/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/authentication/JwtFilter.java b/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/authentication/JwtFilter.java index bf0966064..8e9358f28 100644 --- a/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/authentication/JwtFilter.java +++ b/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/authentication/JwtFilter.java @@ -39,13 +39,15 @@ public class JwtFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(final HttpServletRequest request, @NonNull final HttpServletResponse response, @NonNull final FilterChain filterChain) - throws ServletException, IOException { + throws ServletException, IOException + { String authorizingToken = request.getHeader("Authorization"); if (authorizingToken == null && request.getCookies() != null) { for (Cookie cookie : request.getCookies()) { - if (cookie.getName().equals("jwt")) { + if (cookie.getName() + .equals("jwt")) { authorizingToken = cookie.getValue(); break; } @@ -69,21 +71,24 @@ protected void doFilterInternal(final HttpServletRequest request, response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "invalid_token"); } - if (userName != null && SecurityContextHolder.getContext().getAuthentication() == null) { + if (userName != null && SecurityContextHolder.getContext() + .getAuthentication() == null) { final Optional person = personRepository.findOneByNameIgnoreCase(userName); if (person.isEmpty()) { throw new UsernameNotFoundException("Invalid name"); } if (jwtUtil.validateToken(token, person.get())) { - // Add a check if token and ip was changed? To give like a "warning" to the user that he has a new ip logged into his account userDataService.checkAndAddIpRelation(person.get(), request.getRemoteAddr(), token, request.getHeader("User-Agent")); - final JwtPerson authenticationToken = new JwtPerson(person.get(), - person.get().getAuthorities()); + final JwtPerson authenticationToken = new JwtPerson(person.get(), person.get() + .getAuthorities()); authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); - SecurityContextHolder.getContext().setAuthentication(authenticationToken); + SecurityContextHolder.getContext() + .setAuthentication(authenticationToken); + } else { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "invalid_token"); } } filterChain.doFilter(request, response); diff --git a/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/authentication/config/SecurityConfig.java b/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/authentication/config/SecurityConfig.java index 94f604e61..d2546195b 100644 --- a/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/authentication/config/SecurityConfig.java +++ b/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/authentication/config/SecurityConfig.java @@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; @@ -19,9 +20,10 @@ @Configuration @EnableWebSecurity @RequiredArgsConstructor +@Order(2) public class SecurityConfig { - private final JwtFilter jwtFilter; + private final JwtFilter lemmyJwtFilter; /** * Returns a configured SecurityFilterChain object for the application's security. @@ -33,13 +35,14 @@ public class SecurityConfig { @Bean public SecurityFilterChain filterChain(final HttpSecurity http) throws Exception { - http - .csrf(AbstractHttpConfigurer::disable) - .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) + http.csrf(AbstractHttpConfigurer::disable) + .securityMatcher("/api/v3/**") .authorizeHttpRequests((requests) -> requests.anyRequest() .permitAll()) .sessionManagement((sessionManagement) -> sessionManagement.sessionCreationPolicy( - SessionCreationPolicy.STATELESS)); + SessionCreationPolicy.STATELESS)) + .addFilterBefore(lemmyJwtFilter, UsernamePasswordAuthenticationFilter.class); + return http.build(); } } \ No newline at end of file diff --git a/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/common/controllers/AbstractLemmyApiController.java b/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/common/controllers/AbstractLemmyApiController.java index 5385d300e..2d0469916 100644 --- a/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/common/controllers/AbstractLemmyApiController.java +++ b/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/common/controllers/AbstractLemmyApiController.java @@ -34,7 +34,7 @@ public Person getPersonOrThrowBadRequest(JwtPerson principal) throws ResponseSta * @throws ResponseStatusException Exception thrown when Person not present */ public Person getPersonOrThrow(JwtPerson principal, - Supplier exceptionSupplier) throws X { + Supplier exceptionSupplier) throws X { return Optional.ofNullable(principal).map(p -> (Person) p.getPrincipal()) .orElseThrow(exceptionSupplier); diff --git a/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/community/controllers/CommunityModActionsController.java b/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/community/controllers/CommunityModActionsController.java index 1f9a25a2e..b4bc6f0a0 100644 --- a/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/community/controllers/CommunityModActionsController.java +++ b/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/community/controllers/CommunityModActionsController.java @@ -18,7 +18,6 @@ import com.sublinks.sublinksapi.api.lemmy.v3.modlog.services.ModerationLogService; import com.sublinks.sublinksapi.api.lemmy.v3.user.services.LemmyPersonService; import com.sublinks.sublinksapi.authorization.enums.RolePermissionCommunityTypes; -import com.sublinks.sublinksapi.authorization.enums.RolePermissionPersonTypes; import com.sublinks.sublinksapi.authorization.services.RolePermissionService; import com.sublinks.sublinksapi.comment.services.CommentReportService; import com.sublinks.sublinksapi.comment.services.CommentService; @@ -85,7 +84,8 @@ public class CommunityModActionsController extends AbstractLemmyApiController { schema = @Schema(implementation = CommunityResponse.class))})}) @PutMapping("hide") CommunityResponse hide(@Valid @RequestBody final HideCommunity hideCommunityForm, - JwtPerson principal) { + JwtPerson principal) + { final Person person = getPersonOrThrowUnauthorized(principal); @@ -111,6 +111,7 @@ CommunityResponse hide(@Valid @RequestBody final HideCommunity hideCommunityForm moderationLogService.createModerationLog(moderationLog); return CommunityResponse.builder() + .community_view(lemmyCommunityService.communityViewFromCommunity(community)) .build(); } @@ -151,18 +152,20 @@ CommunityResponse delete(@Valid final DeleteCommunity deleteCommunityForm, JwtPe moderationLogService.createModerationLog(moderationLog); return CommunityResponse.builder() + .community_view(lemmyCommunityService.communityViewFromCommunity(community)) .build(); } - @Operation(summary = "A moderator remove for a community.") + @Operation(summary = "A moderator pin for a community.") @ApiResponses(value = {@ApiResponse(responseCode = "200", description = "OK", content = {@Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = CommunityResponse.class))})}) @PostMapping("remove") CommunityResponse remove(@Valid @RequestBody final RemoveCommunity removeCommunityForm, - JwtPerson principal) { + JwtPerson principal) + { final Person person = getPersonOrThrowUnauthorized(principal); @@ -175,12 +178,9 @@ CommunityResponse remove(@Valid @RequestBody final RemoveCommunity removeCommuni .orElseThrow( () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "community_not_found")); - final boolean isAllowed = linkPersonCommunityService.hasLink(community, person, - LinkPersonCommunityType.moderator) || linkPersonCommunityService.hasLink(community, person, - LinkPersonCommunityType.owner); - - if (!isAllowed) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "not_allowed"); + if (!linkPersonCommunityService.hasAnyLink(person, community, + List.of(LinkPersonCommunityType.moderator, LinkPersonCommunityType.owner))) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized"); } community.setRemoved(removeCommunityForm.removed()); @@ -199,6 +199,7 @@ CommunityResponse remove(@Valid @RequestBody final RemoveCommunity removeCommuni moderationLogService.createModerationLog(moderationLog); return CommunityResponse.builder() + .community_view(lemmyCommunityService.communityViewFromCommunity(community)) .build(); } @@ -210,7 +211,8 @@ CommunityResponse remove(@Valid @RequestBody final RemoveCommunity removeCommuni schema = @Schema(implementation = GetCommunityResponse.class))})}) @PostMapping("transfer") GetCommunityResponse transfer(@Valid @RequestBody final TransferCommunity transferCommunityForm, - JwtPerson principal) { + JwtPerson principal) + { final Person person = getPersonOrThrowUnauthorized(principal); @@ -222,12 +224,9 @@ GetCommunityResponse transfer(@Valid @RequestBody final TransferCommunity transf (long) transferCommunityForm.community_id()) .orElseThrow( () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "community_not_found")); - - final boolean isAllowed = RolePermissionService.isAdmin(person) - || linkPersonCommunityService.hasLink(community, person, LinkPersonCommunityType.owner); - - if (!isAllowed) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "not_allowed"); + if (!linkPersonCommunityService.hasLinkOrAdmin(person, community, + LinkPersonCommunityType.owner)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized"); } final Person newOwner = personRepository.findById((long) transferCommunityForm.person_id()) @@ -242,6 +241,7 @@ GetCommunityResponse transfer(@Valid @RequestBody final TransferCommunity transf List.of(LinkPersonCommunityType.owner)) .stream() .findFirst() + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "owner_not_found")) .getPerson(); linkPersonCommunityService.createLinkPersonCommunityLink(community, oldOwner, @@ -266,6 +266,7 @@ GetCommunityResponse transfer(@Valid @RequestBody final TransferCommunity transf moderationLogService.createModerationLog(moderationLog); return GetCommunityResponse.builder() + .community_view(lemmyCommunityService.communityViewFromCommunity(community)) .build(); } @@ -277,20 +278,21 @@ GetCommunityResponse transfer(@Valid @RequestBody final TransferCommunity transf schema = @Schema(implementation = BanFromCommunityResponse.class))})}) @PostMapping("ban_user") BanFromCommunityResponse banUser(@Valid @RequestBody final BanFromCommunity banPersonForm, - JwtPerson principal) { + JwtPerson principal) + { final Person person = getPersonOrThrowUnauthorized(principal); - rolePermissionService.isPermitted(person, RolePermissionPersonTypes.MODERATOR_BAN_USER, + rolePermissionService.isPermitted(person, RolePermissionCommunityTypes.MODERATOR_BAN_USER, () -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "unauthorized")); final Community community = communityRepository.findById((long) banPersonForm.community_id()) .orElseThrow( () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "community_not_found")); - if (!linkPersonCommunityService.hasAnyLink(community, person, + if (!linkPersonCommunityService.hasAnyLinkOrAdmin(person, community, List.of(LinkPersonCommunityType.moderator, LinkPersonCommunityType.owner))) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "not_allowed"); + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized"); } final Person personToBan = personRepository.findById((long) banPersonForm.person_id()) @@ -335,6 +337,7 @@ BanFromCommunityResponse banUser(@Valid @RequestBody final BanFromCommunity banP return BanFromCommunityResponse.builder() .banned(banPersonForm.ban()) + .person_view(lemmyPersonService.getPersonView(personToBan)) .build(); } @@ -346,7 +349,8 @@ BanFromCommunityResponse banUser(@Valid @RequestBody final BanFromCommunity banP schema = @Schema(implementation = AddModToCommunityResponse.class))})}) @PostMapping("mod") AddModToCommunityResponse addMod(@Valid @RequestBody AddModToCommunity addModToCommunityForm, - JwtPerson principal) { + JwtPerson principal) + { final Person person = getPersonOrThrowUnauthorized(principal); @@ -365,7 +369,7 @@ AddModToCommunityResponse addMod(@Valid @RequestBody AddModToCommunity addModToC LinkPersonCommunityType.owner); if (!isAllowed) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "not_allowed"); + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized"); } final Person personToAdd = personRepository.findById((long) addModToCommunityForm.person_id()) @@ -389,6 +393,7 @@ AddModToCommunityResponse addMod(@Valid @RequestBody AddModToCommunity addModToC .toList(); List moderatorsView = moderators.stream() + .map(moderator -> CommunityModeratorView.builder() .moderator(conversionService.convert(moderator, com.sublinks.sublinksapi.api.lemmy.v3.user.models.Person.class)) diff --git a/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/community/controllers/CommunityOwnerController.java b/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/community/controllers/CommunityOwnerController.java index 26866fd88..0eafa4a97 100644 --- a/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/community/controllers/CommunityOwnerController.java +++ b/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/community/controllers/CommunityOwnerController.java @@ -79,23 +79,21 @@ public CommunityResponse create(@Valid @RequestBody final CreateCommunity create final List languages = new ArrayList<>(); if (createCommunityForm.discussion_languages() != null) { for (String languageCode : createCommunityForm.discussion_languages()) { - final Optional language = localInstanceContext.languageRepository() - .findById(Long.valueOf(languageCode)); + final Optional language = localInstanceContext.languageRepository().findById( + Long.valueOf(languageCode)); language.ifPresent(languages::add); } } - Community.CommunityBuilder communityBuilder = Community.builder() - .instance(localInstanceContext.instance()) - .title(createCommunityForm.title()) - .titleSlug(slugUtil.stringToSlug(createCommunityForm.name())) - .description(createCommunityForm.description()) + Community.CommunityBuilder communityBuilder = Community.builder().instance( + localInstanceContext.instance()).title(createCommunityForm.title()).titleSlug( + slugUtil.stringToSlug(createCommunityForm.name())).description( + createCommunityForm.description()) .isPostingRestrictedToMods(createCommunityForm.posting_restricted_to_mods() != null - && createCommunityForm.posting_restricted_to_mods()) - .isNsfw(createCommunityForm.nsfw() != null && createCommunityForm.nsfw()) - .iconImageUrl(createCommunityForm.icon()) - .bannerImageUrl(createCommunityForm.banner()) - .languages(languages); + && createCommunityForm.posting_restricted_to_mods()).isNsfw( + createCommunityForm.nsfw() != null && createCommunityForm.nsfw()).iconImageUrl( + createCommunityForm.icon()).bannerImageUrl(createCommunityForm.banner()).languages( + languages); try { communityBuilder.title(slurFilterService.censorText(createCommunityForm.title())); @@ -202,8 +200,8 @@ CommunityResponse update(@Valid final @RequestBody EditCommunity editCommunityFo final List languages = new ArrayList<>(); for (String languageCode : editCommunityForm.discussion_languages()) { - final Optional language = localInstanceContext.languageRepository() - .findById(Long.valueOf(languageCode)); + final Optional language = localInstanceContext.languageRepository().findById( + Long.valueOf(languageCode)); language.ifPresent(languages::add); } diff --git a/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/enums/mappers/LemmyListingTypeMapper.java b/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/enums/mappers/ListingTypeMapper.java similarity index 89% rename from src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/enums/mappers/LemmyListingTypeMapper.java rename to src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/enums/mappers/ListingTypeMapper.java index a95c8ecd8..877a85d79 100644 --- a/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/enums/mappers/LemmyListingTypeMapper.java +++ b/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/enums/mappers/ListingTypeMapper.java @@ -5,7 +5,7 @@ import org.mapstruct.MappingConstants; @Mapper(componentModel = MappingConstants.ComponentModel.SPRING) -public interface LemmyListingTypeMapper { +public interface ListingTypeMapper { ListingType map(com.sublinks.sublinksapi.api.lemmy.v3.enums.ListingType listingType); } diff --git a/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/modlog/controllers/ModerationLogController.java b/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/modlog/controllers/ModerationLogController.java index 8bf231df4..8ee2190d6 100644 --- a/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/modlog/controllers/ModerationLogController.java +++ b/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/modlog/controllers/ModerationLogController.java @@ -54,11 +54,8 @@ public class ModerationLogController extends AbstractLemmyApiController { private final RolePermissionService rolePermissionService; @Operation(summary = "Get the modlog.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "OK", - content = {@Content(mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = @Schema(implementation = GetModlogResponse.class))}) - }) + @ApiResponses(value = {@ApiResponse(responseCode = "200", description = "OK", content = { + @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = GetModlogResponse.class))})}) @GetMapping GetModlogResponse index(@Valid final GetModLog getModLogForm, final JwtPerson principal) { @@ -68,7 +65,7 @@ GetModlogResponse index(@Valid final GetModLog getModLogForm, final JwtPerson pr RolePermissionModLogTypes.READ_MODLOG, () -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "unauthorized")); - // Lemmy limit is 20 per ModLog table and there are 15 tables + // Lemmy perPage is 20 per ModLog table and there are 15 tables final int limit = 300; final List removed_posts = new ArrayList<>(); @@ -88,12 +85,8 @@ GetModlogResponse index(@Valid final GetModLog getModLogForm, final JwtPerson pr final List hidden_communities = new ArrayList<>(); final Page moderationLogs = moderationLogService.searchModerationLogs( - getModLogForm.type_(), - getModLogForm.community_id(), - getModLogForm.mod_person_id(), - getModLogForm.other_person_id(), - getModLogForm.page(), - limit, + getModLogForm.type_(), getModLogForm.community_id(), getModLogForm.mod_person_id(), + getModLogForm.other_person_id(), getModLogForm.page(), limit, Sort.by("createdAt").descending()); for (ModerationLog moderationLog : moderationLogs.getContent()) { diff --git a/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/modlog/services/ModerationLogService.java b/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/modlog/services/ModerationLogService.java index 0522cd9d5..ce65808c6 100644 --- a/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/modlog/services/ModerationLogService.java +++ b/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/modlog/services/ModerationLogService.java @@ -73,7 +73,7 @@ public class ModerationLogService { * @param moderationPersonId Moderation Person Id * @param otherPersonId Other Person Id * @param page the page number - * @param pageSize the size limit of a page + * @param pageSize the size perPage of a page * @param sort the sort option * @return a Page of moderation logs */ diff --git a/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/post/controllers/PostController.java b/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/post/controllers/PostController.java index 3ceb55047..f0387a9f0 100644 --- a/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/post/controllers/PostController.java +++ b/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/post/controllers/PostController.java @@ -370,7 +370,8 @@ public GetPostsResponse index(@Valid final GetPosts getPostsForm, final JwtPerso schema = @Schema(implementation = ApiError.class))})}) @PostMapping("like") public PostResponse like(@Valid @RequestBody CreatePostLike createPostLikeForm, - JwtPerson principal) { + JwtPerson principal) + { final Person person = getPersonOrThrowUnauthorized(principal); @@ -405,7 +406,9 @@ public PostResponse like(@Valid @RequestBody CreatePostLike createPostLikeForm, content = {@Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiError.class))})}) @GetMapping("like/list") - ListPostLikesResponse listLikes(@Valid ListPostLikes listPostLikesForm, JwtPerson principal) { + public ListPostLikesResponse listLikes(@Valid ListPostLikes listPostLikesForm, + JwtPerson principal) + { final Person person = getPersonOrThrowUnauthorized(principal); @@ -439,9 +442,9 @@ ListPostLikesResponse listLikes(@Valid ListPostLikes listPostLikesForm, JwtPerso content = {@Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiError.class))})}) @PutMapping("save") - public PostResponse saveForLater(@Valid @RequestBody SavePost savePostForm, JwtPerson jwtPerson) { + public PostResponse saveForLater(@Valid @RequestBody SavePost savePostForm, JwtPerson principal) { - final Person person = getPersonOrThrowUnauthorized(jwtPerson); + final Person person = getPersonOrThrowUnauthorized(principal); rolePermissionService.isPermitted(person, RolePermissionPostTypes.FAVORITE_POST, () -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "unauthorized")); @@ -471,7 +474,8 @@ public PostResponse saveForLater(@Valid @RequestBody SavePost savePostForm, JwtP schema = @Schema(implementation = ResponseStatusException.class))})}) @PostMapping("report") public PostReportResponse report(@Valid @RequestBody final CreatePostReport createPostReportForm, - final JwtPerson principal) { + final JwtPerson principal) + { final Person person = getPersonOrThrowUnauthorized(principal); diff --git a/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/post/controllers/PostModActionsController.java b/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/post/controllers/PostModActionsController.java index 4cd278c7c..4114c3866 100644 --- a/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/post/controllers/PostModActionsController.java +++ b/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/post/controllers/PostModActionsController.java @@ -75,7 +75,7 @@ public class PostModActionsController extends AbstractLemmyApiController { private final PostService postService; private final ModerationLogService moderationLogService; - @Operation(summary = "A moderator remove for a post.") + @Operation(summary = "A moderator pin for a post.") @ApiResponses(value = {@ApiResponse(responseCode = "200", description = "OK", content = {@Content(mediaType = MediaType.APPLICATION_JSON_VALUE, @@ -106,7 +106,7 @@ PostResponse remove(@Valid @RequestBody final ModRemovePost modRemovePostForm, post.getCommunity(), person, LinkPersonCommunityType.owner); if (!moderatesCommunity) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "not_a_moderator"); + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized"); } } @@ -164,7 +164,7 @@ PostResponse lock(@Valid @RequestBody final ModLockPost modLockPostForm, JwtPers post.getCommunity(), person, LinkPersonCommunityType.owner); if (!moderatesCommunity) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "not_a_moderator"); + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized"); } } @@ -214,7 +214,7 @@ PostResponse feature(@Valid @RequestBody FeaturePost featurePostForm, JwtPerson post.getCommunity(), person, LinkPersonCommunityType.owner); if (!moderatesCommunity) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "not_a_moderator"); + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized"); } } switch (featurePostForm.feature_type()) { @@ -223,7 +223,7 @@ PostResponse feature(@Valid @RequestBody FeaturePost featurePostForm, JwtPerson break; case Local: if (!isAdmin) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "not_a_admin"); + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized"); } post.setFeatured(featurePostForm.featured()); break; diff --git a/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/post/mappers/PostMapper.java b/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/post/mappers/PostMapper.java index d837f9f72..7c7ca9c63 100644 --- a/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/post/mappers/PostMapper.java +++ b/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/post/mappers/PostMapper.java @@ -5,6 +5,7 @@ import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.MappingConstants; +import org.mapstruct.Named; import org.springframework.core.convert.converter.Converter; import org.springframework.lang.Nullable; @@ -31,4 +32,5 @@ public interface PostMapper extends Converter privateMessages = privateMessageRepository.allPrivateMessagesBySearchCriteria( @@ -124,7 +125,8 @@ PrivateMessagesResponse list(@Valid final GetPrivateMessages getPrivateMessagesF @PostMapping PrivateMessageResponse create( @Valid @RequestBody final CreatePrivateMessage createPrivateMessageForm, - final JwtPerson principal) { + final JwtPerson principal) + { final Person sender = getPersonOrThrowUnauthorized(principal); @@ -164,7 +166,8 @@ PrivateMessageResponse create( schema = @Schema(implementation = ApiError.class))})}) @PutMapping PrivateMessageResponse update(@Valid @RequestBody final EditPrivateMessage editPrivateMessageForm, - final JwtPerson principal) { + final JwtPerson principal) + { final Person person = getPersonOrThrowUnauthorized(principal); @@ -200,7 +203,8 @@ PrivateMessageResponse update(@Valid @RequestBody final EditPrivateMessage editP @PostMapping("delete") PrivateMessageResponse delete( @Valid @RequestBody final DeletePrivateMessage deletePrivateMessageForm, - final JwtPerson principal) { + final JwtPerson principal) + { final Person person = getPersonOrThrowUnauthorized(principal); @@ -234,7 +238,8 @@ PrivateMessageResponse delete( @PostMapping("mark_as_read") PrivateMessageResponse markAsRead( @Valid @RequestBody MarkPrivateMessageAsRead markPrivateMessageAsReadForm, - final JwtPerson principal) { + final JwtPerson principal) + { final Person person = getPersonOrThrowUnauthorized(principal); @@ -266,7 +271,8 @@ PrivateMessageResponse markAsRead( @PostMapping("report") PrivateMessageReportResponse report( @Valid @RequestBody final CreatePrivateMessageReport privateMessageReportForm, - final JwtPerson principal) { + final JwtPerson principal) + { final Person person = getPersonOrThrowUnauthorized(principal); @@ -303,7 +309,8 @@ PrivateMessageReportResponse report( @PutMapping("report/resolve") PrivateMessageReportResponse reportResolve( @Valid @RequestBody ResolvePrivateMessageReport privateMessageReportForm, - final JwtPerson principal) { + final JwtPerson principal) + { final Person person = getPersonOrThrowUnauthorized(principal); @@ -334,7 +341,8 @@ PrivateMessageReportResponse reportResolve( @GetMapping("report/list") ListPrivateMessageReportsResponse reportList( @Valid final ListPrivateMessageReports listPrivateMessageReportsForm, - final JwtPerson principal) { + final JwtPerson principal) + { final Person person = getPersonOrThrowUnauthorized(principal); diff --git a/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/privatemessage/models/GetPrivateMessages.java b/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/privatemessage/models/GetPrivateMessages.java index 36afc4879..e1e9a839d 100644 --- a/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/privatemessage/models/GetPrivateMessages.java +++ b/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/privatemessage/models/GetPrivateMessages.java @@ -9,7 +9,7 @@ public record GetPrivateMessages( Integer page, Integer limit, Long creator_id, - Optional unread_only + Boolean unread_only ) { } \ No newline at end of file diff --git a/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/search/controllers/SearchController.java b/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/search/controllers/SearchController.java index 22ed726e4..93ba0bf5b 100644 --- a/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/search/controllers/SearchController.java +++ b/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/search/controllers/SearchController.java @@ -64,9 +64,9 @@ public class SearchController extends AbstractLemmyApiController { )}) @GetMapping - SearchResponse search(@Valid final Search searchForm, final JwtPerson jwtPerson) { + SearchResponse search(@Valid final Search searchForm, final JwtPerson JwtPerson) { - final Optional person = getOptionalPerson(jwtPerson); + final Optional person = getOptionalPerson(JwtPerson); rolePermissionService.isPermitted(person.orElse(null), RolePermissionInstanceTypes.INSTANCE_SEARCH, diff --git a/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/site/controllers/SiteController.java b/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/site/controllers/SiteController.java index c2caf3645..51010492c 100644 --- a/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/site/controllers/SiteController.java +++ b/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/site/controllers/SiteController.java @@ -161,9 +161,7 @@ public SiteResponse createSite(@Valid @RequestBody final CreateSite createSiteFo config.reportEmailAdmins(false); final InstanceConfig instanceConfig = config.build(); - instanceConfigService.createInstanceConfig(instanceConfig); - slurFilterService.updateOrCreateLemmySlur(createSiteForm.slur_filter_regex()); instance.setInstanceConfig(instanceConfig); diff --git a/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/user/controllers/UserAuthController.java b/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/user/controllers/UserAuthController.java index b5f14d828..b9258b0f0 100644 --- a/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/user/controllers/UserAuthController.java +++ b/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/user/controllers/UserAuthController.java @@ -82,7 +82,7 @@ public class UserAuthController extends AbstractLemmyApiController { private static final Logger logger = LoggerFactory.getLogger(UserAuthController.class); - private final JwtUtil jwtUtil; + private final JwtUtil lemmyJwtUtil; private final PersonService personService; private final PersonRepository personRepository; private final LocalInstanceContext localInstanceContext; @@ -146,7 +146,7 @@ LoginResponse create(final HttpServletRequest request, throw new RuntimeException("Passwords do not match"); } personService.createPerson(person); - String token = jwtUtil.generateToken(person); + String token = lemmyJwtUtil.generateToken(person); boolean send_verification_email = false; @@ -154,12 +154,9 @@ LoginResponse create(final HttpServletRequest request, if (instanceConfig.getRegistrationMode() == RegistrationMode.RequireApplication) { personRegistrationApplicationService.createPersonRegistrationApplication( - PersonRegistrationApplication.builder() - .applicationStatus(PersonRegistrationApplicationStatus.pending) - .person(person) - .question(instanceConfig.getRegistrationQuestion()) - .answer(registerForm.answer()) - .build()); + PersonRegistrationApplication.builder().applicationStatus( + PersonRegistrationApplicationStatus.pending).person(person).question( + instanceConfig.getRegistrationQuestion()).answer(registerForm.answer()).build()); token = ""; } @@ -223,11 +220,8 @@ LoginResponse create(final HttpServletRequest request, } person.setEmailVerified(!send_verification_email); personService.updatePerson(person); - return LoginResponse.builder() - .jwt(token) - .registration_created(true) - .verify_email_sent(send_verification_email) - .build(); + return LoginResponse.builder().jwt(token).registration_created(true).verify_email_sent( + send_verification_email).build(); } @Operation(summary = "Fetch a Captcha.") @@ -240,9 +234,8 @@ GetCaptchaResponse captcha() { return GetCaptchaResponse.builder().build(); } Captcha captcha = captchaService.getCaptcha(); - return GetCaptchaResponse.builder() - .ok(conversionService.convert(captcha, CaptchaResponse.class)) - .build(); + return GetCaptchaResponse.builder().ok( + conversionService.convert(captcha, CaptchaResponse.class)).build(); } @Operation(summary = "Log into lemmy.") @@ -280,14 +273,13 @@ LoginResponse login(final HttpServletRequest request, @Valid @RequestBody final throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "password_incorrect"); } - final String token = jwtUtil.generateToken(person); + final String token = lemmyJwtUtil.generateToken(person); userDataService.checkAndAddIpRelation(person, request.getRemoteAddr(), token, request.getHeader("User-Agent")); - return LoginResponse.builder() - .jwt(token) - .registration_created(false) // @todo return true if application created + return LoginResponse.builder().jwt(token).registration_created( + false) // @todo return true if application created .verify_email_sent(false) // @todo return true if welcome email sent for verification .build(); } @@ -347,10 +339,9 @@ PasswordResetResponse passwordReset( params.put("person", foundPerson); - String url = localInstanceContext.instance() - .getDomain() - .substring(0, localInstanceContext.instance().getDomain().length() - 4) - + "/password_change/" + passwordReset.getToken(); + String url = localInstanceContext.instance().getDomain().substring(0, + localInstanceContext.instance().getDomain().length() - 4) + "/password_change/" + + passwordReset.getToken(); // #todo: implement the password reset in the frontend params.put("resetUrl", url); @@ -406,7 +397,7 @@ LoginResponse passwordChange(final HttpServletRequest request, personService.updatePerson(person); userDataService.invalidateAllUserData(person); - String token = jwtUtil.generateToken(person); + String token = lemmyJwtUtil.generateToken(person); userDataService.checkAndAddIpRelation(person, request.getRemoteAddr(), token, request.getHeader("User-Agent")); @@ -484,7 +475,7 @@ LoginResponse changePassword(final HttpServletRequest request, personService.updatePerson(person); userDataService.invalidateAllUserData(person); - String token = jwtUtil.generateToken(person); + String token = lemmyJwtUtil.generateToken(person); userDataService.checkAndAddIpRelation(person, request.getRemoteAddr(), token, request.getHeader("User-Agent")); @@ -566,9 +557,7 @@ SuccessResponse validate_auth(final JwtPerson principal) { Optional person = getOptionalPerson(principal); - return SuccessResponse.builder() - .success(person.isPresent()) - .error(person.isPresent() ? null : "not_logged_in") - .build(); + return SuccessResponse.builder().success(person.isPresent()).error( + person.isPresent() ? null : "not_logged_in").build(); } } diff --git a/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/user/controllers/UserController.java b/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/user/controllers/UserController.java index 90dfd2f46..872c95559 100644 --- a/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/user/controllers/UserController.java +++ b/src/main/java/com/sublinks/sublinksapi/api/lemmy/v3/user/controllers/UserController.java @@ -127,7 +127,7 @@ GetPersonDetailsResponse show(@Valid final GetPersonDetails getPersonDetailsForm } rolePermissionService.isPermitted(person, RolePermissionPersonTypes.READ_USER, - () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "no_permission")); + () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized")); return GetPersonDetailsResponse.builder() .person_view(lemmyPersonService.getPersonView(person)) @@ -144,12 +144,13 @@ GetPersonDetailsResponse show(@Valid final GetPersonDetails getPersonDetailsForm schema = @Schema(implementation = GetPersonMentionsResponse.class))})}) @GetMapping("mention") GetPersonMentionsResponse mention(@Valid final GetPersonMentions getPersonMentionsForm, - final JwtPerson principal) { + final JwtPerson principal) + { final Person person = getPersonOrThrowUnauthorized(principal); rolePermissionService.isPermitted(person, RolePermissionPersonTypes.READ_MENTION_USER, - () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "no_permission")); + () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized")); final int page = PaginationControllerUtils.getAbsoluteMinNumber(getPersonMentionsForm.page(), 1); @@ -184,12 +185,13 @@ GetPersonMentionsResponse mention(@Valid final GetPersonMentions getPersonMentio schema = @Schema(implementation = PersonMentionResponse.class))})}) @PostMapping("mention/mark_as_read") PersonMentionResponse mentionMarkAsRead( - @Valid final MarkPersonMentionAsRead markPersonMentionAsReadForm, final JwtPerson principal) { + @Valid final MarkPersonMentionAsRead markPersonMentionAsReadForm, final JwtPerson principal) + { final Person person = getPersonOrThrowUnauthorized(principal); rolePermissionService.isPermitted(person, RolePermissionPersonTypes.MARK_MENTION_AS_READ, - () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "no_permission")); + () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized")); final PersonMention personMention = personMentionRepository.findById( (long) markPersonMentionAsReadForm.person_mention_id()) @@ -219,7 +221,7 @@ GetRepliesResponse replies(@Valid final GetReplies getReplies, JwtPerson princip final Person person = getPersonOrThrowUnauthorized(principal); rolePermissionService.isPermitted(person, RolePermissionPersonTypes.READ_REPLIES, - () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "no_permission")); + () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized")); final int page = PaginationControllerUtils.getAbsoluteMinNumber(getReplies.page(), 1); final int perPage = PaginationControllerUtils.getAbsoluteMinNumber(getReplies.limit(), 20); @@ -254,7 +256,7 @@ BannedPersonsResponse bannedList(final JwtPerson principal) { final Person person = getPersonOrThrowUnauthorized(principal); rolePermissionService.isPermitted(person, RolePermissionInstanceTypes.INSTANCE_BAN_READ, - () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "no_permission")); + () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized")); final Collection bannedPersons = roleService.getBannedUsers() .stream() @@ -279,7 +281,7 @@ GetRepliesResponse markAllAsRead(final JwtPerson principal) { final Person person = getPersonOrThrowUnauthorized(principal); rolePermissionService.isPermitted(person, RolePermissionPersonTypes.READ_REPLIES, - () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "no_permission")); + () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized")); final MarkAllAsReadResponse readReplies = privateMessageService.markAllAsRead(person); @@ -301,12 +303,14 @@ GetRepliesResponse markAllAsRead(final JwtPerson principal) { @Transactional @PutMapping("save_user_settings") public LoginResponse saveUserSettings(@Valid @RequestBody SaveUserSettings saveUserSettingsForm, - JwtPerson principal) { + JwtPerson principal) + { final Person person = getPersonOrThrowUnauthorized(principal); rolePermissionService.isPermitted(person, RolePermissionPersonTypes.UPDATE_USER_SETTINGS, - () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "no_permission")); + () -> new ResponseStatusException(HttpStatus.FORBIDDEN, + "unauthorized")); // @todo expand form validation to check for email formatting, etc. if (saveUserSettingsForm.show_nsfw() != null) { @@ -425,14 +429,15 @@ public LoginResponse saveUserSettings(@Valid @RequestBody SaveUserSettings saveU schema = @Schema(implementation = GetUnreadCountResponse.class))})}) @GetMapping("unread_count") GetUnreadCountResponse unreadCount(@Valid final GetUnreadCount getUnreadCountForm, - JwtPerson principal) { + JwtPerson principal) + { final Person person = getPersonOrThrowUnauthorized(principal); rolePermissionService.isPermitted(person, Set.of(RolePermissionPersonTypes.READ_MENTION_USER, RolePermissionPersonTypes.READ_REPLIES, RolePermissionPrivateMessageTypes.READ_PRIVATE_MESSAGES), - () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "no_permission")); + () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized")); GetUnreadCountResponse.GetUnreadCountResponseBuilder builder = GetUnreadCountResponse.builder(); @@ -463,10 +468,15 @@ GetUnreadCountResponse unreadCount(@Valid final GetUnreadCount getUnreadCountFor schema = @Schema(implementation = SuccessResponse.class))})}) @PostMapping("import_settings") SuccessResponse import_settings(@Valid final ImportSettings importSettingsForm, - JwtPerson principal) { + JwtPerson principal) + { final Person person = getPersonOrThrowUnauthorized(principal); + rolePermissionService.isPermitted(person, RolePermissionPersonTypes.UPDATE_USER_SETTINGS, + () -> new ResponseStatusException(HttpStatus.FORBIDDEN, + "unauthorized")); + final SuccessResponse.SuccessResponseBuilder builder = SuccessResponse.builder(); UserExportSettings settings = importSettingsForm.settings(); @@ -547,6 +557,9 @@ ExportSettingsResponse export_settings(final JwtPerson principal) { final Person person = getPersonOrThrowUnauthorized(principal); + rolePermissionService.isPermitted(person, RolePermissionPersonTypes.USER_EXPORT, + () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized")); + ExportSettingsResponse.ExportSettingsResponseBuilder builder = ExportSettingsResponse.builder(); UserExportSettings.UserExportSettingsBuilder settings_builder = UserExportSettings.builder(); diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/annoucement/controllers/SublinksAnnouncementController.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/annoucement/controllers/SublinksAnnouncementController.java new file mode 100644 index 000000000..f5b2b9ea2 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/annoucement/controllers/SublinksAnnouncementController.java @@ -0,0 +1,100 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.annoucement.controllers; + +import com.sublinks.sublinksapi.api.sublinks.v1.annoucement.models.AnnouncementResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.annoucement.models.CreateAnnouncement; +import com.sublinks.sublinksapi.api.sublinks.v1.annoucement.models.IndexAnnouncement; +import com.sublinks.sublinksapi.api.sublinks.v1.annoucement.models.UpdateAnnouncement; +import com.sublinks.sublinksapi.api.sublinks.v1.annoucement.services.SublinksAnnouncementService; +import com.sublinks.sublinksapi.api.sublinks.v1.authentication.SublinksJwtPerson; +import com.sublinks.sublinksapi.api.sublinks.v1.common.controllers.AbstractSublinksApiController; +import com.sublinks.sublinksapi.person.entities.Person; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import java.util.Optional; +import lombok.AllArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("api/v1/announcement") +@Tag(name = "Sublinks Announcement", description = "Announcement API") +@AllArgsConstructor +public class SublinksAnnouncementController extends AbstractSublinksApiController { + + private final SublinksAnnouncementService sublinksAnnouncementService; + + @Operation(summary = "Get a list of announcements") + @GetMapping + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public List index(final IndexAnnouncement indexAnnouncementForm, + final SublinksJwtPerson sublinksJwtPerson) + { + + final Optional person = getOptionalPerson(sublinksJwtPerson); + + return sublinksAnnouncementService.index(indexAnnouncementForm, person.orElse(null)); + } + + @Operation(summary = "Get a specific announcement") + @GetMapping("/{key}") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public AnnouncementResponse show(@PathVariable String key, + final SublinksJwtPerson sublinksJwtPerson) + { + + final Optional person = getOptionalPerson(sublinksJwtPerson); + + return sublinksAnnouncementService.show(Long.parseLong(key), person.orElse(null)); + } + + @Operation(summary = "Create a new announcement") + @PostMapping + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public AnnouncementResponse create(@RequestBody final CreateAnnouncement createAnnouncementForm, + final SublinksJwtPerson sublinksJwtPerson) + { + + final Person person = getPersonOrThrowUnauthorized(sublinksJwtPerson); + + return sublinksAnnouncementService.create(createAnnouncementForm, person); + } + + @Operation(summary = "Update an announcement") + @PostMapping("/{id}") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public AnnouncementResponse update(@PathVariable final String id, + @RequestBody final UpdateAnnouncement updateAnnouncementForm, + final SublinksJwtPerson sublinksJwtPerson) + { + + final Person person = getPersonOrThrowUnauthorized(sublinksJwtPerson); + + return sublinksAnnouncementService.update(Long.parseLong(id), updateAnnouncementForm, person); + } + + @Operation(summary = "Delete an announcement") + @DeleteMapping("/{id}") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public AnnouncementResponse delete(@PathVariable final String id, + final SublinksJwtPerson sublinksJwtPerson) + { + + final Person person = getPersonOrThrowUnauthorized(sublinksJwtPerson); + + return sublinksAnnouncementService.delete(Long.parseLong(id), person); + + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/annoucement/mappers/SublinksAnnouncementMapper.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/annoucement/mappers/SublinksAnnouncementMapper.java new file mode 100644 index 000000000..cd7f4d277 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/annoucement/mappers/SublinksAnnouncementMapper.java @@ -0,0 +1,29 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.annoucement.mappers; + +import com.sublinks.sublinksapi.announcement.entities.Announcement; +import com.sublinks.sublinksapi.api.sublinks.v1.annoucement.models.AnnouncementResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.person.mappers.SublinksPersonMapper; +import com.sublinks.sublinksapi.api.sublinks.v1.utils.DateUtils; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingConstants; +import org.springframework.core.convert.converter.Converter; +import org.springframework.lang.Nullable; + +@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, + uses = {SublinksPersonMapper.class}) +public abstract class SublinksAnnouncementMapper implements + Converter { + + @Mapping(target = "key", source = "announcement.id") + @Mapping(target = "content", source = "announcement.content") + @Mapping(target = "createdAt", + source = "announcement.createdAt", + dateFormat = DateUtils.FRONT_END_DATE_FORMAT) + @Mapping(target = "updatedAt", + source = "announcement.updatedAt", + dateFormat = DateUtils.FRONT_END_DATE_FORMAT) + public abstract AnnouncementResponse convert(@Nullable Announcement announcement); + + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/annoucement/models/AnnouncementResponse.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/annoucement/models/AnnouncementResponse.java new file mode 100644 index 000000000..696240257 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/annoucement/models/AnnouncementResponse.java @@ -0,0 +1,25 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.annoucement.models; + +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; + +public record AnnouncementResponse( + @Schema(description = "The key of the announcement", + requiredMode = RequiredMode.REQUIRED, + example = "1") String key, + @Schema(description = "The content of the announcement", + requiredMode = RequiredMode.REQUIRED, + example = "This is an announcement") String content, + @Schema(description = "The created at date", + requiredMode = RequiredMode.REQUIRED, + example = "2021-01-01T00:00:00Z") String createdAt, + @Schema( + description = "The active status of the announcement", + requiredMode = RequiredMode.REQUIRED, + example = "true" + ) Boolean active, + @Schema(description = "The updated at date", + requiredMode = RequiredMode.REQUIRED, + example = "2021-01-01T00:00:00Z") String updatedAt) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/annoucement/models/CreateAnnouncement.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/annoucement/models/CreateAnnouncement.java new file mode 100644 index 000000000..5928e163b --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/annoucement/models/CreateAnnouncement.java @@ -0,0 +1,27 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.annoucement.models; + +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +public record CreateAnnouncement( + @Schema(description = "The content of the announcement", + requiredMode = RequiredMode.REQUIRED) String content, + @Schema(description = "The active status of the announcement", + requiredMode = RequiredMode.NOT_REQUIRED, + defaultValue = "true") Boolean active) { + + public CreateAnnouncement { + + if (content == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "content_required"); + } + } + + @Override + public Boolean active() { + + return active == null || active; + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/annoucement/models/IndexAnnouncement.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/annoucement/models/IndexAnnouncement.java new file mode 100644 index 000000000..387020ace --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/annoucement/models/IndexAnnouncement.java @@ -0,0 +1,37 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.annoucement.models; + +import com.sublinks.sublinksapi.api.sublinks.v1.common.enums.SortOrder; +import com.sublinks.sublinksapi.utils.PaginationUtils; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; + +public record IndexAnnouncement( + @Schema(description = "The sort order", + requiredMode = RequiredMode.NOT_REQUIRED, + defaultValue = "Asc") SortOrder sortOrder, + @Schema(description = "The page", + requiredMode = RequiredMode.NOT_REQUIRED, + defaultValue = "1") Integer page, + @Schema(description = "The number of items per page", + requiredMode = RequiredMode.NOT_REQUIRED, + defaultValue = "20") Integer perPage) { + + + @Override + public SortOrder sortOrder() { + + return sortOrder == null ? SortOrder.Asc : sortOrder; + } + + @Override + public Integer page() { + + return page == null ? 1 : Math.max(1, page); + } + + @Override + public Integer perPage() { + + return perPage == null ? 20 : PaginationUtils.Clamp(perPage, 1, 20); + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/annoucement/models/RemoveAnnouncement.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/annoucement/models/RemoveAnnouncement.java new file mode 100644 index 000000000..7ac8a931e --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/annoucement/models/RemoveAnnouncement.java @@ -0,0 +1,18 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.annoucement.models; + +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; + +public record RemoveAnnouncement( + @Schema(description = "The new content of the announcement", + requiredMode = RequiredMode.NOT_REQUIRED, + defaultValue = "true") Boolean removed, + @Schema(description = "The reason for removing the announcement", + requiredMode = RequiredMode.NOT_REQUIRED) String reason) { + + @Override + public Boolean removed() { + + return removed == null || removed; + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/annoucement/models/UpdateAnnouncement.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/annoucement/models/UpdateAnnouncement.java new file mode 100644 index 000000000..db2bc5263 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/annoucement/models/UpdateAnnouncement.java @@ -0,0 +1,13 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.annoucement.models; + +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; + +public record UpdateAnnouncement( + @Schema(description = "The new content of the announcement", + requiredMode = RequiredMode.NOT_REQUIRED) String content, + @Schema(description = "The new active status of the announcement", + requiredMode = RequiredMode.NOT_REQUIRED, + defaultValue = "true") Boolean active) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/annoucement/services/SublinksAnnouncementService.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/annoucement/services/SublinksAnnouncementService.java new file mode 100644 index 000000000..34ad0487d --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/annoucement/services/SublinksAnnouncementService.java @@ -0,0 +1,192 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.annoucement.services; + +import com.sublinks.sublinksapi.announcement.entities.Announcement; +import com.sublinks.sublinksapi.announcement.repositories.AnnouncementRepository; +import com.sublinks.sublinksapi.api.sublinks.v1.annoucement.models.AnnouncementResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.annoucement.models.CreateAnnouncement; +import com.sublinks.sublinksapi.api.sublinks.v1.annoucement.models.IndexAnnouncement; +import com.sublinks.sublinksapi.api.sublinks.v1.annoucement.models.RemoveAnnouncement; +import com.sublinks.sublinksapi.api.sublinks.v1.annoucement.models.UpdateAnnouncement; +import com.sublinks.sublinksapi.api.sublinks.v1.common.enums.SortOrder; +import com.sublinks.sublinksapi.authorization.enums.RolePermissionInstanceTypes; +import com.sublinks.sublinksapi.authorization.services.RolePermissionService; +import com.sublinks.sublinksapi.instance.models.LocalInstanceContext; +import com.sublinks.sublinksapi.person.entities.Person; +import jakarta.persistence.EntityNotFoundException; +import java.util.List; +import lombok.AllArgsConstructor; +import org.springframework.core.convert.ConversionService; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Sort.Direction; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +@AllArgsConstructor +@Service +public class SublinksAnnouncementService { + + private final LocalInstanceContext localInstanceContext; + private final ConversionService conversionService; + private final RolePermissionService rolePermissionService; + private final AnnouncementRepository announcementRepository; + + /** + * Retrieves a list of AnnouncementResponse objects based on the provided indexAnnouncementForm + * and person. + * + * @param indexAnnouncementForm The form containing the sorting and pagination details. + * @param person The person requesting the announcements. + * @return Returns a list of AnnouncementResponse objects representing the retrieved + * announcements. + * @throws ResponseStatusException if the person is not allowed to read the announcements. + */ + public List index(final IndexAnnouncement indexAnnouncementForm, + final Person person) + { + + rolePermissionService.isPermitted(person, + RolePermissionInstanceTypes.INSTANCE_READ_ANNOUNCEMENTS, + () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized")); + + final List announcements = this.announcementRepository.findAll( + PageRequest.of(indexAnnouncementForm.page() - 1, indexAnnouncementForm.perPage(), Sort.by( + indexAnnouncementForm.sortOrder() == SortOrder.Desc ? Direction.DESC : Direction.ASC, + "createdAt"))) + .toList(); + + return announcements.stream() + .map((announcement) -> this.conversionService.convert(announcement, + AnnouncementResponse.class)) + .toList(); + } + + /** + * Retrieves the announcement with the given key and converts it to an + * {@link AnnouncementResponse} object. + * + * @param key The key of the announcement to be retrieved. + * @param person The person requesting the announcement. + * @return Returns the converted {@link AnnouncementResponse} object representing the retrieved + * announcement. + * @throws ResponseStatusException if the person is not allowed to read the announcement or if the + * announcement does not exist. + */ + public AnnouncementResponse show(final Long key, final Person person) { + + rolePermissionService.isPermitted(person, + RolePermissionInstanceTypes.INSTANCE_READ_ANNOUNCEMENT, + () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized")); + + try { + final Announcement announcement = this.announcementRepository.getReferenceById(key); + + return this.conversionService.convert(announcement, AnnouncementResponse.class); + } catch (EntityNotFoundException e) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "announcement_not_found"); + } + } + + /** + * Creates a new announcement based on the provided form and person. + * + * @param createAnnouncementForm The form containing the details of the announcement to be + * created. + * @param person The person creating the announcement. + * @return Returns an AnnouncementResponse object representing the created announcement. + */ + public AnnouncementResponse create(final CreateAnnouncement createAnnouncementForm, + final Person person) + { + + rolePermissionService.isPermitted(person, + RolePermissionInstanceTypes.INSTANCE_CREATE_ANNOUNCEMENT, + () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized")); + + final Announcement announcement = Announcement.builder() + .content(createAnnouncementForm.content()) + .localSiteId(localInstanceContext.instance() + .getId()) + .active(createAnnouncementForm.active()) + .creator(person) + .build(); + + this.announcementRepository.save(announcement); + + return this.conversionService.convert(announcement, AnnouncementResponse.class); + } + + /** + * Updates an announcement based on the given ID, update form, and person. + * + * @param id The ID of the announcement to be updated. + * @param updateAnnouncementForm The form containing the updated details. + * @param person The person making the update. + * @return Returns an AnnouncementResponse object representing the updated announcement. + */ + public AnnouncementResponse update(final Long id, final UpdateAnnouncement updateAnnouncementForm, + final Person person) + { + + rolePermissionService.isPermitted(person, + RolePermissionInstanceTypes.INSTANCE_UPDATE_ANNOUNCEMENT, + () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized")); + + final Announcement announcement = this.announcementRepository.getReferenceById(id); + + if (updateAnnouncementForm.content() != null) { + announcement.setContent(updateAnnouncementForm.content()); + } + if (updateAnnouncementForm.active() != null) { + announcement.setActive(updateAnnouncementForm.active()); + } + this.announcementRepository.save(announcement); + return conversionService.convert(announcement, AnnouncementResponse.class); + } + + /** + * Deletes an announcement based on the given ID and person. + * + * @param id The ID of the announcement to be deleted. + * @param removeAnnouncementForm The form containing the removal details. + * @param person The person making the deletion. + * @return Returns an {@link AnnouncementResponse} object representing the deleted announcement. + */ + public AnnouncementResponse remove(final Long id, final RemoveAnnouncement removeAnnouncementForm, + final Person person) + { + + rolePermissionService.isPermitted(person, + RolePermissionInstanceTypes.INSTANCE_DELETE_ANNOUNCEMENT, + () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized")); + + final Announcement announcement = this.announcementRepository.getReferenceById(id); + + announcement.setActive(removeAnnouncementForm.removed()); + + announcementRepository.save(announcement); + + return conversionService.convert(announcement, AnnouncementResponse.class); + } + + /** + * Deletes an announcement based on the given ID and person. + * + * @param id The ID of the announcement to be deleted. + * @param person The person making the deletion. + * @return Returns an {@link AnnouncementResponse} object representing the deleted announcement. + */ + public AnnouncementResponse delete(final Long id, final Person person) { + + rolePermissionService.isPermitted(person, + RolePermissionInstanceTypes.INSTANCE_DELETE_ANNOUNCEMENT, + () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized")); + + final Announcement announcement = this.announcementRepository.getReferenceById(id); + + announcementRepository.delete(announcement); + + return conversionService.convert(announcement, AnnouncementResponse.class); + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/authentication/SublinksJwtFilter.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/authentication/SublinksJwtFilter.java new file mode 100644 index 000000000..a22fce599 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/authentication/SublinksJwtFilter.java @@ -0,0 +1,97 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.authentication; + +import com.sublinks.sublinksapi.person.entities.Person; +import com.sublinks.sublinksapi.person.repositories.PersonRepository; +import com.sublinks.sublinksapi.person.services.UserDataService; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.security.SignatureException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.core.annotation.Order; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +@RequiredArgsConstructor +@Order(1) +public class SublinksJwtFilter extends OncePerRequestFilter { + + private final SublinksJwtUtil sublinksJwtUtil; + private final PersonRepository personRepository; + private final UserDataService userDataService; + + @Override + protected void doFilterInternal(final HttpServletRequest request, + final HttpServletResponse response, final FilterChain filterChain) + throws ServletException, IOException + { + + String authorizingToken = request.getHeader("Authorization"); + + if (authorizingToken == null && request.getCookies() != null) { + for (Cookie cookie : request.getCookies()) { + if (cookie.getName() + .equals("jwt")) { + authorizingToken = cookie.getValue(); + break; + } + } + + } + + String token = null; + String userName = null; + + try { + if (authorizingToken != null) { + if (authorizingToken.startsWith("Bearer ")) { + token = authorizingToken.substring(7); + } else { + token = authorizingToken; + } + userName = sublinksJwtUtil.extractUsername(token); + } + } catch (ExpiredJwtException | SignatureException ex) { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "invalid_token"); + } + + if (userName != null && SecurityContextHolder.getContext() + .getAuthentication() == null) { + final Optional person = personRepository.findOneByNameIgnoreCase(userName); + if (person.isEmpty()) { + throw new UsernameNotFoundException("Invalid name"); + } + + if (sublinksJwtUtil.validateToken(token, person.get())) { + // Add a check if token and ip was changed? To give like a "warning" to the user that he has a new ip logged into his account + userDataService.checkAndAddIpRelation(person.get(), request.getRemoteAddr(), token, + request.getHeader("User-Agent")); + final SublinksJwtPerson authenticationToken = new SublinksJwtPerson(person.get(), + person.get() + .getAuthorities(), token); + authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext() + .setAuthentication(authenticationToken); + } else { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "invalid_token"); + } + } + filterChain.doFilter(request, response); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { + + String servletPath = request.getServletPath(); + return !servletPath.startsWith("/api/v1") && !servletPath.startsWith("/pictrs"); + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/authentication/SublinksJwtPerson.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/authentication/SublinksJwtPerson.java new file mode 100644 index 000000000..505776707 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/authentication/SublinksJwtPerson.java @@ -0,0 +1,36 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.authentication; + +import com.sublinks.sublinksapi.person.entities.Person; +import java.util.Collection; +import lombok.Getter; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; + +public class SublinksJwtPerson extends AbstractAuthenticationToken { + + private final Person person; + @Getter + private final String token; + + public SublinksJwtPerson(final Person person, + final Collection authorities, final String token) + { + + super(authorities); + this.person = person; + this.token = token; + setAuthenticated(true); + } + + @Override + public Object getCredentials() { + + return null; + } + + @Override + public Object getPrincipal() { + + return this.person; + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/authentication/SublinksJwtUtil.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/authentication/SublinksJwtUtil.java new file mode 100644 index 000000000..854c8f980 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/authentication/SublinksJwtUtil.java @@ -0,0 +1,88 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.authentication; + +import com.sublinks.sublinksapi.person.entities.Person; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import java.io.Serial; +import java.io.Serializable; +import java.security.Key; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; +import javax.crypto.SecretKey; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class SublinksJwtUtil implements Serializable { + + public static final long JWT_TOKEN_VALIDITY = 24 * 60 * 60; + @Serial + private static final long serialVersionUID = -2550185165626007488L; + private final String secret; + + public SublinksJwtUtil(@Value("${jwt.secret}") final String secret) { + + this.secret = secret; + } + + public String generateToken(final Person person) { + + final Map claims = new HashMap<>(); + return doGenerateToken(claims, person.getUsername()); + } + + public Boolean validateToken(final String token, final Person person) { + + final String tokenUsername = extractUsername(token); + return (tokenUsername.equals(person.getUsername()) && !isTokenExpired(token)); + } + + public String extractUsername(final String token) { + + return extractClaim(token, Claims::getSubject); + } + + public Date extractExpiration(final String token) { + + return extractClaim(token, Claims::getExpiration); + } + + public T extractClaim(final String token, final Function claimsResolver) { + + final Claims claims = extractAllClaims(token); + return claimsResolver.apply(claims); + } + + private Claims extractAllClaims(final String token) { + + final SecretKey key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret)); + return Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + private Boolean isTokenExpired(final String token) { + + return extractExpiration(token).before(new Date()); + } + + private String doGenerateToken(final Map claims, final String subject) { + + final byte[] keyBytes = Decoders.BASE64.decode(secret); + final Key key = Keys.hmacShaKeyFor(keyBytes); + return Jwts.builder() + .claims(claims) + .subject(subject) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + JWT_TOKEN_VALIDITY * 1000)) + .signWith(key) + .compact(); + } + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/authentication/config/SublinksSecurityConfig.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/authentication/config/SublinksSecurityConfig.java new file mode 100644 index 000000000..afd917c82 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/authentication/config/SublinksSecurityConfig.java @@ -0,0 +1,47 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.authentication.config; + +import com.sublinks.sublinksapi.api.sublinks.v1.authentication.SublinksJwtFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +/** + * This class represents the configuration for the security of the application. It is responsible + * for setting up the security filters and rules. + */ +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +@Order(1) +public class SublinksSecurityConfig { + + private final SublinksJwtFilter sublinksJwtFilter; + + /** + * Returns a configured SecurityFilterChain object for the application's security. + * + * @param http The HttpSecurity object used to configure the security. + * @return The configured SecurityFilterChain object. + * @throws Exception If an error occurs during the configuration process. + */ + @Bean + public SecurityFilterChain sublinksFilterChain(final HttpSecurity http) throws Exception { + + http.csrf(AbstractHttpConfigurer::disable) + .securityMatcher("/api/v1/**") + .authorizeHttpRequests((requests) -> requests.anyRequest() + .permitAll()) + .sessionManagement((sessionManagement) -> sessionManagement.sessionCreationPolicy( + SessionCreationPolicy.STATELESS)) + .addFilterBefore(sublinksJwtFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/controllers/SublinksCommentAggerateController.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/controllers/SublinksCommentAggerateController.java new file mode 100644 index 000000000..245f56c93 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/controllers/SublinksCommentAggerateController.java @@ -0,0 +1,39 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.comment.controllers; + +import com.sublinks.sublinksapi.api.sublinks.v1.authentication.SublinksJwtPerson; +import com.sublinks.sublinksapi.api.sublinks.v1.comment.models.CommentAggregateResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.comment.services.SublinksCommentService; +import com.sublinks.sublinksapi.api.sublinks.v1.common.controllers.AbstractSublinksApiController; +import com.sublinks.sublinksapi.person.entities.Person; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.Optional; +import lombok.AllArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@AllArgsConstructor +@RequestMapping("api/v1/comment/{key}/aggregate") +@Tag(name = "Sublinks Comment Aggregation", description = "Comment Aggregate API") +public class SublinksCommentAggerateController extends AbstractSublinksApiController { + + private final SublinksCommentService sublinksCommentService; + + @Operation(summary = "Aggregate a comment") + @GetMapping + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public CommentAggregateResponse aggregate(@PathVariable final String key, + final SublinksJwtPerson sublinksJwtPerson) + { + + final Optional person = getOptionalPerson(sublinksJwtPerson); + + return sublinksCommentService.aggregate(key, person.orElse(null)); + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/controllers/SublinksCommentController.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/controllers/SublinksCommentController.java new file mode 100644 index 000000000..c4edb446e --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/controllers/SublinksCommentController.java @@ -0,0 +1,101 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.comment.controllers; + +import com.sublinks.sublinksapi.api.sublinks.v1.authentication.SublinksJwtPerson; +import com.sublinks.sublinksapi.api.sublinks.v1.comment.models.CommentResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.comment.models.CreateComment; +import com.sublinks.sublinksapi.api.sublinks.v1.comment.models.DeleteComment; +import com.sublinks.sublinksapi.api.sublinks.v1.comment.models.IndexComment; +import com.sublinks.sublinksapi.api.sublinks.v1.comment.models.UpdateComment; +import com.sublinks.sublinksapi.api.sublinks.v1.comment.services.SublinksCommentService; +import com.sublinks.sublinksapi.api.sublinks.v1.common.controllers.AbstractSublinksApiController; +import com.sublinks.sublinksapi.person.entities.Person; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import java.util.Optional; +import lombok.AllArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@AllArgsConstructor +@RequestMapping("api/v1/comment") +@Tag(name = "Sublinks Comment", description = "Comment API") +public class SublinksCommentController extends AbstractSublinksApiController { + + private final SublinksCommentService sublinksCommentService; + + + @Operation(summary = "Get a list of comments") + @GetMapping + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public List index(IndexComment indexCommentParam, + final SublinksJwtPerson sublinksJwtPerson) + { + + final Optional person = getOptionalPerson(sublinksJwtPerson); + + return sublinksCommentService.index(indexCommentParam != null ? indexCommentParam + : IndexComment.builder() + .build(), person.orElse(null)); + } + + @Operation(summary = "Get a specific comment") + @GetMapping("/{key}") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public CommentResponse show(@PathVariable final String key, + final SublinksJwtPerson sublinksJwtPerson) + { + + final Optional person = getOptionalPerson(sublinksJwtPerson); + + return sublinksCommentService.show(key, person.orElse(null)); + } + + @Operation(summary = "Create a new comment") + @PostMapping + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public CommentResponse create(final CreateComment createCommentForm, + final SublinksJwtPerson sublinksJwtPerson) + { + + final Person person = getPersonOrThrowUnauthorized(sublinksJwtPerson); + + return sublinksCommentService.createComment(createCommentForm, person); + } + + @Operation(summary = "Update an comment") + @PostMapping("/{key}") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public CommentResponse update(@PathVariable String key, final UpdateComment createCommentForm, + final SublinksJwtPerson sublinksJwtPerson) + { + + final Person person = getPersonOrThrowUnauthorized(sublinksJwtPerson); + + return sublinksCommentService.updateComment(key, createCommentForm, person); + } + + @Operation(summary = "Delete an comment") + @DeleteMapping("/{key}") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public CommentResponse delete(@PathVariable String key, final DeleteComment deleteCommentForm, + final SublinksJwtPerson sublinksJwtPerson) + { + + final Person person = getPersonOrThrowUnauthorized(sublinksJwtPerson); + + return sublinksCommentService.delete(key, deleteCommentForm, person); + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/controllers/SublinksCommentModerationController.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/controllers/SublinksCommentModerationController.java new file mode 100644 index 000000000..10f4eb6a4 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/controllers/SublinksCommentModerationController.java @@ -0,0 +1,78 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.comment.controllers; + +import com.sublinks.sublinksapi.api.sublinks.v1.authentication.SublinksJwtPerson; +import com.sublinks.sublinksapi.api.sublinks.v1.comment.models.CommentResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.comment.models.Moderation.PinComment; +import com.sublinks.sublinksapi.api.sublinks.v1.comment.models.Moderation.PurgeComment; +import com.sublinks.sublinksapi.api.sublinks.v1.comment.models.Moderation.RemoveComment; +import com.sublinks.sublinksapi.api.sublinks.v1.comment.services.SublinksCommentService; +import com.sublinks.sublinksapi.api.sublinks.v1.common.controllers.AbstractSublinksApiController; +import com.sublinks.sublinksapi.api.sublinks.v1.common.models.RequestResponse; +import com.sublinks.sublinksapi.person.entities.Person; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.AllArgsConstructor; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@AllArgsConstructor +@RequestMapping("api/v1/comment/{key}/moderation") +@Tag(name = "Sublinks Comment Moderation", description = "Comment Moderation API") +public class SublinksCommentModerationController extends AbstractSublinksApiController { + + private final SublinksCommentService sublinksCommentService; + + @Operation(summary = "Remove a comment") + @PostMapping("/remove") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public CommentResponse remove(@PathVariable final String key, + @RequestBody @Valid final RemoveComment removeComment, + final SublinksJwtPerson sublinksJwtPerson) + { + + final Person person = getPersonOrThrowUnauthorized(sublinksJwtPerson); + + return sublinksCommentService.remove(key, removeComment, person); + } + + @Operation(summary = "Purge a comment") + @PostMapping("/purge") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public RequestResponse delete(@PathVariable final String key, + @RequestBody @Valid final PurgeComment purgeCommentForm, + final SublinksJwtPerson sublinksJwtPerson) + { + + final Person person = getPersonOrThrowUnauthorized(sublinksJwtPerson); + + // @todo: Implement purge + + return RequestResponse.builder() + .success(false) + .error("not_implemented") + .build(); + } + + @Operation(summary = "Pin/Unpin a comment") + @GetMapping("/pin") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public CommentResponse pin(@PathVariable final String key, final PinComment pinCommentForm, + final SublinksJwtPerson sublinksJwtPerson) + { + + final Person person = getPersonOrThrowUnauthorized(sublinksJwtPerson); + + return sublinksCommentService.pin(key, pinCommentForm, person); + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/mappers/SublinksCommentMapper.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/mappers/SublinksCommentMapper.java new file mode 100644 index 000000000..5c85d5d7c --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/mappers/SublinksCommentMapper.java @@ -0,0 +1,36 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.comment.mappers; + +import com.sublinks.sublinksapi.api.sublinks.v1.comment.models.CommentResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.person.mappers.SublinksPersonMapper; +import com.sublinks.sublinksapi.api.sublinks.v1.utils.DateUtils; +import com.sublinks.sublinksapi.comment.entities.Comment; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingConstants; +import org.springframework.core.convert.converter.Converter; +import org.springframework.lang.Nullable; + +@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, + uses = {SublinksPersonMapper.class}) +public abstract class SublinksCommentMapper implements Converter { + + @Override + @Mapping(target = "key", source = "comment.path") + @Mapping(target = "activityPubId", source = "comment.activityPubId") + @Mapping(target = "body", source = "comment.commentBody") + @Mapping(target = "path", source = "comment.path") + @Mapping(target = "isLocal", source = "comment.local") + @Mapping(target = "isDeleted", source = "comment.deleted") + @Mapping(target = "isFeatured", source = "comment.featured") + @Mapping(target = "isRemoved", expression = "java(comment != null && comment.isRemoved())") + // @Mapping(target = "creator", expression = "java(personMapper.convert(comment.getPerson()))") + @Mapping(target = "creator", source = "comment.person") + @Mapping(target = "createdAt", + source = "comment.createdAt", + dateFormat = DateUtils.FRONT_END_DATE_FORMAT) + @Mapping(target = "updatedAt", + source = "comment.updatedAt", + dateFormat = DateUtils.FRONT_END_DATE_FORMAT) + @Mapping(target = "replies", ignore = true) + public abstract CommentResponse convert(@Nullable Comment comment); +} \ No newline at end of file diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/mappers/SublinksCommentSortTypeMapper.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/mappers/SublinksCommentSortTypeMapper.java new file mode 100644 index 000000000..7f9d174b0 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/mappers/SublinksCommentSortTypeMapper.java @@ -0,0 +1,38 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.comment.mappers; + +import com.sublinks.sublinksapi.comment.enums.CommentSortType; +import com.sublinks.sublinksapi.person.enums.SortType; +import org.mapstruct.Mapper; +import org.mapstruct.MappingConstants; +import org.mapstruct.ValueMapping; +import org.mapstruct.extensions.spring.AdapterMethodName; +import org.springframework.core.convert.converter.Converter; +import org.springframework.lang.Nullable; + +@Mapper(componentModel = MappingConstants.ComponentModel.SPRING) +@AdapterMethodName("SublinksCommentSortTypeMapper") +public abstract class SublinksCommentSortTypeMapper implements + Converter { + + @Override + @ValueMapping(source = "Hot", target = "Hot") + @ValueMapping(source = "New", target = "New") + @ValueMapping(source = "Old", target = "Old") + @ValueMapping(source = "Active", target = "Hot") + @ValueMapping(source = "TopDay", target = "Top") + @ValueMapping(source = "TopWeek", target = "Top") + @ValueMapping(source = "TopMonth", target = "Top") + @ValueMapping(source = "TopYear", target = "Top") + @ValueMapping(source = "TopAll", target = "Top") + @ValueMapping(source = "MostComments", target = "Top") + @ValueMapping(source = "NewComments", target = "Top") + @ValueMapping(source = "TopHour", target = "Top") + @ValueMapping(source = "TopSixHour", target = "Top") + @ValueMapping(source = "TopTwelveHour", target = "Top") + @ValueMapping(source = "TopThreeMonths", target = "Top") + @ValueMapping(source = "TopSixMonths", target = "Top") + @ValueMapping(source = "TopNineMonths", target = "Top") + @ValueMapping(source = "Controversial", target = "New") + @ValueMapping(source = "Scaled", target = "New") + public abstract CommentSortType convert(@Nullable SortType sortType); +} \ No newline at end of file diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/models/CommentAggregateResponse.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/models/CommentAggregateResponse.java new file mode 100644 index 000000000..cf2038877 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/models/CommentAggregateResponse.java @@ -0,0 +1,22 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.comment.models; + +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import lombok.Builder; + +@Builder +public record CommentAggregateResponse( + @Schema(description = "Search query", + requiredMode = RequiredMode.NOT_REQUIRED) String commentKey, + @Schema(description = "The number of upvotes", + requiredMode = RequiredMode.NOT_REQUIRED) Integer upVotes, + @Schema(description = "The number of downvotes", + requiredMode = RequiredMode.NOT_REQUIRED) Integer downVotes, + @Schema(description = "The number of hot rank", + requiredMode = RequiredMode.NOT_REQUIRED) Integer hotRank, + @Schema(description = "The number of controversy rank", + requiredMode = RequiredMode.NOT_REQUIRED) Integer controversyRank, + @Schema(description = "The number of reply count", + requiredMode = RequiredMode.NOT_REQUIRED) Integer replyCount) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/models/CommentResponse.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/models/CommentResponse.java new file mode 100644 index 000000000..9e52c9194 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/models/CommentResponse.java @@ -0,0 +1,49 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.comment.models; + +import com.sublinks.sublinksapi.api.sublinks.v1.person.models.PersonResponse; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import java.util.List; +import lombok.Builder; + +@Builder(toBuilder = true) +public record CommentResponse( + @Schema(description = "The key of the comment", + requiredMode = RequiredMode.REQUIRED) String key, + @Schema(description = "The activity pub id of the comment", + requiredMode = RequiredMode.REQUIRED) String activityPubId, + @Schema(description = "The body of the comment", + requiredMode = RequiredMode.REQUIRED) String body, + @Schema(description = "The path of the comment", + requiredMode = RequiredMode.REQUIRED) String path, + @Schema(description = "Is the comment local", + requiredMode = RequiredMode.REQUIRED) Boolean isLocal, + @Schema(description = "Is the comment deleted", + requiredMode = RequiredMode.REQUIRED) Boolean isDeleted, + @Schema(description = "Is the comment featured", + requiredMode = RequiredMode.REQUIRED) Boolean isFeatured, + @Schema(description = "Is the comment removed", + requiredMode = RequiredMode.REQUIRED) Boolean isRemoved, + + @Schema(description = "The creator of the comment", + requiredMode = RequiredMode.REQUIRED) PersonResponse creator, + @Schema(description = "The parent of the comment", + requiredMode = RequiredMode.NOT_REQUIRED) List replies, + @Schema(description = "The created at date", + requiredMode = RequiredMode.REQUIRED) String createdAt, + @Schema(description = "The updated at date", + requiredMode = RequiredMode.REQUIRED) String updatedAt) { + + + public String getId() { + + List ids = List.of(key.split("\\.")); + return ids.get(ids.size() - 1); + } + + public String getParentKey() { + + List ids = List.of(key.split("\\.")); + return ids.size() > 1 ? String.join(".", ids.subList(0, ids.size() - 1)) : null; + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/models/CreateComment.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/models/CreateComment.java new file mode 100644 index 000000000..33ada80ab --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/models/CreateComment.java @@ -0,0 +1,35 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.comment.models; + +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import lombok.Builder; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +@Builder +public record CreateComment( + String body, + @Schema(description = "The parent key of the comment", + requiredMode = RequiredMode.NOT_REQUIRED) String parentKey, + String postKey, + @Schema(description = "The language key of the comment", + defaultValue = "und", + example = "und", + requiredMode = RequiredMode.NOT_REQUIRED) String languageKey, + @Schema(description = "Whether the comment is featured ( Requires permission to do so. )", + defaultValue = "false", + example = "false", + requiredMode = RequiredMode.NOT_REQUIRED) Boolean featured) { + + public CreateComment { + + if (body.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "body_can_not_be_blank"); + } + } + + public String languageKey() { + + return languageKey == null ? "und" : languageKey; + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/models/DeleteComment.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/models/DeleteComment.java new file mode 100644 index 000000000..e882640df --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/models/DeleteComment.java @@ -0,0 +1,22 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.comment.models; + +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import lombok.Builder; + +@Builder +public record DeleteComment( + @Schema(requiredMode = RequiredMode.REQUIRED, + description = "The reason for deleting the comment", + example = "Spam") String reason, + @Schema(requiredMode = RequiredMode.NOT_REQUIRED, + description = "Whether to remove the comment", + example = "true", + defaultValue = "true") Boolean remove) { + + @Override + public Boolean remove() { + + return remove == null || remove; + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/models/IndexComment.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/models/IndexComment.java new file mode 100644 index 000000000..5a257126b --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/models/IndexComment.java @@ -0,0 +1,39 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.comment.models; + +import com.sublinks.sublinksapi.api.sublinks.v1.common.enums.SortType; +import com.sublinks.sublinksapi.api.sublinks.v1.common.enums.SublinksListingType; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import lombok.Builder; + +@Builder +public record IndexComment( + @Schema(description = "Search query", requiredMode = RequiredMode.NOT_REQUIRED) String search, + @Schema(description = "Sort type", requiredMode = RequiredMode.NOT_REQUIRED) SortType sortType, + @Schema(description = "Listing type", + requiredMode = RequiredMode.NOT_REQUIRED) SublinksListingType listingType, + @Schema(description = "Community key", + requiredMode = RequiredMode.NOT_REQUIRED) String communityKey, + @Schema(description = "Post key", requiredMode = RequiredMode.NOT_REQUIRED) String postKey, + @Schema(description = "Parent Comment key", + requiredMode = RequiredMode.NOT_REQUIRED) String parentCommentKey, + @Schema(description = "Show NSFW", requiredMode = RequiredMode.NOT_REQUIRED) Boolean showNsfw, + @Schema(description = "Saved only", requiredMode = RequiredMode.NOT_REQUIRED) Boolean savedOnly, + @Schema(description = "Max Depth", + requiredMode = RequiredMode.NOT_REQUIRED, + defaultValue = "3") Integer maxDepth, + @Schema(description = "Per page", requiredMode = RequiredMode.NOT_REQUIRED) Integer perPage, + @Schema(description = "Page", requiredMode = RequiredMode.NOT_REQUIRED) Integer page) { + + @Override + public Integer perPage() { + + return perPage != null ? perPage : 20; + } + + @Override + public Integer page() { + + return page != null ? page : 1; + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/models/Moderation/PinComment.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/models/Moderation/PinComment.java new file mode 100644 index 000000000..dffa394a6 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/models/Moderation/PinComment.java @@ -0,0 +1,5 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.comment.models.Moderation; + +public record PinComment(Boolean pin) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/models/Moderation/PurgeComment.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/models/Moderation/PurgeComment.java new file mode 100644 index 000000000..b766174ba --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/models/Moderation/PurgeComment.java @@ -0,0 +1,7 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.comment.models.Moderation; + +public record PurgeComment( + String reason, + Boolean remove) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/models/Moderation/RemoveComment.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/models/Moderation/RemoveComment.java new file mode 100644 index 000000000..b125faf33 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/models/Moderation/RemoveComment.java @@ -0,0 +1,7 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.comment.models.Moderation; + +public record RemoveComment( + String reason, + Boolean remove) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/models/UpdateComment.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/models/UpdateComment.java new file mode 100644 index 000000000..05cc63a76 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/models/UpdateComment.java @@ -0,0 +1,19 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.comment.models; + +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import lombok.Builder; + +@Builder +public record UpdateComment( + @Schema(description = "The new content of the comment", + requiredMode = RequiredMode.REQUIRED) String body, + @Schema(description = "The language key of the comment", + defaultValue = "und", + example = "und", + requiredMode = RequiredMode.NOT_REQUIRED) String languageKey, + @Schema(description = "Whether the comment is featured ( Requires Moderator or Admin )", + example = "false", + requiredMode = RequiredMode.NOT_REQUIRED) Boolean featured) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/services/SublinksCommentService.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/services/SublinksCommentService.java new file mode 100644 index 000000000..7b4cc646c --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/comment/services/SublinksCommentService.java @@ -0,0 +1,415 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.comment.services; + +import com.sublinks.sublinksapi.api.sublinks.v1.comment.models.CommentAggregateResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.comment.models.CommentResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.comment.models.CreateComment; +import com.sublinks.sublinksapi.api.sublinks.v1.comment.models.DeleteComment; +import com.sublinks.sublinksapi.api.sublinks.v1.comment.models.IndexComment; +import com.sublinks.sublinksapi.api.sublinks.v1.comment.models.Moderation.PinComment; +import com.sublinks.sublinksapi.api.sublinks.v1.comment.models.Moderation.RemoveComment; +import com.sublinks.sublinksapi.api.sublinks.v1.comment.models.UpdateComment; +import com.sublinks.sublinksapi.authorization.enums.RolePermissionCommentTypes; +import com.sublinks.sublinksapi.authorization.services.RolePermissionService; +import com.sublinks.sublinksapi.comment.entities.Comment; +import com.sublinks.sublinksapi.comment.enums.CommentSortType; +import com.sublinks.sublinksapi.comment.models.CommentSearchCriteria; +import com.sublinks.sublinksapi.comment.repositories.CommentAggregateRepository; +import com.sublinks.sublinksapi.comment.repositories.CommentRepository; +import com.sublinks.sublinksapi.comment.services.CommentService; +import com.sublinks.sublinksapi.community.entities.Community; +import com.sublinks.sublinksapi.community.repositories.CommunityRepository; +import com.sublinks.sublinksapi.instance.models.LocalInstanceContext; +import com.sublinks.sublinksapi.language.entities.Language; +import com.sublinks.sublinksapi.language.repositories.LanguageRepository; +import com.sublinks.sublinksapi.language.services.LanguageService; +import com.sublinks.sublinksapi.person.entities.Person; +import com.sublinks.sublinksapi.person.enums.LinkPersonCommunityType; +import com.sublinks.sublinksapi.person.enums.ListingType; +import com.sublinks.sublinksapi.person.enums.SortType; +import com.sublinks.sublinksapi.person.services.LinkPersonCommunityService; +import com.sublinks.sublinksapi.post.entities.Post; +import com.sublinks.sublinksapi.post.repositories.PostRepository; +import com.sublinks.sublinksapi.shared.RemovedState; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import lombok.AllArgsConstructor; +import org.springframework.core.convert.ConversionService; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +@AllArgsConstructor +@Service +public class SublinksCommentService { + + private final LocalInstanceContext localInstanceContext; + private final ConversionService conversionService; + private final LanguageRepository languageRepository; + private final CommunityRepository communityRepository; + private final CommentRepository commentRepository; + private final CommentAggregateRepository commentAggregateRepository; + private final CommentService commentService; + private final PostRepository postRepository; + private final LanguageService languageService; + private final RolePermissionService rolePermissionService; + private final LinkPersonCommunityService linkPersonCommunityService; + + /** + * Retrieves a list of CommentResponse objects based on the provided IndexComment and Person + * objects. + * + * @param indexCommentForm The IndexComment object representing the search criteria for retrieving + * comments. + * @param person The Person object representing the user performing the operation. + * @return A list of CommentResponse objects representing the retrieved comments. + * @throws ResponseStatusException If the user does not have permission to read comments. + */ + public List index(final IndexComment indexCommentForm, final Person person) { + + rolePermissionService.isPermitted(person, RolePermissionCommentTypes.READ_COMMENTS, + () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized")); + + Optional parentComment = Optional.empty(); + if (indexCommentForm.postKey() != null) { + parentComment = commentRepository.findByPath(indexCommentForm.parentCommentKey()); + } + + Optional community = Optional.empty(); + + if (indexCommentForm.communityKey() != null) { + community = communityRepository.findCommunityByTitleSlug(indexCommentForm.communityKey()); + } + + Optional post = Optional.empty(); + + if (indexCommentForm.postKey() != null) { + post = postRepository.findByTitleSlug(indexCommentForm.postKey()); + } + + com.sublinks.sublinksapi.person.enums.SortType sortType; + if (indexCommentForm.sortType() != null) { + sortType = conversionService.convert(indexCommentForm.sortType(), SortType.class); + } else if (person != null && person.getDefaultSortType() != null) { + sortType = person.getDefaultSortType(); + } else { + sortType = SortType.New; + } + + ListingType sublinksListingType; + + if (indexCommentForm.listingType() != null) { + sublinksListingType = conversionService.convert(indexCommentForm.listingType(), + ListingType.class); + } else if (person != null && person.getDefaultListingType() != null) { + sublinksListingType = person.getDefaultListingType(); + } else { + sublinksListingType = ListingType.Local; + } + + CommentSearchCriteria.CommentSearchCriteriaBuilder commentSearchCriteria = CommentSearchCriteria.builder() + .search(indexCommentForm.search()) + .page(indexCommentForm.page()) + .commentSortType(conversionService.convert(sortType, CommentSortType.class)) + .perPage(indexCommentForm.perPage()) + .savedOnly(indexCommentForm.savedOnly()) + .listingType(sublinksListingType) + .community(community.orElse(null)) + .parent(parentComment.orElse(null)) + .post(post.orElse(null)) + .maxDepth(Math.max( + Math.min(indexCommentForm.maxDepth() != null ? indexCommentForm.maxDepth() : 3, 5), 0)) + .person(person); + + return buildReplies(commentRepository.allCommentsBySearchCriteria(commentSearchCriteria.build()) + .stream() + .map(comment -> conversionService.convert(comment, CommentResponse.class)) + .toList()); + } + + /** + * Retrieves a comment based on the provided key. + * + * @param key The key of the comment to retrieve. + * @param person The Person object representing the user performing the operation. + * @return A CommentResponse object representing the retrieved comment. + * @throws ResponseStatusException If the user does not have permission to read the comment or the + * comment is not found. + */ + public CommentResponse show(String key, Person person) { + + rolePermissionService.isPermitted(person, RolePermissionCommentTypes.READ_COMMENT, + () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized")); + + return commentRepository.findByPath(key) + .map(comment -> conversionService.convert(comment, CommentResponse.class)) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "comment_not_found")); + } + + /** + * Creates a new comment based on the provided data. + * + * @param createComment The object representing the data for creating a comment. + * @param person The person creating the comment. + * @return A CommentResponse object representing the created comment. + * @throws ResponseStatusException If the user does not have permission to create a comment, the + * post or parent comment is not found, or the language is not + * found. + */ + public CommentResponse createComment(CreateComment createComment, Person person) { + + rolePermissionService.isPermitted(person, RolePermissionCommentTypes.CREATE_COMMENT, + () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized")); + + final Post parentPost = postRepository.findByTitleSlugAndRemovedStateIs(createComment.postKey(), + RemovedState.NOT_REMOVED) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "post_not_found")); + + final Comment parentComment = commentRepository.findByPath(createComment.parentKey()) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "comment_not_found")); + + Language language; + try { + language = languageRepository.findLanguageByCode( + createComment.languageKey() != null ? createComment.languageKey() + : languageService.getLanguageOfCommunityOrUndefined(parentPost.getCommunity()) + .getCode()); + } catch (Exception e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "language_not_found"); + } + Comment comment = Comment.builder() + .commentBody(createComment.body()) + .person(person) + .post(parentPost) + .path(parentComment.getPath()) + .language(language) + .build(); + + commentService.createComment(comment, parentComment); + return conversionService.convert(comment, CommentResponse.class); + } + + /** + * Updates a comment based on the provided comment key, UpdateComment form, and user. + * + * @param commentKey The key of the comment to be updated. + * @param updateCommentForm The UpdateComment object representing the updated comment data. + * @param person The Person object representing the user performing the update. + * @return A CommentResponse object representing the updated comment. + * @throws ResponseStatusException If the user is not authorized to update the comment, the + * comment is not found, or the language is not found. + */ + public CommentResponse updateComment(final String commentKey, UpdateComment updateCommentForm, + Person person) + { + + rolePermissionService.isPermitted(person, RolePermissionCommentTypes.UPDATE_COMMENT, + () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized")); + + Comment comment = commentRepository.findByPath(commentKey) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "comment_not_found")); + + if (!Objects.equals(comment.getPerson() + .getId(), person.getId())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized"); + } + + if (updateCommentForm.featured() != null) { + comment.setFeatured(updateCommentForm.featured()); + } + if (updateCommentForm.body() != null) { + comment.setCommentBody(updateCommentForm.body()); + } + if (updateCommentForm.languageKey() != null) { + Language language; + try { + language = languageRepository.findLanguageByCode(updateCommentForm.languageKey()); + } catch (Exception e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "language_not_found"); + } + comment.setLanguage(language); + } + + commentService.updateComment(comment); + return conversionService.convert(comment, CommentResponse.class); + } + + + /** + * Removes a comment based on the provided key, comment pin form, and person. + * + * @param key The key of the comment to be removed. + * @param removeCommentForm The CommentRemove object representing the pin form data. + * @param person The Person object representing the user performing the removal. + * @return A CommentResponse object representing the removed comment. + * @throws ResponseStatusException If the user does not have permission to pin the comment or the + * comment is not found. + */ + public CommentResponse remove(String key, RemoveComment removeCommentForm, Person person) { + + rolePermissionService.isPermitted(person, RolePermissionCommentTypes.REMOVE_COMMENT, + () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized")); + + Comment comment = commentRepository.findByPath(key) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "comment_not_found")); + + comment.setRemovedState( + removeCommentForm.remove() != null ? removeCommentForm.remove() ? RemovedState.REMOVED + : RemovedState.NOT_REMOVED : RemovedState.REMOVED); + commentService.updateComment(comment); + // @todo: modlog + + return conversionService.convert(comment, CommentResponse.class); + } + + /** + * Deletes a comment based on the provided key, delete comment form, and person. + * + * @param key The key of the comment to be deleted. + * @param deleteCommentForm The DeleteComment object representing the delete comment form data. + * @param person The Person object representing the user performing the deletion. + * @return A CommentResponse object representing the deleted comment. + * @throws ResponseStatusException If the user does not have permission to delete the comment or + * the comment is not found. + */ + public CommentResponse delete(String key, DeleteComment deleteCommentForm, Person person) { + + rolePermissionService.isPermitted(person, RolePermissionCommentTypes.DELETE_COMMENT, + () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized")); + + Comment comment = commentRepository.findByPath(key) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "comment_not_found")); + + if (!Objects.equals(comment.getPerson() + .getId(), person.getId())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized"); + } + + comment.setDeleted(deleteCommentForm.remove()); + commentService.updateComment(comment); + // @todo: modlog + + return conversionService.convert(comment, CommentResponse.class); + } + + /** + * Pins or unpins a comment and returns the updated CommentResponse object. + * + * @param key The key of the comment. + * @param pinCommentForm The CommentPin object representing the pin form data. + * @param person The Person object representing the user performing the + * pinning/unpinning. + * @return A CommentResponse object representing the updated comment. + * @throws ResponseStatusException If the comment is not found or the user does not have + * permission to perform the operation. + */ + public CommentResponse pin(String key, PinComment pinCommentForm, Person person) { + + rolePermissionService.isPermitted(person, RolePermissionCommentTypes.MODERATOR_PIN_COMMENT, + () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized")); + + Comment comment = commentRepository.findByPath(key) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "comment_not_found")); + if (linkPersonCommunityService.hasAnyLinkOrAdmin(person, comment.getCommunity(), + List.of(LinkPersonCommunityType.moderator, LinkPersonCommunityType.owner))) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized"); + } + + comment.setFeatured( + pinCommentForm.pin() != null ? pinCommentForm.pin() : !comment.isFeatured()); + + commentService.updateComment(comment); + + return conversionService.convert(comment, CommentResponse.class); + } + + /** + * Retrieves a {@link CommentAggregateResponse} based on the provided comment key and person. + * + * @param commentKey The key of the comment to retrieve the aggregate for. + * @param person The {@link Person} object representing the user performing the operation. + * @return A {@link CommentAggregateResponse} object representing the retrieved comment aggregate. + * @throws ResponseStatusException If the comment is not found. + */ + public CommentAggregateResponse aggregate(String commentKey, Person person) { + + rolePermissionService.isPermitted(person, RolePermissionCommentTypes.READ_COMMENT_AGGREGATE, + () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized")); + + return commentAggregateRepository.findByComment_Path(commentKey) + .map(commentAggregate -> conversionService.convert(commentAggregate, + CommentAggregateResponse.class)) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "comment_not_found")); + } + + // @todo refactor this fine piece of code. + + /** + * Builds a tree structure of comment replies based on the provided list of CommentResponse + * objects. + *

+ * If a comment has no parent or the parent is not found in the list of comments, it is considered + * orphans and added to the root of the tree. + * + * @param commentResponses The list of CommentResponse objects representing the comments. + * @return A list of CommentResponse objects representing the comments organized in a tree + * structure. + */ + public List buildReplies(List commentResponses) { + + List rootComments = commentResponses.stream() + .filter( + commentResponse -> commentResponse.getParentKey() == null || commentResponses.stream() + .noneMatch(commentResponse1 -> commentResponse1.key() + .equals(commentResponse.getParentKey()))) + .toList(); + + List commentTree = new ArrayList<>(rootComments.stream() + .map(commentResponse -> buildTree(commentResponse, commentResponses)) + .toList()); + + List leftOverComments = commentResponses.stream() + .filter(commentResponse -> rootComments.stream() + .noneMatch(commentResponse1 -> commentResponse1.key() + .equals(commentResponse.key()))) + .toList(); + + commentTree.addAll(leftOverComments); + + return commentTree; + } + + /** + * Builds a tree structure of comment replies based on the provided list of CommentResponse + * objects. + * + * @param commentResponse The CommentResponse object representing the root comment. + * @param commentResponses The list of CommentResponse objects representing all the comments. + * @return A CommentResponse object representing the root comment with its replies organized in a + * tree structure. + */ + private CommentResponse buildTree(final CommentResponse commentResponse, + List commentResponses) + { + + List replies = commentResponses.stream() + .filter(commentResponse1 -> Objects.equals(commentResponse1.getParentKey(), + commentResponse.key())) + .toList(); + + return commentResponse.toBuilder() + .replies(replies.stream() + .map(commentResponse1 -> buildTree(commentResponse1, commentResponses)) + .toList()) + .build(); + + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/common/controllers/AbstractSublinksApiController.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/common/controllers/AbstractSublinksApiController.java new file mode 100644 index 000000000..b68955fa3 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/common/controllers/AbstractSublinksApiController.java @@ -0,0 +1,74 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.common.controllers; + +import com.sublinks.sublinksapi.api.sublinks.v1.authentication.SublinksJwtPerson; +import com.sublinks.sublinksapi.person.entities.Person; +import java.util.Optional; +import java.util.function.Supplier; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +public abstract class AbstractSublinksApiController { + + /** + * Get the person object or throw a 400 Bad Request exception. + * + * @param principal JwtPerson object that contains the person as it's principal + * @return Person + * @throws ResponseStatusException Exception thrown when Person not present + */ + public Person getPersonOrThrowBadRequest(SublinksJwtPerson principal) + throws ResponseStatusException + { + + return Optional.ofNullable(principal) + .map(p -> (Person) p.getPrincipal()) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.BAD_REQUEST)); + } + + /** + * Get the person object or throw a 400 Bad Request exception. + * + * @param principal JwtPerson object that contains the person as it's principal + * @return Person + * @throws ResponseStatusException Exception thrown when Person not present + */ + public Person getPersonOrThrow(SublinksJwtPerson principal, + Supplier exceptionSupplier) throws X + { + + return Optional.ofNullable(principal) + .map(p -> (Person) p.getPrincipal()) + .orElseThrow( + exceptionSupplier); + } + + + /** + * Get the person object or throw a 401 Unauthorized exception. + * + * @param principal JwtPerson object that contains the person as it's principal + * @return Person + * @throws ResponseStatusException Exception thrown when Person not present + */ + public Person getPersonOrThrowUnauthorized(SublinksJwtPerson principal) + throws ResponseStatusException + { + + return Optional.ofNullable(principal) + .map(p -> (Person) p.getPrincipal()) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.UNAUTHORIZED)); + } + + public Person getPerson(SublinksJwtPerson principal) { + + return getOptionalPerson(principal).orElse(null); + } + + public Optional getOptionalPerson(SublinksJwtPerson principal) { + + return Optional.ofNullable(principal) + .map(p -> (Person) p.getPrincipal()); + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/common/enums/SortOrder.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/common/enums/SortOrder.java new file mode 100644 index 000000000..ff1bb00a6 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/common/enums/SortOrder.java @@ -0,0 +1,6 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.common.enums; + +public enum SortOrder { + Asc, + Desc, +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/common/enums/SortType.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/common/enums/SortType.java new file mode 100644 index 000000000..300445962 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/common/enums/SortType.java @@ -0,0 +1,23 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.common.enums; + +public enum SortType { + Active, + Hot, + New, + Old, + TopDay, + TopWeek, + TopMonth, + TopYear, + TopAll, + MostComments, + NewComments, + TopHour, + TopSixHour, + TopTwelveHour, + TopThreeMonths, + TopSixMonths, + TopNineMonths, + Controversial, + Scaled, +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/common/enums/SublinksListingType.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/common/enums/SublinksListingType.java new file mode 100644 index 000000000..0091b83a6 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/common/enums/SublinksListingType.java @@ -0,0 +1,9 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.common.enums; + +public enum SublinksListingType { + All, + Local, + Subscribed, + ModeratorView, +} + diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/common/mappers/EntityListingTypeMapper.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/common/mappers/EntityListingTypeMapper.java new file mode 100644 index 000000000..3b4ba3092 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/common/mappers/EntityListingTypeMapper.java @@ -0,0 +1,25 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.common.mappers; + +import com.sublinks.sublinksapi.api.sublinks.v1.common.enums.SublinksListingType; +import com.sublinks.sublinksapi.person.enums.ListingType; +import org.mapstruct.Mapper; +import org.mapstruct.MappingConstants; +import org.springframework.core.convert.converter.Converter; +import org.springframework.lang.Nullable; + +@Mapper(componentModel = MappingConstants.ComponentModel.SPRING) +public class EntityListingTypeMapper implements + Converter { + + @Nullable + @Override + public SublinksListingType convert( + ListingType listingType) + { + + return SublinksListingType.valueOf( + listingType.name()); + } + + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/common/mappers/LemmyListingTypeMapper.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/common/mappers/LemmyListingTypeMapper.java new file mode 100644 index 000000000..9a1d2e9a6 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/common/mappers/LemmyListingTypeMapper.java @@ -0,0 +1,24 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.common.mappers; + +import com.sublinks.sublinksapi.api.lemmy.v3.enums.ListingType; +import com.sublinks.sublinksapi.api.sublinks.v1.common.enums.SublinksListingType; +import org.mapstruct.Mapper; +import org.mapstruct.MappingConstants; +import org.springframework.core.convert.converter.Converter; +import org.springframework.lang.Nullable; + +@Mapper(componentModel = MappingConstants.ComponentModel.SPRING) +public class LemmyListingTypeMapper implements + Converter { + + @Nullable + @Override + public ListingType convert( + SublinksListingType sublinksListingType) + { + + return ListingType.valueOf(sublinksListingType.name()); + } + + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/common/mappers/SublinksListingTypeMapper.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/common/mappers/SublinksListingTypeMapper.java new file mode 100644 index 000000000..11f612bb6 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/common/mappers/SublinksListingTypeMapper.java @@ -0,0 +1,24 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.common.mappers; + +import com.sublinks.sublinksapi.api.sublinks.v1.common.enums.SublinksListingType; +import org.mapstruct.Mapper; +import org.mapstruct.MappingConstants; +import org.springframework.core.convert.converter.Converter; +import org.springframework.lang.Nullable; + +@Mapper(componentModel = MappingConstants.ComponentModel.SPRING) +public class SublinksListingTypeMapper implements + Converter { + + @Nullable + @Override + public SublinksListingType convert( + com.sublinks.sublinksapi.api.lemmy.v3.enums.ListingType listingType) + { + + return SublinksListingType.valueOf( + listingType.name()); + } + + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/common/mappers/SublinksSortTypeMapper.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/common/mappers/SublinksSortTypeMapper.java new file mode 100644 index 000000000..a52c60f46 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/common/mappers/SublinksSortTypeMapper.java @@ -0,0 +1,21 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.common.mappers; + +import com.sublinks.sublinksapi.api.lemmy.v3.enums.SortType; +import org.mapstruct.Mapper; +import org.mapstruct.MappingConstants; +import org.springframework.core.convert.converter.Converter; +import org.springframework.lang.Nullable; + +@Mapper(componentModel = MappingConstants.ComponentModel.SPRING) +public class SublinksSortTypeMapper implements + Converter { + + @Nullable + @Override + public SortType convert( + com.sublinks.sublinksapi.api.sublinks.v1.common.enums.SortType listingType) + { + + return SortType.valueOf(listingType.name()); + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/common/models/RequestResponse.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/common/models/RequestResponse.java new file mode 100644 index 000000000..4a46bf055 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/common/models/RequestResponse.java @@ -0,0 +1,10 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.common.models; + +import lombok.Builder; + +@Builder +public record RequestResponse( + Boolean success, + String error) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/controllers/SublinksCommunityAggregationController.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/controllers/SublinksCommunityAggregationController.java new file mode 100644 index 000000000..6e40bb9d7 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/controllers/SublinksCommunityAggregationController.java @@ -0,0 +1,45 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.community.controllers; + +import com.sublinks.sublinksapi.api.sublinks.v1.authentication.SublinksJwtPerson; +import com.sublinks.sublinksapi.api.sublinks.v1.common.controllers.AbstractSublinksApiController; +import com.sublinks.sublinksapi.api.sublinks.v1.community.models.CommunityAggregateResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.community.services.SublinksCommunityService; +import com.sublinks.sublinksapi.authorization.services.RolePermissionService; +import com.sublinks.sublinksapi.community.repositories.CommunityAggregateRepository; +import com.sublinks.sublinksapi.person.entities.Person; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.Optional; +import lombok.AllArgsConstructor; +import org.springframework.core.convert.ConversionService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@AllArgsConstructor +@RequestMapping("/api/v1/community/{key}/aggregate") +@Tag(name = "Sublinks Community Aggregation", description = "Community Aggregation API") +public class SublinksCommunityAggregationController extends AbstractSublinksApiController { + + private final CommunityAggregateRepository communityAggregateRepository; + private final ConversionService conversionService; + private final RolePermissionService rolePermissionService; + private final SublinksCommunityService sublinksCommunityService; + + @Operation(summary = "Get a community aggregate") + @GetMapping + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public CommunityAggregateResponse show(@PathVariable final String key, + final SublinksJwtPerson sublinksJwtPerson) + { + + final Optional person = getOptionalPerson(sublinksJwtPerson); + + return sublinksCommunityService.showAggregate(key, person.orElse(null)); + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/controllers/SublinksCommunityController.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/controllers/SublinksCommunityController.java new file mode 100644 index 000000000..36ecd0623 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/controllers/SublinksCommunityController.java @@ -0,0 +1,104 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.community.controllers; + +import com.sublinks.sublinksapi.api.sublinks.v1.authentication.SublinksJwtPerson; +import com.sublinks.sublinksapi.api.sublinks.v1.common.controllers.AbstractSublinksApiController; +import com.sublinks.sublinksapi.api.sublinks.v1.community.models.CommunityResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.community.models.CreateCommunity; +import com.sublinks.sublinksapi.api.sublinks.v1.community.models.DeleteCommunity; +import com.sublinks.sublinksapi.api.sublinks.v1.community.models.IndexCommunity; +import com.sublinks.sublinksapi.api.sublinks.v1.community.models.UpdateCommunity; +import com.sublinks.sublinksapi.api.sublinks.v1.community.services.SublinksCommunityService; +import com.sublinks.sublinksapi.person.entities.Person; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.List; +import java.util.Optional; +import lombok.AllArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@AllArgsConstructor +@RequestMapping("/api/v1/community") +@Tag(name = "Sublinks Community", description = "Community API") +public class SublinksCommunityController extends AbstractSublinksApiController { + + private final SublinksCommunityService sublinksCommunityService; + + @Operation(summary = "Get a list of communities") + @GetMapping + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public List index( + @RequestParam(required = false) final Optional indexCommunityParam, + final SublinksJwtPerson sublinksJwtPerson) + { + + final Optional person = getOptionalPerson(sublinksJwtPerson); + + return sublinksCommunityService.index(indexCommunityParam.orElse(IndexCommunity.builder() + .build()), person.orElse(null)); + + } + + @Operation(summary = "Get a specific community") + @GetMapping("/{key}") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public CommunityResponse show(@PathVariable final String key, + final SublinksJwtPerson sublinksJwtPerson) + { + + final Optional person = getOptionalPerson(sublinksJwtPerson); + + return sublinksCommunityService.show(key, person.orElse(null)); + } + + @Operation(summary = "Create a new community") + @PostMapping + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public CommunityResponse create(@RequestBody @Valid final CreateCommunity createCommunity, + final SublinksJwtPerson sublinksJwtPerson) + { + + final Person person = getPersonOrThrowUnauthorized(sublinksJwtPerson); + + return sublinksCommunityService.createCommunity(createCommunity, person); + } + + @Operation(summary = "Update an community") + @PostMapping("/{key}") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public CommunityResponse update(@PathVariable final String key, + @RequestBody @Valid UpdateCommunity updateCommunityForm, final SublinksJwtPerson principal) + { + + final Person person = getPersonOrThrowUnauthorized(principal); + + return sublinksCommunityService.updateCommunity(key, updateCommunityForm, person); + } + + @Operation(summary = "Delete a community") + @DeleteMapping("/{key}") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public CommunityResponse delete(@PathVariable final String key, + final DeleteCommunity deleteCommunityParam, final SublinksJwtPerson sublinksJwtPerson) + { + + final Person person = getPersonOrThrowUnauthorized(sublinksJwtPerson); + + return sublinksCommunityService.delete(key, deleteCommunityParam, person); + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/controllers/SublinksCommunityModerationController.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/controllers/SublinksCommunityModerationController.java new file mode 100644 index 000000000..79a95e858 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/controllers/SublinksCommunityModerationController.java @@ -0,0 +1,217 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.community.controllers; + +import com.sublinks.sublinksapi.api.sublinks.v1.authentication.SublinksJwtPerson; +import com.sublinks.sublinksapi.api.sublinks.v1.common.controllers.AbstractSublinksApiController; +import com.sublinks.sublinksapi.api.sublinks.v1.common.models.RequestResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.community.models.CommunityResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.community.models.moderation.CommunityBanPerson; +import com.sublinks.sublinksapi.api.sublinks.v1.community.models.moderation.CommunityModeratorResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.community.models.moderation.PurgeCommunity; +import com.sublinks.sublinksapi.api.sublinks.v1.community.models.moderation.RemoveCommunity; +import com.sublinks.sublinksapi.api.sublinks.v1.community.services.SublinksCommunityService; +import com.sublinks.sublinksapi.api.sublinks.v1.person.models.PersonResponse; +import com.sublinks.sublinksapi.authorization.enums.RolePermissionCommunityTypes; +import com.sublinks.sublinksapi.authorization.services.RolePermissionService; +import com.sublinks.sublinksapi.community.entities.Community; +import com.sublinks.sublinksapi.community.repositories.CommunityRepository; +import com.sublinks.sublinksapi.person.entities.Person; +import com.sublinks.sublinksapi.person.enums.LinkPersonCommunityType; +import com.sublinks.sublinksapi.person.repositories.PersonRepository; +import com.sublinks.sublinksapi.person.services.LinkPersonCommunityService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.List; +import java.util.Optional; +import lombok.AllArgsConstructor; +import org.springframework.core.convert.ConversionService; +import org.springframework.http.HttpStatus; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@AllArgsConstructor +@RequestMapping("/api/v1/community/{key}/moderation") +@Tag(name = "Sublinks Community Moderation", description = "Community Moderation API") +public class SublinksCommunityModerationController extends AbstractSublinksApiController { + + private final LinkPersonCommunityService linkPersonCommunityService; + private final SublinksCommunityService sublinksCommunityService; + private final CommunityRepository communityRepository; + private final PersonRepository personRepository; + private final ConversionService conversionService; + private final RolePermissionService rolePermissionService; + + @Operation(summary = "Remove a community") + @PostMapping("/remove") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public CommunityResponse remove(@PathVariable final String key, + @RequestBody RemoveCommunity removeCommunityForm, SublinksJwtPerson sublinksJwtPerson) + { + + final Person person = getPersonOrThrowUnauthorized(sublinksJwtPerson); + + return sublinksCommunityService.remove(key, removeCommunityForm, person); + } + + @Operation(summary = "Purge a community") + @PostMapping("/purge") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public RequestResponse purge(@PathVariable final String key, + @RequestBody @Valid PurgeCommunity purgeCommunityForm, SublinksJwtPerson sublinksJwtPerson) + { + + final Person person = getPersonOrThrowUnauthorized(sublinksJwtPerson); + + // @todo: Implement purge + + return RequestResponse.builder() + .success(true) + .build(); + } + + + @Operation(summary = "Get moderators of the community") + @GetMapping("/moderators") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public List show(@PathVariable final String key, + final SublinksJwtPerson sublinksJwtPerson) + { + + Optional person = getOptionalPerson(sublinksJwtPerson); + + rolePermissionService.isPermitted(person.orElse(null), + RolePermissionCommunityTypes.READ_COMMUNITY_MODERATORS, () -> { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized"); + }); + + return sublinksCommunityService.getCommunityModerators(key, person.orElse(null)) + .stream() + .map(communityModerator -> conversionService.convert(communityModerator, + CommunityModeratorResponse.class)) + .toList(); + } + + @Operation(summary = "Add a moderator to the community") + @PostMapping("/moderator/add/{personKey}") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public List add(@PathVariable final String key, + @PathVariable final String personKey, final SublinksJwtPerson sublinksJwtPerson) + { + + final Person person = getPersonOrThrowUnauthorized(sublinksJwtPerson); + + final Community community = communityRepository.findCommunityByTitleSlug(key) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "community_not_found")); + + if (!(linkPersonCommunityService.hasAnyLinkOrAdmin(person, community, + List.of(LinkPersonCommunityType.moderator, LinkPersonCommunityType.owner)) + && rolePermissionService.isPermitted(person, + RolePermissionCommunityTypes.MODERATOR_ADD_MODERATOR)) + && !rolePermissionService.isPermitted(person, + RolePermissionCommunityTypes.ADMIN_ADD_COMMUNITY_MODERATOR)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized"); + } + + final Person newModerator = personRepository.findOneByNameIgnoreCase(personKey) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "person_not_found")); + + if (linkPersonCommunityService.hasAnyLinkOrAdmin(newModerator, community, + List.of(LinkPersonCommunityType.moderator, LinkPersonCommunityType.owner))) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "person_already_moderator"); + } + + linkPersonCommunityService.createLinkPersonCommunityLink(community, newModerator, + LinkPersonCommunityType.moderator); + + return sublinksCommunityService.getCommunityModerators(key, person) + .stream() + .map(communityModerator -> conversionService.convert(communityModerator, + CommunityModeratorResponse.class)) + .toList(); + } + + @Operation(summary = "Remove a moderator from the community") + @PostMapping("/moderator/remove/{personKey}") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public List remove(@PathVariable final String key, + @PathVariable final String personKey) + { + + final Community community = communityRepository.findCommunityByTitleSlug(key) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "community_not_found")); + + final Person person = personRepository.findOneByNameIgnoreCase(personKey) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "person_not_found")); + + if (!linkPersonCommunityService.hasAnyLinkOrAdmin(person, community, + List.of(LinkPersonCommunityType.moderator, LinkPersonCommunityType.owner))) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized"); + } + + linkPersonCommunityService.deleteLink(community, person, LinkPersonCommunityType.moderator); + + return sublinksCommunityService.getCommunityModerators(key, person) + .stream() + .map(communityModerator -> conversionService.convert(communityModerator, + CommunityModeratorResponse.class)) + .toList(); + } + + @Operation(summary = "Ban/Unban a user from a community") + @PostMapping("/ban/{personKey}") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public PersonResponse ban(@PathVariable final String key, @PathVariable final String personKey, + CommunityBanPerson communityBanPersonForm, final SublinksJwtPerson sublinksJwtPerson) + { + + final Person person = getPersonOrThrowUnauthorized(sublinksJwtPerson); + + final Community community = communityRepository.findCommunityByTitleSlug(key) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "community_not_found")); + + if (!linkPersonCommunityService.hasAnyLinkOrAdmin(person, community, + List.of(LinkPersonCommunityType.moderator, LinkPersonCommunityType.owner))) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized"); + } + + final Person bannedPerson = sublinksCommunityService.banPerson(key, personKey, person, + communityBanPersonForm); + + return conversionService.convert(bannedPerson, PersonResponse.class); + } + + + @Operation(summary = "Get banned users from a community") + @GetMapping("/banned") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public List banned(@PathVariable final String key) { + + final Community community = communityRepository.findCommunityByTitleSlug(key) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "community_not_found")); + + return linkPersonCommunityService.getLinkPersonCommunitiesByCommunityAndPersonAndLinkTypeIsIn( + community, List.of(LinkPersonCommunityType.banned)) + .stream() + .map(person -> conversionService.convert(person, PersonResponse.class)) + .toList(); + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/enums/SublinksPersonCommunityType.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/enums/SublinksPersonCommunityType.java new file mode 100644 index 000000000..903b85201 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/enums/SublinksPersonCommunityType.java @@ -0,0 +1,10 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.community.enums; + +public enum SublinksPersonCommunityType { + owner, + moderator, + follower, + pending_follow, + blocked, + banned +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/mappers/SublinksCommunityAggregatesResponseMapper.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/mappers/SublinksCommunityAggregatesResponseMapper.java new file mode 100644 index 000000000..23c4b95f2 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/mappers/SublinksCommunityAggregatesResponseMapper.java @@ -0,0 +1,27 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.community.mappers; + +import com.sublinks.sublinksapi.api.sublinks.v1.community.models.CommunityAggregateResponse; +import com.sublinks.sublinksapi.community.entities.CommunityAggregate; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingConstants; +import org.springframework.core.convert.converter.Converter; +import org.springframework.lang.Nullable; + +@Mapper(componentModel = MappingConstants.ComponentModel.SPRING) +public abstract class SublinksCommunityAggregatesResponseMapper implements + Converter { + + @Override + @Mapping(target = "communityKey", source = "communityAggregate.community.titleSlug") + @Mapping(target = "subscriberCount", source = "communityAggregate.subscriberCount") + @Mapping(target = "postCount", source = "communityAggregate.postCount") + @Mapping(target = "commentCount", source = "communityAggregate.commentCount") + @Mapping(target = "activeDailyUserCount", source = "communityAggregate.activeDailyUserCount") + @Mapping(target = "activeWeeklyUserCount", source = "communityAggregate.activeWeeklyUserCount") + @Mapping(target = "activeMonthlyUserCount", source = "communityAggregate.activeMonthlyUserCount") + @Mapping(target = "activeHalfYearUserCount", + source = "communityAggregate.activeHalfYearUserCount") + public abstract CommunityAggregateResponse convert( + @Nullable CommunityAggregate communityAggregate); +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/mappers/SublinksCommunityMapper.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/mappers/SublinksCommunityMapper.java new file mode 100644 index 000000000..aa8a1ff2f --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/mappers/SublinksCommunityMapper.java @@ -0,0 +1,40 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.community.mappers; + +import com.sublinks.sublinksapi.api.sublinks.v1.community.models.CommunityResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.languages.mappers.SublinksLanguageMapper; +import com.sublinks.sublinksapi.api.sublinks.v1.utils.DateUtils; +import com.sublinks.sublinksapi.api.sublinks.v1.utils.mappers.OptionalStringMapper; +import com.sublinks.sublinksapi.community.entities.Community; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingConstants; +import org.springframework.core.convert.converter.Converter; +import org.springframework.lang.Nullable; + +@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, uses = { + SublinksLanguageMapper.class, OptionalStringMapper.class}) +public abstract class SublinksCommunityMapper implements Converter { + + @Override + @Mapping(target = "key", source = "community.titleSlug") + @Mapping(target = "title", source = "community.title") + @Mapping(target = "titleSlug", source = "community.titleSlug") + @Mapping(target = "description", source = "community.description") + @Mapping(target = "iconImageUrl", source = "community.iconImageUrl") + @Mapping(target = "bannerImageUrl", source = "community.bannerImageUrl") + @Mapping(target = "activityPubId", source = "community.activityPubId") + @Mapping(target = "languages", source = "community.languages") + @Mapping(target = "isLocal", source = "community.local") + @Mapping(target = "isDeleted", source = "community.deleted") + @Mapping(target = "isRemoved", source = "community.removed") + @Mapping(target = "isNsfw", source = "community.nsfw") + @Mapping(target = "restrictedToModerators", source = "community.postingRestrictedToMods") + @Mapping(target = "publicKey", source = "community.publicKey") + @Mapping(target = "createdAt", + source = "community.createdAt", + dateFormat = DateUtils.FRONT_END_DATE_FORMAT) + @Mapping(target = "updatedAt", + source = "community.updatedAt", + dateFormat = DateUtils.FRONT_END_DATE_FORMAT) + public abstract CommunityResponse convert(@Nullable Community community); +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/mappers/SublinksCommunityTypeMapper.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/mappers/SublinksCommunityTypeMapper.java new file mode 100644 index 000000000..fa109d2bb --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/mappers/SublinksCommunityTypeMapper.java @@ -0,0 +1,23 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.community.mappers; + +import com.sublinks.sublinksapi.api.sublinks.v1.community.enums.SublinksPersonCommunityType; +import com.sublinks.sublinksapi.api.sublinks.v1.community.models.moderation.CommunityModeratorResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.person.mappers.SublinksPersonMapper; +import com.sublinks.sublinksapi.person.entities.LinkPersonCommunity; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingConstants; +import org.springframework.core.convert.converter.Converter; +import org.springframework.lang.Nullable; + +@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, uses = {SublinksPersonMapper.class, + SublinksPersonCommunityType.class}) +public abstract class SublinksCommunityTypeMapper implements + Converter { + + @Override + @Mapping(target = "person", source = "linkPersonCommunity.person") + @Mapping(target = "linkType", source = "linkPersonCommunity.linkType") + public abstract CommunityModeratorResponse convert( + @Nullable LinkPersonCommunity linkPersonCommunity); +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/mappers/SublinksPersonCommunityTypeMapper.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/mappers/SublinksPersonCommunityTypeMapper.java new file mode 100644 index 000000000..37ecbb312 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/mappers/SublinksPersonCommunityTypeMapper.java @@ -0,0 +1,28 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.community.mappers; + +import com.sublinks.sublinksapi.api.sublinks.v1.community.enums.SublinksPersonCommunityType; +import com.sublinks.sublinksapi.api.sublinks.v1.languages.mappers.SublinksLanguageMapper; +import com.sublinks.sublinksapi.api.sublinks.v1.utils.mappers.OptionalStringMapper; +import com.sublinks.sublinksapi.person.enums.LinkPersonCommunityType; +import org.mapstruct.Mapper; +import org.mapstruct.MappingConstants; +import org.springframework.core.convert.converter.Converter; +import org.springframework.lang.Nullable; + +@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, uses = { + SublinksLanguageMapper.class, OptionalStringMapper.class}) +public abstract class SublinksPersonCommunityTypeMapper implements + Converter { + + @Override + public SublinksPersonCommunityType convert( + @Nullable LinkPersonCommunityType linkPersonCommunityType) + { + + if (linkPersonCommunityType == null) { + return null; + } + return SublinksPersonCommunityType.valueOf(linkPersonCommunityType.name()); + + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/models/CommunityAggregateResponse.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/models/CommunityAggregateResponse.java new file mode 100644 index 000000000..2ff88440d --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/models/CommunityAggregateResponse.java @@ -0,0 +1,38 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.community.models; + +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; + +public record CommunityAggregateResponse( + @Schema(description = "The key of the community", + requiredMode = RequiredMode.REQUIRED) String communityKey, + @Schema(description = "The subscriber count of the community", + requiredMode = RequiredMode.REQUIRED, + example = "0", + defaultValue = "0") Integer subscriberCount, + @Schema(description = "The post count of the community", + requiredMode = RequiredMode.REQUIRED, + example = "0", + defaultValue = "0") Integer postCount, + @Schema(description = "The comment count of the community", + requiredMode = RequiredMode.REQUIRED, + example = "0", + defaultValue = "0") Integer commentCount, + @Schema(description = "The active daily user count of the community", + requiredMode = RequiredMode.REQUIRED, + example = "0", + defaultValue = "0") Integer activeDailyUserCount, + @Schema(description = "The active weekly user count of the community", + requiredMode = RequiredMode.REQUIRED, + example = "0", + defaultValue = "0") Integer activeWeeklyUserCount, + @Schema(description = "The active monthly user count of the community", + requiredMode = RequiredMode.REQUIRED, + example = "0", + defaultValue = "0") Integer activeMonthlyUserCount, + @Schema(description = "The active quarterly user count of the community", + requiredMode = RequiredMode.REQUIRED, + example = "0", + defaultValue = "0") Integer activeHalfYearUserCount) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/models/CommunityResponse.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/models/CommunityResponse.java new file mode 100644 index 000000000..c2cec1f88 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/models/CommunityResponse.java @@ -0,0 +1,47 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.community.models; + +import com.sublinks.sublinksapi.api.sublinks.v1.languages.models.LanguageResponse; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import java.util.List; +import lombok.Builder; + +@Builder +public record CommunityResponse( + @Schema(description = "The key of the community", + requiredMode = RequiredMode.REQUIRED) String key, + @Schema(description = "The name of the community", + requiredMode = RequiredMode.REQUIRED) String title, + @Schema(description = "The slug of the community", + requiredMode = RequiredMode.REQUIRED) String titleSlug, + String description, + @Schema(description = "The icon image URL of the community", + requiredMode = RequiredMode.NOT_REQUIRED) String iconImageUrl, + String bannerImageUrl, + @Schema(description = "The activityPub ID of the community", + requiredMode = RequiredMode.REQUIRED) String activityPubId, + @Schema(description = "The activityPub followers count of the community", + requiredMode = RequiredMode.NOT_REQUIRED) List languages, + Boolean isLocal, + @Schema(description = "Is the community deleted", + requiredMode = RequiredMode.REQUIRED, + defaultValue = "false") Boolean isDeleted, + @Schema(description = "Is the community featured", + requiredMode = RequiredMode.REQUIRED, + defaultValue = "false") Boolean isRemoved, + @Schema(description = "Is the community nsfw", + requiredMode = RequiredMode.REQUIRED, + defaultValue = "false") Boolean isNsfw, + @Schema(description = "Is the community restricted to moderators", + requiredMode = RequiredMode.REQUIRED, + defaultValue = "false") Boolean restrictedToModerators, + @Schema(description = "Public key of the community", + requiredMode = RequiredMode.REQUIRED) String publicKey, + @Schema(description = "The creation date of the community", + requiredMode = RequiredMode.REQUIRED) String createdAt, + @Schema(description = "The update date of the community", + requiredMode = RequiredMode.REQUIRED) String updatedAt + +) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/models/CreateCommunity.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/models/CreateCommunity.java new file mode 100644 index 000000000..0c98fc3a1 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/models/CreateCommunity.java @@ -0,0 +1,17 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.community.models; + +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; + +public record CreateCommunity( + @Schema(description = "The title of the community") String title, + @Schema(description = "The title slug of the community") String titleSlug, + @Schema(description = "The description of the community") String description, + @Schema(description = "The icon image URL of the community", + requiredMode = RequiredMode.NOT_REQUIRED) String iconImageUrl, + @Schema(description = "The banner image URL of the community", + requiredMode = RequiredMode.NOT_REQUIRED) String bannerImageUrl, + @Schema(description = "The active status of the community") Boolean isNsfw, + @Schema(description = "The active status of the community") Boolean isPostingRestrictedToMods) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/models/DeleteCommunity.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/models/DeleteCommunity.java new file mode 100644 index 000000000..22423a5e0 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/models/DeleteCommunity.java @@ -0,0 +1,13 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.community.models; + +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; + +public record DeleteCommunity( + @Schema(description = "The reason for deleting the community", + requiredMode = RequiredMode.NOT_REQUIRED) String reason, + @Schema(description = "The boolean to remove the community", + requiredMode = RequiredMode.NOT_REQUIRED, + defaultValue = "true") Boolean remove) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/models/IndexCommunity.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/models/IndexCommunity.java new file mode 100644 index 000000000..88dd30490 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/models/IndexCommunity.java @@ -0,0 +1,41 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.community.models; + +import com.sublinks.sublinksapi.api.sublinks.v1.common.enums.SortType; +import com.sublinks.sublinksapi.api.sublinks.v1.common.enums.SublinksListingType; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import lombok.Builder; + +@Builder +public record IndexCommunity( + @Schema(description = "Search query", + example = "Search query", + requiredMode = RequiredMode.NOT_REQUIRED) String search, + @Schema(description = "Sort type", + example = "Hot", + requiredMode = RequiredMode.NOT_REQUIRED) SortType sortType, + @Schema(description = "Sublinks listing type", + example = "All", + requiredMode = RequiredMode.NOT_REQUIRED) SublinksListingType listingType, + @Schema(description = "Show NSFW", + example = "false", + defaultValue = "false", + requiredMode = RequiredMode.NOT_REQUIRED) Boolean showNsfw, + Integer perPage, + @Schema(description = "Page", + example = "1", + defaultValue = "1", + requiredMode = RequiredMode.NOT_REQUIRED) Integer page) { + + @Override + public Integer perPage() { + + return perPage != null ? perPage : 20; + } + + @Override + public Integer page() { + + return page != null ? page : 1; + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/models/UpdateCommunity.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/models/UpdateCommunity.java new file mode 100644 index 000000000..59eefa2fe --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/models/UpdateCommunity.java @@ -0,0 +1,20 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.community.models; + +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; + +public record UpdateCommunity( + @Schema(description = "The title of the community", + requiredMode = RequiredMode.NOT_REQUIRED) String title, + @Schema(description = "The description of the community", + requiredMode = RequiredMode.NOT_REQUIRED) String description, + @Schema(description = "The icon image URL of the community", + requiredMode = RequiredMode.NOT_REQUIRED) String iconImageUrl, + @Schema(description = "The banner image URL of the community", + requiredMode = RequiredMode.NOT_REQUIRED) String bannerImageUrl, + @Schema(description = "The active status of the community", + requiredMode = RequiredMode.NOT_REQUIRED) Boolean isNsfw, + @Schema(description = "Is the community restricted to moderators", + requiredMode = RequiredMode.NOT_REQUIRED) Boolean isPostingRestrictedToMods) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/models/moderation/CommunityBanPerson.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/models/moderation/CommunityBanPerson.java new file mode 100644 index 000000000..97009ab3f --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/models/moderation/CommunityBanPerson.java @@ -0,0 +1,7 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.community.models.moderation; + +public record CommunityBanPerson( + String reason, + Boolean ban) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/models/moderation/CommunityModeratorResponse.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/models/moderation/CommunityModeratorResponse.java new file mode 100644 index 000000000..05f633d07 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/models/moderation/CommunityModeratorResponse.java @@ -0,0 +1,12 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.community.models.moderation; + +import com.sublinks.sublinksapi.api.sublinks.v1.community.enums.SublinksPersonCommunityType; +import com.sublinks.sublinksapi.api.sublinks.v1.person.models.PersonResponse; +import lombok.Builder; + +@Builder +public record CommunityModeratorResponse( + PersonResponse person, + SublinksPersonCommunityType linkType) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/models/moderation/PurgeCommunity.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/models/moderation/PurgeCommunity.java new file mode 100644 index 000000000..31eebe3c7 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/models/moderation/PurgeCommunity.java @@ -0,0 +1,6 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.community.models.moderation; + +public record PurgeCommunity( + String reason) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/models/moderation/RemoveCommunity.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/models/moderation/RemoveCommunity.java new file mode 100644 index 000000000..41392d0da --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/models/moderation/RemoveCommunity.java @@ -0,0 +1,7 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.community.models.moderation; + +public record RemoveCommunity( + String reason, + Boolean remove) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/services/SublinksCommunityService.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/services/SublinksCommunityService.java new file mode 100644 index 000000000..65813b8c5 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/community/services/SublinksCommunityService.java @@ -0,0 +1,370 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.community.services; + +import com.sublinks.sublinksapi.api.lemmy.v3.enums.ListingType; +import com.sublinks.sublinksapi.api.lemmy.v3.enums.SortType; +import com.sublinks.sublinksapi.api.sublinks.v1.common.enums.SublinksListingType; +import com.sublinks.sublinksapi.api.sublinks.v1.community.models.CommunityAggregateResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.community.models.CommunityResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.community.models.CreateCommunity; +import com.sublinks.sublinksapi.api.sublinks.v1.community.models.DeleteCommunity; +import com.sublinks.sublinksapi.api.sublinks.v1.community.models.IndexCommunity; +import com.sublinks.sublinksapi.api.sublinks.v1.community.models.UpdateCommunity; +import com.sublinks.sublinksapi.api.sublinks.v1.community.models.moderation.CommunityBanPerson; +import com.sublinks.sublinksapi.api.sublinks.v1.community.models.moderation.RemoveCommunity; +import com.sublinks.sublinksapi.api.sublinks.v1.utils.ActorIdUtils; +import com.sublinks.sublinksapi.authorization.enums.RolePermissionCommunityTypes; +import com.sublinks.sublinksapi.authorization.services.RolePermissionService; +import com.sublinks.sublinksapi.community.entities.Community; +import com.sublinks.sublinksapi.community.models.CommunitySearchCriteria; +import com.sublinks.sublinksapi.community.repositories.CommunityRepository; +import com.sublinks.sublinksapi.community.services.CommunityService; +import com.sublinks.sublinksapi.instance.models.LocalInstanceContext; +import com.sublinks.sublinksapi.person.entities.LinkPersonCommunity; +import com.sublinks.sublinksapi.person.entities.Person; +import com.sublinks.sublinksapi.person.enums.LinkPersonCommunityType; +import com.sublinks.sublinksapi.person.repositories.PersonRepository; +import com.sublinks.sublinksapi.person.services.LinkPersonCommunityService; +import java.util.List; +import java.util.Optional; +import lombok.AllArgsConstructor; +import org.springframework.core.convert.ConversionService; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +@AllArgsConstructor +@Service +public class SublinksCommunityService { + + private final CommunityRepository communityRepository; + private final ConversionService conversionService; + private final CommunityService communityService; + private final LocalInstanceContext localInstanceContext; + private final RolePermissionService rolePermissionService; + private final LinkPersonCommunityService linkPersonCommunityService; + private final PersonRepository personRepository; + + public CommunityResponse show(String key, Person person) { + + rolePermissionService.isPermitted(person, RolePermissionCommunityTypes.READ_COMMUNITY, + () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized")); + + final Community community = communityRepository.findCommunityByTitleSlug(key) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "community_not_found")); + + return conversionService.convert(community, CommunityResponse.class); + } + + /** + * Retrieves a list of CommunityResponse objects based on the search criteria provided. + * + * @param indexCommunityForm The search criteria for retrieving community responses. + * @param person The person requesting the community responses. + * @return The list of CommunityResponse objects that match the search criteria. + */ + public List index(IndexCommunity indexCommunityForm, Person person) { + + rolePermissionService.isPermitted(person, RolePermissionCommunityTypes.READ_COMMUNITY, + () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized")); + + com.sublinks.sublinksapi.api.sublinks.v1.common.enums.SortType sortType = indexCommunityForm.sortType(); + + if (sortType == null) { + if (person != null && person.getDefaultSortType() != null) { + sortType = conversionService.convert(person.getDefaultSortType(), + com.sublinks.sublinksapi.api.sublinks.v1.common.enums.SortType.class); + } else { + sortType = com.sublinks.sublinksapi.api.sublinks.v1.common.enums.SortType.New; + } + } + + SublinksListingType sublinksListingType = indexCommunityForm.listingType(); + + if (sublinksListingType == null) { + if (person != null && person.getDefaultListingType() != null) { + sublinksListingType = conversionService.convert(person.getDefaultListingType(), + SublinksListingType.class); + } else if (localInstanceContext.instance() + .getInstanceConfig() + .getDefaultPostListingType() != null) { + sublinksListingType = conversionService.convert(localInstanceContext.instance() + .getInstanceConfig() + .getDefaultPostListingType(), SublinksListingType.class); + } else { + sublinksListingType = SublinksListingType.Local; + } + } + + boolean showNsfw = + (indexCommunityForm.showNsfw() != null && indexCommunityForm.showNsfw()) || (person != null + && person.isShowNsfw()); + + final CommunitySearchCriteria.CommunitySearchCriteriaBuilder criteria = CommunitySearchCriteria.builder() + .perPage(indexCommunityForm.perPage()) + .page(indexCommunityForm.page()) + .sortType(conversionService.convert(sortType, SortType.class)) + .listingType(conversionService.convert(sublinksListingType, ListingType.class)) + .showNsfw(showNsfw) + .search(indexCommunityForm.search()) + .person(person); + + final CommunitySearchCriteria communitySearchCriteria = criteria.build(); + + return communityRepository.allCommunitiesBySearchCriteria(communitySearchCriteria) + .stream() + .map(community -> conversionService.convert(community, CommunityResponse.class)) + .toList(); + } + + /** + * Creates a new community based on the provided community data and person. + * + * @param createCommunity The data for the new community. + * @param person The person creating the community. + * @return The response containing the newly created community. + * @throws ResponseStatusException If the community slug already exists or if the person is not + * authorized to create a community. + */ + public CommunityResponse createCommunity(CreateCommunity createCommunity, Person person) { + + final Optional oldCommunity = communityRepository.findCommunityByTitleSlug( + createCommunity.titleSlug()); + if (oldCommunity.isPresent()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "community_slug_already_exist"); + } + + if (rolePermissionService.isPermitted(person, RolePermissionCommunityTypes.CREATE_COMMUNITY)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized"); + } + + Community community = Community.builder() + .title(createCommunity.title()) + .titleSlug(createCommunity.titleSlug()) + .bannerImageUrl( + createCommunity.bannerImageUrl() != null ? createCommunity.bannerImageUrl() : null) + .iconImageUrl( + createCommunity.iconImageUrl() != null ? createCommunity.iconImageUrl() : null) + .isNsfw(createCommunity.isNsfw() != null ? createCommunity.isNsfw() : false) + .isPostingRestrictedToMods(createCommunity.isPostingRestrictedToMods()) + .description(createCommunity.description()) + .instance(localInstanceContext.instance()) + .build(); + communityService.createCommunity(community); + + return conversionService.convert(community, CommunityResponse.class); + } + + /** + * Updates a community based on the provided key, update form, and person. + * + * @param key The key of the community to update. + * @param updateCommunityForm The update form containing the new community data. + * @param person The person performing the update. + * @return The updated community response. + * @throws ResponseStatusException If the community is not found, or the person is not authorized + * to perform the update. + */ + public CommunityResponse updateCommunity(String key, final UpdateCommunity updateCommunityForm, + final Person person) + { + + String domain = ActorIdUtils.getActorDomain(key); + if (domain != null && domain.equals(localInstanceContext.instance() + .getDomain())) { + key = ActorIdUtils.getActorId(key); + } + Community community = communityRepository.findCommunityByTitleSlug(key) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "community_not_found")); + + if (!(linkPersonCommunityService.hasAnyLinkOrAdmin(person, community, + List.of(LinkPersonCommunityType.moderator, LinkPersonCommunityType.owner)) + && rolePermissionService.isPermitted(person, RolePermissionCommunityTypes.UPDATE_COMMUNITY)) + && !rolePermissionService.isPermitted(person, + RolePermissionCommunityTypes.ADMIN_UPDATE_COMMUNITY)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized"); + } + + if (updateCommunityForm.title() != null) { + community.setTitle(updateCommunityForm.title()); + } + if (updateCommunityForm.description() != null) { + community.setDescription(updateCommunityForm.description()); + } + if (updateCommunityForm.iconImageUrl() != null) { + community.setIconImageUrl(updateCommunityForm.iconImageUrl()); + } + if (updateCommunityForm.bannerImageUrl() != null) { + community.setBannerImageUrl(updateCommunityForm.bannerImageUrl()); + } + if (updateCommunityForm.isNsfw() != null) { + community.setNsfw(updateCommunityForm.isNsfw()); + } + if (updateCommunityForm.isPostingRestrictedToMods() != null) { + community.setPostingRestrictedToMods(updateCommunityForm.isPostingRestrictedToMods()); + } + + communityService.updateCommunity(community); + + return conversionService.convert(community, CommunityResponse.class); + } + + /** + * Retrieves the moderators of a community. + * + * @param key The key of the community. + * @return The list of moderators in the community. + * @throws ResponseStatusException If the community is not found. + */ + public List getCommunityModerators(String key, Person person) { + + rolePermissionService.isPermitted(person, + RolePermissionCommunityTypes.READ_COMMUNITY_MODERATORS, + () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized")); + + final Community community = communityRepository.findCommunityByTitleSlug(key) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "community_not_found")); + + return linkPersonCommunityService.getLinkPersonCommunitiesByCommunityAndPersonAndLinkTypeIsIn( + community, List.of(LinkPersonCommunityType.moderator, LinkPersonCommunityType.owner)) + .stream() + .toList(); + } + + /** + * Bans a person from a community. + * + * @param key The key of the community. + * @param personKey The key of the person to ban. + * @param person The person performing the ban action. + * @param communityBanPersonForm The form containing ban information. + * @return The banned person. + * @throws ResponseStatusException If the community or person is not found, or if the person is + * not authorized to perform the ban action. + */ + public Person banPerson(String key, String personKey, Person person, + CommunityBanPerson communityBanPersonForm) + { + + final Community community = communityRepository.findCommunityByTitleSlug(key) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "community_not_found")); + + final Person personToBan = personRepository.findOneByNameIgnoreCase(personKey) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "person_not_found")); + + if (communityBanPersonForm.ban() && linkPersonCommunityService.hasAnyLinkOrAdmin(personToBan, + community, List.of(LinkPersonCommunityType.banned))) { + return personToBan; + } + + if (!(linkPersonCommunityService.hasAnyLinkOrAdmin(person, community, + List.of(LinkPersonCommunityType.moderator, LinkPersonCommunityType.owner)) + && rolePermissionService.isPermitted(person, + RolePermissionCommunityTypes.MODERATOR_BAN_USER)) && !rolePermissionService.isPermitted( + person, RolePermissionCommunityTypes.ADMIN_BAN_USER)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized"); + } + + if (!communityBanPersonForm.ban()) { + linkPersonCommunityService.deleteLink(community, personToBan, LinkPersonCommunityType.banned); + } else { + linkPersonCommunityService.removeAnyLink(personToBan, community, + List.of(LinkPersonCommunityType.owner, LinkPersonCommunityType.moderator, + LinkPersonCommunityType.follower, LinkPersonCommunityType.pending_follow)); + + linkPersonCommunityService.createLinkPersonCommunityLink(community, personToBan, + LinkPersonCommunityType.banned); + } + // @todo: Modlog + + return personToBan; + } + + /** + * Removes a community based on the provided key, pin comment, and person. + * + * @param key The key of the community to pin. + * @param removeComment The comment specifying the reason for removal and whether to pin all + * content. + * @param person The person performing the removal. + * @return The response containing the removed community. + * @throws ResponseStatusException If the community is not found, or the person is not authorized + * to pin the community. + */ + public CommunityResponse remove(String key, RemoveCommunity removeComment, Person person) { + + final Community community = communityRepository.findCommunityByTitleSlug(key) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "community_not_found")); + + if (!rolePermissionService.isPermitted(person, + RolePermissionCommunityTypes.ADMIN_REMOVE_COMMUNITY)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized"); + } + + community.setRemoved(removeComment.remove() != null ? removeComment.remove() : true); + + communityService.updateCommunity(community); + // @todo: modlog + + return conversionService.convert(community, CommunityResponse.class); + } + + /** + * Deletes a community based on the provided key, delete form, and person. + * + * @param key The key of the community to delete. + * @param deleteCommunityForm The delete form specifying the reason for deletion and whether to + * pin the community. + * @param person The person performing the deletion. + * @return The response containing the deleted community. + * @throws ResponseStatusException If the community is not found, or the person is not authorized + * to delete the community. + */ + public CommunityResponse delete(String key, DeleteCommunity deleteCommunityForm, Person person) { + + final Community community = communityRepository.findCommunityByTitleSlug(key) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "community_not_found")); + + if (!(linkPersonCommunityService.hasAnyLinkOrAdmin(person, community, + List.of(LinkPersonCommunityType.owner)) && rolePermissionService.isPermitted(person, + RolePermissionCommunityTypes.MODERATOR_REMOVE_COMMUNITY))) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized"); + } + + community.setDeleted( + deleteCommunityForm.remove() != null ? deleteCommunityForm.remove() : true); + + communityService.updateCommunity(community); + // @todo: modlog + + return conversionService.convert(community, CommunityResponse.class); + } + + /** + * Retrieves the aggregated information of a community. + * + * @param communityKey The key of the community. + * @param person The person requesting the information. + * @return The CommunityAggregateResponse containing the aggregated information of the community. + * @throws ResponseStatusException If the person is not authorized to read the community + * aggregation or if the community is not found. + */ + public CommunityAggregateResponse showAggregate(String communityKey, Person person) { + + rolePermissionService.isPermitted(person, + RolePermissionCommunityTypes.READ_COMMUNITY_AGGREGATION, + () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized")); + + final Community community = communityRepository.findCommunityByTitleSlug(communityKey) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "community_not_found")); + + return conversionService.convert(community.getCommunityAggregate(), + CommunityAggregateResponse.class); + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/controllers/SublinksInstanceAggregateController.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/controllers/SublinksInstanceAggregateController.java new file mode 100644 index 000000000..01ad2adcb --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/controllers/SublinksInstanceAggregateController.java @@ -0,0 +1,32 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.instance.controllers; + +import com.sublinks.sublinksapi.api.sublinks.v1.common.controllers.AbstractSublinksApiController; +import com.sublinks.sublinksapi.api.sublinks.v1.instance.models.InstanceAggregateResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.instance.service.SublinksInstanceService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.AllArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@AllArgsConstructor +@RestController +@RequestMapping("api/v1/instance/{key}/aggregate") +@Tag(name = "Sublinks Instance Aggregate", description = "Instance Aggretate API") +public class SublinksInstanceAggregateController extends AbstractSublinksApiController { + + private final SublinksInstanceService sublinksInstanceService; + + @Operation(summary = "Get a specific instance Aggregate") + @GetMapping + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public InstanceAggregateResponse show(@PathVariable String key) { + + return sublinksInstanceService.showAggregate(key); + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/controllers/SublinksInstanceConfigController.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/controllers/SublinksInstanceConfigController.java new file mode 100644 index 000000000..0baeb1227 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/controllers/SublinksInstanceConfigController.java @@ -0,0 +1,56 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.instance.controllers; + +import com.sublinks.sublinksapi.api.sublinks.v1.authentication.SublinksJwtPerson; +import com.sublinks.sublinksapi.api.sublinks.v1.common.controllers.AbstractSublinksApiController; +import com.sublinks.sublinksapi.api.sublinks.v1.instance.models.InstanceConfigResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.instance.models.UpdateInstanceConfig; +import com.sublinks.sublinksapi.api.sublinks.v1.instance.service.SublinksInstanceService; +import com.sublinks.sublinksapi.person.entities.Person; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.AllArgsConstructor; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@AllArgsConstructor +@RestController +@RequestMapping("api/v1/instance/{key}/config") +@Tag(name = "Sublinks Instance Config", description = "Instance Config API") +public class SublinksInstanceConfigController extends AbstractSublinksApiController { + + private final SublinksInstanceService sublinksInstanceService; + + @Operation(summary = "Get a specific instance config") + @GetMapping + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public InstanceConfigResponse show(@PathVariable String key, + final SublinksJwtPerson sublinksJwtPerson) + { + + final Person person = getPersonOrThrowUnauthorized(sublinksJwtPerson); + + return sublinksInstanceService.showConfig(key, person); + } + + @Operation(summary = "Update an instance config") + @PostMapping + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public InstanceConfigResponse update(@PathVariable String key, + @RequestBody @Valid UpdateInstanceConfig updateInstanceConfigForm, + final SublinksJwtPerson sublinksJwtPerson) + { + + final Person person = getPersonOrThrowUnauthorized(sublinksJwtPerson); + + return sublinksInstanceService.updateConfig(key, updateInstanceConfigForm, person); + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/controllers/SublinksInstanceController.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/controllers/SublinksInstanceController.java new file mode 100644 index 000000000..d50cc284f --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/controllers/SublinksInstanceController.java @@ -0,0 +1,45 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.instance.controllers; + +import com.sublinks.sublinksapi.api.sublinks.v1.common.controllers.AbstractSublinksApiController; +import com.sublinks.sublinksapi.api.sublinks.v1.instance.models.IndexInstance; +import com.sublinks.sublinksapi.api.sublinks.v1.instance.models.InstanceResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.instance.service.SublinksInstanceService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import lombok.AllArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@AllArgsConstructor +@RestController +@RequestMapping("api/v1/instance") +@Tag(name = "Sublinks Instance", description = "Instance API") +public class SublinksInstanceController extends AbstractSublinksApiController { + + private final SublinksInstanceService sublinksInstanceService; + + @Operation(summary = "Get a list of instances") + @GetMapping + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public List index(final IndexInstance indexInstance) + { + + return sublinksInstanceService.index(indexInstance == null ? IndexInstance.builder() + .build() : indexInstance); + } + + @Operation(summary = "Get a specific instance") + @GetMapping("/{key}") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public InstanceResponse show(@PathVariable String key) { + + return sublinksInstanceService.show(key); + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/enums/ListingType.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/enums/ListingType.java new file mode 100644 index 000000000..827073cb9 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/enums/ListingType.java @@ -0,0 +1,8 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.instance.enums; + +public enum ListingType { + All, + Local, + Subscribed, + ModeratorView, +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/enums/RegistrationMode.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/enums/RegistrationMode.java new file mode 100644 index 000000000..20ab3f897 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/enums/RegistrationMode.java @@ -0,0 +1,7 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.instance.enums; + +public enum RegistrationMode { + Closed, + RequireApplication, + Open, +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/mappers/SublinksInstanceAggregateMapper.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/mappers/SublinksInstanceAggregateMapper.java new file mode 100644 index 000000000..58392f948 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/mappers/SublinksInstanceAggregateMapper.java @@ -0,0 +1,27 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.instance.mappers; + +import com.sublinks.sublinksapi.api.sublinks.v1.instance.models.InstanceAggregateResponse; +import com.sublinks.sublinksapi.instance.entities.InstanceAggregate; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingConstants; +import org.springframework.core.convert.converter.Converter; +import org.springframework.lang.Nullable; + +@Mapper(componentModel = MappingConstants.ComponentModel.SPRING) +public abstract class SublinksInstanceAggregateMapper implements + Converter { + + @Override + @Mapping(target = "instanceKey", source = "instanceAggregate.instance.domain") + @Mapping(target = "userCount", source = "instanceAggregate.userCount") + @Mapping(target = "postCount", source = "instanceAggregate.postCount") + @Mapping(target = "commentCount", source = "instanceAggregate.commentCount") + @Mapping(target = "communityCount", source = "instanceAggregate.communityCount") + @Mapping(target = "activeDailyUserCount", source = "instanceAggregate.activeDailyUserCount") + @Mapping(target = "activeWeeklyUserCount", source = "instanceAggregate.activeWeeklyUserCount") + @Mapping(target = "activeMonthlyUserCount", source = "instanceAggregate.activeMonthlyUserCount") + @Mapping(target = "activeHalfYearlyUserCount", + source = "instanceAggregate.activeHalfYearUserCount") + public abstract InstanceAggregateResponse convert(@Nullable InstanceAggregate instanceAggregate); +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/mappers/SublinksInstanceConfigMapper.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/mappers/SublinksInstanceConfigMapper.java new file mode 100644 index 000000000..969c002a8 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/mappers/SublinksInstanceConfigMapper.java @@ -0,0 +1,43 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.instance.mappers; + +import com.sublinks.sublinksapi.api.sublinks.v1.instance.models.InstanceConfigResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.utils.DateUtils; +import com.sublinks.sublinksapi.instance.entities.InstanceConfig; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingConstants; +import org.springframework.core.convert.converter.Converter; +import org.springframework.lang.Nullable; + +@Mapper(componentModel = MappingConstants.ComponentModel.SPRING) +public abstract class SublinksInstanceConfigMapper implements + Converter { + + @Override + @Mapping(target = "instanceKey", source = "instanceConfig.instance.domain") + @Mapping(target = "registrationMode", source = "instanceConfig.registrationMode") + @Mapping(target = "registrationQuestion", source = "instanceConfig.registrationQuestion") + @Mapping(target = "privateInstance", source = "instanceConfig.privateInstance") + @Mapping(target = "requireEmailVerification", source = "instanceConfig.requireEmailVerification") + @Mapping(target = "enableDownvotes", source = "instanceConfig.enableDownvotes") + @Mapping(target = "enableNsfw", source = "instanceConfig.enableNsfw") + @Mapping(target = "communityCreationAdminOnly", + source = "instanceConfig.communityCreationAdminOnly") + @Mapping(target = "applicationEmailAdmins", source = "instanceConfig.applicationEmailAdmins") + @Mapping(target = "reportEmailAdmins", source = "instanceConfig.reportEmailAdmins") + @Mapping(target = "hideModlogModNames", source = "instanceConfig.hideModlogModNames") + @Mapping(target = "federationEnabled", source = "instanceConfig.federationEnabled") + @Mapping(target = "captchaEnabled", source = "instanceConfig.captchaEnabled") + @Mapping(target = "captchaDifficulty", source = "instanceConfig.captchaDifficulty") + @Mapping(target = "nameMaxLength", source = "instanceConfig.actorNameMaxLength") + @Mapping(target = "defaultTheme", source = "instanceConfig.defaultTheme") + @Mapping(target = "defaultPostListingType", source = "instanceConfig.defaultPostListingType") + @Mapping(target = "legalInformation", source = "instanceConfig.legalInformation") + @Mapping(target = "createdAt", + source = "instanceConfig.createdAt", + dateFormat = DateUtils.FRONT_END_DATE_FORMAT) + @Mapping(target = "updatedAt", + source = "instanceConfig.updatedAt", + dateFormat = DateUtils.FRONT_END_DATE_FORMAT) + public abstract InstanceConfigResponse convert(@Nullable InstanceConfig instanceConfig); +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/mappers/SublinksInstanceMapper.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/mappers/SublinksInstanceMapper.java new file mode 100644 index 000000000..18347afb9 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/mappers/SublinksInstanceMapper.java @@ -0,0 +1,33 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.instance.mappers; + +import com.sublinks.sublinksapi.api.sublinks.v1.instance.models.InstanceResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.utils.DateUtils; +import com.sublinks.sublinksapi.instance.entities.Instance; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingConstants; +import org.springframework.core.convert.converter.Converter; +import org.springframework.lang.Nullable; + +@Mapper(componentModel = MappingConstants.ComponentModel.SPRING) +public abstract class SublinksInstanceMapper implements Converter { + + @Override + @Mapping(target = "key", source = "instance.domain") + @Mapping(target = "name", source = "instance.name") + @Mapping(target = "description", source = "instance.description") + @Mapping(target = "domain", source = "instance.domain") + @Mapping(target = "software", source = "instance.software") + @Mapping(target = "version", source = "instance.version") + @Mapping(target = "sidebar", source = "instance.sidebar") + @Mapping(target = "iconUrl", source = "instance.iconUrl") + @Mapping(target = "bannerUrl", source = "instance.bannerUrl") + @Mapping(target = "publicKey", source = "instance.publicKey") + @Mapping(target = "createdAt", + source = "instance.createdAt", + dateFormat = DateUtils.FRONT_END_DATE_FORMAT) + @Mapping(target = "updatedAt", + source = "instance.updatedAt", + dateFormat = DateUtils.FRONT_END_DATE_FORMAT) + public abstract InstanceResponse convert(@Nullable Instance instance); +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/models/IndexInstance.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/models/IndexInstance.java new file mode 100644 index 000000000..a86011c8f --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/models/IndexInstance.java @@ -0,0 +1,11 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.instance.models; + +import lombok.Builder; + +@Builder +public record IndexInstance( + String search, + Integer page, + Integer perPage) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/models/InstanceAggregateResponse.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/models/InstanceAggregateResponse.java new file mode 100644 index 000000000..a644ee76a --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/models/InstanceAggregateResponse.java @@ -0,0 +1,14 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.instance.models; + +public record InstanceAggregateResponse( + String instanceKey, + Integer userCount, + Integer postCount, + Integer commentCount, + Integer communityCount, + Integer activeDailyUserCount, + Integer activeWeeklyUserCount, + Integer activeMonthlyUserCount, + Integer activeHalfYearlyUserCount) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/models/InstanceConfigResponse.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/models/InstanceConfigResponse.java new file mode 100644 index 000000000..924223517 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/models/InstanceConfigResponse.java @@ -0,0 +1,28 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.instance.models; + +import com.sublinks.sublinksapi.api.sublinks.v1.instance.enums.ListingType; +import com.sublinks.sublinksapi.api.sublinks.v1.instance.enums.RegistrationMode; + +public record InstanceConfigResponse( + String instanceKey, + RegistrationMode registrationMode, + String registrationQuestion, + Boolean privateInstance, + Boolean requireEmailVerification, + Boolean enableDownvotes, + Boolean enableNsfw, + Boolean communityCreationAdminOnly, + Boolean applicationEmailAdmins, + Boolean reportEmailAdmins, + Boolean hideModlogModNames, + Boolean federationEnabled, + Boolean captchaEnabled, + String captchaDifficulty, + Integer nameMaxLength, + String defaultTheme, + ListingType defaultPostListingType, + String legalInformation, + String createdAt, + String updatedAt) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/models/InstanceResponse.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/models/InstanceResponse.java new file mode 100644 index 000000000..ec3406607 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/models/InstanceResponse.java @@ -0,0 +1,17 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.instance.models; + +public record InstanceResponse( + String key, + String name, + String description, + String domain, + String software, + String version, + String sidebar, + String iconUrl, + String bannerUrl, + String publicKey, + String createdAt, + String updatedAt) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/models/UpdateInstanceConfig.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/models/UpdateInstanceConfig.java new file mode 100644 index 000000000..414d66e9e --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/models/UpdateInstanceConfig.java @@ -0,0 +1,27 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.instance.models; + +import com.sublinks.sublinksapi.api.sublinks.v1.instance.enums.ListingType; +import com.sublinks.sublinksapi.api.sublinks.v1.instance.enums.RegistrationMode; + +public record UpdateInstanceConfig( + RegistrationMode registrationMode, + String registrationQuestion, + Boolean privateInstance, + Boolean requireEmailVerification, + Boolean enableDownvotes, + Boolean enableNsfw, + Boolean communityCreationAdminOnly, + Boolean applicationEmailAdmins, + Boolean reportEmailAdmins, + Boolean hideModlogModNames, + Boolean federationEnabled, + Boolean captchaEnabled, + String captchaDifficulty, + Integer nameMaxLength, + String defaultTheme, + ListingType defaultPostListingType, + String legalInformation, + String createdAt, + String updatedAt) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/models/moderation/IndexBannedPerson.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/models/moderation/IndexBannedPerson.java new file mode 100644 index 000000000..010e04cae --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/models/moderation/IndexBannedPerson.java @@ -0,0 +1,14 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.instance.models.moderation; + +import com.sublinks.sublinksapi.api.sublinks.v1.common.enums.SortOrder; +import lombok.Builder; + +@Builder +public record IndexBannedPerson( + String search, + Boolean local, + SortOrder sortOrder, + Integer limit, + Integer page) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/service/SublinksInstanceService.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/service/SublinksInstanceService.java new file mode 100644 index 000000000..7f0b95cd1 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/instance/service/SublinksInstanceService.java @@ -0,0 +1,103 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.instance.service; + +import com.sublinks.sublinksapi.api.sublinks.v1.instance.models.IndexInstance; +import com.sublinks.sublinksapi.api.sublinks.v1.instance.models.InstanceAggregateResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.instance.models.InstanceConfigResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.instance.models.InstanceResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.instance.models.UpdateInstanceConfig; +import com.sublinks.sublinksapi.authorization.enums.RolePermissionInstanceTypes; +import com.sublinks.sublinksapi.authorization.services.RolePermissionService; +import com.sublinks.sublinksapi.instance.entities.Instance; +import com.sublinks.sublinksapi.instance.entities.InstanceConfig; +import com.sublinks.sublinksapi.instance.repositories.InstanceAggregateRepository; +import com.sublinks.sublinksapi.instance.repositories.InstanceConfigRepository; +import com.sublinks.sublinksapi.instance.repositories.InstanceRepository; +import com.sublinks.sublinksapi.instance.services.InstanceConfigService; +import com.sublinks.sublinksapi.instance.services.InstanceService; +import com.sublinks.sublinksapi.person.entities.Person; +import com.sublinks.sublinksapi.utils.PaginationUtils; +import java.util.List; +import lombok.AllArgsConstructor; +import org.springframework.core.convert.ConversionService; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +@Service +@AllArgsConstructor +public class SublinksInstanceService { + + private final InstanceRepository instanceRepository; + private final InstanceConfigRepository instanceConfigRepository; + private final InstanceAggregateRepository instanceAggregateRepository; + private final InstanceService instanceService; + private final InstanceConfigService instanceConfigService; + private final ConversionService conversionService; + private final RolePermissionService rolePermissionService; + + public List index(final IndexInstance indexInstance) { + + PageRequest pageRequest = PageRequest.of(PaginationUtils.getPage(indexInstance.page()), + PaginationUtils.getPerPage(indexInstance.perPage())); + List instances; + + if (indexInstance.search() == null) { + instances = instanceRepository.findAll(pageRequest) + .getContent(); + } else { + instances = instanceRepository.findInstancesByDomainOrDescriptionOrSidebar( + indexInstance.search(), pageRequest); + } + + return convertToInstanceResponses(instances); + } + + private List convertToInstanceResponses(List instances) { + + return instances.stream() + .map(instance -> conversionService.convert(instance, InstanceResponse.class)) + .toList(); + } + + public InstanceResponse show(final String key) { + + return conversionService.convert(instanceRepository.findInstanceByDomain(key), + InstanceResponse.class); + } + + public InstanceAggregateResponse showAggregate(final String key) { + + return conversionService.convert(instanceAggregateRepository.findByInstance_Domain(key), + InstanceAggregateResponse.class); + } + + public InstanceConfigResponse showConfig(final String key, final Person person) { + + rolePermissionService.isPermitted(person, + RolePermissionInstanceTypes.INSTANCE_READ_ANNOUNCEMENT, + () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized")); + + return conversionService.convert(instanceConfigRepository.findByInstance_Domain(key) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "instance_not_found")), + InstanceConfigResponse.class); + } + + public InstanceConfigResponse updateConfig(final String key, + final UpdateInstanceConfig updateInstanceConfigForm, final Person person) + { + + rolePermissionService.isPermitted(person, RolePermissionInstanceTypes.INSTANCE_UPDATE_SETTINGS, + () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized")); + + final InstanceConfig config = instanceConfigRepository.findByInstance_Domain(key) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "instance_not_found")); + + if (updateInstanceConfigForm.nameMaxLength() != null) { + config.setActorNameMaxLength(Math.max(updateInstanceConfigForm.nameMaxLength(), 0)); + } + + instanceConfigService.updateInstanceConfig(config); + return conversionService.convert(config, InstanceConfigResponse.class); + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/languages/controllers/SublinksLanguageController.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/languages/controllers/SublinksLanguageController.java new file mode 100644 index 000000000..49f1fc0f8 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/languages/controllers/SublinksLanguageController.java @@ -0,0 +1,65 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.languages.controllers; + +import com.sublinks.sublinksapi.api.sublinks.v1.common.controllers.AbstractSublinksApiController; +import com.sublinks.sublinksapi.api.sublinks.v1.languages.models.LanguageResponse; +import com.sublinks.sublinksapi.instance.models.LocalInstanceContext; +import com.sublinks.sublinksapi.language.entities.Language; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import lombok.AllArgsConstructor; +import org.springframework.core.convert.ConversionService; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("api/v1/languages") +@Tag(name = "Sublinks Languages", description = "Languages API") +@AllArgsConstructor +public class SublinksLanguageController extends AbstractSublinksApiController { + + private final LocalInstanceContext localInstanceContext; + private final ConversionService conversionService; + + @Operation(summary = "Get a list of languagesKeys") + @GetMapping + @ApiResponses(value = { + @ApiResponse(responseCode = "200", + description = "List of languagesKeys", + useReturnTypeSchema = true)}) + public List index() { + + List languages = localInstanceContext.instance() + .getLanguages(); + + return languages.stream() + .map(language -> conversionService.convert(language, LanguageResponse.class)) + .toList(); + + } + + @Operation(summary = "Get a specific language") + @GetMapping("/{id}") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Language", useReturnTypeSchema = true), + @ApiResponse(responseCode = "404", description = "Language not found")}) + public LanguageResponse show(@PathVariable String id) { + + List languages = localInstanceContext.instance() + .getLanguages(); + + Language foundLanguage = languages.stream() + .filter(language -> language.getId() + .equals(Long.valueOf(id))) + .findFirst() + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "language_not_found")); + + return conversionService.convert(foundLanguage, LanguageResponse.class); + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/languages/mappers/SublinksLanguageMapper.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/languages/mappers/SublinksLanguageMapper.java new file mode 100644 index 000000000..4538e3b11 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/languages/mappers/SublinksLanguageMapper.java @@ -0,0 +1,22 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.languages.mappers; + +import com.sublinks.sublinksapi.api.sublinks.v1.languages.models.LanguageResponse; +import com.sublinks.sublinksapi.authorization.services.RolePermissionService; +import com.sublinks.sublinksapi.language.entities.Language; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingConstants; +import org.springframework.core.convert.converter.Converter; +import org.springframework.lang.Nullable; + +@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, uses = { + RolePermissionService.class}) +public abstract class SublinksLanguageMapper implements Converter { + + @Override + @Mapping(target = "key", source = "language.name") + @Mapping(target = "name", source = "language.name") + @Mapping(target = "code", source = "language.code") + public abstract LanguageResponse convert(@Nullable Language language); + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/languages/models/LanguageResponse.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/languages/models/LanguageResponse.java new file mode 100644 index 000000000..1a5cd1491 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/languages/models/LanguageResponse.java @@ -0,0 +1,11 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.languages.models; + +import lombok.Builder; + +@Builder +public record LanguageResponse( + String key, + String code, + String name) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/controllers/SublinksPersonAggregationController.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/controllers/SublinksPersonAggregationController.java new file mode 100644 index 000000000..38940f4f3 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/controllers/SublinksPersonAggregationController.java @@ -0,0 +1,55 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.person.controllers; + +import com.sublinks.sublinksapi.api.sublinks.v1.authentication.SublinksJwtPerson; +import com.sublinks.sublinksapi.api.sublinks.v1.common.controllers.AbstractSublinksApiController; +import com.sublinks.sublinksapi.api.sublinks.v1.person.models.PersonAggregateResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.person.services.SublinksPersonService; +import com.sublinks.sublinksapi.authorization.enums.RolePermissionPersonTypes; +import com.sublinks.sublinksapi.authorization.services.RolePermissionService; +import com.sublinks.sublinksapi.person.entities.Person; +import com.sublinks.sublinksapi.person.repositories.PersonAggregateRepository; +import com.sublinks.sublinksapi.person.repositories.PersonRepository; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.Optional; +import lombok.AllArgsConstructor; +import org.springframework.core.convert.ConversionService; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("api/v1/person/{key}/aggregation") +@Tag(name = "Sublinks Person Aggegation", description = "Person Aggregation API") +@AllArgsConstructor +public class SublinksPersonAggregationController extends AbstractSublinksApiController { + + private final SublinksPersonService sublinksPersonService; + private final ConversionService conversionService; + private final RolePermissionService rolePermissionService; + private final PersonAggregateRepository personAggregateRepository; + private final PersonRepository personRepository; + + @Operation(summary = "Get a person's aggregation") + @GetMapping + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public PersonAggregateResponse aggregate(@PathVariable final String key, + final SublinksJwtPerson sublinksJwtPerson) + { + + final Optional person = getOptionalPerson(sublinksJwtPerson); + + rolePermissionService.isPermitted(person.orElse(null), + RolePermissionPersonTypes.READ_PERSON_AGGREGATION, + () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized")); + + return conversionService.convert(sublinksPersonService.showAggregate(key, person.orElse(null)), + PersonAggregateResponse.class); + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/controllers/SublinksPersonController.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/controllers/SublinksPersonController.java new file mode 100644 index 000000000..b26d54cab --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/controllers/SublinksPersonController.java @@ -0,0 +1,127 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.person.controllers; + +import com.sublinks.sublinksapi.api.sublinks.v1.authentication.SublinksJwtPerson; +import com.sublinks.sublinksapi.api.sublinks.v1.common.controllers.AbstractSublinksApiController; +import com.sublinks.sublinksapi.api.sublinks.v1.common.models.RequestResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.person.models.CreatePerson; +import com.sublinks.sublinksapi.api.sublinks.v1.person.models.DeletePerson; +import com.sublinks.sublinksapi.api.sublinks.v1.person.models.IndexPerson; +import com.sublinks.sublinksapi.api.sublinks.v1.person.models.LoginPerson; +import com.sublinks.sublinksapi.api.sublinks.v1.person.models.LoginResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.person.models.PersonResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.person.models.UpdatePerson; +import com.sublinks.sublinksapi.api.sublinks.v1.person.services.SublinksPersonService; +import com.sublinks.sublinksapi.person.entities.Person; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import java.util.List; +import java.util.Optional; +import lombok.AllArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("api/v1/person") +@Tag(name = "Sublinks Person", description = "Person API") +@AllArgsConstructor +public class SublinksPersonController extends AbstractSublinksApiController { + + private final SublinksPersonService sublinksPersonService; + private final SublinksPersonSessionController sublinksPersonSessionController; + + @Operation(summary = "Get a list of persons") + @GetMapping + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public List index(final IndexPerson indexPersonParam, + final SublinksJwtPerson principal) + { + + final Optional person = getOptionalPerson(principal); + + return sublinksPersonService.index(indexPersonParam != null ? indexPersonParam + : IndexPerson.builder() + .build(), person.orElse(null)); + } + + @Operation(summary = "Get a specific person") + @GetMapping("/{key}") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public PersonResponse show(@PathVariable String key, final SublinksJwtPerson principal) { + + final Optional person = getOptionalPerson(principal); + + return sublinksPersonService.show(key, person.orElse(null)); + } + + @Operation(summary = "Register a new person") + @PostMapping + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public LoginResponse create(final HttpServletRequest request, + @RequestBody @Valid final CreatePerson createPerson) + { + + return sublinksPersonService.registerPerson(createPerson, request.getRemoteAddr(), + request.getHeader("User-Agent")); + } + + @Operation(summary = "Log into a user") + @PostMapping("/login") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public LoginResponse login(final HttpServletRequest request, + @RequestBody @Valid final LoginPerson loginPerson) + { + + return sublinksPersonService.login(loginPerson, request.getRemoteAddr(), + request.getHeader("User-Agent")); + } + + @Operation(summary = "Log out of a user ( Alias for invalidating the current session with /api/v1/session/invalidate )") + @PostMapping("/logout") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public RequestResponse logout(final SublinksJwtPerson principal) + { + + return sublinksPersonSessionController.invalidateCurrentSession(principal); + } + + + @Operation(summary = "Update an person") + @PostMapping("/{key}") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public PersonResponse update(@PathVariable String key, + @RequestBody @Valid final UpdatePerson updatePersonForm, final SublinksJwtPerson principal) + { + + final Person person = getPersonOrThrowUnauthorized(principal); + + return sublinksPersonService.updatePerson(person, updatePersonForm); + } + + @Operation(summary = "Delete an person") + @DeleteMapping("/{key}") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public PersonResponse delete(@RequestBody final DeletePerson deletePersonForm, + @PathVariable String key, final SublinksJwtPerson principal) + { + + final Person person = getPersonOrThrowUnauthorized(principal); + + return sublinksPersonService.deletePerson(key, deletePersonForm, person); + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/controllers/SublinksPersonModerationController.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/controllers/SublinksPersonModerationController.java new file mode 100644 index 000000000..54882404a --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/controllers/SublinksPersonModerationController.java @@ -0,0 +1,84 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.person.controllers; + +import com.sublinks.sublinksapi.api.sublinks.v1.authentication.SublinksJwtPerson; +import com.sublinks.sublinksapi.api.sublinks.v1.common.controllers.AbstractSublinksApiController; +import com.sublinks.sublinksapi.api.sublinks.v1.common.models.RequestResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.person.models.PersonResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.person.models.moderation.BanPerson; +import com.sublinks.sublinksapi.api.sublinks.v1.person.services.SublinksPersonService; +import com.sublinks.sublinksapi.api.sublinks.v1.privatemessage.models.moderation.PurgePrivateMessage; +import com.sublinks.sublinksapi.api.sublinks.v1.privatemessage.services.SublinksPrivateMessageService; +import com.sublinks.sublinksapi.authorization.enums.RolePermissionPrivateMessageTypes; +import com.sublinks.sublinksapi.authorization.services.AclService; +import com.sublinks.sublinksapi.person.entities.Person; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.AllArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("api/v1/person/{key}/moderation") +@Tag(name = "Sublinks Person Moderation", description = "Person Moderation API") +@AllArgsConstructor +public class SublinksPersonModerationController extends AbstractSublinksApiController { + + private final SublinksPersonService sublinksPersonService; + private final AclService aclService; + private final SublinksPrivateMessageService sublinksPrivateMessageService; + + @Operation(summary = "Ban a person") + @GetMapping("/ban") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public PersonResponse ban(@PathVariable String key, @RequestBody @Valid BanPerson banPersonForm, + final SublinksJwtPerson jwtPerson) + { + + final Person person = getPersonOrThrowUnauthorized(jwtPerson); + + return sublinksPersonService.banPerson(banPersonForm, person); + } + + + @Operation(summary = "Delete/Purge an person ( as an admin )") + @DeleteMapping("/purge") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public RequestResponse purge(@PathVariable String key) { + // TODO: implement + + return RequestResponse.builder() + .success(true) + .build(); + } + + @Operation(summary = "Delete/Purge an person ( as an admin )") + @DeleteMapping("/purge/privatemessages") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public RequestResponse purgeAll(@PathVariable String key, + @RequestBody final PurgePrivateMessage purgePrivateMessageForm, + final SublinksJwtPerson jwtPerson) + { + + final Person person = getPersonOrThrowUnauthorized(jwtPerson); + + aclService.canPerson(person) + .performTheAction(RolePermissionPrivateMessageTypes.PURGE_PRIVATE_MESSAGES) + .orThrowUnauthorized(); + + sublinksPrivateMessageService.purgeAllPrivateMessages(key, purgePrivateMessageForm, person); + + return RequestResponse.builder() + .success(true) + .build(); + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/controllers/SublinksPersonSessionController.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/controllers/SublinksPersonSessionController.java new file mode 100644 index 000000000..3731fc413 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/controllers/SublinksPersonSessionController.java @@ -0,0 +1,151 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.person.controllers; + +import com.sublinks.sublinksapi.api.sublinks.v1.authentication.SublinksJwtPerson; +import com.sublinks.sublinksapi.api.sublinks.v1.common.controllers.AbstractSublinksApiController; +import com.sublinks.sublinksapi.api.sublinks.v1.common.models.RequestResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.person.models.PersonSessionDataResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.person.services.SublinksPersonService; +import com.sublinks.sublinksapi.api.sublinks.v1.utils.PersonKeyUtils; +import com.sublinks.sublinksapi.person.entities.Person; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.AllArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("api/v1/session/") +@Tag(name = "Sublinks Person Session", description = "Person Session API") +@AllArgsConstructor +public class SublinksPersonSessionController extends AbstractSublinksApiController { + + private final SublinksPersonService sublinksPersonService; + private final PersonKeyUtils personKeyUtils; + + @Operation(summary = "Get metadata for a person ( requires permission to view other peoples sessions )") + @GetMapping("/person/{personKey}") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public PersonSessionDataResponse getMetaData(@PathVariable String personKey, + final SublinksJwtPerson jwtPerson) + { + + final Person person = getPersonOrThrowUnauthorized(jwtPerson); + + return sublinksPersonService.getMetaData(personKey, person); + } + + @Operation(summary = "Get metadata for a person ( requires permission to view other peoples sessions )") + @GetMapping("/person") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public PersonSessionDataResponse getMetaData(final SublinksJwtPerson jwtPerson) + { + + final Person person = getPersonOrThrowUnauthorized(jwtPerson); + + return sublinksPersonService.getMetaData(personKeyUtils.getPersonKey(person), person); + } + + @GetMapping("/data/{sessionKey}") + @Operation(summary = "Get one metadata ( requires permission to view other peoples sessions )") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public PersonSessionDataResponse getOneMetaData(@PathVariable String sessionKey, + final SublinksJwtPerson jwtPerson) + { + + final Person person = getPersonOrThrowUnauthorized(jwtPerson); + + return sublinksPersonService.getOneMetaData(sessionKey, person); + } + + @Operation(summary = "Invalidate one metadata for a person ( requires permission to invalidate other peoples metadata )") + @DeleteMapping("/person/invalidate/{sessionKey}") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public RequestResponse invalidateOneMetaData(@PathVariable String sessionKey, + final SublinksJwtPerson jwtPerson) + { + + final Person person = getPersonOrThrowUnauthorized(jwtPerson); + + sublinksPersonService.invalidateUserData(sessionKey, person); + + return RequestResponse.builder() + .success(true) + .build(); + } + + @Operation(summary = "Invalidate all metadata for a person ( requires permission to invalidate other peoples metadata )") + @DeleteMapping("/person/{personKey}/invalidate") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public RequestResponse invalidateMetaData(@PathVariable String personKey, + final SublinksJwtPerson jwtPerson) + { + + final Person person = getPersonOrThrowUnauthorized(jwtPerson); + + sublinksPersonService.invalidateAllUserData(personKey, person); + + return RequestResponse.builder() + .success(true) + .build(); + } + + @Operation(summary = "Deletes all metadata for a person ( requires permission to delete other peoples metadata )") + @DeleteMapping("/person/{personKey}") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public RequestResponse deleteMetaData(@PathVariable String personKey, + final SublinksJwtPerson jwtPerson) + { + + final Person person = getPersonOrThrowUnauthorized(jwtPerson); + + sublinksPersonService.deleteAllUserData(personKey, person); + + return RequestResponse.builder() + .success(true) + .build(); + } + + @Operation(summary = "Deletes one metadata for a person ( requires permission to delete other peoples metadata )") + @DeleteMapping("/data/{sessionKey}") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public RequestResponse deleteOneMetaData(@PathVariable String sessionKey, + final SublinksJwtPerson jwtPerson) + { + + final Person person = getPersonOrThrowUnauthorized(jwtPerson); + + sublinksPersonService.deleteUserData(sessionKey, person); + + return RequestResponse.builder() + .success(true) + .build(); + } + + @Operation(summary = "Invalidates your current session") + @DeleteMapping("/invalidate") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public RequestResponse invalidateCurrentSession(final SublinksJwtPerson jwtPerson) + { + + final Person person = getPersonOrThrowUnauthorized(jwtPerson); + + sublinksPersonService.invalidateUserDataByToken(jwtPerson.getToken(), person); + + return RequestResponse.builder() + .success(true) + .build(); + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/enums/RegistrationState.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/enums/RegistrationState.java new file mode 100644 index 000000000..a41186f21 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/enums/RegistrationState.java @@ -0,0 +1,9 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.person.enums; + +public enum RegistrationState { + UNCHANGED, + CREATED, + APPLICATION_CREATED, + VERIFICATION_EMAIL_SENT, + NOT_CREATED +} \ No newline at end of file diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/mappers/SublinksPersonAggregationMapper.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/mappers/SublinksPersonAggregationMapper.java new file mode 100644 index 000000000..520919491 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/mappers/SublinksPersonAggregationMapper.java @@ -0,0 +1,38 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.person.mappers; + +import com.sublinks.sublinksapi.api.sublinks.v1.person.models.PersonAggregateResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.utils.PersonKeyUtils; +import com.sublinks.sublinksapi.person.entities.Person; +import com.sublinks.sublinksapi.person.entities.PersonAggregate; +import lombok.NoArgsConstructor; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingConstants; +import org.mapstruct.Named; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.convert.converter.Converter; +import org.springframework.lang.Nullable; + +@Mapper(componentModel = MappingConstants.ComponentModel.SPRING) +@NoArgsConstructor +public abstract class SublinksPersonAggregationMapper implements + Converter { + + @Autowired + private PersonKeyUtils personKeyUtils; + + @Override + @Mapping(target = "personKey", source = "person", qualifiedByName = "personKey") + @Mapping(target = "postCount", source = "postCount") + @Mapping(target = "commentCount", source = "commentCount") + @Mapping(target = "postScore", source = "postScore") + @Mapping(target = "commentScore", source = "commentScore") + public abstract PersonAggregateResponse convert(@Nullable PersonAggregate personAggregate); + + @Named("personKey") + String mapPersonKey(Person person) { + + return personKeyUtils.getPersonKey(person); + } + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/mappers/SublinksPersonMapper.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/mappers/SublinksPersonMapper.java new file mode 100644 index 000000000..717c2d2e6 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/mappers/SublinksPersonMapper.java @@ -0,0 +1,94 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.person.mappers; + +import com.sublinks.sublinksapi.api.sublinks.v1.instance.mappers.SublinksInstanceMapper; +import com.sublinks.sublinksapi.api.sublinks.v1.person.models.PersonResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.roles.mappers.SublinksPersonRoleMapper; +import com.sublinks.sublinksapi.api.sublinks.v1.utils.DateUtils; +import com.sublinks.sublinksapi.api.sublinks.v1.utils.PersonKeyUtils; +import com.sublinks.sublinksapi.authorization.services.RolePermissionService; +import com.sublinks.sublinksapi.person.entities.Person; +import java.text.SimpleDateFormat; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingConstants; +import org.mapstruct.Named; +import org.mapstruct.ReportingPolicy; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.convert.converter.Converter; +import org.springframework.lang.Nullable; + +@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, + uses = {SublinksPersonRoleMapper.class, SublinksInstanceMapper.class}, + unmappedTargetPolicy = ReportingPolicy.IGNORE) +public abstract class SublinksPersonMapper implements Converter { + + @Autowired + public PersonKeyUtils personKeyUtils; + + @Override + @Mapping(target = "key", source = "person", qualifiedByName = "personKey") + @Mapping(target = "name", source = "person.name") + @Mapping(target = "displayName", source = "person", qualifiedByName = "display_name") + @Mapping(target = "isBanned", source = "person", qualifiedByName = "is_banned") + @Mapping(target = "banExpiresAt", source = "person", qualifiedByName = "ban_expires_at") + @Mapping(target = "isDeleted", source = "person.deleted") + @Mapping(target = "avatarImageUrl", source = "person", qualifiedByName = "avatar") + @Mapping(target = "bannerImageUrl", source = "person", qualifiedByName = "banner") + @Mapping(target = "isBotAccount", source = "person.botAccount") + @Mapping(target = "role", source = "person.role") + @Mapping(target = "bio", source = "person.biography") + @Mapping(target = "isLocal", source = "person.local") + @Mapping(target = "instance", source = "person.instance") + @Mapping(target = "createdAt", + source = "person.createdAt", + dateFormat = DateUtils.FRONT_END_DATE_FORMAT) + @Mapping(target = "updatedAt", + source = "person.updatedAt", + dateFormat = DateUtils.FRONT_END_DATE_FORMAT) + public abstract PersonResponse convert(@Nullable Person person); + + @Named("personKey") + String mapPersonKey(Person person) { + + return personKeyUtils.getPersonKey(person); + } + + @Named("is_banned") + Boolean mapIsBanned(Person person) { + + return RolePermissionService.isBanned(person); + } + + @Named("display_name") + String mapDisplayName(Person person) { + + return !person.getDisplayName() + .isBlank() ? person.getDisplayName() : null; + } + + @Named("avatar") + String mapAvatar(Person person) { + + return !person.getAvatarImageUrl() + .isBlank() ? person.getAvatarImageUrl() : null; + } + + @Named("banner") + String mapBanner(Person person) { + + return !person.getBannerImageUrl() + .isBlank() ? person.getBannerImageUrl() : null; + } + + @Named("ban_expires_at") + String mapBanExpiresAt(Person person) { + + SimpleDateFormat simpleDateFormat = new SimpleDateFormat(); + + simpleDateFormat.applyPattern(DateUtils.FRONT_END_DATE_FORMAT); + + return person.getRole() + .getExpiresAt() != null ? simpleDateFormat.format(person.getRole() + .getExpiresAt()) : null; + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/mappers/SublinksPersonMetaDataMapper.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/mappers/SublinksPersonMetaDataMapper.java new file mode 100644 index 000000000..07d5ce7c3 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/mappers/SublinksPersonMetaDataMapper.java @@ -0,0 +1,28 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.person.mappers; + +import com.sublinks.sublinksapi.api.sublinks.v1.person.models.PersonSessionData; +import com.sublinks.sublinksapi.api.sublinks.v1.utils.DateUtils; +import com.sublinks.sublinksapi.person.entities.PersonMetaData; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingConstants; +import org.springframework.core.convert.converter.Converter; +import org.springframework.lang.Nullable; + +@Mapper(componentModel = MappingConstants.ComponentModel.SPRING) +public abstract class SublinksPersonMetaDataMapper implements + Converter { + + @Override + @Mapping(target = "key", source = "metadata.id") + @Mapping(target = "ipAddress", source = "metadata.ipAddress") + @Mapping(target = "userAgent", source = "metadata.userAgent") + @Mapping(target = "active", source = "metadata.active") + @Mapping(target = "createdAt", + source = "metadata.createdAt", + dateFormat = DateUtils.FRONT_END_DATE_FORMAT) + @Mapping(target = "updatedAt", + source = "metadata.lastUsedAt", + dateFormat = DateUtils.FRONT_END_DATE_FORMAT) + public abstract PersonSessionData convert(@Nullable PersonMetaData metadata); +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/models/CreatePerson.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/models/CreatePerson.java new file mode 100644 index 000000000..a9dd8d5c1 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/models/CreatePerson.java @@ -0,0 +1,23 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.person.models; + +import com.sublinks.sublinksapi.api.sublinks.v1.languages.models.LanguageResponse; +import java.util.List; +import lombok.Builder; + +@Builder +public record CreatePerson( + String name, + String displayName, + String email, + List languages, + String avatarImageUrl, + String bannerImageUrl, + String bio, + String matrixUserId, + String password, + String passwordConfirmation, + String answer, + String captcha_token, + String captcha_answer) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/models/DeletePerson.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/models/DeletePerson.java new file mode 100644 index 000000000..491636420 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/models/DeletePerson.java @@ -0,0 +1,15 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.person.models; + +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; + +public record DeletePerson( + @Schema(description = "The reason for deleting the person", + example = "I dont use this account anymore", + requiredMode = RequiredMode.NOT_REQUIRED) String reason, + @Schema(description = "Whether to pin your Post/Comments/Private Messages or not", + example = "true", + defaultValue = "false", + requiredMode = RequiredMode.NOT_REQUIRED) Boolean deleteContent) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/models/IndexPerson.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/models/IndexPerson.java new file mode 100644 index 000000000..20f8cd818 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/models/IndexPerson.java @@ -0,0 +1,45 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.person.models; + +import com.sublinks.sublinksapi.api.sublinks.v1.common.enums.SortType; +import com.sublinks.sublinksapi.api.sublinks.v1.common.enums.SublinksListingType; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import lombok.Builder; + +@Builder +public record IndexPerson( + @Schema(description = "The search query", + requiredMode = RequiredMode.NOT_REQUIRED) String search, + @Schema(description = "The listing type", + requiredMode = RequiredMode.NOT_REQUIRED, + defaultValue = "All") SublinksListingType listingType, + @Schema(description = "The sort type", + requiredMode = RequiredMode.NOT_REQUIRED, + defaultValue = "New") SortType sortType, + @Schema(description = "The page", + requiredMode = RequiredMode.NOT_REQUIRED, + defaultValue = "1") Integer page, + @Schema(description = "The number of items per page", + requiredMode = RequiredMode.NOT_REQUIRED, + defaultValue = "20") Integer perPage) { + + @Override + public SublinksListingType listingType() { + return listingType == null ? SublinksListingType.Local : listingType; + } + + @Override + public SortType sortType() { + return sortType == null ? SortType.New : sortType; + } + + @Override + public Integer page() { + return page == null ? 0 : page; + } + + @Override + public Integer perPage() { + return perPage == null ? 20 : perPage; + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/models/LoginPerson.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/models/LoginPerson.java new file mode 100644 index 000000000..955c58b1c --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/models/LoginPerson.java @@ -0,0 +1,34 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.person.models; + +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import lombok.Builder; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +@Builder +public record LoginPerson( + @Schema(description = "The username", + requiredMode = RequiredMode.NOT_REQUIRED, + example = "john_doe") String username, + @Schema(description = "The password", + requiredMode = RequiredMode.NOT_REQUIRED, + example = "topsecretpasswordnooneknows") String password, + @Schema(description = "The captcha token", + requiredMode = RequiredMode.NOT_REQUIRED, + example = "03AGdBq26") String captcha_token, + @Schema(description = "The captcha answer", + requiredMode = RequiredMode.NOT_REQUIRED, + example = "3") String captcha_answer) { + + public LoginPerson { + + if (username == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "username_required"); + } + if (password == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "password_required"); + } + } + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/models/LoginResponse.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/models/LoginResponse.java new file mode 100644 index 000000000..9f51238a7 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/models/LoginResponse.java @@ -0,0 +1,16 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.person.models; + +import com.sublinks.sublinksapi.api.sublinks.v1.person.enums.RegistrationState; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import lombok.Builder; + +@Builder +public record LoginResponse( + @Schema(description = "The token to be used in the Authorization header for future requests", + requiredMode = RequiredMode.NOT_REQUIRED) String token, + RegistrationState status, + @Schema(description = "The error message if the login failed", + requiredMode = RequiredMode.NOT_REQUIRED) String error) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/models/PersonAggregateResponse.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/models/PersonAggregateResponse.java new file mode 100644 index 000000000..48fcc8dba --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/models/PersonAggregateResponse.java @@ -0,0 +1,13 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.person.models; + +import lombok.Builder; + +@Builder +public record PersonAggregateResponse( + String personKey, + Integer postCount, + Integer commentCount, + Integer postScore, + Integer commentScore) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/models/PersonIdentity.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/models/PersonIdentity.java new file mode 100644 index 000000000..561b02070 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/models/PersonIdentity.java @@ -0,0 +1,10 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.person.models; + +import lombok.Builder; + +@Builder +public record PersonIdentity( + String name, + String domain) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/models/PersonResponse.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/models/PersonResponse.java new file mode 100644 index 000000000..0b50fd109 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/models/PersonResponse.java @@ -0,0 +1,30 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.person.models; + +import com.sublinks.sublinksapi.api.sublinks.v1.instance.models.InstanceResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.roles.models.PersonRoleResponse; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import lombok.Builder; + +@Builder +public record PersonResponse( + String key, + String name, + String displayName, + String avatarImageUrl, + String bannerImageUrl, + String bio, + String matrixUserId, + String actorId, + PersonRoleResponse role, + Boolean isLocal, + Boolean isBanned, + @Schema(description = "The date and time the users ban expires at", + requiredMode = RequiredMode.NOT_REQUIRED) String banExpiresAt, + Boolean isDeleted, + Boolean isBotAccount, + String createdAt, + InstanceResponse instance, + String updatedAt) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/models/PersonSessionData.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/models/PersonSessionData.java new file mode 100644 index 000000000..b7eb757e6 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/models/PersonSessionData.java @@ -0,0 +1,14 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.person.models; + +import lombok.Builder; + +@Builder +public record PersonSessionData( + Long key, + String ipAddress, + String userAgent, + Boolean active, + String createdAt, + String updatedAt) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/models/PersonSessionDataResponse.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/models/PersonSessionDataResponse.java new file mode 100644 index 000000000..b85e6b78f --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/models/PersonSessionDataResponse.java @@ -0,0 +1,12 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.person.models; + +import lombok.Builder; +import java.util.List; + +@Builder +public record PersonSessionDataResponse( + String personKey, + List sessions + ) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/models/UpdatePerson.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/models/UpdatePerson.java new file mode 100644 index 000000000..5a21da4a3 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/models/UpdatePerson.java @@ -0,0 +1,19 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.person.models; + +import java.util.List; +import lombok.Builder; + +@Builder +public record UpdatePerson( + String displayName, + String email, + List languagesKeys, + String avatarImageUrl, + String bannerImageUrl, + String bio, + String matrixUserId, + String oldPassword, + String password, + String passwordConfirmation) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/models/moderation/BanPerson.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/models/moderation/BanPerson.java new file mode 100644 index 000000000..e235e99bc --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/models/moderation/BanPerson.java @@ -0,0 +1,45 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.person.models.moderation; + +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +@Builder +public record BanPerson( + @Schema(description = "The person key", + requiredMode = RequiredMode.REQUIRED) @NotNull() String key, + @Schema(description = "The reason for the ban", + requiredMode = RequiredMode.NOT_REQUIRED) String reason, + @Schema(description = "Ban the user", + requiredMode = RequiredMode.NOT_REQUIRED, + defaultValue = "true") Boolean ban, + @Schema(description = "Remove all data associated with the user", + requiredMode = RequiredMode.NOT_REQUIRED, + defaultValue = "false") Boolean removeData, + @Schema(description = "Unix timestamp when the ban should expire", + requiredMode = RequiredMode.NOT_REQUIRED) Long expirationTimestamp) { + + public BanPerson { + + if (expirationTimestamp != null && expirationTimestamp < System.currentTimeMillis() / 1000) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "expiration_timestamp_must_be_in_the_future"); + } + } + + @Override + public Boolean ban() { + + return ban == null || ban; + } + + @Override + public Boolean removeData() { + + return removeData != null && removeData; + } + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/services/SublinksPersonService.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/services/SublinksPersonService.java new file mode 100644 index 000000000..0ed816558 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/person/services/SublinksPersonService.java @@ -0,0 +1,760 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.person.services; + +import com.sublinks.sublinksapi.api.lemmy.v3.enums.RegistrationMode; +import com.sublinks.sublinksapi.api.sublinks.v1.authentication.SublinksJwtUtil; +import com.sublinks.sublinksapi.api.sublinks.v1.common.enums.SortOrder; +import com.sublinks.sublinksapi.api.sublinks.v1.common.enums.SublinksListingType; +import com.sublinks.sublinksapi.api.sublinks.v1.instance.models.moderation.IndexBannedPerson; +import com.sublinks.sublinksapi.api.sublinks.v1.person.enums.RegistrationState; +import com.sublinks.sublinksapi.api.sublinks.v1.person.models.CreatePerson; +import com.sublinks.sublinksapi.api.sublinks.v1.person.models.DeletePerson; +import com.sublinks.sublinksapi.api.sublinks.v1.person.models.IndexPerson; +import com.sublinks.sublinksapi.api.sublinks.v1.person.models.LoginPerson; +import com.sublinks.sublinksapi.api.sublinks.v1.person.models.LoginResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.person.models.PersonAggregateResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.person.models.PersonIdentity; +import com.sublinks.sublinksapi.api.sublinks.v1.person.models.PersonResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.person.models.PersonSessionData; +import com.sublinks.sublinksapi.api.sublinks.v1.person.models.PersonSessionDataResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.person.models.UpdatePerson; +import com.sublinks.sublinksapi.api.sublinks.v1.person.models.moderation.BanPerson; +import com.sublinks.sublinksapi.api.sublinks.v1.utils.PersonKeyUtils; +import com.sublinks.sublinksapi.authorization.entities.Role; +import com.sublinks.sublinksapi.authorization.enums.RolePermissionInstanceTypes; +import com.sublinks.sublinksapi.authorization.enums.RolePermissionPersonTypes; +import com.sublinks.sublinksapi.authorization.services.AclService; +import com.sublinks.sublinksapi.authorization.services.RolePermissionService; +import com.sublinks.sublinksapi.authorization.services.RoleService; +import com.sublinks.sublinksapi.comment.services.CommentReportService; +import com.sublinks.sublinksapi.email.entities.Email; +import com.sublinks.sublinksapi.email.enums.EmailTemplatesEnum; +import com.sublinks.sublinksapi.email.services.EmailService; +import com.sublinks.sublinksapi.instance.entities.InstanceConfig; +import com.sublinks.sublinksapi.instance.models.LocalInstanceContext; +import com.sublinks.sublinksapi.language.repositories.LanguageRepository; +import com.sublinks.sublinksapi.person.entities.Person; +import com.sublinks.sublinksapi.person.entities.PersonAggregate; +import com.sublinks.sublinksapi.person.entities.PersonEmailVerification; +import com.sublinks.sublinksapi.person.entities.PersonMetaData; +import com.sublinks.sublinksapi.person.entities.PersonRegistrationApplication; +import com.sublinks.sublinksapi.person.enums.PersonRegistrationApplicationStatus; +import com.sublinks.sublinksapi.person.repositories.PersonAggregateRepository; +import com.sublinks.sublinksapi.person.repositories.PersonRegistrationApplicationRepository; +import com.sublinks.sublinksapi.person.repositories.PersonRepository; +import com.sublinks.sublinksapi.person.repositories.UserDataRepository; +import com.sublinks.sublinksapi.person.services.PersonEmailVerificationService; +import com.sublinks.sublinksapi.person.services.PersonRegistrationApplicationService; +import com.sublinks.sublinksapi.person.services.PersonService; +import com.sublinks.sublinksapi.person.services.UserDataService; +import com.sublinks.sublinksapi.post.services.PostReportService; +import com.sublinks.sublinksapi.privatemessages.services.PrivateMessageReportService; +import com.sublinks.sublinksapi.utils.PaginationUtils; +import com.sublinks.sublinksapi.utils.UrlUtil; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import lombok.AllArgsConstructor; +import org.springframework.core.convert.ConversionService; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; +import org.thymeleaf.context.Context; + +@AllArgsConstructor +@Service +public class SublinksPersonService { + + private final PersonService personService; + private final SublinksJwtUtil sublinksJwtUtil; + private final PersonRepository personRepository; + private final LocalInstanceContext localInstanceContext; + private final EmailService emailService; + private final PersonEmailVerificationService personEmailVerificationService; + private final PersonRegistrationApplicationService personRegistrationApplicationService; + private final PersonRegistrationApplicationRepository personRegistrationApplicationRepository; + private final UserDataService userDataService; + private final ConversionService conversionService; + private final LanguageRepository languageRepository; + private final RoleService roleService; + private final RolePermissionService rolePermissionService; + private final PersonAggregateRepository personAggregateRepository; + private final PostReportService postReportService; + private final CommentReportService commentReportService; + private final PrivateMessageReportService privateMessageReportService; + private final UserDataRepository userDataRepository; + private final PersonKeyUtils personKeyUtils; + private final UrlUtil urlUtil; + private final AclService aclService; + + /** + * Retrieves the person identifiers from the given key. + * + * @param key the key used to retrieve the person identifiers + * @return the person identity object containing the name and domain + */ + public PersonIdentity getPersonIdentifiersFromKey(String key) { + + String name, domain; + if (key.contains("@")) { + return personKeyUtils.getPersonIdentity(key); + } else { + name = key; + domain = urlUtil.cleanUrlProtocol(localInstanceContext.instance() + .getDomain()); + } + + return PersonIdentity.builder() + .name(name) + .domain(domain) + .build(); + } + + /** + * Retrieves a list of {@link PersonResponse} objects based on the provided search criteria. + * + * @param indexPerson A {@link IndexPerson} object containing the search parameters. + * @param person A {@link Person} object representing the authenticated user. + * @return A list of {@link PersonResponse} objects that match the search criteria. + * @throws ResponseStatusException If the authenticated user does not have permission to perform + * the action. + */ + public List index(final IndexPerson indexPerson, final Person person) { + + aclService.canPerson(person) + .performTheAction(RolePermissionPersonTypes.READ_USER) + .orThrowUnauthorized(); + + final int page = PaginationUtils.getPage(indexPerson.page() == null ? 0 : indexPerson.page()) + - 1; + final int perPage = PaginationUtils.getPerPage( + indexPerson.perPage() == null ? 20 : indexPerson.perPage()) - 1; + + if (indexPerson.search() == null) { + return handleNullSearch(indexPerson, page, perPage); + } + + return handleNonNullSearch(indexPerson, page, perPage); + } + + /** + * Handles a null search for person records based on the specified index person, page number, and + * items per page. + * + * @param indexPerson the index person used for determining the type of listing + * @param page the page number for pagination + * @param perPage the number of items per page + * @return a list of {@code PersonResponse} objects based on the search query + */ + private List handleNullSearch(IndexPerson indexPerson, int page, int perPage) { + + if (indexPerson.listingType() == SublinksListingType.Local) { + return personRepository.findAllByIsLocal(true, PageRequest.of(page, perPage)) + .stream() + .map(p -> conversionService.convert(p, PersonResponse.class)) + .toList(); + } + return personRepository.findAll(PageRequest.of(page, perPage)) + .stream() + .map(p -> conversionService.convert(p, PersonResponse.class)) + .toList(); + } + + /** + * Handles a non-null search for persons in the index. + * + * @param indexPerson the IndexPerson object containing the search parameters + * @param page the page number for pagination + * @param perPage the number of results per page for pagination + * @return a List of PersonResponse objects that match the search criteria + */ + private List handleNonNullSearch(IndexPerson indexPerson, int page, int perPage) { + + if (indexPerson.listingType() == SublinksListingType.Local) { + return personRepository.findAllByNameAndBiographyAndLocal(indexPerson.search(), true, + PageRequest.of(page, perPage)) + .stream() + .map(p -> conversionService.convert(p, PersonResponse.class)) + .toList(); + } + return personRepository.findAllByNameAndBiography(indexPerson.search(), + PageRequest.of(page, perPage)) + .stream() + .map(p -> conversionService.convert(p, PersonResponse.class)) + .toList(); + } + + /** + * Retrieves a PersonResponse object based on the provided key. + * + * @param key The key containing the person's information. If the key contains "@", it is split + * into name and domain using "@" as the separator. Otherwise, the name is set as the + * key and the domain is obtained from the local instance context. + * @return The PersonResponse object representing the person information. + * @throws ResponseStatusException if the person is not found. + */ + public PersonResponse show(final String key, final Person person) { + + rolePermissionService.isPermitted(person, RolePermissionPersonTypes.READ_USER, + () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized")); + + final PersonIdentity ids = getPersonIdentifiersFromKey(key); + + return personRepository.findOneByNameAndInstance_Domain(ids.name(), ids.domain()) + .map(p -> conversionService.convert(p, PersonResponse.class)) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "person_not_found")); + } + + /** + * Registers a person with the given details. + * + * @param createPersonForm The form containing the details of the person to be registered. + * @param ip The IP address of the client making the registration request. + * @param userAgent The user agent string of the client making the registration request. + * @return The registration response containing the token, registration status, and error (if + * any). + * @throws ResponseStatusException if the email is required but not provided, or if the email send + * failed. + */ + public LoginResponse registerPerson(final CreatePerson createPersonForm, final String ip, + final String userAgent) + { + + final InstanceConfig instanceConfig = localInstanceContext.instance() + .getInstanceConfig(); + + if (instanceConfig != null && instanceConfig.isRequireEmailVerification()) { + if (createPersonForm.email() + .isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "email_required"); + } + } + + // Password gets encrypted inside .createPerson + final Person.PersonBuilder personBuilder = Person.builder() + .password(createPersonForm.password()) + .name(createPersonForm.name()) + .displayName(createPersonForm.displayName()) + .avatarImageUrl(createPersonForm.avatarImageUrl()) + .bannerImageUrl(createPersonForm.bannerImageUrl()) + .biography(createPersonForm.bio()) + .matrixUserId(createPersonForm.matrixUserId()); + + final Person person = personBuilder.build(); + + personService.createPerson(person); + + String token = sublinksJwtUtil.generateToken(person); + RegistrationState status = RegistrationState.CREATED; + + if (instanceConfig != null && instanceConfig.isRequireEmailVerification()) { + token = null; + + PersonEmailVerification personEmailVerification = personEmailVerificationService.create( + person, ip, userAgent); + + Map params = emailService.getDefaultEmailParameters(); + + params.put("person", person); + params.put("verificationUrl", localInstanceContext.instance() + .getDomain() + "/verify_email/" + personEmailVerification.getToken()); + try { + final String template_name = EmailTemplatesEnum.VERIFY_EMAIL.toString(); + emailService.saveToQueue(Email.builder() + .personRecipients(List.of(person)) + .subject(emailService.getSubjects() + .get(template_name) + .getAsString()) + .htmlContent(emailService.formatTextEmailTemplate(template_name, + new Context(Locale.getDefault(), params))) + .textContent(emailService.formatEmailTemplate(template_name, + new Context(Locale.getDefault(), params))) + .build()); + status = RegistrationState.VERIFICATION_EMAIL_SENT; + } catch (Exception e) { + personRepository.delete(person); + return LoginResponse.builder() + .status(RegistrationState.NOT_CREATED) + .error("email_send_failed") + .build(); + } + } + if (instanceConfig != null + && instanceConfig.getRegistrationMode() == RegistrationMode.RequireApplication) { + token = null; + + personRegistrationApplicationService.createPersonRegistrationApplication( + PersonRegistrationApplication.builder() + .applicationStatus(instanceConfig.isRequireEmailVerification() + ? PersonRegistrationApplicationStatus.inactive + : PersonRegistrationApplicationStatus.pending) + .person(person) + .question(instanceConfig.getRegistrationQuestion()) + .answer(createPersonForm.answer()) + .build()); + if (!instanceConfig.isRequireEmailVerification()) { + status = RegistrationState.APPLICATION_CREATED; + } + } + + if (token != null) { + userDataService.checkAndAddIpRelation(person, ip, token, userAgent); + } + + return LoginResponse.builder() + .token(token) + .status(status) + .build(); + } + + /** + * Logs in a person with the given credentials. + * + * @param loginPersonForm The login details of the person. + * @param ip The IP address of the client making the login request. + * @param userAgent The user agent string of the client making the login request. + * @return The login response containing the token, registration status, and error (if any). + * @throws ResponseStatusException if the person is not found, or if the person is deleted, or if + * the person's email is not verified, or if the person's + * registration application is not approved, or if the password is + * incorrect. + */ + public LoginResponse login(final LoginPerson loginPersonForm, final String ip, + final String userAgent) + { + + final Person person = personRepository.findOneByNameIgnoreCase(loginPersonForm.username()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "person_not_found")); + + rolePermissionService.isPermitted(person, RolePermissionPersonTypes.USER_LOGIN, + () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized")); + + if (!rolePermissionService.isPermitted(person, RolePermissionPersonTypes.USER_LOGIN)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized"); + } + + if (person.isDeleted()) { + return LoginResponse.builder() + .status(RegistrationState.UNCHANGED) + .error("person_deleted") + .build(); + } + + if (!person.isEmailVerified()) { + return LoginResponse.builder() + .status(RegistrationState.UNCHANGED) + .error("email_not_verified") + .build(); + } + + Optional application = personRegistrationApplicationRepository.findOneByPerson( + person); + + if (application.isPresent() && application.get() + .getApplicationStatus() != PersonRegistrationApplicationStatus.approved) { + return LoginResponse.builder() + .status(RegistrationState.UNCHANGED) + .error("application_not_approved") + .build(); + } + + if (!personService.isValidPersonPassword(person, loginPersonForm.password())) { + return LoginResponse.builder() + .status(RegistrationState.UNCHANGED) + .error("password_incorrect") + .build(); + } + + final String token = sublinksJwtUtil.generateToken(person); + + userDataService.checkAndAddIpRelation(person, ip, token, userAgent); + + return LoginResponse.builder() + .token(token) + .status(RegistrationState.UNCHANGED) + .build(); + } + + /** + * Updates a person's information. + * + * @param person The person object to be updated. + * @param updatePersonForm The form containing updated person information. + * @return The updated person response. + */ + public PersonResponse updatePerson(Person person, UpdatePerson updatePersonForm) { + + rolePermissionService.isPermitted(person, RolePermissionPersonTypes.UPDATE_USER_SETTINGS, + () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized")); + + if (person.isDeleted()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "person_deleted"); + } + + if (Optional.ofNullable(updatePersonForm.languagesKeys()) + .isPresent()) { + person.setLanguages(languageRepository.findAllByCodeIsIn(updatePersonForm.languagesKeys())); + } + + Optional.ofNullable(updatePersonForm.displayName()) + .ifPresent(person::setDisplayName); + Optional.ofNullable(updatePersonForm.email()) + .ifPresent(person::setEmail); + Optional.ofNullable(updatePersonForm.avatarImageUrl()) + .ifPresent(person::setAvatarImageUrl); + Optional.ofNullable(updatePersonForm.bannerImageUrl()) + .ifPresent(person::setBannerImageUrl); + Optional.ofNullable(updatePersonForm.bio()) + .ifPresent(person::setBiography); + Optional.ofNullable(updatePersonForm.matrixUserId()) + .ifPresent(person::setMatrixUserId); + + if (Optional.ofNullable(updatePersonForm.password()) + .isPresent()) { + Optional.ofNullable(updatePersonForm.oldPassword()) + .ifPresent(oldPassword -> { + if (!personService.isValidPersonPassword(person, oldPassword)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "password_incorrect"); + } + }); + if (!updatePersonForm.password() + .equals(updatePersonForm.passwordConfirmation())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "passwords_do_not_match"); + } + personService.updatePassword(person, updatePersonForm.password()); + } + + personRepository.save(person); + + return conversionService.convert(person, PersonResponse.class); + } + + /** + * Retrieves a list of banned persons based on the search criteria. + * + * @param indexBannedPersonForm The form containing the search criteria. + * @return A list of Person objects representing the banned persons. + * @throws ResponseStatusException if the banned role is not found. + */ + public List indexBannedPersons(final IndexBannedPerson indexBannedPersonForm, + final Person person) + { + + rolePermissionService.isPermitted(person, RolePermissionInstanceTypes.INSTANCE_BAN_READ, + () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized")); + Optional bannedRole = roleService.getBannedRole(); + + if (bannedRole.isEmpty()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "banned_role_not_found"); + } + + return personRepository.findAllByNameAndBiographyAndRole(indexBannedPersonForm.search(), + bannedRole.get() + .getId(), PageRequest.of(indexBannedPersonForm.limit(), indexBannedPersonForm.page(), + indexBannedPersonForm.sortOrder() == SortOrder.Asc ? Sort.by("name") + .ascending() : Sort.by("name") + .descending())); + } + + /** + * Bans a person based on the provided ban form and person details. + * + * @param banPersonForm The form containing the ban details. + * @param person The person to be banned. + * @return The PersonResponse object representing the updated person information. + */ + public PersonResponse banPerson(final BanPerson banPersonForm, final Person person) { + + PersonIdentity ids = getPersonIdentifiersFromKey(banPersonForm.key()); + + rolePermissionService.isPermitted(person, RolePermissionInstanceTypes.INSTANCE_BAN_USER, + () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized")); + + Person bannedPerson = personRepository.findOneByNameAndInstance_Domain(ids.name(), ids.domain()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "person_not_found")); + + bannedPerson.setRole(banPersonForm.ban() ? roleService.getBannedRole( + () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "banned_role_not_found")) + : roleService.getDefaultRegisteredRole( + () -> new ResponseStatusException(HttpStatus.NOT_FOUND, + "default_registered_role_not_found"))); + personService.updatePerson(person); + // @todo: modlog + + return conversionService.convert(person, PersonResponse.class); + } + + /** + * Retrieves the aggregate information of a person. + * + * @param key The key containing the person's information. If the key contains "@", it is split + * into name and domain using "@" as the separator. Otherwise, the name is set as + * the key and the domain is obtained from the local instance context. + * @param person The Person object representing the person making the request. + * @return The PersonAggregateResponse object containing the aggregate information of the person. + * @throws ResponseStatusException If the person is not authorized to read the person aggregation + * or if the person is not found. + */ + public PersonAggregateResponse showAggregate(final String key, final Person person) { + + rolePermissionService.isPermitted(person, RolePermissionPersonTypes.READ_PERSON_AGGREGATION, + () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized")); + + final PersonIdentity personIdentity = getPersonIdentifiersFromKey(key); + + final Person foundPerson = personRepository.findOneByNameAndInstance_Domain( + personIdentity.name(), personIdentity.domain()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "person_not_found")); + + final PersonAggregate personAggregate = personAggregateRepository.findByPerson(foundPerson); + + return conversionService.convert(personAggregate, PersonAggregateResponse.class); + } + + /** + * Sets the person deleted to true. + * + * @param key The key containing the person's information. If the key contains "@", + * it is split into name and domain using "@" as the separator. Otherwise, + * the name is set as the key and the domain is obtained from the local + * instance context. + * @param deletePersonForm The form containing the reason for deleting the person. + * @param person The person object to be deleted. + * @return The PersonResponse object representing the deleted person information. + * @throws ResponseStatusException if the person is not authorized to delete a user, if the person + * is not found, or if there is an error while deleting the + * person. + */ + public PersonResponse deletePerson(final String key, final DeletePerson deletePersonForm, + final Person person) + { + + rolePermissionService.isPermitted(person, RolePermissionPersonTypes.DELETE_USER, () -> { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized"); + }); + + final PersonIdentity ids = getPersonIdentifiersFromKey(key); + + final Person personToDelete = personRepository.findOneByNameAndInstance_Domain(ids.name(), + ids.domain()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "person_not_found")); + + personToDelete.setDeleted(true); + // @todo: modlog + personRepository.save(personToDelete); + + // Resolve all reports by the person + postReportService.resolveAllReportsByPerson(personToDelete, person); + commentReportService.resolveAllReportsByCommentCreator(personToDelete, person); + privateMessageReportService.resolveAllReportsByPerson(personToDelete, person); + + personService.deleteUserAccount(personToDelete, deletePersonForm.deleteContent()); + + return conversionService.convert(personToDelete, PersonResponse.class); + } + + + /** + * Retrieves the meta data of a person's sessions based on the provided target key and person. + * + * @param targetKey The key containing the target person's information. + * @param person The Person object representing the person making the request. + * @return The PersonSessionDataResponse object containing the meta data of the target person's + * sessions. + * @throws ResponseStatusException If the person is not authorized to read the person's meta data + * or if the person does not have permission to read the target + * person's meta data or if the target person is not found. + */ + public PersonSessionDataResponse getMetaData(final String targetKey, final Person person) { + + final PersonIdentity targetIds = getPersonIdentifiersFromKey(targetKey); + + final Person target = personRepository.findOneByNameAndInstance_Domain(targetIds.name(), + targetIds.domain()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "person_not_found")); + + if (!(rolePermissionService.isPermitted(person, + RolePermissionPersonTypes.READ_USER_OWN_METADATAS) && person.equals(target)) + && !rolePermissionService.isPermitted(person, + RolePermissionPersonTypes.READ_USER_METADATAS)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized"); + } + + final List personMetaData = userDataRepository.findAllByPerson(target); + + return PersonSessionDataResponse.builder() + .personKey(targetIds.name() + "@" + targetIds.domain()) + .sessions(personMetaData.stream() + .map(m -> conversionService.convert(m, PersonSessionData.class)) + .toList()) + .build(); + } + + /** + * Retrieves the meta data of a single session belonging to a person based on the provided session + * key and person. + * + * @param targetSessionKey The session key containing the session's information. + * @param person The Person object representing the person making the request. + * @return The PersonSessionDataResponse object containing the meta data of the session. + * @throws ResponseStatusException If the person is not authorized to read the person's meta data + * or if the person does not have permission to read the session's + * meta data or if the session is not found. + */ + public PersonSessionDataResponse getOneMetaData(final String targetSessionKey, + final Person person) + { + + final PersonMetaData personMetaData = userDataRepository.findById( + Long.parseLong(targetSessionKey)) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "person_metadata_not_found")); + + if (!(rolePermissionService.isPermitted(person, + RolePermissionPersonTypes.READ_USER_OWN_METADATAS) && personMetaData.getPerson() + .equals(person)) && !rolePermissionService.isPermitted(person, + RolePermissionPersonTypes.READ_USER_METADATAS)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized"); + } + + return PersonSessionDataResponse.builder() + .personKey(personMetaData.getPerson() + .getName() + "@" + personMetaData.getPerson() + .getInstance() + .getDomain()) + .sessions(List.of(Objects.requireNonNull( + conversionService.convert(personMetaData, PersonSessionData.class)))) + .build(); + } + + /** + * Invalidates the data associated with a specific user. + * + * @param targetUserData The key containing the user's data. If the key contains "@", it is split + * into name and domain using "@" as the separator. Otherwise, the key is + * assumed to be the user ID. + * @param person The Person object representing the user performing the operation. + * @throws ResponseStatusException If the user is not authorized to invalidate the user data or if + * the user data is not found. + */ + public void invalidateUserData(String targetUserData, final Person person) { + + final PersonMetaData personMetaData = userDataRepository.findById( + Long.parseLong(targetUserData)) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "person_metadata_not_found")); + if (!(rolePermissionService.isPermitted(person, + RolePermissionPersonTypes.INVALIDATE_USER_OWN_METADATA) && personMetaData.getPerson() + .equals(person)) && !rolePermissionService.isPermitted(person, + RolePermissionPersonTypes.INVALIDATE_USER_METADATA)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized"); + } + + userDataService.invalidate(personMetaData); + } + + /** + * Invalidates the data associated with all user data for a specific person. + * + * @param targetPersonKey The key containing the target person's information. If the key contains + * "@", it is split into name and domain using "@" as the separator. + * Otherwise, the key is assumed to be the user ID. + * @param person The Person object representing the user performing the operation. + * @throws ResponseStatusException If the user is not authorized to invalidate the user data or if + * the person is not found. + */ + public void invalidateAllUserData(final String targetPersonKey, final Person person) { + + final PersonIdentity personIdentity = personKeyUtils.getPersonIdentity(targetPersonKey); + + final Person target = personRepository.findOneByNameAndInstance_Domain(personIdentity.name(), + personIdentity.domain()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "person_not_found")); + + if (!(rolePermissionService.isPermitted(person, + RolePermissionPersonTypes.INVALIDATE_USER_OWN_METADATA) && target.equals(person)) + && !rolePermissionService.isPermitted(person, + RolePermissionPersonTypes.INVALIDATE_USER_METADATA)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized"); + } + + userDataService.invalidateAllUserData(target); + } + + /** + * Deletes the user data for a given targetUserDataKey and person. + * + * @param targetUserDataKey The key of the user data to delete. + * @param person The person associated with the user data. + * @throws ResponseStatusException If the user data is not found or the person is not authorized + * to delete it. + */ + public void deleteUserData(final String targetUserDataKey, final Person person) { + + final PersonMetaData personMetaData = userDataRepository.findById( + Long.parseLong(targetUserDataKey)) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "person_metadata_not_found")); + if (rolePermissionService.isPermitted(person, + RolePermissionPersonTypes.DELETE_USER_OWN_METADATA) && !personMetaData.getPerson() + .equals(person) && !rolePermissionService.isPermitted(person, + RolePermissionPersonTypes.DELETE_USER_METADATA)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized"); + } + + userDataRepository.delete(personMetaData); + } + + /** + * Deletes all user data for a given person. + * + * @param targetPersonKey The unique key of the target person. + * @param person The person performing the deletion. + * @throws ResponseStatusException if the target person is not found or the person is not + * authorized to delete the data. + */ + public void deleteAllUserData(final String targetPersonKey, final Person person) + { + + final PersonIdentity targetPersonIdentity = personKeyUtils.getPersonIdentity(targetPersonKey); + + final Person target = personRepository.findOneByNameAndInstance_Domain( + targetPersonIdentity.name(), targetPersonIdentity.domain()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "person_not_found")); + if (!(rolePermissionService.isPermitted(person, + RolePermissionPersonTypes.DELETE_USER_OWN_METADATA) && target.equals(person)) + && !rolePermissionService.isPermitted(person, + RolePermissionPersonTypes.DELETE_USER_METADATA)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized"); + } + userDataRepository.deleteAll(userDataRepository.findAllByPerson(target)); + } + + /** + * Invalidates the user data associated with a specific token. + * + * @param token The token associated with the user data. + * @param person The person performing the operation. + * @throws ResponseStatusException If the user data is not found or the person is not authorized + * to invalidate it. + */ + public void invalidateUserDataByToken(final String token, final Person person) { + + final PersonMetaData personMetaData = userDataRepository.findByToken(token) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "person_metadata_not_found")); + + if (!(rolePermissionService.isPermitted(person, + RolePermissionPersonTypes.INVALIDATE_USER_OWN_METADATA) && personMetaData.getPerson() + .equals(person)) && !rolePermissionService.isPermitted(person, + RolePermissionPersonTypes.INVALIDATE_USER_METADATA)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized"); + } + + userDataService.invalidate(personMetaData); + } + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/controllers/SublinksPostAggerateController.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/controllers/SublinksPostAggerateController.java new file mode 100644 index 000000000..274de6e22 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/controllers/SublinksPostAggerateController.java @@ -0,0 +1,39 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.post.controllers; + +import com.sublinks.sublinksapi.api.sublinks.v1.authentication.SublinksJwtPerson; +import com.sublinks.sublinksapi.api.sublinks.v1.common.controllers.AbstractSublinksApiController; +import com.sublinks.sublinksapi.api.sublinks.v1.post.models.AggregatePostResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.post.services.SublinksPostService; +import com.sublinks.sublinksapi.person.entities.Person; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.Optional; +import lombok.AllArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@AllArgsConstructor +@RequestMapping("api/v1/post/{key}/aggregate") +@Tag(name = "Sublinks Post Aggregation", description = "Post Aggregate API") +public class SublinksPostAggerateController extends AbstractSublinksApiController { + + private final SublinksPostService sublinksPostService; + + @Operation(summary = "Aggregate a comment") + @GetMapping + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public AggregatePostResponse aggregate(@PathVariable final String key, + final SublinksJwtPerson sublinksJwtPerson) + { + + final Optional person = getOptionalPerson(sublinksJwtPerson); + + return sublinksPostService.aggregate(key, person.orElse(null)); + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/controllers/SublinksPostController.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/controllers/SublinksPostController.java new file mode 100644 index 000000000..b54ae3dd6 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/controllers/SublinksPostController.java @@ -0,0 +1,116 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.post.controllers; + +import com.sublinks.sublinksapi.api.sublinks.v1.authentication.SublinksJwtPerson; +import com.sublinks.sublinksapi.api.sublinks.v1.common.controllers.AbstractSublinksApiController; +import com.sublinks.sublinksapi.api.sublinks.v1.common.models.RequestResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.post.models.CreatePost; +import com.sublinks.sublinksapi.api.sublinks.v1.post.models.DeletePost; +import com.sublinks.sublinksapi.api.sublinks.v1.post.models.IndexPost; +import com.sublinks.sublinksapi.api.sublinks.v1.post.models.PostResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.post.models.UpdatePost; +import com.sublinks.sublinksapi.api.sublinks.v1.post.models.moderation.FavoritePost; +import com.sublinks.sublinksapi.api.sublinks.v1.post.services.SublinksPostService; +import com.sublinks.sublinksapi.person.entities.Person; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import java.util.Optional; +import lombok.AllArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("api/v1/post") +@Tag(name = "Sublinks Post", description = "Post API") +@AllArgsConstructor +public class SublinksPostController extends AbstractSublinksApiController { + + private final SublinksPostService sublinksPostService; + + @Operation(summary = "Get a list of posts") + @GetMapping + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public List index(final Optional indexPost, + final SublinksJwtPerson sublinksJwtPerson) + { + + final Optional person = getOptionalPerson(sublinksJwtPerson); + + return sublinksPostService.index(indexPost.orElse(IndexPost.builder() + .build()), person.orElse(null)); + } + + @Operation(summary = "Get a specific post") + @GetMapping("/{key}") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public PostResponse show(@PathVariable String key, final SublinksJwtPerson sublinksJwtPerson) { + + final Optional person = getOptionalPerson(sublinksJwtPerson); + + return sublinksPostService.show(key, person.orElse(null)); + } + + @Operation(summary = "Create a new post") + @PostMapping + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public void create(final CreatePost createPost, final SublinksJwtPerson sublinksJwtPerson) { + + final Person person = getPersonOrThrowUnauthorized(sublinksJwtPerson); + + sublinksPostService.create(createPost, person); + } + + @Operation(summary = "Update an post") + @PostMapping("/{key}") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public PostResponse update(final UpdatePost updatePostForm, @PathVariable final String key, + final SublinksJwtPerson sublinksJwtPerson) + { + + final Person person = getPersonOrThrowUnauthorized(sublinksJwtPerson); + + return sublinksPostService.update(key, updatePostForm, person); + } + + @Operation(summary = "Delete an post") + @DeleteMapping("/{key}") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public RequestResponse delete(@PathVariable final String key, final DeletePost deletePostParam, + final SublinksJwtPerson sublinksJwtPerson) + { + + final Person person = getPersonOrThrowUnauthorized(sublinksJwtPerson); + + sublinksPostService.delete(key, deletePostParam, person); + + return RequestResponse.builder() + .success(true) + .build(); + } + + @Operation(summary = "Favorite a post") + @PostMapping("{key}/favorite") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public PostResponse favorite(@PathVariable final String key, + @RequestBody final FavoritePost favoritePostForm, final SublinksJwtPerson sublinksJwtPerson) + { + + final Person person = getPersonOrThrowUnauthorized(sublinksJwtPerson); + + return sublinksPostService.favorite(key, favoritePostForm, person); + } + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/controllers/SublinksPostModerationController.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/controllers/SublinksPostModerationController.java new file mode 100644 index 000000000..10df95f06 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/controllers/SublinksPostModerationController.java @@ -0,0 +1,85 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.post.controllers; + +import com.sublinks.sublinksapi.api.sublinks.v1.authentication.SublinksJwtPerson; +import com.sublinks.sublinksapi.api.sublinks.v1.common.controllers.AbstractSublinksApiController; +import com.sublinks.sublinksapi.api.sublinks.v1.common.models.RequestResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.post.models.PostResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.post.models.moderation.PinPost; +import com.sublinks.sublinksapi.api.sublinks.v1.post.models.moderation.PurgePost; +import com.sublinks.sublinksapi.api.sublinks.v1.post.models.moderation.RemovePost; +import com.sublinks.sublinksapi.api.sublinks.v1.post.services.SublinksPostService; +import com.sublinks.sublinksapi.person.entities.Person; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.AllArgsConstructor; +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; + +@RestController +@RequestMapping("api/v1/post/{key}/moderation") +@Tag(name = "Sublinks Post Moderation", description = "Post Moderation API") +@AllArgsConstructor +public class SublinksPostModerationController extends AbstractSublinksApiController { + + private final SublinksPostService sublinksPostService; + + @Operation(summary = "Remove a post") + @PostMapping + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public void remove(@PathVariable final String key, @RequestBody final RemovePost removePostForm, + final SublinksJwtPerson sublinksJwtPerson) + { + + final Person person = getPersonOrThrowUnauthorized(sublinksJwtPerson); + + sublinksPostService.remove(key, removePostForm, person); + } + + @Operation(summary = "Pin a post") + @PostMapping("/pin") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public PostResponse pin(@PathVariable final String key, @RequestBody final PinPost pinPostForm, + final SublinksJwtPerson sublinksJwtPerson) + { + + final Person person = getPersonOrThrowUnauthorized(sublinksJwtPerson); + + return sublinksPostService.pin(key, pinPostForm, person); + } + + @Operation(summary = "Pin a post in a community") + @PostMapping("/pin/community") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public PostResponse pinInCommunity(@PathVariable final String key, + @RequestBody final PinPost pinPostForm, final SublinksJwtPerson sublinksJwtPerson) + { + + final Person person = getPersonOrThrowUnauthorized(sublinksJwtPerson); + + return sublinksPostService.pinCommunity(key, pinPostForm, person); + } + + @Operation(summary = "Purge a post") + @PostMapping("/purge") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public RequestResponse delete(@PathVariable final String key, + @RequestBody final PurgePost purgePostForm, final SublinksJwtPerson sublinksJwtPerson) + { + + final Person person = getPersonOrThrowUnauthorized(sublinksJwtPerson); + + return sublinksPostService.purge(key, purgePostForm != null ? purgePostForm + : PurgePost.builder() + .build(), person); + + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/mappers/SublinksLinkMetaDataMapper.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/mappers/SublinksLinkMetaDataMapper.java new file mode 100644 index 000000000..6a8d13c04 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/mappers/SublinksLinkMetaDataMapper.java @@ -0,0 +1,24 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.post.mappers; + +import com.sublinks.sublinksapi.api.sublinks.v1.post.models.LinkMetaData; +import com.sublinks.sublinksapi.post.entities.Post; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingConstants; +import org.springframework.core.convert.converter.Converter; +import org.springframework.lang.Nullable; + +@Mapper(componentModel = MappingConstants.ComponentModel.SPRING) +public abstract class SublinksLinkMetaDataMapper implements Converter { + + @Override + @Mapping(target = "postKey", source = "post.titleSlug") + @Mapping(target = "linkUrl", source = "post.linkUrl") + @Mapping(target = "linkTitle", source = "post.linkTitle") + @Mapping(target = "linkDescription", source = "post.linkDescription") + @Mapping(target = "linkThumbnailUrl", source = "post.linkThumbnailUrl") + @Mapping(target = "LinkVideoUrl", source = "post.linkVideoUrl") + public abstract LinkMetaData convert(@Nullable Post post); + + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/mappers/SublinksPostAggregationMapper.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/mappers/SublinksPostAggregationMapper.java new file mode 100644 index 000000000..43488da0a --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/mappers/SublinksPostAggregationMapper.java @@ -0,0 +1,26 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.post.mappers; + +import com.sublinks.sublinksapi.api.sublinks.v1.post.models.AggregatePostResponse; +import com.sublinks.sublinksapi.post.entities.PostAggregate; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingConstants; +import org.springframework.core.convert.converter.Converter; +import org.springframework.lang.Nullable; + +@Mapper(componentModel = MappingConstants.ComponentModel.SPRING) +public abstract class SublinksPostAggregationMapper implements + Converter { + + @Override + @Mapping(target = "key", source = "postAggregate.id") + @Mapping(target = "commentCount", source = "postAggregate.commentCount") + @Mapping(target = "downvoteCount", source = "postAggregate.downVoteCount") + @Mapping(target = "upvoteCount", source = "postAggregate.upVoteCount") + @Mapping(target = "score", source = "postAggregate.score") + @Mapping(target = "hotRank", source = "postAggregate.hotRank") + @Mapping(target = "controversyRank", source = "postAggregate.controversyRank") + public abstract AggregatePostResponse convert(@Nullable PostAggregate postAggregate); + + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/mappers/SublinksPostMapper.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/mappers/SublinksPostMapper.java new file mode 100644 index 000000000..91b83cf15 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/mappers/SublinksPostMapper.java @@ -0,0 +1,65 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.post.mappers; + +import com.sublinks.sublinksapi.api.sublinks.v1.community.mappers.SublinksCommunityMapper; +import com.sublinks.sublinksapi.api.sublinks.v1.person.mappers.SublinksPersonMapper; +import com.sublinks.sublinksapi.api.sublinks.v1.person.models.PersonResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.post.models.PostResponse; +import com.sublinks.sublinksapi.person.entities.LinkPersonPost; +import com.sublinks.sublinksapi.person.enums.LinkPersonPostType; +import com.sublinks.sublinksapi.post.entities.Post; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingConstants; +import org.mapstruct.ReportingPolicy; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.converter.Converter; +import org.springframework.lang.Nullable; + +@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, + unmappedTargetPolicy = ReportingPolicy.IGNORE, + uses = {SublinksLinkMetaDataMapper.class, SublinksPostAggregationMapper.class, + SublinksPersonMapper.class, SublinksCommunityMapper.class, ConversionService.class}) +public abstract class SublinksPostMapper implements Converter { + + protected SublinksPostAggregationMapper sublinksPostAggregationMapper; + protected SublinksLinkMetaDataMapper sublinksLinkMetaDataMapper; + protected SublinksPersonMapper sublinksPersonMapper; + protected SublinksCommunityMapper sublinksCommunityMapper; + protected ConversionService conversionService; + + @Override + @Mapping(target = "key", source = "post.titleSlug") + @Mapping(target = "title", source = "post.title") + @Mapping(target = "titleSlug", source = "post.titleSlug") + @Mapping(target = "body", source = "post.postBody") + @Mapping(target = "linkMetaData", source = "post") + @Mapping(target = "isLocal", source = "post.local") + @Mapping(target = "isDeleted", source = "post.deleted") + @Mapping(target = "isNsfw", source = "post.nsfw") + @Mapping(target = "isLocked", source = "post.locked") + @Mapping(target = "isFeatured", source = "post.featured") + @Mapping(target = "isFeaturedInCommunity", source = "post.featuredInCommunity") + @Mapping(target = "community", source = "post.community") + @Mapping(target = "creator", + source = "post", + defaultExpression = "java(getCreator(post, conversionService))") + @Mapping(target = "postAggregate", source = "post.postAggregate") + @Mapping(target = "activityPubId", source = "post.activityPubId") + @Mapping(target = "publicKey", source = "post.publicKey") + @Mapping(target = "isRemoved", expression = "java(post.isRemoved())") + public abstract PostResponse convert(@Nullable Post post); + + + public PersonResponse getCreator(Post post, ConversionService conversionService) + { + + for (LinkPersonPost linkPersonPost : post.getLinkPersonPost()) { + if (linkPersonPost.getLinkType() + .equals(LinkPersonPostType.creator)) { + return conversionService.convert(linkPersonPost.getPerson(), PersonResponse.class); + } + } + return null; + } + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/models/AggregatePostResponse.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/models/AggregatePostResponse.java new file mode 100644 index 000000000..044cd0463 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/models/AggregatePostResponse.java @@ -0,0 +1,12 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.post.models; + +public record AggregatePostResponse( + String key, + String commentCount, + String downvoteCount, + String upvoteCount, + String score, + String hotRank, + String controversyRank) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/models/CreatePost.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/models/CreatePost.java new file mode 100644 index 000000000..3e5c5f90d --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/models/CreatePost.java @@ -0,0 +1,64 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.post.models; + +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import lombok.Builder; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +@Builder +public record CreatePost( + @Schema(description = "The title of the post", + requiredMode = RequiredMode.REQUIRED) String title, + @Schema(description = "The body of the post", + requiredMode = RequiredMode.NOT_REQUIRED) String body, + @Schema(description = "The language key of the post", + defaultValue = "und", + example = "und", + requiredMode = RequiredMode.NOT_REQUIRED) String languageKey, + @Schema(description = "Whether the post is featured ( Requires permission to do so. )", + defaultValue = "false", + example = "false", + requiredMode = RequiredMode.NOT_REQUIRED) Boolean featuredLocal, + @Schema(description = "Whether the post is featured in the community ( Requires permission to do so. )", + defaultValue = "false", + example = "false", + requiredMode = RequiredMode.NOT_REQUIRED) Boolean featuredCommunity, + + @Schema(description = "The community key of the post", + requiredMode = RequiredMode.NOT_REQUIRED) String communityKey, + @Schema(description = "Is the post nsfw", + defaultValue = "false", + example = "false", + requiredMode = RequiredMode.NOT_REQUIRED) Boolean nsfw, + @Schema(description = "The link of the post", + requiredMode = RequiredMode.NOT_REQUIRED) String link) { + + public CreatePost { + + if (title.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "title_can_not_be_blank"); + } + } + + public String languageKey() { + + return languageKey == null ? "und" : languageKey; + } + + public Boolean featuredLocal() { + + return featuredLocal != null && featuredLocal; + } + + public Boolean featuredCommunity() { + + return featuredCommunity != null && featuredCommunity; + } + + public Boolean nsfw() { + + return nsfw != null && nsfw; + } + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/models/DeletePost.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/models/DeletePost.java new file mode 100644 index 000000000..68ef50dc8 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/models/DeletePost.java @@ -0,0 +1,7 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.post.models; + +public record DeletePost( + String reason, + Boolean remove) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/models/IndexPost.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/models/IndexPost.java new file mode 100644 index 000000000..ba924b847 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/models/IndexPost.java @@ -0,0 +1,35 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.post.models; + +import com.sublinks.sublinksapi.api.sublinks.v1.common.enums.SortType; +import com.sublinks.sublinksapi.api.sublinks.v1.common.enums.SublinksListingType; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import java.util.List; +import lombok.Builder; + +@Builder +public record IndexPost( + @Schema(description = "Search query", requiredMode = RequiredMode.NOT_REQUIRED) String search, + @Schema(description = "Sort type", requiredMode = RequiredMode.NOT_REQUIRED) SortType sortType, + @Schema(description = "Listing type", + requiredMode = RequiredMode.NOT_REQUIRED) SublinksListingType listingType, + @Schema(description = "Community keys", + requiredMode = RequiredMode.NOT_REQUIRED) List communityKeys, + @Schema(description = "Show NSFW", requiredMode = RequiredMode.NOT_REQUIRED) Boolean showNsfw, + @Schema(description = "Saved only", requiredMode = RequiredMode.NOT_REQUIRED) Boolean savedOnly, + String pageCursor, + @Schema(description = "Per page", requiredMode = RequiredMode.NOT_REQUIRED) Integer perPage, + @Schema(description = "Page", requiredMode = RequiredMode.NOT_REQUIRED) Integer page) { + + @Override + public Integer perPage() { + + return perPage != null ? perPage : 20; + } + + @Override + public Integer page() { + + return page != null ? page : 0; + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/models/LinkMetaData.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/models/LinkMetaData.java new file mode 100644 index 000000000..1019abbfb --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/models/LinkMetaData.java @@ -0,0 +1,14 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.post.models; + +import lombok.Builder; + +@Builder +public record LinkMetaData( + String postKey, + String linkUrl, + String linkTitle, + String linkDescription, + String linkThumbnailUrl, + String LinkVideoUrl) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/models/PostResponse.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/models/PostResponse.java new file mode 100644 index 000000000..7f77f2023 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/models/PostResponse.java @@ -0,0 +1,27 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.post.models; + +import com.sublinks.sublinksapi.api.sublinks.v1.community.models.CommunityResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.person.models.PersonResponse; + +public record PostResponse( + String key, + String title, + String titleSlug, + String body, + LinkMetaData linkMetaData, + Boolean isLocal, + Boolean isDeleted, + Boolean isRemoved, + Boolean isNsfw, + Boolean isLocked, + Boolean isFeatured, + Boolean isFeaturedInCommunity, + CommunityResponse community, + PersonResponse creator, + AggregatePostResponse postAggregate, + String activityPubId, + String publicKey, + String createdAt, + String updatedAt) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/models/UpdatePost.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/models/UpdatePost.java new file mode 100644 index 000000000..7cc053a67 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/models/UpdatePost.java @@ -0,0 +1,45 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.post.models; + +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import lombok.Builder; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +@Builder +public record UpdatePost( + @Schema(description = "The title of the post", + requiredMode = RequiredMode.REQUIRED) String title, + @Schema(description = "The body of the post", + requiredMode = RequiredMode.NOT_REQUIRED) String body, + @Schema(description = "The language key of the post", + defaultValue = "und", + example = "und", + requiredMode = RequiredMode.NOT_REQUIRED) String languageKey, + @Schema(description = "Whether the post is featured ( Requires permission to do so. )", + defaultValue = "false", + example = "false", + requiredMode = RequiredMode.NOT_REQUIRED) Boolean featuredLocal, + @Schema(description = "Whether the post is featured in the community ( Requires permission to do so. )", + defaultValue = "false", + example = "false", + requiredMode = RequiredMode.NOT_REQUIRED) Boolean featuredCommunity, + @Schema(description = "Is the post nsfw", + defaultValue = "false", + example = "false", + requiredMode = RequiredMode.NOT_REQUIRED) Boolean nsfw, + @Schema(description = "The link of the post", + requiredMode = RequiredMode.NOT_REQUIRED) String link) { + + public UpdatePost { + + if (title.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "title_can_not_be_blank"); + } + } + + public String languageKey() { + + return languageKey == null ? "und" : languageKey; + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/models/moderation/DeletePost.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/models/moderation/DeletePost.java new file mode 100644 index 000000000..b751b608a --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/models/moderation/DeletePost.java @@ -0,0 +1,12 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.post.models.moderation; + +public record DeletePost( + String reason, + Boolean remove) { + + @Override + public Boolean remove() { + + return remove == null || remove; + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/models/moderation/FavoritePost.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/models/moderation/FavoritePost.java new file mode 100644 index 000000000..3f21286a0 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/models/moderation/FavoritePost.java @@ -0,0 +1,15 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.post.models.moderation; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record FavoritePost( + @Schema(description = "If the post is favourited by you", + defaultValue = "true", + example = "true") Boolean favorite) { + + @Override + public Boolean favorite() { + + return favorite == null || favorite; + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/models/moderation/PinPost.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/models/moderation/PinPost.java new file mode 100644 index 000000000..bf3fd741d --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/models/moderation/PinPost.java @@ -0,0 +1,5 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.post.models.moderation; + +public record PinPost(Boolean pin) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/models/moderation/PurgePost.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/models/moderation/PurgePost.java new file mode 100644 index 000000000..60ca9b96d --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/models/moderation/PurgePost.java @@ -0,0 +1,13 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.post.models.moderation; + +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import lombok.Builder; + +@Builder +public record PurgePost( + @Schema(description = "The reason for purging the post", + example = "This post is spam", + requiredMode = RequiredMode.NOT_REQUIRED) String reason) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/models/moderation/RemovePost.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/models/moderation/RemovePost.java new file mode 100644 index 000000000..38b687338 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/models/moderation/RemovePost.java @@ -0,0 +1,12 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.post.models.moderation; + +public record RemovePost( + String reason, + Boolean remove) { + + @Override + public Boolean remove() { + + return remove == null || remove; + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/services/SublinksPostService.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/services/SublinksPostService.java new file mode 100644 index 000000000..dbde04493 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/post/services/SublinksPostService.java @@ -0,0 +1,599 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.post.services; + +import com.sublinks.sublinksapi.api.sublinks.v1.common.models.RequestResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.post.models.AggregatePostResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.post.models.CreatePost; +import com.sublinks.sublinksapi.api.sublinks.v1.post.models.DeletePost; +import com.sublinks.sublinksapi.api.sublinks.v1.post.models.IndexPost; +import com.sublinks.sublinksapi.api.sublinks.v1.post.models.PostResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.post.models.UpdatePost; +import com.sublinks.sublinksapi.api.sublinks.v1.post.models.moderation.FavoritePost; +import com.sublinks.sublinksapi.api.sublinks.v1.post.models.moderation.PinPost; +import com.sublinks.sublinksapi.api.sublinks.v1.post.models.moderation.PurgePost; +import com.sublinks.sublinksapi.api.sublinks.v1.post.models.moderation.RemovePost; +import com.sublinks.sublinksapi.authorization.enums.RolePermissionPostTypes; +import com.sublinks.sublinksapi.authorization.services.AclService; +import com.sublinks.sublinksapi.authorization.services.RolePermissionService; +import com.sublinks.sublinksapi.community.entities.Community; +import com.sublinks.sublinksapi.community.repositories.CommunityRepository; +import com.sublinks.sublinksapi.instance.entities.Instance; +import com.sublinks.sublinksapi.language.entities.Language; +import com.sublinks.sublinksapi.language.repositories.LanguageRepository; +import com.sublinks.sublinksapi.person.entities.Person; +import com.sublinks.sublinksapi.person.enums.LinkPersonCommunityType; +import com.sublinks.sublinksapi.person.enums.LinkPersonPostType; +import com.sublinks.sublinksapi.person.enums.ListingType; +import com.sublinks.sublinksapi.person.enums.SortType; +import com.sublinks.sublinksapi.person.repositories.LinkPersonPostRepository; +import com.sublinks.sublinksapi.person.services.LinkPersonCommunityService; +import com.sublinks.sublinksapi.person.services.LinkPersonPostService; +import com.sublinks.sublinksapi.person.services.PersonService; +import com.sublinks.sublinksapi.post.entities.Post; +import com.sublinks.sublinksapi.post.entities.Post.PostBuilder; +import com.sublinks.sublinksapi.post.entities.PostReport; +import com.sublinks.sublinksapi.post.models.PostSearchCriteria; +import com.sublinks.sublinksapi.post.repositories.PostAggregateRepository; +import com.sublinks.sublinksapi.post.repositories.PostRepository; +import com.sublinks.sublinksapi.post.services.PostReportService; +import com.sublinks.sublinksapi.post.services.PostService; +import com.sublinks.sublinksapi.shared.RemovedState; +import com.sublinks.sublinksapi.slurfilter.exceptions.SlurFilterBlockedException; +import com.sublinks.sublinksapi.slurfilter.exceptions.SlurFilterReportException; +import com.sublinks.sublinksapi.slurfilter.services.SlurFilterService; +import com.sublinks.sublinksapi.utils.SiteMetadataUtil; +import com.sublinks.sublinksapi.utils.UrlUtil; +import java.util.List; +import java.util.Objects; +import lombok.AllArgsConstructor; +import org.springframework.core.convert.ConversionService; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +@AllArgsConstructor +@Service +public class SublinksPostService { + + private final PostService postService; + private final PostRepository postRepository; + private final ConversionService conversionService; + private final CommunityRepository communityRepository; + private final RolePermissionService rolePermissionService; + private final LinkPersonCommunityService linkPersonCommunityService; + private final LanguageRepository languageRepository; + private final Instance localInstance; + private final SlurFilterService slurFilterService; + private final UrlUtil urlUtil; + private final SiteMetadataUtil siteMetadataUtil; + private final PostReportService postReportService; + private final PersonService personService; + private final PostAggregateRepository postAggregateRepository; + private final LinkPersonPostService linkPersonPostService; + private final LinkPersonPostRepository linkPersonPostRepository; + private final AclService aclService; + + /** + * Retrieves a list of PostResponse objects based on the provided search criteria. + * + * @param indexPostForm The IndexPost object containing the search criteria. + * @param person The Person object representing the user. + * @return A list of PostResponse objects matching the search criteria. + */ + public List index(final IndexPost indexPostForm, final Person person) { + + aclService.canPerson(person) + .performTheAction(RolePermissionPostTypes.READ_POSTS) + .orThrowUnauthorized(); + + final List communities = indexPostForm.communityKeys() == null ? null + : communityRepository.findCommunityByTitleSlugIn(indexPostForm.communityKeys()); + + List posts = postRepository.allPostsBySearchCriteria(PostSearchCriteria.builder() + .search(indexPostForm.search()) + .sortType(conversionService.convert(indexPostForm.sortType(), SortType.class)) + .listingType(conversionService.convert(indexPostForm.listingType(), ListingType.class)) + .communityIds(communities == null ? null : communities.stream() + .map(Community::getId) + .toList()) + .isShowNsfw(indexPostForm.showNsfw()) + .isSavedOnly(indexPostForm.savedOnly()) + .perPage(indexPostForm.perPage()) + .page(indexPostForm.page()) + .person(person) + .cursorBasedPageable(indexPostForm.pageCursor()) + .build()); + + return posts.stream() + .map(post -> conversionService.convert(post, PostResponse.class)) + .toList(); + } + + /** + * Retrieves the PostResponse object for the given key and person. + * + * @param key The key of the post. + * @param person The person object representing the user. + * @return The PostResponse object matching the key. + * @throws ResponseStatusException If the post is not found or the user does not have permission + * to access the post. + */ + public PostResponse show(final String key, final Person person) { + + final Post post = postRepository.findByTitleSlug(key) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "post_not_found")); + + if (post.isRemoved() || post.isDeleted()) { + + final boolean isPermittedToReadRemovedPosts = + person != null && (rolePermissionService.isPermitted(person, + RolePermissionPostTypes.ADMIN_SHOW_DELETED_POST) || ( + rolePermissionService.isPermitted(person, + RolePermissionPostTypes.MODERATOR_SHOW_DELETED_POST) + && linkPersonCommunityService.hasAnyLink(person, post.getCommunity(), + List.of(LinkPersonCommunityType.moderator, LinkPersonCommunityType.owner)))); + + final boolean isOwner = person != null && postService.getPostCreator(post) == person; + + if (!isPermittedToReadRemovedPosts || !(isOwner && !post.isRemoved())) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "post_not_found"); + } + } + + return conversionService.convert(post, PostResponse.class); + } + + /** + * Creates a new Post with the provided form data and person. + * + * @param createPostForm The CreatePost object containing the form data for creating the post. + * @param person The Person object representing the user who is creating the post. + * @return The created PostResponse object. + * @throws ResponseStatusException If the community or feature post permission is not found or + * denied. + */ + public PostResponse create(final CreatePost createPostForm, final Person person) { + + aclService.canPerson(person) + .performTheAction(RolePermissionPostTypes.CREATE_POST) + .orThrowUnauthorized(); + + final Community community = communityRepository.findCommunityByTitleSlug( + createPostForm.communityKey()) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "community_not_found")); + + final Language language = createPostForm.languageKey() != null && !createPostForm.languageKey() + .isEmpty() ? languageRepository.findLanguageByCode(createPostForm.languageKey()) + : personService.getPersonDefaultPostLanguage(person, community) + .orElse(languageRepository.findLanguageByCode("und")); + + if (language == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "language_not_found"); + } + + if (community.getLanguages() + .stream() + .noneMatch(communityLanguage -> communityLanguage.getCode() + .equals(language.getCode()))) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "language_not_supported_by_community"); + } + + if (community.getInstance() + .getLanguages() + .stream() + .noneMatch(instanceLanguage -> instanceLanguage.getCode() + .equals(language.getCode()))) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "language_not_supported_by_instance"); + } + + // @todo: modlog? + + if (createPostForm.featuredLocal()) { + if (!rolePermissionService.isPermitted(person, RolePermissionPostTypes.ADMIN_PIN_POST)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized"); + } + } + if (createPostForm.featuredCommunity()) { + if (!(rolePermissionService.isPermitted(person, RolePermissionPostTypes.MODERATOR_PIN_POST) + && linkPersonCommunityService.hasAnyLink(person, community, + List.of(LinkPersonCommunityType.moderator, LinkPersonCommunityType.owner))) + && !rolePermissionService.isPermitted(person, RolePermissionPostTypes.ADMIN_PIN_POST)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized"); + } + } + + final PostBuilder postBuilder = Post.builder() + .title(createPostForm.title()) + .postBody(createPostForm.body()) + .language(language) + .community(community) + .isNsfw(createPostForm.nsfw()) + .isFeatured(createPostForm.featuredLocal()) + .isFeaturedInCommunity(createPostForm.featuredCommunity()) + .instance(localInstance) + .removedState(RemovedState.NOT_REMOVED); + + boolean shouldReport = false; + + try { + postBuilder.postBody(slurFilterService.censorText(createPostForm.body())); + } catch (SlurFilterReportException e) { + shouldReport = true; + postBuilder.postBody(createPostForm.body()); + } catch (SlurFilterBlockedException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "post_blocked_by_slur_filter"); + } + + try { + postBuilder.title(slurFilterService.censorText(createPostForm.title())); + } catch (SlurFilterReportException e) { + shouldReport = true; + postBuilder.title(createPostForm.title()); + } catch (SlurFilterBlockedException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "post_blocked_by_slur_filter"); + } + String url = createPostForm.link(); + SiteMetadataUtil.SiteMetadata metadata = null; + if (url != null) { + String metadataUrl = urlUtil.normalizeUrl(url); + urlUtil.checkUrlProtocol(metadataUrl); + metadata = siteMetadataUtil.fetchSiteMetadata(metadataUrl); + } + + if (url != null) { + postBuilder.linkUrl(url); + if (metadata != null) { + postBuilder.linkTitle(metadata.title()) + .linkDescription(metadata.description()) + .linkVideoUrl(metadata.videoUrl()) + .linkThumbnailUrl(metadata.imageUrl()); + } + } + + final Post post = postBuilder.build(); + postService.createPost(post, person); + + if (shouldReport) { + postReportService.createPostReport(PostReport.builder() + .post(post) + .creator(person) + .reason("AUTOMATED: Post creation triggered a slur filter") + .originalBody(post.getPostBody() == null ? "" : post.getPostBody()) + .originalTitle(post.getTitle() == null ? "" : post.getTitle()) + .originalUrl(post.getLinkUrl() == null ? "" : post.getLinkUrl()) + .build()); + } + + return conversionService.convert(post, PostResponse.class); + } + + /** + * Updates a post with the provided information. + * + * @param postKey The key of the post to update. + * @param updatePostForm The UpdatePost object containing the updated information. + * @param person The Person object representing the user making the update. + * @return The updated PostResponse object. + * @throws ResponseStatusException If the post is not found, the language is not supported, or the + * user does not have permission to update the post. + */ + public PostResponse update(final String postKey, final UpdatePost updatePostForm, + final Person person) + { + + final Post post = postRepository.findByTitleSlug(postKey) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "post_not_found")); + final Community community = post.getCommunity(); + + aclService.canPerson(person) + .onCommunity(community) + .performTheAction(RolePermissionPostTypes.UPDATE_POST) + .orThrowUnauthorized(); + + if (updatePostForm.languageKey() != null && !updatePostForm.languageKey() + .isEmpty()) { + + final Language language = languageRepository.findLanguageByCode(updatePostForm.languageKey()); + + if (language == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "language_not_found"); + } + + if (community.getLanguages() + .stream() + .noneMatch(communityLanguage -> communityLanguage.getCode() + .equals(language.getCode()))) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "language_not_supported_by_community"); + } + + if (community.getInstance() + .getLanguages() + .stream() + .noneMatch(instanceLanguage -> instanceLanguage.getCode() + .equals(language.getCode()))) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "language_not_supported_by_instance"); + } + + post.setLanguage(language); + } + + // @todo: modlog? + + if (updatePostForm.featuredLocal() != null) { + if (!rolePermissionService.isPermitted(person, RolePermissionPostTypes.ADMIN_PIN_POST)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized"); + } + + post.setFeatured(updatePostForm.featuredLocal()); + } + if (updatePostForm.featuredCommunity() != null) { + if (!(rolePermissionService.isPermitted(person, RolePermissionPostTypes.MODERATOR_PIN_POST) + && linkPersonCommunityService.hasAnyLink(person, post.getCommunity(), + List.of(LinkPersonCommunityType.moderator, LinkPersonCommunityType.owner))) + && !rolePermissionService.isPermitted(person, RolePermissionPostTypes.ADMIN_PIN_POST)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized"); + } + + post.setFeatured(updatePostForm.featuredCommunity()); + } + + boolean shouldReport = false; + + if (updatePostForm.title() != null && !updatePostForm.title() + .isBlank()) { + try { + post.setPostBody(slurFilterService.censorText(updatePostForm.body())); + } catch (SlurFilterReportException e) { + shouldReport = true; + post.setPostBody(updatePostForm.body()); + } catch (SlurFilterBlockedException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "post_blocked_by_slur_filter"); + } + } + + if (updatePostForm.nsfw() != null) { + post.setNsfw(updatePostForm.nsfw()); + } + if (updatePostForm.body() != null && !updatePostForm.body() + .isBlank()) { + try { + post.setTitle(slurFilterService.censorText(updatePostForm.title())); + } catch (SlurFilterReportException e) { + shouldReport = true; + post.setTitle(updatePostForm.title()); + } catch (SlurFilterBlockedException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "post_blocked_by_slur_filter"); + } + } + + String url = updatePostForm.link(); + SiteMetadataUtil.SiteMetadata metadata = null; + if (url != null) { + String metadataUrl = urlUtil.normalizeUrl(url); + urlUtil.checkUrlProtocol(metadataUrl); + metadata = siteMetadataUtil.fetchSiteMetadata(metadataUrl); + } + + if (url != null) { + post.setLinkUrl(url); + if (metadata != null) { + post.setLinkUrl(metadata.title()); + post.setLinkDescription(metadata.description()); + post.setLinkVideoUrl(metadata.videoUrl()); + post.setLinkThumbnailUrl(metadata.imageUrl()); + } + } + + postService.updatePost(post); + + if (shouldReport) { + postReportService.createPostReport(PostReport.builder() + .post(post) + .creator(person) + .reason("AUTOMATED: Post update triggered a slur filter") + .originalBody(post.getPostBody() == null ? "" : post.getPostBody()) + .originalTitle(post.getTitle() == null ? "" : post.getTitle()) + .originalUrl(post.getLinkUrl() == null ? "" : post.getLinkUrl()) + .build()); + } + + return conversionService.convert(post, PostResponse.class); + } + + /** + * Removes a post from the system. + * + * @param postKey The key of the post to be removed. + * @param removePostForm The RemovePost object containing the removal information. + * @param person The Person object representing the user removing the post. + * @return The PostResponse object for the removed post. + * @throws ResponseStatusException If the post is not found, the user is unauthorized, or an error + * occurs during the removal process. + */ + public PostResponse remove(final String postKey, final RemovePost removePostForm, + final Person person) + { + + final Post post = postRepository.findByTitleSlug(postKey) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "post_not_found")); + + if (!rolePermissionService.isPermitted(person, RolePermissionPostTypes.REMOVE_POST) && !( + rolePermissionService.isPermitted(person, RolePermissionPostTypes.MODERATOR_REMOVE_POST) + && linkPersonCommunityService.hasAnyLink(person, post.getCommunity(), + List.of(LinkPersonCommunityType.moderator, LinkPersonCommunityType.owner)))) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized"); + } + + post.setRemovedState(removePostForm.remove() ? RemovedState.REMOVED : RemovedState.NOT_REMOVED); + + // @todo: modlog? + + postService.updatePost(post); + return conversionService.convert(post, PostResponse.class); + } + + /** + * Deletes a post with the specified post key, using the provided delete post form and person. + * + * @param postKey The key of the post to delete. + * @param deletePostForm The DeletePost object containing additional parameters for the deletion. + * @param person The Person object representing the user performing the deletion. + * @return The PostResponse object for the deleted post. + * @throws ResponseStatusException If the post is not found or the user is not permitted to delete + * the post. + */ + public PostResponse delete(final String postKey, final DeletePost deletePostForm, + final Person person) + { + + final Post post = postRepository.findByTitleSlug(postKey) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "post_not_found")); + + if (!(aclService.canPerson(person) + .onCommunity(post.getCommunity()) + .performTheAction(RolePermissionPostTypes.DELETE_POST) + .isPermitted() && !Objects.equals(postService.getPostCreator(post) + .getId(), person.getId()) && !post.isRemoved())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized"); + } + + post.setDeleted(deletePostForm.remove()); + + // @todo: modlog? + + postService.updatePost(post); + return conversionService.convert(post, PostResponse.class); + } + + /** + * Retrieves the aggregated post information for the given post key and person. + * + * @param postKey The key of the post to aggregate. + * @param person The Person object representing the user. + * @return The aggregated post information as an AggregatePostResponse object. + * @throws ResponseStatusException If the post is not found or the user does not have permission + * to access the post. + */ + public AggregatePostResponse aggregate(String postKey, Person person) { + + rolePermissionService.isPermitted(person, RolePermissionPostTypes.READ_POST_AGGREGATE, + () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized")); + + return postAggregateRepository.findByPost_TitleSlug(postKey) + .map(postAggregate -> conversionService.convert(postAggregate, AggregatePostResponse.class)) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "post_not_found")); + } + + + public PostResponse favorite(final String postKey, final FavoritePost favoritePostForm, + final Person person) + { + + rolePermissionService.isPermitted(person, RolePermissionPostTypes.FAVORITE_POST, + () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized")); + + final Post post = postRepository.findByTitleSlug(postKey) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "post_not_found")); + + if (favoritePostForm.favorite()) { + if (linkPersonPostRepository.getLinkPersonPostByPostAndPersonAndLinkType(post, person, + LinkPersonPostType.follower) + .isEmpty()) { + linkPersonPostService.createPostLink(post, person, LinkPersonPostType.follower); + } + } else { + linkPersonPostService.deleteLink(post, person, LinkPersonPostType.follower); + } + + return conversionService.convert(post, PostResponse.class); + } + + /** + * Pins a post. + * + * @param postKey The key of the post to pin. + * @param pinPostForm The PinPost object containing the pin information. + * @param person The Person object representing the user pinning the post. + * @return The PostResponse object for the pinned post. + * @throws ResponseStatusException If the post is not found or the user does not have permission + * to pin the post. + */ + public PostResponse pin(final String postKey, final PinPost pinPostForm, final Person person) { + + final Post post = postRepository.findByTitleSlug(postKey) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "post_not_found")); + + if (!rolePermissionService.isPermitted(person, RolePermissionPostTypes.ADMIN_PIN_POST)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized"); + } + + post.setFeatured(pinPostForm.pin() != null ? pinPostForm.pin() : !post.isFeatured()); + postService.updatePost(post); + + return conversionService.convert(post, PostResponse.class); + } + + /** + * Pins a post in a community. + * + * @param postKey The key of the post to pin. + * @param pinPostForm The PinPost object containing the pin information. + * @param person The Person object representing the user pinning the post. + * @return The PostResponse object for the pinned post. + * @throws ResponseStatusException If the post is not found or the user does not have permission + * to pin the post. + */ + public PostResponse pinCommunity(final String postKey, final PinPost pinPostForm, + final Person person) + { + + final Post post = postRepository.findByTitleSlug(postKey) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "post_not_found")); + + if (!rolePermissionService.isPermitted(person, RolePermissionPostTypes.ADMIN_PIN_POST) && !( + rolePermissionService.isPermitted(person, RolePermissionPostTypes.MODERATOR_PIN_POST) + && !linkPersonCommunityService.hasAnyLink(person, post.getCommunity(), + List.of(LinkPersonCommunityType.moderator, LinkPersonCommunityType.owner)))) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized"); + } + + post.setFeaturedInCommunity(pinPostForm.pin()); + postService.updatePost(post); + + return conversionService.convert(post, PostResponse.class); + } + + /** + * Removes a post from the system. + * + * @param postKey The key of the post to be removed. + * @param purgePostForm The PurgePost object containing the removal information. + * @param person The Person object representing the user removing the post. + * @return The RequestResponse object indicating the result of the purge operation. + * @throws ResponseStatusException If the post is not found, the user is unauthorized, or an error + * occurs during the removal process. + */ + public RequestResponse purge(final String postKey, final PurgePost purgePostForm, + final Person person) + { + + final Post post = postRepository.findByTitleSlug(postKey) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "post_not_found")); + + aclService.canPerson(person) + .onCommunity(post.getCommunity()) + .performTheAction(RolePermissionPostTypes.PURGE_POST) + .orThrowUnauthorized(); + + // @todo: implement + + return RequestResponse.builder() + .success(false) + .error("not_implemented") + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/privatemessage/controllers/SublinksPrivatemessageController.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/privatemessage/controllers/SublinksPrivatemessageController.java new file mode 100644 index 000000000..efd193d15 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/privatemessage/controllers/SublinksPrivatemessageController.java @@ -0,0 +1,99 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.privatemessage.controllers; + +import com.sublinks.sublinksapi.api.sublinks.v1.authentication.SublinksJwtPerson; +import com.sublinks.sublinksapi.api.sublinks.v1.common.controllers.AbstractSublinksApiController; +import com.sublinks.sublinksapi.api.sublinks.v1.privatemessage.models.CreatePrivateMessage; +import com.sublinks.sublinksapi.api.sublinks.v1.privatemessage.models.DeletePrivateMessage; +import com.sublinks.sublinksapi.api.sublinks.v1.privatemessage.models.IndexPrivateMessages; +import com.sublinks.sublinksapi.api.sublinks.v1.privatemessage.models.PrivateMessageResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.privatemessage.models.UpdatePrivateMessage; +import com.sublinks.sublinksapi.api.sublinks.v1.privatemessage.services.SublinksPrivateMessageService; +import com.sublinks.sublinksapi.person.entities.Person; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import lombok.AllArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@AllArgsConstructor +@RestController +@RequestMapping("api/v1/privatemessage") +@Tag(name = "Sublinks Private Messages", description = "Private Messages API") +public class SublinksPrivatemessageController extends AbstractSublinksApiController { + + private final SublinksPrivateMessageService sublinksPrivateMessageService; + + @Operation(summary = "Get a list of privatemessages") + @GetMapping + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public List index(final IndexPrivateMessages indexPrivateMessagesForm, + final SublinksJwtPerson sublinksJwtPerson) + { + + final Person person = getPersonOrThrowUnauthorized(sublinksJwtPerson); + + return sublinksPrivateMessageService.index(indexPrivateMessagesForm, person); + } + + @Operation(summary = "Get a specific privatemessage") + @GetMapping("/{key}") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public PrivateMessageResponse show(@PathVariable String key, + final SublinksJwtPerson sublinksJwtPerson) + { + + final Person person = getPersonOrThrowUnauthorized(sublinksJwtPerson); + + return sublinksPrivateMessageService.show(key, person); + } + + @Operation(summary = "Create a new privatemessage") + @PostMapping + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public PrivateMessageResponse create(final CreatePrivateMessage createPrivateMessageForm, + final SublinksJwtPerson sublinksJwtPerson) + { + + final Person person = getPersonOrThrowUnauthorized(sublinksJwtPerson); + + return sublinksPrivateMessageService.create(createPrivateMessageForm, person); + } + + @Operation(summary = "Update an privatemessage") + @PostMapping("/{key}") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public PrivateMessageResponse update(@PathVariable String key, + final UpdatePrivateMessage updatePrivateMessageForm, + final SublinksJwtPerson sublinksJwtPerson) + { + + final Person person = getPersonOrThrowBadRequest(sublinksJwtPerson); + + return sublinksPrivateMessageService.update(updatePrivateMessageForm, person); + } + + @Operation(summary = "Delete an privatemessage") + @DeleteMapping("/{key}") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public PrivateMessageResponse delete(@PathVariable String key, + final DeletePrivateMessage deletePrivateMessageForm, + final SublinksJwtPerson sublinksJwtPerson) + { + + final Person person = getPersonOrThrowBadRequest(sublinksJwtPerson); + + return sublinksPrivateMessageService.delete(key, deletePrivateMessageForm, person); + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/privatemessage/controllers/SublinksPrivatemessageModeratorController.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/privatemessage/controllers/SublinksPrivatemessageModeratorController.java new file mode 100644 index 000000000..c17b12d62 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/privatemessage/controllers/SublinksPrivatemessageModeratorController.java @@ -0,0 +1,39 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.privatemessage.controllers; + +import com.sublinks.sublinksapi.api.sublinks.v1.authentication.SublinksJwtPerson; +import com.sublinks.sublinksapi.api.sublinks.v1.common.controllers.AbstractSublinksApiController; +import com.sublinks.sublinksapi.api.sublinks.v1.common.models.RequestResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.privatemessage.models.moderation.PurgePrivateMessage; +import com.sublinks.sublinksapi.api.sublinks.v1.privatemessage.services.SublinksPrivateMessageService; +import com.sublinks.sublinksapi.person.entities.Person; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.AllArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@AllArgsConstructor +@RestController +@RequestMapping("api/v1/privatemessage/{key}/") +@Tag(name = "Sublinks Private Messages Moderation", description = "Private Messages Moderation API") +public class SublinksPrivatemessageModeratorController extends AbstractSublinksApiController { + + private final SublinksPrivateMessageService sublinksPrivateMessageService; + + @Operation(summary = "Purge an private message") + @DeleteMapping("/purge") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public RequestResponse purge(@PathVariable String key, + final PurgePrivateMessage purgePrivateMessageParam, final SublinksJwtPerson sublinksJwtPerson) + { + + final Person person = getPersonOrThrowUnauthorized(sublinksJwtPerson); + + return sublinksPrivateMessageService.purgePrivateMessage(key, purgePrivateMessageParam, person); + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/privatemessage/mappers/SublinksPrivateMessageMapper.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/privatemessage/mappers/SublinksPrivateMessageMapper.java new file mode 100644 index 000000000..f20944bcc --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/privatemessage/mappers/SublinksPrivateMessageMapper.java @@ -0,0 +1,37 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.privatemessage.mappers; + +import com.sublinks.sublinksapi.api.sublinks.v1.person.mappers.SublinksPersonMapper; +import com.sublinks.sublinksapi.api.sublinks.v1.privatemessage.models.PrivateMessageResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.utils.DateUtils; +import com.sublinks.sublinksapi.privatemessages.entities.PrivateMessage; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingConstants; +import org.springframework.core.convert.converter.Converter; +import org.springframework.lang.Nullable; + +@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, + uses = {SublinksPersonMapper.class}) +public abstract class SublinksPrivateMessageMapper implements + Converter { + + SublinksPersonMapper sublinksPersonMapper; + + @Override + @Mapping(target = "key", source = "privateMessage.id") + @Mapping(target = "content", source = "privateMessage.content") + @Mapping(target = "isLocal", source = "privateMessage.local") + @Mapping(target = "isDeleted", source = "privateMessage.deleted") + @Mapping(target = "isRead", source = "privateMessage.read") + //@Mapping(target = "sender", expression = "java(personMapper.convert(privateMessage.getSender()))") + //@Mapping(target = "recipient", expression = "java(personMapper.convert(privateMessage.getRecipient()))") + @Mapping(target = "sender", source = "privateMessage.sender") + @Mapping(target = "recipient", source = "privateMessage.recipient") + @Mapping(target = "createdAt", + source = "privateMessage.createdAt", + dateFormat = DateUtils.FRONT_END_DATE_FORMAT) + @Mapping(target = "updatedAt", + source = "privateMessage.updatedAt", + dateFormat = DateUtils.FRONT_END_DATE_FORMAT) + public abstract PrivateMessageResponse convert(@Nullable PrivateMessage privateMessage); +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/privatemessage/models/CreatePrivateMessage.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/privatemessage/models/CreatePrivateMessage.java new file mode 100644 index 000000000..7eceb56cd --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/privatemessage/models/CreatePrivateMessage.java @@ -0,0 +1,7 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.privatemessage.models; + +public record CreatePrivateMessage( + String recipientKey, + String message) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/privatemessage/models/DeletePrivateMessage.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/privatemessage/models/DeletePrivateMessage.java new file mode 100644 index 000000000..f4300899b --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/privatemessage/models/DeletePrivateMessage.java @@ -0,0 +1,6 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.privatemessage.models; + +public record DeletePrivateMessage( + Boolean removed) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/privatemessage/models/IndexPrivateMessages.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/privatemessage/models/IndexPrivateMessages.java new file mode 100644 index 000000000..e2c60e925 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/privatemessage/models/IndexPrivateMessages.java @@ -0,0 +1,23 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.privatemessage.models; + +import com.sublinks.sublinksapi.privatemessages.enums.PrivateMessageSortType; + +public record IndexPrivateMessages( + String search, + Boolean unreadOnly, + PrivateMessageSortType sort, + Boolean localOnly, + String senderKey, + Integer page, + Integer perPage) { + + public Integer page() { + + return page != null ? page : 1; + } + + public Integer perPage() { + + return perPage != null ? perPage : 20; + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/privatemessage/models/MarkAsReadPrivateMessage.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/privatemessage/models/MarkAsReadPrivateMessage.java new file mode 100644 index 000000000..843c46257 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/privatemessage/models/MarkAsReadPrivateMessage.java @@ -0,0 +1,6 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.privatemessage.models; + +public record MarkAsReadPrivateMessage( + Boolean read) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/privatemessage/models/PrivateMessageResponse.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/privatemessage/models/PrivateMessageResponse.java new file mode 100644 index 000000000..a0e8c98ae --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/privatemessage/models/PrivateMessageResponse.java @@ -0,0 +1,19 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.privatemessage.models; + +import com.sublinks.sublinksapi.api.sublinks.v1.person.models.PersonResponse; +import lombok.Builder; + +@Builder +public record PrivateMessageResponse( + String key, + PersonResponse sender, + PersonResponse recipient, + String content, + Boolean isLocal, + Boolean isDeleted, + Boolean isRead, + String activityPubId, + String createdAt, + String updatedAt) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/privatemessage/models/UpdatePrivateMessage.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/privatemessage/models/UpdatePrivateMessage.java new file mode 100644 index 000000000..50cae7fe5 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/privatemessage/models/UpdatePrivateMessage.java @@ -0,0 +1,7 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.privatemessage.models; + +public record UpdatePrivateMessage( + String privateMessageKey, + String message) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/privatemessage/models/moderation/PurgePrivateMessage.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/privatemessage/models/moderation/PurgePrivateMessage.java new file mode 100644 index 000000000..cf75173a3 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/privatemessage/models/moderation/PurgePrivateMessage.java @@ -0,0 +1,6 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.privatemessage.models.moderation; + +public record PurgePrivateMessage( + String reason) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/privatemessage/services/SublinksPrivateMessageService.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/privatemessage/services/SublinksPrivateMessageService.java new file mode 100644 index 000000000..ccbc70c59 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/privatemessage/services/SublinksPrivateMessageService.java @@ -0,0 +1,359 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.privatemessage.services; + +import com.sublinks.sublinksapi.api.sublinks.v1.common.models.RequestResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.person.models.PersonIdentity; +import com.sublinks.sublinksapi.api.sublinks.v1.person.services.SublinksPersonService; +import com.sublinks.sublinksapi.api.sublinks.v1.privatemessage.models.CreatePrivateMessage; +import com.sublinks.sublinksapi.api.sublinks.v1.privatemessage.models.DeletePrivateMessage; +import com.sublinks.sublinksapi.api.sublinks.v1.privatemessage.models.IndexPrivateMessages; +import com.sublinks.sublinksapi.api.sublinks.v1.privatemessage.models.MarkAsReadPrivateMessage; +import com.sublinks.sublinksapi.api.sublinks.v1.privatemessage.models.PrivateMessageResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.privatemessage.models.UpdatePrivateMessage; +import com.sublinks.sublinksapi.api.sublinks.v1.privatemessage.models.moderation.PurgePrivateMessage; +import com.sublinks.sublinksapi.authorization.enums.RolePermissionPrivateMessageTypes; +import com.sublinks.sublinksapi.authorization.services.AclService; +import com.sublinks.sublinksapi.person.entities.Person; +import com.sublinks.sublinksapi.person.repositories.PersonRepository; +import com.sublinks.sublinksapi.person.services.PersonService; +import com.sublinks.sublinksapi.post.repositories.PostRepository; +import com.sublinks.sublinksapi.privatemessages.entities.PrivateMessage; +import com.sublinks.sublinksapi.privatemessages.models.PrivateMessageSearchCriteria; +import com.sublinks.sublinksapi.privatemessages.repositories.PrivateMessageRepository; +import com.sublinks.sublinksapi.privatemessages.services.PrivateMessageService; +import java.util.List; +import java.util.stream.Collectors; +import lombok.AllArgsConstructor; +import org.springframework.core.convert.ConversionService; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +@AllArgsConstructor +@Service +public class SublinksPrivateMessageService { + + + private final PrivateMessageRepository privateMessageRepository; + private final ConversionService conversionService; + private final PostRepository postRepository; + private final AclService aclService; + private final PrivateMessageService privateMessageService; + private final PersonRepository personRepository; + private final PersonService personService; + private final SublinksPersonService sublinksPersonService; + + /** + * Retrieves a list of private messages based on the given search criteria. + * + * @param indexPrivateMessagesForm The form containing the search criteria for filtering the + * private messages. + * @param person The person accessing the private messages. + * @return A list of PrivateMessageResponse objects containing the details of the private + * messages. + * @throws ResponseStatusException If the person is not allowed to read private messages. + */ + public List index(final IndexPrivateMessages indexPrivateMessagesForm, + final Person person) + { + + aclService.canPerson(person) + .performTheAction(RolePermissionPrivateMessageTypes.READ_PRIVATE_MESSAGES) + .orThrowUnauthorized(); + + return privateMessageRepository.allPrivateMessagesBySearchCriteria( + PrivateMessageSearchCriteria.builder() + .search(indexPrivateMessagesForm.search()) + .person(person) + .unreadOnly(indexPrivateMessagesForm.unreadOnly() != null + && indexPrivateMessagesForm.unreadOnly()) + .privateMessageSortType(indexPrivateMessagesForm.sort()) + .page(indexPrivateMessagesForm.page()) + .perPage(indexPrivateMessagesForm.perPage()) + .build()) + .stream() + .map(privateMessage -> conversionService.convert(privateMessage, + PrivateMessageResponse.class)) + .collect(Collectors.toList()); + } + + /** + * Retrieves the details of a private message. + * + * @param id The ID of the private message. + * @param person The person accessing the private message. + * @return The PrivateMessageResponse object containing the details of the private message. + * @throws ResponseStatusException If the person is not allowed to read private messages or if the + * private message is not found. + */ + public PrivateMessageResponse show(final String id, final Person person) { + + aclService.canPerson(person) + .performTheAction(RolePermissionPrivateMessageTypes.READ_PRIVATE_MESSAGES) + .orThrowUnauthorized(); + + final PrivateMessage privateMessage = privateMessageRepository.findById(Long.parseLong(id)) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, + "Image could not be written")); + + if (!privateMessage.getRecipient() + .equals(person) && !privateMessage.getSender() + .equals(person)) { + return null; + } + + return conversionService.convert(privateMessage, PrivateMessageResponse.class); + } + + /** + * Creates a new private message. + * + * @param createPrivateMessageForm The form containing the information of the new private + * message. + * @param person The person creating the private message. + * @return The newly created private message as a PrivateMessageResponse object. + * @throws ResponseStatusException If the person is not allowed to send private messages. + */ + public PrivateMessageResponse create(final CreatePrivateMessage createPrivateMessageForm, + final Person person) + { + + aclService.canPerson(person) + .performTheAction(RolePermissionPrivateMessageTypes.CREATE_PRIVATE_MESSAGE) + .orThrowUnauthorized(); + + final PrivateMessage privateMessage = PrivateMessage.builder() + .recipient(person) + .sender(person) + .content(createPrivateMessageForm.message()) + .isLocal(true) + .build(); + + privateMessageService.createPrivateMessage(privateMessage); + + return conversionService.convert(privateMessageRepository.save(privateMessage), + PrivateMessageResponse.class); + } + + /** + * Updates a private message. + * + * @param updatePrivateMessageForm The form containing the updated private message information. + * @param person The person performing the update. + * @return The updated private message as a PrivateMessageResponse object. + * @throws ResponseStatusException If the person is not allowed to update the private message or + * if the private message is not found. + */ + public PrivateMessageResponse update(final UpdatePrivateMessage updatePrivateMessageForm, + final Person person) + { + + aclService.canPerson(person) + .performTheAction(RolePermissionPrivateMessageTypes.UPDATE_PRIVATE_MESSAGE) + .orThrowUnauthorized(); + + final PrivateMessage privateMessage = privateMessageRepository.findById( + Long.parseLong(updatePrivateMessageForm.privateMessageKey())) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, + "Image could not be written")); + + if (!privateMessage.getRecipient() + .equals(person) && !privateMessage.getSender() + .equals(person)) { + return null; + } + + privateMessage.setContent(updatePrivateMessageForm.message()); + privateMessageService.updatePrivateMessage(privateMessage); + + return conversionService.convert(privateMessageRepository.save(privateMessage), + PrivateMessageResponse.class); + } + + /** + * Deletes a private message. + * + * @param id The ID of the private message to be deleted. + * @param deletePrivateMessageForm The form containing the deletion status to update. + * @param person The person performing the deletion. + * @return The response of the operation as a PrivateMessageResponse object. + * @throws ResponseStatusException If the person is not allowed to delete the private message or + * if the private message is not found. + */ + public PrivateMessageResponse delete(final String id, + final DeletePrivateMessage deletePrivateMessageForm, final Person person) + { + + aclService.canPerson(person) + .performTheAction(RolePermissionPrivateMessageTypes.DELETE_PRIVATE_MESSAGE) + .orThrowUnauthorized(); + + final PrivateMessage privateMessage = privateMessageRepository.findById(Long.parseLong(id)) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "private_message_not_found")); + + if (!privateMessage.getSender() + .equals(person)) { + return null; + } + + privateMessage.setDeleted(deletePrivateMessageForm.removed()); + privateMessageService.updatePrivateMessage(privateMessage); + + return conversionService.convert(privateMessage, PrivateMessageResponse.class); + } + + /** + * Marks all private messages of a given person as read. + * + * @param person The person whose private messages should be marked as read. + */ + public void markAllAsRead(final Person person) { + + aclService.canPerson(person) + .performTheAction(RolePermissionPrivateMessageTypes.MARK_PRIVATE_MESSAGE_AS_READ) + .orThrowUnauthorized(); + + List privateMessages = privateMessageRepository.findByRecipientAndReadIsFalse( + person); + privateMessages.forEach(privateMessage -> { + privateMessage.setRead(true); + privateMessageService.updatePrivateMessage(privateMessage); + }); + } + + /** + * Marks a private message as read. + * + * @param id The ID of the private message to mark as read. + * @param markAsReadPrivateMessageForm The form containing the read status to update. + * @param person The person performing the action. + * @return The updated private message as a PrivateMessageResponse object. + * @throws ResponseStatusException If the person is not allowed to mark the private message as + * read or if the private message is not found. + */ + public PrivateMessageResponse markAsRead(final String id, + final MarkAsReadPrivateMessage markAsReadPrivateMessageForm, final Person person) + { + + aclService.canPerson(person) + .performTheAction(RolePermissionPrivateMessageTypes.MARK_PRIVATE_MESSAGE_AS_READ) + .orThrowUnauthorized(); + + final PrivateMessage privateMessage = privateMessageRepository.findById(Long.parseLong(id)) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "private_message_not_found")); + + if (!privateMessage.getRecipient() + .equals(person)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized"); + } + + privateMessage.setRead(markAsReadPrivateMessageForm.read()); + privateMessageService.updatePrivateMessage(privateMessage); + + return conversionService.convert(privateMessage, PrivateMessageResponse.class); + } + + /** + * Purges a private message. + * + * @param id The ID of the private message to be purged. + * @param purgePrivateMessage The purge details for the private message. + * @param person The person performing the purge. + * @return The response of the operation as a PrivateMessageResponse object, or null if the person + * is not authorized. + * @throws ResponseStatusException If the person is not authorized or if the private message is + * not found. + */ + public RequestResponse purgePrivateMessage(final String id, + final PurgePrivateMessage purgePrivateMessage, final Person person) + { + + aclService.canPerson(person) + .performTheAction(RolePermissionPrivateMessageTypes.PURGE_PRIVATE_MESSAGE) + .orThrowUnauthorized(); + + final PrivateMessage privateMessage = privateMessageRepository.findById(Long.parseLong(id)) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "private_message_not_found")); + + if (!privateMessage.getRecipient() + .equals(person) && !privateMessage.getSender() + .equals(person)) { + return null; + } + + privateMessageService.deletePrivateMessage(privateMessage); + + // @todo: Modlog + + return RequestResponse.builder() + .success(true) + .build(); + } + + /** + * Purges private messages based on the provided IDs and person performing the purge. + * + * @param ids The IDs of the private messages to be purged. + * @param person The person performing the purge. + * @return A list of PrivateMessageResponse objects representing the purged private messages. + * @throws ResponseStatusException If the person is not authorized or if any of the private + * messages are not found. + */ + public List purgePrivateMessages(List ids, final Person person) { + + aclService.canPerson(person) + .performTheAction(RolePermissionPrivateMessageTypes.PURGE_PRIVATE_MESSAGES) + .orThrowUnauthorized(); + + return privateMessageRepository.findAllById(ids.stream() + .map(Long::parseLong) + .collect(Collectors.toList())) + .stream() + .map(privateMessage -> { + privateMessageService.deletePrivateMessage(privateMessage); + return conversionService.convert(privateMessage, PrivateMessageResponse.class); + }) + .collect(Collectors.toList()); + } + + /** + * Purge all private messages belonging to a specific person. + * + * @param person The person for whom to purge all private messages. + * @throws ResponseStatusException If the person is not allowed to delete private messages. + */ + public List purgeAllPrivateMessages(final Person person) { + + aclService.canPerson(person) + .performTheAction(RolePermissionPrivateMessageTypes.PURGE_PRIVATE_MESSAGE) + .orThrowUnauthorized(); + + return privateMessageService.deleteAllPrivateMessagesByPerson(person, true) + .stream() + .map(privateMessage -> conversionService.convert(privateMessage, + PrivateMessageResponse.class)) + .collect(Collectors.toList()); + } + + public void purgeAllPrivateMessages(final String key, + final PurgePrivateMessage purgePrivateMessageForm, final Person person) + { + + aclService.canPerson(person) + .performTheAction(RolePermissionPrivateMessageTypes.PURGE_PRIVATE_MESSAGES) + .orThrowUnauthorized(); + + final PersonIdentity personToPurgeIdentity = sublinksPersonService.getPersonIdentifiersFromKey( + key); + + final Person personToPurge = personRepository.findOneByNameAndInstance_Domain( + personToPurgeIdentity.name(), personToPurgeIdentity.domain()) + .orElseThrow(); + + // @todo: Modlog + + privateMessageService.deleteAllPrivateMessagesByPerson(personToPurge, true); + } +} \ No newline at end of file diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/roles/controllers/SublinksRolesController.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/roles/controllers/SublinksRolesController.java new file mode 100644 index 000000000..9932f10f0 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/roles/controllers/SublinksRolesController.java @@ -0,0 +1,101 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.roles.controllers; + +import com.sublinks.sublinksapi.api.sublinks.v1.authentication.SublinksJwtPerson; +import com.sublinks.sublinksapi.api.sublinks.v1.common.controllers.AbstractSublinksApiController; +import com.sublinks.sublinksapi.api.sublinks.v1.common.models.RequestResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.roles.models.CreateRole; +import com.sublinks.sublinksapi.api.sublinks.v1.roles.models.IndexRole; +import com.sublinks.sublinksapi.api.sublinks.v1.roles.models.RoleResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.roles.models.UpdateRole; +import com.sublinks.sublinksapi.api.sublinks.v1.roles.services.SublinksRoleService; +import com.sublinks.sublinksapi.person.entities.Person; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.List; +import java.util.Optional; +import lombok.AllArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@AllArgsConstructor +@RestController +@RequestMapping("api/v1/roles") +@Tag(name = "Sublinks Roles", description = "Roles API") +public class SublinksRolesController extends AbstractSublinksApiController { + + private final SublinksRoleService sublinksRoleService; + + @Operation(summary = "Get a list of roles") + @GetMapping + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public List index(@Valid final IndexRole indexRoleForm, + final SublinksJwtPerson sublinksJwtPerson) + { + + final Optional person = getOptionalPerson(sublinksJwtPerson); + + return sublinksRoleService.indexRole(indexRoleForm, person.orElse(null)); + } + + @Operation(summary = "Get a specific role") + @GetMapping("/{key}") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public RoleResponse show(@PathVariable String key, final SublinksJwtPerson sublinksJwtPerson) { + + final Optional person = getOptionalPerson(sublinksJwtPerson); + + return sublinksRoleService.show(key, person.orElse(null)); + } + + @Operation(summary = "Create a new Role") + @PostMapping + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public RoleResponse create(@Valid @RequestBody CreateRole createRoleForm, + final SublinksJwtPerson sublinksJwtPerson) + { + + final Person person = getPersonOrThrowUnauthorized(sublinksJwtPerson); + + return sublinksRoleService.create(createRoleForm, person); + } + + @Operation(summary = "Update an Role") + @PostMapping("/{key}") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public RoleResponse update(@PathVariable String key, + @Valid @RequestBody UpdateRole updateRoleForm, final SublinksJwtPerson sublinksJwtPerson) + { + + final Person person = getPersonOrThrowUnauthorized(sublinksJwtPerson); + + return sublinksRoleService.update(key, updateRoleForm, person); + } + + @Operation(summary = "Delete an Role") + @DeleteMapping("/{key}") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public RequestResponse delete(@PathVariable String key, final SublinksJwtPerson sublinksJwtPerson) + { + + final Person person = getPersonOrThrowUnauthorized(sublinksJwtPerson); + + sublinksRoleService.delete(key, person); + + return RequestResponse.builder() + .success(true) + .build(); + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/roles/mappers/SublinksPermissionInterfaceMapper.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/roles/mappers/SublinksPermissionInterfaceMapper.java new file mode 100644 index 000000000..2c361002a --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/roles/mappers/SublinksPermissionInterfaceMapper.java @@ -0,0 +1,33 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.roles.mappers; + +import com.sublinks.sublinksapi.authorization.enums.AllRoleTypes; +import com.sublinks.sublinksapi.authorization.enums.RolePermissionInterface; +import com.sublinks.sublinksapi.authorization.services.RolePermissionService; +import java.util.logging.Logger; +import org.mapstruct.Mapper; +import org.mapstruct.MappingConstants; +import org.springframework.core.convert.converter.Converter; +import org.springframework.lang.Nullable; + + +@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, + uses = {RolePermissionService.class}) +public class SublinksPermissionInterfaceMapper implements + Converter { + + private final Logger logger = Logger.getLogger(SublinksPermissionInterfaceMapper.class.getName()); + + @Override + public RolePermissionInterface convert(@Nullable String permission) + { + + for (RolePermissionInterface rolePermissionInterface : AllRoleTypes.ALL_ROLE_TYPES) { + if (rolePermissionInterface.toString() + .equals(permission)) { + return rolePermissionInterface; + } + } + logger.warning("Permission not found: " + permission); + return null; + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/roles/mappers/SublinksPersonRoleMapper.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/roles/mappers/SublinksPersonRoleMapper.java new file mode 100644 index 000000000..405935258 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/roles/mappers/SublinksPersonRoleMapper.java @@ -0,0 +1,44 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.roles.mappers; + +import com.sublinks.sublinksapi.api.lemmy.v3.utils.DateUtils; +import com.sublinks.sublinksapi.api.sublinks.v1.roles.models.PersonRoleResponse; +import com.sublinks.sublinksapi.authorization.entities.Role; +import com.sublinks.sublinksapi.authorization.services.RolePermissionService; +import java.util.Date; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingConstants; +import org.mapstruct.Named; +import org.springframework.core.convert.converter.Converter; +import org.springframework.lang.Nullable; + +@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, + uses = {RolePermissionService.class}) +public abstract class SublinksPersonRoleMapper implements Converter { + + @Override + @Mapping(target = "key", source = "role.name") + @Mapping(target = "name", source = "role.name") + @Mapping(target = "description", source = "role.description") + @Mapping(target = "isActive", source = "role.active") + @Mapping(target = "isExpired", source = "role", qualifiedByName = "is_expired") + @Mapping(target = "expiresAt", + source = "role.expiresAt", + dateFormat = DateUtils.FRONT_END_DATE_FORMAT) + @Mapping(target = "createdAt", + source = "role.createdAt", + dateFormat = DateUtils.FRONT_END_DATE_FORMAT) + @Mapping(target = "updatedAt", + source = "role.updatedAt", + dateFormat = DateUtils.FRONT_END_DATE_FORMAT) + public abstract PersonRoleResponse convert(@Nullable Role role); + + @Named("is_expired") + Boolean mapIsExpired(Role role) { + + if (role.getExpiresAt() == null) { + return false; + } + return new Date().after(role.getExpiresAt()); + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/roles/mappers/SublinksRoleMapper.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/roles/mappers/SublinksRoleMapper.java new file mode 100644 index 000000000..c14c48608 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/roles/mappers/SublinksRoleMapper.java @@ -0,0 +1,81 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.roles.mappers; + +import com.sublinks.sublinksapi.api.lemmy.v3.utils.DateUtils; +import com.sublinks.sublinksapi.api.sublinks.v1.roles.models.RoleResponse; +import com.sublinks.sublinksapi.authorization.entities.Role; +import com.sublinks.sublinksapi.authorization.entities.RolePermissions; +import com.sublinks.sublinksapi.authorization.enums.RolePermissionInterface; +import java.util.Date; +import java.util.HashSet; +import java.util.Set; +import java.util.stream.Collectors; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingConstants; +import org.mapstruct.Named; +import org.springframework.core.convert.converter.Converter; +import org.springframework.lang.Nullable; + +@Mapper(componentModel = MappingConstants.ComponentModel.SPRING) +public abstract class SublinksRoleMapper implements Converter { + + @Override + @Mapping(target = "key", source = "role.name") + @Mapping(target = "name", source = "role.name") + @Mapping(target = "description", source = "role.description") + @Mapping(target = "permissions", source = "role", qualifiedByName = "map_permissions") + @Mapping(target = "inheritedPermissions", + source = "role", + qualifiedByName = "map_inherited_permissions") + @Mapping(target = "inheritsFrom", source = "role.inheritsFrom.name") + @Mapping(target = "isActive", source = "role.active") + @Mapping(target = "isExpired", source = "role", qualifiedByName = "is_expired") + @Mapping(target = "expiresAt", + source = "role.expiresAt", + dateFormat = DateUtils.FRONT_END_DATE_FORMAT) + @Mapping(target = "createdAt", + source = "role.createdAt", + dateFormat = DateUtils.FRONT_END_DATE_FORMAT) + @Mapping(target = "updatedAt", + source = "role.updatedAt", + dateFormat = DateUtils.FRONT_END_DATE_FORMAT) + public abstract RoleResponse convert(@Nullable Role role); + + @Named("is_expired") + Boolean mapIsExpired(Role role) { + + if (role.getExpiresAt() == null) { + return false; + } + return new Date().after(role.getExpiresAt()); + } + + // @code-duplication TODO: Improve the following two methods as i didnt knew any better way to do inject those dependencies + @Named("map_permissions") + Set mapPermissions(Role role) { + + return role.getRolePermissions() + .stream() + .map((permission) -> new SublinksPermissionInterfaceMapper().convert( + permission.getPermission())) + .collect(Collectors.toSet()); + } + + @Named("map_inherited_permissions") + Set mapInheritedPermissions(Role role) { + + final Set rolePermissions = new HashSet<>(); + + Role currentRole = role; + + while (currentRole != null) { + rolePermissions.addAll(currentRole.getRolePermissions()); + currentRole = currentRole.getInheritsFrom(); + } + + return rolePermissions.stream() + .map((permission) -> new SublinksPermissionInterfaceMapper().convert( + permission.getPermission())) + .collect(Collectors.toSet()); + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/roles/models/CreateRole.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/roles/models/CreateRole.java new file mode 100644 index 000000000..df45d01ce --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/roles/models/CreateRole.java @@ -0,0 +1,25 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.roles.models; + +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import java.util.List; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +public record CreateRole( + @Schema(description = "The name of the role", example = "admin") String name, + @Schema(description = "The description of the role", + example = "Administrator") String description, + @Schema(description = "The active status of the role", example = "true") Boolean active, + @Schema(description = "The permissions of the role") List permissions, + @Schema(description = "The expiration date of the role", + requiredMode = RequiredMode.NOT_REQUIRED) Long expiresAt) { + + public CreateRole { + + if (expiresAt != null && expiresAt < System.currentTimeMillis() / 1000) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "expiration_timestamp_must_be_in_the_future"); + } + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/roles/models/IndexRole.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/roles/models/IndexRole.java new file mode 100644 index 000000000..f26a4016e --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/roles/models/IndexRole.java @@ -0,0 +1,13 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.roles.models; + +import com.sublinks.sublinksapi.api.sublinks.v1.common.enums.SortOrder; +import lombok.Builder; + +@Builder +public record IndexRole( + String search, + Integer page, + Integer limit, + SortOrder sort) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/roles/models/PersonRoleResponse.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/roles/models/PersonRoleResponse.java new file mode 100644 index 000000000..35bb5f0f1 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/roles/models/PersonRoleResponse.java @@ -0,0 +1,22 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.roles.models; + +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import lombok.Builder; + +@Builder +public record PersonRoleResponse( + String key, + String name, + String description, + Boolean isActive, + Boolean isExpired, + @Schema( + requiredMode = RequiredMode.NOT_REQUIRED, + description = "The date and time the role expires at" + ) + String expiresAt, + String createdAt, + String updatedAt) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/roles/models/RoleResponse.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/roles/models/RoleResponse.java new file mode 100644 index 000000000..0183e93dc --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/roles/models/RoleResponse.java @@ -0,0 +1,26 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.roles.models; + +import com.sublinks.sublinksapi.authorization.enums.RolePermissionInterface; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import java.util.Set; +import lombok.Builder; + +@Builder +public record RoleResponse( + String key, + String name, + String description, + Set permissions, + @Schema(requiredMode = RequiredMode.REQUIRED, + description = "The permissions this role inherits") Set inheritedPermissions, + @Schema(requiredMode = RequiredMode.NOT_REQUIRED, + description = "The role this role inherits from") String inheritsFrom, + Boolean isActive, + Boolean isExpired, + @Schema(requiredMode = RequiredMode.NOT_REQUIRED, + description = "The date and time the role expires at") String expiresAt, + String createdAt, + String updatedAt) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/roles/models/UpdateRole.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/roles/models/UpdateRole.java new file mode 100644 index 000000000..a4719915c --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/roles/models/UpdateRole.java @@ -0,0 +1,31 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.roles.models; + +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import java.util.List; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +public record UpdateRole( + @Schema(description = "The name of the role", + example = "admin", + requiredMode = RequiredMode.NOT_REQUIRED) String name, + @Schema(description = "The description of the role", + example = "Administrator", + requiredMode = RequiredMode.NOT_REQUIRED) String description, + @Schema(description = "The active status of the role", + example = "true", + requiredMode = RequiredMode.NOT_REQUIRED) Boolean active, + @Schema(description = "The permissions of the role", + requiredMode = RequiredMode.NOT_REQUIRED) List permissions, + @Schema(description = "The expiration date of the role", + requiredMode = RequiredMode.NOT_REQUIRED) Long expiresAt) { + + public UpdateRole { + + if (expiresAt != null && expiresAt < System.currentTimeMillis() / 1000) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "expiration_timestamp_must_be_in_the_future"); + } + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/roles/services/SublinksRoleService.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/roles/services/SublinksRoleService.java new file mode 100644 index 000000000..5342de742 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/roles/services/SublinksRoleService.java @@ -0,0 +1,178 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.roles.services; + +import com.sublinks.sublinksapi.api.sublinks.v1.common.enums.SortOrder; +import com.sublinks.sublinksapi.api.sublinks.v1.roles.models.CreateRole; +import com.sublinks.sublinksapi.api.sublinks.v1.roles.models.IndexRole; +import com.sublinks.sublinksapi.api.sublinks.v1.roles.models.RoleResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.roles.models.UpdateRole; +import com.sublinks.sublinksapi.authorization.entities.Role; +import com.sublinks.sublinksapi.authorization.entities.RolePermissions; +import com.sublinks.sublinksapi.authorization.enums.RolePermissionRoleTypes; +import com.sublinks.sublinksapi.authorization.repositories.RoleRepository; +import com.sublinks.sublinksapi.authorization.services.AclService; +import com.sublinks.sublinksapi.authorization.services.RoleService; +import com.sublinks.sublinksapi.person.entities.Person; +import java.util.Date; +import java.util.List; +import java.util.stream.Collectors; +import lombok.AllArgsConstructor; +import lombok.NonNull; +import org.springframework.core.convert.ConversionService; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +@AllArgsConstructor +@Service +public class SublinksRoleService { + + private final ConversionService conversionService; + private final AclService aclService; + private final RoleRepository roleRepository; + private final RoleService roleService; + + public List indexRole(final IndexRole indexRoleForm, final Person person) + { + + aclService.canPerson(person) + .performTheAction(RolePermissionRoleTypes.ROLE_READ) + .orThrowUnauthorized(); + + final List roles = new java.util.ArrayList<>(); + + final int page = indexRoleForm.page() != null ? Math.max(indexRoleForm.page(), 0) : 0; + final int limit = indexRoleForm.limit() != null ? Math.max(Math.min(indexRoleForm.limit(), 20), + 0) : 20; + + Sort sortOrder = indexRoleForm.sort() != null && indexRoleForm.sort() + .equals(SortOrder.Desc) ? Sort.by("name") + .descending() : Sort.by("name") + .ascending(); + + PageRequest pageRequest = PageRequest.of(page, limit, sortOrder); + + if (indexRoleForm.search() != null) { + roles.addAll( + roleRepository.findAllByNameIsLikeIgnoreCase(indexRoleForm.search(), pageRequest)); + } else { + roles.addAll(roleRepository.findAll(pageRequest) + .stream() + .toList()); + } + + return roles.stream() + .map(role -> conversionService.convert(role, RoleResponse.class)) + .toList(); + } + + public RoleResponse show(@NonNull final String key, final Person person) + { + + aclService.canPerson(person) + .performTheAction(RolePermissionRoleTypes.ROLE_READ) + .orThrowUnauthorized(); + + return roleRepository.findFirstByName(key) + .map(role -> conversionService.convert(role, RoleResponse.class)) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "role not found")); + } + + /** + * Creates a new role with the provided information. + * + * @param createRoleForm The CreateRole object containing the details of the role to be created. + * Must not be null. + * @param person The Person object representing the user performing the action. Must not + * be null. + * @return The RoleResponse object representing the newly created role. + * @throws ResponseStatusException if the user is not authorized to perform the action. + */ + public RoleResponse create(@NonNull final CreateRole createRoleForm, final Person person) + { + + aclService.canPerson(person) + .performTheAction(RolePermissionRoleTypes.ROLE_CREATE) + .orThrowUnauthorized(); + + final Role role = Role.builder() + .name(createRoleForm.name()) + .description(createRoleForm.description()) + .isActive(createRoleForm.active()) + .rolePermissions(createRoleForm.permissions() + .stream() + .map(permission -> RolePermissions.builder() + .permission(permission) + .build()) + .collect(Collectors.toSet())) + .expiresAt(createRoleForm.expiresAt() != null ? new Date(createRoleForm.expiresAt() * 1000L) + : null) + .build(); + + return conversionService.convert(roleService.createRole(role), RoleResponse.class); + } + + /** + * Updates an existing role with the provided information. + * + * @param key The key of the role to be updated. Must not be null. + * @param updateRoleForm The UpdateRole object containing the updated details of the role. Must + * not be null. + * @param person The Person object representing the user performing the action. Must not + * be null. + * @return The RoleResponse object representing the updated role. + * @throws ResponseStatusException if the user is not authorized to perform the action or if the + * role is not found. + */ + public RoleResponse update(final String key, @NonNull final UpdateRole updateRoleForm, + final Person person) + { + + aclService.canPerson(person) + .performTheAction(RolePermissionRoleTypes.ROLE_UPDATE) + .orThrowUnauthorized(); + + final Role role = roleRepository.findFirstByName(key) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "role_not_found")); + + if (updateRoleForm.name() != null) { + role.setName(updateRoleForm.name()); + } + if (updateRoleForm.description() != null) { + role.setDescription(updateRoleForm.description()); + } + if (updateRoleForm.active() != null) { + role.setActive(updateRoleForm.active()); + } + if (updateRoleForm.permissions() != null) { + role.setRolePermissions(updateRoleForm.permissions() + .stream() + .map(permission -> RolePermissions.builder() + .permission(permission) + .build()) + .collect(Collectors.toSet())); + } + if (updateRoleForm.expiresAt() != null) { + role.setExpiresAt(new Date(updateRoleForm.expiresAt() * 1000L)); + } + + return conversionService.convert(roleService.updateRole(role), RoleResponse.class); + } + + /** + * + */ + public void delete(final String key, final Person person) + { + + aclService.canPerson(person) + .performTheAction(RolePermissionRoleTypes.ROLE_DELETE) + .orThrowUnauthorized(); + + final Role role = roleRepository.findFirstByName(key) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "role_not_found")); + + roleService.deleteRole(role); + } +} \ No newline at end of file diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/search/controllers/SublinksSearchController.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/search/controllers/SublinksSearchController.java new file mode 100644 index 000000000..4999a3c56 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/search/controllers/SublinksSearchController.java @@ -0,0 +1,38 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.search.controllers; + +import com.sublinks.sublinksapi.api.sublinks.v1.authentication.SublinksJwtPerson; +import com.sublinks.sublinksapi.api.sublinks.v1.common.controllers.AbstractSublinksApiController; +import com.sublinks.sublinksapi.api.sublinks.v1.search.models.Search; +import com.sublinks.sublinksapi.api.sublinks.v1.search.models.SearchResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.search.services.SublinksSearchService; +import com.sublinks.sublinksapi.person.entities.Person; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.Optional; +import lombok.AllArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@AllArgsConstructor +@RestController +@RequestMapping("api/v1/search") +@Tag(name = "Sublinks Search", description = "Search API") +public class SublinksSearchController extends AbstractSublinksApiController { + + private final SublinksSearchService sublinksSearchService; + + @Operation(summary = "Get a list of privatemessages") + @GetMapping + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true)}) + public SearchResponse index(@RequestBody Search search, final SublinksJwtPerson principal) { + + final Optional person = getOptionalPerson(principal); + + return sublinksSearchService.list(search, person); + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/search/enums/SearchScope.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/search/enums/SearchScope.java new file mode 100644 index 000000000..167cfb93b --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/search/enums/SearchScope.java @@ -0,0 +1,10 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.search.enums; + +public enum SearchScope { + POSTS, + COMMENTS, + USERS, + COMMUNITIES, + INSTANCES, + PRIVATE_MESSAGES, +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/search/models/Search.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/search/models/Search.java new file mode 100644 index 000000000..31493bdca --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/search/models/Search.java @@ -0,0 +1,20 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.search.models; + +import com.sublinks.sublinksapi.api.sublinks.v1.common.enums.SortType; +import com.sublinks.sublinksapi.api.sublinks.v1.common.enums.SublinksListingType; +import com.sublinks.sublinksapi.api.sublinks.v1.search.enums.SearchScope; +import lombok.Builder; +import java.util.Set; + +@Builder +public record Search( + String search, + SortType type, + SublinksListingType listingType, + Set scopes, + Boolean showNsfw, + Boolean savedOnly, + Integer perPage, + Integer page) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/search/models/SearchResponse.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/search/models/SearchResponse.java new file mode 100644 index 000000000..40bf2430e --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/search/models/SearchResponse.java @@ -0,0 +1,22 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.search.models; + +import com.sublinks.sublinksapi.api.sublinks.v1.comment.models.CommentResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.community.models.CommunityResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.instance.models.InstanceResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.person.models.PersonResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.post.models.PostResponse; +import com.sublinks.sublinksapi.api.sublinks.v1.privatemessage.models.PrivateMessageResponse; +import java.util.List; +import lombok.Builder; + +// @todo: Add Communities, Posts, Comments, and Messages +@Builder +public record SearchResponse( + List persons, + List communities, + List posts, + List comments, + List instances, + List messages) { + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/search/services/SublinksSearchService.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/search/services/SublinksSearchService.java new file mode 100644 index 000000000..e75912f4a --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/search/services/SublinksSearchService.java @@ -0,0 +1,128 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.search.services; + +import com.sublinks.sublinksapi.api.sublinks.v1.comment.models.IndexComment; +import com.sublinks.sublinksapi.api.sublinks.v1.comment.services.SublinksCommentService; +import com.sublinks.sublinksapi.api.sublinks.v1.community.models.IndexCommunity; +import com.sublinks.sublinksapi.api.sublinks.v1.community.services.SublinksCommunityService; +import com.sublinks.sublinksapi.api.sublinks.v1.instance.models.IndexInstance; +import com.sublinks.sublinksapi.api.sublinks.v1.instance.service.SublinksInstanceService; +import com.sublinks.sublinksapi.api.sublinks.v1.person.models.IndexPerson; +import com.sublinks.sublinksapi.api.sublinks.v1.person.services.SublinksPersonService; +import com.sublinks.sublinksapi.api.sublinks.v1.post.models.IndexPost; +import com.sublinks.sublinksapi.api.sublinks.v1.post.services.SublinksPostService; +import com.sublinks.sublinksapi.api.sublinks.v1.search.enums.SearchScope; +import com.sublinks.sublinksapi.api.sublinks.v1.search.models.Search; +import com.sublinks.sublinksapi.api.sublinks.v1.search.models.SearchResponse; +import com.sublinks.sublinksapi.authorization.enums.RolePermissionCommentTypes; +import com.sublinks.sublinksapi.authorization.enums.RolePermissionCommunityTypes; +import com.sublinks.sublinksapi.authorization.enums.RolePermissionInstanceTypes; +import com.sublinks.sublinksapi.authorization.enums.RolePermissionPostTypes; +import com.sublinks.sublinksapi.authorization.services.RolePermissionService; +import com.sublinks.sublinksapi.person.entities.Person; +import com.sublinks.sublinksapi.utils.PaginationUtils; +import java.util.Optional; +import lombok.AllArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +@AllArgsConstructor +@Service +public class SublinksSearchService { + + + private final RolePermissionService rolePermissionService; + private final SublinksPersonService sublinksPersonService; + private final SublinksCommentService sublinksCommentService; + private final SublinksCommunityService sublinksCommunityService; + private final SublinksPostService sublinksPostService; + private final SublinksInstanceService sublinksInstanceService; + + /** + * Perform a search and return the search result. + * + * @param searchForm the search form containing search parameters + * @param person an optional person object representing the user performing the search + * @return the search response containing the search results + */ + public SearchResponse list(final Search searchForm, final Optional person) { + + rolePermissionService.isPermitted(person.orElse(null), + RolePermissionInstanceTypes.INSTANCE_SEARCH, + () -> new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized")); + + final String search = searchForm.search(); + + final int page = PaginationUtils.getPage(searchForm.page()); + final int perPage = PaginationUtils.getPerPage(searchForm.perPage()); + + final SearchResponse.SearchResponseBuilder searchResponseBuilder = SearchResponse.builder(); + + if (rolePermissionService.isPermitted(person.orElse(null), RolePermissionPostTypes.READ_POSTS) + && searchForm.scopes() + .contains(SearchScope.POSTS)) { + + searchResponseBuilder.persons(sublinksPersonService.index(IndexPerson.builder() + .search(search) + .page(page) + .perPage(perPage) + .listingType(searchForm.listingType()) + .sortType(searchForm.type()) + .build(), person.orElse(null))); + } + + if (rolePermissionService.isPermitted(person.orElse(null), RolePermissionPostTypes.READ_POSTS) + && searchForm.scopes() + .contains(SearchScope.POSTS)) { + + searchResponseBuilder.posts(sublinksPostService.index(IndexPost.builder() + .search(search) + .sortType(searchForm.type()) + .listingType(searchForm.listingType()) + .showNsfw(searchForm.showNsfw()) + .savedOnly(searchForm.savedOnly()) + .perPage(perPage) + .page(page) + .build(), person.orElse(null))); + } + + if (rolePermissionService.isPermitted(person.orElse(null), + RolePermissionCommentTypes.READ_COMMENTS) && searchForm.scopes() + .contains(SearchScope.COMMENTS)) { + + searchResponseBuilder.comments(sublinksCommentService.index(IndexComment.builder() + .search(search) + .listingType(searchForm.listingType()) + .showNsfw(searchForm.showNsfw()) + .page(searchForm.page()) + .perPage(searchForm.perPage()) + .build(), person.orElse(null))); + } + + if (rolePermissionService.isPermitted(person.orElse(null), + RolePermissionCommunityTypes.READ_COMMUNITIES) && searchForm.scopes() + .contains(SearchScope.COMMUNITIES)) { + + searchResponseBuilder.communities(sublinksCommunityService.index(IndexCommunity.builder() + .search(search) + .page(page) + .perPage(perPage) + .listingType(searchForm.listingType()) + .showNsfw(searchForm.showNsfw()) + .sortType(searchForm.type()) + .build(), person.orElse(null))); + } + + if (rolePermissionService.isPermitted(person.orElse(null), + RolePermissionInstanceTypes.INSTANCE_SEARCH) && searchForm.scopes() + .contains(SearchScope.INSTANCES)) { + searchResponseBuilder.instances(sublinksInstanceService.index(IndexInstance.builder() + .search(search) + .page(page) + .perPage(perPage) + .build())); + } + + return searchResponseBuilder.build(); + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/utils/ActorIdUtils.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/utils/ActorIdUtils.java new file mode 100644 index 000000000..447050acf --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/utils/ActorIdUtils.java @@ -0,0 +1,41 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.utils; + +import java.util.List; + +public class ActorIdUtils { + + public static boolean isActorIdValid(String actorId) { + + return actorId.contains("@"); + } + + public static List splitActorId(String actorId) { + + if (!actorId.contains("@")) { + return null; + } + return List.of(actorId.split("@")); + } + + + public static String getActorId(String actorId) { + + List parts = splitActorId(actorId); + if (parts == null) { + return null; + } + + return parts.get(0); + } + + public static String getActorDomain(String actorId) { + + List parts = splitActorId(actorId); + if (parts == null) { + return null; + } + + return parts.get(1); + } + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/utils/DateUtils.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/utils/DateUtils.java new file mode 100644 index 000000000..cd094f7fd --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/utils/DateUtils.java @@ -0,0 +1,13 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.utils; + +import java.time.format.DateTimeFormatter; + +/** + * Date Utils + */ +public class DateUtils { + + public static final String FRONT_END_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSX"; + public static DateTimeFormatter FRONT_END_DATETIME_FORMATTER = DateTimeFormatter.ofPattern( + DateUtils.FRONT_END_DATE_FORMAT); +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/utils/PaginationControllerUtils.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/utils/PaginationControllerUtils.java new file mode 100644 index 000000000..32e4b1692 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/utils/PaginationControllerUtils.java @@ -0,0 +1,26 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.utils; + +import org.springframework.lang.Nullable; + +public class PaginationControllerUtils { + + public static int getAbsoluteMinNumber(@Nullable Integer number, + @Nullable Integer defaultNumber) + { + + if (number == null && defaultNumber == null) { + return 1; + } + + if (defaultNumber == null) { + defaultNumber = number; + } + + if (number == null) { + return Math.abs(defaultNumber); + } + + return Math.abs(Math.min(number, defaultNumber)); + } + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/utils/PersonKeyUtils.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/utils/PersonKeyUtils.java new file mode 100644 index 000000000..499607449 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/utils/PersonKeyUtils.java @@ -0,0 +1,25 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.utils; + +import com.sublinks.sublinksapi.api.sublinks.v1.person.models.PersonIdentity; +import com.sublinks.sublinksapi.person.entities.Person; +import com.sublinks.sublinksapi.utils.UrlUtil; +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Component; + + +@Component +@AllArgsConstructor +public class PersonKeyUtils { + + private final UrlUtil urlUtil; + + public String getPersonKey(Person person) { + return person.getName() + "@" + urlUtil.cleanUrlProtocol(person.getInstance().getDomain()); + } + + public PersonIdentity getPersonIdentity(String personKey) { + String[] parts = personKey.split("@"); + return new PersonIdentity(parts[0], parts[1]); + } + +} diff --git a/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/utils/mappers/OptionalStringMapper.java b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/utils/mappers/OptionalStringMapper.java new file mode 100644 index 000000000..46e5b3ac1 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/api/sublinks/v1/utils/mappers/OptionalStringMapper.java @@ -0,0 +1,20 @@ +package com.sublinks.sublinksapi.api.sublinks.v1.utils.mappers; + +import com.sublinks.sublinksapi.api.sublinks.v1.languages.mappers.SublinksLanguageMapper; +import java.util.Optional; +import org.mapstruct.Mapper; +import org.mapstruct.MappingConstants; +import org.springframework.core.convert.converter.Converter; +import org.springframework.lang.Nullable; + +@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, + uses = {SublinksLanguageMapper.class}) +public class OptionalStringMapper implements Converter> { + + @Nullable + @Override + public Optional convert(@Nullable String source) { + + return Optional.ofNullable(source); + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/authorization/entities/Acl.java b/src/main/java/com/sublinks/sublinksapi/authorization/entities/Acl.java index 777d49ff3..3f1dab3e1 100644 --- a/src/main/java/com/sublinks/sublinksapi/authorization/entities/Acl.java +++ b/src/main/java/com/sublinks/sublinksapi/authorization/entities/Acl.java @@ -43,7 +43,6 @@ public class Acl { @Column(updatable = false, nullable = false, name = "entity_type") @Enumerated(EnumType.STRING) - private AuthorizedEntityType entityType; @Column(updatable = true, nullable = false, name = "entity_id") @@ -59,7 +58,6 @@ public class Acl { @Column(updatable = false, nullable = false, name = "created_at") private Date createdAt; - @UpdateTimestamp(source = SourceType.DB) @Column(updatable = false, name = "updated_at") private Date updatedAt; diff --git a/src/main/java/com/sublinks/sublinksapi/authorization/entities/Role.java b/src/main/java/com/sublinks/sublinksapi/authorization/entities/Role.java index 8fb87ecd9..76f10e3b4 100644 --- a/src/main/java/com/sublinks/sublinksapi/authorization/entities/Role.java +++ b/src/main/java/com/sublinks/sublinksapi/authorization/entities/Role.java @@ -1,15 +1,19 @@ package com.sublinks.sublinksapi.authorization.entities; import com.sublinks.sublinksapi.person.entities.Person; +import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; import java.util.Date; +import java.util.HashSet; import java.util.Set; import lombok.AllArgsConstructor; import lombok.Builder; @@ -29,8 +33,15 @@ @Table(name = "acl_roles") public class Role { - @OneToMany(mappedBy = "role", fetch = FetchType.EAGER) - Set rolePermissions; + @OneToMany(mappedBy = "role", fetch = FetchType.EAGER, cascade = CascadeType.ALL) + private Set rolePermissions; + + @ManyToOne(targetEntity = Role.class, fetch = FetchType.LAZY, cascade = CascadeType.ALL) + @JoinColumn(name = "inherits_from", referencedColumnName = "id", nullable = true) + private Role inheritsFrom; + + @OneToMany(mappedBy = "inheritsFrom", fetch = FetchType.LAZY, cascade = CascadeType.ALL) + private Set inheritedRoles; @OneToMany(mappedBy = "role") private Set persons; @@ -56,7 +67,6 @@ public class Role { @Column(updatable = false, nullable = false, name = "created_at") private Date createdAt; - @UpdateTimestamp(source = SourceType.DB) @Column(updatable = false, name = "updated_at") private Date updatedAt; diff --git a/src/main/java/com/sublinks/sublinksapi/authorization/entities/RolePermissions.java b/src/main/java/com/sublinks/sublinksapi/authorization/entities/RolePermissions.java index 52e295943..571ea77f4 100644 --- a/src/main/java/com/sublinks/sublinksapi/authorization/entities/RolePermissions.java +++ b/src/main/java/com/sublinks/sublinksapi/authorization/entities/RolePermissions.java @@ -1,7 +1,9 @@ package com.sublinks.sublinksapi.authorization.entities; +import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; @@ -27,7 +29,7 @@ public class RolePermissions { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @ManyToOne + @ManyToOne(targetEntity = Role.class, fetch = FetchType.LAZY, cascade = CascadeType.ALL) @JoinColumn(name = "role_id", referencedColumnName = "id", nullable = false) private Role role; diff --git a/src/main/java/com/sublinks/sublinksapi/authorization/enums/AllRoleTypes.java b/src/main/java/com/sublinks/sublinksapi/authorization/enums/AllRoleTypes.java new file mode 100644 index 000000000..054c9ca78 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/authorization/enums/AllRoleTypes.java @@ -0,0 +1,27 @@ +package com.sublinks.sublinksapi.authorization.enums; + +import java.util.ArrayList; +import java.util.List; + +public class AllRoleTypes { + + public static final List ALL_ROLE_TYPES = getAuthorizedEntityTypes(); + + public static List getAuthorizedEntityTypes() { + + final List permissions = new ArrayList<>(); + + permissions.addAll(List.of(RolePermissionRoleTypes.values())); + permissions.addAll(List.of(RolePermissionPostTypes.values())); + permissions.addAll(List.of(RolePermissionCommentTypes.values())); + permissions.addAll(List.of(RolePermissionPrivateMessageTypes.values())); + permissions.addAll(List.of(RolePermissionInstanceTypes.values())); + permissions.addAll(List.of(RolePermissionCommunityTypes.values())); + permissions.addAll(List.of(RolePermissionEmojiTypes.values())); + permissions.addAll(List.of(RolePermissionMediaTypes.values())); + permissions.addAll(List.of(RolePermissionModLogTypes.values())); + permissions.addAll(List.of(RolePermissionPersonTypes.values())); + + return permissions; + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/authorization/enums/RolePermissionCommentTypes.java b/src/main/java/com/sublinks/sublinksapi/authorization/enums/RolePermissionCommentTypes.java index 08fee9885..18122dbe6 100644 --- a/src/main/java/com/sublinks/sublinksapi/authorization/enums/RolePermissionCommentTypes.java +++ b/src/main/java/com/sublinks/sublinksapi/authorization/enums/RolePermissionCommentTypes.java @@ -4,6 +4,8 @@ public enum RolePermissionCommentTypes implements RolePermissionInterface { // Person permissions READ_COMMENT("comment", AuthorizeAction.READ), + READ_COMMENTS("comments", AuthorizeAction.READ), + READ_COMMENT_AGGREGATE("comment-aggregate", AuthorizeAction.READ), MARK_COMMENT_AS_READ("comment-read", AuthorizeAction.UPDATE), CREATE_COMMENT("comment", AuthorizeAction.CREATE), UPDATE_COMMENT("comment", AuthorizeAction.UPDATE), @@ -15,6 +17,7 @@ public enum RolePermissionCommentTypes implements RolePermissionInterface { COMMENT_UPVOTE("comment-upvote", AuthorizeAction.CREATE), COMMENT_DOWNVOTE("comment-downvote", AuthorizeAction.CREATE), COMMENT_NEUTRALVOTE("comment-neutralvote", AuthorizeAction.CREATE), + REPORT_COMMENT("comment-report", AuthorizeAction.CREATE), // Moderator permissions MODERATOR_REMOVE_COMMENT("comment-moderator", AuthorizeAction.DELETE), @@ -25,8 +28,7 @@ public enum RolePermissionCommentTypes implements RolePermissionInterface { // Admin permissions ADMIN_SHOW_DELETED_COMMENT("comment-admin", AuthorizeAction.READ), - ADMIN_SPEAK("admin-speak", AuthorizeAction.CREATE), - REPORT_COMMENT("comment-report", AuthorizeAction.CREATE); + ADMIN_SPEAK("admin-speak", AuthorizeAction.CREATE); public final String Entity; public final AuthorizeAction Action; diff --git a/src/main/java/com/sublinks/sublinksapi/authorization/enums/RolePermissionCommunityTypes.java b/src/main/java/com/sublinks/sublinksapi/authorization/enums/RolePermissionCommunityTypes.java index 115245260..ed25fe7dd 100644 --- a/src/main/java/com/sublinks/sublinksapi/authorization/enums/RolePermissionCommunityTypes.java +++ b/src/main/java/com/sublinks/sublinksapi/authorization/enums/RolePermissionCommunityTypes.java @@ -5,6 +5,8 @@ public enum RolePermissionCommunityTypes implements RolePermissionInterface { // Person permissions READ_COMMUNITY("community", AuthorizeAction.READ), READ_COMMUNITIES("communities", AuthorizeAction.READ), + READ_COMMUNITY_MODERATORS("communities-moderator", AuthorizeAction.READ), + READ_COMMUNITY_AGGREGATION("communities-aggregation", AuthorizeAction.READ), CREATE_COMMUNITY("community", AuthorizeAction.CREATE), UPDATE_COMMUNITY("community", AuthorizeAction.UPDATE), DELETE_COMMUNITY("community", AuthorizeAction.DELETE), @@ -19,6 +21,7 @@ public enum RolePermissionCommunityTypes implements RolePermissionInterface { MODERATOR_TRANSFER_COMMUNITY("community-moderator", AuthorizeAction.UPDATE), MODERATOR_ADD_MODERATOR("community-moderator", AuthorizeAction.CREATE), MODERATOR_REMOVE_MODERATOR("community-moderator-moderator", AuthorizeAction.DELETE), + MODERATOR_BAN_USER("community-moderator", AuthorizeAction.BAN), // Admin permissions ADMIN_SHOW_DELETED_COMMUNITY("community-admin", AuthorizeAction.READ), @@ -26,9 +29,11 @@ public enum RolePermissionCommunityTypes implements RolePermissionInterface { /** * Unused */ + ADMIN_UPDATE_COMMUNITY("community-admin", AuthorizeAction.UPDATE), ADMIN_REMOVE_COMMUNITY("community-admin", AuthorizeAction.REMOVE), ADMIN_ADD_COMMUNITY_MODERATOR("community-admin-moderator", AuthorizeAction.CREATE), ADMIN_REMOVE_COMMUNITY_MODERATOR("community-admin-moderator", AuthorizeAction.REMOVE), + ADMIN_BAN_USER("community-admin", AuthorizeAction.BAN), /** * Unused diff --git a/src/main/java/com/sublinks/sublinksapi/authorization/enums/RolePermissionInstanceTypes.java b/src/main/java/com/sublinks/sublinksapi/authorization/enums/RolePermissionInstanceTypes.java index cc118e2b2..619738d92 100644 --- a/src/main/java/com/sublinks/sublinksapi/authorization/enums/RolePermissionInstanceTypes.java +++ b/src/main/java/com/sublinks/sublinksapi/authorization/enums/RolePermissionInstanceTypes.java @@ -7,6 +7,7 @@ public enum RolePermissionInstanceTypes implements RolePermissionInterface { // Person permissions + INSTANCE_READ_CONFIG("instance-config", AuthorizeAction.READ), INSTANCE_UPDATE_SETTINGS("instance", AuthorizeAction.UPDATE), INSTANCE_BAN_USER("user-admin", AuthorizeAction.BAN), INSTANCE_BAN_READ("user-admin-ban", AuthorizeAction.READ), @@ -19,7 +20,13 @@ public enum RolePermissionInstanceTypes implements RolePermissionInterface { INSTANCE_REMOVE_ADMIN("instance-admin", AuthorizeAction.DELETE), INSTANCE_SEARCH("instance-search", AuthorizeAction.READ), REPORT_INSTANCE_READ("report-instance", AuthorizeAction.READ), - REPORT_INSTANCE_RESOLVE("report-instance", AuthorizeAction.UPDATE); + REPORT_INSTANCE_RESOLVE("report-instance", AuthorizeAction.UPDATE), + INSTANCE_READ_ANNOUNCEMENT("instance-announcement", AuthorizeAction.READ), + INSTANCE_READ_ANNOUNCEMENTS("instance-announcements", AuthorizeAction.READ), + INSTANCE_CREATE_ANNOUNCEMENT("instance-announcement", AuthorizeAction.CREATE), + INSTANCE_UPDATE_ANNOUNCEMENT("instance-announcement", AuthorizeAction.UPDATE), + INSTANCE_DELETE_ANNOUNCEMENT("instance-announcement", AuthorizeAction.DELETE); + public final String entity; public final AuthorizeAction action; diff --git a/src/main/java/com/sublinks/sublinksapi/authorization/enums/RolePermissionPersonTypes.java b/src/main/java/com/sublinks/sublinksapi/authorization/enums/RolePermissionPersonTypes.java index c2b52d293..77858eaee 100644 --- a/src/main/java/com/sublinks/sublinksapi/authorization/enums/RolePermissionPersonTypes.java +++ b/src/main/java/com/sublinks/sublinksapi/authorization/enums/RolePermissionPersonTypes.java @@ -4,19 +4,29 @@ public enum RolePermissionPersonTypes implements RolePermissionInterface { // Person permissions READ_USER("user", AuthorizeAction.READ), + READ_USERS("users", AuthorizeAction.READ), UPDATE_USER("user", AuthorizeAction.UPDATE), UPDATE_USER_SETTINGS("user-settings", AuthorizeAction.UPDATE), DELETE_USER("user", AuthorizeAction.DELETE), PURGE_USER("user", AuthorizeAction.PURGE), READ_MENTION_USER("user-mention", AuthorizeAction.READ), + READ_PERSON_AGGREGATION("user-aggregation", AuthorizeAction.READ), MARK_MENTION_AS_READ("user-mention-read", AuthorizeAction.UPDATE), READ_REPLIES("user-reply-read", AuthorizeAction.READ), MARK_REPLIES_AS_READ("user-reply-read", AuthorizeAction.UPDATE), RESET_PASSWORD("user-reset-password", AuthorizeAction.DELETE), USER_BLOCK("user", AuthorizeAction.BLOCK), - - // Moderator permissions - MODERATOR_BAN_USER("user-moderator", AuthorizeAction.BAN), + USER_LOGIN("user-login", AuthorizeAction.READ), + USER_EXPORT("user-export", AuthorizeAction.READ), + + READ_USER_METADATA("user-metadata", AuthorizeAction.READ), + READ_USER_METADATAS("user-metadatas", AuthorizeAction.READ), + READ_USER_OWN_METADATA("user-own-metadata", AuthorizeAction.READ), + READ_USER_OWN_METADATAS("user-own-metadatas", AuthorizeAction.READ), + INVALIDATE_USER_METADATA("user-metadata", AuthorizeAction.UPDATE), + INVALIDATE_USER_OWN_METADATA("user-own-metadata", AuthorizeAction.UPDATE), + DELETE_USER_METADATA("user-metadata", AuthorizeAction.DELETE), + DELETE_USER_OWN_METADATA("user-own-metadata", AuthorizeAction.DELETE), /** * Unused diff --git a/src/main/java/com/sublinks/sublinksapi/authorization/enums/RolePermissionPostTypes.java b/src/main/java/com/sublinks/sublinksapi/authorization/enums/RolePermissionPostTypes.java index 158970830..2d3d7ec92 100644 --- a/src/main/java/com/sublinks/sublinksapi/authorization/enums/RolePermissionPostTypes.java +++ b/src/main/java/com/sublinks/sublinksapi/authorization/enums/RolePermissionPostTypes.java @@ -4,6 +4,7 @@ public enum RolePermissionPostTypes implements RolePermissionInterface { // Person permissions READ_POST("post", AuthorizeAction.READ), + READ_POST_AGGREGATE("post-aggregate", AuthorizeAction.READ), READ_POSTS("posts", AuthorizeAction.READ), MARK_POST_AS_READ("post-read", AuthorizeAction.UPDATE), CREATE_POST("post", AuthorizeAction.CREATE), diff --git a/src/main/java/com/sublinks/sublinksapi/authorization/enums/RolePermissionPrivateMessageTypes.java b/src/main/java/com/sublinks/sublinksapi/authorization/enums/RolePermissionPrivateMessageTypes.java index 7a5904a3d..50f817153 100644 --- a/src/main/java/com/sublinks/sublinksapi/authorization/enums/RolePermissionPrivateMessageTypes.java +++ b/src/main/java/com/sublinks/sublinksapi/authorization/enums/RolePermissionPrivateMessageTypes.java @@ -3,12 +3,14 @@ public enum RolePermissionPrivateMessageTypes implements RolePermissionInterface { // Person permissions - READ_PRIVATE_MESSAGES("message", AuthorizeAction.READ), + READ_PRIVATE_MESSAGES("messages", AuthorizeAction.READ), + READ_PRIVATE_MESSAGE("message", AuthorizeAction.READ), MARK_PRIVATE_MESSAGE_AS_READ("message-read", AuthorizeAction.UPDATE), CREATE_PRIVATE_MESSAGE("message", AuthorizeAction.CREATE), UPDATE_PRIVATE_MESSAGE("message", AuthorizeAction.UPDATE), DELETE_PRIVATE_MESSAGE("message", AuthorizeAction.DELETE), PURGE_PRIVATE_MESSAGE("message", AuthorizeAction.PURGE), + PURGE_PRIVATE_MESSAGES("messages", AuthorizeAction.PURGE), // Report permissions REPORT_PRIVATE_MESSAGE("message-report", AuthorizeAction.CREATE); diff --git a/src/main/java/com/sublinks/sublinksapi/authorization/enums/RolePermissionRoleTypes.java b/src/main/java/com/sublinks/sublinksapi/authorization/enums/RolePermissionRoleTypes.java new file mode 100644 index 000000000..42510d757 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/authorization/enums/RolePermissionRoleTypes.java @@ -0,0 +1,27 @@ +package com.sublinks.sublinksapi.authorization.enums; + +public enum RolePermissionRoleTypes implements RolePermissionInterface { + + // Person permissions + ROLE_READ("role", AuthorizeAction.READ), + ROLES_READ("roles", AuthorizeAction.READ), + ROLE_CREATE("role", AuthorizeAction.CREATE), + ROLE_UPDATE("role", AuthorizeAction.UPDATE), + ROLE_DELETE("role", AuthorizeAction.DELETE); + + + public final String Entity; + public final AuthorizeAction Action; + + RolePermissionRoleTypes(String Entity, AuthorizeAction Action) { + + this.Entity = Entity; + this.Action = Action; + } + + @Override + public String toString() { + + return this.Entity + ":" + this.Action; + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/authorization/events/RoleCreatedEvent.java b/src/main/java/com/sublinks/sublinksapi/authorization/events/RoleCreatedEvent.java new file mode 100644 index 000000000..13ce988b0 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/authorization/events/RoleCreatedEvent.java @@ -0,0 +1,21 @@ +package com.sublinks.sublinksapi.authorization.events; + +import com.sublinks.sublinksapi.authorization.entities.Role; +import com.sublinks.sublinksapi.person.entities.LinkPersonCommunity; +import lombok.Getter; +import org.springframework.context.ApplicationEvent; + +@Getter +public class RoleCreatedEvent extends ApplicationEvent { + + private final Role role; + + public RoleCreatedEvent( + final Object source, + final Role createdRole + ) { + + super(source); + this.role = createdRole; + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/authorization/events/RoleCreatedPublisher.java b/src/main/java/com/sublinks/sublinksapi/authorization/events/RoleCreatedPublisher.java new file mode 100644 index 000000000..56903b391 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/authorization/events/RoleCreatedPublisher.java @@ -0,0 +1,23 @@ +package com.sublinks.sublinksapi.authorization.events; + +import com.sublinks.sublinksapi.authorization.entities.Role; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +@Component +public class RoleCreatedPublisher { + + private final ApplicationEventPublisher applicationEventPublisher; + + public RoleCreatedPublisher(final ApplicationEventPublisher applicationEventPublisher) + { + + this.applicationEventPublisher = applicationEventPublisher; + } + + public void publish(final Role role) { + + RoleCreatedEvent roleCreatedEvent = new RoleCreatedEvent(this, role); + applicationEventPublisher.publishEvent(roleCreatedEvent); + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/authorization/events/RoleDeletedEvent.java b/src/main/java/com/sublinks/sublinksapi/authorization/events/RoleDeletedEvent.java new file mode 100644 index 000000000..96eb05834 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/authorization/events/RoleDeletedEvent.java @@ -0,0 +1,18 @@ +package com.sublinks.sublinksapi.authorization.events; + +import com.sublinks.sublinksapi.authorization.entities.Role; +import lombok.Getter; +import org.springframework.context.ApplicationEvent; + +@Getter +public class RoleDeletedEvent extends ApplicationEvent { + + private final Role role; + + public RoleDeletedEvent(final Object source, final Role updatedRole) + { + + super(source); + this.role = updatedRole; + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/authorization/events/RoleDeletedPublisher.java b/src/main/java/com/sublinks/sublinksapi/authorization/events/RoleDeletedPublisher.java new file mode 100644 index 000000000..271878a61 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/authorization/events/RoleDeletedPublisher.java @@ -0,0 +1,23 @@ +package com.sublinks.sublinksapi.authorization.events; + +import com.sublinks.sublinksapi.authorization.entities.Role; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +@Component +public class RoleDeletedPublisher { + + private final ApplicationEventPublisher applicationEventPublisher; + + public RoleDeletedPublisher(final ApplicationEventPublisher applicationEventPublisher) + { + + this.applicationEventPublisher = applicationEventPublisher; + } + + public void publish(final Role role) { + + RoleDeletedEvent roleDeletedEvent = new RoleDeletedEvent(this, role); + applicationEventPublisher.publishEvent(roleDeletedEvent); + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/authorization/events/RoleUpdatedEvent.java b/src/main/java/com/sublinks/sublinksapi/authorization/events/RoleUpdatedEvent.java new file mode 100644 index 000000000..8e9ef3ac6 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/authorization/events/RoleUpdatedEvent.java @@ -0,0 +1,18 @@ +package com.sublinks.sublinksapi.authorization.events; + +import com.sublinks.sublinksapi.authorization.entities.Role; +import lombok.Getter; +import org.springframework.context.ApplicationEvent; + +@Getter +public class RoleUpdatedEvent extends ApplicationEvent { + + private final Role role; + + public RoleUpdatedEvent(final Object source, final Role updatedRole) + { + + super(source); + this.role = updatedRole; + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/authorization/events/RoleUpdatedPublisher.java b/src/main/java/com/sublinks/sublinksapi/authorization/events/RoleUpdatedPublisher.java new file mode 100644 index 000000000..61d572f60 --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/authorization/events/RoleUpdatedPublisher.java @@ -0,0 +1,23 @@ +package com.sublinks.sublinksapi.authorization.events; + +import com.sublinks.sublinksapi.authorization.entities.Role; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +@Component +public class RoleUpdatedPublisher { + + private final ApplicationEventPublisher applicationEventPublisher; + + public RoleUpdatedPublisher(final ApplicationEventPublisher applicationEventPublisher) + { + + this.applicationEventPublisher = applicationEventPublisher; + } + + public void publish(final Role role) { + + RoleUpdatedEvent roleUpdatedEvent = new RoleUpdatedEvent(this, role); + applicationEventPublisher.publishEvent(roleUpdatedEvent); + } +} diff --git a/src/main/java/com/sublinks/sublinksapi/authorization/repositories/RoleRepository.java b/src/main/java/com/sublinks/sublinksapi/authorization/repositories/RoleRepository.java index 102d9fba8..2890836d9 100644 --- a/src/main/java/com/sublinks/sublinksapi/authorization/repositories/RoleRepository.java +++ b/src/main/java/com/sublinks/sublinksapi/authorization/repositories/RoleRepository.java @@ -1,22 +1,14 @@ package com.sublinks.sublinksapi.authorization.repositories; import com.sublinks.sublinksapi.authorization.entities.Role; -import java.util.Collection; +import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; public interface RoleRepository extends JpaRepository, RoleSearchRepository { - Optional> findAllByName(String name); - Optional findFirstByName(String name); - Collection findByIdIn(Collection roleIds); - - Collection findAllByNameIn(Collection roleNames); - - Collection findAllByNameContaining(String roleName); - - Collection findAllByNameContainingIgnoreCase(String roleName); - + List findAllByNameIsLikeIgnoreCase(String name, Pageable pageable); } diff --git a/src/main/java/com/sublinks/sublinksapi/authorization/services/AclService.java b/src/main/java/com/sublinks/sublinksapi/authorization/services/AclService.java index 85733fd2e..5ab3b436b 100644 --- a/src/main/java/com/sublinks/sublinksapi/authorization/services/AclService.java +++ b/src/main/java/com/sublinks/sublinksapi/authorization/services/AclService.java @@ -13,7 +13,9 @@ import java.util.List; import java.util.function.Supplier; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; /** * The AclService class provides methods for handling Access Control List (ACL) policies. @@ -107,7 +109,8 @@ public static class EntityPolicy { * @param aclRepository the repository for accessing and manipulating ACL entities */ public EntityPolicy(final ActionType actionType, final AclRepository aclRepository, - final RolePermissionService rolePermissionService, RoleService roleService) { + final RolePermissionService rolePermissionService, RoleService roleService) + { this.roleService = roleService; @@ -127,7 +130,8 @@ public EntityPolicy(final ActionType actionType, final AclRepository aclReposito */ public EntityPolicy(final Person person, final ActionType actionType, final AclRepository aclRepository, RolePermissionService rolePermissionService, - RoleService roleService) { + RoleService roleService) + { this.person = person; this.actionType = actionType; @@ -201,8 +205,8 @@ public boolean isPermitted() { * @param the type of exception to be thrown * @throws X the thrown exception if the condition is not satisfied */ - public void orElseThrow(Supplier exceptionSupplier) - throws X { + public void orElseThrow(Supplier exceptionSupplier) throws X + { execute(); if (!isPermitted) { @@ -248,7 +252,6 @@ private void checkRolePermission() { this.isPermitted = this.authorizedActions.stream() .allMatch(permission -> rolePermissionService.isPermitted(this.person, permission, community.getId())); - } else { this.isPermitted = this.authorizedActions.stream() .allMatch(permission -> rolePermissionService.isPermitted(this.person, permission)); @@ -361,5 +364,21 @@ private void createAclRules() { } } } + + public void orThrowUnauthorized() { + + execute(); + if (!isPermitted) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "unauthorized"); + } + } + + public void orThrowBadRequest() { + + execute(); + if (!isPermitted) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "bad_request"); + } + } } } diff --git a/src/main/java/com/sublinks/sublinksapi/authorization/services/InitialRoleSetupService.java b/src/main/java/com/sublinks/sublinksapi/authorization/services/InitialRoleSetupService.java index 339307a1a..672086ecb 100644 --- a/src/main/java/com/sublinks/sublinksapi/authorization/services/InitialRoleSetupService.java +++ b/src/main/java/com/sublinks/sublinksapi/authorization/services/InitialRoleSetupService.java @@ -14,9 +14,10 @@ import com.sublinks.sublinksapi.authorization.enums.RoleTypes; import com.sublinks.sublinksapi.authorization.repositories.RolePermissionsRepository; import com.sublinks.sublinksapi.authorization.repositories.RoleRepository; +import jakarta.persistence.EntityManager; +import jakarta.transaction.Transactional; import java.util.HashSet; import java.util.Set; -import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -29,18 +30,20 @@ public class InitialRoleSetupService { private final RoleRepository roleRepository; private final RolePermissionsRepository rolePermissionsRepository; + private final EntityManager entityManager; /** * Generates the initial roles for the application. */ + @Transactional public void generateInitialRoles() { if (roleRepository.findAll() .isEmpty()) { - createAdminRole(); - createBannedRole(); - createGuestRole(); - createRegisteredRole(); + final Role bannedRole = createBannedRole(); + final Role guestRole = createGuestRole(bannedRole); + final Role registeredRole = createRegisteredRole(guestRole); + createAdminRole(registeredRole); } } @@ -50,14 +53,16 @@ public void generateInitialRoles() { * @param role the role for which the permissions are being saved * @param rolePermissions the set of role permissions to be saved */ - private void savePermissions(Role role, Set rolePermissions) { + protected void savePermissions(Role role, Set rolePermissions) { - role.setRolePermissions(rolePermissions.stream() - .map(rolePermission -> rolePermissionsRepository.save(RolePermissions.builder() + entityManager.merge(role); + rolePermissionsRepository.saveAllAndFlush(rolePermissions.stream() + .map(rolePermission -> RolePermissions.builder() .role(role) .permission(rolePermission.toString()) - .build())) - .collect(Collectors.toSet())); + .build()) + .toList()); + } /** @@ -65,28 +70,40 @@ private void savePermissions(Role role, Set rolePermiss * * @param rolePermissions the set of role permissions to which common permissions will be added */ - private void applyCommonPermissions(Set rolePermissions) { + protected void applyCommonPermissions(Set rolePermissions) { + rolePermissions.add(RolePermissionPrivateMessageTypes.READ_PRIVATE_MESSAGE); rolePermissions.add(RolePermissionPrivateMessageTypes.READ_PRIVATE_MESSAGES); rolePermissions.add(RolePermissionPostTypes.READ_POST); rolePermissions.add(RolePermissionPostTypes.READ_POSTS); rolePermissions.add(RolePermissionCommentTypes.READ_COMMENT); + rolePermissions.add(RolePermissionCommentTypes.READ_COMMENTS); rolePermissions.add(RolePermissionCommunityTypes.READ_COMMUNITY); rolePermissions.add(RolePermissionCommunityTypes.READ_COMMUNITIES); rolePermissions.add(RolePermissionPersonTypes.READ_USER); + rolePermissions.add(RolePermissionPersonTypes.READ_USERS); rolePermissions.add(RolePermissionModLogTypes.READ_MODLOG); + rolePermissions.add(RolePermissionPersonTypes.READ_PERSON_AGGREGATION); + rolePermissions.add(RolePermissionCommunityTypes.READ_COMMUNITY_AGGREGATION); + rolePermissions.add(RolePermissionCommunityTypes.READ_COMMUNITY_MODERATORS); + rolePermissions.add(RolePermissionPersonTypes.USER_LOGIN); rolePermissions.add(RolePermissionInstanceTypes.INSTANCE_SEARCH); + rolePermissions.add(RolePermissionInstanceTypes.INSTANCE_READ_ANNOUNCEMENT); + rolePermissions.add(RolePermissionInstanceTypes.INSTANCE_READ_ANNOUNCEMENTS); + rolePermissions.add(RolePermissionInstanceTypes.INSTANCE_READ_CONFIG); } /** * Creates the admin role with the specified permissions. */ - private void createAdminRole() { + protected void createAdminRole(final Role inheritedRole) { Set rolePermissions = new HashSet<>(); - Role adminRole = roleRepository.save(Role.builder() + Role adminRole = roleRepository.saveAndFlush(Role.builder() + .inheritsFrom(inheritedRole) .description("Admin role for admins") .name(RoleTypes.ADMIN.toString()) + .rolePermissions(new HashSet<>()) .isActive(true) .build()); @@ -96,44 +113,47 @@ private void createAdminRole() { /** * Creates the guest role with all associated permissions. */ - private void createGuestRole() { + protected Role createGuestRole(final Role inheritedRole) { Set rolePermissions = new HashSet<>(); - applyCommonPermissions(rolePermissions); - Role defaultUserRole = roleRepository.save(Role.builder() + Role defaultUserRole = roleRepository.saveAndFlush(Role.builder() + .inheritsFrom(inheritedRole) .description("Default role for all users") .name(RoleTypes.GUEST.toString()) + .rolePermissions(new HashSet<>()) .isActive(true) .build()); savePermissions(defaultUserRole, rolePermissions); + return defaultUserRole; } /** * Creates the banned role with all associated permissions. */ - private void createBannedRole() { + protected Role createBannedRole() { Set rolePermissions = new HashSet<>(); applyCommonPermissions(rolePermissions); - Role bannedRole = roleRepository.save(Role.builder() + Role bannedRole = roleRepository.saveAndFlush(Role.builder() .description("Banned role for banned users") .name(RoleTypes.BANNED.toString()) + .rolePermissions(new HashSet<>()) .isActive(true) .build()); savePermissions(bannedRole, rolePermissions); + return bannedRole; } /** * Creates the registered role with all associated permissions. */ - private void createRegisteredRole() { + protected Role createRegisteredRole(final Role inheritedRole) { Set rolePermissions = new HashSet<>(); - applyCommonPermissions(rolePermissions); rolePermissions.add(RolePermissionMediaTypes.CREATE_MEDIA); @@ -151,6 +171,8 @@ private void createRegisteredRole() { rolePermissions.add(RolePermissionCommunityTypes.CREATE_COMMUNITY); rolePermissions.add(RolePermissionCommunityTypes.UPDATE_COMMUNITY); rolePermissions.add(RolePermissionCommunityTypes.DELETE_COMMUNITY); + rolePermissions.add(RolePermissionCommunityTypes.READ_COMMUNITY_AGGREGATION); + rolePermissions.add(RolePermissionCommunityTypes.READ_COMMUNITY_MODERATORS); rolePermissions.add(RolePermissionPostTypes.CREATE_POST); rolePermissions.add(RolePermissionPostTypes.UPDATE_POST); @@ -162,11 +184,21 @@ private void createRegisteredRole() { rolePermissions.add(RolePermissionPersonTypes.UPDATE_USER_SETTINGS); rolePermissions.add(RolePermissionPersonTypes.RESET_PASSWORD); + rolePermissions.add(RolePermissionPersonTypes.USER_EXPORT); + rolePermissions.add(RolePermissionPersonTypes.READ_USER_OWN_METADATAS); + rolePermissions.add(RolePermissionPersonTypes.READ_USER_OWN_METADATA); + rolePermissions.add(RolePermissionPersonTypes.INVALIDATE_USER_OWN_METADATA); + rolePermissions.add(RolePermissionPersonTypes.DELETE_USER_OWN_METADATA); + rolePermissions.add(RolePermissionPersonTypes.MARK_MENTION_AS_READ); + rolePermissions.add(RolePermissionPersonTypes.MARK_REPLIES_AS_READ); + rolePermissions.add(RolePermissionPersonTypes.READ_MENTION_USER); + rolePermissions.add(RolePermissionPersonTypes.READ_REPLIES); rolePermissions.add(RolePermissionPostTypes.MODERATOR_REMOVE_POST); rolePermissions.add(RolePermissionCommentTypes.MODERATOR_REMOVE_COMMENT); rolePermissions.add(RolePermissionCommunityTypes.MODERATOR_REMOVE_COMMUNITY); - rolePermissions.add(RolePermissionPersonTypes.MODERATOR_BAN_USER); + rolePermissions.add(RolePermissionCommunityTypes.MODERATOR_BAN_USER); + rolePermissions.add(RolePermissionCommentTypes.MODERATOR_SPEAK); rolePermissions.add(RolePermissionCommentTypes.MODERATOR_SHOW_DELETED_COMMENT); rolePermissions.add(RolePermissionPostTypes.MODERATOR_SHOW_DELETED_POST); @@ -190,12 +222,15 @@ private void createRegisteredRole() { rolePermissions.add(RolePermissionCommunityTypes.REPORT_COMMUNITY_RESOLVE); rolePermissions.add(RolePermissionCommunityTypes.REPORT_COMMUNITY_READ); - Role registeredUserRole = roleRepository.save(Role.builder() + Role registeredUserRole = roleRepository.saveAndFlush(Role.builder() .description("Default Role for all registered users") + .inheritsFrom(inheritedRole) + .rolePermissions(new HashSet<>()) .name(RoleTypes.REGISTERED.toString()) .isActive(true) .build()); savePermissions(registeredUserRole, rolePermissions); + return registeredUserRole; } } diff --git a/src/main/java/com/sublinks/sublinksapi/authorization/services/RolePermissionService.java b/src/main/java/com/sublinks/sublinksapi/authorization/services/RolePermissionService.java index e884c7277..04851943d 100644 --- a/src/main/java/com/sublinks/sublinksapi/authorization/services/RolePermissionService.java +++ b/src/main/java/com/sublinks/sublinksapi/authorization/services/RolePermissionService.java @@ -1,6 +1,7 @@ package com.sublinks.sublinksapi.authorization.services; import com.sublinks.sublinksapi.authorization.entities.Role; +import com.sublinks.sublinksapi.authorization.entities.RolePermissions; import com.sublinks.sublinksapi.authorization.enums.RolePermissionInterface; import com.sublinks.sublinksapi.authorization.enums.RoleTypes; import com.sublinks.sublinksapi.community.entities.Community; @@ -9,11 +10,14 @@ import com.sublinks.sublinksapi.person.enums.LinkPersonCommunityType; import com.sublinks.sublinksapi.person.services.LinkPersonCommunityService; import jakarta.annotation.Nullable; +import java.util.HashSet; import java.util.Objects; import java.util.Set; import java.util.function.Supplier; +import java.util.stream.Collectors; import lombok.NonNull; import lombok.RequiredArgsConstructor; +import org.springframework.core.convert.ConversionService; import org.springframework.stereotype.Service; /** @@ -26,6 +30,7 @@ public class RolePermissionService { private final RoleService roleService; private final LinkPersonCommunityService linkPersonCommunityService; private final CommunityRepository communityRepository; + private final ConversionService conversionService; /** * Checks if a role is banned. @@ -112,7 +117,8 @@ public static boolean isAdmin(@NonNull final Role role) { * @throws X The exception provided by the exceptionSupplier if the person is not an admin. */ public static void isAdminElseThrow(Person person, - Supplier exceptionSupplier) throws X { + Supplier exceptionSupplier) throws X + { if (!isAdmin(person)) { throw exceptionSupplier.get(); @@ -127,7 +133,8 @@ public static void isAdminElseThrow(Person person, * @return True if the person is permitted, false otherwise. */ public boolean isPermitted(@Nullable final Person person, - final RolePermissionInterface rolePermission) { + final RolePermissionInterface rolePermission) + { final Role role = person == null ? roleService.getDefaultGuestRole( () -> new RuntimeException("No Guest role found.")) : person.getRole(); @@ -142,8 +149,8 @@ public boolean isPermitted(@Nullable final Person person, * @param rolePermission The permission to check. * @return True if the role is permitted, false otherwise. */ - public boolean isPermitted(@NonNull final Role role, - final RolePermissionInterface rolePermission) { + public boolean isPermitted(@NonNull final Role role, final RolePermissionInterface rolePermission) + { return isAdmin(role) || doesRoleHavePermission(role, rolePermission); } @@ -156,7 +163,8 @@ public boolean isPermitted(@NonNull final Role role, * @return True if the role is permitted, false otherwise. */ public boolean isPermitted(@NonNull final Role role, - final Set rolePermissions) { + final Set rolePermissions) + { return rolePermissions.stream() .anyMatch(x -> doesRoleHavePermission(role, x)); @@ -174,7 +182,8 @@ public boolean isPermitted(@NonNull final Role role, */ public void isPermitted(@NonNull final Role role, final Set rolePermissions, Supplier exceptionSupplier) - throws X { + throws X + { if (!isPermitted(role, rolePermissions)) { throw exceptionSupplier.get(); @@ -193,7 +202,8 @@ public void isPermitted(@NonNull final Role role, */ public void isPermitted(final Person person, final Set rolePermissions, Supplier exceptionSupplier) - throws X { + throws X + { final Role role = person == null ? roleService.getDefaultGuestRole( () -> new RuntimeException("No Guest role found.")) : person.getRole(); @@ -213,7 +223,8 @@ public void isPermitted(final Person person, */ public void isPermitted(@NonNull final Role role, final RolePermissionInterface rolePermission, Supplier exceptionSupplier) - throws X { + throws X + { if (!isPermitted(role, rolePermission)) { throw exceptionSupplier.get(); @@ -232,7 +243,8 @@ public void isPermitted(@NonNull final Role role, */ public void isPermitted(final Person person, final RolePermissionInterface rolePermission, Supplier exceptionSupplier) - throws X { + throws X + { if (person != null && person.isDeleted()) { throw exceptionSupplier.get(); @@ -253,12 +265,13 @@ public void isPermitted(final Person person, * @return True if the person is permitted, false otherwise. */ public boolean isPermitted(final Person person, final RolePermissionInterface rolePermission, - final Long communityId) { + final Long communityId) + { if (person != null && person.isDeleted()) { return false; } - Role role = null; + Role role; if (person != null) { role = isBannedInCommunity(person, communityId) ? this.roleService.getBannedRole() @@ -283,7 +296,8 @@ public boolean isPermitted(final Person person, final RolePermissionInterface ro */ public void isPermitted(final Person person, final RolePermissionInterface rolePermission, final Long communityId, - final Supplier exceptionSupplier) throws X { + final Supplier exceptionSupplier) throws X + { if (person != null && person.isDeleted()) { throw exceptionSupplier.get(); @@ -306,14 +320,30 @@ public void isPermitted(final Person person, * @return True if the role has the permission, false otherwise. */ private boolean doesRoleHavePermission(final Role role, - final RolePermissionInterface rolePermission) { + final RolePermissionInterface rolePermission) + { if (isAdmin(role)) { return true; } - return role.getRolePermissions() - .stream() - .anyMatch(x -> x.getPermission() + return getRolePermissions(role).stream() + .anyMatch(x -> x.toString() .equals(rolePermission.toString())); } + + public Set getRolePermissions(final Role role) { + + final Set rolePermissions = new HashSet<>(); + + Role currentRole = role; + + while (currentRole != null) { + rolePermissions.addAll(currentRole.getRolePermissions()); + currentRole = currentRole.getInheritsFrom(); + } + + return rolePermissions.stream() + .map(x -> conversionService.convert(x.getPermission(), RolePermissionInterface.class)) + .collect(Collectors.toSet()); + } } diff --git a/src/main/java/com/sublinks/sublinksapi/authorization/services/RoleService.java b/src/main/java/com/sublinks/sublinksapi/authorization/services/RoleService.java index 9b7e0b480..6d54feff9 100644 --- a/src/main/java/com/sublinks/sublinksapi/authorization/services/RoleService.java +++ b/src/main/java/com/sublinks/sublinksapi/authorization/services/RoleService.java @@ -1,7 +1,11 @@ package com.sublinks.sublinksapi.authorization.services; import com.sublinks.sublinksapi.authorization.entities.Role; +import com.sublinks.sublinksapi.authorization.entities.RolePermissions; import com.sublinks.sublinksapi.authorization.enums.RoleTypes; +import com.sublinks.sublinksapi.authorization.events.RoleCreatedPublisher; +import com.sublinks.sublinksapi.authorization.events.RoleDeletedPublisher; +import com.sublinks.sublinksapi.authorization.events.RoleUpdatedPublisher; import com.sublinks.sublinksapi.authorization.repositories.RoleRepository; import com.sublinks.sublinksapi.person.entities.Person; import com.sublinks.sublinksapi.person.repositories.PersonRepository; @@ -15,8 +19,12 @@ @RequiredArgsConstructor public class RoleService { - final private RoleRepository roleRepository; + private final RoleRepository roleRepository; private final PersonRepository personRepository; + private final RoleCreatedPublisher roleCreatedEventPublisher; + private final RoleUpdatedPublisher roleUpdatedEventPublisher; + private final RoleDeletedPublisher roleDeletedEventPublisher; + /** * Retrieves the admin role from the role repository. @@ -64,7 +72,8 @@ public Optional getDefaultRegisteredRole() { * @throws X The exception provided by the supplier if the default registered role is not found. */ public Role getDefaultRegisteredRole(Supplier supplier) - throws X { + throws X + { return getDefaultRegisteredRole().orElseThrow(supplier); } @@ -141,4 +150,48 @@ public Set getBannedUsers() { return personRepository.findAllByRole(getBannedRole(() -> new RuntimeException( "Cannot produce list of banned people because the Banned role doesn't exist."))); } + + public Role createRole(Role role) { + + roleRepository.save(role); + roleCreatedEventPublisher.publish(role); + + return role; + } + + public Role updateRole(Role role) { + + roleRepository.save(role); + roleUpdatedEventPublisher.publish(role); + + return role; + } + + public void deleteRole(Role role) { + + roleRepository.delete(role); + roleDeletedEventPublisher.publish(role); + } + + public RolePermissions getOrCreateRolePermission(Role role, String permission) { + + return role.getRolePermissions() + .stream() + .filter(rolePermission -> rolePermission.getPermission() + .equals(permission)) + .findFirst() + .orElseGet(() -> { + + RolePermissions rolePermission = RolePermissions.builder() + .role(role) + .permission(permission) + .build(); + + role.getRolePermissions() + .add(rolePermission); + this.updateRole(role); + + return rolePermission; + }); + } } diff --git a/src/main/java/com/sublinks/sublinksapi/comment/entities/Comment.java b/src/main/java/com/sublinks/sublinksapi/comment/entities/Comment.java index e5d2dc9c5..0ac3c2920 100644 --- a/src/main/java/com/sublinks/sublinksapi/comment/entities/Comment.java +++ b/src/main/java/com/sublinks/sublinksapi/comment/entities/Comment.java @@ -110,6 +110,9 @@ public class Comment implements Serializable, AclEntityInterface { @Column(nullable = false) private String path; + @Column(nullable = true, updatable = false, insertable = false, name = "search_vector") + private String searchVector; + @CreationTimestamp(source = SourceType.DB) @Column(updatable = false, nullable = false, name = "created_at") private Date createdAt; diff --git a/src/main/java/com/sublinks/sublinksapi/comment/models/CommentSearchCriteria.java b/src/main/java/com/sublinks/sublinksapi/comment/models/CommentSearchCriteria.java index 52a717ef5..50f5cea64 100644 --- a/src/main/java/com/sublinks/sublinksapi/comment/models/CommentSearchCriteria.java +++ b/src/main/java/com/sublinks/sublinksapi/comment/models/CommentSearchCriteria.java @@ -9,15 +9,17 @@ import lombok.Builder; @Builder -public record CommentSearchCriteria(ListingType listingType, - CommentSortType commentSortType, - Integer perPage, - Integer page, - Integer maxDepth, - Community community, - Post post, - Comment parent, - Boolean savedOnly, - Person person) { +public record CommentSearchCriteria( + String search, + ListingType listingType, + CommentSortType commentSortType, + Integer perPage, + Integer page, + Integer maxDepth, + Community community, + Post post, + Comment parent, + Boolean savedOnly, + Person person) { } diff --git a/src/main/java/com/sublinks/sublinksapi/comment/repositories/CommentAggregateRepository.java b/src/main/java/com/sublinks/sublinksapi/comment/repositories/CommentAggregateRepository.java index 3caa6bea3..45c1fca48 100644 --- a/src/main/java/com/sublinks/sublinksapi/comment/repositories/CommentAggregateRepository.java +++ b/src/main/java/com/sublinks/sublinksapi/comment/repositories/CommentAggregateRepository.java @@ -1,8 +1,12 @@ package com.sublinks.sublinksapi.comment.repositories; +import com.sublinks.sublinksapi.comment.entities.Comment; import com.sublinks.sublinksapi.comment.entities.CommentAggregate; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; public interface CommentAggregateRepository extends JpaRepository { + Optional findByComment_Path(String path); + } diff --git a/src/main/java/com/sublinks/sublinksapi/comment/repositories/CommentRepository.java b/src/main/java/com/sublinks/sublinksapi/comment/repositories/CommentRepository.java index 99eb0ba1d..1235c8717 100644 --- a/src/main/java/com/sublinks/sublinksapi/comment/repositories/CommentRepository.java +++ b/src/main/java/com/sublinks/sublinksapi/comment/repositories/CommentRepository.java @@ -5,6 +5,7 @@ import java.util.List; import java.util.Optional; import com.sublinks.sublinksapi.post.entities.Post; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface CommentRepository extends JpaRepository, CommentRepositorySearch { @@ -12,4 +13,6 @@ public interface CommentRepository extends JpaRepository, Comment List findByPerson(Person person); Optional findByPost(Post post); + + Optional findByPath(String path); } diff --git a/src/main/java/com/sublinks/sublinksapi/comment/repositories/CommentRepositoryImpl.java b/src/main/java/com/sublinks/sublinksapi/comment/repositories/CommentRepositoryImpl.java index 8d83bb2da..c9815faa6 100644 --- a/src/main/java/com/sublinks/sublinksapi/comment/repositories/CommentRepositoryImpl.java +++ b/src/main/java/com/sublinks/sublinksapi/comment/repositories/CommentRepositoryImpl.java @@ -85,6 +85,13 @@ public List allCommentsBySearchCriteria(CommentSearchCriteria commentSe cb.equal(roleJoin.get("name"), RoleTypes.ADMIN.toString()))); } + if (commentSearchCriteria.search() != null && !commentSearchCriteria.search() + .isEmpty()) { + predicates.add(cb.equal( + cb.function("fn_search_vector_is_same", Boolean.class, commentTable.get("searchVector"), + cb.literal(commentSearchCriteria.search())), true)); + } + if (commentSearchCriteria.savedOnly() != null && commentSearchCriteria.savedOnly()) { final Join linkPersonCommentJoin = commentTable.join( @@ -120,7 +127,8 @@ public List allCommentsBySearchCriteria(CommentSearchCriteria commentSe @Override public List allCommentsByCommunityAndPersonAndRemoved(Community community, Person person, - @Nullable List removedStates) { + @Nullable List removedStates) + { if (community == null || person == null) { throw new IllegalArgumentException("Community and person must be provided"); @@ -150,7 +158,8 @@ public List allCommentsByCommunityAndPersonAndRemoved(Community communi @Override public List allCommentsByPersonAndRemoved(Person person, - @Nullable List removedStates) { + @Nullable List removedStates) + { if (person == null) { throw new IllegalArgumentException("Person must be provided"); diff --git a/src/main/java/com/sublinks/sublinksapi/comment/services/CommentService.java b/src/main/java/com/sublinks/sublinksapi/comment/services/CommentService.java index d6e958cc3..d9c0690d4 100644 --- a/src/main/java/com/sublinks/sublinksapi/comment/services/CommentService.java +++ b/src/main/java/com/sublinks/sublinksapi/comment/services/CommentService.java @@ -38,7 +38,8 @@ public class CommentService { * @return A string representing the ActivityPub ID. */ public String generateActivityPubId( - final com.sublinks.sublinksapi.comment.entities.Comment comment) { + final com.sublinks.sublinksapi.comment.entities.Comment comment) + { String domain = localInstanceContext.instance() .getDomain(); @@ -75,9 +76,9 @@ public Optional getParentComment(final Comment comment) { @Transactional public void createComment(final Comment comment) { + commentRepository.saveAndFlush(comment); if (comment.getPath() == null || comment.getPath() .isBlank()) { - commentRepository.saveAndFlush(comment); comment.setPath(String.format("0.%d", comment.getId())); } comment.setActivityPubId(generateActivityPubId(comment)); @@ -195,7 +196,8 @@ public void updateComment(final Comment comment) { */ @Transactional public void removeAllCommentsFromCommunityAndUser(final Community community, final Person person, - final boolean removed) { + final boolean removed) + { commentRepository.allCommentsByCommunityAndPersonAndRemoved(community, person, List.of(removed ? RemovedState.NOT_REMOVED : RemovedState.REMOVED_BY_COMMUNITY)) diff --git a/src/main/java/com/sublinks/sublinksapi/common/enums/SortOrder.java b/src/main/java/com/sublinks/sublinksapi/common/enums/SortOrder.java new file mode 100644 index 000000000..1187b11cc --- /dev/null +++ b/src/main/java/com/sublinks/sublinksapi/common/enums/SortOrder.java @@ -0,0 +1,6 @@ +package com.sublinks.sublinksapi.common.enums; + +public enum SortOrder { + Asc, + Desc, +} diff --git a/src/main/java/com/sublinks/sublinksapi/community/models/CommunitySearchCriteria.java b/src/main/java/com/sublinks/sublinksapi/community/models/CommunitySearchCriteria.java index 65eb62427..e20e6650f 100644 --- a/src/main/java/com/sublinks/sublinksapi/community/models/CommunitySearchCriteria.java +++ b/src/main/java/com/sublinks/sublinksapi/community/models/CommunitySearchCriteria.java @@ -7,12 +7,12 @@ @Builder public record CommunitySearchCriteria( + String search, SortType sortType, ListingType listingType, int perPage, int page, - boolean showNsfw, - Person person -) { + Boolean showNsfw, + Person person) { } diff --git a/src/main/java/com/sublinks/sublinksapi/community/repositories/CommunityAggregateRepository.java b/src/main/java/com/sublinks/sublinksapi/community/repositories/CommunityAggregateRepository.java index 549d0685a..22df87b13 100644 --- a/src/main/java/com/sublinks/sublinksapi/community/repositories/CommunityAggregateRepository.java +++ b/src/main/java/com/sublinks/sublinksapi/community/repositories/CommunityAggregateRepository.java @@ -2,7 +2,11 @@ import com.sublinks.sublinksapi.community.entities.CommunityAggregate; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; public interface CommunityAggregateRepository extends JpaRepository { + @Query("SELECT ca FROM CommunityAggregate ca WHERE ca.community.titleSlug = :key") + CommunityAggregate findByCommunityKey(String key); + } diff --git a/src/main/java/com/sublinks/sublinksapi/community/repositories/CommunityRepository.java b/src/main/java/com/sublinks/sublinksapi/community/repositories/CommunityRepository.java index 8eecdc056..9f023b572 100644 --- a/src/main/java/com/sublinks/sublinksapi/community/repositories/CommunityRepository.java +++ b/src/main/java/com/sublinks/sublinksapi/community/repositories/CommunityRepository.java @@ -1,6 +1,9 @@ package com.sublinks.sublinksapi.community.repositories; import com.sublinks.sublinksapi.community.entities.Community; +import com.sublinks.sublinksapi.instance.entities.Instance; +import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface CommunityRepository extends JpaRepository, @@ -10,5 +13,13 @@ public interface CommunityRepository extends JpaRepository, Community findCommunityByIsLocalTrueAndTitleSlug(String titleSlug); - Community findCommunityByTitleSlug(String titleSlug); + Optional findCommunityByTitleSlug(String titleSlug); + + List findCommunityByTitleSlugIn(List titleSlug); + + boolean existsByTitleSlug(String titleSlug); + + List findCommunitiesByInstance(Instance instance); + + Community findCommunityByPublicKey(String publicKey); } diff --git a/src/main/java/com/sublinks/sublinksapi/community/repositories/CommunityRepositoryImpl.java b/src/main/java/com/sublinks/sublinksapi/community/repositories/CommunityRepositoryImpl.java index 904bb7d79..1749c7af9 100644 --- a/src/main/java/com/sublinks/sublinksapi/community/repositories/CommunityRepositoryImpl.java +++ b/src/main/java/com/sublinks/sublinksapi/community/repositories/CommunityRepositoryImpl.java @@ -22,7 +22,8 @@ public class CommunityRepositoryImpl implements CommunitySearchRepository { @Override public List allCommunitiesBySearchCriteria( - final CommunitySearchCriteria communitySearchCriteria) { + final CommunitySearchCriteria communitySearchCriteria) + { final CriteriaBuilder cb = em.getCriteriaBuilder(); final CriteriaQuery cq = cb.createQuery(Community.class); @@ -56,6 +57,13 @@ public List allCommunitiesBySearchCriteria( break; } + if (communitySearchCriteria.search() != null && !communitySearchCriteria.search() + .isEmpty()) { + predicates.add(cb.equal( + cb.function("fn_search_vector_is_same", Boolean.class, communityTable.get("searchVector"), + cb.literal(communitySearchCriteria.search())), true)); + } + cq.where(predicates.toArray(new Predicate[0])); switch (communitySearchCriteria.sortType()) { diff --git a/src/main/java/com/sublinks/sublinksapi/instance/entities/InstanceConfig.java b/src/main/java/com/sublinks/sublinksapi/instance/entities/InstanceConfig.java index 9a7e352fb..654ff3d3f 100644 --- a/src/main/java/com/sublinks/sublinksapi/instance/entities/InstanceConfig.java +++ b/src/main/java/com/sublinks/sublinksapi/instance/entities/InstanceConfig.java @@ -47,7 +47,6 @@ public class InstanceConfig { @Column(name = "registration_mode") @Enumerated(EnumType.STRING) - private RegistrationMode registrationMode; @Column(name = "registration_question") @@ -94,7 +93,6 @@ public class InstanceConfig { @Column(name = "default_post_listing_type") @Enumerated(EnumType.STRING) - private ListingType defaultPostListingType; @Column(name = "legal_information") diff --git a/src/main/java/com/sublinks/sublinksapi/instance/repositories/InstanceAggregateRepository.java b/src/main/java/com/sublinks/sublinksapi/instance/repositories/InstanceAggregateRepository.java index c28743d23..bc16e0f19 100644 --- a/src/main/java/com/sublinks/sublinksapi/instance/repositories/InstanceAggregateRepository.java +++ b/src/main/java/com/sublinks/sublinksapi/instance/repositories/InstanceAggregateRepository.java @@ -5,4 +5,6 @@ public interface InstanceAggregateRepository extends JpaRepository { + InstanceAggregate findByInstance_Domain(String domain); + } diff --git a/src/main/java/com/sublinks/sublinksapi/instance/repositories/InstanceConfigRepository.java b/src/main/java/com/sublinks/sublinksapi/instance/repositories/InstanceConfigRepository.java index fd2fc2f1b..836e689a3 100644 --- a/src/main/java/com/sublinks/sublinksapi/instance/repositories/InstanceConfigRepository.java +++ b/src/main/java/com/sublinks/sublinksapi/instance/repositories/InstanceConfigRepository.java @@ -8,4 +8,6 @@ public interface InstanceConfigRepository extends JpaRepository { Optional findByInstance(Instance instance); + + Optional findByInstance_Domain(String instance_domain); } diff --git a/src/main/java/com/sublinks/sublinksapi/instance/repositories/InstanceRepository.java b/src/main/java/com/sublinks/sublinksapi/instance/repositories/InstanceRepository.java index ffbdcc5fd..7bdd5e2ed 100644 --- a/src/main/java/com/sublinks/sublinksapi/instance/repositories/InstanceRepository.java +++ b/src/main/java/com/sublinks/sublinksapi/instance/repositories/InstanceRepository.java @@ -1,8 +1,19 @@ package com.sublinks.sublinksapi.instance.repositories; import com.sublinks.sublinksapi.instance.entities.Instance; +import java.util.List; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface InstanceRepository extends JpaRepository { + Instance findInstanceByDomain(String domain); + + @Query(value = "SELECT s.* FROM instances s WHERE s.search_vector @@ to_tsquery('english', :keyword)", + countQuery = "SELECT COUNT(s.id) FROM instances s WHERE s.search_vector @@ to_tsquery('english', :keyword)", + nativeQuery = true) + List findInstancesByDomainOrDescriptionOrSidebar(@Param("keyword") String keyword, Pageable pageable); + } diff --git a/src/main/java/com/sublinks/sublinksapi/instance/services/InstanceConfigService.java b/src/main/java/com/sublinks/sublinksapi/instance/services/InstanceConfigService.java index df38d25e0..36e28e0f7 100644 --- a/src/main/java/com/sublinks/sublinksapi/instance/services/InstanceConfigService.java +++ b/src/main/java/com/sublinks/sublinksapi/instance/services/InstanceConfigService.java @@ -4,7 +4,7 @@ import com.sublinks.sublinksapi.instance.events.InstanceConfigCreatedPublisher; import com.sublinks.sublinksapi.instance.events.InstanceConfigUpdatedPublisher; import com.sublinks.sublinksapi.instance.repositories.InstanceConfigRepository; -import jakarta.validation.constraints.NotNull; +import lombok.NonNull; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -18,14 +18,14 @@ public class InstanceConfigService { private final InstanceConfigUpdatedPublisher instanceConfigUpdatedPublisher; @Transactional - public void createInstanceConfig(@NotNull InstanceConfig instance) { + public void createInstanceConfig(@NonNull InstanceConfig instance) { instanceConfigRepository.save(instance); instanceConfigCreatedPublisher.publish(instance); } @Transactional - public void updateInstanceConfig(@NotNull InstanceConfig instance) { + public void updateInstanceConfig(@NonNull InstanceConfig instance) { instanceConfigRepository.save(instance); instanceConfigUpdatedPublisher.publish(instance); diff --git a/src/main/java/com/sublinks/sublinksapi/instance/services/InstanceService.java b/src/main/java/com/sublinks/sublinksapi/instance/services/InstanceService.java index 78b09aea1..605198eec 100644 --- a/src/main/java/com/sublinks/sublinksapi/instance/services/InstanceService.java +++ b/src/main/java/com/sublinks/sublinksapi/instance/services/InstanceService.java @@ -6,6 +6,7 @@ import com.sublinks.sublinksapi.utils.KeyGeneratorUtil; import com.sublinks.sublinksapi.utils.KeyStore; import jakarta.validation.constraints.NotNull; +import lombok.NonNull; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -20,7 +21,7 @@ public class InstanceService { private final KeyGeneratorUtil keyGeneratorUtil; @Transactional - public void createInstance(@NotNull Instance instance) { + public void createInstance(@NonNull Instance instance) { KeyStore keys = keyGeneratorUtil.generate(); instance.setPublicKey(keys.publicKey()); @@ -29,14 +30,14 @@ public void createInstance(@NotNull Instance instance) { } @Transactional - public void createInstanceAndFlush(@NotNull Instance instance) { + public void createInstanceAndFlush(@NonNull Instance instance) { createInstance(instance); instanceRepository.flush(); } @Transactional - public void updateInstance(@NotNull Instance instance) { + public void updateInstance(@NonNull Instance instance) { instanceRepository.save(instance); } diff --git a/src/main/java/com/sublinks/sublinksapi/language/entities/Language.java b/src/main/java/com/sublinks/sublinksapi/language/entities/Language.java index 746becb7d..af1fef185 100644 --- a/src/main/java/com/sublinks/sublinksapi/language/entities/Language.java +++ b/src/main/java/com/sublinks/sublinksapi/language/entities/Language.java @@ -3,6 +3,7 @@ import com.sublinks.sublinksapi.community.entities.Community; import com.sublinks.sublinksapi.instance.entities.Instance; import com.sublinks.sublinksapi.person.entities.Person; +import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; @@ -52,8 +53,10 @@ public class Language { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Column(unique = true) private String code; + @Column(unique = true) private String name; @Override diff --git a/src/main/java/com/sublinks/sublinksapi/language/repositories/LanguageRepository.java b/src/main/java/com/sublinks/sublinksapi/language/repositories/LanguageRepository.java index b77329309..831372d71 100644 --- a/src/main/java/com/sublinks/sublinksapi/language/repositories/LanguageRepository.java +++ b/src/main/java/com/sublinks/sublinksapi/language/repositories/LanguageRepository.java @@ -1,10 +1,12 @@ package com.sublinks.sublinksapi.language.repositories; import com.sublinks.sublinksapi.language.entities.Language; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; public interface LanguageRepository extends JpaRepository { Language findLanguageByCode(String code); + List findAllByCodeIsIn(List codes); } diff --git a/src/main/java/com/sublinks/sublinksapi/language/services/LanguageService.java b/src/main/java/com/sublinks/sublinksapi/language/services/LanguageService.java index b4d9acda0..7fc660fe6 100644 --- a/src/main/java/com/sublinks/sublinksapi/language/services/LanguageService.java +++ b/src/main/java/com/sublinks/sublinksapi/language/services/LanguageService.java @@ -1,5 +1,6 @@ package com.sublinks.sublinksapi.language.services; +import com.sublinks.sublinksapi.community.entities.Community; import com.sublinks.sublinksapi.instance.entities.Instance; import com.sublinks.sublinksapi.language.entities.Language; import com.sublinks.sublinksapi.language.repositories.LanguageRepository; @@ -16,6 +17,14 @@ public class LanguageService { private final LanguageRepository languageRepository; + public Language getLanguageOfCommunityOrUndefined(final Community community) { + + return community.getLanguages() + .stream() + .findFirst() + .orElse(languageRepository.findLanguageByCode("und")); + } + public List instanceLanguageIds(final Instance instance) { final List discussionLanguages = new ArrayList<>(); diff --git a/src/main/java/com/sublinks/sublinksapi/person/entities/Person.java b/src/main/java/com/sublinks/sublinksapi/person/entities/Person.java index 7050fb5ae..dffe6a84d 100644 --- a/src/main/java/com/sublinks/sublinksapi/person/entities/Person.java +++ b/src/main/java/com/sublinks/sublinksapi/person/entities/Person.java @@ -163,28 +163,27 @@ public class Person implements UserDetails, Principal { @Column(nullable = false) private String password; - @Column(nullable = false) + @Column(nullable = true, name = "avatar_image_url") private String avatarImageUrl; - @Column(nullable = false, name = "banner_image_url") + @Column(nullable = true, name = "banner_image_url") private String bannerImageUrl; - @Column(nullable = false) + @Column(nullable = true) private String biography; - @Column(nullable = false, name = "interface_language") + @Column(nullable = true, name = "interface_language") private String interfaceLanguage; - @Column(nullable = false, name = "default_theme") + @Column(nullable = true, name = "default_theme") private String defaultTheme; - @Column(nullable = false, name = "default_listing_type") + @Column(nullable = true, name = "default_listing_type") @Enumerated(EnumType.STRING) private ListingType defaultListingType; - @Column(nullable = false, name = "default_sort_type") + @Column(nullable = true, name = "default_sort_type") @Enumerated(EnumType.STRING) - private SortType defaultSortType; @Column(nullable = false, name = "post_listing_type") @@ -351,4 +350,9 @@ public final int hashCode() { .getPersistentClass() .hashCode() : getClass().hashCode(); } + + public String getKey() { + + return name + "@" + instance.getDomain(); + } } diff --git a/src/main/java/com/sublinks/sublinksapi/person/entities/PersonRegistrationApplication.java b/src/main/java/com/sublinks/sublinksapi/person/entities/PersonRegistrationApplication.java index f49318c4a..7bd2775d4 100644 --- a/src/main/java/com/sublinks/sublinksapi/person/entities/PersonRegistrationApplication.java +++ b/src/main/java/com/sublinks/sublinksapi/person/entities/PersonRegistrationApplication.java @@ -59,7 +59,6 @@ public class PersonRegistrationApplication { @Column(nullable = false, name = "application_status") @Enumerated(EnumType.STRING) - private PersonRegistrationApplicationStatus applicationStatus; @CreationTimestamp(source = SourceType.DB) @@ -97,6 +96,7 @@ public final boolean equals(Object o) { public final int hashCode() { return this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer() - .getPersistentClass().hashCode() : getClass().hashCode(); + .getPersistentClass() + .hashCode() : getClass().hashCode(); } } diff --git a/src/main/java/com/sublinks/sublinksapi/person/enums/PersonRegistrationApplicationStatus.java b/src/main/java/com/sublinks/sublinksapi/person/enums/PersonRegistrationApplicationStatus.java index 6dec96dce..177e9eeaa 100644 --- a/src/main/java/com/sublinks/sublinksapi/person/enums/PersonRegistrationApplicationStatus.java +++ b/src/main/java/com/sublinks/sublinksapi/person/enums/PersonRegistrationApplicationStatus.java @@ -1,6 +1,7 @@ package com.sublinks.sublinksapi.person.enums; public enum PersonRegistrationApplicationStatus { + inactive, pending, approved, rejected diff --git a/src/main/java/com/sublinks/sublinksapi/person/repositories/PersonAggregateRepository.java b/src/main/java/com/sublinks/sublinksapi/person/repositories/PersonAggregateRepository.java index df680446e..c9b90a779 100644 --- a/src/main/java/com/sublinks/sublinksapi/person/repositories/PersonAggregateRepository.java +++ b/src/main/java/com/sublinks/sublinksapi/person/repositories/PersonAggregateRepository.java @@ -1,9 +1,11 @@ package com.sublinks.sublinksapi.person.repositories; +import com.sublinks.sublinksapi.person.entities.Person; import com.sublinks.sublinksapi.person.entities.PersonAggregate; import org.springframework.data.jpa.repository.JpaRepository; public interface PersonAggregateRepository extends JpaRepository { - PersonAggregate findFirstByPersonId(Long personId); + + PersonAggregate findByPerson(Person person); } diff --git a/src/main/java/com/sublinks/sublinksapi/person/repositories/PersonRepository.java b/src/main/java/com/sublinks/sublinksapi/person/repositories/PersonRepository.java index c9a10fc56..a0e156a98 100644 --- a/src/main/java/com/sublinks/sublinksapi/person/repositories/PersonRepository.java +++ b/src/main/java/com/sublinks/sublinksapi/person/repositories/PersonRepository.java @@ -6,7 +6,10 @@ import java.util.HashSet; import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; /** * The PersonRepository interface is a repository interface that extends the JpaRepository @@ -16,9 +19,32 @@ public interface PersonRepository extends JpaRepository { Optional findOneByNameIgnoreCase(String name); + @Query(value = "SELECT p.*, lpi.instance_id, lpi.person_id, i.id as iid FROM people p JOIN link_person_instances lpi ON lpi.person_id = p.id JOIN instances i ON i.id = lpi.instance_id WHERE p.name = :name AND i.domain ILIKE concat('%','/',:instance_domain)", + nativeQuery = true) + Optional findOneByNameAndInstance_Domain(String name, String instance_domain); + Optional findOneByEmail(String email); HashSet findAllByRole(Role role); + List findAllByIsLocal(Boolean local, Pageable pageable); + + @Query(value = "SELECT p.*, i.id as link_instance_id, i.instance_id FROM people p JOIN link_person_instances i ON p.id = i.person_id WHERE p.search_vector @@ to_tsquery('english', :keyword)", + countQuery = "SELECT COUNT(p.id) FROM people p WHERE p.search_vector @@ to_tsquery('english', :keyword)", + nativeQuery = true) + List findAllByNameAndBiography(@Param("keyword") String keyword, Pageable pageable); + + @Query(value = "SELECT p.*, i.id as link_instance_id, i.instance_id FROM people p JOIN link_person_instances i ON p.id = i.person_id WHERE p.search_vector @@ to_tsquery('english', :keyword) AND p.is_local = :local", + countQuery = "SELECT COUNT(p.id) FROM people p WHERE p.search_vector @@ to_tsquery('english', :keyword)", + nativeQuery = true) + List findAllByNameAndBiographyAndLocal(@Param("keyword") String keyword, + @Param("local") Boolean local, Pageable pageable); + + @Query(value = "SELECT p.*, i.id as link_instance_id, i.instance_id FROM people p JOIN link_person_instances i ON p.id = i.person_id WHERE p.search_vector @@ to_tsquery('english', :keyword) AND p.role_id = :role", + countQuery = "SELECT COUNT(p.id) FROM people p WHERE p.search_vector @@ to_tsquery('english', :keyword) AND p.role_id = :role", + nativeQuery = true) + List findAllByNameAndBiographyAndRole(@Param("keyword") String keyword, + @Param("role") long roleId, Pageable pageable); + List findAllByRoleExpireAtBefore(Date expireAt); } diff --git a/src/main/java/com/sublinks/sublinksapi/person/repositories/UserDataRepository.java b/src/main/java/com/sublinks/sublinksapi/person/repositories/UserDataRepository.java index 036a41b8a..7c7d51a62 100644 --- a/src/main/java/com/sublinks/sublinksapi/person/repositories/UserDataRepository.java +++ b/src/main/java/com/sublinks/sublinksapi/person/repositories/UserDataRepository.java @@ -15,11 +15,10 @@ public interface UserDataRepository extends JpaRepository List findFirstByPersonAndIpAddress(Person person, String ipAddress); Optional findFirstByPersonAndIpAddressAndUserAgentAndActiveIsTrue(Person person, - String ipAddress, - String userAgent); + String ipAddress, String userAgent); - Optional findFirstByPersonAndTokenAndIpAddressAndUserAgentAndActiveIsTrue(Person person, - String token, String ipAddress, String userAgent); + Optional findFirstByPersonAndTokenAndIpAddressAndUserAgentAndActiveIsTrue( + Person person, String token, String ipAddress, String userAgent); Optional findFirstByPersonAndTokenAndIpAddressAndActiveIsTrue(Person person, String token, String ipAddress); @@ -31,8 +30,17 @@ Optional findFirstByPersonAndTokenAndIpAddressAndActiveIsTrue(Pe List findAllByLastUsedAtBefore(Date lastUsedAt); + List findAllByPerson(Person person); @Modifying @Query("update PersonMetaData u set u.active = false where u.person = :person") void updateAllByPersonSetActiveToFalse(@Param(value = "person") Person person); + + @Modifying + @Query( + "update PersonMetaData u set u.active = false where u.token = :token") + void updateAllByPersonAndIpAddressSetActiveToFalse(@Param(value = "token") String token); + + Optional findByToken(String token); } + diff --git a/src/main/java/com/sublinks/sublinksapi/person/services/LinkPersonCommunityService.java b/src/main/java/com/sublinks/sublinksapi/person/services/LinkPersonCommunityService.java index 999487c30..7a71f0704 100644 --- a/src/main/java/com/sublinks/sublinksapi/person/services/LinkPersonCommunityService.java +++ b/src/main/java/com/sublinks/sublinksapi/person/services/LinkPersonCommunityService.java @@ -1,5 +1,6 @@ package com.sublinks.sublinksapi.person.services; +import com.sublinks.sublinksapi.authorization.services.RolePermissionService; import com.sublinks.sublinksapi.common.interfaces.ILinkingService; import com.sublinks.sublinksapi.community.entities.Community; import com.sublinks.sublinksapi.person.entities.LinkPersonCommunity; @@ -11,6 +12,7 @@ import com.sublinks.sublinksapi.person.repositories.LinkPersonCommunityRepository; import java.util.Date; import java.util.List; +import java.util.Objects; import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -26,16 +28,46 @@ public class LinkPersonCommunityService implements private final LinkPersonCommunityUpdatedPublisher linkPersonCommunityUpdatedPublisher; private final LinkPersonCommunityDeletedPublisher linkPersonCommunityDeletedPublisher; + public boolean hasLinkOrAdmin(Person person, Community community, LinkPersonCommunityType type) { + + return RolePermissionService.isAdmin(person) || hasLink(person, community, type); + } + + public boolean hasAnyLinkOrAdmin(Person person, Community community, + List types) + { + + return RolePermissionService.isAdmin(person) || hasAnyLink(person, community, types); + } + + public boolean hasLink(Person person, Community community, LinkPersonCommunityType type) { + + final Optional linkPersonCommunity = linkPersonCommunityRepository.getLinkPersonCommunityByCommunityAndPersonAndLinkType( + community, person, type); + return linkPersonCommunity.isPresent(); + } + + public boolean hasAnyLink(Person person, Community community, List types) + { + + final List linkPersonCommunity = linkPersonCommunityRepository.getLinkPersonCommunityByCommunityAndPersonAndLinkTypeIsIn( + community, person, types); + return !linkPersonCommunity.isEmpty(); + } + + @Transactional public void createLinkPersonCommunityLink(Community community, Person person, - LinkPersonCommunityType type) { + LinkPersonCommunityType type) + { createLinkPersonCommunityLink(community, person, type, null); } @Transactional public void createLinkPersonCommunityLink(Community community, Person person, - LinkPersonCommunityType type, Date expireAt) { + LinkPersonCommunityType type, Date expireAt) + { final LinkPersonCommunity newLink = LinkPersonCommunity.builder() .community(community) @@ -49,7 +81,8 @@ public void createLinkPersonCommunityLink(Community community, Person person, @Override public boolean hasLink(Community community, Person person, - LinkPersonCommunityType linkPersonCommunityType) { + LinkPersonCommunityType linkPersonCommunityType) + { return this.linkPersonCommunityRepository.getLinkPersonCommunityByCommunityAndPersonAndLinkType( community, person, linkPersonCommunityType) @@ -58,7 +91,8 @@ public boolean hasLink(Community community, Person person, @Override public boolean hasAnyLink(Community community, Person person, - List linkPersonCommunityTypes) { + List linkPersonCommunityTypes) + { return !this.linkPersonCommunityRepository.getLinkPersonCommunitiesByCommunityAndLinkTypeIn( community, linkPersonCommunityTypes) @@ -111,7 +145,8 @@ public void deleteLink(LinkPersonCommunity link) { @Transactional @Override public void deleteLink(Community community, Person person, - LinkPersonCommunityType linkPersonCommunityType) { + LinkPersonCommunityType linkPersonCommunityType) + { final Optional linkOptional = this.getLink(community, person, linkPersonCommunityType); @@ -135,12 +170,32 @@ public void deleteLinks(List linkPersonCommunities) { @Override public Optional getLink(Community community, Person person, - LinkPersonCommunityType linkPersonCommunityType) { + LinkPersonCommunityType linkPersonCommunityType) + { return this.linkPersonCommunityRepository.getLinkPersonCommunityByCommunityAndPersonAndLinkType( community, person, linkPersonCommunityType); } + public void removeAnyLink(Person person, Community community, List types) + { + + final List linkPersonCommunity = linkPersonCommunityRepository.getLinkPersonCommunityByCommunityAndPersonAndLinkTypeIsIn( + community, person, types); + if (linkPersonCommunity.isEmpty()) { + return; + } + + linkPersonCommunity.forEach(l -> { + person.getLinkPersonCommunity() + .removeIf(link -> Objects.equals(link.getId(), l.getId())); + community.getLinkPersonCommunity() + .removeIf(link -> Objects.equals(link.getId(), l.getId())); + linkPersonCommunityRepository.delete(l); + linkPersonCommunityDeletedPublisher.publish(l); + }); + } + @Override public List getLinks(Person person) { @@ -162,7 +217,8 @@ public List getLinks(Person person, LinkPersonCommunityType } @Override - public List getLinksByEntity(Community community, Person person) { + public List getLinksByEntity(Community community, Person person) + { return this.linkPersonCommunityRepository.getLinkPersonCommunitiesByCommunityAndPerson( community, person); @@ -170,12 +226,21 @@ public List getLinksByEntity(Community community, Person pe @Override public List getLinksByEntity(Community community, - List linkPersonCommunityType) { + List linkPersonCommunityType) + { return this.linkPersonCommunityRepository.getLinkPersonCommunitiesByCommunityAndLinkTypeIn( community, linkPersonCommunityType); } + public List getLinkPersonCommunitiesByCommunityAndPersonAndLinkTypeIsIn( + Community community, List types) + { + + return linkPersonCommunityRepository.getLinkPersonCommunitiesByCommunityAndLinkTypeIn(community, + types); + } + @Override public List getLinksByEntity(Community community) { diff --git a/src/main/java/com/sublinks/sublinksapi/person/services/LinkPersonPostService.java b/src/main/java/com/sublinks/sublinksapi/person/services/LinkPersonPostService.java index c7705156e..6b9575376 100644 --- a/src/main/java/com/sublinks/sublinksapi/person/services/LinkPersonPostService.java +++ b/src/main/java/com/sublinks/sublinksapi/person/services/LinkPersonPostService.java @@ -71,7 +71,7 @@ public void createLinks(List linkPersonPosts) { linkPersonPostRepository.saveAllAndFlush(linkPersonPosts) .forEach((link) -> { - + this.linkPersonPostCreatedPublisher.publish(link); }); } @@ -91,7 +91,7 @@ public void updateLinks(List linkPersonPosts) { this.linkPersonPostRepository.saveAllAndFlush(linkPersonPosts) .forEach((link) -> { - + linkPersonPostUpdatedPublisher.publish(link); }); } @@ -114,7 +114,7 @@ public void deleteLink(Post post, Person person, LinkPersonPostType linkPersonPo if (linkPersonPostOptional.isPresent()) { final LinkPersonPost link = linkPersonPostOptional.get(); - + this.linkPersonPostDeletedPublisher.publish(link); } } @@ -125,7 +125,7 @@ public void deleteLinks(List linkPersonPosts) { this.linkPersonPostRepository.deleteAll(linkPersonPosts); linkPersonPosts.forEach((link) -> { - + this.linkPersonPostDeletedPublisher.publish(link); }); } diff --git a/src/main/java/com/sublinks/sublinksapi/person/services/PersonService.java b/src/main/java/com/sublinks/sublinksapi/person/services/PersonService.java index 47ad4cc6b..b969f696e 100644 --- a/src/main/java/com/sublinks/sublinksapi/person/services/PersonService.java +++ b/src/main/java/com/sublinks/sublinksapi/person/services/PersonService.java @@ -63,7 +63,8 @@ public class PersonService { */ @Transactional public Optional getPersonDefaultPostLanguage(final Person person, - final Community community) { + final Community community) + { for (Language language : person.getLanguages()) { if (community.getLanguages() @@ -218,4 +219,11 @@ public void deleteUserAccount(final Person person, final boolean deleteContent) personDeletedPublisher.publish(personRepository.save(person), deleteContent); } + + public void updatePassword(Person person, String newPassword) { + + person.setPassword(encodePassword(newPassword)); + personRepository.save(person); + + } } diff --git a/src/main/java/com/sublinks/sublinksapi/person/services/UserDataService.java b/src/main/java/com/sublinks/sublinksapi/person/services/UserDataService.java index 4bf22dd39..41612dbb6 100644 --- a/src/main/java/com/sublinks/sublinksapi/person/services/UserDataService.java +++ b/src/main/java/com/sublinks/sublinksapi/person/services/UserDataService.java @@ -29,12 +29,13 @@ public class UserDataService { public void invalidate(PersonMetaData personMetaData) { personMetaData.setActive(false); - userDataRepository.save(personMetaData); + userDataRepository.saveAndFlush(personMetaData); userDataInvalidationEventPublisher.publish(personMetaData); } public void checkAndAddIpRelation(Person person, String ipAddress, String token, - @Nullable String userAgent) { + @Nullable String userAgent) + { boolean saveUserData = userDataConfig.isSaveUserData(); Optional foundData = getActiveUserDataByPersonAndToken(person, token); @@ -70,11 +71,8 @@ public Optional getActiveUserDataByPersonAndToken(Person person, } private Optional getActiveUserDataByPersonAndIpAddress(Person person, - String token, String ipAddress, String userAgent) { - - if (userDataConfig.isSaveUserData()) { - return userDataRepository.findFirstByPersonAndTokenAndActiveIsTrue(person, ipAddress); - } + String token, String ipAddress, String userAgent) + { return userDataRepository.findFirstByPersonAndTokenAndIpAddressAndUserAgentAndActiveIsTrue( person, token, ipAddress, userAgent); @@ -84,6 +82,7 @@ private Optional getActiveUserDataByPersonAndIpAddress(Person pe public void invalidateAllUserData(Person person) { userDataRepository.updateAllByPersonSetActiveToFalse(person); + userDataRepository.flush(); userDataInvalidationEventPublisher.publish(person); } diff --git a/src/main/java/com/sublinks/sublinksapi/post/entities/Post.java b/src/main/java/com/sublinks/sublinksapi/post/entities/Post.java index 7511bdfc9..87ad7d52c 100644 --- a/src/main/java/com/sublinks/sublinksapi/post/entities/Post.java +++ b/src/main/java/com/sublinks/sublinksapi/post/entities/Post.java @@ -61,7 +61,9 @@ public class Post implements AclEntityInterface { Set linkPersonPost; @ManyToOne - @JoinTable(name = "post_post_cross_post", joinColumns = @JoinColumn(name = "post_id"), inverseJoinColumns = @JoinColumn(name = "cross_post_id")) + @JoinTable(name = "post_post_cross_post", + joinColumns = @JoinColumn(name = "post_id"), + inverseJoinColumns = @JoinColumn(name = "cross_post_id")) CrossPost crossPost; @ManyToOne(fetch = FetchType.EAGER) @@ -102,7 +104,6 @@ public class Post implements AclEntityInterface { @Column(nullable = false, name = "removed_state") @Enumerated(EnumType.STRING) - private RemovedState removedState; @Column(nullable = false, name = "is_local") @@ -149,6 +150,9 @@ public class Post implements AclEntityInterface { @Column(nullable = true, name = "private_key") private String privateKey; + @Column(nullable = false, updatable = false, insertable = false, name = "search_vector") + private String searchVector; + @CreationTimestamp(source = SourceType.DB) @Column(updatable = false, nullable = false, name = "created_at") private Date createdAt; diff --git a/src/main/java/com/sublinks/sublinksapi/post/models/PostSearchCriteria.java b/src/main/java/com/sublinks/sublinksapi/post/models/PostSearchCriteria.java index a3f382c3b..b9eeffa21 100644 --- a/src/main/java/com/sublinks/sublinksapi/post/models/PostSearchCriteria.java +++ b/src/main/java/com/sublinks/sublinksapi/post/models/PostSearchCriteria.java @@ -8,6 +8,7 @@ @Builder public record PostSearchCriteria( + String search, SortType sortType, ListingType listingType, int perPage, @@ -15,9 +16,9 @@ public record PostSearchCriteria( String cursorBasedPageable, List communityIds, Person person, - boolean isSavedOnly, - boolean isLikedOnly, - boolean isDislikedOnly -) { + Boolean isShowNsfw, + Boolean isSavedOnly, + Boolean isLikedOnly, + Boolean isDislikedOnly) { } diff --git a/src/main/java/com/sublinks/sublinksapi/post/repositories/PostAggregateRepository.java b/src/main/java/com/sublinks/sublinksapi/post/repositories/PostAggregateRepository.java index 61f80c294..91a8ad1d2 100644 --- a/src/main/java/com/sublinks/sublinksapi/post/repositories/PostAggregateRepository.java +++ b/src/main/java/com/sublinks/sublinksapi/post/repositories/PostAggregateRepository.java @@ -1,8 +1,11 @@ package com.sublinks.sublinksapi.post.repositories; import com.sublinks.sublinksapi.post.entities.PostAggregate; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface PostAggregateRepository extends JpaRepository { + Optional findByPost_TitleSlug(String titleSlug); + } diff --git a/src/main/java/com/sublinks/sublinksapi/post/repositories/PostLikeRepositoryImpl.java b/src/main/java/com/sublinks/sublinksapi/post/repositories/PostLikeRepositoryImpl.java index 5a1ced013..e71e51a18 100644 --- a/src/main/java/com/sublinks/sublinksapi/post/repositories/PostLikeRepositoryImpl.java +++ b/src/main/java/com/sublinks/sublinksapi/post/repositories/PostLikeRepositoryImpl.java @@ -19,8 +19,8 @@ public class PostLikeRepositoryImpl implements PostLikeRepositorySearch { private final EntityManager em; - public List allPostLikesBySearchCriteria( - PostLikeSearchCriteria postLikeSearchCriteria) { + public List allPostLikesBySearchCriteria(PostLikeSearchCriteria postLikeSearchCriteria) + { final CriteriaBuilder cb = em.getCriteriaBuilder(); final CriteriaQuery cq = cb.createQuery(PostLike.class); @@ -29,8 +29,8 @@ public List allPostLikesBySearchCriteria( final List predicates = new ArrayList<>(); - predicates.add( - cb.equal(commentLikeTable.get("post").get("id"), postLikeSearchCriteria.postId())); + predicates.add(cb.equal(commentLikeTable.get("post") + .get("id"), postLikeSearchCriteria.postId())); cq.where(predicates.toArray(new Predicate[0])); diff --git a/src/main/java/com/sublinks/sublinksapi/post/repositories/PostReportRepositoryImpl.java b/src/main/java/com/sublinks/sublinksapi/post/repositories/PostReportRepositoryImpl.java index 01c0b9339..a120db716 100644 --- a/src/main/java/com/sublinks/sublinksapi/post/repositories/PostReportRepositoryImpl.java +++ b/src/main/java/com/sublinks/sublinksapi/post/repositories/PostReportRepositoryImpl.java @@ -1,5 +1,7 @@ package com.sublinks.sublinksapi.post.repositories; +import static com.sublinks.sublinksapi.utils.PaginationUtils.applyPagination; + import com.sublinks.sublinksapi.community.entities.Community; import com.sublinks.sublinksapi.person.entities.LinkPersonPost; import com.sublinks.sublinksapi.person.entities.Person; @@ -27,7 +29,8 @@ public class PostReportRepositoryImpl implements PostReportRepositorySearch { @Override public List allPostReportsBySearchCriteria( - PostReportSearchCriteria postReportSearchCriteria) { + PostReportSearchCriteria postReportSearchCriteria) + { final CriteriaBuilder cb = em.getCriteriaBuilder(); final CriteriaQuery cq = cb.createQuery(PostReport.class); @@ -44,10 +47,11 @@ public List allPostReportsBySearchCriteria( // Join PostG and check community id final Join postJoin = postTable.join("post", JoinType.LEFT); List communityPredicates = new ArrayList<>(); - postReportSearchCriteria.community().forEach(community -> { + postReportSearchCriteria.community() + .forEach(community -> { - communityPredicates.add(cb.equal(postJoin.get("community"), community)); - }); + communityPredicates.add(cb.equal(postJoin.get("community"), community)); + }); predicates.add(cb.or(communityPredicates.toArray(new Predicate[0]))); } @@ -56,18 +60,17 @@ public List allPostReportsBySearchCriteria( cq.orderBy(cb.desc(postTable.get("createdAt"))); int perPage = Math.min(Math.abs(postReportSearchCriteria.perPage()), 20); - int page = Math.max(postReportSearchCriteria.page() - 1, 0); TypedQuery query = em.createQuery(cq); - query.setMaxResults(perPage); - query.setFirstResult(page * perPage); + + applyPagination(query, postReportSearchCriteria.page(), perPage); return query.getResultList(); } @Override - public long countAllPostReportsByResolvedFalseAndCommunity( - @Nullable List communities) { + public long countAllPostReportsByResolvedFalseAndCommunity(@Nullable List communities) + { final CriteriaBuilder cb = em.getCriteriaBuilder(); final CriteriaQuery cq = cb.createQuery(Long.class); @@ -94,7 +97,8 @@ public long countAllPostReportsByResolvedFalseAndCommunity( cq.select(cb.count(postReportTable)); - return em.createQuery(cq).getSingleResult(); + return em.createQuery(cq) + .getSingleResult(); } @Override @@ -135,7 +139,8 @@ public void resolveAllPostReportsByPerson(Person person, Person resolver) { @Override public void resolveAllPostReportsByPersonAndCommunity(Person person, Community community, - Person resolver) { + Person resolver) + { final CriteriaBuilder cb = em.getCriteriaBuilder(); final CriteriaQuery cq = cb.createQuery(PostReport.class); diff --git a/src/main/java/com/sublinks/sublinksapi/post/repositories/PostRepository.java b/src/main/java/com/sublinks/sublinksapi/post/repositories/PostRepository.java index 715a5192e..1880261c7 100644 --- a/src/main/java/com/sublinks/sublinksapi/post/repositories/PostRepository.java +++ b/src/main/java/com/sublinks/sublinksapi/post/repositories/PostRepository.java @@ -1,8 +1,13 @@ package com.sublinks.sublinksapi.post.repositories; import com.sublinks.sublinksapi.post.entities.Post; +import com.sublinks.sublinksapi.shared.RemovedState; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface PostRepository extends JpaRepository, PostRepositorySearch { + Optional findByTitleSlugAndRemovedStateIs(String titleSlug, RemovedState removedState); + + Optional findByTitleSlug(String titleSlug); } diff --git a/src/main/java/com/sublinks/sublinksapi/post/repositories/PostRepositoryImpl.java b/src/main/java/com/sublinks/sublinksapi/post/repositories/PostRepositoryImpl.java index 83aceb5c2..08f3982eb 100644 --- a/src/main/java/com/sublinks/sublinksapi/post/repositories/PostRepositoryImpl.java +++ b/src/main/java/com/sublinks/sublinksapi/post/repositories/PostRepositoryImpl.java @@ -1,9 +1,11 @@ package com.sublinks.sublinksapi.post.repositories; import com.sublinks.sublinksapi.community.entities.Community; +import com.sublinks.sublinksapi.instance.models.LocalInstanceContext; import com.sublinks.sublinksapi.person.entities.LinkPersonPost; import com.sublinks.sublinksapi.person.entities.Person; import com.sublinks.sublinksapi.person.enums.LinkPersonPostType; +import com.sublinks.sublinksapi.person.enums.ListingType; import com.sublinks.sublinksapi.post.entities.Post; import com.sublinks.sublinksapi.post.models.PostSearchCriteria; import com.sublinks.sublinksapi.post.services.PostSearchQueryService; @@ -28,6 +30,7 @@ public class PostRepositoryImpl implements PostRepositorySearch { private final EntityManager em; private final PostSearchQueryService postSearchQueryService; + private final LocalInstanceContext localInstanceContext; @Override public List allPostsBySearchCriteria(final PostSearchCriteria postSearchCriteria) { @@ -47,6 +50,10 @@ public List allPostsBySearchCriteria(final PostSearchCriteria postSearchCr } else { searchBuilder.filterByListingType(postSearchCriteria.listingType()); } + if (postSearchCriteria.person() != null + && postSearchCriteria.listingType() == ListingType.Subscribed) { + searchBuilder.filterByListingType(postSearchCriteria.listingType()); + } if (postSearchCriteria.cursorBasedPageable() != null) { searchBuilder.setCursor(postSearchCriteria.cursorBasedPageable()); } @@ -55,6 +62,21 @@ public List allPostsBySearchCriteria(final PostSearchCriteria postSearchCr } searchBuilder.setSavedOnly(postSearchCriteria.isSavedOnly()); + if (localInstanceContext.instance() != null && !localInstanceContext.instance() + .getInstanceConfig() + .isEnableNsfw()) { + searchBuilder.setShowNsfw(false); + } else if (postSearchCriteria.isShowNsfw() != null) { + searchBuilder.setShowNsfw(postSearchCriteria.isShowNsfw()); + } else if (postSearchCriteria.person() != null) { + searchBuilder.setShowNsfw(postSearchCriteria.person() + .isShowNsfw()); + } else { + searchBuilder.setShowNsfw(false); + } + + searchBuilder.addSearch(postSearchCriteria.search()); + Results results = postSearchQueryService.results(searchBuilder); results.setPerPage(postSearchCriteria.perPage()); if (postSearchCriteria.cursorBasedPageable() != null) { @@ -66,7 +88,8 @@ public List allPostsBySearchCriteria(final PostSearchCriteria postSearchCr @Override public List allPostsByCommunityAndPersonAndRemoved(Community community, Person person, - List removedStates) { + List removedStates) + { if (community == null || person == null) { throw new IllegalArgumentException("Community and person cannot be null"); @@ -99,7 +122,8 @@ public List allPostsByCommunityAndPersonAndRemoved(Community community, Pe @Override public List allPostsByPersonAndRemoved(@Nullable Person person, - @Nullable List removedStates) { + @Nullable List removedStates) + { final CriteriaBuilder cb = em.getCriteriaBuilder(); final CriteriaQuery cq = cb.createQuery(Post.class); diff --git a/src/main/java/com/sublinks/sublinksapi/post/services/PostSearchQueryService.java b/src/main/java/com/sublinks/sublinksapi/post/services/PostSearchQueryService.java index 18965225c..58bec90e8 100644 --- a/src/main/java/com/sublinks/sublinksapi/post/services/PostSearchQueryService.java +++ b/src/main/java/com/sublinks/sublinksapi/post/services/PostSearchQueryService.java @@ -138,7 +138,8 @@ public static class Builder { private Set removedState; public Builder(EntityManager entityManager, CriteriaBuilder criteriaBuilder, - SortFactory sortFactory) { + SortFactory sortFactory) + { this.entityManager = entityManager; this.criteriaBuilder = criteriaBuilder; @@ -309,5 +310,24 @@ public Builder setPerson(Person person) { this.person = person; return this; } + + public Builder setShowNsfw(Boolean isShowNsfw) { + + if (!isShowNsfw) { + predicates.add(criteriaBuilder.isFalse(postTable.get("isNsfw"))); + } + return this; + } + + public Builder addSearch(String search) { + + if (search != null && !search.isBlank()) { + + predicates.add(criteriaBuilder.equal( + criteriaBuilder.function("fn_search_vector_is_same", Boolean.class, + postTable.get("searchVector"), criteriaBuilder.literal(search)), true)); + } + return this; + } } } diff --git a/src/main/java/com/sublinks/sublinksapi/post/services/PostService.java b/src/main/java/com/sublinks/sublinksapi/post/services/PostService.java index 0da3103ac..8c3d69d18 100644 --- a/src/main/java/com/sublinks/sublinksapi/post/services/PostService.java +++ b/src/main/java/com/sublinks/sublinksapi/post/services/PostService.java @@ -131,7 +131,8 @@ public List deleteAllPostsByPerson(final Person person) { @Transactional public void removeAllPostsFromCommunityAndUser(final Community community, final Person person, - final boolean removed) { + final boolean removed) + { postRepository.allPostsByCommunityAndPersonAndRemoved(community, person, List.of(removed ? RemovedState.NOT_REMOVED : RemovedState.REMOVED_BY_COMMUNITY)) diff --git a/src/main/java/com/sublinks/sublinksapi/privatemessages/models/PrivateMessageSearchCriteria.java b/src/main/java/com/sublinks/sublinksapi/privatemessages/models/PrivateMessageSearchCriteria.java index 04d914e18..4c0201f4a 100644 --- a/src/main/java/com/sublinks/sublinksapi/privatemessages/models/PrivateMessageSearchCriteria.java +++ b/src/main/java/com/sublinks/sublinksapi/privatemessages/models/PrivateMessageSearchCriteria.java @@ -6,11 +6,11 @@ @Builder public record PrivateMessageSearchCriteria( + String search, PrivateMessageSortType privateMessageSortType, int perPage, int page, boolean unreadOnly, - Person person -) { + Person person) { } diff --git a/src/main/java/com/sublinks/sublinksapi/privatemessages/services/PrivateMessageService.java b/src/main/java/com/sublinks/sublinksapi/privatemessages/services/PrivateMessageService.java index bdbaf0e14..fea091c5f 100644 --- a/src/main/java/com/sublinks/sublinksapi/privatemessages/services/PrivateMessageService.java +++ b/src/main/java/com/sublinks/sublinksapi/privatemessages/services/PrivateMessageService.java @@ -34,9 +34,11 @@ public class PrivateMessageService { private final PersonMentionRepository personMentionRepository; public String generateActivityPubId( - final com.sublinks.sublinksapi.privatemessages.entities.PrivateMessage privateMessage) { + final com.sublinks.sublinksapi.privatemessages.entities.PrivateMessage privateMessage) + { - String domain = localInstanceContext.instance().getDomain(); + String domain = localInstanceContext.instance() + .getDomain(); return String.format("%s/private_message/%d", domain, privateMessage.getId()); } @@ -62,6 +64,7 @@ public void updatePrivateMessage(final PrivateMessage privateMessage) { public void deletePrivateMessage(final PrivateMessage privateMessage) { privateMessageRepository.delete(privateMessage); + privateMessageDeletedPublisher.publish(privateMessage); } @Transactional @@ -97,16 +100,44 @@ public MarkAllAsReadResponse markAllAsRead(final Person person) { .build(); } + @Transactional + public PrivateMessage deletePrivateMessage(final String id) { + + return this.deletePrivateMessage(id, false); + } + + @Transactional + public PrivateMessage deletePrivateMessage(final String id, final boolean byAdmin) + { + + final PrivateMessage privateMessage = privateMessageRepository.findById(Long.parseLong(id)) + .orElseThrow(); + privateMessage.setDeleted(true); + privateMessage.setRead(true); + privateMessage.setContent("*Permanently deleted by " + (byAdmin ? "admin" : "creator") + "*"); + this.updatePrivateMessage(privateMessage); + return privateMessage; + } + @Transactional public List deleteAllPrivateMessagesByPerson(final Person person) { + return this.deleteAllPrivateMessagesByPerson(person, false); + + } + + @Transactional + public List deleteAllPrivateMessagesByPerson(final Person person, + final boolean byAdmin) + { + final List privateMessages = privateMessageRepository.findAllBySender(person); privateMessages.forEach(privateMessage -> { privateMessage.setDeleted(true); privateMessage.setRead(true); - privateMessage.setContent("*Permanently deleted by creator*"); + privateMessage.setContent("*Permanently deleted by " + (byAdmin ? "admin" : "creator") + "*"); - privateMessageDeletedPublisher.publish(privateMessageRepository.save(privateMessage)); + this.updatePrivateMessage(privateMessage); }); return privateMessages; } diff --git a/src/main/java/com/sublinks/sublinksapi/utils/PaginationUtils.java b/src/main/java/com/sublinks/sublinksapi/utils/PaginationUtils.java index c44a1ca7b..03c1e31cc 100644 --- a/src/main/java/com/sublinks/sublinksapi/utils/PaginationUtils.java +++ b/src/main/java/com/sublinks/sublinksapi/utils/PaginationUtils.java @@ -1,6 +1,7 @@ package com.sublinks.sublinksapi.utils; import jakarta.persistence.TypedQuery; +import org.springframework.lang.NonNull; import org.springframework.lang.Nullable; public class PaginationUtils { @@ -25,12 +26,38 @@ public static int getOffset(int page, int size) { * @param page The page number to retrieve. * @param size The number of records per page. */ - public static void applyPagination(TypedQuery query, @Nullable Integer page, - Integer size) { + public static void applyPagination(TypedQuery query, @Nullable Integer page, Integer size) + { if (page != null) { query.setFirstResult(getOffset(page, size)); } query.setMaxResults(Math.abs(size)); } + + public static int Clamp(@NonNull final T value, final T min, final T max) { + + return Math.min(Math.max(value, min), max); + } + + public static int getPage(final T value) { + + return Clamp(value == null ? 0 : value, 0, Integer.MAX_VALUE); + } + + public static int getPerPage(final T value, final T min, final T max) { + + return Clamp(value == null ? max : value, min, max); + } + + public static int getPerPage(final T value, final T max) { + + return getPerPage(value == null ? max : value, 1, max); + } + + public static int getPerPage(final T value) { + + return getPerPage(value == null ? 20 : value, 1, 20); + } + } diff --git a/src/main/java/com/sublinks/sublinksapi/utils/UrlUtil.java b/src/main/java/com/sublinks/sublinksapi/utils/UrlUtil.java index 812802ec8..7a41f2271 100644 --- a/src/main/java/com/sublinks/sublinksapi/utils/UrlUtil.java +++ b/src/main/java/com/sublinks/sublinksapi/utils/UrlUtil.java @@ -57,7 +57,8 @@ private String removeTrackingParameters(final String queryString) { if (parameters.isEmpty()) { return null; } - return parameters.entrySet().stream() + return parameters.entrySet() + .stream() .map(e -> e.getKey() + "=" + e.getValue()) .collect(Collectors.joining("&")); } @@ -75,11 +76,31 @@ public void checkUrlProtocol(String providedUrl) { try { final URL url = new URL(providedUrl); - if (!List.of("http", "https", "magnet").contains(url.getProtocol())) { + if (!List.of("http", "https", "magnet") + .contains(url.getProtocol())) { throw new RuntimeException("Invalid URL Scheme"); } } catch (Exception e) { throw new RuntimeException("Invalid URL Scheme"); } } + + public String cleanUrlProtocol(String providedUrl) { + + try { + final URL url = new URL(providedUrl); + + final StringBuilder sb = new StringBuilder(); + + sb.append(url.getHost()); + if (url.getPort() != -1) { + sb.append(":") + .append(url.getPort()); + } + + return sb.toString(); + } catch (MalformedURLException e) { + return providedUrl; + } + } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 0a763a106..94f349f12 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -9,12 +9,11 @@ spring.datasource.url=${SUBLINKS_DB_URL} spring.datasource.username=${SUBLINKS_DB_USERNAME} spring.datasource.password=${SUBLINKS_DB_PASSWORD} # Spring Doc -springdoc.pathsToMatch=/api/v3/** -springdoc.version=v0.19.0 +springdoc.version=v0.1.0 # Production and test Lemmy URLs -springdoc.servers={'https://lemmy.ml','https://enterprise.lemmy.ml','https://ds9.lemmy.ml','https://voyager.lemmy.ml'} +springdoc.servers={'https://discuss.online'} springdoc.swagger-ui.path=/swagger-ui -springdoc.api-docs.path=/v3/api-docs +springdoc.api-docs.path=/api/api-docs springdoc.swagger-ui.enabled=true springdoc.api-docs.enabled=true springdoc.enable-spring-security=true @@ -88,3 +87,4 @@ sublinks.keep_comment_history=${KEEP_COMMENT_HISTORY:false} spring.thymeleaf.check-template-location=false # enable enable_lazy_load_no_trans spring.jpa.properties.hibernate.enable_lazy_load_no_trans=true +spring.datasource.hikari.allow-pool-suspension=true diff --git a/src/main/resources/db/migration/V20231003__Create_initial_entity_tables.sql b/src/main/resources/db/migration/V20231003__Create_initial_entity_tables.sql index 3595e0916..aed2c3bc2 100644 --- a/src/main/resources/db/migration/V20231003__Create_initial_entity_tables.sql +++ b/src/main/resources/db/migration/V20231003__Create_initial_entity_tables.sql @@ -11,6 +11,14 @@ BEGIN END; $$ language 'plpgsql'; +CREATE OR REPLACE FUNCTION fn_search_vector_is_same(search_vector TSVECTOR, text TEXT) + RETURNS BOOLEAN AS +$$ +BEGIN + RETURN search_vector @@ to_tsquery('english', text); +END; +$$ language 'plpgsql'; + /** * Comments table */ @@ -124,6 +132,10 @@ CREATE TABLE instances name VARCHAR(255) NULL, description TEXT NULL, sidebar TEXT NULL, + search_vector TSVECTOR GENERATED ALWAYS AS (to_tsvector('english', + coalesce(name, '') || + ' ' || + coalesce(description, ''))) STORED, icon_url TEXT NULL, banner_url TEXT NULL, public_key TEXT NOT NULL, @@ -203,7 +215,7 @@ CREATE TABLE people biography TEXT NULL, interface_language VARCHAR(20) NULL, default_theme VARCHAR(255) NULL, - default_listing_type VARCHAR(255) NULL, + default_listing_type VARCHAR(255) NULL DEFAULT 'List', default_sort_type VARCHAR(255) NULL DEFAULT 'Active', is_show_scores BOOL NOT NULL DEFAULT false, is_show_read_posts BOOL NOT NULL DEFAULT false, @@ -218,7 +230,7 @@ CREATE TABLE people is_collapse_bot_comments BOOL NOT NULL DEFAULT false, is_auto_expanding BOOL NOT NULL DEFAULT false, is_blur_nsfw BOOL NOT NULL DEFAULT false, - post_listing_type VARCHAR(255) DEFAULT 'List', + post_listing_type VARCHAR(255) NULL DEFAULT 'List', matrix_user_id TEXT NULL, public_key TEXT NOT NULL, private_key TEXT NULL, @@ -611,6 +623,8 @@ CREATE TABLE announcements ( id BIGSERIAL PRIMARY KEY, content TEXT NOT NULL, + is_active BOOL NOT NULL DEFAULT true, + creator_id BIGINT NOT NULL, local_site_id BIGINT NOT NULL, created_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3) NOT NULL, updated_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3) NOT NULL @@ -664,15 +678,19 @@ CREATE INDEX IDX_CUSTOM_EMOJI_KEYWORD_CUSTOM_EMOJI_ID ON custom_emoji_keywords ( */ CREATE TABLE acl_roles ( - id BIGSERIAL PRIMARY KEY, - name VARCHAR(255) NOT NULL, - description TEXT NOT NULL, - is_active BOOL NOT NULL DEFAULT true, - expires_at TIMESTAMP(3) NULL, - created_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3) NOT NULL, - updated_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3) NOT NULL + id BIGSERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT NOT NULL, + inherits_from BIGINT NULL, + is_active BOOL NOT NULL DEFAULT true, + expires_at TIMESTAMP(3) NULL, + created_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3) NOT NULL, + updated_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3) NOT NULL ); +CREATE UNIQUE INDEX IDX_ACL_ROLES_NAME ON acl_roles (name); +CREATE INDEX IDX_ACL_ROLES_INHERITS_FROM ON acl_roles (inherits_from); + /** Role permissions table */ diff --git a/src/main/resources/db/migration/V20231006__Create_foreign_key_references.sql b/src/main/resources/db/migration/V20231006__Create_foreign_key_references.sql index a8eec01ca..d4f61b939 100644 --- a/src/main/resources/db/migration/V20231006__Create_foreign_key_references.sql +++ b/src/main/resources/db/migration/V20231006__Create_foreign_key_references.sql @@ -196,6 +196,11 @@ ALTER TABLE custom_emoji_keywords ALTER TABLE acl_role_permissions ADD FOREIGN KEY (role_id) REFERENCES acl_roles (id) ON DELETE CASCADE; +/** + Role Table + */ +ALTER TABLE acl_roles + ADD FOREIGN KEY (inherits_from) REFERENCES acl_roles (id) ON DELETE SET NULL; /** People table @@ -254,6 +259,11 @@ ALTER TABLE link_person_comments ALTER TABLE link_person_posts ADD FOREIGN KEY (person_id) REFERENCES people (id) ON DELETE CASCADE, ADD FOREIGN KEY (post_id) REFERENCES posts (id) ON DELETE CASCADE; +/** + Announcement table + */ +ALTER TABLE announcements + ADD FOREIGN KEY (creator_id) REFERENCES people (id) ON DELETE CASCADE; /** Link Person Person Table */ diff --git a/src/test/java/com/sublinks/sublinksapi/language/services/LanguageServiceUnitTests.java b/src/test/java/com/sublinks/sublinksapi/language/services/LanguageServiceUnitTests.java index c234d9fb0..16d406c45 100644 --- a/src/test/java/com/sublinks/sublinksapi/language/services/LanguageServiceUnitTests.java +++ b/src/test/java/com/sublinks/sublinksapi/language/services/LanguageServiceUnitTests.java @@ -52,9 +52,8 @@ void givenInstance_whenInstanceLanguageIds_thenReturnListOfIds() { List expectedList = Arrays.asList(1L, 2L); - assertTrue(expectedList.size() == discussionLanguages.size() - && expectedList.containsAll(discussionLanguages) - && discussionLanguages.containsAll(expectedList), + assertTrue(expectedList.size() == discussionLanguages.size() && expectedList.containsAll( + discussionLanguages) && discussionLanguages.containsAll(expectedList), "List of language ids returned did not match expected"); } @@ -65,8 +64,10 @@ void givenCollectionOfLanguageIds_whenLanguageIdsToEntity_thenReturnListOfLangua Optional optionalEnglish = Optional.of(english); Optional optionalAfrikaans = Optional.of(afrikaans); - Mockito.when(languageRepository.findById(1L)).thenReturn(optionalEnglish); - Mockito.when(languageRepository.findById(2L)).thenReturn(optionalAfrikaans); + Mockito.when(languageRepository.findById(1L)) + .thenReturn(optionalEnglish); + Mockito.when(languageRepository.findById(2L)) + .thenReturn(optionalAfrikaans); LanguageService languageService = new LanguageService(languageRepository);