From 654623ce4e3f460922513922f7f1746ab68a6487 Mon Sep 17 00:00:00 2001 From: Andrew Lalis Date: Thu, 16 Feb 2023 18:11:03 +0100 Subject: [PATCH] Added submission count to gym index, made user data access protected. --- .../gymboard_api/config/SecurityConfig.java | 3 +- .../submission/UserSubmissionService.java | 10 ++- .../domains/auth/dto/UserResponse.java | 6 +- .../auth/service/UserAccessService.java | 87 +++++++++++++++++++ gymboard-app/src/api/main/index.ts | 4 +- gymboard-app/src/api/main/submission.ts | 2 +- gymboard-app/src/api/main/users.ts | 12 +++ gymboard-app/src/i18n/en-US/index.ts | 4 +- gymboard-app/src/i18n/nl-NL/index.ts | 4 +- gymboard-app/src/pages/UserPage.vue | 52 ++++++++--- gymboard-app/src/utils.ts | 10 +++ .../gymboardsearch/dto/GymResponse.java | 6 +- .../gymboardsearch/index/IndexComponents.java | 3 + .../src/main/resources/sql/select-gyms.sql | 29 ++++--- .../src/main/resources/sql/select-users.sql | 2 +- 15 files changed, 195 insertions(+), 39 deletions(-) create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/service/UserAccessService.java create mode 100644 gymboard-app/src/api/main/users.ts diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityConfig.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityConfig.java index 86a7801..9c84819 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityConfig.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityConfig.java @@ -50,7 +50,8 @@ public class SecurityConfig { "/auth/reset-password", "/auth/users/*", "/auth/users/*/followers", - "/auth/users/*/following" + "/auth/users/*/following", + "/users/*/recent-submissions" ).permitAll() .requestMatchers(// Allow the following POST endpoints to be public. HttpMethod.POST, diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/submission/UserSubmissionService.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/submission/UserSubmissionService.java index 3416c92..d051ed5 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/submission/UserSubmissionService.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/submission/UserSubmissionService.java @@ -4,6 +4,7 @@ import nl.andrewlalis.gymboard_api.domains.api.dao.submission.SubmissionReposito import nl.andrewlalis.gymboard_api.domains.api.dto.SubmissionResponse; import nl.andrewlalis.gymboard_api.domains.auth.dao.UserRepository; import nl.andrewlalis.gymboard_api.domains.auth.model.User; +import nl.andrewlalis.gymboard_api.domains.auth.service.UserAccessService; import nl.andrewlalis.gymboard_api.util.PredicateBuilder; import org.springframework.data.domain.PageRequest; import org.springframework.http.HttpStatus; @@ -17,19 +18,20 @@ import java.util.List; public class UserSubmissionService { private final UserRepository userRepository; private final SubmissionRepository submissionRepository; + private final UserAccessService userAccessService; - public UserSubmissionService(UserRepository userRepository, SubmissionRepository submissionRepository) { + public UserSubmissionService(UserRepository userRepository, SubmissionRepository submissionRepository, UserAccessService userAccessService) { this.userRepository = userRepository; this.submissionRepository = submissionRepository; + this.userAccessService = userAccessService; } @Transactional(readOnly = true) public List getRecentSubmissions(String userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); - if (user.getPreferences().isAccountPrivate()) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN); - } + userAccessService.enforceUserAccess(user); + return submissionRepository.findAll((root, query, criteriaBuilder) -> { query.orderBy( criteriaBuilder.desc(root.get("performedAt")), diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dto/UserResponse.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dto/UserResponse.java index 0c70817..6995fef 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dto/UserResponse.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dto/UserResponse.java @@ -6,16 +6,14 @@ public record UserResponse( String id, boolean activated, String email, - String name, - boolean accountPrivate + String name ) { public UserResponse(User user) { this( user.getId(), user.isActivated(), user.getEmail(), - user.getName(), - user.getPreferences().isAccountPrivate() + user.getName() ); } } diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/service/UserAccessService.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/service/UserAccessService.java new file mode 100644 index 0000000..734a6f0 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/service/UserAccessService.java @@ -0,0 +1,87 @@ +package nl.andrewlalis.gymboard_api.domains.auth.service; + +import nl.andrewlalis.gymboard_api.domains.auth.dao.UserFollowingRepository; +import nl.andrewlalis.gymboard_api.domains.auth.dao.UserRepository; +import nl.andrewlalis.gymboard_api.domains.auth.model.TokenAuthentication; +import nl.andrewlalis.gymboard_api.domains.auth.model.User; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +/** + * A simple service that provides methods to determine whether a user has access + * to another user's data. + */ +@Service +public class UserAccessService { + private final UserRepository userRepository; + private final UserFollowingRepository followingRepository; + + public UserAccessService(UserRepository userRepository, UserFollowingRepository followingRepository) { + this.userRepository = userRepository; + this.followingRepository = followingRepository; + } + + /** + * Determines if the given user is allowed to access a user. This method is + * meant to be used during a persistence session! + * @param user The user who's trying to access. + * @param targetUser The target user. + * @return True if the user may access them, or false otherwise. + */ + public boolean userHasAccess(User user, User targetUser) { + if (targetUser != null && !targetUser.getPreferences().isAccountPrivate()) { + return true; + } + return user != null && followingRepository.existsByFollowedUserAndFollowingUser(targetUser, user); + } + + public boolean currentUserHasAccess(User targetUser) { + User currentUser = null; + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth instanceof TokenAuthentication tokenAuth) { + currentUser = tokenAuth.user(); + } + return userHasAccess(currentUser, targetUser); + } + + public void enforceUserAccess(User targetUser) { + if (!currentUserHasAccess(targetUser)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "You don't have permission to access that user."); + } + } + + /** + * Determines if the given user is allowed to access the user with the given + * id. Generally, a user can access another user if any of the following are + * true: + * - The target user's account is set as public via their preferences. + * - The accessing user is a follower of the target user. + * @param user The user who's trying to access. + * @param userId The id of the target user. + * @return True if the user may access them, or false otherwise. + */ + @Transactional(readOnly = true) + public boolean userHasAccess(User user, String userId) { + User targetUser = userRepository.findById(userId).orElse(null); + return userHasAccess(user, targetUser); + } + + /** + * Determines if the currently authenticated user is allowed to access the + * user with the given id. + * @param userId The id of the target user. + * @return True if the user may access them, or false otherwise. + */ + @Transactional(readOnly = true) + public boolean currentUserHasAccess(String userId) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth instanceof TokenAuthentication tokenAuth) { + return userHasAccess(tokenAuth.user(), userId); + } + return false; + } +} diff --git a/gymboard-app/src/api/main/index.ts b/gymboard-app/src/api/main/index.ts index de65c54..c0b15bc 100644 --- a/gymboard-app/src/api/main/index.ts +++ b/gymboard-app/src/api/main/index.ts @@ -3,7 +3,8 @@ import GymsModule from 'src/api/main/gyms'; import ExercisesModule from 'src/api/main/exercises'; import LeaderboardsModule from 'src/api/main/leaderboards'; import AuthModule from 'src/api/main/auth'; -console.log(process.env); +import UsersModule from 'src/api/main/users'; + export const api = axios.create({ baseURL: process.env.API_URL, }); @@ -11,6 +12,7 @@ export const api = axios.create({ class GymboardApi { public readonly auth = new AuthModule(); public readonly gyms = new GymsModule(); + public readonly users = new UsersModule(); public readonly exercises = new ExercisesModule(); public readonly leaderboards = new LeaderboardsModule(); } diff --git a/gymboard-app/src/api/main/submission.ts b/gymboard-app/src/api/main/submission.ts index dbca033..3818e72 100644 --- a/gymboard-app/src/api/main/submission.ts +++ b/gymboard-app/src/api/main/submission.ts @@ -3,7 +3,7 @@ import { Exercise } from 'src/api/main/exercises'; import { api } from 'src/api/main/index'; import { getGymCompoundId, GymRoutable } from 'src/router/gym-routing'; import { DateTime } from 'luxon'; -import {User} from "src/api/main/auth"; +import {User} from 'src/api/main/auth'; /** * The data that's sent when creating a submission. diff --git a/gymboard-app/src/api/main/users.ts b/gymboard-app/src/api/main/users.ts new file mode 100644 index 0000000..d757610 --- /dev/null +++ b/gymboard-app/src/api/main/users.ts @@ -0,0 +1,12 @@ +import {api} from 'src/api/main'; +import {AuthStoreType} from 'stores/auth-store'; +import {ExerciseSubmission, parseSubmission} from 'src/api/main/submission'; + +class UsersModule { + public async getRecentSubmissions(userId: string, authStore: AuthStoreType): Promise> { + const response = await api.get(`/users/${userId}/recent-submissions`, authStore.axiosConfig); + return response.data.map(parseSubmission); + } +} + +export default UsersModule; diff --git a/gymboard-app/src/i18n/en-US/index.ts b/gymboard-app/src/i18n/en-US/index.ts index aaae410..6389ee7 100644 --- a/gymboard-app/src/i18n/en-US/index.ts +++ b/gymboard-app/src/i18n/en-US/index.ts @@ -44,7 +44,9 @@ export default { notFound: { title: 'User Not Found', description: 'We couldn\'t find the user you\'re looking for.' - } + }, + accountPrivate: 'This account is private.', + recentLifts: 'Recent Lifts', }, userSettingsPage: { title: 'Account Settings', diff --git a/gymboard-app/src/i18n/nl-NL/index.ts b/gymboard-app/src/i18n/nl-NL/index.ts index 12e33fc..c8b31d6 100644 --- a/gymboard-app/src/i18n/nl-NL/index.ts +++ b/gymboard-app/src/i18n/nl-NL/index.ts @@ -44,7 +44,9 @@ export default { notFound: { title: 'Gebruiker niet gevonden', description: 'Wij konden de gebruiker voor wie jij zoekt niet vinden, helaas.' - } + }, + accountPrivate: 'Dit account is privaat.', + recentLifts: 'Recente liften', }, userSettingsPage: { title: 'Account instellingen', diff --git a/gymboard-app/src/pages/UserPage.vue b/gymboard-app/src/pages/UserPage.vue index 61d319d..166e544 100644 --- a/gymboard-app/src/pages/UserPage.vue +++ b/gymboard-app/src/pages/UserPage.vue @@ -8,6 +8,20 @@
+
+ This account is private. +
+ +
+

{{ $t('userPage.recentLifts') }}

+ + + +
@@ -19,42 +33,56 @@ diff --git a/gymboard-app/src/utils.ts b/gymboard-app/src/utils.ts index 2bab3c8..db60ec5 100644 --- a/gymboard-app/src/utils.ts +++ b/gymboard-app/src/utils.ts @@ -1 +1,11 @@ +import {QVueGlobals} from "quasar"; + export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +export function showApiErrorToast(i18n: any, quasar: QVueGlobals) { + quasar.notify({ + message: i18n.t('generalErrors.apiError'), + type: 'danger', + position: 'top' + }); +} diff --git a/gymboard-search/src/main/java/nl/andrewlalis/gymboardsearch/dto/GymResponse.java b/gymboard-search/src/main/java/nl/andrewlalis/gymboardsearch/dto/GymResponse.java index 8b4676e..3d6da64 100644 --- a/gymboard-search/src/main/java/nl/andrewlalis/gymboardsearch/dto/GymResponse.java +++ b/gymboard-search/src/main/java/nl/andrewlalis/gymboardsearch/dto/GymResponse.java @@ -12,7 +12,8 @@ public record GymResponse( String countryName, String streetAddress, double latitude, - double longitude + double longitude, + long submissionCount ) { public GymResponse(Document doc) { this( @@ -25,7 +26,8 @@ public record GymResponse( doc.get("country_name"), doc.get("street_address"), doc.getField("latitude").numericValue().doubleValue(), - doc.getField("longitude").numericValue().doubleValue() + doc.getField("longitude").numericValue().doubleValue(), + doc.getField("submission_count").numericValue().longValue() ); } } diff --git a/gymboard-search/src/main/java/nl/andrewlalis/gymboardsearch/index/IndexComponents.java b/gymboard-search/src/main/java/nl/andrewlalis/gymboardsearch/index/IndexComponents.java index 19badcb..e8b3203 100644 --- a/gymboard-search/src/main/java/nl/andrewlalis/gymboardsearch/index/IndexComponents.java +++ b/gymboard-search/src/main/java/nl/andrewlalis/gymboardsearch/index/IndexComponents.java @@ -66,6 +66,7 @@ public class IndexComponents { String streetAddress = rs.getString("street_address"); BigDecimal latitude = rs.getBigDecimal("latitude"); BigDecimal longitude = rs.getBigDecimal("longitude"); + long submissionCount = rs.getLong("submission_count"); String gymCompoundId = String.format("%s_%s_%s", countryCode, cityShortName, shortName); Document doc = new Document(); @@ -81,6 +82,8 @@ public class IndexComponents { doc.add(new StoredField("latitude", latitude.doubleValue())); doc.add(new DoublePoint("longitude_point", longitude.doubleValue())); doc.add(new StoredField("longitude", longitude.doubleValue())); + doc.add(new LongPoint("submission_count_point", submissionCount)); + doc.add(new StoredField("submission_count", submissionCount)); return doc; } ); diff --git a/gymboard-search/src/main/resources/sql/select-gyms.sql b/gymboard-search/src/main/resources/sql/select-gyms.sql index 010ae8d..26f110d 100644 --- a/gymboard-search/src/main/resources/sql/select-gyms.sql +++ b/gymboard-search/src/main/resources/sql/select-gyms.sql @@ -1,14 +1,21 @@ SELECT - gym.short_name as short_name, - gym.display_name as display_name, - city.short_name as city_short_name, - city.name as city_name, - country.code as country_code, - country.name as country_name, - gym.street_address as street_address, - gym.latitude as latitude, - gym.longitude as longitude + gym.short_name AS short_name, + gym.display_name AS display_name, + city.short_name AS city_short_name, + city.name AS city_name, + country.code AS country_code, + country.name AS country_name, + gym.street_address AS street_address, + gym.latitude AS latitude, + gym.longitude AS longitude, + ( + SELECT COUNT(id) + FROM submission + WHERE submission.gym_short_name = gym.short_name AND + submission.gym_city_short_name = gym.city_short_name AND + submission.gym_city_country_code = gym.city_country_code + ) AS submission_count FROM gym -LEFT JOIN city on gym.city_short_name = city.short_name -LEFT JOIN country on gym.city_country_code = country.code +LEFT JOIN city ON gym.city_short_name = city.short_name +LEFT JOIN country ON gym.city_country_code = country.code ORDER BY gym.created_at; diff --git a/gymboard-search/src/main/resources/sql/select-users.sql b/gymboard-search/src/main/resources/sql/select-users.sql index 37fbbcd..3298bcf 100644 --- a/gymboard-search/src/main/resources/sql/select-users.sql +++ b/gymboard-search/src/main/resources/sql/select-users.sql @@ -4,5 +4,5 @@ SELECT u.name as name FROM auth_user u LEFT JOIN auth_user_preferences p ON u.id = p.user_id -WHERE u.activated = TRUE AND p.account_private = FALSE +WHERE u.activated = TRUE ORDER BY u.created_at; \ No newline at end of file