Add account deletion, data requests, and more.

This commit is contained in:
Andrew Lalis 2023-03-31 19:02:46 +02:00
parent 7a31ab5028
commit 9d1712889e
35 changed files with 681 additions and 71 deletions

View File

@ -1,10 +1,14 @@
package nl.andrewlalis.gymboard_api.domains.api.dao.submission; 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.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.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@Repository @Repository
public interface SubmissionRepository extends JpaRepository<Submission, String>, JpaSpecificationExecutor<Submission> { public interface SubmissionRepository extends JpaRepository<Submission, String>, JpaSpecificationExecutor<Submission> {
@Modifying
void deleteAllByUser(User user);
} }

View File

@ -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.Role;
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.model.UserPreferences; 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.UserAccessService;
import nl.andrewlalis.gymboard_api.domains.auth.service.UserAccountDeletionService;
import nl.andrewlalis.gymboard_api.domains.auth.service.UserService; import nl.andrewlalis.gymboard_api.domains.auth.service.UserService;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; 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.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List; import java.util.List;
@RestController @RestController
public class UserController { public class UserController {
private final UserService userService; private final UserService userService;
private final DataRequestService dataRequestService;
private final UserAccountDeletionService accountDeletionService;
private final UserAccessService userAccessService; private final UserAccessService userAccessService;
public UserController(UserService userService, UserAccessService userAccessService) { public UserController(UserService userService, DataRequestService dataRequestService, UserAccountDeletionService accountDeletionService, UserAccessService userAccessService) {
this.userService = userService; this.userService = userService;
this.dataRequestService = dataRequestService;
this.accountDeletionService = accountDeletionService;
this.userAccessService = userAccessService; this.userAccessService = userAccessService;
} }
@ -165,4 +170,16 @@ public class UserController {
public List<String> getMyRoles(@AuthenticationPrincipal User myUser) { public List<String> getMyRoles(@AuthenticationPrincipal User myUser) {
return myUser.getRoles().stream().map(Role::getShortName).toList(); 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();
}
} }

View File

@ -1,6 +1,7 @@
package nl.andrewlalis.gymboard_api.domains.auth.dao; 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.EmailResetCode;
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Modifying;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@ -11,4 +12,10 @@ import java.time.LocalDateTime;
public interface EmailResetCodeRepository extends JpaRepository<EmailResetCode, String> { public interface EmailResetCodeRepository extends JpaRepository<EmailResetCode, String> {
@Modifying @Modifying
void deleteAllByCreatedAtBefore(LocalDateTime cutoff); void deleteAllByCreatedAtBefore(LocalDateTime cutoff);
boolean existsByNewEmail(String newEmail);
@Modifying
void deleteByNewEmail(String newEmail);
@Modifying
void deleteAllByUser(User user);
} }

View File

@ -1,6 +1,7 @@
package nl.andrewlalis.gymboard_api.domains.auth.dao; 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.PasswordResetCode;
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Modifying;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@ -11,4 +12,6 @@ import java.time.LocalDateTime;
public interface PasswordResetCodeRepository extends JpaRepository<PasswordResetCode, String> { public interface PasswordResetCodeRepository extends JpaRepository<PasswordResetCode, String> {
@Modifying @Modifying
void deleteAllByCreatedAtBefore(LocalDateTime cutoff); void deleteAllByCreatedAtBefore(LocalDateTime cutoff);
@Modifying
void deleteAllByUser(User user);
} }

View File

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

View File

@ -1,5 +1,6 @@
package nl.andrewlalis.gymboard_api.domains.auth.dao; 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 nl.andrewlalis.gymboard_api.domains.auth.model.UserActivationCode;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Modifying;
@ -14,4 +15,6 @@ public interface UserActivationCodeRepository extends JpaRepository<UserActivati
@Modifying @Modifying
void deleteAllByCreatedAtBefore(LocalDateTime cutoff); void deleteAllByCreatedAtBefore(LocalDateTime cutoff);
@Modifying
void deleteAllByUser(User user);
} }

View File

@ -14,6 +14,8 @@ public interface UserFollowingRepository extends JpaRepository<UserFollowing, Lo
@Modifying @Modifying
void deleteByFollowedUserAndFollowingUser(User followedUser, User followingUser); void deleteByFollowedUserAndFollowingUser(User followedUser, User followingUser);
@Modifying
void deleteAllByFollowedUserOrFollowingUser(User followedUser, User followingUser);
Page<UserFollowing> findAllByFollowedUserOrderByCreatedAtDesc(User followedUser, Pageable pageable); Page<UserFollowing> findAllByFollowedUserOrderByCreatedAtDesc(User followedUser, Pageable pageable);
Page<UserFollowing> findAllByFollowingUserOrderByCreatedAtDesc(User followingUser, Pageable pageable); Page<UserFollowing> findAllByFollowingUserOrderByCreatedAtDesc(User followingUser, Pageable pageable);

View File

@ -1,9 +1,13 @@
package nl.andrewlalis.gymboard_api.domains.auth.dao; 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 nl.andrewlalis.gymboard_api.domains.auth.model.UserReport;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@Repository @Repository
public interface UserReportRepository extends JpaRepository<UserReport, Long> { public interface UserReportRepository extends JpaRepository<UserReport, Long> {
@Modifying
void deleteAllByUserOrReportedBy(User user, User reportedBy);
} }

View File

@ -1,11 +1,14 @@
package nl.andrewlalis.gymboard_api.domains.auth.dao; 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.User;
import org.springframework.data.domain.Page;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.Optional; import java.util.Optional;
@Repository @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") @Query("SELECT u FROM User u LEFT JOIN FETCH u.roles WHERE u.email = :email")
Optional<User> findByEmailWithRoles(String email); Optional<User> findByEmailWithRoles(String email);
@Modifying
void deleteAllByActivatedFalseAndCreatedAtBefore(LocalDateTime cutoff);
} }

View File

@ -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 * 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. * 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 @Entity
public class EmailResetCode { public class EmailResetCode {
public static final Duration VALID_FOR = Duration.ofMinutes(30); public static final Duration VALID_FOR = Duration.ofMinutes(30);

View File

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

View File

@ -1,9 +1,6 @@
package nl.andrewlalis.gymboard_api.domains.auth.service; 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.*;
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.model.EmailResetCode; 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.PasswordResetCode;
import nl.andrewlalis.gymboard_api.domains.auth.model.UserActivationCode; import nl.andrewlalis.gymboard_api.domains.auth.model.UserActivationCode;
@ -21,12 +18,20 @@ public class CleanupService {
private final UserActivationCodeRepository activationCodeRepository; private final UserActivationCodeRepository activationCodeRepository;
private final UserFollowRequestRepository followRequestRepository; private final UserFollowRequestRepository followRequestRepository;
private final EmailResetCodeRepository emailResetCodeRepository; 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.passwordResetCodeRepository = passwordResetCodeRepository;
this.activationCodeRepository = activationCodeRepository; this.activationCodeRepository = activationCodeRepository;
this.followRequestRepository = followRequestRepository; this.followRequestRepository = followRequestRepository;
this.emailResetCodeRepository = emailResetCodeRepository; this.emailResetCodeRepository = emailResetCodeRepository;
this.userRepository = userRepository;
} }
/** /**
@ -44,5 +49,7 @@ public class CleanupService {
followRequestRepository.deleteAllByCreatedAtBefore(followRequestCutoff); followRequestRepository.deleteAllByCreatedAtBefore(followRequestCutoff);
LocalDateTime emailResetCodeCutoff = LocalDateTime.now().minus(EmailResetCode.VALID_FOR); LocalDateTime emailResetCodeCutoff = LocalDateTime.now().minus(EmailResetCode.VALID_FOR);
emailResetCodeRepository.deleteAllByCreatedAtBefore(emailResetCodeCutoff); emailResetCodeRepository.deleteAllByCreatedAtBefore(emailResetCodeCutoff);
LocalDateTime inactiveUserCutoff = LocalDateTime.now().minusDays(7);
userRepository.deleteAllByActivatedFalseAndCreatedAtBefore(inactiveUserCutoff);
} }
} }

View File

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

View File

@ -17,6 +17,7 @@ import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.nio.file.Files; import java.nio.file.Files;
@ -29,6 +30,10 @@ import java.time.temporal.ChronoUnit;
import java.util.Date; import java.util.Date;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/**
* This service is responsible for generating, verifying, and generally managing
* authentication tokens.
*/
@Service @Service
public class TokenService { public class TokenService {
private static final Logger log = LoggerFactory.getLogger(TokenService.class); private static final Logger log = LoggerFactory.getLogger(TokenService.class);
@ -47,7 +52,12 @@ public class TokenService {
this.passwordEncoder = passwordEncoder; 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); Instant expiration = Instant.now().plus(30, ChronoUnit.MINUTES);
return Jwts.builder() return Jwts.builder()
.setSubject(user.getId()) .setSubject(user.getId())
@ -63,6 +73,12 @@ public class TokenService {
.compact(); .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) { public TokenResponse generateAccessToken(TokenCredentials credentials) {
User user = userRepository.findByEmailWithRoles(credentials.email()) User user = userRepository.findByEmailWithRoles(credentials.email())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED)); .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED));

View File

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

View File

@ -236,6 +236,8 @@ public class UserService {
if (userRepository.existsByEmail(payload.newEmail())) { if (userRepository.existsByEmail(payload.newEmail())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Email is taken."); 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( EmailResetCode emailResetCode = emailResetCodeRepository.save(new EmailResetCode(
StringGenerator.randomString(127, StringGenerator.Alphabet.ALPHANUMERIC), StringGenerator.randomString(127, StringGenerator.Alphabet.ALPHANUMERIC),
payload.newEmail(), payload.newEmail(),

View File

@ -263,6 +263,14 @@ class AuthModule {
const response = await api.get('/auth/me/roles', authStore.axiosConfig); const response = await api.get('/auth/me/roles', authStore.axiosConfig);
return response.data; 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; export default AuthModule;

View File

@ -21,13 +21,13 @@ declare module 'vue-i18n' {
} }
/* eslint-enable @typescript-eslint/no-empty-interface */ /* eslint-enable @typescript-eslint/no-empty-interface */
export default boot(({ app }) => { export const i18n = createI18n({
const i18n = createI18n({ locale: 'en-US',
locale: 'en-US', legacy: false,
legacy: false, messages,
messages, });
});
export default boot(({ app }) => {
// Set the locale to the preferred locale, if possible. // Set the locale to the preferred locale, if possible.
const userLocale = window.navigator.language; const userLocale = window.navigator.language;
if (userLocale === 'nl-NL') { if (userLocale === 'nl-NL') {

View File

@ -17,7 +17,7 @@ account-related actions.
<q-item-label>{{ $t('accountMenuItem.profile') }}</q-item-label> <q-item-label>{{ $t('accountMenuItem.profile') }}</q-item-label>
</q-item-section> </q-item-section>
</q-item> </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-section>
<q-item-label>{{ $t('accountMenuItem.settings') }}</q-item-label> <q-item-label>{{ $t('accountMenuItem.settings') }}</q-item-label>
</q-item-section> </q-item-section>

View File

@ -18,6 +18,11 @@ export default {
register: 'Register', register: 'Register',
error: 'An error occurred.', 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: { loginPage: {
title: 'Login to Gymboard', title: 'Login to Gymboard',
email: 'Email', email: 'Email',
@ -67,6 +72,7 @@ export default {
userSettingsPage: { userSettingsPage: {
title: 'Account Settings', title: 'Account Settings',
email: 'Email', email: 'Email',
changeEmail: 'Change your email address',
name: 'Name', name: 'Name',
password: 'Password', password: 'Password',
passwordHint: 'Set a new password for your account.', passwordHint: 'Set a new password for your account.',
@ -89,7 +95,33 @@ export default {
language: 'Language' language: 'Language'
}, },
save: 'Save', 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: { submissionPage: {
confirmDeletion: 'Confirm Deletion', confirmDeletion: 'Confirm Deletion',
@ -108,5 +140,9 @@ export default {
weightUnit: { weightUnit: {
kilograms: 'Kilograms', kilograms: 'Kilograms',
pounds: 'Pounds' pounds: 'Pounds'
},
confirm: {
title: 'Confirm',
message: 'Are you sure you want to continue?'
} }
}; };

View File

@ -62,6 +62,7 @@ export default {
userSettingsPage: { userSettingsPage: {
title: 'Account instellingen', title: 'Account instellingen',
email: 'E-mail', email: 'E-mail',
changeEmail: 'E-mail adres wijzigen',
name: 'Naam', name: 'Naam',
password: 'Wachtwoord', password: 'Wachtwoord',
passwordHint: 'Stel een nieuw wachtwoord voor je account in.', passwordHint: 'Stel een nieuw wachtwoord voor je account in.',

View File

@ -57,7 +57,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {onMounted, ref} from 'vue'; import {ref} from 'vue';
import AccountMenuItem from 'components/AccountMenuItem.vue'; import AccountMenuItem from 'components/AccountMenuItem.vue';
import {useAuthStore} from 'stores/auth-store'; import {useAuthStore} from 'stores/auth-store';
@ -67,8 +67,4 @@ const leftDrawerOpen = ref(false);
function toggleLeftDrawer() { function toggleLeftDrawer() {
leftDrawerOpen.value = !leftDrawerOpen.value; leftDrawerOpen.value = !leftDrawerOpen.value;
} }
onMounted(async () => {
await authStore.tryLogInWithStoredToken();
});
</script> </script>

View File

@ -41,7 +41,7 @@ import { DateTime } from 'luxon';
import { getFileUrl } from 'src/api/cdn'; import { getFileUrl } from 'src/api/cdn';
import { getGymRoute } from 'src/router/gym-routing'; import { getGymRoute } from 'src/router/gym-routing';
import {useAuthStore} from 'stores/auth-store'; import {useAuthStore} from 'stores/auth-store';
import {showApiErrorToast} from 'src/utils'; import {confirm, showApiErrorToast} from 'src/utils';
import {useI18n} from 'vue-i18n'; import {useI18n} from 'vue-i18n';
import {useQuasar} from 'quasar'; import {useQuasar} from 'quasar';
@ -69,11 +69,10 @@ onMounted(async () => {
* the user back to their home page that shows all their lifts. * the user back to their home page that shows all their lifts.
*/ */
async function deleteSubmission() { async function deleteSubmission() {
quasar.dialog({ confirm({
title: i18n.t('submissionPage.confirmDeletion'), title: i18n.t('submissionPage.confirmDeletion'),
message: i18n.t('submissionPage.confirmDeletionMsg'), message: i18n.t('submissionPage.confirmDeletionMsg')
cancel: true }).then(async () => {
}).onOk(async () => {
if (!submission.value) return; if (!submission.value) return;
try { try {
await api.gyms.submissions.deleteSubmission(submission.value.id, authStore); await api.gyms.submissions.deleteSubmission(submission.value.id, authStore);

View File

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

View File

@ -1,8 +1,8 @@
<template> <template>
<StandardCenteredPage> <StandardCenteredPage>
<h3 class="text-center">{{ $t('registrationSuccessPage.title') }}</h3> <h3 class="text-center">{{ $t('registrationSuccessPage.title') }}</h3>
<p>Check your email for the link to activate your account.</p> <p>{{ $t('registrationSuccessPage.p1') }}</p>
<p>You may safely close this page.</p> <p>{{ $t('registrationSuccessPage.p2') }}</p>
</StandardCenteredPage> </StandardCenteredPage>
</template> </template>

View File

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

View File

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

View File

@ -3,7 +3,7 @@ The page where users can edit their personal information and preferences.
--> -->
<template> <template>
<q-page> <q-page>
<StandardCenteredPage v-if="authStore.loggedIn"> <StandardCenteredPage>
<h3>{{ $t('userSettingsPage.title') }}</h3> <h3>{{ $t('userSettingsPage.title') }}</h3>
<hr> <hr>
@ -11,6 +11,11 @@ The page where users can edit their personal information and preferences.
<span class="property-label">{{ $t('userSettingsPage.email') }}</span> <span class="property-label">{{ $t('userSettingsPage.email') }}</span>
<q-input type="email" v-model="authStore.user.email" dense readonly/> <q-input type="email" v-model="authStore.user.email" dense readonly/>
</div> </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"> <div class="row justify-between">
<span class="property-label">{{ $t('userSettingsPage.name') }}</span> <span class="property-label">{{ $t('userSettingsPage.name') }}</span>
<q-input type="text" v-model="authStore.user.name" dense readonly/> <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>
<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> </StandardCenteredPage>
</q-page> </q-page>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import StandardCenteredPage from 'components/StandardCenteredPage.vue'; import StandardCenteredPage from 'components/StandardCenteredPage.vue';
import {useRoute, useRouter} from 'vue-router';
import {useAuthStore} from 'stores/auth-store'; import {useAuthStore} from 'stores/auth-store';
import {computed, onMounted, ref, Ref, toRaw} from 'vue'; import {computed, onMounted, ref, Ref, toRaw} from 'vue';
import {UserPersonalDetails, UserPreferences} from 'src/api/main/auth'; import {UserPersonalDetails, UserPreferences} from 'src/api/main/auth';
@ -103,8 +117,6 @@ import {resolveLocale, supportedLocales} from 'src/i18n';
import {useI18n} from 'vue-i18n'; import {useI18n} from 'vue-i18n';
import {showApiErrorToast, showSuccessToast, showWarningToast} from 'src/utils'; import {showApiErrorToast, showSuccessToast, showWarningToast} from 'src/utils';
const route = useRoute();
const router = useRouter();
const authStore = useAuthStore(); const authStore = useAuthStore();
const i18n = useI18n({useScope: 'global'}); const i18n = useI18n({useScope: 'global'});
@ -117,19 +129,13 @@ let initialPreferences: UserPreferences | null = null;
const newPassword = ref(''); const newPassword = ref('');
onMounted(async () => { onMounted(async () => {
const userId = route.params.userId as string; personalDetails.value = await api.auth.getMyPersonalDetails(authStore);
if (authStore.user && authStore.user.id === userId) { initialPersonalDetails = structuredClone(toRaw(personalDetails.value));
personalDetails.value = await api.auth.getMyPersonalDetails(authStore);
initialPersonalDetails = structuredClone(toRaw(personalDetails.value));
preferences.value = await api.auth.getMyPreferences(authStore); preferences.value = await api.auth.getMyPreferences(authStore);
initialPreferences = structuredClone(toRaw(preferences.value)); initialPreferences = structuredClone(toRaw(preferences.value));
newPassword.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(() => { const personalDetailsChanged = computed(() => {

View File

@ -7,6 +7,7 @@ import {
} from 'vue-router'; } from 'vue-router';
import routes from './routes'; import routes from './routes';
import {useAuthStore} from 'stores/auth-store';
/* /*
* If not building with SSR mode, you can * If not building with SSR mode, you can
@ -24,7 +25,7 @@ export default route(function (/* { store, ssrContext } */) {
? createWebHistory ? createWebHistory
: createWebHashHistory; : createWebHashHistory;
const Router = createRouter({ const router = createRouter({
scrollBehavior: () => ({ left: 0, top: 0 }), scrollBehavior: () => ({ left: 0, top: 0 }),
routes, routes,
@ -34,5 +35,16 @@ export default route(function (/* { store, ssrContext } */) {
history: createHistory(process.env.VUE_ROUTER_BASE), 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;
}); });

View File

@ -1,4 +1,4 @@
import { RouteRecordRaw } from 'vue-router'; import {RouteRecordRaw} from 'vue-router';
import MainLayout from 'layouts/MainLayout.vue'; import MainLayout from 'layouts/MainLayout.vue';
import GymSearchPage from 'pages/GymSearchPage.vue'; import GymSearchPage from 'pages/GymSearchPage.vue';
import GymPage from 'pages/gym/GymPage.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 AdminPage from 'pages/admin/AdminPage.vue';
import {useAuthStore} from 'stores/auth-store'; import {useAuthStore} from 'stores/auth-store';
import AboutPage from 'pages/AboutPage.vue'; 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[] = [ const routes: RouteRecordRaw[] = [
// Auth-related pages, which live outside the main layout. // Auth-related pages, which live outside the main layout.
@ -31,7 +34,6 @@ const routes: RouteRecordRaw[] = [
children: [ children: [
{ path: '', component: GymSearchPage }, { path: '', component: GymSearchPage },
{ path: 'users', component: UserSearchPage }, { path: 'users', component: UserSearchPage },
{ path: 'users/:userId/settings', component: UserSettingsPage },
{ // Match anything under /users/:userId to the UserPage, since it manages sub-pages manually. { // Match anything under /users/:userId to the UserPage, since it manages sub-pages manually.
path: 'users/:userId+', path: 'users/:userId+',
component: UserPage component: UserPage
@ -53,6 +55,21 @@ const routes: RouteRecordRaw[] = [
} }
}, },
{ path: 'about', component: AboutPage }, { 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 }
]
}
], ],
}, },

View File

@ -1,5 +1,5 @@
import {useQuasar} from 'quasar'; import {i18n} from 'boot/i18n';
import {useI18n} from 'vue-i18n'; import {Notify, Dialog, QDialogOptions} from 'quasar';
/** /**
* Sleeps for a given number of milliseconds before resolving. * 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)); 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) { function showToast(type: string, messageKey: string) {
const quasar = useQuasar(); const { t } = i18n.global;
const i18n = useI18n(); Notify.create({
quasar.notify({ message: t(messageKey),
message: i18n.t(messageKey),
type: type, type: type,
position: 'top' position: 'top'
}); });
@ -25,10 +50,10 @@ function showToast(type: string, messageKey: string) {
* @param error The error to display. * @param error The error to display.
*/ */
export function showApiErrorToast(error?: unknown) { export function showApiErrorToast(error?: unknown) {
showToast('danger', 'generalErrors.apiError');
if (error) { if (error) {
console.error(error); console.error(error);
} }
showToast('danger', 'generalErrors.apiError');
} }
export function showInfoToast(messageKey: string) { export function showInfoToast(messageKey: string) {

View File

@ -4,7 +4,7 @@
], ],
"copyright": "Copyright © 2023, Andrew Lalis", "copyright": "Copyright © 2023, Andrew Lalis",
"dependencies": { "dependencies": {
"handy-httpd": "~>5.7.0", "handy-httpd": "~>6.0.0",
"slf4d": "~>2.1.1" "slf4d": "~>2.1.1"
}, },
"description": "Service for handling Gymboard file uploads.", "description": "Service for handling Gymboard file uploads.",

View File

@ -1,7 +1,7 @@
{ {
"fileVersion": 1, "fileVersion": 1,
"versions": { "versions": {
"handy-httpd": "5.7.0", "handy-httpd": "6.0.0",
"httparsed": "1.2.1", "httparsed": "1.2.1",
"slf4d": "2.1.1" "slf4d": "2.1.1"
} }

View File

@ -10,6 +10,7 @@ void main() {
ctx.response.writeBodyString("online"); ctx.response.writeBodyString("online");
}); });
pathHandler.addMapping("POST", "/uploads", new VideoUploadHandler()); pathHandler.addMapping("POST", "/uploads", new VideoUploadHandler());
pathHandler.addMapping("POST", "/uploads/{uploadId}/process", new VideoProcessingHandler());
HttpServer server = new HttpServer(pathHandler, getServerConfig()); HttpServer server = new HttpServer(pathHandler, getServerConfig());
server.start(); server.start();

View File

@ -1,25 +1,75 @@
module handlers; module handlers;
import handy_httpd; import handy_httpd;
import slf4d;
import std.conv : to; 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 { class VideoUploadHandler : HttpRequestHandler {
public void handle(ref HttpRequestContext ctx) { public void handle(ref HttpRequestContext ctx) {
if ("Content-Length" !in ctx.request.headers) { if (!validateHeaders(ctx)) return;
ctx.response.status = 411;
ctx.response.statusText = "Length Required";
return;
}
ulong contentLength = ctx.request.headers["Content-Length"].to!ulong; if (!exists(TEMP_UPLOADS_DIR)) mkdir(TEMP_UPLOADS_DIR);
if (contentLength == 0 || contentLength > MAX_UPLOAD_SIZE) {
ctx.response.status = 413;
ctx.response.statusText = "Payload Too Large";
return;
}
// TODO: Implement this! 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());
} }
}
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;
}
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);
}
}