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.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/// The Class MavenCentralUpload.
053///
054public class MavenCentralUpload extends MvnPublishingDestination {
055
056    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
057    private boolean publishAutomatically;
058    private URI uploadUri = URI
059        .create("https://central.sonatype.com/api/v1/publisher/upload");
060
061    /// Initializes a new Maven central upload.
062    ///
063    public MavenCentralUpload() {
064        super(PublicationType.RELEASE);
065    }
066
067    /// Publish the release automatically.
068    ///
069    /// @return the mvn publisher
070    ///
071    public MavenCentralUpload publishAutomatically() {
072        publishAutomatically = true;
073        return this;
074    }
075
076    /// Sets the upload URI.
077    ///
078    /// @param uri the repository URI
079    /// @return the Maven publisher
080    ///
081    public MavenCentralUpload uploadUri(URI uri) {
082        this.uploadUri = uri;
083        return this;
084    }
085
086    /// Returns the upload URI. Defaults to 
087    /// `https://central.sonatype.com/api/v1/publisher/upload`.
088    ///
089    /// @return the uri
090    ///
091    public URI uploadUri() {
092        return uploadUri;
093    }
094
095    @Override
096    @SuppressWarnings("PMD.AvoidLiteralsInIfCondition")
097    /* default */void publish(BuildContext context, MvnPublisher publisher,
098            Artifact mainArtifact, List<Artifact> toDeploy) {
099        var project = publisher.project();
100        // Create zip file with all artifacts for release, see
101        // https://central.sonatype.org/publish/publish-portal-upload/
102        var zipName = Optional.ofNullable(project.get(ArtifactId))
103            .orElse(project.name()) + "-" + mainArtifact.getVersion()
104            + "-release.zip";
105        var zipPath = publisher.artifactDirectory().resolve(zipName);
106        try {
107            Path praefix = Path.of(mainArtifact.getGroupId().replace('.', '/'))
108                .resolve(mainArtifact.getArtifactId())
109                .resolve(mainArtifact.getVersion());
110            try (ZipOutputStream zos
111                = new ZipOutputStream(Files.newOutputStream(zipPath))) {
112                for (var artifact : toDeploy) {
113                    @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
114                    var entry = new ZipEntry(praefix.resolve(
115                        artifact.getArtifactId() + "-" + artifact.getVersion()
116                            + (artifact.getClassifier().isEmpty()
117                                ? ""
118                                : "-" + artifact.getClassifier())
119                            + "." + artifact.getExtension())
120                        .toString());
121                    zos.putNextEntry(entry);
122                    try (var fis = Files.newInputStream(
123                        artifact.getPath())) {
124                        fis.transferTo(zos);
125                    }
126                    zos.closeEntry();
127                }
128            }
129        } catch (IOException e) {
130            throw new BuildException().from(publisher).cause(e);
131        }
132
133        try (var client = HttpClient.newHttpClient()) {
134            var boundary = "===" + System.currentTimeMillis() + "===";
135            var user = repositoryUser(context);
136            var password = repositoryPassword(context);
137            var token = new String(Base64.encode((user + ":" + password)
138                .getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8);
139            var effectiveUri = uploadUri;
140            if (publishAutomatically) {
141                effectiveUri = addQueryParameter(
142                    uploadUri, "publishingType", "AUTOMATIC");
143            }
144            HttpRequest request = HttpRequest.newBuilder().uri(effectiveUri)
145                .header("Authorization", "Bearer " + token)
146                .header("Content-Type",
147                    "multipart/form-data; boundary=" + boundary)
148                .POST(HttpRequest.BodyPublishers
149                    .ofInputStream(() -> getAsMultipart(zipPath, boundary)))
150                .build();
151            logger.atInfo().log("Uploading release bundle...");
152            HttpResponse<String> response = client.send(request,
153                HttpResponse.BodyHandlers.ofString());
154            logger.atFinest().log("Upload response: %s", response.body());
155            if (response.statusCode() / 100 != 2) {
156                throw new ConfigurationException().from(publisher).message(
157                    "Failed to upload release bundle: " + response.body());
158            }
159        } catch (IOException | InterruptedException e) {
160            throw new BuildException().from(publisher).cause(e);
161        }
162    }
163
164    private static URI addQueryParameter(URI uri, String key, String value) {
165        String query = uri.getQuery();
166        try {
167            String newQueryParam
168                = key + "=" + URLEncoder.encode(value, "UTF-8");
169            String newQuery = (query == null || query.isEmpty()) ? newQueryParam
170                : query + "&" + newQueryParam;
171
172            // Build a new URI with the new query string
173            return new URI(uri.getScheme(), uri.getAuthority(), uri.getPath(),
174                newQuery, uri.getFragment());
175        } catch (UnsupportedEncodingException | URISyntaxException e) {
176            // UnsupportedEncodingException cannot happen, UTF-8 is standard.
177            // URISyntaxException cannot happen when starting with a valid URI
178            throw new IllegalArgumentException(e);
179        }
180    }
181
182    @SuppressWarnings("PMD.UseTryWithResources")
183    private InputStream getAsMultipart(Path zipPath, String boundary) {
184        // Use Piped streams for streaming multipart content
185        var fromPipe = new PipedInputStream();
186
187        // Write multipart content to pipe
188        ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
189        OutputStream toPipe;
190        try {
191            toPipe = new PipedOutputStream(fromPipe);
192        } catch (IOException e) {
193            throw new UncheckedIOException(e);
194        }
195        executor.submit(() -> {
196            try (var mpOut = new BufferedOutputStream(toPipe)) {
197                final String lineFeed = "\r\n";
198                @SuppressWarnings("PMD.InefficientStringBuffering")
199                StringBuilder intro = new StringBuilder(100)
200                    .append("--").append(boundary).append(lineFeed)
201                    .append("Content-Disposition: form-data; name=\"bundle\";"
202                        + " filename=\"%s\"".formatted(zipPath.getFileName()))
203                    .append(lineFeed)
204                    .append("Content-Type: application/octet-stream")
205                    .append(lineFeed).append(lineFeed);
206                mpOut.write(
207                    intro.toString().getBytes(StandardCharsets.US_ASCII));
208                Files.newInputStream(zipPath).transferTo(mpOut);
209                mpOut.write((lineFeed + "--" + boundary + "--")
210                    .getBytes(StandardCharsets.US_ASCII));
211            } catch (IOException e) {
212                throw new UncheckedIOException(e);
213            } finally {
214                executor.close();
215            }
216        });
217        return fromPipe;
218    }
219
220}