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