Added submission count to gym index, made user data access protected.

This commit is contained in:
Andrew Lalis 2023-02-16 18:11:03 +01:00
parent 6b7b38595e
commit 654623ce4e
15 changed files with 195 additions and 39 deletions

View File

@ -50,7 +50,8 @@ public class SecurityConfig {
"/auth/reset-password", "/auth/reset-password",
"/auth/users/*", "/auth/users/*",
"/auth/users/*/followers", "/auth/users/*/followers",
"/auth/users/*/following" "/auth/users/*/following",
"/users/*/recent-submissions"
).permitAll() ).permitAll()
.requestMatchers(// Allow the following POST endpoints to be public. .requestMatchers(// Allow the following POST endpoints to be public.
HttpMethod.POST, HttpMethod.POST,

View File

@ -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.api.dto.SubmissionResponse;
import nl.andrewlalis.gymboard_api.domains.auth.dao.UserRepository; 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.model.User;
import nl.andrewlalis.gymboard_api.domains.auth.service.UserAccessService;
import nl.andrewlalis.gymboard_api.util.PredicateBuilder; import nl.andrewlalis.gymboard_api.util.PredicateBuilder;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
@ -17,19 +18,20 @@ import java.util.List;
public class UserSubmissionService { public class UserSubmissionService {
private final UserRepository userRepository; private final UserRepository userRepository;
private final SubmissionRepository submissionRepository; 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.userRepository = userRepository;
this.submissionRepository = submissionRepository; this.submissionRepository = submissionRepository;
this.userAccessService = userAccessService;
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
public List<SubmissionResponse> getRecentSubmissions(String userId) { public List<SubmissionResponse> getRecentSubmissions(String userId) {
User user = userRepository.findById(userId) User user = userRepository.findById(userId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
if (user.getPreferences().isAccountPrivate()) { userAccessService.enforceUserAccess(user);
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
}
return submissionRepository.findAll((root, query, criteriaBuilder) -> { return submissionRepository.findAll((root, query, criteriaBuilder) -> {
query.orderBy( query.orderBy(
criteriaBuilder.desc(root.get("performedAt")), criteriaBuilder.desc(root.get("performedAt")),

View File

@ -6,16 +6,14 @@ public record UserResponse(
String id, String id,
boolean activated, boolean activated,
String email, String email,
String name, String name
boolean accountPrivate
) { ) {
public UserResponse(User user) { public UserResponse(User user) {
this( this(
user.getId(), user.getId(),
user.isActivated(), user.isActivated(),
user.getEmail(), user.getEmail(),
user.getName(), user.getName()
user.getPreferences().isAccountPrivate()
); );
} }
} }

View File

@ -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;
}
}

View File

@ -3,7 +3,8 @@ import GymsModule from 'src/api/main/gyms';
import ExercisesModule from 'src/api/main/exercises'; import ExercisesModule from 'src/api/main/exercises';
import LeaderboardsModule from 'src/api/main/leaderboards'; import LeaderboardsModule from 'src/api/main/leaderboards';
import AuthModule from 'src/api/main/auth'; import AuthModule from 'src/api/main/auth';
console.log(process.env); import UsersModule from 'src/api/main/users';
export const api = axios.create({ export const api = axios.create({
baseURL: process.env.API_URL, baseURL: process.env.API_URL,
}); });
@ -11,6 +12,7 @@ export const api = axios.create({
class GymboardApi { class GymboardApi {
public readonly auth = new AuthModule(); public readonly auth = new AuthModule();
public readonly gyms = new GymsModule(); public readonly gyms = new GymsModule();
public readonly users = new UsersModule();
public readonly exercises = new ExercisesModule(); public readonly exercises = new ExercisesModule();
public readonly leaderboards = new LeaderboardsModule(); public readonly leaderboards = new LeaderboardsModule();
} }

View File

@ -3,7 +3,7 @@ import { Exercise } from 'src/api/main/exercises';
import { api } from 'src/api/main/index'; import { api } from 'src/api/main/index';
import { getGymCompoundId, GymRoutable } from 'src/router/gym-routing'; import { getGymCompoundId, GymRoutable } from 'src/router/gym-routing';
import { DateTime } from 'luxon'; 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. * The data that's sent when creating a submission.

View File

@ -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<Array<ExerciseSubmission>> {
const response = await api.get(`/users/${userId}/recent-submissions`, authStore.axiosConfig);
return response.data.map(parseSubmission);
}
}
export default UsersModule;

View File

@ -44,7 +44,9 @@ export default {
notFound: { notFound: {
title: 'User Not Found', title: 'User Not Found',
description: 'We couldn\'t find the user you\'re looking for.' description: 'We couldn\'t find the user you\'re looking for.'
} },
accountPrivate: 'This account is private.',
recentLifts: 'Recent Lifts',
}, },
userSettingsPage: { userSettingsPage: {
title: 'Account Settings', title: 'Account Settings',

View File

@ -44,7 +44,9 @@ export default {
notFound: { notFound: {
title: 'Gebruiker niet gevonden', title: 'Gebruiker niet gevonden',
description: 'Wij konden de gebruiker voor wie jij zoekt niet vinden, helaas.' description: 'Wij konden de gebruiker voor wie jij zoekt niet vinden, helaas.'
} },
accountPrivate: 'Dit account is privaat.',
recentLifts: 'Recente liften',
}, },
userSettingsPage: { userSettingsPage: {
title: 'Account instellingen', title: 'Account instellingen',

View File

@ -8,6 +8,20 @@
<hr> <hr>
<div v-if="userPrivate">
This account is private.
</div>
<div v-if="recentSubmissions.length > 0">
<h4 class="text-center">{{ $t('userPage.recentLifts') }}</h4>
<q-list separator>
<ExerciseSubmissionListItem
v-for="sub in recentSubmissions"
:submission="sub"
:key="sub.id"
/>
</q-list>
</div>
</StandardCenteredPage> </StandardCenteredPage>
<StandardCenteredPage v-if="userNotFound"> <StandardCenteredPage v-if="userNotFound">
@ -19,42 +33,56 @@
<script setup lang="ts"> <script setup lang="ts">
import StandardCenteredPage from 'components/StandardCenteredPage.vue'; import StandardCenteredPage from 'components/StandardCenteredPage.vue';
import {computed, onMounted, ref, Ref} from 'vue'; import {onMounted, ref, Ref} from 'vue';
import {User} from 'src/api/main/auth'; import {User} from 'src/api/main/auth';
import api from 'src/api/main'; import api from 'src/api/main';
import {useRoute} from 'vue-router'; import {useRoute} from 'vue-router';
import {useAuthStore} from 'stores/auth-store'; import {useAuthStore} from 'stores/auth-store';
import { getUserRoute } from 'src/router/user-routing'; import {useI18n} from 'vue-i18n';
import {useQuasar} from 'quasar';
import {showApiErrorToast} from 'src/utils';
import {ExerciseSubmission} from 'src/api/main/submission';
import ExerciseSubmissionListItem from 'components/ExerciseSubmissionListItem.vue';
const route = useRoute(); const route = useRoute();
const authStore = useAuthStore(); const authStore = useAuthStore();
const i18n = useI18n();
const quasar = useQuasar();
/** /**
* The user that this page displays information about. * The user that this page displays information about.
*/ */
const user: Ref<User | undefined> = ref(); const user: Ref<User | undefined> = ref();
const recentSubmissions: Ref<ExerciseSubmission[]> = ref([]);
/**
* Flag that tells whether this user is the currently authenticated user.
*/
const isOwnUser = ref(false); const isOwnUser = ref(false);
/**
* Flag used to indicate whether we should show a "not found" message instead
* of the usual user page.
*/
const userNotFound = ref(false); const userNotFound = ref(false);
const userPrivate = ref(false);
onMounted(async () => { onMounted(async () => {
const userId = route.params.userId as string; const userId = route.params.userId as string;
try { try {
user.value = await api.auth.getUser(userId, authStore); user.value = await api.auth.getUser(userId, authStore);
} catch (error: any) { } catch (error: any) {
if (error.response && error.response.code === 404) { if (error.response && error.response.status === 404) {
userNotFound.value = true; userNotFound.value = true;
} else {
showApiErrorToast(i18n, quasar);
} }
} }
isOwnUser.value = authStore.loggedIn && user.value?.id === authStore.user?.id; isOwnUser.value = authStore.loggedIn && user.value?.id === authStore.user?.id;
// If the user exists, try and fetch their latest submissions, and handle a 403 forbidden as private.
if (user.value) {
try {
recentSubmissions.value = await api.users.getRecentSubmissions(user.value?.id, authStore);
} catch (error: any) {
if (error.response && error.response.status === 403) {
userPrivate.value = true;
} else {
showApiErrorToast(i18n, quasar);
}
}
}
}); });
</script> </script>

View File

@ -1 +1,11 @@
import {QVueGlobals} from "quasar";
export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); 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'
});
}

View File

@ -12,7 +12,8 @@ public record GymResponse(
String countryName, String countryName,
String streetAddress, String streetAddress,
double latitude, double latitude,
double longitude double longitude,
long submissionCount
) { ) {
public GymResponse(Document doc) { public GymResponse(Document doc) {
this( this(
@ -25,7 +26,8 @@ public record GymResponse(
doc.get("country_name"), doc.get("country_name"),
doc.get("street_address"), doc.get("street_address"),
doc.getField("latitude").numericValue().doubleValue(), doc.getField("latitude").numericValue().doubleValue(),
doc.getField("longitude").numericValue().doubleValue() doc.getField("longitude").numericValue().doubleValue(),
doc.getField("submission_count").numericValue().longValue()
); );
} }
} }

View File

@ -66,6 +66,7 @@ public class IndexComponents {
String streetAddress = rs.getString("street_address"); String streetAddress = rs.getString("street_address");
BigDecimal latitude = rs.getBigDecimal("latitude"); BigDecimal latitude = rs.getBigDecimal("latitude");
BigDecimal longitude = rs.getBigDecimal("longitude"); BigDecimal longitude = rs.getBigDecimal("longitude");
long submissionCount = rs.getLong("submission_count");
String gymCompoundId = String.format("%s_%s_%s", countryCode, cityShortName, shortName); String gymCompoundId = String.format("%s_%s_%s", countryCode, cityShortName, shortName);
Document doc = new Document(); Document doc = new Document();
@ -81,6 +82,8 @@ public class IndexComponents {
doc.add(new StoredField("latitude", latitude.doubleValue())); doc.add(new StoredField("latitude", latitude.doubleValue()));
doc.add(new DoublePoint("longitude_point", longitude.doubleValue())); doc.add(new DoublePoint("longitude_point", longitude.doubleValue()));
doc.add(new StoredField("longitude", 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; return doc;
} }
); );

View File

@ -1,14 +1,21 @@
SELECT SELECT
gym.short_name as short_name, gym.short_name AS short_name,
gym.display_name as display_name, gym.display_name AS display_name,
city.short_name as city_short_name, city.short_name AS city_short_name,
city.name as city_name, city.name AS city_name,
country.code as country_code, country.code AS country_code,
country.name as country_name, country.name AS country_name,
gym.street_address as street_address, gym.street_address AS street_address,
gym.latitude as latitude, gym.latitude AS latitude,
gym.longitude as longitude 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 FROM gym
LEFT JOIN city on gym.city_short_name = city.short_name LEFT JOIN city ON gym.city_short_name = city.short_name
LEFT JOIN country on gym.city_country_code = country.code LEFT JOIN country ON gym.city_country_code = country.code
ORDER BY gym.created_at; ORDER BY gym.created_at;

View File

@ -4,5 +4,5 @@ SELECT
u.name as name u.name as name
FROM auth_user u FROM auth_user u
LEFT JOIN auth_user_preferences p ON u.id = p.user_id 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; ORDER BY u.created_at;