TicketStore.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.stores;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import org.ctan.site.domain.account.Ticket;
import org.ctan.site.services.DateUtils;
import org.ctan.site.stores.base.GeneralPage;
import org.hibernate.SessionFactory;

import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import io.dropwizard.hibernate.AbstractDAO;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.Root;
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;

/**
 * The class <code>TicketStore</code> contains the repository for tickets.
 *
 * @author <a href="mailto:gene@ctan.org">Gerd Neugebauer</a>
 */
@Slf4j
public class TicketStore extends AbstractDAO<Ticket> {

    /**
     * This function creates a random string and builds the SHA-1 digest.
     *
     * @return a random string
     */
    private static String randomDigest() {

        var pw = new StringBuilder();
        for (var i = 1; i <= 32; i++) {
            pw.append((char) (Math.round((float) Math.random() * (127 - 32))
                + 32));
        }
        MessageDigest algorithm;
        try {
            algorithm = MessageDigest.getInstance("SHA-1");
        } catch (NoSuchAlgorithmException e) {
            log.error(e.toString());
            return null;
        }
        algorithm.reset();
        algorithm.update(pw.toString().getBytes(StandardCharsets.UTF_8));
        return new String(algorithm.digest(), StandardCharsets.UTF_8);
    }

    /**
     * This is the constructor for the <code>PkgStore</code>.
     *
     * @param sessionFactory the session factory
     */
    @SuppressFBWarnings(value = "CT_CONSTRUCTOR_THROW")
    public TicketStore(@NonNull SessionFactory sessionFactory) {

        super(sessionFactory);
    }

    /**
     * Create a new removal ticket.
     *
     * @param account the user name
     *
     * @return the new ticket
     */
    public Ticket createDeleteTicket(String account) {

        return createTicket(Ticket.DELETE_PREFIX, account);
    }

    /**
     * Create a new password ticket.
     *
     * @param account the user name
     *
     * @return the new ticket
     */
    public Ticket createPasswordTicket(String account) {

        return createTicket(Ticket.PASSWORD_PREFIX, account);
    }

    /**
     * Create a new registration ticket.
     *
     * @param account the user name
     *
     * @return the new ticket
     */
    public Ticket createRegisterTicket(String account) {

        return createTicket(Ticket.REGISTER_PREFIX, account);
    }

    /**
     * Create a random ticket and store it in the database. Collisions with
     * existing tickets are checked and avoided.
     *
     * @param prefix the prefix for the ticket key
     * @param account the user
     *
     * @return the random key of the ticket
     */
    private Ticket createTicket(String prefix, String account) {

        for (Ticket ticket : findAllByAccount(account)) {
            currentSession().remove(ticket);
        }
        var key = prefix + randomDigest();
        while (get(key) != null) {
            key = prefix + randomDigest();
        }
        return persist(Ticket.builder()
            .account(account)
            .key(key)
            .build());
    }

    /**
     * The method <code>findAllByAccount</code> provides means to find a list of
     * all tickets by their account name.
     *
     * @param account the account name
     * @return the list of tickets
     */
    public List<Ticket> findAllByAccount(String account) {

        var query = criteriaQuery();
        CriteriaBuilder cb = currentSession().getCriteriaBuilder();
        Root<Ticket> ticket = query.from(Ticket.class);
        query.where(cb.equal(ticket.get("account"), account));
        return list(query);
    }

    /**
     * The method <code>getByKey</code> provides means to find an ticket by its
     * key.
     *
     * @param key the ticket key
     * @return the user or {@code null}
     */
    public Ticket get(@NonNull String key) {

        return super.get(key);
    }

    /**
     * The method <code>list</code> provides means to extract a page of items.
     *
     * @param term the search term
     * @param page the current page
     * @param pageSize the page size
     * @param orderBy the name or the column to sort by
     * @param asc the indicator for ascending/descending sort order
     * @return the page
     */
    public GeneralPage list(String term, int page, int pageSize,
        String orderBy, boolean asc) {

        if (page < 0 || pageSize < 1) {
            return null;
        }
        CriteriaBuilder cb = currentSession().getCriteriaBuilder();
        var query = criteriaQuery();
        Root<Ticket> ticket = query.from(Ticket.class);
        if (term != null && !term.isBlank()) {
            var t = "%" + term.toLowerCase() + "%";
            query.where(cb.like(cb.lower(ticket.get("account")), t));
        }
        if (orderBy != null && !orderBy.isBlank()) {
            if (asc) {
                query.orderBy(cb.asc(ticket.get(orderBy)));
            } else {
                query.orderBy(cb.desc(ticket.get(orderBy)));
            }
        }
        var hits = list(query);
        var hitCount = hits.size();
        var now = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC);
        var list = hits
            .subList(Math.min(page * pageSize, hitCount),
                Math.min((page + 1) * pageSize, hitCount))
            .stream()
            .map((t) -> Map.of("key", (Object) t.getKey(),
                "account", t.getAccount(),
                "created", DateUtils.formatDateTime(t.getDateCreated()),
                "age", (now - t.getDateCreated().toEpochSecond(ZoneOffset.UTC))
                    / 1000))
            .collect(Collectors.toList());
        return GeneralPage.builder()
            .size(hitCount)
            .list(list)
            .build();
    }

    /**
     * The method <code>remove</code> provides means to delete a ticket.
     *
     * @param key the ticket key
     * @return <code>true</code> iff the removing succeeded
     */
    public boolean remove(String key) {

        return remove(get(key));
    }

    /**
     * The method <code>remove</code> provides means to delete a ticket.
     *
     * @param t the ticket
     * @return <code>true</code> iff the removing succeeded
     */
    public boolean remove(Ticket t) {

        if (t == null) {
            return false;
        }
        currentSession().remove(t);
        return true;
    }

    /**
     * The method <code>save</code> provides means to persist a ticket.
     *
     * @param ticket the ticket to save
     * @return the ticket
     */
    public Ticket save(Ticket ticket) {

        return persist(ticket);
    }
}