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