Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 31 additions & 8 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,47 @@ on:
push:
branches:
- master
pull_request:
branches:
- master

permissions:
contents: read

jobs:
build:
name: Build and analyze
name: Build, test, and analyze
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
- name: Check out repository
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Set up Python
uses: actions/setup-python@v5
with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- uses: SonarSource/sonarqube-scan-action@v6
python-version: '3.12'
cache: 'pip'

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt

- name: Run unit tests
run: python -m unittest discover -s tests -v

- name: SonarQube scan
uses: SonarSource/sonarqube-scan-action@v6
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
Comment on lines +39 to 43
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Guard Sonar scan against forked pull_request runs

This step now runs on every pull_request, but forked PRs do not receive repository secrets, so SONAR_TOKEN/SONAR_HOST_URL are empty in that context and the Sonar action fails; that makes the workflow fail for external contributors even when tests pass. This regression was introduced by adding the pull_request trigger without gating the Sonar step, so the scan should be conditional (or moved to a trusted event) for forked PRs.

Useful? React with 👍 / 👎.

# If you wish to fail your job when the Quality Gate is red, uncomment the
# following lines. This would typically be used to fail a deployment.
# - uses: SonarSource/sonarqube-quality-gate-action@v1

# Enable this if you want the pipeline to fail on a red quality gate.
# - name: SonarQube quality gate
# uses: SonarSource/sonarqube-quality-gate-action@v1
# timeout-minutes: 5
# env:
# SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
9 changes: 7 additions & 2 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from qrcoderesponse import qrcoderesponse

MAX_QR_TEXT_LENGTH = 2048
MAX_LOGO_BYTES = 2 * 1024 * 1024

app = Flask(
__name__,
Expand All @@ -10,14 +11,15 @@
template_folder="templates",
)
app.config.setdefault("MAX_QR_TEXT_LENGTH", MAX_QR_TEXT_LENGTH)
app.config.setdefault("MAX_CONTENT_LENGTH", MAX_LOGO_BYTES)


@app.route("/")
def show_form():
return render_template("index.html")


@app.route("/qr")
@app.route("/qr", methods=["GET", "POST"])
def generate_qrcode():
text = request.values.get("text")

Expand All @@ -32,7 +34,10 @@ def generate_qrcode():
if len(text) > max_length:
return f"Text parameter is too long (max {max_length} characters)", 400

return qrcoderesponse(text)
logo_file = request.files.get("logo")
logo_scale = request.values.get("logo_scale", 20)

return qrcoderesponse(text, logo_file=logo_file, logo_scale=logo_scale)


if __name__ == "__main__":
Expand Down
65 changes: 59 additions & 6 deletions app/qrcoderesponse.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import io
import os

from PIL import Image, ImageOps
import qrcode
from qrcode.constants import ERROR_CORRECT_H
from flask import send_file

DEFAULT_PIXEL_WIDTH = 50
DEFAULT_PIXEL_WIDTH = 12
DEFAULT_FG_COLOR = "#000000"
DEFAULT_BG_COLOR = "#ffffff"
MIN_PIXEL_WIDTH = 1
MAX_PIXEL_WIDTH = 100
DEFAULT_LOGO_SCALE = 20
MIN_LOGO_SCALE = 5
MAX_LOGO_SCALE = 30


def _pixel_width_from_env():
Expand All @@ -29,22 +34,70 @@ def _pixel_width_from_env():
BG_COLOR = os.getenv("BG_COLOR", DEFAULT_BG_COLOR)


def _normalized_logo_scale(scale_value):
try:
scale = int(scale_value)
except (TypeError, ValueError):
return DEFAULT_LOGO_SCALE

return max(MIN_LOGO_SCALE, min(MAX_LOGO_SCALE, scale))


def imagetoresponse(img):
img_file = io.BytesIO()
img.save(img_file, format="PNG")
img_file.seek(0)
return send_file(img_file, mimetype="image/png")


def makeqrcode(text):
def _add_center_logo(qr_image, logo_file, logo_scale):
qr_image = qr_image.convert("RGBA")
logo = Image.open(logo_file).convert("RGBA")

qr_width, qr_height = qr_image.size
target_ratio = _normalized_logo_scale(logo_scale) / 100
target_size = max(32, int(min(qr_width, qr_height) * target_ratio))

logo.thumbnail((target_size, target_size), Image.Resampling.LANCZOS)

padding = max(8, target_size // 8)
background = Image.new(
"RGBA",
(logo.width + (padding * 2), logo.height + (padding * 2)),
(255, 255, 255, 255),
)
background = ImageOps.expand(background, border=0, fill=(255, 255, 255, 255))

pos = (
(background.width - logo.width) // 2,
(background.height - logo.height) // 2,
)
background.alpha_composite(logo, dest=pos)

offset = (
(qr_width - background.width) // 2,
(qr_height - background.height) // 2,
)
qr_image.alpha_composite(background, dest=offset)
return qr_image.convert("RGB")


def makeqrcode(text, logo_file=None, logo_scale=DEFAULT_LOGO_SCALE):
qr = qrcode.QRCode(
version=1,
version=None,
error_correction=ERROR_CORRECT_H,
box_size=PIXEL_WIDTH,
border=4,
)
qr.add_data(text)
return qr.make_image(fill_color=FG_COLOR, back_color=BG_COLOR)
qr.make(fit=True)
image = qr.make_image(fill_color=FG_COLOR, back_color=BG_COLOR).convert("RGB")

if logo_file and getattr(logo_file, "filename", ""):
image = _add_center_logo(image, logo_file, logo_scale)

return image


def qrcoderesponse(text):
return imagetoresponse(makeqrcode(text))
def qrcoderesponse(text, logo_file=None, logo_scale=DEFAULT_LOGO_SCALE):
return imagetoresponse(makeqrcode(text, logo_file=logo_file, logo_scale=logo_scale))
63 changes: 57 additions & 6 deletions app/static/css/qr.css
Original file line number Diff line number Diff line change
@@ -1,17 +1,68 @@

.headmaster {
background: url('/img/bg-pattern.png'), linear-gradient(to left, #7b4397, #dc2430);
padding: 5px 0px 5px;
padding: 5px 0 5px;
}

.devicefull {
background-image:url('/img/iphone_6_plus_white_port.png');
background-image: url('/img/iphone_6_plus_white_port.png');
}

.qrtexting {
.qrtexting,
.qr-input {
width: 400px;
padding: 5px 15px 5px 15px;
padding: 5px 15px;
margin-bottom: 15px;
margin-right: 20px;
margin-left: 20px;
}
}

.form-label {
margin-left: 20px;
color: #ffffff;
font-weight: 600;
}

.logo-slider-wrap {
width: 400px;
margin: 0 20px 15px;
color: #ffffff;
}

.logo-slider-wrap span {
display: inline-block;
margin-top: 6px;
font-weight: 600;
}

.qr-screen {
display: flex;
align-items: center;
justify-content: center;
min-height: 460px;
}

.qrcode {
max-width: 100%;
max-height: 400px;
object-fit: contain;
}

.qr-placeholder {
width: 100%;
text-align: center;
color: #666666;
}

.qr-placeholder-box {
width: 260px;
height: 260px;
margin: 0 auto 12px;
border-radius: 18px;
border: 2px dashed #dddddd;
background: #ffffff;
}

.qr-placeholder p {
font-size: 1rem;
font-weight: 600;
}
90 changes: 74 additions & 16 deletions app/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,20 @@
<body id="page-top" data-bs-spy="scroll" data-bs-target="#mainNav" data-bs-offset="5">
<header class="masthead headmaster">
<div class="container h-100">
<div class="row h-100">
<div class="row h-100 align-items-center justify-content-center">
<div class="col-lg-5 my-auto">
<form>
<label for="qrtext">
QRCode contents:
</label>
<form id="qr-form" enctype="multipart/form-data">
<label for="qrtext">QRCode contents:</label>
<textarea class="form-control qrtexting" id="qrtext" name="text" placeholder="...enter text for QRCode!"></textarea>

<textarea class="form-control qrtexting" id="qrtext" placeholder="...enter text for QRCode!"></textarea>
<label class="form-label mt-3" for="logo">Logo (optional):</label>
<input class="form-control qr-input" type="file" id="logo" name="logo" accept="image/png,image/jpeg,image/webp,image/gif,image/svg+xml">

<label class="form-label mt-3" for="logo_scale">Logo size:</label>
<div class="logo-slider-wrap">
<input class="form-range" type="range" id="logo_scale" name="logo_scale" min="5" max="30" step="1" value="20">
<span id="logo-scale-value">20%</span>
</div>
</form>
<div class="device-container"></div>
<div class="iphone-mockup"></div>
Expand All @@ -32,7 +38,13 @@
<div class="device-container">
<div class="device-mockup iphone6_plus portrait white">
<div class="device devicefull">
<div class="screen"><img class="img-fluid d-flex justify-content-center align-items-center mx-auto qrcode" width="400px" height="auto" alt="Here's your QRCode!" id="qrcodeimg"></div>
<div class="screen qr-screen">
<div class="qr-placeholder" id="qrplaceholder">
<div class="qr-placeholder-box"></div>
<p>Enter text to generate QR</p>
</div>
<img class="img-fluid d-flex justify-content-center align-items-center mx-auto qrcode d-none" width="400" height="auto" alt="Here's your QRCode!" id="qrcodeimg">
</div>
<div class="button"></div>
</div>
</div>
Expand All @@ -54,14 +66,60 @@
<script src="/bootstrap/js/bootstrap.min.js"></script>
<script src="/js/new-age.js"></script>
<script>
var qrimg = document.getElementById('qrcodeimg');
var qrtxt = document.getElementById('qrtext');
function fetchcode(){
qrimg.src = '/qr?text='+qrtxt.value;
console.log(qrimg.src);
const qrimg = document.getElementById('qrcodeimg');
const qrtxt = document.getElementById('qrtext');
const logoInput = document.getElementById('logo');
const logoScale = document.getElementById('logo_scale');
const logoScaleValue = document.getElementById('logo-scale-value');
const placeholder = document.getElementById('qrplaceholder');

function showPlaceholder() {
qrimg.classList.add('d-none');
qrimg.removeAttribute('src');
placeholder.classList.remove('d-none');
}
qrtxt.onkeyup = fetchcode;
fetchcode();
</script>

async function fetchcode() {
const text = qrtxt.value.trim();
logoScaleValue.textContent = `${logoScale.value}%`;

if (!text) {
showPlaceholder();
return;
}

const formData = new FormData();
formData.append('text', text);
formData.append('logo_scale', logoScale.value);
if (logoInput.files[0]) {
formData.append('logo', logoInput.files[0]);
}

try {
const response = await fetch('/qr', {
method: 'POST',
body: formData
});

if (!response.ok) {
showPlaceholder();
return;
}

const blob = await response.blob();
const imageUrl = URL.createObjectURL(blob);
qrimg.src = imageUrl;
qrimg.classList.remove('d-none');
placeholder.classList.add('d-none');
} catch (error) {
showPlaceholder();
}
}

qrtxt.addEventListener('input', fetchcode);
logoInput.addEventListener('change', fetchcode);
logoScale.addEventListener('input', fetchcode);
showPlaceholder();
</script>
</body>
</html>
</html>
Loading