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 78af6c7..c40ed74 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 @@ -49,6 +49,7 @@ public class SecurityConfig { "/submissions/**", "/auth/reset-password", "/auth/users/*", + "/auth/users/*/access", "/auth/users/*/followers", "/auth/users/*/following", "/users/*/recent-submissions" diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/controller/SubmissionController.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/controller/SubmissionController.java index c900102..87cb30e 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/controller/SubmissionController.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/controller/SubmissionController.java @@ -2,10 +2,10 @@ package nl.andrewlalis.gymboard_api.domains.api.controller; import nl.andrewlalis.gymboard_api.domains.api.dto.SubmissionResponse; import nl.andrewlalis.gymboard_api.domains.api.service.submission.ExerciseSubmissionService; -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 nl.andrewlalis.gymboard_api.domains.auth.model.User; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; @RestController @RequestMapping(path = "/submissions") @@ -20,4 +20,10 @@ public class SubmissionController { public SubmissionResponse getSubmission(@PathVariable String submissionId) { return submissionService.getSubmission(submissionId); } + + @DeleteMapping(path = "/{submissionId}") + public ResponseEntity deleteSubmission(@PathVariable String submissionId, @AuthenticationPrincipal User user) { + submissionService.deleteSubmission(submissionId, user); + return ResponseEntity.noContent().build(); + } } diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/submission/ExerciseSubmissionService.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/submission/ExerciseSubmissionService.java index a2f2cec..b82a6a9 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/submission/ExerciseSubmissionService.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/submission/ExerciseSubmissionService.java @@ -132,4 +132,15 @@ public class ExerciseSubmissionService { } return response; } + + @Transactional + public void deleteSubmission(String submissionId, User user) { + Submission submission = submissionRepository.findById(submissionId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + if (!submission.getUser().getId().equals(user.getId())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Cannot delete other user's submission."); + } + // TODO: Find a secure way to delete the associated video. + submissionRepository.delete(submission); + } } diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/controller/UserController.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/controller/UserController.java index 2603188..7e2451a 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/controller/UserController.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/controller/UserController.java @@ -2,6 +2,7 @@ package nl.andrewlalis.gymboard_api.domains.auth.controller; import nl.andrewlalis.gymboard_api.domains.auth.dto.*; import nl.andrewlalis.gymboard_api.domains.auth.model.User; +import nl.andrewlalis.gymboard_api.domains.auth.service.UserAccessService; import nl.andrewlalis.gymboard_api.domains.auth.service.UserService; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -12,9 +13,11 @@ import org.springframework.web.bind.annotation.*; @RestController public class UserController { private final UserService userService; + private final UserAccessService userAccessService; - public UserController(UserService userService) { + public UserController(UserService userService, UserAccessService userAccessService) { this.userService = userService; + this.userAccessService = userAccessService; } /** @@ -33,6 +36,11 @@ public class UserController { return userService.getUser(userId); } + @GetMapping(path = "/auth/users/{userId}/access") + public UserAccessResponse getUserAccess(@PathVariable String userId) { + return new UserAccessResponse(userAccessService.currentUserHasAccess(userId)); + } + /** * Endpoint for updating one's own password. * @param user The user that's updating their password. @@ -71,6 +79,11 @@ public class UserController { return userService.updatePreferences(user.getId(), payload); } + @GetMapping(path = "/auth/users/{user1Id}/relationship-to/{user2Id}") + public UserRelationshipResponse getRelationship(@PathVariable String user1Id, @PathVariable String user2Id) { + return userService.getRelationship(user1Id, user2Id); + } + @PostMapping(path = "/auth/users/{userId}/followers") public ResponseEntity followUser(@AuthenticationPrincipal User myUser, @PathVariable String userId) { userService.followUser(myUser.getId(), userId); @@ -92,4 +105,14 @@ public class UserController { public Page getFollowing(@AuthenticationPrincipal User user, Pageable pageable) { return userService.getFollowing(user.getId(), pageable); } + + @GetMapping(path = "/auth/users/{userId}/followers") + public Page getUserFollowers(@PathVariable String userId, Pageable pageable) { + return userService.getFollowers(userId, pageable); + } + + @GetMapping(path = "/auth/users/{userId}/following") + public Page getUserFollowing(@PathVariable String userId, Pageable pageable) { + return userService.getFollowing(userId, pageable); + } } diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dto/UserAccessResponse.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dto/UserAccessResponse.java new file mode 100644 index 0000000..1335611 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dto/UserAccessResponse.java @@ -0,0 +1,3 @@ +package nl.andrewlalis.gymboard_api.domains.auth.dto; + +public record UserAccessResponse(boolean accessible) {} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dto/UserRelationshipResponse.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dto/UserRelationshipResponse.java new file mode 100644 index 0000000..682d436 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dto/UserRelationshipResponse.java @@ -0,0 +1,6 @@ +package nl.andrewlalis.gymboard_api.domains.auth.dto; + +public record UserRelationshipResponse( + boolean following, + boolean followedBy +) {} 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 index 734a6f0..bd319a5 100644 --- 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 @@ -36,6 +36,9 @@ public class UserAccessService { if (targetUser != null && !targetUser.getPreferences().isAccountPrivate()) { return true; } + if (user != null && targetUser != null && user.getId().equals(targetUser.getId())) { + return true; + } return user != null && followingRepository.existsByFollowedUserAndFollowingUser(targetUser, user); } @@ -79,9 +82,10 @@ public class UserAccessService { @Transactional(readOnly = true) public boolean currentUserHasAccess(String userId) { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + User user = null; if (auth instanceof TokenAuthentication tokenAuth) { - return userHasAccess(tokenAuth.user(), userId); + user = tokenAuth.user(); } - return false; + return userHasAccess(user, userId); } } diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/service/UserService.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/service/UserService.java index e505b88..0c5404d 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/service/UserService.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/service/UserService.java @@ -38,6 +38,7 @@ public class UserService { private final UserActivationCodeRepository activationCodeRepository; private final PasswordResetCodeRepository passwordResetCodeRepository; private final UserFollowingRepository userFollowingRepository; + private final UserAccessService userAccessService; private final ULID ulid; private final PasswordEncoder passwordEncoder; private final JavaMailSender mailSender; @@ -51,7 +52,7 @@ public class UserService { UserPreferencesRepository userPreferencesRepository, UserActivationCodeRepository activationCodeRepository, PasswordResetCodeRepository passwordResetCodeRepository, - UserFollowingRepository userFollowingRepository, ULID ulid, + UserFollowingRepository userFollowingRepository, UserAccessService userAccessService, ULID ulid, PasswordEncoder passwordEncoder, JavaMailSender mailSender ) { @@ -61,6 +62,7 @@ public class UserService { this.activationCodeRepository = activationCodeRepository; this.passwordResetCodeRepository = passwordResetCodeRepository; this.userFollowingRepository = userFollowingRepository; + this.userAccessService = userAccessService; this.ulid = ulid; this.passwordEncoder = passwordEncoder; this.mailSender = mailSender; @@ -280,6 +282,7 @@ public class UserService { @Transactional public void followUser(String followerId, String followedId) { + if (followerId.equals(followedId)) return; User follower = userRepository.findById(followerId).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); User followed = userRepository.findById(followedId).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); @@ -290,6 +293,7 @@ public class UserService { @Transactional public void unfollowUser(String followerId, String followedId) { + if (followerId.equals(followedId)) return; User follower = userRepository.findById(followerId).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); User followed = userRepository.findById(followedId).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); @@ -300,6 +304,7 @@ public class UserService { public Page getFollowers(String userId, Pageable pageable) { User user = userRepository.findById(userId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + userAccessService.enforceUserAccess(user); return userFollowingRepository.findAllByFollowedUserOrderByCreatedAtDesc(user, pageable) .map(UserFollowing::getFollowingUser) .map(UserResponse::new); @@ -309,8 +314,24 @@ public class UserService { public Page getFollowing(String userId, Pageable pageable) { User user = userRepository.findById(userId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + userAccessService.enforceUserAccess(user); return userFollowingRepository.findAllByFollowingUserOrderByCreatedAtDesc(user, pageable) .map(UserFollowing::getFollowedUser) .map(UserResponse::new); } + + @Transactional(readOnly = true) + public UserRelationshipResponse getRelationship(String user1Id, String user2Id) { + User user1 = userRepository.findById(user1Id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + User user2 = userRepository.findById(user2Id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + userAccessService.enforceUserAccess(user1); + boolean user1FollowingUser2 = userFollowingRepository.existsByFollowedUserAndFollowingUser(user2, user1); + boolean user1FollowedByUser2 = userFollowingRepository.existsByFollowedUserAndFollowingUser(user1, user2); + return new UserRelationshipResponse( + user1FollowingUser2, + user1FollowedByUser2 + ); + } } diff --git a/gymboard-api/src/main/resources/application-development.properties b/gymboard-api/src/main/resources/application-development.properties index 580e6ab..05850cf 100644 --- a/gymboard-api/src/main/resources/application-development.properties +++ b/gymboard-api/src/main/resources/application-development.properties @@ -17,3 +17,5 @@ spring.mail.properties.mail.smtp.timeout=10000 app.auth.private-key-location=./private_key.der app.web-origin=http://localhost:9000 app.cdn-origin=http://localhost:8082 + +#logging.level.root=DEBUG diff --git a/gymboard-app/src/api/main/auth.ts b/gymboard-app/src/api/main/auth.ts index 575d258..1b7773c 100644 --- a/gymboard-app/src/api/main/auth.ts +++ b/gymboard-app/src/api/main/auth.ts @@ -27,6 +27,11 @@ export interface UserPersonalDetails { sex: PersonSex; } +export interface UserRelationship { + following: boolean; + followedBy: boolean; +} + export interface UserPreferences { userId: string; accountPrivate: boolean; @@ -104,6 +109,11 @@ class AuthModule { return response.data; } + public async isUserAccessible(userId: string, authStore: AuthStoreType): Promise { + const response = await api.get(`/auth/users/${userId}/access`, authStore.axiosConfig); + return response.data.accessible; + } + public async updatePassword(newPassword: string, authStore: AuthStoreType) { await api.post( '/auth/me/password', @@ -142,6 +152,29 @@ class AuthModule { const response = await api.post('/auth/me/preferences', newPreferences, authStore.axiosConfig); return response.data; } + + public async getFollowers(userId: string, authStore: AuthStoreType, page: number, count: number): Promise { + const response = await api.get(`/auth/users/${userId}/followers?page=${page}&count=${count}`, authStore.axiosConfig); + return response.data.content; + } + + public async getFollowing(userId: string, authStore: AuthStoreType, page: number, count: number): Promise { + const response = await api.get(`/auth/users/${userId}/following?page=${page}&count=${count}`, authStore.axiosConfig); + return response.data.content; + } + + public async getRelationshipTo(userId: string, targetUserId: string, authStore: AuthStoreType): Promise { + const response = await api.get(`/auth/users/${userId}/relationship-to/${targetUserId}`, authStore.axiosConfig); + return response.data; + } + + public async followUser(userId: string, authStore: AuthStoreType) { + await api.post(`/auth/users/${userId}/followers`, undefined, authStore.axiosConfig); + } + + public async unfollowUser(userId: string, authStore: AuthStoreType) { + await api.delete(`/auth/users/${userId}/followers`, authStore.axiosConfig); + } } export default AuthModule; diff --git a/gymboard-app/src/api/main/submission.ts b/gymboard-app/src/api/main/submission.ts index edce481..e22ebd0 100644 --- a/gymboard-app/src/api/main/submission.ts +++ b/gymboard-app/src/api/main/submission.ts @@ -66,6 +66,10 @@ class SubmissionsModule { const response = await api.post(`/gyms/${gymId}/submissions`, payload, authStore.axiosConfig); return parseSubmission(response.data); } + + public async deleteSubmission(id: string, authStore: AuthStoreType) { + await api.delete(`/submissions/${id}`, authStore.axiosConfig); + } } export default SubmissionsModule; diff --git a/gymboard-app/src/components/PageMenu.vue b/gymboard-app/src/components/PageMenu.vue new file mode 100644 index 0000000..806b5a3 --- /dev/null +++ b/gymboard-app/src/components/PageMenu.vue @@ -0,0 +1,44 @@ + + + + + + diff --git a/gymboard-app/src/components/UserSearchResultListItem.vue b/gymboard-app/src/components/UserSearchResultListItem.vue index e433225..2a58328 100644 --- a/gymboard-app/src/components/UserSearchResultListItem.vue +++ b/gymboard-app/src/components/UserSearchResultListItem.vue @@ -3,7 +3,7 @@ {{ user.name }} - + diff --git a/gymboard-app/src/pages/SubmissionPage.vue b/gymboard-app/src/pages/SubmissionPage.vue index f4228b8..f4c5401 100644 --- a/gymboard-app/src/pages/SubmissionPage.vue +++ b/gymboard-app/src/pages/SubmissionPage.vue @@ -20,6 +20,13 @@

{{ submission.performedAt.setLocale($i18n.locale).toLocaleString(DateTime.DATETIME_MED) }}

+ + + @@ -33,11 +40,18 @@ import { useRoute, useRouter } from 'vue-router'; import { DateTime } from 'luxon'; import { getFileUrl } from 'src/api/cdn'; import { getGymRoute } from 'src/router/gym-routing'; +import {useAuthStore} from "stores/auth-store"; +import {showApiErrorToast} from "src/utils"; +import {useI18n} from "vue-i18n"; +import {useQuasar} from "quasar"; const submission: Ref = ref(); const route = useRoute(); const router = useRouter(); +const authStore = useAuthStore(); +const i18n = useI18n(); +const quasar = useQuasar(); onMounted(async () => { const submissionId = route.params.submissionId as string; @@ -48,6 +62,18 @@ onMounted(async () => { await router.push('/'); } }); + +async function deleteSubmission() { + // TODO: Confirm via a dialog or something before deleting. + if (!submission.value) return; + try { + await api.gyms.submissions.deleteSubmission(submission.value.id, authStore); + await router.push('/'); + } catch (error) { + console.error(error); + showApiErrorToast(i18n, quasar); + } +} diff --git a/gymboard-app/src/pages/gym/GymPage.vue b/gymboard-app/src/pages/gym/GymPage.vue index dbb03e9..6567bea 100644 --- a/gymboard-app/src/pages/gym/GymPage.vue +++ b/gymboard-app/src/pages/gym/GymPage.vue @@ -2,23 +2,14 @@

{{ gym.displayName }}

- - - - - +
@@ -30,9 +21,12 @@ import { useRoute, useRouter } from 'vue-router'; import StandardCenteredPage from 'components/StandardCenteredPage.vue'; import { getGymFromRoute, getGymRoute } from 'src/router/gym-routing'; import { Gym } from 'src/api/main/gyms'; +import PageMenu from "components/PageMenu.vue"; +import {useI18n} from "vue-i18n"; const route = useRoute(); const router = useRouter(); +const t = useI18n().t; const gym: Ref = ref(); diff --git a/gymboard-app/src/pages/user/UserFollowersPage.vue b/gymboard-app/src/pages/user/UserFollowersPage.vue new file mode 100644 index 0000000..b82ba48 --- /dev/null +++ b/gymboard-app/src/pages/user/UserFollowersPage.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/gymboard-app/src/pages/user/UserFollowingPage.vue b/gymboard-app/src/pages/user/UserFollowingPage.vue new file mode 100644 index 0000000..15b932c --- /dev/null +++ b/gymboard-app/src/pages/user/UserFollowingPage.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/gymboard-app/src/pages/user/UserPage.vue b/gymboard-app/src/pages/user/UserPage.vue new file mode 100644 index 0000000..6005991 --- /dev/null +++ b/gymboard-app/src/pages/user/UserPage.vue @@ -0,0 +1,138 @@ + + + + + diff --git a/gymboard-app/src/pages/user/UserSubmissionsPage.vue b/gymboard-app/src/pages/user/UserSubmissionsPage.vue new file mode 100644 index 0000000..ed8174f --- /dev/null +++ b/gymboard-app/src/pages/user/UserSubmissionsPage.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/gymboard-app/src/router/routes.ts b/gymboard-app/src/router/routes.ts index 5cbebfa..6a7b64e 100644 --- a/gymboard-app/src/router/routes.ts +++ b/gymboard-app/src/router/routes.ts @@ -10,7 +10,7 @@ import RegisterPage from 'pages/auth/RegisterPage.vue'; import RegistrationSuccessPage from 'pages/auth/RegistrationSuccessPage.vue'; import ActivationPage from 'pages/auth/ActivationPage.vue'; import SubmissionPage from 'pages/SubmissionPage.vue'; -import UserPage from 'pages/UserPage.vue'; +import UserPage from 'pages/user/UserPage.vue'; import UserSettingsPage from 'pages/auth/UserSettingsPage.vue'; import UserSearchPage from 'pages/UserSearchPage.vue'; @@ -28,15 +28,11 @@ const routes: RouteRecordRaw[] = [ children: [ { path: '', component: GymSearchPage }, { path: 'users', component: UserSearchPage }, - { - path: 'users/:userId', - children: [ - { path: '', component: UserPage }, - { path: 'settings', component: UserSettingsPage } - ] + { path: 'users/:userId/settings', component: UserSettingsPage }, + { // Match anything under /users/:userId to the UserPage, since it manages sub-pages manually. + path: 'users/:userId+', + component: UserPage }, - // { path: 'users/:userId', component: UserPage }, - // { path: 'users/:userId/settings', component: UserSettingsPage }, { path: 'gyms/:countryCode/:cityShortName/:gymShortName', component: GymPage,