Added submission count to gym index, made user data access protected.
This commit is contained in:
parent
6b7b38595e
commit
654623ce4e
|
@ -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,
|
||||||
|
|
|
@ -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")),
|
||||||
|
|
|
@ -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()
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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;
|
|
@ -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',
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
@ -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()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
Loading…
Reference in New Issue