From ac11de2abb9b95d294621bf15cd20112866c1200 Mon Sep 17 00:00:00 2001 From: vladp Date: Tue, 17 Feb 2026 20:17:34 +0700 Subject: [PATCH] dev add reset password --- .../nocopy/configuration/HandlerConfig.java | 2 + .../java/ru/soune/nocopy/dto/MessageCode.java | 3 +- .../dto/register/ResetPasswordRequest.java | 24 +++ .../dto/register/ResetPasswordResponse.java | 13 ++ .../ru/soune/nocopy/entity/user/User.java | 10 + .../nocopy/entity/user/UserNotActive.java | 32 +++ .../nocopy/handler/ResetPasswordHandler.java | 112 ++++++++++ .../handler/VerifyRegisterUserHandler.java | 5 +- .../repository/AuthTokenRepository.java | 2 +- .../repository/UserNotActiveRepository.java | 16 ++ .../service/auth/CleanUpNotActiveUser.java | 48 +++++ .../nocopy/service/mail/EmailService.java | 32 ++- .../nocopy/service/register/AuthService.java | 47 +++- src/main/resources/application.yaml | 1 + .../resources/templates/reset-password.html | 201 ++++++++++++++++++ 15 files changed, 534 insertions(+), 14 deletions(-) create mode 100644 src/main/java/ru/soune/nocopy/dto/register/ResetPasswordRequest.java create mode 100644 src/main/java/ru/soune/nocopy/dto/register/ResetPasswordResponse.java create mode 100644 src/main/java/ru/soune/nocopy/entity/user/UserNotActive.java create mode 100644 src/main/java/ru/soune/nocopy/handler/ResetPasswordHandler.java create mode 100644 src/main/java/ru/soune/nocopy/repository/UserNotActiveRepository.java create mode 100644 src/main/java/ru/soune/nocopy/service/auth/CleanUpNotActiveUser.java create mode 100644 src/main/resources/templates/reset-password.html diff --git a/src/main/java/ru/soune/nocopy/configuration/HandlerConfig.java b/src/main/java/ru/soune/nocopy/configuration/HandlerConfig.java index f4465ef..87e703b 100644 --- a/src/main/java/ru/soune/nocopy/configuration/HandlerConfig.java +++ b/src/main/java/ru/soune/nocopy/configuration/HandlerConfig.java @@ -24,6 +24,7 @@ public class HandlerConfig { TariffHandler tariffHandler, TariffInfoHandler tariffInfoHandler, ReferralHandler referralHandler, + ResetPasswordHandler resetPasswordHandler, DaDataHandler daDataHandler ) { Map map = new HashMap<>(); @@ -35,6 +36,7 @@ public class HandlerConfig { map.put(20007, imageFoundRequestHandler); map.put(20008, authRequestHandler); map.put(20009, verifyRegisterUser); + map.put(20010, resetPasswordHandler); map.put(30000, companyHandler); map.put(30001, tariffHandler); map.put(30002, tariffInfoHandler); diff --git a/src/main/java/ru/soune/nocopy/dto/MessageCode.java b/src/main/java/ru/soune/nocopy/dto/MessageCode.java index d6a1379..1a53d2d 100644 --- a/src/main/java/ru/soune/nocopy/dto/MessageCode.java +++ b/src/main/java/ru/soune/nocopy/dto/MessageCode.java @@ -44,7 +44,8 @@ public enum MessageCode { COMPANY_NOT_FOUND(4, "Company not found"), COMPANY_ALREADY_EXISTS(2, "Company already exists"), USER_LIMIT_IS_OVER(2, "Over user limits"), - ERROR_TARIFF_INFO(2, "Erorr with tariff info"); + ERROR_TARIFF_INFO(2, "Erorr with tariff info"), + USER_NOT_ACTIVE(2, "User not active"); private final Integer code; diff --git a/src/main/java/ru/soune/nocopy/dto/register/ResetPasswordRequest.java b/src/main/java/ru/soune/nocopy/dto/register/ResetPasswordRequest.java new file mode 100644 index 0000000..60ab799 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/register/ResetPasswordRequest.java @@ -0,0 +1,24 @@ +package ru.soune.nocopy.dto.register; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class ResetPasswordRequest { + + @JsonProperty("email") + private String email; + + @JsonProperty("verifyToken") + private String verifyToken; + + @JsonProperty("action") + private String action; + + @JsonProperty("password") + private String password; +} \ No newline at end of file diff --git a/src/main/java/ru/soune/nocopy/dto/register/ResetPasswordResponse.java b/src/main/java/ru/soune/nocopy/dto/register/ResetPasswordResponse.java new file mode 100644 index 0000000..b3d7122 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/register/ResetPasswordResponse.java @@ -0,0 +1,13 @@ +package ru.soune.nocopy.dto.register; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class ResetPasswordResponse { + @JsonProperty("user_id") + private Long userId; + + @JsonProperty("authToken") + private String authToken; +} diff --git a/src/main/java/ru/soune/nocopy/entity/user/User.java b/src/main/java/ru/soune/nocopy/entity/user/User.java index b3bd024..cd8febd 100644 --- a/src/main/java/ru/soune/nocopy/entity/user/User.java +++ b/src/main/java/ru/soune/nocopy/entity/user/User.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import jakarta.persistence.*; import jakarta.validation.constraints.Size; import lombok.*; +import org.hibernate.annotations.ColumnDefault; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; import ru.soune.nocopy.entity.company.Company; @@ -102,6 +103,15 @@ public class User { @ToString.Exclude private TariffInfo personalTariffInfo; + @Column(name = "failed_login_attempts") + @ColumnDefault("0") + private Integer failedLoginAttempts = 0; + + @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + @JsonIgnore + @ToString.Exclude + private UserNotActive userNotActive; + public TariffInfo getActiveTariffInfo() { if (company != null && company.getTariffInfo() != null) { return company.getTariffInfo(); diff --git a/src/main/java/ru/soune/nocopy/entity/user/UserNotActive.java b/src/main/java/ru/soune/nocopy/entity/user/UserNotActive.java new file mode 100644 index 0000000..e586ade --- /dev/null +++ b/src/main/java/ru/soune/nocopy/entity/user/UserNotActive.java @@ -0,0 +1,32 @@ +package ru.soune.nocopy.entity.user; + +import jakarta.persistence.*; +import lombok.*; +import org.springframework.data.annotation.CreatedDate; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "user_not_active") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@ToString +@Builder +public class UserNotActive { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + private User user; + + @CreatedDate + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @Column(name = "blocked_until") + private LocalDateTime blockedUntil; +} diff --git a/src/main/java/ru/soune/nocopy/handler/ResetPasswordHandler.java b/src/main/java/ru/soune/nocopy/handler/ResetPasswordHandler.java new file mode 100644 index 0000000..717416b --- /dev/null +++ b/src/main/java/ru/soune/nocopy/handler/ResetPasswordHandler.java @@ -0,0 +1,112 @@ +package ru.soune.nocopy.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import ru.soune.nocopy.dto.BaseRequest; +import ru.soune.nocopy.dto.BaseResponse; +import ru.soune.nocopy.dto.MessageCode; +import ru.soune.nocopy.dto.file.ActionResponse; +import ru.soune.nocopy.dto.register.ResetPasswordRequest; +import ru.soune.nocopy.dto.register.ResetPasswordResponse; +import ru.soune.nocopy.entity.user.AuthToken; +import ru.soune.nocopy.entity.user.EmailVerificationToken; +import ru.soune.nocopy.entity.user.User; +import ru.soune.nocopy.exception.NotValidFieldException; +import ru.soune.nocopy.exception.UserNotFoundException; +import ru.soune.nocopy.repository.AuthTokenRepository; +import ru.soune.nocopy.repository.EmailVerificationTokenRepository; +import ru.soune.nocopy.repository.UserRepository; +import ru.soune.nocopy.service.mail.EmailService; +import ru.soune.nocopy.service.register.AuthService; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@Component +@Slf4j +@RequiredArgsConstructor +public class ResetPasswordHandler implements RequestHandler{ + + private final ObjectMapper objectMapper; + + private final EmailService emailService; + + private final AuthService authService; + + private final UserRepository userRepository; + + private final EmailVerificationTokenRepository emailVerificationTokenRepository; + + private final AuthTokenRepository authTokenRepository; + + private final PasswordEncoder passwordEncoder; + + @Override + public BaseResponse handle(BaseRequest request) throws Exception { + ResetPasswordRequest resetPasswordRequest = objectMapper.convertValue(request.getMessageBody(), + ResetPasswordRequest.class); + String action = resetPasswordRequest.getAction(); + User user = userRepository.findByEmail(resetPasswordRequest.getEmail()); + AuthToken authToken; + + if (user == null) { + throw new UserNotFoundException("User not found with email: " + resetPasswordRequest.getEmail()); + } + + switch (action) { + case "resetPassword": + authToken = authService.generateAuthToken(user); + + EmailVerificationToken emailToken = emailService.createEmailVerificationToken(authToken); + + emailService.sendResetPasswordEmail(authToken.getUser(), emailToken.getToken()); + + return successResponse(request, authToken, user, MessageCode.SUCCESS.getDescription()); + case "confirmVerification": + String verifyToken = resetPasswordRequest.getVerifyToken(); + + EmailVerificationToken verificationToken = + emailVerificationTokenRepository.findByUserIdAndToken(user.getId(), verifyToken); + + if (verificationToken == null) { + throw new NotValidFieldException("Verification token not found", + new BaseResponse(request.getMsgId(), MessageCode.INVALID_TOKEN.getCode(), + MessageCode.INVALID_TOKEN.getDescription(), Map.of("verifyToken", verifyToken))); + } + List authTokens = authTokenRepository.findByUserId(user.getId()); + + authToken = authTokens.isEmpty() ? authService.generateAuthToken(user): authTokens.getFirst(); + + user.setPassword(passwordEncoder.encode(resetPasswordRequest.getPassword())); + user.setActive(true); + + emailVerificationTokenRepository.delete(verificationToken); + + User saveUser = userRepository.save(user); + + return successResponse(request, authToken, saveUser, MessageCode.SUCCESS.getDescription()); + default: + ActionResponse response = ActionResponse.builder() + .action(action) + .availableActions(Arrays.asList("confirmVerification", "resetPassword")) + .build(); + + return new BaseResponse(request.getMsgId(), MessageCode.INVALID_ACTION.getCode(), + "Invalid action: " + action, response); + + } + } + + private BaseResponse successResponse(BaseRequest request, AuthToken authToken, User user, String message) { + ResetPasswordResponse resetPasswordResponse = new ResetPasswordResponse(); + resetPasswordResponse.setAuthToken(authToken.getToken()); + resetPasswordResponse.setUserId(user.getId()); + + return new BaseResponse(request.getMsgId(), MessageCode.SUCCESS.getCode(), message, resetPasswordResponse); + } +} \ No newline at end of file diff --git a/src/main/java/ru/soune/nocopy/handler/VerifyRegisterUserHandler.java b/src/main/java/ru/soune/nocopy/handler/VerifyRegisterUserHandler.java index 373b953..35efc32 100644 --- a/src/main/java/ru/soune/nocopy/handler/VerifyRegisterUserHandler.java +++ b/src/main/java/ru/soune/nocopy/handler/VerifyRegisterUserHandler.java @@ -23,6 +23,7 @@ import ru.soune.nocopy.service.register.AuthService; import ru.soune.nocopy.service.user.UserService; import java.io.IOException; +import java.util.List; import java.util.Map; import java.util.Optional; @@ -53,9 +54,9 @@ public class VerifyRegisterUserHandler implements RequestHandler { User user = userRepository.findById(userId) .orElseThrow(() -> new UserNotFoundException("User not found with id: " + userId)); - Optional userAuthTokens = authTokenRepository.findByUserId(userId); + List userAuthTokens = authTokenRepository.findByUserId(userId); - AuthToken authToken = userAuthTokens.isEmpty() ? authService.generateAuthToken(user): userAuthTokens.get(); + AuthToken authToken = userAuthTokens.isEmpty() ? authService.generateAuthToken(user): userAuthTokens.getFirst(); if (verifyUserRequest.getResend() != null && verifyUserRequest.getResend() == 1) { return handleResend(request, user, authToken); diff --git a/src/main/java/ru/soune/nocopy/repository/AuthTokenRepository.java b/src/main/java/ru/soune/nocopy/repository/AuthTokenRepository.java index c1b65e2..48efb76 100644 --- a/src/main/java/ru/soune/nocopy/repository/AuthTokenRepository.java +++ b/src/main/java/ru/soune/nocopy/repository/AuthTokenRepository.java @@ -20,5 +20,5 @@ public interface AuthTokenRepository extends JpaRepository { nativeQuery = true) Long findUserIdByToken(String token); Optional findByUserIdAndIsActive(Long userId, boolean isActive); - Optional findByUserId(Long userId); + List findByUserId(Long userId); } diff --git a/src/main/java/ru/soune/nocopy/repository/UserNotActiveRepository.java b/src/main/java/ru/soune/nocopy/repository/UserNotActiveRepository.java new file mode 100644 index 0000000..e3fad73 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/repository/UserNotActiveRepository.java @@ -0,0 +1,16 @@ +package ru.soune.nocopy.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import ru.soune.nocopy.entity.user.UserNotActive; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +public interface UserNotActiveRepository extends JpaRepository { + Optional findByUserId(Long userId); + List findByBlockedUntilBefore(LocalDateTime now); + +} diff --git a/src/main/java/ru/soune/nocopy/service/auth/CleanUpNotActiveUser.java b/src/main/java/ru/soune/nocopy/service/auth/CleanUpNotActiveUser.java new file mode 100644 index 0000000..6ea818e --- /dev/null +++ b/src/main/java/ru/soune/nocopy/service/auth/CleanUpNotActiveUser.java @@ -0,0 +1,48 @@ +package ru.soune.nocopy.service.auth; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import ru.soune.nocopy.entity.user.User; +import ru.soune.nocopy.entity.user.UserNotActive; +import ru.soune.nocopy.repository.UserNotActiveRepository; +import ru.soune.nocopy.repository.UserRepository; + +import java.time.LocalDateTime; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CleanUpNotActiveUser { + + @Autowired + private UserNotActiveRepository userNotActiveRepository; + + @Autowired + private UserRepository userRepository; + + @Transactional + @Scheduled(fixedDelay = 600000) + public void cleanupNotActiveUsers() { + LocalDateTime now = LocalDateTime.now(); + + List expiredTokens = userNotActiveRepository.findByBlockedUntilBefore(now); + + for (UserNotActive userNotActive : expiredTokens) { + User user = userNotActive.getUser(); + user.setActive(true); + user.setFailedLoginAttempts(0); + + userRepository.save(user); + } + + if (!expiredTokens.isEmpty()) { + userNotActiveRepository.deleteAll(expiredTokens); + + } + } +} \ No newline at end of file diff --git a/src/main/java/ru/soune/nocopy/service/mail/EmailService.java b/src/main/java/ru/soune/nocopy/service/mail/EmailService.java index 5544bf2..68fef77 100644 --- a/src/main/java/ru/soune/nocopy/service/mail/EmailService.java +++ b/src/main/java/ru/soune/nocopy/service/mail/EmailService.java @@ -38,6 +38,9 @@ public class EmailService { @Value("${app.email.verification.user}") private String user; + @Value("${app.email.verification.drop-password-subject}") + private String dropPasswordSubject; + public void sendVerificationEmail(User user, String code) throws MessagingException, IOException { MimeMessage message = mailSender.createMimeMessage(); MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); @@ -45,8 +48,8 @@ public class EmailService { helper.setFrom("noreply@no-copy.ru"); helper.setTo(user.getEmail()); helper.setSubject(standartSubject); - - String htmlContent = loadAndProcessTemplate(user.getFullName(), code); + String htmlContent = loadAndProcessTemplate(user.getFullName(), code, + "templates/email-verification.html"); helper.setText(htmlContent, true); mailSender.send(message); @@ -69,8 +72,8 @@ public class EmailService { return String.valueOf(code); } - private String loadAndProcessTemplate(String username, String code) throws IOException { - ClassPathResource resource = new ClassPathResource("templates/email-verification.html"); + private String loadAndProcessTemplate(String username, String code, String templatePath) throws IOException { + ClassPathResource resource = new ClassPathResource(templatePath); String template = new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8); String safeUsername = escapeHtml(username != null ? username : user); String safeCode = escapeHtml(code); @@ -89,4 +92,25 @@ public class EmailService { .replace("\"", """) .replace("'", "'"); } + + public void sendResetPasswordEmail(User user, String code) { + try { + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + + helper.setFrom(standartAdressFrom); + helper.setTo(user.getEmail()); + helper.setSubject(dropPasswordSubject); + + String htmlContent = loadAndProcessTemplate(user.getFullName(), code, + "templates/reset-password.html"); + helper.setText(htmlContent, true); + + mailSender.send(message); + + log.info("Email sent to: {}", user.getEmail()); + } catch (Exception e) { + log.error("Failed to send email: {}", e.getMessage()); + } + } } diff --git a/src/main/java/ru/soune/nocopy/service/register/AuthService.java b/src/main/java/ru/soune/nocopy/service/register/AuthService.java index 29b0be6..efed941 100644 --- a/src/main/java/ru/soune/nocopy/service/register/AuthService.java +++ b/src/main/java/ru/soune/nocopy/service/register/AuthService.java @@ -17,12 +17,10 @@ import ru.soune.nocopy.entity.tarif.TariffType; import ru.soune.nocopy.entity.user.AuthToken; import ru.soune.nocopy.entity.user.Permission; import ru.soune.nocopy.entity.user.User; +import ru.soune.nocopy.entity.user.UserNotActive; import ru.soune.nocopy.exception.NotFoundAuthToken; import ru.soune.nocopy.exception.NotValidFieldException; -import ru.soune.nocopy.repository.AuthTokenRepository; -import ru.soune.nocopy.repository.CompanyRepository; -import ru.soune.nocopy.repository.TariffInfoRepository; -import ru.soune.nocopy.repository.UserRepository; +import ru.soune.nocopy.repository.*; import ru.soune.nocopy.service.file.CheckCounterService; import ru.soune.nocopy.service.tariff.TariffInfoService; @@ -48,6 +46,8 @@ public class AuthService { private final TariffInfoService tariffInfoService; + private final UserNotActiveRepository userNotActiveRepository; + private final SecureRandom secureRandom = new SecureRandom(); @Transactional @@ -102,14 +102,30 @@ public class AuthService { return authToken.getUser().getId(); } - - @Transactional public AuthToken login(LoginRequest request) { User user = userRepository.findByEmail(request.getEmail()); + if (!user.isActive()) { + throw new NotValidFieldException("User not active", new BaseResponse(20003, + MessageCode.USER_NOT_ACTIVE.getCode(), + MessageCode.USER_NOT_ACTIVE.getDescription(), Map.of("userId", user.getId()))); + } + if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) { LoginAnswer loginAnswer = new LoginAnswer(); loginAnswer.setFieldErrors(Arrays.asList(Map.of("password", request.getPassword()))); + loginAnswer.setVerified(user.isEmailVerified()); + loginAnswer.setActive(user.isActive()); + + int attempts = user.getFailedLoginAttempts() + 1; + user.setFailedLoginAttempts(attempts); + + if (attempts >= 5) { + user.setActive(false); + blockUser(user); + } + + userRepository.save(user); throw new NotValidFieldException("Invalid password", new BaseResponse(20003, MessageCode.AUTH_PASSWORD_NOT_MATCHES.getCode(), @@ -117,6 +133,9 @@ public class AuthService { } user.setLastLoginAt(LocalDateTime.now()); + user.setFailedLoginAttempts(0); + user.setActive(true); + User savedUser = userRepository.save(user); AuthToken authToken = new AuthToken(); @@ -132,6 +151,7 @@ public class AuthService { .ifPresent(authTokenRepository::delete); } + @Transactional public AuthToken generateAuthToken(User user) { return authTokenRepository.save(genereateAuthToken(user)); } @@ -211,4 +231,19 @@ public class AuthService { user.setCompany(company); user.grantPermission(Permission.DEFAULT_USER_PERMISSIONS); } + + public void blockUser(User user) { + Optional existing = userNotActiveRepository.findByUserId(user.getId()); + if (existing.isEmpty()) { + LocalDateTime now = LocalDateTime.now(); + + UserNotActive notActive = UserNotActive.builder() + .user(user) + .createdAt(now) + .blockedUntil(now.plusMinutes(15)) + .build(); + + userNotActiveRepository.save(notActive); + } + } } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index cddeb0d..e04c46d 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -110,6 +110,7 @@ app: verification: standart-subject: NO COPY - Подтверждение email адреса standart-address-from: vlad.popovtsev@gmail.com + drop-password-subject: NO COPY - сброс пароля user: Пользователь tarif: diff --git a/src/main/resources/templates/reset-password.html b/src/main/resources/templates/reset-password.html new file mode 100644 index 0000000..abc80ec --- /dev/null +++ b/src/main/resources/templates/reset-password.html @@ -0,0 +1,201 @@ + + + + + + Восстановление пароля + + + +
+
+ +

Восстановление пароля

+

Безопасный доступ к вашему аккаунту

+
+ +
+

+ Здравствуйте, {{username}}! +

+ +

Мы получили запрос на восстановление пароля для вашего аккаунта NO COPY.

+

Для продолжения восстановления доступа, пожалуйста, используйте следующий код подтверждения:

+ +
+

{{code}}

+

Код для восстановления пароля

+
+ +
+

Код действителен в течение 15 минут

+
+ +

Как использовать этот код:

+
    +
  1. Вернитесь на страницу восстановления пароля
  2. +
  3. Введите полученный код в поле подтверждения
  4. +
  5. Создайте новый надежный пароль
  6. +
  7. Войдите в аккаунт с новым паролем
  8. +
+ + + + + +
+ Важная информация:
+ 1. Если вы не запрашивали восстановление пароля, проигнорируйте это письмо.
+ 2. Никому не сообщайте этот код, включая сотрудников поддержки.
+ 3. После успешного восстановления старый пароль будет недействителен. +
+ +

+ Совет по безопасности: Используйте пароль длиной не менее 8 символов, + содержащий буквы (заглавные и строчные), цифры и специальные символы. +

+
+ + +
+ + \ No newline at end of file