Fase 3 v1: new qr types, svg export, clipboard, frame text, batch CSV
New static qr types: - App Link: Android intent:// links (with package + optional browser fallback) or a generic custom-scheme URI. iOS Universal Links need no special encoding (they're just plain https:// URLs). - Bluetooth: device name + MAC address. Purely informational, since unlike WIFI:/vCard there's no OS-native "scan to pair" convention. SVG export: already worked (format whitelist/dropdown existed since Fase 1), verified rather than reimplemented. Copy-to-clipboard button next to the download button on both qr list tables, using the Clipboard API against a fetched blob. Optional frame text label rendered below the qr code via GD after generation (raster formats only, no-op for svg/eps). Batch CSV upload (batch_qrcode.php): filename,link rows create dynamic qr codes with sane defaults, downloadable as a zip. Required refactoring Qrcode-intchil.php's generation path (previously always redirected/exited via failure()/success(), which can't run in a loop) into a private renderAndStore() that throws instead, shared by addQrcode() and the new addQrcodeBatch(). Qrcode.php's addQrcodeBatch() is a separate, deliberately duplicated implementation instead, since its generation logic is small enough that duplication carries less risk than refactoring the working external-API code path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
<?php
|
||||
require_once 'includes/bootstrap.php';
|
||||
require_once BASE_PATH . '/includes/auth_validate.php';
|
||||
require_once BASE_PATH . '/lib/DynamicQrcode/DynamicQrcode.php';
|
||||
|
||||
if ($_SESSION['type'] === 'user') {
|
||||
$_SESSION['failure'] = 'The "user" role is read-only and cannot create qr codes.';
|
||||
header('Location: index.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$dynamic_qrcode_instance = new DynamicQrcode();
|
||||
|
||||
$results = null;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
csrf_verify_or_die();
|
||||
|
||||
$id_owner = $_SESSION['type'] === 'super' ? ($_POST['id_owner'] ?? '') : $_SESSION['user_id'];
|
||||
|
||||
if (!isset($_FILES['csv_file']) || $_FILES['csv_file']['error'] !== UPLOAD_ERR_OK) {
|
||||
$_SESSION['failure'] = 'Please choose a CSV file to upload.';
|
||||
header('Location: batch_qrcode.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$handle = fopen($_FILES['csv_file']['tmp_name'], 'r');
|
||||
$rows = [];
|
||||
if ($handle !== false) {
|
||||
while (($row = fgetcsv($handle)) !== false) {
|
||||
$rows[] = $row;
|
||||
}
|
||||
fclose($handle);
|
||||
}
|
||||
|
||||
// Skip an optional header row.
|
||||
if (!empty($rows) && strtolower(trim($rows[0][0] ?? '')) === 'filename') {
|
||||
array_shift($rows);
|
||||
}
|
||||
|
||||
$successes = [];
|
||||
$failures = [];
|
||||
$created_ids = [];
|
||||
|
||||
foreach ($rows as $index => $row) {
|
||||
$line_number = $index + 1;
|
||||
$filename = $row[0] ?? '';
|
||||
$link = $row[1] ?? '';
|
||||
|
||||
$result = $dynamic_qrcode_instance->addQrcodeBatchRow($filename, $link, $id_owner);
|
||||
|
||||
if ($result['ok']) {
|
||||
$successes[] = $filename;
|
||||
$created_ids[] = $result['id'];
|
||||
} else {
|
||||
$failures[] = ['line' => $line_number, 'filename' => $filename, 'error' => $result['error']];
|
||||
}
|
||||
}
|
||||
|
||||
$zip_filename = null;
|
||||
|
||||
if (!empty($created_ids)) {
|
||||
$db = getDbInstance();
|
||||
$files = [];
|
||||
|
||||
foreach ($created_ids as $id) {
|
||||
$db->where('id', $id);
|
||||
$row = $db->getOne('dynamic_qrcodes');
|
||||
if ($row !== null) {
|
||||
$files[] = SAVED_QRCODE_DIRECTORY . $row['qrcode'];
|
||||
}
|
||||
}
|
||||
|
||||
$zip_filename = 'qrcodes_' . uniqid() . '.zip';
|
||||
$zip_path = SAVED_QRCODE_DIRECTORY . 'zip/' . $zip_filename;
|
||||
@unlink($zip_path);
|
||||
|
||||
$zip = new ZipArchive();
|
||||
$zip->open($zip_path, ZipArchive::CREATE);
|
||||
foreach ($files as $file) {
|
||||
$content = @file_get_contents($file);
|
||||
if ($content !== false) {
|
||||
$zip->addFromString(basename($file), $content);
|
||||
}
|
||||
}
|
||||
$zip->close();
|
||||
|
||||
$_SESSION['generated_zips'][] = $zip_filename;
|
||||
|
||||
audit_log('batch_qrcode_created', 'dynamic_qrcodes', implode(',', $created_ids));
|
||||
}
|
||||
|
||||
$results = [
|
||||
'successes' => $successes,
|
||||
'failures' => $failures,
|
||||
'zip_filename' => $zip_filename,
|
||||
];
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<title>Qrcode Generator</title>
|
||||
<head>
|
||||
<?php include './includes/head.php'; ?>
|
||||
</head>
|
||||
<body class="hold-transition sidebar-mini layout-fixed layout-navbar-fixed layout-footer-fixed">
|
||||
<div class="wrapper">
|
||||
<!-- Navbar -->
|
||||
<?php include './includes/navbar.php'; ?>
|
||||
<!-- /.navbar -->
|
||||
|
||||
<!-- Main Sidebar Container -->
|
||||
<?php include './includes/sidebar.php'; ?>
|
||||
<!-- /.Main Sidebar Container -->
|
||||
|
||||
<!-- Content Wrapper. Contains page content -->
|
||||
<div class="content-wrapper">
|
||||
<!-- Content Header (Page header) -->
|
||||
<div class="content-header">
|
||||
<div class="container-fluid">
|
||||
<div class="row mb-2">
|
||||
<div class="col-sm-6">
|
||||
<h1 class="m-0 text-dark">Batch-create dynamic qr codes</h1>
|
||||
</div><!-- /.col -->
|
||||
</div><!-- /.row -->
|
||||
</div><!-- /.container-fluid -->
|
||||
</div>
|
||||
<!-- /.content-header -->
|
||||
|
||||
<!-- Flash messages -->
|
||||
<?php include BASE_PATH.'/includes/flash_messages.php'; ?>
|
||||
<!-- /.Flash messages -->
|
||||
|
||||
<!-- Main content -->
|
||||
<section class="content">
|
||||
<div class="container-fluid">
|
||||
|
||||
<?php if ($results !== null): ?>
|
||||
<div class="card card-primary">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Result</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p><strong><?php echo count($results['successes']); ?></strong> qr code(s) created,
|
||||
<strong><?php echo count($results['failures']); ?></strong> row(s) failed.</p>
|
||||
|
||||
<?php if ($results['zip_filename']): ?>
|
||||
<a href="qrcode_zip_download.php?file=<?php echo rawurlencode($results['zip_filename']); ?>" class="btn btn-primary">
|
||||
<i class="fa fa-download"></i> Download all as ZIP
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($results['failures'])): ?>
|
||||
<table class="table table-striped table-bordered mt-3">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Line</th>
|
||||
<th>Filename</th>
|
||||
<th>Error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($results['failures'] as $failure): ?>
|
||||
<tr>
|
||||
<td><?php echo (int) $failure['line']; ?></td>
|
||||
<td><?php echo htmlspecialchars($failure['filename']); ?></td>
|
||||
<td><?php echo htmlspecialchars($failure['error']); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card card-primary">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Upload a CSV file</h3>
|
||||
</div>
|
||||
<form action="" method="post" enctype="multipart/form-data">
|
||||
<?php echo csrf_field(); ?>
|
||||
<div class="card-body">
|
||||
<p>The CSV needs two columns: <code>filename,link</code>. An optional header row
|
||||
starting with "filename" is skipped automatically. Each row creates one dynamic
|
||||
qr code (PNG, default colors/size) redirecting to the given link.</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="csv_file">CSV file</label>
|
||||
<input type="file" name="csv_file" id="csv_file" accept=".csv,text/csv" required="required" class="form-control">
|
||||
</div>
|
||||
|
||||
<?php if ($_SESSION['type'] === 'super'): ?>
|
||||
<div class="form-group">
|
||||
<label for="id_owner">Owner</label>
|
||||
<select name="id_owner" class="form-control">
|
||||
<option value="" selected>All</option>
|
||||
<?php
|
||||
require_once BASE_PATH . '/lib/Users/Users.php';
|
||||
$users_instance = new Users();
|
||||
$users = $users_instance->getAllUsers();
|
||||
foreach ($users as $user) {
|
||||
?>
|
||||
<option value="<?php echo $user["id"]; ?>"><?php echo htmlspecialchars($user["username"]); ?></option>
|
||||
<?php
|
||||
}
|
||||
?>
|
||||
</select>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<button type="submit" class="btn btn-primary">Upload and generate</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div><!--/. container-fluid -->
|
||||
</section><!-- /.content -->
|
||||
</div><!-- /.content-wrapper -->
|
||||
|
||||
<!-- Footer and scripts -->
|
||||
<?php include './includes/footer.php'; ?>
|
||||
</body>
|
||||
</html>
|
||||
Vendored
+20
@@ -500,3 +500,23 @@
|
||||
return false;
|
||||
});
|
||||
})(jQuery)
|
||||
// Copy a qr code image straight to the clipboard (Fase 3 UX feature).
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
document.querySelectorAll('.copy-qr-btn').forEach(function (btn) {
|
||||
btn.addEventListener('click', async function () {
|
||||
const icon = btn.querySelector('i');
|
||||
const originalClass = icon.className;
|
||||
|
||||
try {
|
||||
const response = await fetch(btn.getAttribute('data-qr-src'));
|
||||
const blob = await response.blob();
|
||||
await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]);
|
||||
|
||||
icon.className = 'fa fa-check';
|
||||
setTimeout(function () { icon.className = originalClass; }, 1500);
|
||||
} catch (err) {
|
||||
alert('Could not copy this image to the clipboard (your browser may not support this image format for clipboard access): ' + err.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,6 +44,12 @@
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-toggle="pill" href="#twofa" role="tab" aria-controls="custom-tabs-four-settings" aria-selected="false">2FA <i class="fa fa-key"></i></a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-toggle="pill" href="#applink" role="tab" aria-controls="custom-tabs-four-settings" aria-selected="false">App Link <i class="fas fa-mobile-alt"></i></a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-toggle="pill" href="#bluetooth" role="tab" aria-controls="custom-tabs-four-settings" aria-selected="false">Bluetooth <i class="fab fa-bluetooth-b"></i></a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@@ -90,6 +96,12 @@
|
||||
<div class="tab-pane fade" id="twofa" role="tabpanel" aria-labelledby="custom-tabs-four-profile-tab">
|
||||
<?php include BASE_PATH . '/forms/static/2fa.php'; ?>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="applink" role="tabpanel" aria-labelledby="custom-tabs-four-profile-tab">
|
||||
<?php include BASE_PATH . '/forms/static/applink.php'; ?>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="bluetooth" role="tabpanel" aria-labelledby="custom-tabs-four-profile-tab">
|
||||
<?php include BASE_PATH . '/forms/static/bluetooth.php'; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /.card -->
|
||||
|
||||
@@ -88,6 +88,14 @@ if (QRCODE_GENERATOR === "internal-chillerlan.qrcode") {
|
||||
<option value="eps">EPS</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-4">
|
||||
<div class="form-group">
|
||||
<label for="frame_text">Frame text</label>
|
||||
<input type="text" name="frame_text" value="" placeholder="e.g. Scan me" maxlength="60" class="form-control" id="frame_text">
|
||||
<small class="form-text text-muted">Optional label rendered below the code. Only applies to PNG/JPEG/GIF, not SVG/EPS.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<form class="form" action="static_qrcode.php?type=applink" method="post" id="static_form" enctype="multipart/form-data">
|
||||
<?php echo csrf_field(); ?>
|
||||
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
|
||||
<!-- Input forms -->
|
||||
<div class="col-sm-12 mb-2">
|
||||
<div class="row">
|
||||
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="form-group">
|
||||
<label>Platform *</label>
|
||||
<select name="platform" id="applink-platform" class="form-control">
|
||||
<option value="android" selected>Android (intent link)</option>
|
||||
<option value="generic">Generic (custom scheme)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="form-group">
|
||||
<label>Scheme *</label>
|
||||
<input type="text" name="scheme" value="" placeholder="myapp" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="form-group">
|
||||
<label>Path *</label>
|
||||
<input type="text" name="path" value="" placeholder="open?ref=123" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-md-3" id="applink-package-group">
|
||||
<div class="form-group">
|
||||
<label>Android package *</label>
|
||||
<input type="text" name="package" value="" placeholder="com.example.app" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-md-3" id="applink-fallback-group">
|
||||
<div class="form-group">
|
||||
<label>Fallback URL</label>
|
||||
<input type="text" name="fallback_url" value="" placeholder="https://play.google.com/store/apps/details?id=..." class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var platformSelect = document.getElementById('applink-platform');
|
||||
var packageGroup = document.getElementById('applink-package-group');
|
||||
var fallbackGroup = document.getElementById('applink-fallback-group');
|
||||
|
||||
function updateVisibility() {
|
||||
var isAndroid = platformSelect.value === 'android';
|
||||
packageGroup.style.display = isAndroid ? '' : 'none';
|
||||
fallbackGroup.style.display = isAndroid ? '' : 'none';
|
||||
}
|
||||
|
||||
platformSelect.addEventListener('change', updateVisibility);
|
||||
updateVisibility();
|
||||
})();
|
||||
</script>
|
||||
|
||||
<div class="col-sm-12 mb-2">
|
||||
<div class="row">
|
||||
<div class="col-6 col-md-3">
|
||||
<button type="submit" class="btn btn-primary">Submit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
@@ -0,0 +1,37 @@
|
||||
<form class="form" action="static_qrcode.php?type=bluetooth" method="post" id="static_form" enctype="multipart/form-data">
|
||||
<?php echo csrf_field(); ?>
|
||||
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
|
||||
<!-- Input forms -->
|
||||
<div class="col-sm-12 mb-2">
|
||||
<small class="form-text text-muted mb-2">
|
||||
There is no OS-native "scan to pair" standard for Bluetooth like there is for Wifi, so this
|
||||
just encodes the device name and address for reference - whoever scans it still pairs
|
||||
manually via their Bluetooth settings.
|
||||
</small>
|
||||
<div class="row">
|
||||
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="form-group">
|
||||
<label>Device name *</label>
|
||||
<input type="text" name="device_name" value="" placeholder="" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="form-group">
|
||||
<label>MAC address *</label>
|
||||
<input type="text" name="mac_address" value="" placeholder="AA:BB:CC:DD:EE:FF" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-12 mb-2">
|
||||
<div class="row">
|
||||
<div class="col-6 col-md-3">
|
||||
<button type="submit" class="btn btn-primary">Submit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
@@ -89,6 +89,9 @@
|
||||
<?php endif; ?>
|
||||
<!-- DOWNLOAD -->
|
||||
<a href="qrcode_image.php?type=dynamic&id=<?php echo $row['id']; ?>&download=1" class="btn btn-primary"><i class="fa fa-download"></i></a>
|
||||
|
||||
<!-- COPY TO CLIPBOARD -->
|
||||
<button type="button" class="btn btn-secondary copy-qr-btn" data-qr-src="qrcode_image.php?type=dynamic&id=<?php echo $row['id']; ?>" title="Copy image to clipboard"><i class="fa fa-copy"></i></button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
|
||||
@@ -85,6 +85,9 @@
|
||||
<?php endif; ?>
|
||||
<!-- DOWNLOAD -->
|
||||
<a href="qrcode_image.php?type=static&id=<?php echo $row['id']; ?>&download=1" class="btn btn-primary"><i class="fa fa-download"></i></a>
|
||||
|
||||
<!-- COPY TO CLIPBOARD -->
|
||||
<button type="button" class="btn btn-secondary copy-qr-btn" data-qr-src="qrcode_image.php?type=static&id=<?php echo $row['id']; ?>" title="Copy image to clipboard"><i class="fa fa-copy"></i></button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
|
||||
@@ -56,6 +56,12 @@
|
||||
<p>Add new</p>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="./batch_qrcode.php" <?php echo (CURRENT_PAGE == 'batch_qrcode.php') ? ' class="nav-link active"' : ' class="nav-link"'; ?>>
|
||||
<i class="far fa-circle nav-icon"></i>
|
||||
<p>Batch create (CSV)</p>
|
||||
</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
@@ -76,6 +76,45 @@ class DynamicQrcode {
|
||||
$this->qrcode_instance->addQrcode($input_data, $data_to_db, $data_to_qrcode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-safe variant used by batch_qrcode.php: creates one dynamic qr code from a
|
||||
* CSV row (filename + link) with sane defaults, returning a result array
|
||||
* (['ok' => bool, 'id'|'error' => ...]) instead of redirecting/exiting.
|
||||
*/
|
||||
public function addQrcodeBatchRow($filename, $link, $id_owner) {
|
||||
$filename = trim((string) $filename);
|
||||
$link = trim((string) $link);
|
||||
|
||||
if ($filename === '') {
|
||||
return ['ok' => false, 'error' => 'Filename is required.'];
|
||||
}
|
||||
|
||||
if ($link === '' || strlen($link) > 500) {
|
||||
return ['ok' => false, 'error' => 'Link is required and must be at most 500 characters.'];
|
||||
}
|
||||
|
||||
$data_to_db['id_owner'] = $id_owner !== '' ? $id_owner : NULL;
|
||||
$data_to_db['filename'] = htmlspecialchars($filename, ENT_QUOTES, 'UTF-8');
|
||||
$data_to_db['created_at'] = date('Y-m-d H:i:s');
|
||||
$data_to_db['link'] = htmlspecialchars($link, ENT_QUOTES, 'UTF-8');
|
||||
$data_to_db['created_by'] = $_SESSION['user_id'];
|
||||
$data_to_db['format'] = 'png';
|
||||
$data_to_db['identifier'] = randomString(rand(5, 8));
|
||||
$data_to_db['qrcode'] = $data_to_db['filename'].'.'.$data_to_db['format'];
|
||||
|
||||
$data_to_qrcode = READ_PATH.$data_to_db['identifier'];
|
||||
|
||||
$input_data = [
|
||||
'level' => 'L',
|
||||
'size' => 200,
|
||||
'foreground' => '#000000',
|
||||
'background' => '#ffffff',
|
||||
'frame_text' => '',
|
||||
];
|
||||
|
||||
return $this->qrcode_instance->addQrcodeBatch($input_data, $data_to_db, $data_to_qrcode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit qr code
|
||||
*
|
||||
|
||||
@@ -52,11 +52,11 @@ class Qrcode {
|
||||
$filename = trim((string) $filename);
|
||||
|
||||
if ($filename === '' || strlen($filename) > 45) {
|
||||
$this->failure('Filename must be between 1 and 45 characters.');
|
||||
throw new \InvalidArgumentException('Filename must be between 1 and 45 characters.');
|
||||
}
|
||||
|
||||
if (preg_match('#[\\/\\\\]#', $filename) || strpos($filename, '..') !== false || strpos($filename, "\0") !== false) {
|
||||
$this->failure('Filename cannot contain path separators.');
|
||||
throw new \InvalidArgumentException('Filename cannot contain path separators.');
|
||||
}
|
||||
|
||||
return $filename;
|
||||
@@ -66,12 +66,52 @@ class Qrcode {
|
||||
$format = strtolower((string) $format);
|
||||
|
||||
if (!in_array($format, self::ALLOWED_FORMATS, true)) {
|
||||
$this->failure('Invalid qr code format.');
|
||||
throw new \InvalidArgumentException('Invalid qr code format.');
|
||||
}
|
||||
|
||||
return $format;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an optional text label below the qr code. Only supported for raster
|
||||
* formats (png/jpg/jpeg/gif) via GD; a no-op for svg/svgbw/eps.
|
||||
*/
|
||||
private function addFrameText($path, $format, $text) {
|
||||
$text = trim((string) $text);
|
||||
$loaders = ['png' => 'imagecreatefrompng', 'jpg' => 'imagecreatefromjpeg', 'jpeg' => 'imagecreatefromjpeg', 'gif' => 'imagecreatefromgif'];
|
||||
$savers = ['png' => 'imagepng', 'jpg' => 'imagejpeg', 'jpeg' => 'imagejpeg', 'gif' => 'imagegif'];
|
||||
|
||||
if ($text === '' || !isset($loaders[$format]) || !is_file($path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$source = @$loaders[$format]($path);
|
||||
if ($source === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$width = imagesx($source);
|
||||
$height = imagesy($source);
|
||||
$padding = 30;
|
||||
|
||||
$canvas = imagecreatetruecolor($width, $height + $padding);
|
||||
$white = imagecolorallocate($canvas, 255, 255, 255);
|
||||
$black = imagecolorallocate($canvas, 0, 0, 0);
|
||||
imagefill($canvas, 0, 0, $white);
|
||||
imagecopy($canvas, $source, 0, 0, 0, 0, $width, $height);
|
||||
|
||||
$font = 5;
|
||||
$text_width = imagefontwidth($font) * strlen($text);
|
||||
$x = max(0, (int) (($width - $text_width) / 2));
|
||||
$y = $height + (int) (($padding - imagefontheight($font)) / 2);
|
||||
imagestring($canvas, $font, $x, $y, $text, $black);
|
||||
|
||||
$savers[$format]($canvas, $path);
|
||||
|
||||
imagedestroy($source);
|
||||
imagedestroy($canvas);
|
||||
}
|
||||
|
||||
public function getQrcode($id) {
|
||||
$db = getDbInstance();
|
||||
|
||||
@@ -121,6 +161,36 @@ class Qrcode {
|
||||
* We save into db the url of qrcode image
|
||||
*/
|
||||
public function addQrcode($input_data, $data_to_db, $data_to_qrcode) {
|
||||
try {
|
||||
$last_id = $this->renderAndStore($input_data, $data_to_db, $data_to_qrcode);
|
||||
} catch (\Throwable $e) {
|
||||
$this->failure($e->getMessage());
|
||||
}
|
||||
|
||||
audit_log('qrcode_created', $this->table, $last_id);
|
||||
$this->success('Qr code added successfully!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-safe variant of addQrcode(): generates and stores the qr code but returns a
|
||||
* result array (['ok' => bool, 'id'|'error' => ...]) instead of redirecting/exiting,
|
||||
* so batch_qrcode.php can create many codes in one request.
|
||||
*/
|
||||
public function addQrcodeBatch($input_data, $data_to_db, $data_to_qrcode) {
|
||||
try {
|
||||
$last_id = $this->renderAndStore($input_data, $data_to_db, $data_to_qrcode);
|
||||
audit_log('qrcode_created', $this->table, $last_id);
|
||||
return ['ok' => true, 'id' => $last_id];
|
||||
} catch (\Throwable $e) {
|
||||
return ['ok' => false, 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Core qr code rendering + storage, shared by addQrcode() and addQrcodeBatch().
|
||||
* Throws instead of calling failure() so batch processing can catch and continue.
|
||||
*/
|
||||
private function renderAndStore($input_data, $data_to_db, $data_to_qrcode) {
|
||||
$options = $this->setOptions($input_data);
|
||||
|
||||
$data_to_db['filename'] = $this->sanitizeFilename($data_to_db['filename']);
|
||||
@@ -324,9 +394,11 @@ class Qrcode {
|
||||
}
|
||||
catch(Exception $e)
|
||||
{
|
||||
$this->failure($e->getMessage());
|
||||
throw new \RuntimeException($e->getMessage());
|
||||
}
|
||||
|
||||
$this->addFrameText($filename, $fileExt, $input_data['frame_text'] ?? '');
|
||||
|
||||
// If you want you can customi<e qr code with logo
|
||||
//$this->addLogo($data_to_db['qrcode'], $options['optionlogo']);
|
||||
|
||||
@@ -334,15 +406,13 @@ class Qrcode {
|
||||
$last_id = $db->insert($this->table, $data_to_db);
|
||||
}
|
||||
else
|
||||
$this->failure('You cannot create a new qr code with an existing name on the server!');
|
||||
throw new \RuntimeException('You cannot create a new qr code with an existing name on the server!');
|
||||
|
||||
if ($last_id){
|
||||
audit_log('qrcode_created', $this->table, $last_id);
|
||||
$this->success('Qr code added successfully!');
|
||||
}
|
||||
else {
|
||||
$this->failure('Insert failed: ' . $db->getLastError());
|
||||
if (!$last_id) {
|
||||
throw new \RuntimeException('Insert failed: ' . $db->getLastError());
|
||||
}
|
||||
|
||||
return $last_id;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -353,7 +423,11 @@ class Qrcode {
|
||||
$db = getDbInstance();
|
||||
$old_qrcode = $this->getQrcode($input_data["id"]);
|
||||
|
||||
try {
|
||||
$data_to_db['filename'] = $this->sanitizeFilename($data_to_db['filename']);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$this->failure($e->getMessage());
|
||||
}
|
||||
$data_to_db['qrcode'] = $data_to_db['filename'].'.'.$old_qrcode["format"];
|
||||
|
||||
if(!file_exists(SAVED_QRCODE_DIRECTORY.$data_to_db['filename'].'.'.$old_qrcode["format"]) || $data_to_db['filename'] == $input_data["old_filename"]){
|
||||
|
||||
@@ -57,6 +57,46 @@ class Qrcode {
|
||||
return $format;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an optional text label below the qr code. Only supported for raster
|
||||
* formats (png/jpg/jpeg/gif) via GD; a no-op for svg/svgbw/eps.
|
||||
*/
|
||||
private function addFrameText($path, $format, $text) {
|
||||
$text = trim((string) $text);
|
||||
$loaders = ['png' => 'imagecreatefrompng', 'jpg' => 'imagecreatefromjpeg', 'jpeg' => 'imagecreatefromjpeg', 'gif' => 'imagecreatefromgif'];
|
||||
$savers = ['png' => 'imagepng', 'jpg' => 'imagejpeg', 'jpeg' => 'imagejpeg', 'gif' => 'imagegif'];
|
||||
|
||||
if ($text === '' || !isset($loaders[$format]) || !is_file($path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$source = @$loaders[$format]($path);
|
||||
if ($source === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$width = imagesx($source);
|
||||
$height = imagesy($source);
|
||||
$padding = 30;
|
||||
|
||||
$canvas = imagecreatetruecolor($width, $height + $padding);
|
||||
$white = imagecolorallocate($canvas, 255, 255, 255);
|
||||
$black = imagecolorallocate($canvas, 0, 0, 0);
|
||||
imagefill($canvas, 0, 0, $white);
|
||||
imagecopy($canvas, $source, 0, 0, 0, 0, $width, $height);
|
||||
|
||||
$font = 5;
|
||||
$text_width = imagefontwidth($font) * strlen($text);
|
||||
$x = max(0, (int) (($width - $text_width) / 2));
|
||||
$y = $height + (int) (($padding - imagefontheight($font)) / 2);
|
||||
imagestring($canvas, $font, $x, $y, $text, $black);
|
||||
|
||||
$savers[$format]($canvas, $path);
|
||||
|
||||
imagedestroy($source);
|
||||
imagedestroy($canvas);
|
||||
}
|
||||
|
||||
public function getQrcode($id) {
|
||||
$db = getDbInstance();
|
||||
|
||||
@@ -133,6 +173,8 @@ class Qrcode {
|
||||
$this->failure($e->getMessage());
|
||||
}
|
||||
|
||||
$this->addFrameText($filename, $data_to_db['format'], $input_data['frame_text'] ?? '');
|
||||
|
||||
// If you want you can customi<e qr code with logo
|
||||
//$this->addLogo($data_to_db['qrcode'], $options['optionlogo']);
|
||||
|
||||
@@ -151,6 +193,72 @@ class Qrcode {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-safe variant of addQrcode(): generates and stores the qr code but returns a
|
||||
* result array (['ok' => bool, 'id'|'error' => ...]) instead of redirecting/exiting,
|
||||
* so batch_qrcode.php can create many codes in one request. Deliberately does not
|
||||
* reuse addQrcode()/sanitizeFilename()/validateFormat(), since those call failure()
|
||||
* (redirect + exit) which would abort the whole batch after the first bad row.
|
||||
*/
|
||||
public function addQrcodeBatch($input_data, $data_to_db, $data_to_qrcode) {
|
||||
$filename = trim((string) $data_to_db['filename']);
|
||||
$format = strtolower((string) $data_to_db['format']);
|
||||
|
||||
if ($filename === '' || strlen($filename) > 45) {
|
||||
return ['ok' => false, 'error' => 'Filename must be between 1 and 45 characters.'];
|
||||
}
|
||||
|
||||
if (preg_match('#[\\/\\\\]#', $filename) || strpos($filename, '..') !== false || strpos($filename, "\0") !== false) {
|
||||
return ['ok' => false, 'error' => 'Filename cannot contain path separators.'];
|
||||
}
|
||||
|
||||
if (!in_array($format, self::ALLOWED_FORMATS, true)) {
|
||||
return ['ok' => false, 'error' => 'Invalid qr code format.'];
|
||||
}
|
||||
|
||||
$data_to_db['filename'] = $filename;
|
||||
$data_to_db['format'] = $format;
|
||||
|
||||
$path = SAVED_QRCODE_DIRECTORY.$filename.'.'.$format;
|
||||
|
||||
if (file_exists($path)) {
|
||||
return ['ok' => false, 'error' => 'A qr code with this filename already exists.'];
|
||||
}
|
||||
|
||||
$options = $this->setOptions($input_data);
|
||||
$url =
|
||||
'https://api.qrserver.com/v1/create-qr-code/?data='.
|
||||
$data_to_qrcode.
|
||||
'&&size='.$options['size'].'x'.$options['size'].
|
||||
'&ecc='.$options['errorCorrectionLevel'].
|
||||
'&margin=0&color='.$options['foreground'].
|
||||
'&bgcolor='.$options['background'].
|
||||
'&qzone=2'.
|
||||
'&format='.$format;
|
||||
|
||||
$content = @file_get_contents($url);
|
||||
if ($content === false) {
|
||||
return ['ok' => false, 'error' => 'Could not generate the qr code image.'];
|
||||
}
|
||||
|
||||
if (@file_put_contents($path, $content) === false) {
|
||||
return ['ok' => false, 'error' => 'Could not write the qr code file.'];
|
||||
}
|
||||
|
||||
$this->addFrameText($path, $format, $input_data['frame_text'] ?? '');
|
||||
|
||||
$db = getDbInstance();
|
||||
$last_id = $db->insert($this->table, $data_to_db);
|
||||
|
||||
if (!$last_id) {
|
||||
return ['ok' => false, 'error' => 'Insert failed: ' . $db->getLastError()];
|
||||
}
|
||||
|
||||
audit_log('qrcode_created', $this->table, $last_id);
|
||||
|
||||
return ['ok' => true, 'id' => $last_id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit qr code
|
||||
*
|
||||
|
||||
@@ -378,6 +378,68 @@ class StaticQrcode {
|
||||
$this->requiredFieldsError();
|
||||
}
|
||||
|
||||
/**
|
||||
* create a qr code of type "applink" (mobile app deep link)
|
||||
* @string platform -> required, "android" (intent:// link with optional fallback) or "generic" (plain custom-scheme URI)
|
||||
* @string scheme -> required, e.g. "myapp"
|
||||
* @string path -> required, e.g. "open?ref=123" (without the scheme prefix)
|
||||
* @string package -> required when platform is "android" (Android package name, e.g. com.example.app)
|
||||
* @string fallback_url -> optional, Play Store/web fallback used by the Android intent link
|
||||
*/
|
||||
public function applinkQrcode($platform, $scheme, $path, $package, $fallback_url)
|
||||
{
|
||||
$is_android = $platform === 'android';
|
||||
|
||||
if ($scheme != NULL && $path != NULL && (!$is_android || $package != NULL)) {
|
||||
if ($is_android) {
|
||||
$this->sData = 'intent://' . $path . '#Intent;scheme=' . $scheme . ';package=' . $package;
|
||||
if (!empty($fallback_url)) {
|
||||
$this->sData .= ';S.browser_fallback_url=' . rawurlencode($fallback_url);
|
||||
}
|
||||
$this->sData .= ';end';
|
||||
} else {
|
||||
$this->sData = $scheme . '://' . $path;
|
||||
}
|
||||
|
||||
$this->sContent = '<strong>Platform:</strong> ' . ($is_android ? 'Android (intent)' : 'Generic') . '<br>';
|
||||
$this->sContent .= '<strong>Scheme:</strong> ' . $scheme . '<br>';
|
||||
$this->sContent .= '<strong>Path:</strong> ' . $path;
|
||||
|
||||
if ($is_android) {
|
||||
$this->sContent .= '<br><strong>Package:</strong> ' . $package;
|
||||
}
|
||||
|
||||
if (!empty($fallback_url)) {
|
||||
$this->sContent .= '<br><strong>Fallback URL:</strong> ' . $fallback_url;
|
||||
}
|
||||
|
||||
$this->addQrcode("applink");
|
||||
} else {
|
||||
$this->requiredFieldsError();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* create a qr code of type "bluetooth" (device pairing info)
|
||||
* @string device_name -> required
|
||||
* @string mac_address -> required
|
||||
*
|
||||
* Note: unlike WIFI:/vCard there is no OS-native "scan to pair" convention for
|
||||
* Bluetooth, so this is purely informational - whoever scans it still has to pair
|
||||
* the device manually via their Bluetooth settings using the name/address shown.
|
||||
*/
|
||||
public function bluetoothQrcode($device_name, $mac_address)
|
||||
{
|
||||
if ($device_name != NULL && $mac_address != NULL) {
|
||||
$this->sData = 'BT:N:' . $device_name . ';M:' . $mac_address . ';';
|
||||
$this->sContent = '<strong>Device name:</strong> ' . $device_name . '<br>' . '<strong>MAC address:</strong> ' . $mac_address;
|
||||
|
||||
$this->addQrcode("bluetooth");
|
||||
} else {
|
||||
$this->requiredFieldsError();
|
||||
}
|
||||
}
|
||||
|
||||
public function getQrcode($id) {
|
||||
return $this->qrcode_instance->getQrcode($id);
|
||||
}
|
||||
@@ -410,6 +472,7 @@ class StaticQrcode {
|
||||
|
||||
$input_data["foreground"] = $_POST['foreground'];
|
||||
$input_data["background"] = $_POST['background'];
|
||||
$input_data["frame_text"] = $_POST['frame_text'] ?? '';
|
||||
|
||||
$data_to_qrcode = urlencode($this->sData);
|
||||
|
||||
|
||||
@@ -77,6 +77,12 @@ if($_SERVER["REQUEST_METHOD"] === "POST" && !isset($_POST["edit"])) {
|
||||
|
||||
case '2fa': $static_qrcode_instance->twofaQrcode($_POST['algorithms'], $_POST['secret'], rawurlencode($_POST['label']), rawurlencode($_POST['issuer']));
|
||||
break;
|
||||
|
||||
case 'applink': $static_qrcode_instance->applinkQrcode($_POST['platform'], $_POST['scheme'], $_POST['path'], $_POST['package'] ?? '', $_POST['fallback_url'] ?? '');
|
||||
break;
|
||||
|
||||
case 'bluetooth': $static_qrcode_instance->bluetoothQrcode($_POST['device_name'], $_POST['mac_address']);
|
||||
break;
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
||||
Reference in New Issue
Block a user