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}