Skip to content
Open
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
17 changes: 16 additions & 1 deletion app/Controllers/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace App\Controllers;

use App\Libraries\Barcode_lib;
use App\Libraries\Image_lib;
use App\Libraries\Mailchimp_lib;
use App\Libraries\Receiving_lib;
use App\Libraries\Sale_lib;
Expand Down Expand Up @@ -249,6 +250,10 @@ public function getIndex(): string
$data['image_allowed_types'] = array_combine($image_allowed_types, $image_allowed_types);
$data['selected_image_allowed_types'] = explode(',', $this->config['image_allowed_types']);

$exif_fields = ['Make', 'Model', 'Orientation', 'Copyright', 'Software', 'DateTime', 'GPS'];
$data['exif_fields'] = array_combine($exif_fields, $exif_fields);
$data['selected_exif_fields'] = array_filter(explode(',', $this->config['exif_fields_to_keep'] ?? ''));

// Integrations Related fields
$data['mailchimp'] = [];

Expand Down Expand Up @@ -354,6 +359,15 @@ private function upload_logo(): array

$file->move(FCPATH . 'uploads/', $file_info['raw_name'] . '.' . $file_info['file_ext'], true);

$exif_fields_to_keep = array_filter(explode(',', $this->appconfig->get_value('exif_fields_to_keep', 'Copyright,Orientation,Software')));
if (!empty($exif_fields_to_keep)) {
$image_lib = new Image_lib();
$filepath = FCPATH . 'uploads/' . $file_info['raw_name'] . '.' . $file_info['file_ext'];
if (!$image_lib->stripEXIF($filepath, $exif_fields_to_keep)) {
log_message('warning', 'EXIF stripping failed for: ' . $filepath);
}
}

return ($file_info);
}

Expand Down Expand Up @@ -381,7 +395,8 @@ public function postSaveGeneral(): ResponseInterface
'image_max_width' => $this->request->getPost('image_max_width', FILTER_SANITIZE_NUMBER_INT),
'image_max_height' => $this->request->getPost('image_max_height', FILTER_SANITIZE_NUMBER_INT),
'image_max_size' => $this->request->getPost('image_max_size', FILTER_SANITIZE_NUMBER_INT),
'image_allowed_types' => implode(',', $this->request->getPost('image_allowed_types')),
'image_allowed_types' => implode(',', $this->request->getPost('image_allowed_types') ?? []),
'exif_fields_to_keep' => implode(',', $this->request->getPost('exif_fields_to_keep') ?? []),
'gcaptcha_enable' => $this->request->getPost('gcaptcha_enable') != null,
'gcaptcha_secret_key' => $this->request->getPost('gcaptcha_secret_key'),
'gcaptcha_site_key' => $this->request->getPost('gcaptcha_site_key'),
Expand Down
14 changes: 14 additions & 0 deletions app/Controllers/Items.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
namespace App\Controllers;

use App\Libraries\Barcode_lib;
use App\Libraries\Image_lib;
use App\Libraries\Item_lib;

use App\Models\Appconfig;
use App\Models\Attribute;
use App\Models\Inventory;
use App\Models\Item;
Expand Down Expand Up @@ -39,6 +41,7 @@ class Items extends Secure_Controller
private Stock_location $stock_location;
private Supplier $supplier;
private Tax_category $tax_category;
private Appconfig $appconfig;
private array $config;


Expand All @@ -62,6 +65,7 @@ public function __construct()
$this->stock_location = model(Stock_location::class);
$this->supplier = model(Supplier::class);
$this->tax_category = model(Tax_category::class);
$this->appconfig = model(Appconfig::class);
$this->config = config(OSPOS::class)->settings;
}

Expand Down Expand Up @@ -780,6 +784,16 @@ private function upload_image(): array
];

$file->move(FCPATH . 'uploads/item_pics/', $file_info['raw_name'] . '.' . $file_info['file_ext'], true);

$exif_fields_to_keep = array_filter(explode(',', $this->appconfig->get_value('exif_fields_to_keep', 'Copyright,Orientation,Software')));
if (!empty($exif_fields_to_keep)) {
$image_lib = new Image_lib();
$filepath = FCPATH . 'uploads/item_pics/' . $file_info['raw_name'] . '.' . $file_info['file_ext'];
if (!$image_lib->stripEXIF($filepath, $exif_fields_to_keep)) {
log_message('warning', 'EXIF stripping failed for: ' . $filepath);
}
}

return ($file_info);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

namespace App\Database\Migrations;

use CodeIgniter\Database\Migration;
use Config\Database;

class MigrationEXIFStrippingOptions extends Migration
{
/**
* Perform a migration step.
*/
public function up(): void
{
log_message('info', 'Migrating EXIF Stripping Options');

$db = Database::connect();

$configs = [
[
'key' => 'exif_fields_to_keep',
'value' => 'Copyright,Orientation,Software'
]
];

foreach ($configs as $config) {
$existing = $db->table('app_config')
->where('key', $config['key'])
->get()
->getRow();

if ($existing === null) {
$db->table('app_config')->insert($config);
}
}
}

/**
* Revert a migration step.
*/
public function down(): void
{
$db = Database::connect();

$db->table('app_config')
->where('key', 'exif_fields_to_keep')
->delete();
}
}
2 changes: 2 additions & 0 deletions app/Language/en/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -328,4 +328,6 @@
"wholesale_markup" => "",
"work_order_enable" => "Work Order Support",
"work_order_format" => "Work Order Format",
"exif_fields_to_keep" => "EXIF Fields to Keep",
"exif_fields_to_keep_tooltip" => "Select EXIF fields to preserve in uploaded images. Fields not selected will be removed. Leave empty to disable EXIF stripping. Keeps beneficial metadata while removing privacy-sensitive data like GPS location.",
];
220 changes: 220 additions & 0 deletions app/Libraries/Image_lib.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
<?php

namespace App\Libraries;

use lsolesen\pel\PelIfd;
use lsolesen\pel\PelJpeg;
use lsolesen\pel\PelTag;

class Image_lib
{
private array $exif_to_pel_tags = [
'Make' => PelTag::MAKE,
'Model' => PelTag::MODEL,
'Orientation' => PelTag::ORIENTATION,
'Copyright' => PelTag::COPYRIGHT,
'Software' => PelTag::SOFTWARE,
'DateTime' => PelTag::DATE_TIME,
];

public function stripEXIF(string $filepath, array $fields_to_keep = []): bool
{
if (!file_exists($filepath)) {
return false;
}

$mimetype = mime_content_type($filepath);
$allowed_types = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];

if (!in_array($mimetype, $allowed_types)) {
return false;
}

if ($mimetype === 'image/jpeg' || $mimetype === 'image/jpg') {
return $this->stripExifJpeg($filepath, $fields_to_keep);
}

if ($mimetype === 'image/png') {
return $this->stripExifPng($filepath);
}

if ($mimetype === 'image/gif') {
return $this->stripExifGif($filepath);
}

if ($mimetype === 'image/webp') {
return $this->stripExifWebp($filepath);
}

return false;
}

private function stripExifJpeg(string $filepath, array $fields_to_keep = []): bool
{
try {
$data = file_get_contents($filepath);
if ($data === false) {
return false;
}

$jpeg = new PelJpeg($data);

$exif = $jpeg->getExif();
if ($exif === null) {
return true;
}

$tiff = $exif->getTiff();
if ($tiff === null) {
return true;
}

$ifd0 = $tiff->getIfd();
if ($ifd0 !== null) {
$this->removeExifFields($ifd0, $fields_to_keep);

$subIfd = $ifd0->getSubIfd(PelTag::EXIF_IFD_POINTER);
if ($subIfd !== null) {
$this->removeExifFields($subIfd, $fields_to_keep);
}

if (!in_array('GPS', $fields_to_keep)) {
$ifd0->removeEntry(PelTag::GPS_INFO_IFD_POINTER);
}
Comment on lines +81 to +83
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | πŸ”΄ Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if there's a way to verify GPS removal or if there are any tests
rg -n 'GPS|gps' app/Libraries/Image_lib.php -A 2 -B 2

# Look for any test files that might verify this behavior
fd -e php -g '*test*' | xargs -I {} rg -l 'Image_lib|stripEXIF|GPS' {} 2>/dev/null || echo "No test files found"

Repository: opensourcepos/opensourcepos

Length of output: 286


🌐 Web query:

fileeye/pel removeEntry GPS sub-IFD removal

πŸ’‘ Result:

In fileeye/pel (PEL), GPS metadata lives in the GPS sub-IFD (type PelIfd::GPS). You can delete entries with the ArrayAccess β€œunset” API (offsetUnset) and you can attach a sub-IFD with addSubIfd(), but there is no public β€œremove sub-IFD” method. (pel.github.io)

Remove GPS entries (keeps an empty GPS IFD)

$gps = $ifd0->getSubIfd(PelIfd::GPS);
if ($gps) {
    foreach ($gps->getEntries() as $tag => $_entry) {
        unset($gps[$tag]); // PelIfd::offsetUnset()
    }
}

unset($ifd[$tag]) is the supported way to delete an entry. (pel.github.io)

Remove the GPS sub-IFD entirely (so it won’t be written back)

When serializing, PEL writes sub-IFDs based on the parent’s internal $sub list (and generates pointer entries like GPS_INFO_IFD_POINTER from that list). So deleting only the pointer tag is not sufficient if the sub-IFD still exists. (pel.github.io)

Practical workaround: remove the GPS element from the private $sub array via reflection:

$rp = new ReflectionProperty($ifd0, 'sub');
$rp->setAccessible(true);
$sub = $rp->getValue($ifd0);
unset($sub[PelIfd::GPS]);
$rp->setValue($ifd0, $sub);

If you want, paste your current snippet (how you’re calling removeEntry / unset) and I’ll tailor this to your exact object chain (PelJpeg β†’ PelExif β†’ PelTiff β†’ PelIfd).

Citations:


GPS data is not being removed; only the pointer tag is deleted.

In PEL, GPS metadata is stored as a sub-IFD in IFD0's internal $sub array. Calling removeEntry(PelTag::GPS_INFO_IFD_POINTER) removes the pointer tag but leaves the GPS sub-IFD intact. When PEL serializes the image, it regenerates the GPS pointer tag from the internal $sub array, re-adding GPS data to the output.

To properly remove GPS, remove the GPS sub-IFD from IFD0's internal list:

$rp = new ReflectionProperty($ifd0, 'sub');
$rp->setAccessible(true);
$sub = $rp->getValue($ifd0);
unset($sub[PelIfd::GPS]);
$rp->setValue($ifd0, $sub);
πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Libraries/Image_lib.php` around lines 81 - 83, The code only calls
$ifd0->removeEntry(PelTag::GPS_INFO_IFD_POINTER), which deletes the pointer tag
but leaves the GPS sub-IFD in IFD0's internal $sub array so PEL will recreate
the pointer on serialization; fix this in Image_lib.php by using a
ReflectionProperty on $ifd0->sub (make it accessible, getValue, unset the
PelIfd::GPS index from the array, then setValue back) to remove the GPS sub-IFD
entirely instead of only removing the pointer tag.

}

$jpeg->saveFile($filepath);
return true;
} catch (\Exception $e) {
return $this->stripExifFallback($filepath);
}
}

private function removeExifFields(PelIfd $ifd, array $fields_to_keep): void
{
$tags_to_remove = array_diff(array_keys($this->exif_to_pel_tags), $fields_to_keep);

foreach ($tags_to_remove as $field_name) {
$pel_tag = $this->exif_to_pel_tags[$field_name];
$entry = $ifd->getEntry($pel_tag);

if ($entry !== null) {
$ifd->removeEntry($pel_tag);
}
}
}

private function stripExifPng(string $filepath): bool
{
$image = @imagecreatefrompng($filepath);
if ($image === false) {
return false;
}

imagealphablending($image, false);
imagesavealpha($image, true);

$result = imagepng($image, $filepath, 9);
imagedestroy($image);

return $result;
}

private function stripExifGif(string $filepath): bool
{
$content = file_get_contents($filepath);
if ($content === false) {
return false;
}

if (strpos($content, "\x21\xF9\x04") !== false || strpos($content, 'NETSCAPE2.0') !== false) {
return true;
}
Comment on lines +130 to +132
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Animated GIF detection is overly broad and will skip processing most static GIFs.

The Graphic Control Extension (\x21\xF9\x04) is present in virtually all GIF files, not just animated ones. Static GIFs with transparency or any delay timing also contain this marker. This check will incorrectly classify most static GIFs as animated and skip EXIF stripping.

A more reliable approach is to count Image Descriptor blocks (0x2C), as animated GIFs have multiple frames (multiple image descriptors).

πŸ› Proposed fix for accurate animated GIF detection
     private function stripExifGif(string $filepath): bool
     {
         $content = file_get_contents($filepath);
         if ($content === false) {
             return false;
         }
 
-        if (strpos($content, "\x21\xF9\x04") !== false || strpos($content, 'NETSCAPE2.0') !== false) {
+        // Check for NETSCAPE loop extension (strong indicator of animation)
+        if (strpos($content, 'NETSCAPE2.0') !== false) {
+            return true;
+        }
+
+        // Count Image Descriptor blocks (0x2C) - multiple frames indicate animation
+        if (substr_count($content, "\x2C") > 1) {
             return true;
         }
 
         $image = `@imagecreatefromgif`($filepath);


$image = @imagecreatefromgif($filepath);
if ($image === false) {
return false;
}

$result = imagegif($image, $filepath);
imagedestroy($image);

return $result;
}

private function stripExifWebp(string $filepath): bool
{
if (!function_exists('imagecreatefromwebp')) {
return false;
}

$image = @imagecreatefromwebp($filepath);
if ($image === false) {
return false;
}

$result = imagewebp($image, $filepath, 100);
imagedestroy($image);

return $result;
}

private function stripExifFallback(string $filepath): bool
{
$content = file_get_contents($filepath);
if ($content === false) {
return false;
}

$markers = [];
$offset = 0;

while ($offset < strlen($content)) {
if ($offset + 4 > strlen($content)) {
break;
}

$marker = ord($content[$offset + 1]);

if (ord($content[$offset]) !== 0xFF) {
break;
}

if ($marker >= 0xE0 && $marker <= 0xEF) {
$marker_len = ord($content[$offset + 2]) * 256 + ord($content[$offset + 3]);
$markers[] = [$offset, $marker_len + 2];
$offset += $marker_len + 2;
} elseif ($marker === 0xD8 || $marker === 0xD9) {
$offset += 2;
} elseif ($marker === 0x00 || $marker === 0xD0 || $marker === 0xD1 || $marker === 0xD2 || $marker === 0xD3 || $marker === 0xD4 || $marker === 0xD5 || $marker === 0xD6 || $marker === 0xD7) {
$offset += 2;
} elseif ($marker === 0x01) {
$offset += 2;
} else {
if ($offset + 4 > strlen($content)) {
break;
}
$marker_len = ord($content[$offset + 2]) * 256 + ord($content[$offset + 3]);
$offset += $marker_len + 2;
}
}

if (empty($markers)) {
return true;
}

$marker_names = [];
foreach ($markers as $marker_info) {
$marker_byte = ord($content[$marker_info[0] + 1]);
$marker_names[] = 'APP' . ($marker_byte - 0xE0);
}
log_message('warning', "stripExifFallback: Removing all APP markers from {$filepath}: " . implode(', ', $marker_names));

$new_content = $content;
foreach (array_reverse($markers) as $marker_info) {
$new_content = substr_replace($new_content, '', $marker_info[0], $marker_info[1]);
}

return file_put_contents($filepath, $new_content) !== false;
}
}
Loading
Loading