001/* 002 * JDrupes Builder 003 * Copyright (C) 2025 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.BufferedInputStream; 023import java.io.BufferedOutputStream; 024import java.io.FileNotFoundException; 025import java.io.IOException; 026import java.io.InputStream; 027import java.io.OutputStream; 028import java.io.PipedInputStream; 029import java.io.PipedOutputStream; 030import java.io.UncheckedIOException; 031import java.io.UnsupportedEncodingException; 032import java.net.URI; 033import java.net.URISyntaxException; 034import java.net.URLEncoder; 035import java.net.http.HttpClient; 036import java.net.http.HttpRequest; 037import java.net.http.HttpResponse; 038import java.nio.charset.StandardCharsets; 039import java.nio.file.Files; 040import java.nio.file.Path; 041import java.security.MessageDigest; 042import java.security.NoSuchAlgorithmException; 043import java.security.Security; 044import java.util.ArrayList; 045import java.util.List; 046import java.util.Optional; 047import java.util.concurrent.ExecutorService; 048import java.util.concurrent.Executors; 049import java.util.concurrent.atomic.AtomicBoolean; 050import java.util.function.Supplier; 051import java.util.stream.Stream; 052import java.util.zip.ZipEntry; 053import java.util.zip.ZipOutputStream; 054import org.apache.maven.model.building.DefaultModelBuilderFactory; 055import org.apache.maven.model.building.DefaultModelBuildingRequest; 056import org.apache.maven.model.building.ModelBuildingException; 057import org.apache.maven.model.building.ModelBuildingRequest; 058import org.bouncycastle.bcpg.ArmoredOutputStream; 059import org.bouncycastle.jce.provider.BouncyCastleProvider; 060import org.bouncycastle.openpgp.PGPException; 061import org.bouncycastle.openpgp.PGPPrivateKey; 062import org.bouncycastle.openpgp.PGPPublicKey; 063import org.bouncycastle.openpgp.PGPSecretKeyRingCollection; 064import org.bouncycastle.openpgp.PGPSignature; 065import org.bouncycastle.openpgp.PGPSignatureGenerator; 066import org.bouncycastle.openpgp.PGPUtil; 067import org.bouncycastle.openpgp.operator.jcajce.JcaKeyFingerprintCalculator; 068import org.bouncycastle.openpgp.operator.jcajce.JcaPGPContentSignerBuilder; 069import org.bouncycastle.openpgp.operator.jcajce.JcePBESecretKeyDecryptorBuilder; 070import org.bouncycastle.util.encoders.Base64; 071import org.eclipse.aether.AbstractRepositoryListener; 072import org.eclipse.aether.DefaultRepositorySystemSession; 073import org.eclipse.aether.RepositoryEvent; 074import org.eclipse.aether.artifact.Artifact; 075import org.eclipse.aether.artifact.DefaultArtifact; 076import org.eclipse.aether.deployment.DeployRequest; 077import org.eclipse.aether.deployment.DeploymentException; 078import org.eclipse.aether.repository.RemoteRepository; 079import org.eclipse.aether.repository.RepositoryPolicy; 080import org.eclipse.aether.util.artifact.SubArtifact; 081import org.eclipse.aether.util.repository.AuthenticationBuilder; 082import org.jdrupes.builder.api.BuildContext; 083import org.jdrupes.builder.api.BuildException; 084import org.jdrupes.builder.api.Generator; 085import static org.jdrupes.builder.api.Intent.*; 086import org.jdrupes.builder.api.Project; 087import org.jdrupes.builder.api.Resource; 088import org.jdrupes.builder.api.ResourceRequest; 089import org.jdrupes.builder.core.AbstractGenerator; 090import org.jdrupes.builder.java.JavadocJarFile; 091import org.jdrupes.builder.java.LibraryJarFile; 092import org.jdrupes.builder.java.SourcesJarFile; 093import static org.jdrupes.builder.mvnrepo.MvnProperties.ArtifactId; 094import static org.jdrupes.builder.mvnrepo.MvnRepoTypes.*; 095 096/// A [Generator] for maven deployments in response to requests for 097/// [MvnPublication] It supports publishing releases using the 098/// [Publish Portal API](https://central.sonatype.org/publish/publish-portal-api/) 099/// and publishing snapshots using the "traditional" maven approach 100/// (uploading the files, including the appropriate `maven-metadata.xml` 101/// files). 102/// 103/// The publisher requests the [PomFile] from the project and uses 104/// the groupId, artfactId and version as specified in this file. 105/// It also requests the [LibraryJarFile], the [SourcesJarFile] and 106/// the [JavadocJarFile]. The latter two are optional for snapshot 107/// releases. 108/// 109/// Publishing requires credentials for the maven repository and a 110/// PGP/GPG secret key for signing the artifacts. They can be set by 111/// the respective methods. However, it is assumed that the credentials 112/// are usually made available as properties in the build context. 113/// 114@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.ExcessiveImports", 115 "PMD.GodClass", "PMD.TooManyMethods" }) 116public class MvnPublisher extends AbstractGenerator { 117 118 private static final FluentLogger logger = FluentLogger.forEnclosingClass(); 119 private URI uploadUri; 120 private URI snapshotUri; 121 private String repoUser; 122 private String repoPass; 123 private String signingKeyRing; 124 private String signingKeyId; 125 private String signingPassword; 126 private JcaPGPContentSignerBuilder signerBuilder; 127 private PGPPrivateKey privateKey; 128 private PGPPublicKey publicKey; 129 private Supplier<Path> artifactDirectory 130 = () -> project().buildDirectory().resolve("publications/maven"); 131 private boolean keepSubArtifacts; 132 private boolean publishAutomatically; 133 134 /// Creates a new Maven publication generator. 135 /// 136 /// @param project the project 137 /// 138 public MvnPublisher(Project project) { 139 super(project); 140 uploadUri = URI 141 .create("https://central.sonatype.com/api/v1/publisher/upload"); 142 snapshotUri = URI 143 .create("https://central.sonatype.com/repository/maven-snapshots/"); 144 } 145 146 /// Sets the upload URI. 147 /// 148 /// @param uri the repository URI 149 /// @return the maven publication generator 150 /// 151 public MvnPublisher uploadUri(URI uri) { 152 this.uploadUri = uri; 153 return this; 154 } 155 156 /// Returns the upload URI. Defaults to 157 /// `https://central.sonatype.com/api/v1/publisher/upload`. 158 /// 159 /// @return the uri 160 /// 161 public URI uploadUri() { 162 return uploadUri; 163 } 164 165 /// Sets the Maven snapshot repository URI. 166 /// 167 /// @param uri the snapshot repository URI 168 /// @return the maven publication generator 169 /// 170 public MvnPublisher snapshotRepository(URI uri) { 171 this.snapshotUri = uri; 172 return this; 173 } 174 175 /// Returns the snapshot repository. Defaults to 176 /// `https://central.sonatype.com/repository/maven-snapshots/`. 177 /// 178 /// @return the uri 179 /// 180 public URI snapshotRepository() { 181 return snapshotUri; 182 } 183 184 /// Sets the Maven repository credentials. If not specified, the 185 /// publisher looks for properties `mvnrepo.user` and 186 /// `mvnrepo.password` in the properties provided by the [BuildContext]. 187 /// 188 /// @param user the username 189 /// @param pass the password 190 /// @return the maven publication generator 191 /// 192 public MvnPublisher credentials(String user, String pass) { 193 this.repoUser = user; 194 this.repoPass = pass; 195 return this; 196 } 197 198 /// Use the provided information to sign the artifacts. If no 199 /// information is specified, the publisher will use the [BuildContext] 200 /// to look up the properties `signing.secretKeyRingFile`, 201 /// `signing.secretKey` and `signing.password`. 202 /// 203 /// The publisher retrieves the secret key from the key ring using the 204 /// key ID. While this method makes signing in CI/CD pipelines 205 /// more complex, it is considered best practice. 206 /// 207 /// @param secretKeyRing the secret key ring 208 /// @param keyId the key id 209 /// @param password the password 210 /// @return the mvn publisher 211 /// 212 public MvnPublisher signWith(String secretKeyRing, String keyId, 213 String password) { 214 this.signingKeyRing = secretKeyRing; 215 this.signingKeyId = keyId; 216 this.signingPassword = password; 217 return this; 218 } 219 220 /// Keep generated sub artifacts (checksums, signatures). 221 /// 222 /// @return the mvn publication generator 223 /// 224 public MvnPublisher keepSubArtifacts() { 225 keepSubArtifacts = true; 226 return this; 227 } 228 229 /// Publish the release automatically. 230 /// 231 /// @return the mvn publisher 232 /// 233 public MvnPublisher publishAutomatically() { 234 publishAutomatically = true; 235 return this; 236 } 237 238 /// Returns the directory where additional artifacts are created. 239 /// Defaults to sub directory `publications/maven` in the project's 240 /// build directory (see [Project#buildDirectory]). 241 /// 242 /// @return the directory 243 /// 244 public Path artifactDirectory() { 245 return artifactDirectory.get(); 246 } 247 248 /// Sets the directory where additional artifacts are created. 249 /// The [Path] is resolved against the project's build directory 250 /// (see [Project#buildDirectory]). If `destination` is `null`, 251 /// the additional artifacts are created in the directory where 252 /// the base artifact is found. 253 /// 254 /// @param directory the new directory 255 /// @return the maven publication generator 256 /// 257 public MvnPublisher artifactDirectory(Path directory) { 258 if (directory == null) { 259 this.artifactDirectory = () -> null; 260 return this; 261 } 262 this.artifactDirectory 263 = () -> project().buildDirectory().resolve(directory); 264 return this; 265 } 266 267 /// Sets the directory where additional artifacts are created. 268 /// If the [Supplier] returns `null`, the additional artifacts 269 /// are created in the directory where the base artifact is found. 270 /// 271 /// @param directory the new directory 272 /// @return the maven publication generator 273 /// 274 public MvnPublisher artifactDirectory(Supplier<Path> directory) { 275 this.artifactDirectory = directory; 276 return this; 277 } 278 279 @Override 280 protected <T extends Resource> Stream<T> 281 doProvide(ResourceRequest<T> requested) { 282 if (!requested.accepts(MvnPublicationType)) { 283 return Stream.empty(); 284 } 285 PomFile pomResource = resourceCheck(project() 286 .resources(of(PomFile.class).using(Supply)), "POM file"); 287 if (pomResource == null) { 288 return Stream.empty(); 289 } 290 var jarResource = resourceCheck(project() 291 .resources(of(LibraryJarFile.class).using(Supply)), "jar file"); 292 if (jarResource == null) { 293 return Stream.empty(); 294 } 295 var srcsIter = project() 296 .resources(of(SourcesJarFile.class).using(Supply)).iterator(); 297 SourcesJarFile srcsFile = null; 298 if (srcsIter.hasNext()) { 299 srcsFile = srcsIter.next(); 300 if (srcsIter.hasNext()) { 301 logger.atSevere() 302 .log("More than one sources jar resources found."); 303 return Stream.empty(); 304 } 305 } 306 var jdIter = project().resources(of(JavadocJarFile.class).using(Supply)) 307 .iterator(); 308 JavadocJarFile jdFile = null; 309 if (jdIter.hasNext()) { 310 jdFile = jdIter.next(); 311 if (jdIter.hasNext()) { 312 logger.atSevere() 313 .log("More than one javadoc jar resources found."); 314 return Stream.empty(); 315 } 316 } 317 318 // Deploy what we've found 319 @SuppressWarnings("unchecked") 320 var result = (Stream<T>) deploy(pomResource, jarResource, srcsFile, 321 jdFile); 322 return result; 323 } 324 325 private <T extends Resource> T resourceCheck(Stream<T> resources, 326 String name) { 327 var iter = resources.iterator(); 328 if (!iter.hasNext()) { 329 logger.atSevere().log("No %s resource available", name); 330 return null; 331 } 332 var result = iter.next(); 333 if (iter.hasNext()) { 334 logger.atSevere().log("More than one %s resource found.", name); 335 return null; 336 } 337 return result; 338 } 339 340 private record Deployable(Artifact artifact, boolean temporary) { 341 } 342 343 @SuppressWarnings("PMD.AvoidDuplicateLiterals") 344 private Stream<?> deploy(PomFile pomResource, LibraryJarFile jarResource, 345 SourcesJarFile srcsJar, JavadocJarFile javadocJar) { 346 Artifact mainArtifact; 347 try { 348 mainArtifact = mainArtifact(pomResource); 349 } catch (ModelBuildingException e) { 350 throw new BuildException("Cannot build model from POM: %s", 351 e).from(this).cause(e); 352 } 353 if (artifactDirectory() != null) { 354 artifactDirectory().toFile().mkdirs(); 355 } 356 List<Deployable> toDeploy = new ArrayList<>(); 357 addWithGenerated(toDeploy, new SubArtifact(mainArtifact, "", "pom", 358 pomResource.path().toFile())); 359 addWithGenerated(toDeploy, new SubArtifact(mainArtifact, "", "jar", 360 jarResource.path().toFile())); 361 if (srcsJar != null) { 362 addWithGenerated(toDeploy, new SubArtifact(mainArtifact, "sources", 363 "jar", srcsJar.path().toFile())); 364 } 365 if (javadocJar != null) { 366 addWithGenerated(toDeploy, new SubArtifact(mainArtifact, "javadoc", 367 "jar", javadocJar.path().toFile())); 368 } 369 370 try { 371 if (mainArtifact.isSnapshot()) { 372 deploySnapshot(toDeploy); 373 } else { 374 deployRelease(mainArtifact, toDeploy); 375 } 376 } catch (DeploymentException e) { 377 throw new BuildException("Deployment failed for %s: %s", 378 mainArtifact, e.getMessage()).from(this).cause(e); 379 } finally { 380 if (!keepSubArtifacts) { 381 toDeploy.stream().filter(Deployable::temporary).forEach(d -> { 382 d.artifact().getFile().delete(); 383 }); 384 } 385 } 386 return Stream.of(project().newResource(MvnPublicationType, 387 mainArtifact.getGroupId() + ":" + mainArtifact.getArtifactId() 388 + ":" + mainArtifact.getVersion())); 389 } 390 391 private Artifact mainArtifact(PomFile pomResource) 392 throws ModelBuildingException { 393 var repoSystem = MvnRepoLookup.rootContext().repositorySystem(); 394 var repoSession = MvnRepoLookup.rootContext().repositorySystemSession(); 395 var repos 396 = new ArrayList<>(MvnRepoLookup.rootContext().remoteRepositories()); 397 if (snapshotUri != null) { 398 repos.add(createSnapshotRepository()); 399 } 400 var pomFile = pomResource.path().toFile(); 401 var buildingRequest = new DefaultModelBuildingRequest() 402 .setPomFile(pomFile).setProcessPlugins(false) 403 .setValidationLevel(ModelBuildingRequest.VALIDATION_LEVEL_MINIMAL) 404 .setModelResolver( 405 new MvnModelResolver(repoSystem, repoSession, repos)); 406 var model = new DefaultModelBuilderFactory().newInstance() 407 .build(buildingRequest).getEffectiveModel(); 408 return new DefaultArtifact(model.getGroupId(), model.getArtifactId(), 409 "jar", model.getVersion()); 410 } 411 412 private RemoteRepository createSnapshotRepository() { 413 return new RemoteRepository.Builder( 414 "snapshots", "default", snapshotUri.toString()) 415 .setSnapshotPolicy(new RepositoryPolicy( 416 true, // enable snapshots 417 RepositoryPolicy.UPDATE_POLICY_ALWAYS, 418 RepositoryPolicy.CHECKSUM_POLICY_WARN)) 419 .setReleasePolicy(new RepositoryPolicy( 420 false, 421 RepositoryPolicy.UPDATE_POLICY_NEVER, 422 RepositoryPolicy.CHECKSUM_POLICY_IGNORE)) 423 .build(); 424 } 425 426 private void addWithGenerated(List<Deployable> toDeploy, 427 Artifact artifact) { 428 // Add main artifact 429 toDeploy.add(new Deployable(artifact, false)); 430 431 // Generate .md5 and .sha1 checksum files 432 var artifactFile = artifact.getFile().toPath(); 433 try { 434 MessageDigest md5 = MessageDigest.getInstance("MD5"); 435 MessageDigest sha1 = MessageDigest.getInstance("SHA-1"); 436 try (var fis = Files.newInputStream(artifactFile)) { 437 byte[] buffer = new byte[8192]; 438 while (true) { 439 int read = fis.read(buffer); 440 if (read < 0) { 441 break; 442 } 443 md5.update(buffer, 0, read); 444 sha1.update(buffer, 0, read); 445 } 446 } 447 var fileName = artifactFile.getFileName().toString(); 448 449 // Handle generated md5 450 var md5Path = destinationPath(artifactFile, fileName + ".md5"); 451 Files.writeString(md5Path, toHex(md5.digest())); 452 toDeploy.add(new Deployable(new SubArtifact(artifact, "*", "*.md5", 453 md5Path.toFile()), true)); 454 455 // Handle generated sha1 456 var sha1Path = destinationPath(artifactFile, fileName + ".sha1"); 457 Files.writeString(sha1Path, toHex(sha1.digest())); 458 toDeploy.add(new Deployable(new SubArtifact(artifact, "*", "*.sha1", 459 sha1Path.toFile()), true)); 460 461 // Add signature as yet another artifact 462 var sigPath = signResource(artifactFile); 463 toDeploy.add(new Deployable(new SubArtifact(artifact, "*", "*.asc", 464 sigPath.toFile()), true)); 465 } catch (NoSuchAlgorithmException | IOException | PGPException e) { 466 throw new BuildException().from(this).cause(e); 467 } 468 } 469 470 private Path destinationPath(Path base, String fileName) { 471 var dir = artifactDirectory(); 472 if (dir == null) { 473 base.resolveSibling(fileName); 474 } 475 return dir.resolve(fileName); 476 } 477 478 private static String toHex(byte[] bytes) { 479 char[] hexDigits = "0123456789abcdef".toCharArray(); 480 char[] result = new char[bytes.length * 2]; 481 482 for (int i = 0; i < bytes.length; i++) { 483 int unsigned = bytes[i] & 0xFF; 484 result[i * 2] = hexDigits[unsigned >>> 4]; 485 result[i * 2 + 1] = hexDigits[unsigned & 0x0F]; 486 } 487 return new String(result); 488 } 489 490 private void initSigning() 491 throws FileNotFoundException, IOException, PGPException { 492 if (signerBuilder != null) { 493 return; 494 } 495 var keyRingFileName = Optional.ofNullable(signingKeyRing).orElse( 496 project().context().property("signing.secretKeyRingFile", null)); 497 var keyId = Optional.ofNullable(signingKeyId) 498 .orElse(project().context().property("signing.keyId", null)); 499 var passphrase = Optional.ofNullable(signingPassword) 500 .orElse(project().context().property("signing.password", null)) 501 .toCharArray(); 502 if (keyRingFileName == null || keyId == null || passphrase == null) { 503 logger.atWarning() 504 .log("Cannot sign artifacts: properties not set."); 505 return; 506 } 507 Security.addProvider(new BouncyCastleProvider()); 508 var secretKeyRingCollection = new PGPSecretKeyRingCollection( 509 PGPUtil.getDecoderStream( 510 Files.newInputStream(Path.of(keyRingFileName))), 511 new JcaKeyFingerprintCalculator()); 512 var secretKey = secretKeyRingCollection 513 .getSecretKey(Long.parseUnsignedLong(keyId, 16)); 514 publicKey = secretKey.getPublicKey(); 515 privateKey = secretKey.extractPrivateKey( 516 new JcePBESecretKeyDecryptorBuilder().setProvider("BC") 517 .build(passphrase)); 518 signerBuilder = new JcaPGPContentSignerBuilder( 519 publicKey.getAlgorithm(), PGPUtil.SHA256).setProvider("BC"); 520 } 521 522 private Path signResource(Path resource) 523 throws PGPException, IOException { 524 initSigning(); 525 PGPSignatureGenerator signatureGenerator = new PGPSignatureGenerator( 526 signerBuilder, publicKey); 527 signatureGenerator.init(PGPSignature.BINARY_DOCUMENT, privateKey); 528 var sigPath = destinationPath(resource, 529 resource.getFileName() + ".asc"); 530 try (InputStream fileInput = new BufferedInputStream( 531 Files.newInputStream(resource)); 532 OutputStream sigOut 533 = new ArmoredOutputStream(Files.newOutputStream(sigPath))) { 534 byte[] buffer = new byte[8192]; 535 while (true) { 536 int read = fileInput.read(buffer); 537 if (read < 0) { 538 break; 539 } 540 signatureGenerator.update(buffer, 0, read); 541 } 542 PGPSignature signature = signatureGenerator.generate(); 543 signature.encode(sigOut); 544 } 545 return sigPath; 546 } 547 548 private void deploySnapshot(List<Deployable> toDeploy) 549 throws DeploymentException { 550 // Now deploy everything 551 @SuppressWarnings("PMD.CloseResource") 552 var context = MvnRepoLookup.rootContext(); 553 var session = new DefaultRepositorySystemSession( 554 context.repositorySystemSession()); 555 var startMsgLogged = new AtomicBoolean(false); 556 session.setRepositoryListener(new AbstractRepositoryListener() { 557 @Override 558 public void artifactDeploying(RepositoryEvent event) { 559 if (!startMsgLogged.getAndSet(true)) { 560 logger.atInfo().log("Start deploying artifacts..."); 561 } 562 } 563 564 @Override 565 public void artifactDeployed(RepositoryEvent event) { 566 if (!"jar".equals(event.getArtifact().getExtension())) { 567 return; 568 } 569 logger.atInfo().log("Deployed: %s", event.getArtifact()); 570 } 571 572 @Override 573 public void metadataDeployed(RepositoryEvent event) { 574 logger.atInfo().log("Deployed: %s", event.getMetadata()); 575 } 576 577 }); 578 var user = Optional.ofNullable(repoUser) 579 .orElse(project().context().property("mvnrepo.user", "")); 580 var password = Optional.ofNullable(repoPass) 581 .orElse(project().context().property("mvnrepo.password", "")); 582 var repo = new RemoteRepository.Builder("mine", "default", 583 snapshotUri.toString()) 584 .setAuthentication(new AuthenticationBuilder() 585 .addUsername(user).addPassword(password).build()) 586 .build(); 587 var deployReq = new DeployRequest().setRepository(repo); 588 toDeploy.stream().map(d -> d.artifact).forEach(deployReq::addArtifact); 589 context.repositorySystem().deploy(session, deployReq); 590 } 591 592 @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") 593 private void deployRelease(Artifact mainArtifact, 594 List<Deployable> toDeploy) { 595 // Create zip file with all artifacts for release, see 596 // https://central.sonatype.org/publish/publish-portal-upload/ 597 var zipName = Optional.ofNullable(project().get(ArtifactId)) 598 .orElse(project().name()) + "-" + mainArtifact.getVersion() 599 + "-release.zip"; 600 var zipPath = artifactDirectory().resolve(zipName); 601 try { 602 Path praefix = Path.of(mainArtifact.getGroupId().replace('.', '/')) 603 .resolve(mainArtifact.getArtifactId()) 604 .resolve(mainArtifact.getVersion()); 605 try (ZipOutputStream zos 606 = new ZipOutputStream(Files.newOutputStream(zipPath))) { 607 for (Deployable d : toDeploy) { 608 var artifact = d.artifact(); 609 @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") 610 var entry = new ZipEntry(praefix.resolve( 611 artifact.getArtifactId() + "-" + artifact.getVersion() 612 + (artifact.getClassifier().isEmpty() 613 ? "" 614 : "-" + artifact.getClassifier()) 615 + "." + artifact.getExtension()) 616 .toString()); 617 zos.putNextEntry(entry); 618 try (var fis = Files.newInputStream( 619 artifact.getFile().toPath())) { 620 fis.transferTo(zos); 621 } 622 zos.closeEntry(); 623 } 624 } 625 } catch (IOException e) { 626 throw new BuildException("Failed to create release zip: %s", 627 e).from(this).cause(e); 628 } 629 630 try (var client = HttpClient.newHttpClient()) { 631 var boundary = "===" + System.currentTimeMillis() + "==="; 632 var user = Optional.ofNullable(repoUser) 633 .orElse(project().context().property("mvnrepo.user", "")); 634 var password = Optional.ofNullable(repoPass) 635 .orElse(project().context().property("mvnrepo.password", "")); 636 var token = new String(Base64.encode((user + ":" + password) 637 .getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8); 638 var effectiveUri = uploadUri; 639 if (publishAutomatically) { 640 effectiveUri = addQueryParameter( 641 uploadUri, "publishingType", "AUTOMATIC"); 642 } 643 HttpRequest request = HttpRequest.newBuilder().uri(effectiveUri) 644 .header("Authorization", "Bearer " + token) 645 .header("Content-Type", 646 "multipart/form-data; boundary=" + boundary) 647 .POST(HttpRequest.BodyPublishers 648 .ofInputStream(() -> getAsMultipart(zipPath, boundary))) 649 .build(); 650 logger.atInfo().log("Uploading release bundle..."); 651 HttpResponse<String> response = client.send(request, 652 HttpResponse.BodyHandlers.ofString()); 653 logger.atFinest().log("Upload response: %s", response.body()); 654 if (response.statusCode() / 100 != 2) { 655 throw new BuildException( 656 "Failed to upload release bundle: " + response.body()); 657 } 658 } catch (IOException | InterruptedException e) { 659 throw new BuildException("Failed to upload release bundle: %s", 660 e.getMessage()).from(this).cause(e); 661 } finally { 662 if (!keepSubArtifacts) { 663 zipPath.toFile().delete(); 664 } 665 } 666 } 667 668 @SuppressWarnings("PMD.UseTryWithResources") 669 private InputStream getAsMultipart(Path zipPath, String boundary) { 670 // Use Piped streams for streaming multipart content 671 var fromPipe = new PipedInputStream(); 672 673 // Write multipart content to pipe 674 ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); 675 OutputStream toPipe; 676 try { 677 toPipe = new PipedOutputStream(fromPipe); 678 } catch (IOException e) { 679 throw new UncheckedIOException(e); 680 } 681 executor.submit(() -> { 682 try (var mpOut = new BufferedOutputStream(toPipe)) { 683 final String lineFeed = "\r\n"; 684 @SuppressWarnings("PMD.InefficientStringBuffering") 685 StringBuilder intro = new StringBuilder(100) 686 .append("--").append(boundary).append(lineFeed) 687 .append("Content-Disposition: form-data; name=\"bundle\";" 688 + " filename=\"%s\"".formatted(zipPath.getFileName())) 689 .append(lineFeed) 690 .append("Content-Type: application/octet-stream") 691 .append(lineFeed).append(lineFeed); 692 mpOut.write( 693 intro.toString().getBytes(StandardCharsets.US_ASCII)); 694 Files.newInputStream(zipPath).transferTo(mpOut); 695 mpOut.write((lineFeed + "--" + boundary + "--") 696 .getBytes(StandardCharsets.US_ASCII)); 697 } catch (IOException e) { 698 throw new UncheckedIOException(e); 699 } finally { 700 executor.close(); 701 } 702 }); 703 return fromPipe; 704 } 705 706 private static URI addQueryParameter(URI uri, String key, String value) { 707 String query = uri.getQuery(); 708 try { 709 String newQueryParam 710 = key + "=" + URLEncoder.encode(value, "UTF-8"); 711 String newQuery = (query == null || query.isEmpty()) ? newQueryParam 712 : query + "&" + newQueryParam; 713 714 // Build a new URI with the new query string 715 return new URI(uri.getScheme(), uri.getAuthority(), uri.getPath(), 716 newQuery, uri.getFragment()); 717 } catch (UnsupportedEncodingException | URISyntaxException e) { 718 // UnsupportedEncodingException cannot happen, UTF-8 is standard. 719 // URISyntaxException cannot happen when starting with a valid URI 720 throw new IllegalArgumentException(e); 721 } 722 } 723}