001/*
002 * JDrupes Builder
003 * Copyright (C) 2026 Michael N. Lipp
004 * 
005 * This program is free software: you can redistribute it and/or modify
006 * it under the terms of the GNU Affero General Public License as
007 * published by the Free Software Foundation, either version 3 of the
008 * License, or (at your option) any later version.
009 *
010 * This program is distributed in the hope that it will be useful,
011 * but WITHOUT ANY WARRANTY; without even the implied warranty of
012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
013 * GNU Affero General Public License for more details.
014 *
015 * You should have received a copy of the GNU Affero General Public License
016 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
017 */
018
019package org.jdrupes.builder.mvnrepo;
020
021import com.google.common.flogger.FluentLogger;
022import java.io.BufferedOutputStream;
023import java.io.IOException;
024import java.io.InputStream;
025import java.io.OutputStream;
026import java.io.PipedInputStream;
027import java.io.PipedOutputStream;
028import java.io.UncheckedIOException;
029import java.io.UnsupportedEncodingException;
030import java.net.URI;
031import java.net.URISyntaxException;
032import java.net.URLEncoder;
033import java.net.http.HttpClient;
034import java.net.http.HttpRequest;
035import java.net.http.HttpResponse;
036import java.nio.charset.StandardCharsets;
037import java.nio.file.Files;
038import java.nio.file.Path;
039import java.time.Duration;
040import java.util.List;
041import java.util.Optional;
042import java.util.concurrent.ExecutorService;
043import java.util.concurrent.Executors;
044import java.util.zip.ZipEntry;
045import java.util.zip.ZipOutputStream;
046import org.bouncycastle.util.encoders.Base64;
047import org.eclipse.aether.artifact.Artifact;
048import org.jdrupes.builder.api.BuildContext;
049import org.jdrupes.builder.api.BuildException;
050import org.jdrupes.builder.api.ConfigurationException;
051import static org.jdrupes.builder.mvnrepo.MvnProperties.ArtifactId;
052
053/// A Maven publishing destination that publishes releases using the
054/// [Sonatype Publish Portal API](https://central.sonatype.org/publish/publish-portal-api/).
055///
056/// Instead of uploading files individually, this implementation of
057/// [MvnPublishingDestination] bundles all artifacts into a single ZIP
058/// release bundle and uploads it via a multipart HTTP request. It is the
059/// modern recommended way to publish releases to Maven Central.
060///
061public class PortalPublisherDestination extends MvnPublishingDestination {
062
063    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
064    private boolean publishAutomatically;
065    private URI uploadUri = URI
066        .create("https://central.sonatype.com/api/v1/publisher/upload");
067
068    /// Initializes a new portal publisher destination.
069    /// 
070    /// The id is initialized with "central". This allows the credentials
071    /// from the server section in `settings.xml` with this id to be used
072    /// as fallbacks.
073    ///
074    @SuppressWarnings("PMD.ConstructorCallsOverridableMethod")
075    public PortalPublisherDestination() {
076        super(MvnVersionType.RELEASE);
077        id("central");
078    }
079
080    @Override
081    public boolean requiresChecksumArtifacts() {
082        return true;
083    }
084
085    /// Publish the release automatically.
086    ///
087    /// @return this destination
088    ///
089    public PortalPublisherDestination publishAutomatically() {
090        publishAutomatically = true;
091        return this;
092    }
093
094    /// Sets the upload URI.
095    ///
096    /// @param uri the repository URI
097    /// @return this destination
098    ///
099    public PortalPublisherDestination uploadUri(URI uri) {
100        this.uploadUri = uri;
101        return this;
102    }
103
104    /// Returns the upload URI. Defaults to 
105    /// `https://central.sonatype.com/api/v1/publisher/upload`.
106    ///
107    /// @return the uri
108    ///
109    public URI uploadUri() {
110        return uploadUri;
111    }
112
113    @Override
114    @SuppressWarnings("PMD.AvoidLiteralsInIfCondition")
115    /* default */void publish(BuildContext context, MvnPublisher publisher,
116            Artifact mainArtifact, List<Artifact> toDeploy) {
117        var project = publisher.project();
118        // Create zip file with all artifacts for release, see
119        // https://central.sonatype.org/publish/publish-portal-upload/
120        var zipName = Optional.ofNullable(project.get(ArtifactId))
121            .orElse(project.name()) + "-" + mainArtifact.getVersion()
122            + "-release.zip";
123        var zipPath = publisher.artifactDirectory().resolve(zipName);
124        try {
125            Path praefix = Path.of(mainArtifact.getGroupId().replace('.', '/'))
126                .resolve(mainArtifact.getArtifactId())
127                .resolve(mainArtifact.getVersion());
128            try (ZipOutputStream zos
129                = new ZipOutputStream(Files.newOutputStream(zipPath))) {
130                for (var artifact : toDeploy) {
131                    @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
132                    var entry = new ZipEntry(praefix.resolve(
133                        artifact.getArtifactId() + "-" + artifact.getVersion()
134                            + (artifact.getClassifier().isEmpty()
135                                ? ""
136                                : "-" + artifact.getClassifier())
137                            + "." + artifact.getExtension())
138                        .toString());
139                    zos.putNextEntry(entry);
140                    try (var fis = Files.newInputStream(
141                        artifact.getPath())) {
142                        fis.transferTo(zos);
143                    }
144                    zos.closeEntry();
145                }
146            }
147        } catch (IOException e) {
148            throw new BuildException().from(publisher).cause(e);
149        }
150
151        try (var client = HttpClient.newBuilder()
152            .connectTimeout(Duration.ofMinutes(1)).build()) {
153            var boundary = "===" + System.currentTimeMillis() + "===";
154            var user = repositoryUser(context);
155            var password = repositoryPassword(context);
156            var token = new String(Base64.encode((user + ":" + password)
157                .getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8);
158            var effectiveUri = uploadUri;
159            if (publishAutomatically) {
160                effectiveUri = addQueryParameter(
161                    uploadUri, "publishingType", "AUTOMATIC");
162            }
163            HttpRequest request = HttpRequest.newBuilder().uri(effectiveUri)
164                .timeout(Duration.ofMinutes(10))
165                .header("Authorization", "Bearer " + token)
166                .header("Content-Type",
167                    "multipart/form-data; boundary=" + boundary)
168                .POST(HttpRequest.BodyPublishers
169                    .ofInputStream(() -> getAsMultipart(zipPath, boundary)))
170                .build();
171            logger.atInfo().log("Uploading release bundle...");
172            HttpResponse<String> response = client.send(request,
173                HttpResponse.BodyHandlers.ofString());
174            logger.atFinest().log("Upload response: %s", response.body());
175            if (response.statusCode() / 100 != 2) {
176                throw new ConfigurationException().from(publisher).message(
177                    "Failed to upload release bundle: " + response.body());
178            }
179        } catch (IOException | InterruptedException e) {
180            throw new BuildException().from(publisher).cause(e);
181        }
182    }
183
184    private static URI addQueryParameter(URI uri, String key, String value) {
185        String query = uri.getQuery();
186        try {
187            String newQueryParam
188                = key + "=" + URLEncoder.encode(value, "UTF-8");
189            String newQuery = (query == null || query.isEmpty()) ? newQueryParam
190                : query + "&" + newQueryParam;
191
192            // Build a new URI with the new query string
193            return new URI(uri.getScheme(), uri.getAuthority(), uri.getPath(),
194                newQuery, uri.getFragment());
195        } catch (UnsupportedEncodingException | URISyntaxException e) {
196            // UnsupportedEncodingException cannot happen, UTF-8 is standard.
197            // URISyntaxException cannot happen when starting with a valid URI
198            throw new IllegalArgumentException(e);
199        }
200    }
201
202    @SuppressWarnings("PMD.UseTryWithResources")
203    private InputStream getAsMultipart(Path zipPath, String boundary) {
204        // Use Piped streams for streaming multipart content
205        var fromPipe = new PipedInputStream();
206
207        // Write multipart content to pipe
208        ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
209        OutputStream toPipe;
210        try {
211            toPipe = new PipedOutputStream(fromPipe);
212        } catch (IOException e) {
213            throw new UncheckedIOException(e);
214        }
215        executor.submit(() -> {
216            try (var mpOut = new BufferedOutputStream(toPipe)) {
217                final String lineFeed = "\r\n";
218                @SuppressWarnings("PMD.InefficientStringBuffering")
219                StringBuilder intro = new StringBuilder(100)
220                    .append("--").append(boundary).append(lineFeed)
221                    .append("Content-Disposition: form-data; name=\"bundle\";"
222                        + " filename=\"%s\"".formatted(zipPath.getFileName()))
223                    .append(lineFeed)
224                    .append("Content-Type: application/octet-stream")
225                    .append(lineFeed).append(lineFeed);
226                mpOut.write(
227                    intro.toString().getBytes(StandardCharsets.US_ASCII));
228                Files.newInputStream(zipPath).transferTo(mpOut);
229                mpOut.write((lineFeed + "--" + boundary + "--")
230                    .getBytes(StandardCharsets.US_ASCII));
231            } catch (IOException e) {
232                throw new UncheckedIOException(e);
233            } finally {
234                executor.close();
235            }
236        });
237        return fromPipe;
238    }
239
240}