diff --git a/.github/workflows/master-build.yml b/.github/workflows/master-build.yml index 05b9fc31706..97cb4475dbd 100644 --- a/.github/workflows/master-build.yml +++ b/.github/workflows/master-build.yml @@ -1,15 +1,18 @@ name: Build + on: push: branches: - master + paths-ignore: + - 'docs/**' jobs: build: name: Build runs-on: ubuntu-latest steps: - - uses: actions/checkout@4 + - uses: actions/checkout@v4 with: fetch-depth: 0 @@ -50,21 +53,30 @@ jobs: ports: - 9200:9200 - 9300:9300 - options: -e="discovery.type=single-node" -e="xpack.security.enabled=false" --health-cmd="curl http://localhost:9200/_cluster/health" --health-interval=10s --health-timeout=5s --health-retries=10 + options: >- + -e="discovery.type=single-node" + -e="xpack.security.enabled=false" + --health-cmd="curl http://localhost:9200/_cluster/health" + --health-interval=10s + --health-timeout=5s + --health-retries=10 minio: image: docker.io/bawix/minio:2022 ports: - 9000:9000 - 9001:9001 - options: -e="MINIO_ROOT_USER=root" -e="MINIO_ROOT_PASSWORD=password" -e="MINIO_DEFAULT_BUCKETS=default" + options: >- + -e="MINIO_ROOT_USER=root" + -e="MINIO_ROOT_PASSWORD=password" + -e="MINIO_DEFAULT_BUCKETS=default" steps: - - name: Test Database + - name: Test Elasticsearch health env: ELASTIC_SEARCH_URL: http://localhost:${{ job.services.elasticsearch.ports[9200] }} run: | - echo $ELASTIC_SEARCH_URL + echo "Elasticsearch URL: $ELASTIC_SEARCH_URL" curl -fsSL "$ELASTIC_SEARCH_URL/_cat/health?h=status" - uses: actions/checkout@v4 @@ -92,7 +104,12 @@ jobs: restore-keys: ${{ runner.os }}-m2 - name: Generate certificates - run: cd application-engine/src/main/resources/certificates && openssl genrsa -out keypair.pem 4096 && openssl rsa -in keypair.pem -pubout -out public.crt && openssl pkcs8 -topk8 -inform PEM -outform DER -nocrypt -in keypair.pem -out private.der && cd ../../../../.. + run: | + cd application-engine/src/main/resources/certificates + openssl genrsa -out keypair.pem 4096 + openssl rsa -in keypair.pem -pubout -out public.crt + openssl pkcs8 -topk8 -inform PEM -outform DER -nocrypt -in keypair.pem -out private.der + cd ../../../../.. - name: Build run: mvn clean package install -DskipTests=true @@ -103,7 +120,7 @@ jobs: # SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} # run: mvn -B verify org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Dsonar.projectKey=netgrif_application-engine - - name: Build, test + - name: Test env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: mvn -B verify @@ -139,8 +156,10 @@ jobs: mvn javadoc:javadoc cp -r ./application-engine/target/reports/apidocs/* ./docs/javadoc/ - - uses: EndBug/add-and-commit@v9 - with: - add: docs - pathspec_error_handling: exitImmediately - message: 'CI - Update documentation' + - name: Commit docs if changed + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add docs + git diff --staged --quiet || git commit -m "CI - Update documentation" + git push \ No newline at end of file diff --git a/application-engine/pom.xml b/application-engine/pom.xml index 583df212b1a..b22f9aaec13 100644 --- a/application-engine/pom.xml +++ b/application-engine/pom.xml @@ -70,10 +70,6 @@ true - - jitpack.io - https://jitpack.io - @@ -359,9 +355,14 @@ - com.github.kenglxn.qrgen + com.google.zxing + core + 3.5.4 + + + com.google.zxing javase - 3.0.1 + 3.5.4 diff --git a/application-engine/src/main/java/com/netgrif/application/engine/business/qr/QrCode.java b/application-engine/src/main/java/com/netgrif/application/engine/business/qr/QrCode.java index 8c6d3ee1b30..244baa510ce 100644 --- a/application-engine/src/main/java/com/netgrif/application/engine/business/qr/QrCode.java +++ b/application-engine/src/main/java/com/netgrif/application/engine/business/qr/QrCode.java @@ -7,17 +7,22 @@ @Data public class QrCode { + public static final double DEFAULT_LOGO_RATIO = 0.15; + public static final int DEFAULT_LOGO_BACKGROUND_PADDING = 4; + public static final int DEFAULT_LOGO_BACKGROUND_ARC = 8; + public static final QrImageType DEFAULT_IMAGE_TYPE = QrImageType.PNG; + private String content; private String fileName; - private ImageType imageType = ImageType.JPG; + private QrImageType imageType = DEFAULT_IMAGE_TYPE; private int width = 250; private int height = 250; - private ErrorCorrectionLevel errorCorrectionLevel = ErrorCorrectionLevel.L; + private ErrorCorrectionLevel errorCorrectionLevel = ErrorCorrectionLevel.H; private int onColor = 0xFF000000; @@ -25,6 +30,12 @@ public class QrCode { private String charset = "ISO-8859-1"; + private double logoRatio = DEFAULT_LOGO_RATIO; + + private int logoBackgroundPadding = DEFAULT_LOGO_BACKGROUND_PADDING; + + private int logoBackgroundArc = DEFAULT_LOGO_BACKGROUND_ARC; + public QrCode(String fileName, String content) { this.fileName = fileName; this.content = content; diff --git a/application-engine/src/main/java/com/netgrif/application/engine/business/qr/QrImageType.java b/application-engine/src/main/java/com/netgrif/application/engine/business/qr/QrImageType.java new file mode 100644 index 00000000000..0fcc4e7d9cd --- /dev/null +++ b/application-engine/src/main/java/com/netgrif/application/engine/business/qr/QrImageType.java @@ -0,0 +1,20 @@ +package com.netgrif.application.engine.business.qr; + +public enum QrImageType { + + PNG("png"), + JPG("jpg"), + JPEG("jpeg"), + GIF("gif"), + BMP("bmp"); + + private final String format; + + QrImageType(String format) { + this.format = format; + } + + public String getFormat() { + return format; + } +} \ No newline at end of file diff --git a/application-engine/src/main/java/com/netgrif/application/engine/business/qr/QrService.java b/application-engine/src/main/java/com/netgrif/application/engine/business/qr/QrService.java index 17a337360de..c5057deca32 100644 --- a/application-engine/src/main/java/com/netgrif/application/engine/business/qr/QrService.java +++ b/application-engine/src/main/java/com/netgrif/application/engine/business/qr/QrService.java @@ -1,92 +1,242 @@ package com.netgrif.application.engine.business.qr; import com.google.zxing.BarcodeFormat; +import com.google.zxing.EncodeHintType; import com.google.zxing.WriterException; import com.google.zxing.client.j2se.MatrixToImageConfig; import com.google.zxing.client.j2se.MatrixToImageWriter; import com.google.zxing.common.BitMatrix; -import net.glxn.qrgen.javase.QRCode; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import com.google.zxing.qrcode.QRCodeWriter; +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import javax.imageio.ImageIO; -import java.awt.*; +import java.awt.AlphaComposite; +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.RenderingHints; import java.awt.image.BufferedImage; import java.io.*; import java.nio.file.Files; -import java.nio.file.Paths; -import java.nio.file.StandardCopyOption; +import java.nio.file.Path; +import java.util.EnumMap; +import java.util.Map; import java.util.Optional; +@Slf4j @Service public class QrService implements IQrService { - private static Logger log = LoggerFactory.getLogger(QrService.class); @Override public Optional generateToStream(QrCode code) { - try { - return Optional.of(new FileInputStream(generateFile(code))); - } catch (FileNotFoundException e) { - log.error("Error creating qr code.", e); + log.debug("Generating QR code to stream [file={}, size={}x{}, errorCorrection={}]", code.getFileName(), code.getWidth(), code.getHeight(), code.getErrorCorrectionLevel()); + try (ByteArrayOutputStream os = new ByteArrayOutputStream()) { + BitMatrix bitMatrix = createBitMatrix(code); + MatrixToImageConfig config = new MatrixToImageConfig(code.getOnColor(), code.getOffColor()); + String format = resolveImageFormat(code); + + MatrixToImageWriter.writeToStream(bitMatrix, format, os, config); + + log.trace("QR code stream generated successfully [file={}, bytes={}]", code.getFileName(), os.size()); + return Optional.of(new ByteArrayInputStream(os.toByteArray())); + } catch (WriterException | IOException e) { + log.error("Failed to generate QR code to stream [file={}]", code.getFileName(), e); return Optional.empty(); } } @Override public Optional generateToFile(QrCode code) { + log.debug("Generating QR code to file [file={}, size={}x{}, errorCorrection={}]", code.getFileName(), code.getWidth(), code.getHeight(), code.getErrorCorrectionLevel()); try { - return Optional.ofNullable(generateFile(code)); - } catch (Exception e) { - log.error("Error creating qr code.", e); + File result = generateFile(code); + log.trace("QR code file generated successfully [file={}, sizeBytes={}]", result.getAbsolutePath(), result.length()); + return Optional.of(result); + } catch (WriterException | IOException e) { + log.error("Failed to generate QR code to file [file={}]", code.getFileName(), e); return Optional.empty(); } } @Override public Optional generateWithLogo(QrCode code, InputStream imageStream) { + log.debug("Generating QR code with logo [file={}, size={}x{}, errorCorrection={}, logoRatio={}]", code.getFileName(), code.getWidth(), code.getHeight(), code.getErrorCorrectionLevel(), code.getLogoRatio()); + + validateLogoErrorCorrection(code); + + BufferedImage overlay = readLogoImage(imageStream, code.getFileName()); + if (overlay == null) { + return Optional.empty(); + } + try { - ByteArrayOutputStream os = new ByteArrayOutputStream(); - BitMatrix bitMatrix = QRCode.from(code.getContent()) - .to(code.getImageType()) - .withSize(code.getWidth(), code.getHeight()) - .withColor(code.getOnColor(), code.getOffColor()) - .withErrorCorrection(code.getErrorCorrectionLevel()) - .withCharset(code.getCharset()) - .getQrWriter().encode(code.getContent(), BarcodeFormat.QR_CODE, code.getWidth(), code.getHeight()); + BitMatrix bitMatrix = createBitMatrix(code); MatrixToImageConfig config = new MatrixToImageConfig(code.getOnColor(), code.getOffColor()); - BufferedImage qrImage = MatrixToImageWriter.toBufferedImage(bitMatrix, config); - BufferedImage overly = ImageIO.read(imageStream); - int deltaHeight = qrImage.getHeight() - overly.getHeight(); - int deltaWidth = qrImage.getWidth() - overly.getWidth(); + log.trace("QR BitMatrix created [file={}, qrSize={}x{}]", code.getFileName(), qrImage.getWidth(), qrImage.getHeight()); + + BufferedImage scaledLogo = scaleLogo(overlay, qrImage.getWidth(), qrImage.getHeight(), code.getLogoRatio()); + BufferedImage combined = compositeLogoOnQr(qrImage, scaledLogo, code); + writeImageToFile(combined, code); + + File result = new File(code.getFileName()); + log.trace("QR code with logo written to disk [file={}, sizeBytes={}]", result.getAbsolutePath(), result.length()); + return Optional.of(result); + } catch (WriterException | IOException e) { + log.error("Failed to generate QR code with logo [file={}]", code.getFileName(), e); + return Optional.empty(); + } + } + + private File generateFile(QrCode code) throws WriterException, IOException { + BitMatrix bitMatrix = createBitMatrix(code); + MatrixToImageConfig config = new MatrixToImageConfig(code.getOnColor(), code.getOffColor()); + Path outputPath = Path.of(code.getFileName()); + + try (OutputStream outputStream = Files.newOutputStream(outputPath)) { + MatrixToImageWriter.writeToStream(bitMatrix, resolveImageFormat(code), outputStream, config); + } catch (IOException e) { + log.error("Failed to write QR code to file [file={}, path={}]", code.getFileName(), outputPath.toAbsolutePath(), e); + throw e; + } + + return outputPath.toFile(); + } + + private BitMatrix createBitMatrix(QrCode code) throws WriterException { + Map hints = new EnumMap<>(EncodeHintType.class); + hints.put(EncodeHintType.CHARACTER_SET, code.getCharset()); + hints.put(EncodeHintType.ERROR_CORRECTION, code.getErrorCorrectionLevel()); + hints.put(EncodeHintType.MARGIN, 1); + + log.trace("Encoding BitMatrix [file={}, charset={}, errorCorrection={}, margin=1]", code.getFileName(), code.getCharset(), code.getErrorCorrectionLevel()); + + return new QRCodeWriter().encode( + code.getContent(), + BarcodeFormat.QR_CODE, + code.getWidth(), + code.getHeight(), + hints + ); + } + + private BufferedImage readLogoImage(InputStream imageStream, String fileName) { + try { + BufferedImage image = ImageIO.read(imageStream); + if (image == null) { + log.error("Logo image stream produced a null image — unsupported format or empty stream [file={}]", fileName); + return null; + } + log.trace("Logo image read successfully [file={}, logoSize={}x{}]", + fileName, image.getWidth(), image.getHeight()); + return image; + } catch (IOException e) { + log.error("Failed to read logo image stream [file={}]", fileName, e); + return null; + } + } + + private BufferedImage scaleLogo(BufferedImage logo, int qrWidth, int qrHeight, double logoRatio) { + int maxSize = (int) (Math.min(qrWidth, qrHeight) * logoRatio); + int logoWidth = logo.getWidth(); + int logoHeight = logo.getHeight(); + + log.trace("Logo scale check [original={}x{}, maxAllowed={}px, ratio={}]", logoWidth, logoHeight, maxSize, logoRatio); + + if (logoWidth <= maxSize && logoHeight <= maxSize) { + log.debug("Logo fits within ratio, no scaling needed [logo={}x{}, max={}px]", logoWidth, logoHeight, maxSize); + return logo; + } + + double scale = Math.min((double) maxSize / logoWidth, (double) maxSize / logoHeight); + int scaledWidth = Math.max(1, (int) (logoWidth * scale)); + int scaledHeight = Math.max(1, (int) (logoHeight * scale)); + + log.debug("Scaling logo [from={}x{} to={}x{}, maxAllowed={}px, scale={}]", + logoWidth, logoHeight, scaledWidth, scaledHeight, maxSize, String.format("%.4f", scale)); + + BufferedImage scaled = new BufferedImage(scaledWidth, scaledHeight, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = scaled.createGraphics(); + try { + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); + g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g.drawImage(logo, 0, 0, scaledWidth, scaledHeight, null); + } finally { + g.dispose(); + } + + return scaled; + } + + private BufferedImage compositeLogoOnQr(BufferedImage qrImage, BufferedImage logo, QrCode code) { + int x = (qrImage.getWidth() - logo.getWidth()) / 2; + int y = (qrImage.getHeight() - logo.getHeight()) / 2; + int padding = code.getLogoBackgroundPadding(); + int arc = code.getLogoBackgroundArc(); + + log.trace("Compositing logo onto QR [qr={}x{}, logo={}x{}, offset=({},{}), padding={}, arc={}]", qrImage.getWidth(), qrImage.getHeight(), logo.getWidth(), logo.getHeight(), x, y, padding, arc); - BufferedImage combined = new BufferedImage(qrImage.getHeight(), qrImage.getWidth(), BufferedImage.TYPE_INT_ARGB); - Graphics2D g = (Graphics2D) combined.getGraphics(); + BufferedImage combined = new BufferedImage( + qrImage.getWidth(), + qrImage.getHeight(), + BufferedImage.TYPE_INT_ARGB + ); + Graphics2D g = combined.createGraphics(); + try { + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g.drawImage(qrImage, 0, 0, null); + g.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 1f)); - g.drawImage(overly, Math.round(deltaWidth / 2), Math.round(deltaHeight / 2), null); + g.setColor(Color.WHITE); + g.fillRoundRect( + x - padding, + y - padding, + logo.getWidth() + padding * 2, + logo.getHeight() + padding * 2, + arc, + arc + ); - ImageIO.write(combined, "png", os); - Files.copy(new ByteArrayInputStream(os.toByteArray()), Paths.get(code.getFileName()), StandardCopyOption.REPLACE_EXISTING); + g.drawImage(logo, x, y, null); + } finally { + g.dispose(); + } - return Optional.of(new File(code.getFileName())); - } catch (WriterException | IOException e) { - log.error("Error creating qr code.", e); - return Optional.empty(); + return combined; + } + + private void writeImageToFile(BufferedImage image, QrCode code) throws IOException { + String format = resolveImageFormat(code); + Path outputPath = Path.of(code.getFileName()); + + log.trace("Writing combined image to disk [file={}, format={}]", code.getFileName(), format); + + try (OutputStream outputStream = Files.newOutputStream(outputPath)) { + boolean written = ImageIO.write(image, format, outputStream); + if (!written) { + log.error("Failed to write image to disk [file={}, format={}, path={}]", code.getFileName(), format, outputPath.toAbsolutePath()); + throw new IOException("No ImageIO writer found for format: " + format); + } } } - private File generateFile(QrCode code) { - return QRCode.from(code.getContent()) - .to(code.getImageType()) - .withSize(code.getWidth(), code.getHeight()) - .withColor(code.getOnColor(), code.getOffColor()) - .withErrorCorrection(code.getErrorCorrectionLevel()) - .withCharset(code.getCharset()) - .file(code.getFileName()); + + private void validateLogoErrorCorrection(QrCode code) { + if (code.getErrorCorrectionLevel() != ErrorCorrectionLevel.H) { + log.warn("QR code '{}' uses ErrorCorrectionLevel.{} — ErrorCorrectionLevel.H is strongly recommended when embedding a logo to ensure reliable scanning.", code.getFileName(), code.getErrorCorrectionLevel()); + } + } + + private String resolveImageFormat(QrCode code) { + if (code.getImageType() == null) { + log.trace("No image type set, defaulting to PNG [file={}]", code.getFileName()); + return QrImageType.PNG.getFormat(); + } + return code.getImageType().getFormat(); } } \ No newline at end of file