Add account deletion, data requests, and more.
This commit is contained in:
parent
7a31ab5028
commit
9d1712889e
|
@ -1,10 +1,14 @@
|
|||
package nl.andrewlalis.gymboard_api.domains.api.dao.submission;
|
||||
|
||||
import nl.andrewlalis.gymboard_api.domains.api.model.submission.Submission;
|
||||
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface SubmissionRepository extends JpaRepository<Submission, String>, JpaSpecificationExecutor<Submission> {
|
||||
@Modifying
|
||||
void deleteAllByUser(User user);
|
||||
}
|
||||
|
|
|
@ -4,7 +4,9 @@ import nl.andrewlalis.gymboard_api.domains.auth.dto.*;
|
|||
import nl.andrewlalis.gymboard_api.domains.auth.model.Role;
|
||||
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
|
||||
import nl.andrewlalis.gymboard_api.domains.auth.model.UserPreferences;
|
||||
import nl.andrewlalis.gymboard_api.domains.auth.service.DataRequestService;
|
||||
import nl.andrewlalis.gymboard_api.domains.auth.service.UserAccessService;
|
||||
import nl.andrewlalis.gymboard_api.domains.auth.service.UserAccountDeletionService;
|
||||
import nl.andrewlalis.gymboard_api.domains.auth.service.UserService;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
@ -12,16 +14,19 @@ import org.springframework.http.ResponseEntity;
|
|||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
public class UserController {
|
||||
private final UserService userService;
|
||||
private final DataRequestService dataRequestService;
|
||||
private final UserAccountDeletionService accountDeletionService;
|
||||
private final UserAccessService userAccessService;
|
||||
|
||||
public UserController(UserService userService, UserAccessService userAccessService) {
|
||||
public UserController(UserService userService, DataRequestService dataRequestService, UserAccountDeletionService accountDeletionService, UserAccessService userAccessService) {
|
||||
this.userService = userService;
|
||||
this.dataRequestService = dataRequestService;
|
||||
this.accountDeletionService = accountDeletionService;
|
||||
this.userAccessService = userAccessService;
|
||||
}
|
||||
|
||||
|
@ -165,4 +170,16 @@ public class UserController {
|
|||
public List<String> getMyRoles(@AuthenticationPrincipal User myUser) {
|
||||
return myUser.getRoles().stream().map(Role::getShortName).toList();
|
||||
}
|
||||
|
||||
@PostMapping(path = "/auth/me/data-requests")
|
||||
public ResponseEntity<Void> requestData(@AuthenticationPrincipal User myUser) {
|
||||
dataRequestService.createRequest(myUser.getId());
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@DeleteMapping(path = "/auth/me")
|
||||
public ResponseEntity<Void> deleteAccount(@AuthenticationPrincipal User myUser) {
|
||||
accountDeletionService.deleteAccount(myUser);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package nl.andrewlalis.gymboard_api.domains.auth.dao;
|
||||
|
||||
import nl.andrewlalis.gymboard_api.domains.auth.model.EmailResetCode;
|
||||
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
@ -11,4 +12,10 @@ import java.time.LocalDateTime;
|
|||
public interface EmailResetCodeRepository extends JpaRepository<EmailResetCode, String> {
|
||||
@Modifying
|
||||
void deleteAllByCreatedAtBefore(LocalDateTime cutoff);
|
||||
|
||||
boolean existsByNewEmail(String newEmail);
|
||||
@Modifying
|
||||
void deleteByNewEmail(String newEmail);
|
||||
@Modifying
|
||||
void deleteAllByUser(User user);
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package nl.andrewlalis.gymboard_api.domains.auth.dao;
|
||||
|
||||
import nl.andrewlalis.gymboard_api.domains.auth.model.PasswordResetCode;
|
||||
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
@ -11,4 +12,6 @@ import java.time.LocalDateTime;
|
|||
public interface PasswordResetCodeRepository extends JpaRepository<PasswordResetCode, String> {
|
||||
@Modifying
|
||||
void deleteAllByCreatedAtBefore(LocalDateTime cutoff);
|
||||
@Modifying
|
||||
void deleteAllByUser(User user);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
package nl.andrewlalis.gymboard_api.domains.auth.dao;
|
||||
|
||||
import nl.andrewlalis.gymboard_api.domains.auth.model.UserAccountDataRequest;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface UserAccountDataRequestRepository extends JpaRepository<UserAccountDataRequest, Long> {
|
||||
boolean existsByUserIdAndFulfilledFalse(String userId);
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
package nl.andrewlalis.gymboard_api.domains.auth.dao;
|
||||
|
||||
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
|
||||
import nl.andrewlalis.gymboard_api.domains.auth.model.UserActivationCode;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
|
@ -14,4 +15,6 @@ public interface UserActivationCodeRepository extends JpaRepository<UserActivati
|
|||
|
||||
@Modifying
|
||||
void deleteAllByCreatedAtBefore(LocalDateTime cutoff);
|
||||
@Modifying
|
||||
void deleteAllByUser(User user);
|
||||
}
|
||||
|
|
|
@ -14,6 +14,8 @@ public interface UserFollowingRepository extends JpaRepository<UserFollowing, Lo
|
|||
|
||||
@Modifying
|
||||
void deleteByFollowedUserAndFollowingUser(User followedUser, User followingUser);
|
||||
@Modifying
|
||||
void deleteAllByFollowedUserOrFollowingUser(User followedUser, User followingUser);
|
||||
|
||||
Page<UserFollowing> findAllByFollowedUserOrderByCreatedAtDesc(User followedUser, Pageable pageable);
|
||||
Page<UserFollowing> findAllByFollowingUserOrderByCreatedAtDesc(User followingUser, Pageable pageable);
|
||||
|
|
|
@ -1,9 +1,13 @@
|
|||
package nl.andrewlalis.gymboard_api.domains.auth.dao;
|
||||
|
||||
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
|
||||
import nl.andrewlalis.gymboard_api.domains.auth.model.UserReport;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface UserReportRepository extends JpaRepository<UserReport, Long> {
|
||||
@Modifying
|
||||
void deleteAllByUserOrReportedBy(User user, User reportedBy);
|
||||
}
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
package nl.andrewlalis.gymboard_api.domains.auth.dao;
|
||||
|
||||
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
|
@ -18,4 +21,7 @@ public interface UserRepository extends JpaRepository<User, String>, JpaSpecific
|
|||
|
||||
@Query("SELECT u FROM User u LEFT JOIN FETCH u.roles WHERE u.email = :email")
|
||||
Optional<User> findByEmailWithRoles(String email);
|
||||
|
||||
@Modifying
|
||||
void deleteAllByActivatedFalseAndCreatedAtBefore(LocalDateTime cutoff);
|
||||
}
|
||||
|
|
|
@ -10,7 +10,7 @@ import java.time.LocalDateTime;
|
|||
* A code that's sent to a user's new email address to confirm that they own
|
||||
* it. Once confirmed, the user's email address will be updated.
|
||||
*/
|
||||
@Table(name = "auth_email_reset_code")
|
||||
@Table(name = "auth_user_email_reset_code")
|
||||
@Entity
|
||||
public class EmailResetCode {
|
||||
public static final Duration VALID_FOR = Duration.ofMinutes(30);
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
package nl.andrewlalis.gymboard_api.domains.auth.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* A request issued by a user for a download of their entire account data set.
|
||||
* This entity is created when a user sends a request, and will get picked up
|
||||
* and processed eventually by a scheduled task, and ultimately the user will be
|
||||
* sent an email with a link to download their data.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "auth_user_account_data_request")
|
||||
public class UserAccountDataRequest {
|
||||
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@CreationTimestamp
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@ManyToOne(optional = false, fetch = FetchType.LAZY)
|
||||
private User user;
|
||||
|
||||
@Column(nullable = false)
|
||||
private boolean fulfilled = false;
|
||||
|
||||
public UserAccountDataRequest() {}
|
||||
|
||||
public UserAccountDataRequest(User user) {
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public User getUser() {
|
||||
return user;
|
||||
}
|
||||
|
||||
public boolean isFulfilled() {
|
||||
return fulfilled;
|
||||
}
|
||||
}
|
|
@ -1,9 +1,6 @@
|
|||
package nl.andrewlalis.gymboard_api.domains.auth.service;
|
||||
|
||||
import nl.andrewlalis.gymboard_api.domains.auth.dao.EmailResetCodeRepository;
|
||||
import nl.andrewlalis.gymboard_api.domains.auth.dao.PasswordResetCodeRepository;
|
||||
import nl.andrewlalis.gymboard_api.domains.auth.dao.UserActivationCodeRepository;
|
||||
import nl.andrewlalis.gymboard_api.domains.auth.dao.UserFollowRequestRepository;
|
||||
import nl.andrewlalis.gymboard_api.domains.auth.dao.*;
|
||||
import nl.andrewlalis.gymboard_api.domains.auth.model.EmailResetCode;
|
||||
import nl.andrewlalis.gymboard_api.domains.auth.model.PasswordResetCode;
|
||||
import nl.andrewlalis.gymboard_api.domains.auth.model.UserActivationCode;
|
||||
|
@ -21,12 +18,20 @@ public class CleanupService {
|
|||
private final UserActivationCodeRepository activationCodeRepository;
|
||||
private final UserFollowRequestRepository followRequestRepository;
|
||||
private final EmailResetCodeRepository emailResetCodeRepository;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public CleanupService(PasswordResetCodeRepository passwordResetCodeRepository, UserActivationCodeRepository activationCodeRepository, UserFollowRequestRepository followRequestRepository, EmailResetCodeRepository emailResetCodeRepository) {
|
||||
public CleanupService(
|
||||
PasswordResetCodeRepository passwordResetCodeRepository,
|
||||
UserActivationCodeRepository activationCodeRepository,
|
||||
UserFollowRequestRepository followRequestRepository,
|
||||
EmailResetCodeRepository emailResetCodeRepository,
|
||||
UserRepository userRepository
|
||||
) {
|
||||
this.passwordResetCodeRepository = passwordResetCodeRepository;
|
||||
this.activationCodeRepository = activationCodeRepository;
|
||||
this.followRequestRepository = followRequestRepository;
|
||||
this.emailResetCodeRepository = emailResetCodeRepository;
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -44,5 +49,7 @@ public class CleanupService {
|
|||
followRequestRepository.deleteAllByCreatedAtBefore(followRequestCutoff);
|
||||
LocalDateTime emailResetCodeCutoff = LocalDateTime.now().minus(EmailResetCode.VALID_FOR);
|
||||
emailResetCodeRepository.deleteAllByCreatedAtBefore(emailResetCodeCutoff);
|
||||
LocalDateTime inactiveUserCutoff = LocalDateTime.now().minusDays(7);
|
||||
userRepository.deleteAllByActivatedFalseAndCreatedAtBefore(inactiveUserCutoff);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
package nl.andrewlalis.gymboard_api.domains.auth.service;
|
||||
|
||||
import nl.andrewlalis.gymboard_api.domains.auth.dao.UserAccountDataRequestRepository;
|
||||
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.UserAccountDataRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class DataRequestService {
|
||||
private final UserAccountDataRequestRepository dataRequestRepository;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public DataRequestService(UserAccountDataRequestRepository dataRequestRepository, UserRepository userRepository) {
|
||||
this.dataRequestRepository = dataRequestRepository;
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void createRequest(String userId) {
|
||||
if (dataRequestRepository.existsByUserIdAndFulfilledFalse(userId)) {
|
||||
return; // If there's already an open request that hasn't been fulfilled, ignore this one.
|
||||
}
|
||||
User user = userRepository.findById(userId).orElseThrow();
|
||||
dataRequestRepository.save(new UserAccountDataRequest(user));
|
||||
}
|
||||
|
||||
// TODO: Add scheduled task and logic for preparing user data exports.
|
||||
}
|
|
@ -17,6 +17,7 @@ import org.springframework.http.HttpStatus;
|
|||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.nio.file.Files;
|
||||
|
@ -29,6 +30,10 @@ import java.time.temporal.ChronoUnit;
|
|||
import java.util.Date;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* This service is responsible for generating, verifying, and generally managing
|
||||
* authentication tokens.
|
||||
*/
|
||||
@Service
|
||||
public class TokenService {
|
||||
private static final Logger log = LoggerFactory.getLogger(TokenService.class);
|
||||
|
@ -47,7 +52,12 @@ public class TokenService {
|
|||
this.passwordEncoder = passwordEncoder;
|
||||
}
|
||||
|
||||
public String generateAccessToken(User user) {
|
||||
/**
|
||||
* Generates a new short-lived access token for the given user.
|
||||
* @param user The user to generate an access token for.
|
||||
* @return The access token string.
|
||||
*/
|
||||
private String generateAccessToken(User user) {
|
||||
Instant expiration = Instant.now().plus(30, ChronoUnit.MINUTES);
|
||||
return Jwts.builder()
|
||||
.setSubject(user.getId())
|
||||
|
@ -63,6 +73,12 @@ public class TokenService {
|
|||
.compact();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a new access token for a given set of credentials.
|
||||
* @param credentials The credentials to use for authentication.
|
||||
* @return A token response.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public TokenResponse generateAccessToken(TokenCredentials credentials) {
|
||||
User user = userRepository.findByEmailWithRoles(credentials.email())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED));
|
||||
|
|
|
@ -0,0 +1,51 @@
|
|||
package nl.andrewlalis.gymboard_api.domains.auth.service;
|
||||
|
||||
import nl.andrewlalis.gymboard_api.domains.api.dao.submission.SubmissionRepository;
|
||||
import nl.andrewlalis.gymboard_api.domains.auth.dao.*;
|
||||
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class UserAccountDeletionService {
|
||||
private static final Logger logger = LoggerFactory.getLogger(UserAccountDeletionService.class);
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final UserReportRepository userReportRepository;
|
||||
private final UserFollowingRepository userFollowingRepository;
|
||||
private final UserActivationCodeRepository userActivationCodeRepository;
|
||||
private final EmailResetCodeRepository emailResetCodeRepository;
|
||||
private final PasswordResetCodeRepository passwordResetCodeRepository;
|
||||
private final SubmissionRepository submissionRepository;
|
||||
|
||||
public UserAccountDeletionService(UserRepository userRepository,
|
||||
UserReportRepository userReportRepository,
|
||||
UserFollowingRepository userFollowingRepository,
|
||||
UserActivationCodeRepository userActivationCodeRepository,
|
||||
EmailResetCodeRepository emailResetCodeRepository,
|
||||
PasswordResetCodeRepository passwordResetCodeRepository,
|
||||
SubmissionRepository submissionRepository) {
|
||||
this.userRepository = userRepository;
|
||||
this.userReportRepository = userReportRepository;
|
||||
this.userFollowingRepository = userFollowingRepository;
|
||||
this.userActivationCodeRepository = userActivationCodeRepository;
|
||||
this.emailResetCodeRepository = emailResetCodeRepository;
|
||||
this.passwordResetCodeRepository = passwordResetCodeRepository;
|
||||
this.submissionRepository = submissionRepository;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteAccount(User user) {
|
||||
logger.info("Deleting user account {}", user.getEmail());
|
||||
|
||||
passwordResetCodeRepository.deleteAllByUser(user);
|
||||
emailResetCodeRepository.deleteAllByUser(user);
|
||||
userActivationCodeRepository.deleteAllByUser(user);
|
||||
userReportRepository.deleteAllByUserOrReportedBy(user, user);
|
||||
userFollowingRepository.deleteAllByFollowedUserOrFollowingUser(user, user);
|
||||
submissionRepository.deleteAllByUser(user);
|
||||
userRepository.deleteById(user.getId());
|
||||
}
|
||||
}
|
|
@ -236,6 +236,8 @@ public class UserService {
|
|||
if (userRepository.existsByEmail(payload.newEmail())) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Email is taken.");
|
||||
}
|
||||
// Delete any existing email reset code for the chosen email.
|
||||
emailResetCodeRepository.deleteByNewEmail(payload.newEmail());
|
||||
EmailResetCode emailResetCode = emailResetCodeRepository.save(new EmailResetCode(
|
||||
StringGenerator.randomString(127, StringGenerator.Alphabet.ALPHANUMERIC),
|
||||
payload.newEmail(),
|
||||
|
|
|
@ -263,6 +263,14 @@ class AuthModule {
|
|||
const response = await api.get('/auth/me/roles', authStore.axiosConfig);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
public async requestAccountData(authStore: AuthStoreType): Promise<void> {
|
||||
await api.post('/auth/me/data-requests', null, authStore.axiosConfig);
|
||||
}
|
||||
|
||||
public async deleteAccount(authStore: AuthStoreType): Promise<void> {
|
||||
await api.delete('/auth/me', authStore.axiosConfig);
|
||||
}
|
||||
}
|
||||
|
||||
export default AuthModule;
|
||||
|
|
|
@ -21,13 +21,13 @@ declare module 'vue-i18n' {
|
|||
}
|
||||
/* eslint-enable @typescript-eslint/no-empty-interface */
|
||||
|
||||
export default boot(({ app }) => {
|
||||
const i18n = createI18n({
|
||||
export const i18n = createI18n({
|
||||
locale: 'en-US',
|
||||
legacy: false,
|
||||
messages,
|
||||
});
|
||||
});
|
||||
|
||||
export default boot(({ app }) => {
|
||||
// Set the locale to the preferred locale, if possible.
|
||||
const userLocale = window.navigator.language;
|
||||
if (userLocale === 'nl-NL') {
|
||||
|
|
|
@ -17,7 +17,7 @@ account-related actions.
|
|||
<q-item-label>{{ $t('accountMenuItem.profile') }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable v-close-popup :to="getUserRoute(authStore.user) + '/settings'">
|
||||
<q-item clickable v-close-popup to="/me/settings">
|
||||
<q-item-section>
|
||||
<q-item-label>{{ $t('accountMenuItem.settings') }}</q-item-label>
|
||||
</q-item-section>
|
||||
|
|
|
@ -18,6 +18,11 @@ export default {
|
|||
register: 'Register',
|
||||
error: 'An error occurred.',
|
||||
},
|
||||
registrationSuccessPage: {
|
||||
title: 'Account Registration Complete!',
|
||||
p1: 'Check your email for the link to activate your account.',
|
||||
p2: 'You may safely close this page.'
|
||||
},
|
||||
loginPage: {
|
||||
title: 'Login to Gymboard',
|
||||
email: 'Email',
|
||||
|
@ -67,6 +72,7 @@ export default {
|
|||
userSettingsPage: {
|
||||
title: 'Account Settings',
|
||||
email: 'Email',
|
||||
changeEmail: 'Change your email address',
|
||||
name: 'Name',
|
||||
password: 'Password',
|
||||
passwordHint: 'Set a new password for your account.',
|
||||
|
@ -89,7 +95,33 @@ export default {
|
|||
language: 'Language'
|
||||
},
|
||||
save: 'Save',
|
||||
undo: 'Undo'
|
||||
undo: 'Undo',
|
||||
actions: {
|
||||
title: 'Actions',
|
||||
requestData: 'Request Account Data',
|
||||
deleteAccount: 'Delete Account'
|
||||
}
|
||||
},
|
||||
updateEmailPage: {
|
||||
title: 'Update Email Address',
|
||||
inputHint: 'Enter your new email address here',
|
||||
beforeUpdateInfo: "To update your email address, we'll send a secret code to your new address.",
|
||||
updateButton: 'Update Email Address',
|
||||
resetCodeSent: 'A reset code has been sent to your new email address.',
|
||||
resetCodeInputHint: 'Enter your code here',
|
||||
emailUpdated: 'Your email has been updated successfully.'
|
||||
},
|
||||
requestAccountDataPage: {
|
||||
title: 'Request Account Data',
|
||||
requestButton: 'Request Account Data',
|
||||
requestSent: 'Request sent. You will receive an email with a link to download your data in a few days.'
|
||||
},
|
||||
deleteAccountPage: {
|
||||
title: 'Delete Account',
|
||||
deleteButton: 'Delete Account',
|
||||
confirmTitle: 'Confirm Deletion',
|
||||
confirmMessage: 'Are you absolutely certain that you want to delete your Gymboard account? This CANNOT be undone.',
|
||||
accountDeleted: 'Account deleted. You will now be logged out. Goodbye 😭'
|
||||
},
|
||||
submissionPage: {
|
||||
confirmDeletion: 'Confirm Deletion',
|
||||
|
@ -108,5 +140,9 @@ export default {
|
|||
weightUnit: {
|
||||
kilograms: 'Kilograms',
|
||||
pounds: 'Pounds'
|
||||
},
|
||||
confirm: {
|
||||
title: 'Confirm',
|
||||
message: 'Are you sure you want to continue?'
|
||||
}
|
||||
};
|
||||
|
|
|
@ -62,6 +62,7 @@ export default {
|
|||
userSettingsPage: {
|
||||
title: 'Account instellingen',
|
||||
email: 'E-mail',
|
||||
changeEmail: 'E-mail adres wijzigen',
|
||||
name: 'Naam',
|
||||
password: 'Wachtwoord',
|
||||
passwordHint: 'Stel een nieuw wachtwoord voor je account in.',
|
||||
|
|
|
@ -57,7 +57,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {onMounted, ref} from 'vue';
|
||||
import {ref} from 'vue';
|
||||
import AccountMenuItem from 'components/AccountMenuItem.vue';
|
||||
import {useAuthStore} from 'stores/auth-store';
|
||||
|
||||
|
@ -67,8 +67,4 @@ const leftDrawerOpen = ref(false);
|
|||
function toggleLeftDrawer() {
|
||||
leftDrawerOpen.value = !leftDrawerOpen.value;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await authStore.tryLogInWithStoredToken();
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -41,7 +41,7 @@ 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 {confirm, showApiErrorToast} from 'src/utils';
|
||||
import {useI18n} from 'vue-i18n';
|
||||
import {useQuasar} from 'quasar';
|
||||
|
||||
|
@ -69,11 +69,10 @@ onMounted(async () => {
|
|||
* the user back to their home page that shows all their lifts.
|
||||
*/
|
||||
async function deleteSubmission() {
|
||||
quasar.dialog({
|
||||
confirm({
|
||||
title: i18n.t('submissionPage.confirmDeletion'),
|
||||
message: i18n.t('submissionPage.confirmDeletionMsg'),
|
||||
cancel: true
|
||||
}).onOk(async () => {
|
||||
message: i18n.t('submissionPage.confirmDeletionMsg')
|
||||
}).then(async () => {
|
||||
if (!submission.value) return;
|
||||
try {
|
||||
await api.gyms.submissions.deleteSubmission(submission.value.id, authStore);
|
||||
|
|
|
@ -0,0 +1,72 @@
|
|||
<template>
|
||||
<q-page>
|
||||
<StandardCenteredPage>
|
||||
<h3>{{ $t('deleteAccountPage.title') }}</h3>
|
||||
<hr>
|
||||
<div v-if="contentVersion === 'en-US'">
|
||||
<p>
|
||||
On this page, you may choose to delete your Gymboard account. This
|
||||
action removes all Gymboard data associated with your account,
|
||||
permanently, without any possibility of recovery. Please consider
|
||||
carefully before proceeding.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="row justify-end">
|
||||
<q-btn
|
||||
:label="$t('deleteAccountPage.deleteButton')"
|
||||
color="secondary"
|
||||
@click="deleteAccount()"
|
||||
:disable="sent"
|
||||
/>
|
||||
</div>
|
||||
</StandardCenteredPage>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import StandardCenteredPage from 'components/StandardCenteredPage.vue';
|
||||
import {computed, ref} from 'vue';
|
||||
import {useI18n} from 'vue-i18n';
|
||||
import {confirm, showApiErrorToast, showSuccessToast, sleep} from 'src/utils';
|
||||
import api from 'src/api/main';
|
||||
import {useAuthStore} from 'stores/auth-store';
|
||||
import {useRouter} from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
const i18n = useI18n();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const sent = ref(false);
|
||||
|
||||
const contentVersion = computed(() => {
|
||||
if (i18n.locale.value === 'nl-NL') {
|
||||
// TODO: Add dutch translation!
|
||||
}
|
||||
return 'en-US';
|
||||
});
|
||||
|
||||
async function deleteAccount() {
|
||||
confirm({
|
||||
title: i18n.t('deleteAccountPage.confirmTitle'),
|
||||
message: i18n.t('deleteAccountPage.confirmMessage')
|
||||
}).then(async () => {
|
||||
sent.value = true;
|
||||
try {
|
||||
await api.auth.deleteAccount(authStore);
|
||||
showSuccessToast('deleteAccountPage.accountDeleted');
|
||||
await sleep(1000);
|
||||
authStore.logOut();
|
||||
await router.push('/');
|
||||
} catch (error: any) {
|
||||
showApiErrorToast(error);
|
||||
await sleep(1000);
|
||||
sent.value = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
|
@ -1,8 +1,8 @@
|
|||
<template>
|
||||
<StandardCenteredPage>
|
||||
<h3 class="text-center">{{ $t('registrationSuccessPage.title') }}</h3>
|
||||
<p>Check your email for the link to activate your account.</p>
|
||||
<p>You may safely close this page.</p>
|
||||
<p>{{ $t('registrationSuccessPage.p1') }}</p>
|
||||
<p>{{ $t('registrationSuccessPage.p2') }}</p>
|
||||
</StandardCenteredPage>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -0,0 +1,74 @@
|
|||
<template>
|
||||
<q-page>
|
||||
<StandardCenteredPage>
|
||||
<h3>{{ $t('requestAccountDataPage.title') }}</h3>
|
||||
<hr>
|
||||
<div v-if="contentVersion === 'en-US'">
|
||||
<p>
|
||||
You have the right to issue a request for the data that Gymboard keeps
|
||||
for your user account. This may include, but is not limited to, basic
|
||||
user information (username, name, preferences, settings), historical
|
||||
data from previous user information you've provided, and lifting
|
||||
submission videos and their associated metadata.
|
||||
</p>
|
||||
<p>
|
||||
Gymboard makes a best effort to provide account data in a reasonable
|
||||
timeframe, while also accounting for the increased load this places on
|
||||
our services. Therefore, it may take up to <strong>7 days</strong> for
|
||||
your account data to be ready for download after issuing a request.
|
||||
</p>
|
||||
<p>
|
||||
Account data is formatted as compressed ZIP archive containing JSON
|
||||
files, as well as media files for any media you've uploaded. You will
|
||||
receive an email with a direct link to download the account data, once
|
||||
the request has been fulfilled. This link will expire after a few
|
||||
days, after which you must issue a new request to download your data.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="row justify-end">
|
||||
<q-btn
|
||||
:label="$t('requestAccountDataPage.requestButton')"
|
||||
color="secondary"
|
||||
@click="sendRequest()"
|
||||
:disable="sent"
|
||||
/>
|
||||
</div>
|
||||
</StandardCenteredPage>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import StandardCenteredPage from 'components/StandardCenteredPage.vue';
|
||||
import {useI18n} from 'vue-i18n';
|
||||
import {computed, ref} from 'vue';
|
||||
import api from 'src/api/main';
|
||||
import {useAuthStore} from 'stores/auth-store';
|
||||
import {showApiErrorToast, showSuccessToast} from 'src/utils';
|
||||
|
||||
const i18n = useI18n();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const sent = ref(false);
|
||||
|
||||
const contentVersion = computed(() => {
|
||||
if (i18n.locale.value === 'nl-NL') {
|
||||
// TODO: Add dutch translation!
|
||||
}
|
||||
return 'en-US';
|
||||
});
|
||||
|
||||
async function sendRequest() {
|
||||
try {
|
||||
await api.auth.requestAccountData(authStore);
|
||||
showSuccessToast('requestAccountDataPage.requestSent');
|
||||
sent.value = true;
|
||||
} catch (error: any) {
|
||||
showApiErrorToast(error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
|
@ -0,0 +1,101 @@
|
|||
<template>
|
||||
<q-page>
|
||||
<StandardCenteredPage v-if="authStore.loggedIn">
|
||||
<h3>{{ $t('updateEmailPage.title') }}</h3>
|
||||
<hr>
|
||||
|
||||
<div v-if="!waitingForResetCode">
|
||||
<div class="row q-mt-md">
|
||||
<p>{{ $t('updateEmailPage.beforeUpdateInfo') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<q-input
|
||||
type="email"
|
||||
v-model="email"
|
||||
:hint="$t('updateEmailPage.inputHint')"
|
||||
class="full-width"
|
||||
:readonly="waitingForResetCode"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="row justify-end">
|
||||
<q-btn
|
||||
color="primary"
|
||||
:label="$t('updateEmailPage.updateButton')"
|
||||
:disable="!updateButtonEnabled"
|
||||
@click="requestEmailCode()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="waitingForResetCode">
|
||||
<div class="row">
|
||||
<q-input
|
||||
type="text"
|
||||
v-model="resetCode"
|
||||
:hint="$t('updateEmailPage.resetCodeInputHint')"
|
||||
class="full-width"
|
||||
@change="resetCodeChanged()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</StandardCenteredPage>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import StandardCenteredPage from "components/StandardCenteredPage.vue";
|
||||
import {useAuthStore} from "stores/auth-store";
|
||||
import {computed, onBeforeMount, onMounted, ref} from "vue";
|
||||
import {useRouter} from "vue-router";
|
||||
import api from 'src/api/main';
|
||||
import {showApiErrorToast, showSuccessToast, sleep} from "src/utils";
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const email = ref('');
|
||||
const resetCode = ref('');
|
||||
const waitingForResetCode = ref(false);
|
||||
|
||||
onBeforeMount(() => {
|
||||
if (!authStore.user) {
|
||||
router.replace('/');
|
||||
return;
|
||||
}
|
||||
email.value = authStore.user.email;
|
||||
});
|
||||
|
||||
const updateButtonEnabled = computed(() => {
|
||||
return email.value &&
|
||||
email.value.trim().length > 3 &&
|
||||
email.value.trim() !== authStore.user?.email;
|
||||
});
|
||||
|
||||
async function requestEmailCode() {
|
||||
try {
|
||||
await api.auth.generateEmailResetCode(email.value, authStore);
|
||||
waitingForResetCode.value = true;
|
||||
showSuccessToast('updateEmailPage.resetCodeSent');
|
||||
} catch (error: any) {
|
||||
showApiErrorToast(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function resetCodeChanged() {
|
||||
if (resetCode.value && resetCode.value.trim().length > 0) {
|
||||
const code = resetCode.value.trim();
|
||||
try {
|
||||
await api.auth.updateMyEmail(code, authStore);
|
||||
showSuccessToast('updateEmailPage.emailUpdated');
|
||||
await sleep(2000);
|
||||
authStore.logOut();
|
||||
await router.push('/login');
|
||||
} catch (error: any) {
|
||||
showApiErrorToast(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -3,7 +3,7 @@ The page where users can edit their personal information and preferences.
|
|||
-->
|
||||
<template>
|
||||
<q-page>
|
||||
<StandardCenteredPage v-if="authStore.loggedIn">
|
||||
<StandardCenteredPage>
|
||||
<h3>{{ $t('userSettingsPage.title') }}</h3>
|
||||
<hr>
|
||||
|
||||
|
@ -11,6 +11,11 @@ The page where users can edit their personal information and preferences.
|
|||
<span class="property-label">{{ $t('userSettingsPage.email') }}</span>
|
||||
<q-input type="email" v-model="authStore.user.email" dense readonly/>
|
||||
</div>
|
||||
<div class="row justify-end">
|
||||
<router-link to="/me/update-email" class="text-secondary">
|
||||
{{ $t('userSettingsPage.changeEmail') }}
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="row justify-between">
|
||||
<span class="property-label">{{ $t('userSettingsPage.name') }}</span>
|
||||
<q-input type="text" v-model="authStore.user.name" dense readonly/>
|
||||
|
@ -86,13 +91,22 @@ The page where users can edit their personal information and preferences.
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4>{{ $t('userSettingsPage.actions.title') }}</h4>
|
||||
<div class="row q-my-md">
|
||||
<q-btn :label="$t('userSettingsPage.actions.requestData')" color="secondary" to="/me/request-account-data"/>
|
||||
</div>
|
||||
<div class="row q-my-md">
|
||||
<q-btn :label="$t('userSettingsPage.actions.deleteAccount')" color="secondary" to="/me/delete-account"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</StandardCenteredPage>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import StandardCenteredPage from 'components/StandardCenteredPage.vue';
|
||||
import {useRoute, useRouter} from 'vue-router';
|
||||
import {useAuthStore} from 'stores/auth-store';
|
||||
import {computed, onMounted, ref, Ref, toRaw} from 'vue';
|
||||
import {UserPersonalDetails, UserPreferences} from 'src/api/main/auth';
|
||||
|
@ -103,8 +117,6 @@ import {resolveLocale, supportedLocales} from 'src/i18n';
|
|||
import {useI18n} from 'vue-i18n';
|
||||
import {showApiErrorToast, showSuccessToast, showWarningToast} from 'src/utils';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const i18n = useI18n({useScope: 'global'});
|
||||
|
||||
|
@ -117,8 +129,6 @@ let initialPreferences: UserPreferences | null = null;
|
|||
const newPassword = ref('');
|
||||
|
||||
onMounted(async () => {
|
||||
const userId = route.params.userId as string;
|
||||
if (authStore.user && authStore.user.id === userId) {
|
||||
personalDetails.value = await api.auth.getMyPersonalDetails(authStore);
|
||||
initialPersonalDetails = structuredClone(toRaw(personalDetails.value));
|
||||
|
||||
|
@ -126,10 +136,6 @@ onMounted(async () => {
|
|||
initialPreferences = structuredClone(toRaw(preferences.value));
|
||||
|
||||
newPassword.value = '';
|
||||
} else {
|
||||
// Redirect away from the page if the user isn't viewing their own settings.
|
||||
await router.replace(`/users/${userId}`);
|
||||
}
|
||||
});
|
||||
|
||||
const personalDetailsChanged = computed(() => {
|
||||
|
|
|
@ -7,6 +7,7 @@ import {
|
|||
} from 'vue-router';
|
||||
|
||||
import routes from './routes';
|
||||
import {useAuthStore} from 'stores/auth-store';
|
||||
|
||||
/*
|
||||
* If not building with SSR mode, you can
|
||||
|
@ -24,7 +25,7 @@ export default route(function (/* { store, ssrContext } */) {
|
|||
? createWebHistory
|
||||
: createWebHashHistory;
|
||||
|
||||
const Router = createRouter({
|
||||
const router = createRouter({
|
||||
scrollBehavior: () => ({ left: 0, top: 0 }),
|
||||
routes,
|
||||
|
||||
|
@ -34,5 +35,16 @@ export default route(function (/* { store, ssrContext } */) {
|
|||
history: createHistory(process.env.VUE_ROUTER_BASE),
|
||||
});
|
||||
|
||||
return Router;
|
||||
// Before navigating to any route, we add a guard that tries to log in if the
|
||||
// user has a stored authentication token. This way, if a user reloads a page
|
||||
// that can only be accessed through authentication, they'll go back to it
|
||||
// instead of being kicked out to the main page.
|
||||
router.beforeEach(async () => {
|
||||
const authStore = useAuthStore();
|
||||
if (!authStore.loggedIn) {
|
||||
await authStore.tryLogInWithStoredToken();
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
});
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { RouteRecordRaw } from 'vue-router';
|
||||
import {RouteRecordRaw} from 'vue-router';
|
||||
import MainLayout from 'layouts/MainLayout.vue';
|
||||
import GymSearchPage from 'pages/GymSearchPage.vue';
|
||||
import GymPage from 'pages/gym/GymPage.vue';
|
||||
|
@ -16,6 +16,9 @@ import UserSearchPage from 'pages/UserSearchPage.vue';
|
|||
import AdminPage from 'pages/admin/AdminPage.vue';
|
||||
import {useAuthStore} from 'stores/auth-store';
|
||||
import AboutPage from 'pages/AboutPage.vue';
|
||||
import UpdateEmailPage from "pages/auth/UpdateEmailPage.vue";
|
||||
import RequestAccountDataPage from "pages/auth/RequestAccountDataPage.vue";
|
||||
import DeleteAccountPage from "pages/auth/DeleteAccountPage.vue";
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
// Auth-related pages, which live outside the main layout.
|
||||
|
@ -31,7 +34,6 @@ const routes: RouteRecordRaw[] = [
|
|||
children: [
|
||||
{ path: '', component: GymSearchPage },
|
||||
{ path: 'users', component: UserSearchPage },
|
||||
{ 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
|
||||
|
@ -53,6 +55,21 @@ const routes: RouteRecordRaw[] = [
|
|||
}
|
||||
},
|
||||
{ path: 'about', component: AboutPage },
|
||||
|
||||
// Pages under /me are accessible only when authenticated.
|
||||
{
|
||||
path: 'me',
|
||||
beforeEnter: () => {
|
||||
const s = useAuthStore();
|
||||
if (!s.loggedIn) return '/';
|
||||
},
|
||||
children: [
|
||||
{ path: 'settings', component: UserSettingsPage },
|
||||
{ path: 'update-email', component: UpdateEmailPage },
|
||||
{ path: 'request-account-data', component: RequestAccountDataPage },
|
||||
{ path: 'delete-account', component: DeleteAccountPage }
|
||||
]
|
||||
}
|
||||
],
|
||||
},
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import {useQuasar} from 'quasar';
|
||||
import {useI18n} from 'vue-i18n';
|
||||
import {i18n} from 'boot/i18n';
|
||||
import {Notify, Dialog, QDialogOptions} from 'quasar';
|
||||
|
||||
/**
|
||||
* Sleeps for a given number of milliseconds before resolving.
|
||||
|
@ -7,11 +7,36 @@ import {useI18n} from 'vue-i18n';
|
|||
*/
|
||||
export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/**
|
||||
* Shows a confirmation dialog that returns a promise which resolves if the
|
||||
* user clicks on the affirmative button choice.
|
||||
* @param options Options to supply to the dialog, instead of defaults.
|
||||
*/
|
||||
export function confirm(options?: QDialogOptions): Promise<void> {
|
||||
const { t } = i18n.global;
|
||||
const dialogOpts: QDialogOptions = {
|
||||
title: t('confirm.title'),
|
||||
message: t('confirm.message'),
|
||||
cancel: true
|
||||
};
|
||||
if (options?.title) {
|
||||
dialogOpts.title = options.title;
|
||||
}
|
||||
if (options?.message) {
|
||||
dialogOpts.message = options.message;
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
Dialog.create(dialogOpts)
|
||||
.onOk(resolve)
|
||||
.onCancel(reject)
|
||||
.onDismiss(reject);
|
||||
});
|
||||
}
|
||||
|
||||
function showToast(type: string, messageKey: string) {
|
||||
const quasar = useQuasar();
|
||||
const i18n = useI18n();
|
||||
quasar.notify({
|
||||
message: i18n.t(messageKey),
|
||||
const { t } = i18n.global;
|
||||
Notify.create({
|
||||
message: t(messageKey),
|
||||
type: type,
|
||||
position: 'top'
|
||||
});
|
||||
|
@ -25,10 +50,10 @@ function showToast(type: string, messageKey: string) {
|
|||
* @param error The error to display.
|
||||
*/
|
||||
export function showApiErrorToast(error?: unknown) {
|
||||
showToast('danger', 'generalErrors.apiError');
|
||||
if (error) {
|
||||
console.error(error);
|
||||
}
|
||||
showToast('danger', 'generalErrors.apiError');
|
||||
}
|
||||
|
||||
export function showInfoToast(messageKey: string) {
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
],
|
||||
"copyright": "Copyright © 2023, Andrew Lalis",
|
||||
"dependencies": {
|
||||
"handy-httpd": "~>5.7.0",
|
||||
"handy-httpd": "~>6.0.0",
|
||||
"slf4d": "~>2.1.1"
|
||||
},
|
||||
"description": "Service for handling Gymboard file uploads.",
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"fileVersion": 1,
|
||||
"versions": {
|
||||
"handy-httpd": "5.7.0",
|
||||
"handy-httpd": "6.0.0",
|
||||
"httparsed": "1.2.1",
|
||||
"slf4d": "2.1.1"
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ void main() {
|
|||
ctx.response.writeBodyString("online");
|
||||
});
|
||||
pathHandler.addMapping("POST", "/uploads", new VideoUploadHandler());
|
||||
pathHandler.addMapping("POST", "/uploads/{uploadId}/process", new VideoProcessingHandler());
|
||||
|
||||
HttpServer server = new HttpServer(pathHandler, getServerConfig());
|
||||
server.start();
|
||||
|
|
|
@ -1,25 +1,75 @@
|
|||
module handlers;
|
||||
|
||||
import handy_httpd;
|
||||
import slf4d;
|
||||
import std.conv : to;
|
||||
import std.path;
|
||||
import std.file;
|
||||
import std.uuid;
|
||||
import std.json;
|
||||
import std.stdio;
|
||||
|
||||
const ulong MAX_UPLOAD_SIZE = 1024 * 1024 * 1024;
|
||||
static immutable MAX_UPLOAD_SIZE = 1024 * 1024 * 1024;
|
||||
static immutable ALLOWED_MEDIA_TYPES = ["video/mp4"];
|
||||
static immutable TEMP_UPLOADS_DIR = "temp-uploads";
|
||||
|
||||
class VideoUploadHandler : HttpRequestHandler {
|
||||
public void handle(ref HttpRequestContext ctx) {
|
||||
if ("Content-Length" !in ctx.request.headers) {
|
||||
ctx.response.status = 411;
|
||||
ctx.response.statusText = "Length Required";
|
||||
return;
|
||||
if (!validateHeaders(ctx)) return;
|
||||
|
||||
if (!exists(TEMP_UPLOADS_DIR)) mkdir(TEMP_UPLOADS_DIR);
|
||||
|
||||
UUID uploadId = sha1UUID("gymboard-uploads");
|
||||
|
||||
ctx.request.readBodyToFile(getTempFilePath(uploadId));
|
||||
JSONValue metadataObj = JSONValue(string[string].init); // Empty object.
|
||||
string originalFilename = ctx.request.getHeader("X-GYMBOARD-FILENAME");
|
||||
if (originalFilename is null) {
|
||||
originalFilename = "unnamed.mp4";
|
||||
}
|
||||
metadataObj.object["filename"] = originalFilename;
|
||||
File f = File(getTempFileMetadataPath(uploadId), "w");
|
||||
f.write(metadataObj.toPrettyString());
|
||||
f.close();
|
||||
|
||||
infoF!"Saved uploaded video file with id %s."(uploadId.toString);
|
||||
|
||||
ctx.response.setStatus(HttpStatus.CREATED);
|
||||
ctx.response.writeBodyString(uploadId.toString());
|
||||
}
|
||||
|
||||
ulong contentLength = ctx.request.headers["Content-Length"].to!ulong;
|
||||
if (contentLength == 0 || contentLength > MAX_UPLOAD_SIZE) {
|
||||
ctx.response.status = 413;
|
||||
ctx.response.statusText = "Payload Too Large";
|
||||
return;
|
||||
private bool validateHeaders(ref HttpRequestContext ctx) {
|
||||
ulong contentLength = ctx.request.getHeaderAs!ulong("Content-Length");
|
||||
if (contentLength == 0) {
|
||||
ctx.response.status = HttpStatus.LENGTH_REQUIRED;
|
||||
return false;
|
||||
} else if (contentLength > MAX_UPLOAD_SIZE) {
|
||||
ctx.response.status = HttpStatus.PAYLOAD_TOO_LARGE;
|
||||
return false;
|
||||
}
|
||||
|
||||
// TODO: Implement this!
|
||||
import std.algorithm : canFind;
|
||||
string contentType = ctx.request.getHeader("Content-Type");
|
||||
if (contentType is null || !canFind(ALLOWED_MEDIA_TYPES, contentType)) {
|
||||
ctx.response.status = HttpStatus.UNSUPPORTED_MEDIA_TYPE;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private string getTempFilePath(const ref UUID uploadId) {
|
||||
return buildPath(TEMP_UPLOADS_DIR, uploadId.toString());
|
||||
}
|
||||
|
||||
private string getTempFileMetadataPath(const ref UUID uploadId) {
|
||||
return buildPath(TEMP_UPLOADS_DIR, uploadId.toString() ~ "_meta.json");
|
||||
}
|
||||
}
|
||||
|
||||
class VideoProcessingHandler : HttpRequestHandler {
|
||||
public void handle(ref HttpRequestContext ctx) {
|
||||
string uploadIdStr = ctx.request.getPathParamAs!string("uploadId");
|
||||
infoF!"Processing upload %s"(uploadIdStr);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue