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}