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