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/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,
|
||||
|
|
|
@ -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<SubmissionResponse> 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")),
|
||||
|
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 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();
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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: {
|
||||
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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -8,6 +8,20 @@
|
|||
|
||||
<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 v-if="userNotFound">
|
||||
|
@ -19,42 +33,56 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
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 api from 'src/api/main';
|
||||
import {useRoute} from 'vue-router';
|
||||
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 authStore = useAuthStore();
|
||||
const i18n = useI18n();
|
||||
const quasar = useQuasar();
|
||||
|
||||
/**
|
||||
* The user that this page displays information about.
|
||||
*/
|
||||
const user: Ref<User | undefined> = ref();
|
||||
|
||||
/**
|
||||
* Flag that tells whether this user is the currently authenticated user.
|
||||
*/
|
||||
const recentSubmissions: Ref<ExerciseSubmission[]> = ref([]);
|
||||
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 userPrivate = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
const userId = route.params.userId as string;
|
||||
try {
|
||||
user.value = await api.auth.getUser(userId, authStore);
|
||||
} catch (error: any) {
|
||||
if (error.response && error.response.code === 404) {
|
||||
if (error.response && error.response.status === 404) {
|
||||
userNotFound.value = true;
|
||||
} else {
|
||||
showApiErrorToast(i18n, quasar);
|
||||
}
|
||||
}
|
||||
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>
|
||||
|
||||
|
|
|
@ -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'
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
Loading…
Reference in New Issue