AccountService.java

/*
 * Copyright © 2023-2025 The CTAN Team and individual authors
 *
 * This file is distributed under the 3-clause BSD license.
 * See file LICENSE for details.
 */
package org.ctan.site.services.account;

import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import org.ctan.site.domain.Gender;
import org.ctan.site.domain.account.Ticket;
import org.ctan.site.domain.account.User;
import org.ctan.site.services.mail.MailException;
import org.ctan.site.services.mail.MailService;
import org.ctan.site.services.mail.MailService.Mail;
import org.ctan.site.stores.AuthorStore;
import org.ctan.site.stores.TicketStore;
import org.ctan.site.stores.UserStore;

import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;

/**
 * The class <code>AccountService</code> contains service methods for the CTAN
 * site user.
 *
 * @author <a href="mailto:gene@ctan.org">Gerd Neugebauer</a>
 */
@SuppressFBWarnings(value = "EI_EXPOSE_REP")
@Slf4j
public class AccountService {

    /**
     * The package transport object.
     */
    @AllArgsConstructor
    @Builder
    @Data
    @NoArgsConstructor
    public static class PkgTo {

        /**
         * The field <code>key</code> contains the package key.
         */
        private String key;

        /**
         * The field <code>name</code> contains the package name.
         */
        private String name;

        /**
         * The field <code>version</code> contains the package version.
         */
        private String version;
    }

    /**
     * The transport object.
     */
    @Builder
    @Data
    @AllArgsConstructor
    public static class To {

        /**
         * The field <code>value</code> contains the value.
         */
        private String value;

        /**
         * The field <code>msg</code> contains the message.
         */
        private String msg;
    }

    /**
     * The user transport object.
     */
    @AllArgsConstructor
    @Builder
    @Data
    @NoArgsConstructor
    public static class UserTo {

        /**
         * The field <code>account</code> contains the account name.
         */
        private String account;

        /**
         * The field <code>token</code> contains the user token.
         */
        private String token;

        /**
         * The field <code>refresh</code> contains the refresh token.
         */
        private String refresh;

        /**
         * The field <code>email</code> contains the user's email.
         */
        private String email;

        /**
         * The field <code>name</code> contains the user's full name or
         * pseudonym.
         */
        private String name;

        /**
         * The field <code>gender</code> contains the user's gender. It is one
         * of the letters m, f, g, x.
         */
        private String gender;

        /**
         * The field <code>city</code> contains the user's location.
         */
        private String city;

        /**
         * The field <code>country</code> contains the user's location.
         */
        private String country;

        /**
         * The field <code>author</code> contains the author key if the user is
         * an author or {@link null}.
         */
        private String author;

        /**
         * The field <code>authorCand</code> contains the candidate for an
         * author assignment. it is determined by comparing the user's email to
         * the author's email.
         */
        private String authorCand;

        /**
         * The field <code>showName</code> contains the indicator whether to
         * show the name.
         */
        private boolean showName;

        /**
         * The field <code>showEmail</code> contains the indicator whether to
         * show the email.
         */
        private boolean showEmail;

        /**
         * The field <code>htmlEmail</code> contains the indicator whether to
         * send HTML email to the user.
         */
        private boolean htmlEmail;

        /**
         * The field <code>selfDescription</code> contains the description of
         * the user.
         */
        private String selfDescription;

        /**
         * The field <code>is</code> contains the mapping or roles.
         */
        private Map<String, Boolean> is;

        /**
         * The field <code>packages</code> contains the author's packages if the
         * user is author.
         */
        private List<PkgTo> packages;
    }

    /**
     * The constant <code>DAYS</code> contains minutes per day.
     */
    private static final long DAYS = 60L * 24L;

    /**
     * The method <code>isEmpty</code> provides means to check whether the
     * argument is {@code null} or the empty string.
     *
     * @param s the reference string
     * @return {@code true} if the string is {@code null} or empty
     */
    public static final boolean isEmpty(String s) {

        return s == null || s.length() == 0;
    }

    /**
     * The field <code>store</code> contains the user repository.
     */
    @Getter
    private UserStore store;

    /**
     * The field <code>ticketStore</code> contains the ticket repository.
     */
    private TicketStore ticketStore;

    /**
     * The field <code>mailService</code> contains the mail service.
     */
    private MailService mailService;

    /**
     * The field <code>authorStore</code> contains the author repository.
     */
    private AuthorStore authorStore;

    /**
     * This is the constructor for the class <code>UserService</code>.
     *
     * @param store the underlying store
     * @param ticketStore the ticket store
     * @param authorStore the author store
     * @param mailService the mail service
     */
    @SuppressFBWarnings(value = "CT_CONSTRUCTOR_THROW")
    public AccountService(@NonNull UserStore store,
        @NonNull TicketStore ticketStore,
        @NonNull AuthorStore authorStore,
        @NonNull MailService mailService) {

        this.store = store;
        this.ticketStore = ticketStore;
        this.authorStore = authorStore;
        this.mailService = mailService;
    }

    /**
     * The method <code>login</code> provides means to login.
     *
     * @param account the account
     * @param passwd the password
     * @return the user or {@code null}
     */
    public UserTo login(@NonNull String account, @NonNull String passwd) {

        var user = store.getByAccount(account);
        if (user == null || !user.accept(passwd)) {
            return null;
        }
        String authorCand = null;
        var token = JwtManager.createAuth(account);
        var refresh = JwtManager.createRefresh(account);

        Map<String, Boolean> is = new HashMap<String, Boolean>();
        for (var role : user.getRoles()) {
            is.put(role.toString().toLowerCase(),
                Boolean.TRUE);
        }
        List<PkgTo> packages = List.of();
        var gender = user.getGender().getValue();
        var authorKey = user.getAuthorKey();
        if (authorKey != null) {
            var author = authorStore.getByKey(authorKey);
            if (author != null) {
                packages = author.getRefs().stream()
                    .map(p -> PkgTo.builder()
                        .key(p.getPkg().getKey())
                        .name(p.getPkg().getName())
                        .version(p.getPkg().getVers())
                        .build())
                    .collect(Collectors.toList());
            }
        } else {
            var author = authorStore.getByEmail(user.getEmail());
            if (author != null) {
                authorCand = author.getKey();
            }
        }
        return UserTo.builder()
            .account(account)
            .name(user.getName())
            .gender(gender)
            .showName(true)
            .token(token)
            .refresh(refresh)
            .email(user.getEmail())
            .city(user.getCity())
            .country(user.getCountry())
            .author(authorKey)
            .authorCand(authorCand)
            .showName(user.getShowName())
            .showEmail(user.getShowEmail())
            .htmlEmail(user.getHtmlEmail())
            .selfDescription(user.getSelfDescription())
            .is(is)
            .packages(packages)
            .build();
    }

    /**
     * The method <code>passwd</code> provides means to create a ticket and send
     * it to the user.
     *
     * @param account the account name
     * @param email the associated email address
     * @return a new ticket or <code>null</code> if something fails
     */
    public Ticket passwd(String account, String email, String locale) {

        if (account == null || email == null) {
            return null;
        }
        if (locale == null) {
            locale = "en";
        }

        User user = store.getByAccount(account);
        if (user == null
            || !email.toLowerCase().equals(user.getEmail().toLowerCase())) {
            return null;
        }

        Ticket ticket = ticketStore.createPasswordTicket(account);

        try {
            mailService.send(Mail.builder()
                .locale(locale)
                .html(user.getHtmlEmail())
                .template("password")
                .to(email)
                .build());
        } catch (MailException e) {
            log.error("Failed to send passwd ticket to {1}", email);
        }

        return ticket;
    }

    /**
     * The method <code>refresh</code> provides means to refresh the access
     * token.
     *
     * @param account the account
     * @param refresh the refresh token
     * @return the user or {@code null}
     */
    public UserTo refresh(@NonNull String account, @NonNull String refresh) {

        var user = store.getByAccount(account);
        if (user == null
            || !user.getAccount().equals(JwtManager.verifyRefresh(refresh))) {
            return null;
        }

        String authorCand = null;
        var token = JwtManager.createAuth(account);

        Map<String, Boolean> is = new HashMap<String, Boolean>();
        for (var role : user.getRoles()) {
            is.put(role.toString().toLowerCase(),
                Boolean.TRUE);
        }
        List<PkgTo> packages = List.of();
        var gender = user.getGender().getValue();
        var authorKey = user.getAuthorKey();
        if (authorKey != null) {
            var author = authorStore.getByKey(authorKey);
            if (author != null) {
                packages = author.getRefs().stream()
                    .map(p -> PkgTo.builder()
                        .key(p.getPkg().getKey())
                        .name(p.getPkg().getName())
                        .version(p.getPkg().getVers())
                        .build())
                    .collect(Collectors.toList());
            }
        } else {
            var author = authorStore.getByEmail(user.getEmail());
            if (author != null) {
                authorCand = author.getKey();
            }
        }
        return UserTo.builder()
            .account(account)
            .name(user.getName())
            .gender(gender)
            .showName(true)
            .token(token)
            .refresh(refresh)
            .email(user.getEmail())
            .city(user.getCity())
            .country(user.getCountry())
            .author(authorKey)
            .authorCand(authorCand)
            .showName(user.getShowName())
            .showEmail(user.getShowEmail())
            .htmlEmail(user.getHtmlEmail())
            .selfDescription(user.getSelfDescription())
            .is(is)
            .packages(packages)
            .build();
    }

    /**
     * The method <code>registerAccount</code> provides means to start the
     * registration process.
     *
     * @param account the account
     * @param email the email
     * @param name the name
     * @param gender the gender
     * @param lang the locale to use
     * @return the registered account
     */
    public To registerAccount(String account, String email, String name,
        Gender gender, String lang) {

        if (isEmpty(account)) {
            throw new IllegalArgumentException(
                "account is marked non-null but is null");
        }
        if (isEmpty(email)) {
            throw new IllegalArgumentException(
                "email is marked non-null but is null");
        }
        validateName(name);
        if (store.getByAccount(account) != null) {
            return new To(null, "user-exists");
        }
        if (lang == null) {
            lang = "en";
        }
        var user = User.builder()
            .account(account)
            .email(email)
            .name(name)
            .gender(gender)
            .accountLocked(true)
            .dateCreated(LocalDateTime.now())
            .password("NEW")
            .build();
        user = store.save(user);
        var ticket = ticketStore.createRegisterTicket(account);
        try {
            mailService.send(Mail.builder()
                .to(email)
                .template("register")
                .model(Map.of("user", user, "ticket", ticket))
                .html(false)
                .locale(lang)
                .build());
        } catch (MailException e) {
            ticketStore.remove(ticket);
            return new To(null, "mail-failed");
        }
        return new To(ticket.getKey(), null);
    }

    /**
     * The method <code>set</code> provides means to set a single attribute of
     * the user and save the result.
     *
     * @param account the account
     * @param attribute the attribute
     * @param value the value
     */
    public boolean set(@NonNull String account, @NonNull String attribute,
        @NonNull String value) {

        var user = store.getByAccount(account);
        if (user == null) {
            throw new IllegalArgumentException();
        }
        switch (attribute) {
            case "email":
                user.setEmail(value);
                break;
            case "gender":
                user.setGender(Gender.of(value));
                break;
            case "htmlEmail":
                user.setHtmlEmail("true".equals(value));
                break;
            case "city":
                user.setCity(value);
                break;
            case "country":
                user.setCountry(value);
                break;
            case "name":
                if ("".equals(value)) {
                    throw new IllegalArgumentException();
                }
                user.setName(value);
                break;
            case "description":
                user.setSelfDescription(value);
                break;
            case "passwd":
                if (value.length() <= 3) {
                    throw new IllegalArgumentException();
                }
                user.setHashedPassword(value);
                break;
            case "showEmail":
                user.setShowEmail("true".equals(value));
                break;
            case "showName":
                user.setShowName("true".equals(value));
                break;
            default:
                throw new IllegalArgumentException();
        }
        return store.save(user) != null;
    }

    /**
     * The method <code>setPasswdFromTicket</code> provides means to set the
     * password from a ticket.
     *
     * @param ticket the ticket
     * @param password the password
     *
     * @return {@code true} iff the password could be stored
     */
    public boolean setPasswdFromTicket(String ticket, String password) {

        var t = verifyTicket(ticket);
        if (t == null) {
            return false;
        }
        var user = store.getByAccount(t.getAccount());
        ticketStore.remove(t);
        user.setHashedPassword(password);
        return store.save(user) != null;
    }

    /**
     * The method <code>setPassword</code> provides means to set the password
     * from a user.
     *
     * @param account the user name
     * @param password the new password
     * @return {@code true} iff the password has been changed
     */
    public boolean setPassword(@NonNull String account,
        @NonNull String password) {

        var user = store.getByAccount(account);
        if (user == null) {
            return false;
        }
        if (password.length() < 3) {
            return false;
        }
        user.setHashedPassword(password);
        return store.save(user) != null;
    }

    /**
     * The method <code>validateName</code> provides means to check the real
     * name for a valid value.
     *
     * @param name the real name
     */
    private void validateName(String name) {

        if (isEmpty(name)) {
            throw new IllegalArgumentException(
                "name is marked non-null but is null");
        }

        name = name.replaceAll("\\s+", " ").trim();

        if (name.indexOf(' ') < 0) {
            throw new IllegalArgumentException("unexpected name");
        }

        var p = name.replaceAll("[a-z]+[A-Z]", "#");

        if (p.indexOf('#') >= 0) {
            throw new IllegalArgumentException("unexpected name");
        }
    }

    /**
     * The method <code>verifyTicket</code> provides means to check that a
     * ticket exists and is not expired.
     *
     * @param key the ticket key
     * @return the ticket or {@code null}
     */
    public Ticket verifyTicket(String key) {

        var ticket = ticketStore.get(key);
        if (ticket == null) {
            return null;
        }
        if (ticket.isExpired(7L * DAYS)) {
            ticketStore.remove(ticket);
            return null;
        }
        return ticket;
    }
}