From e51cef80148c7fc79d53325275589f9f41702ca8 Mon Sep 17 00:00:00 2001 From: Slawomir Jaranowski Date: Thu, 20 Feb 2025 22:49:37 +0100 Subject: [PATCH] [MPIR-478] Allow to save avatars images for team report during project build --- pom.xml | 4 +- src/it/team-local-avatars/pom.xml | 82 ++++++++ src/it/team-local-avatars/verify.groovy | 26 +++ .../maven/report/projectinfo/TeamReport.java | 179 +++++++++++------- .../projectinfo/avatars/AvatarsProvider.java | 59 ++++++ .../projectinfo/avatars/GravatarProvider.java | 156 +++++++++++++++ .../projectinfo/avatars/default-avatar.jpg | Bin 0 -> 129 bytes .../report/projectinfo/TeamReportTest.java | 3 + .../avatars/GravatarProviderTest.java | 92 +++++++++ .../report/projectinfo/stubs/TeamStub.java | 15 ++ 10 files changed, 543 insertions(+), 73 deletions(-) create mode 100644 src/it/team-local-avatars/pom.xml create mode 100644 src/it/team-local-avatars/verify.groovy create mode 100644 src/main/java/org/apache/maven/report/projectinfo/avatars/AvatarsProvider.java create mode 100644 src/main/java/org/apache/maven/report/projectinfo/avatars/GravatarProvider.java create mode 100644 src/main/resources/org/apache/maven/report/projectinfo/avatars/default-avatar.jpg create mode 100644 src/test/java/org/apache/maven/report/projectinfo/avatars/GravatarProviderTest.java diff --git a/pom.xml b/pom.xml index 3f3732e5..cd81f6ef 100644 --- a/pom.xml +++ b/pom.xml @@ -28,7 +28,7 @@ under the License. maven-project-info-reports-plugin - 3.8.1-SNAPSHOT + 3.9.0-SNAPSHOT maven-plugin Apache Maven Project Info Reports Plugin @@ -123,7 +123,7 @@ under the License. 3.21.0 2.0.1 ParameterNumber,MethodLength - 2024-10-18T09:42:43Z + 2025-02-19T22:31:07Z diff --git a/src/it/team-local-avatars/pom.xml b/src/it/team-local-avatars/pom.xml new file mode 100644 index 00000000..3b068eb9 --- /dev/null +++ b/src/it/team-local-avatars/pom.xml @@ -0,0 +1,82 @@ + + + + + 4.0.0 + + org.apache.maven.plugins.project-info-reports + team-local-avatars + 1.0-SNAPSHOT + + + UTF-8 + UTF-8 + + + + + one-id + one-name + sjaranowski@apache.org + + + two-id + two-name + + + three-id + three-name + email3@ecample.com + + + + + + + org.apache.maven.plugins + maven-site-plugin + @sitePluginVersion@ + + + + + + true + + + org.apache.maven.plugins + maven-project-info-reports-plugin + @project.version@ + + false + + + + + team + + + + + + + diff --git a/src/it/team-local-avatars/verify.groovy b/src/it/team-local-avatars/verify.groovy new file mode 100644 index 00000000..aabeac8c --- /dev/null +++ b/src/it/team-local-avatars/verify.groovy @@ -0,0 +1,26 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +def team = new File( basedir, 'target/site/team.html' ).text + +assert team.count('
') == 1 +assert team.count('
') == 2 + +assert new File(basedir, 'target/site/avatars/90cc13b765c79d2d55ca64388ea2bc5f.jpg').exists() +assert new File(basedir, 'target/site/avatars/00000000000000000000000000000000.jpg').exists() diff --git a/src/main/java/org/apache/maven/report/projectinfo/TeamReport.java b/src/main/java/org/apache/maven/report/projectinfo/TeamReport.java index 5fb6bda4..3b93a1c0 100644 --- a/src/main/java/org/apache/maven/report/projectinfo/TeamReport.java +++ b/src/main/java/org/apache/maven/report/projectinfo/TeamReport.java @@ -20,9 +20,9 @@ import javax.inject.Inject; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; +import java.io.IOException; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Locale; @@ -32,10 +32,11 @@ import org.apache.maven.doxia.sink.Sink; import org.apache.maven.model.Contributor; import org.apache.maven.model.Developer; -import org.apache.maven.model.Model; import org.apache.maven.plugins.annotations.Mojo; import org.apache.maven.plugins.annotations.Parameter; +import org.apache.maven.project.MavenProject; import org.apache.maven.project.ProjectBuilder; +import org.apache.maven.report.projectinfo.avatars.AvatarsProvider; import org.apache.maven.reporting.MavenReportException; import org.apache.maven.repository.RepositorySystem; import org.codehaus.plexus.i18n.I18N; @@ -52,19 +53,51 @@ public class TeamReport extends AbstractProjectInfoReport { /** * Shows avatar images for team members that have a) properties/picUrl set b) An avatar at gravatar.com for their * email address - *

- * Future versions of this plugin may implement different strategies for resolving avatar images, possibly - * using different providers. - *

- *Note: This property will be renamed to {@code tteam.showAvatarImages} in 3.0. + * * @since 2.6 */ @Parameter(property = "teamlist.showAvatarImages", defaultValue = "true") private boolean showAvatarImages; + /** + * Indicate if URL should be used for avatar images. + *

+ * If set to false images will be downloaded and attached to report during build. + * Local path will be used for images. + * + * @since 3.9.0 + */ + @Parameter(property = "teamlist.externalAvatarImages", defaultValue = "true") + private boolean externalAvatarImages; + + /** + * Base URL for avatar provider. + * + * @since 3.9.0 + */ + @Parameter(property = "teamlist.avatarBaseUrl", defaultValue = "https://www.gravatar.com/avatar/") + private String avatarBaseUrl; + + /** + * Provider name for avatar images. + *

+ * Report has one implementation for gravatar.com. Users can provide other by implementing {@link AvatarsProvider}. + * + * @since 3.9.0 + */ + @Parameter(property = "teamlist.avatarProviderName", defaultValue = "gravatar") + private String avatarProviderName; + + private final Map avatarsProviders; + @Inject - public TeamReport(RepositorySystem repositorySystem, I18N i18n, ProjectBuilder projectBuilder) { + public TeamReport( + RepositorySystem repositorySystem, + I18N i18n, + ProjectBuilder projectBuilder, + Map avatarsProviders) { super(repositorySystem, i18n, projectBuilder); + this.avatarsProviders = avatarsProviders; } // ---------------------------------------------------------------------- @@ -83,10 +116,55 @@ public boolean canGenerateReport() throws MavenReportException { } @Override - public void executeReport(Locale locale) { - ProjectTeamRenderer r = - new ProjectTeamRenderer(getSink(), project.getModel(), getI18N(locale), locale, showAvatarImages); - r.render(); + public void executeReport(Locale locale) throws MavenReportException { + + Map avatarImages = prepareAvatars(); + + ProjectTeamRenderer renderer = + new ProjectTeamRenderer(getSink(), project, getI18N(locale), locale, showAvatarImages, avatarImages); + renderer.render(); + } + + private Map prepareAvatars() throws MavenReportException { + + if (!showAvatarImages) { + return Collections.emptyMap(); + } + + AvatarsProvider avatarsProvider = avatarsProviders.get(avatarProviderName); + if (avatarsProvider == null) { + throw new MavenReportException("No AvatarsProvider found for name " + avatarProviderName); + } + avatarsProvider.setBaseUrl(avatarBaseUrl); + avatarsProvider.setOutputDirectory(getReportOutputDirectory()); + + Map result = new HashMap<>(); + try { + prepareContributorAvatars(result, avatarsProvider, project.getDevelopers()); + prepareContributorAvatars(result, avatarsProvider, project.getContributors()); + } catch (IOException e) { + throw new MavenReportException("Unable to load avatar images", e); + } + return result; + } + + private void prepareContributorAvatars( + Map avatarImages, + AvatarsProvider avatarsProvider, + List contributors) + throws IOException { + + for (Contributor contributor : contributors) { + + String picSource = contributor.getProperties().getProperty("picUrl"); + if (picSource == null || picSource.isEmpty()) { + picSource = externalAvatarImages + ? avatarsProvider.getAvatarUrl(contributor.getEmail()) + : avatarsProvider.getLocalAvatarPath(contributor.getEmail()); + } + + avatarImages.put(contributor, picSource); + } } /** @@ -130,24 +208,24 @@ private static class ProjectTeamRenderer extends AbstractProjectInfoRenderer { private static final String ID = "id"; - private final Model model; + private final MavenProject mavenProject; private final boolean showAvatarImages; - private final String protocol; + private final Map avatarImages; - ProjectTeamRenderer(Sink sink, Model model, I18N i18n, Locale locale, boolean showAvatarImages) { + ProjectTeamRenderer( + Sink sink, + MavenProject mavenProject, + I18N i18n, + Locale locale, + boolean showAvatarImages, + Map avatarImages) { super(sink, i18n, locale); - this.model = model; + this.mavenProject = mavenProject; this.showAvatarImages = showAvatarImages; - - // prepare protocol for gravatar - if (model.getUrl() != null && model.getUrl().startsWith("https://")) { - this.protocol = "https"; - } else { - this.protocol = "http"; - } + this.avatarImages = avatarImages; } @Override @@ -164,11 +242,11 @@ protected void renderBody() { paragraph(getI18nString("intro.description2")); // Developer section - List developers = model.getDevelopers(); + List developers = mavenProject.getDevelopers(); startSection(getI18nString("developers.title")); - if (isEmpty(developers)) { + if (developers.isEmpty()) { paragraph(getI18nString("nodeveloper")); } else { paragraph(getI18nString("developers.intro")); @@ -191,11 +269,11 @@ protected void renderBody() { endSection(); // contributors section - List contributors = model.getContributors(); + List contributors = mavenProject.getContributors(); startSection(getI18nString("contributors.title")); - if (isEmpty(contributors)) { + if (contributors.isEmpty()) { paragraph(getI18nString("nocontributor")); } else { paragraph(getI18nString("contributors.intro")); @@ -223,17 +301,9 @@ private void renderTeamMember(Contributor member, Map headersMa sink.tableRow(); if (headersMap.get(IMAGE) == Boolean.TRUE && showAvatarImages) { - Properties properties = member.getProperties(); - String picUrl = properties.getProperty("picUrl"); - if (picUrl == null || picUrl.isEmpty()) { - picUrl = getGravatarUrl(member.getEmail()); - } - if (picUrl == null || picUrl.isEmpty()) { - picUrl = getSpacerGravatarUrl(); - } sink.tableCell(); sink.figure(); - sink.figureGraphics(picUrl); + sink.figureGraphics(avatarImages.get(member)); sink.figure_(); sink.tableCell_(); } @@ -288,35 +358,6 @@ private void renderTeamMember(Contributor member, Map headersMa sink.tableRow_(); } - private static final String AVATAR_SIZE = "s=60"; - - private String getSpacerGravatarUrl() { - return protocol + "://www.gravatar.com/avatar/00000000000000000000000000000000?d=blank&f=y&" + AVATAR_SIZE; - } - - private String getGravatarUrl(String email) { - if (email == null) { - return null; - } - email = StringUtils.trim(email); - email = email.toLowerCase(); - MessageDigest md; - try { - md = MessageDigest.getInstance("MD5"); - md.update(email.getBytes()); - byte[] byteData = md.digest(); - StringBuilder sb = new StringBuilder(); - final int lowerEightBitsOnly = 0xff; - for (byte aByteData : byteData) { - sb.append(Integer.toString((aByteData & lowerEightBitsOnly) + 0x100, 16) - .substring(1)); - } - return protocol + "://www.gravatar.com/avatar/" + sb.toString() + "?d=mm&" + AVATAR_SIZE; - } catch (NoSuchAlgorithmException e) { - return null; - } - } - private String[] getRequiredContrHeaderArray(Map requiredHeaders) { List requiredArray = new ArrayList<>(); String image = getI18nString("contributors.image"); @@ -461,7 +502,7 @@ private static Map checkRequiredHeaders(List list) { - return (list == null) || list.isEmpty(); - } } } diff --git a/src/main/java/org/apache/maven/report/projectinfo/avatars/AvatarsProvider.java b/src/main/java/org/apache/maven/report/projectinfo/avatars/AvatarsProvider.java new file mode 100644 index 00000000..c9c9975d --- /dev/null +++ b/src/main/java/org/apache/maven/report/projectinfo/avatars/AvatarsProvider.java @@ -0,0 +1,59 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.report.projectinfo.avatars; + +import java.io.File; +import java.io.IOException; + +/** + * Avatar provider API. + */ +public interface AvatarsProvider { + + /** + * Set a base URL for provider + * + * @param baseUrl for provider + */ + void setBaseUrl(String baseUrl); + + /** + * Set site output directory. Used to store avatar images in project. + * + * @param outputDirectory a site output directory + */ + void setOutputDirectory(File outputDirectory); + + /** + * Return a URL for avatar image. + * + * @param email email address for gravatar image + * @return a URL for avatar image + */ + String getAvatarUrl(String email); + + /** + * Return a local path to downloaded avatar image. + * + * @param email email address for gravatar image + * @return a local avatar path + * @throws IOException if problem with image downloading + */ + String getLocalAvatarPath(String email) throws IOException; +} diff --git a/src/main/java/org/apache/maven/report/projectinfo/avatars/GravatarProvider.java b/src/main/java/org/apache/maven/report/projectinfo/avatars/GravatarProvider.java new file mode 100644 index 00000000..d3b5405e --- /dev/null +++ b/src/main/java/org/apache/maven/report/projectinfo/avatars/GravatarProvider.java @@ -0,0 +1,156 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.report.projectinfo.avatars; + +import javax.inject.Named; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Locale; + +import org.codehaus.plexus.util.IOUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Provider for user avatar from gravatar.com + *

+ * Gravatar API + */ +@Named("gravatar") +class GravatarProvider implements AvatarsProvider { + + private static final Logger LOGGER = LoggerFactory.getLogger(GravatarProvider.class); + + private static final String AVATAR_SIZE = "s=60"; + + private static final String AVATAR_DIRECTORY = "avatars"; + + private static final String AVATAR_DEFAULT_FILE_NAME = "00000000000000000000000000000000.jpg"; + + private String baseUrl = "https://www.gravatar.com/avatar/"; + + private Path outputDirectory; + + @Override + public void setBaseUrl(String baseUrl) { + this.baseUrl = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/"; + } + + @Override + public void setOutputDirectory(File outputDirectory) { + this.outputDirectory = outputDirectory.toPath(); + } + + public String getAvatarUrl(String email) { + return getAvatarUrl(email, "blank"); + } + + private String getAvatarUrl(String email, String defaultAvatar) { + if (email == null || email.isEmpty()) { + return getSpacerGravatarUrl(); + } + + try { + email = email.trim().toLowerCase(Locale.ROOT); + MessageDigest md = MessageDigest.getInstance("MD5"); + md.update(email.getBytes()); + byte[] byteData = md.digest(); + StringBuilder sb = new StringBuilder(); + final int lowerEightBitsOnly = 0xff; + for (byte aByteData : byteData) { + sb.append(Integer.toString((aByteData & lowerEightBitsOnly) + 0x100, 16) + .substring(1)); + } + return baseUrl + sb + ".jpg?d=" + defaultAvatar + "&" + AVATAR_SIZE; + } catch (NoSuchAlgorithmException e) { + LOGGER.warn("Error while getting MD5 hash, use default image: {}", e.getMessage()); + return getSpacerGravatarUrl(); + } + } + + @Override + public String getLocalAvatarPath(String email) throws IOException { + // use 404 http status for not existing avatars + String avatarUrl = getAvatarUrl(email, "404"); + try { + URL url = new URI(avatarUrl).toURL(); + Path name = Paths.get(url.getPath()).getFileName(); + if (AVATAR_DEFAULT_FILE_NAME.equals(name.toString())) { + copyDefault(); + } else { + copyUrl(url, outputDirectory.resolve(AVATAR_DIRECTORY).resolve(name)); + } + return AVATAR_DIRECTORY + "/" + name; + } catch (URISyntaxException | IOException e) { + if (e instanceof FileNotFoundException) { + LOGGER.debug( + "Error while getting external avatar url for: {}, use default image: {}:{}", + email, + e.getClass().getName(), + e.getMessage()); + } else { + LOGGER.warn( + "Error while getting external avatar url for: {}, use default image: {}:{}", + email, + e.getClass().getName(), + e.getMessage()); + } + copyDefault(); + return AVATAR_DIRECTORY + "/" + AVATAR_DEFAULT_FILE_NAME; + } + } + + private String getSpacerGravatarUrl() { + return baseUrl + AVATAR_DEFAULT_FILE_NAME + "?d=blank&f=y&" + AVATAR_SIZE; + } + + private void copyUrl(URL url, Path outputPath) throws IOException { + if (!Files.exists(outputPath)) { + Files.createDirectories(outputPath.getParent()); + try (InputStream in = url.openStream(); + OutputStream out = Files.newOutputStream(outputPath)) { + LOGGER.debug("Copying URL {} to {}", url, outputPath); + IOUtil.copy(in, out); + } + } + } + + private void copyDefault() throws IOException { + Path outputPath = outputDirectory.resolve(AVATAR_DIRECTORY).resolve(AVATAR_DEFAULT_FILE_NAME); + if (!Files.exists(outputPath)) { + Files.createDirectories(outputPath.getParent()); + try (InputStream in = getClass().getResourceAsStream("default-avatar.jpg"); + OutputStream out = Files.newOutputStream(outputPath)) { + IOUtil.copy(in, out); + } + } + } +} diff --git a/src/main/resources/org/apache/maven/report/projectinfo/avatars/default-avatar.jpg b/src/main/resources/org/apache/maven/report/projectinfo/avatars/default-avatar.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a32c67def65557830b84e5d3bb8cd68093b44f1d GIT binary patch literal 129 zcmeAS@N?(olHy`uVBq!ia0vp^HXzKw1SGf4^HT*@EoV=IxSpVincent Siveton * @version $Id$ @@ -27,4 +32,14 @@ public class TeamStub extends ProjectInfoProjectStub { protected String getPOM() { return "team-plugin-config.xml"; } + + @Override + public List getContributors() { + return getModel().getContributors(); + } + + @Override + public List getDevelopers() { + return getModel().getDevelopers(); + } }