User3Resource.java

/*
 * Copyright © 2022-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.resources.site;

import java.util.Map;

import org.ctan.site.domain.Gender;
import org.ctan.site.domain.account.Ticket;
import org.ctan.site.services.account.AccountService;
import org.ctan.site.services.account.AccountService.To;
import org.ctan.site.services.account.AccountService.UserTo;
import org.ctan.site.services.account.JwtManager;
import org.ctan.site.stores.UserStore;

import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import io.dropwizard.hibernate.UnitOfWork;
import jakarta.annotation.security.PermitAll;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response.Status;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.NonNull;

/**
 * The class <code>User3Resource</code> contains the controller for the user
 * resource. The end-points contained herein start with <code>/3.0/user/</code>.
 *
 * @author <a href="mailto:gene@ctan.org">Gerd Neugebauer</a>
 */
@Path("/3.0/user")
@Produces(MediaType.APPLICATION_JSON)
public class User3Resource {

    /**
     * The class <code>CtanSiteConfigTo</code> contains the transport object for
     * the configuration resource.
     */
    @Getter
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    @SuppressFBWarnings(value = "EI_EXPOSE_REP")
    protected static class CtanSiteConfigTo {

        private String name;

        private String version;

        private String[] languages;
    }

    /**
     * The class <code>LoginRequest</code> contains the transport object for the
     * login request.
     */
    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    public static class LoginRequest {

        private String account;

        private String password;
    }

    /**
     * The class <code>PasswdRequest</code> contains the transport object for
     * the password request.
     */
    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    public static class PasswdRequest {

        private String ticket;

        private String password;
    }

    /**
     * The class <code>PasswdTokenRequest</code> contains the transport object
     * for the password token request.
     */
    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    public static class PasswdTokenRequest {

        private String account;

        private String email;

        private String locale;
    }

    /**
     * The class <code>PasswordRequest</code> contains the transport object for
     * the password request.
     */
    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    public static class PasswordRequest {

        private String token;

        private String password;
    }

    /**
     * The class <code>RefreshRequest</code> contains the transport object for
     * the refresh request.
     */
    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    public static class RefreshRequest {

        private String account;

        private String refresh;
    }

    /**
     * The class <code>RegisterRequest</code> contains the transport object for
     * the register request.
     */
    @Getter
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    public static class RegisterRequest {

        private String account;

        private String email;

        private Gender gender;

        private String lang;

        private String name;
    }

    /**
     * The class <code>RemoveReq</code> contains the parameters for the remove
     * request.
     */
    @Getter
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    protected static class RemoveRequest {

        private String account;

        private String token;
    }

    /**
     * The class <code>SetRequest</code> contains the request object for the set
     * request.
     */
    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    public static class SetRequest {

        private String token;

        private String attribute;

        private String value;
    }

    /**
     * The class <code>UserInfo</code> contains the transport object for the
     * user info request.
     */
    @Data
    @Builder
    public static class UserInfo {

        private String account;

        private String name;

        private String author;

        private String email;

        private String description;

        private String gender;
    }

    /**
     * The field <code>service</code> contains the underlying service.
     */
    private AccountService service;

    /**
     * This is the constructor for the class <code>User3Resource</code>.
     *
     * @param service the underlying service
     */
    @SuppressFBWarnings(value = {"CT_CONSTRUCTOR_THROW", "EI_EXPOSE_REP2"})
    public User3Resource(@NonNull AccountService service) {

        this.service = service;
    }

    /**
     * The method <code>exists</code> provides means to check whether a user
     * exists.
     *
     * @param account the account or {@code null}
     * @param email the email address or {@code null}
     * @return {@code true} iff the user exists
     */
    @GET
    @Path("/exists")
    @PermitAll
    @UnitOfWork(value = "siteDb")
    public Boolean exists(@QueryParam("account") String account,
        @QueryParam("email") String email) {

        if (!AccountService.isEmpty(account)) {
            var user = service.getStore().getByAccount(account);
            if (user == null) {
                return false;
            }
            return AccountService.isEmpty(email)
                || email.equalsIgnoreCase(user.getEmail());
        } else if (!AccountService.isEmpty(email)) {
            return service.getStore().getByEmail(email) != null;
        }
        return false;
    }

    /**
     * The method <code>info</code> provides means to retrieve public data of a
     * user.
     *
     * @param account the account name
     * @return he user info transport object
     */
    @GET
    @Path("/info")
    @PermitAll
    @UnitOfWork(value = "siteDb")
    public UserInfo info(@QueryParam("account") String account) {

        if (AccountService.isEmpty(account)) {
            throw new WebApplicationException(Status.NOT_FOUND);
        }
        var user = service.getStore().getByAccount(account);
        if (user == null) {
            throw new WebApplicationException(Status.NOT_FOUND);
        }
        String desc = user.getSelfDescription();
        var builder = UserInfo.builder()
            .account(user.getAccount())
            .description(desc != null ? desc : "")
            .gender(user.getGender().getValue());
        if (user.getShowName()) {
            builder.name(user.getName());
        }
        if (user.getShowEmail()) {
            builder.email(user.getEmail());
        }
        var authorKey = user.getAuthorKey();
        if (!AccountService.isEmpty(authorKey)) {
            builder.author(authorKey);
        }
        return builder.build();
    }

    /**
     * The method <code>login</code> provides means to perform a login.
     *
     * @param req the request containing account and password
     * @return the user info or {@code null}
     */
    @POST
    @Path("/login")
    @PermitAll
    @UnitOfWork(value = "siteDb")
    public UserTo login(LoginRequest req) {

        var login = service.login(req.getAccount(), req.getPassword());
        if (login == null) {
            throw new WebApplicationException("unknown user", Status.NOT_FOUND);
        }
        return login;
    }

    /**
     * The method <code>login</code> provides means to perform a login.
     *
     * @param req the request containing account and refresh token
     * @return the user info or {@code null}
     */
    @POST
    @Path("/refresh")
    @PermitAll
    @UnitOfWork(value = "siteDb")
    public UserTo refresh(RefreshRequest req) {

        if (req == null
            || req.getAccount() == null
            || req.getRefresh() == null) {
            throw new WebApplicationException("missing arguments",
                Status.BAD_REQUEST);
        }
        UserTo login = service.refresh(req.getAccount(), req.getRefresh());
        if (login == null) {
            throw new WebApplicationException("unknown user", Status.NOT_FOUND);
        }
        return login;
    }

    /**
     * The method <code>registerAccount</code> provides means to request a new
     * account.
     *
     * @param registerRequest the request
     * @return the description of the account or the errors encountered
     */
    @POST
    @Path("/register")
    @PermitAll
    @UnitOfWork(value = "siteDb")
    public To registerAccount(@NonNull RegisterRequest registerRequest) {

        return service.registerAccount(registerRequest.getAccount(),
            registerRequest.getEmail(), registerRequest.getName(),
            registerRequest.getGender(), registerRequest.getLang());
    }

    /**
     * The method <code>remove</code> provides means to delete an account.
     *
     * @param request the request parameters
     */
    @DELETE
    @PermitAll
    @UnitOfWork(value = "siteDb")
    public void remove(RemoveRequest request) {

        String account = request.getAccount();
        if (AccountService.isEmpty(account)) {
            throw new WebApplicationException(Status.FORBIDDEN);
        }
        UserStore store = service.getStore();
        var user = store.getByAccount(account);
        if (user == null) {
            throw new WebApplicationException(Status.FORBIDDEN);
        }
        var acc = JwtManager.verifyAuth(request.getToken());
        if (acc == null || !acc.equals(account)) {
            throw new WebApplicationException(Status.FORBIDDEN);
        }

        store.remove(user);
    }

    /**
     * The method <code>requestPasswdTicket</code> provides means to set the
     * password.
     *
     * @param req the parameters
     * @return the new ticket
     */
    @POST
    @Path("/passwd")
    @PermitAll
    @UnitOfWork(value = "siteDb")
    public Ticket requestPasswdTicket(PasswdTokenRequest req) {

        if (req == null) {
            throw new WebApplicationException(Status.BAD_REQUEST);
        }
        Ticket ticket = service.passwd(req.getAccount(), req.getEmail(),
            req.getLocale());
        if (ticket == null) {
            throw new WebApplicationException(Status.BAD_REQUEST);
        }
        return ticket;
    }

    /**
     * The method <code>set</code> provides means to set a single attribute and
     * store the entity.
     *
     * @param request the request container object
     * @return the success indicator
     */
    @POST
    @Path("/set")
    @PermitAll // TODO
    @UnitOfWork(value = "siteDb")
    @SuppressFBWarnings(value = "DCN_NULLPOINTER_EXCEPTION")
    public boolean set(@NonNull SetRequest request) {

        var account = JwtManager.verifyAuth(request.getToken());
        if (account == null) {
            throw new WebApplicationException(Status.FORBIDDEN);
        }
        try {
            service.set(account, request.getAttribute(), request.getValue());
        } catch (IllegalArgumentException | NullPointerException e) {
            throw new WebApplicationException(Status.BAD_REQUEST);
        }
        return true;
    }

    /**
     * The method <code>setPasswd</code> provides means to set the password.
     *
     * @param req the transport object
     * @return the user name in a map
     */
    @POST
    @Path("/set/passwd")
    @PermitAll // TODO
    @UnitOfWork(value = "siteDb")
    public boolean setPasswd(PasswdRequest req) {

        if (req == null || req.getPassword() == null
            || req.getTicket() == null) {
            return false;
        }
        return service.setPasswdFromTicket(req.getTicket(), req.getPassword());
    }

    /**
     * The method <code>setPassword</code> provides means to set the password.
     *
     * @param req the transport object
     */
    @POST
    @Path("/set/password")
    @PermitAll
    @UnitOfWork(value = "siteDb")
    public void setPassword(PasswordRequest req) {

        if (req == null || req.getPassword() == null
            || req.getToken() == null) {
            throw new WebApplicationException(Status.BAD_REQUEST);
        }
        var account = JwtManager.verifyAuth(req.getToken());
        if (account == null) {
            throw new WebApplicationException(Status.BAD_REQUEST);
        }

        service.setPassword(account, req.getPassword());
    }

    /**
     * The method <code>ticket</code> provides means to check that a ticket
     * exists and is not expired.
     *
     * @param ticket the ticket
     * @return the user name
     */
    @GET
    @Path("/ticket/{ticket}")
    @PermitAll
    @UnitOfWork(value = "siteDb")
    public Map<String, String> ticket(@PathParam("ticket") String ticket) {

        var t = service.verifyTicket(ticket);
        if (t == null) {
            return null;
        }
        return Map.of("account", t.getAccount());
    }
}