SubmitResource.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.resources.catalogue.api;

import java.io.InputStream;
import java.util.List;
import java.util.Map;
import java.util.Optional;

import org.ctan.site.domain.account.User;
import org.ctan.site.services.upload.SubmitService;
import org.ctan.site.services.upload.SubmitService.Fields;
import org.ctan.site.services.upload.SubmitService.UploadData;
import org.ctan.site.services.upload.util.Messages;
import org.ctan.site.services.upload.util.archive.Archive;
import org.glassfish.jersey.media.multipart.FormDataContentDisposition;
import org.glassfish.jersey.media.multipart.FormDataParam;

import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import io.dropwizard.hibernate.UnitOfWork;
import jakarta.annotation.security.PermitAll;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
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;
import jakarta.ws.rs.core.Response.Status;
import lombok.NonNull;

/**
 * The class <code>SubmitResource</code> contains the controller for the submit
 * resource.
 *
 * @author <a href="mailto:gene@ctan.org">Gerd Neugebauer</a>
 */
@Path("/submit")
@Produces(MediaType.APPLICATION_JSON)
@PermitAll
public class SubmitResource {

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

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

        this.service = service;
    }

    /**
     * The method <code>fields</code> provides means to retrieve the supported
     * fields.
     *
     * @param api the API version
     * @return a Map with the fields and their attributes
     */
    @GET
    @Path("/fields")
    public Fields fields(
        @QueryParam("api") String api) {

        if (api == null) {
            api = service.getVersion();
        }
        return switch (api) {
            case "1.0" -> throw new WebApplicationException(Status.GONE);
            case "1.1" -> service.getFields(api);
            default -> throw new WebApplicationException(Status.NOT_FOUND);
        };
    }

    /**
     * The method <code>getVersions</code> provides means to retrieve the
     * supported versions number of the API.
     *
     * @return a Map with version number mapped to a list of supported end-
     *     points.
     */
    @GET
    @Path("/versions")
    public Map<String, List<String>> getVersions() {

        return Map.of("1.1",
            List.of("upload", "validate", "version", "versions", "fields"));
    }

    /**
     * The method <code>isKnownArchive</code> provides means to check whether
     * the file name argument has a known extension.
     *
     * @param filename the file name
     * @return if the file name is known
     */
    private boolean isKnownArchive(String filename) {

        if (filename == null) {
            return false;
        }
        filename = filename.toLowerCase();
        return filename.endsWith(".zip")
            || filename.endsWith(".tgz")
            || filename.endsWith(".tar.gz");
    }

    /**
     * The method <code>upload</code> provides means to upload a package to
     * CTAN.
     *
     * @param api the API version or <code>null</code>
     * @param stream the uploaded file
     * @param formData the meta data for the file
     * @param announce the announce parameter
     * @param announcement the announcement parameter
     * @param authors the authors parameter
     * @param bugs the bugs parameter
     * @param caption the caption parameter
     * @param confirm the confirm parameter
     * @param ctanPath the CTAN path parameter
     * @param description the description parameter
     * @param development the development channel parameter
     * @param email the uploader's email
     * @param home the home page
     * @param licenses the licenses parameter
     * @param mailinglist the mailing list
     * @param name the printable name of the package
     * @param notes the note for the upload managers
     * @param pkg the package name in lower-case
     * @param repository the repository
     * @param support the support channel
     * @param topics the topics parameter
     * @param uploader the name of the uploader
     * @param update the indicator for update vs. new
     * @param vers the version number
     *
     * @return the list of messages
     */
    @POST
    @Path("/upload")
    @Consumes({
        MediaType.APPLICATION_FORM_URLENCODED,
        MediaType.MULTIPART_FORM_DATA,
        MediaType.APPLICATION_JSON})
    @Produces(MediaType.APPLICATION_JSON)
    @UnitOfWork(value = "siteDb")
    public Response upload(
        // @Auth Optional<User> user,
        @QueryParam("api") String api,
        @FormDataParam("file") InputStream stream,
        @FormDataParam("file") FormDataContentDisposition formData,
        @FormDataParam("announce") String announce,
        @FormDataParam("announcement") String announcement,
        @FormDataParam("authors") String authors,
        @FormDataParam("bugs") String bugs,
        @FormDataParam("caption") String caption,
        @FormDataParam("confirm") String confirm,
        @FormDataParam("ctanPath") String ctanPath,
        @FormDataParam("description") String description,
        @FormDataParam("development") String development,
        @FormDataParam("email") String email,
        @FormDataParam("home") String home,
        @FormDataParam("licenses") String licenses,
        @FormDataParam("mailinglist") String mailinglist,
        @FormDataParam("name") String name,
        @FormDataParam("notes") String notes,
        @FormDataParam("pkg") String pkg,
        @FormDataParam("repository") String repository,
        @FormDataParam("support") String support,
        @FormDataParam("topics") String topics,
        @FormDataParam("uploader") String uploader,
        @FormDataParam("update") String update,
        @FormDataParam("vers") String vers) {

        Optional<User> user = Optional.empty();

        if (api == null) {
            api = service.getVersion();
        }
        if (!"1.1".equals(api)) {
            throw new WebApplicationException("1.0".equals(api)
                ? Status.GONE
                : Status.NOT_FOUND);
        }

        UploadData data = UploadData.builder()
            .announce(announce)
            .announcement(announcement)
            .authors(authors)
            .bugs(bugs)
            .caption(caption)
            .confirm("true".equals(confirm))
            .ctanPath(ctanPath)
            .description(description)
            .development(development)
            .email(email)
            .licenses(licenses != null ? licenses.split("[,;: ]+") : null)
            .mailinglist(mailinglist)
            .name(name)
            .notes(notes)
            .pkg(pkg)
            .repository(repository)
            .support(support)
            .topics(topics != null ? topics.split("[,;: ]+") : null)
            .update(update)
            .uploader(uploader)
            .vers(vers)
            .build();
        Messages messages = new Messages();
        if (stream == null || formData == null) {
            messages.error("Missing archive file");
        } else {
            String filename = formData.getFileName();
            Archive archive = Archive.of(filename, stream);
            if (archive == null) {
                messages.error("Unknown archive type", filename);
            } else {
                messages = service.upload(api,
                    data,
                    filename,
                    stream,
                    user.orElse(null));
            }
        }
        return Response
            .status(messages.getStatus())
            .encoding("UTF-8")
            .type(MediaType.APPLICATION_JSON)
            .entity(messages.getValues())
            .build();
    }

    /**
     * The method <code>validate</code> provides means to upload a package to
     * CTAN for validation.
     *
     * @param api the API version
     * @param stream the uploaded file
     * @param contentDispo the meta data for the file
     * @param announce the announce parameter
     * @param announcement the announcement parameter
     * @param authors the authors parameter
     * @param bugs the bugs parameter
     * @param caption the caption parameter
     * @param confirm the confirm parameter
     * @param ctanPath the CTAN path parameter
     * @param description the description parameter
     * @param development the development channel parameter
     * @param email the uploader's email
     * @param home the home page
     * @param licenses the licenses parameter
     * @param mailinglist the mailing list
     * @param name the printable name of the package
     * @param notes the note for the upload managers
     * @param pkg the package name in lower-case
     * @param repository the repository
     * @param support the support channel
     * @param topics the topics parameter
     * @param uploader the name of the uploader
     * @param update the indicator for update vs. new
     * @param vers the version number
     *
     * @return the response
     */
    @POST
    @Path("/validate")
    @Consumes({
        MediaType.APPLICATION_FORM_URLENCODED,
        MediaType.MULTIPART_FORM_DATA,
        MediaType.APPLICATION_JSON})
    @Produces(MediaType.APPLICATION_JSON)
    @UnitOfWork(value = "siteDb")
    public Response validate(
        @FormDataParam("api") String api,
        @FormDataParam("file") InputStream stream,
        @FormDataParam("file") FormDataContentDisposition contentDispo,
        @FormDataParam("announce") String announce,
        @FormDataParam("announcement") String announcement,
        @FormDataParam("authors") String authors,
        @FormDataParam("bugs") String bugs,
        @FormDataParam("caption") String caption,
        @FormDataParam("confirm") String confirm,
        @FormDataParam("ctanPath") String ctanPath,
        @FormDataParam("description") String description,
        @FormDataParam("development") String development,
        @FormDataParam("email") String email,
        @FormDataParam("home") String home,
        @FormDataParam("licenses") String licenses,
        @FormDataParam("mailinglist") String mailinglist,
        @FormDataParam("name") String name,
        @FormDataParam("notes") String notes,
        @FormDataParam("pkg") String pkg,
        @FormDataParam("repository") String repository,
        @FormDataParam("support") String support,
        @FormDataParam("topics") String topics,
        @FormDataParam("uploader") String uploader,
        @FormDataParam("update") String update,
        @FormDataParam("vers") String vers) {

        if (api == null) {
            api = service.getVersion();
        }
        if (!"1.1".equals(api)) {
            throw new WebApplicationException("1.0".equals(api)
                ? Status.GONE
                : Status.NOT_FOUND);
        }
        UploadData data = UploadData.builder()
            .announce(announce)
            .announcement(announcement)
            .authors(authors)
            .bugs(bugs)
            .caption(caption)
            .confirm("true".equals(confirm))
            .ctanPath(ctanPath)
            .description(description)
            .development(development)
            .email(email)
            .licenses(licenses != null ? licenses.split("[,;: ]+") : null)
            .mailinglist(mailinglist)
            .name(name)
            .notes(notes)
            .pkg(pkg)
            .repository(repository)
            .support(support)
            .topics(topics != null ? topics.split("[,;: ]+") : null)
            .update(update)
            .uploader(uploader)
            .vers(vers)
            .build();
        Messages messages;
        if (stream == null || contentDispo == null) {
            messages = Messages.errorMessage("Missing archive file");
        } else {
            String filename = contentDispo.getFileName();
            if (!isKnownArchive(filename)) {
                messages =
                    Messages.errorMessage("Unknown archive type", filename);
            } else {
                messages = service.validate(api, data, filename, stream);
            }
        }
        return Response
            .status(messages.getStatus())
            .encoding("UTF-8")
            .type(MediaType.APPLICATION_JSON)
            .entity(messages.getValues())
            .build();
    }

    /**
     * The method <code>version</code> provides means to retrieve the version
     * number of the API.
     *
     * @return a Map with a single attribute <code>version</code> containing the
     *     version number as String.
     */
    @GET
    @Path("/version")
    public Map<String, String> version() {

        return Map.of("version", service.getVersion());
    }
}