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}