Skip to content
Draft
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
7e6cd67
cache images vary for webp
busgurlu Sep 16, 2021
c3b25c1
sub attachments for subtitles, etc.
busgurlu Nov 7, 2021
517a0f8
setBackgroundColor to white for PDFs
busgurlu Mar 3, 2023
85201af
fix pdf preview
busgurlu Mar 3, 2023
d9cad6d
add 1:1 ratio
busgurlu Aug 16, 2023
ca96849
fix deprecated use, reformat
busgurlu Apr 17, 2024
a68aa7f
s3 initial
busgurlu Jun 22, 2024
92c16fe
fix call
busgurlu Jun 22, 2024
ee20fb2
access externally
busgurlu Jun 22, 2024
99ece5b
take action if tmppath exists
busgurlu Jun 28, 2024
7763d9d
check for tmpPath
busgurlu Jun 28, 2024
e04b68d
delete from path as well
busgurlu Sep 10, 2024
c863662
Update gumlet
busgurlu May 5, 2025
66d4b64
crop image to requested size after resizing with fill-color
busgurlu May 5, 2025
b0a6d87
Merge pull request #1 from Uskur/gumlet2
busgurlu May 5, 2025
187a217
validate parameters and convert quality for png
busgurlu May 5, 2025
6026168
include 4/3 ratio
busgurlu Aug 14, 2025
fbcdbae
fix fill issues
busgurlu Aug 23, 2025
e25b3dd
remove debug mode
busgurlu Aug 23, 2025
564f79f
copy attachment to new entity
busgurlu Sep 6, 2025
cf668f0
require imagick
busgurlu Oct 7, 2025
b8cad00
new index
busgurlu Oct 13, 2025
547adaa
Update src/Model/Table/AttachmentsTable.php
busgurlu Nov 4, 2025
140ad18
Update src/Model/Table/AttachmentsTable.php
busgurlu Nov 4, 2025
f2803cb
Apply suggestions from code review
busgurlu Nov 4, 2025
d29a25b
Apply suggestions from code review
busgurlu Nov 4, 2025
a38ba5b
update deprecated calls
busgurlu Nov 21, 2025
12db687
constrict version
busgurlu Feb 25, 2026
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
4 changes: 3 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
"type": "cakephp-plugin",
"require": {
"php": ">=5.6",
"ext-imagick": "*",
"cakephp/cakephp": ">=3.4 <4.0.0",
"admad/cakephp-sequence": "^2.0",
"gumlet/php-image-resize": "^1.9"
"gumlet/php-image-resize": "^2.0",
"aws/aws-sdk-php": "^3.314"
},
"require-dev": {
"phpunit/phpunit": "*"
Expand Down
19 changes: 19 additions & 0 deletions config/Migrations/20251013171500_AddSequenceIndexToAttachments.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php
use Migrations\AbstractMigration;

class AddSequenceIndexToAttachments extends AbstractMigration
{
/**
* Change Method.
*
* More information on this method is available here:
* http://docs.phinx.org/en/latest/migrations.html#the-change-method
* @return void
*/
public function change()
{
$table = $this->table('attachments');
$table->addIndex(['model', 'foreign_key', 'sequence'], ['name' => 'model_foreign_key_sequence']);
$table->update();
}
}
7 changes: 6 additions & 1 deletion config/attachments.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@

$config = [
'Attachment' => [
'path' => '/tmp/filestorage'
'path' => '/tmp/filestorage',
's3-endpoint' => false,
's3-region' => '',
's3-key' => '',
's3-secret' => '',
's3-bucket' => '',
]
];

Expand Down
117 changes: 92 additions & 25 deletions src/Controller/AttachmentsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,37 @@ public function image($id)
$options = [];
foreach ($validOptions as $option) {
if ($this->request->getQuery($option)) {
//validate quality
if ($option == 'q' && (
!is_numeric($this->request->getQuery($option)) ||
$this->request->getQuery($option) > 100) ||
$this->request->getQuery($option) < 0
) {
throw new \Exception("Invalid quality parameter.");
}
//validate height and width
if (($option == 'w' || $option == 'h') && (
!is_numeric($this->request->getQuery($option)) ||
$this->request->getQuery($option) < 0
)) {
throw new \Exception("Invalid height/width parameter.");
}
//validate crop and enlarge
if (($option == 'c' || $option == 'e') && (
$this->request->getQuery($option) != 0 &&
$this->request->getQuery($option) != 1
)) {
throw new \Exception("Invalid crop/enlarge parameter.");
}
//validate mode
if ($option == 'm' && $this->request->getQuery($option) != 'fill') {
throw new \Exception("Invalid mode parameter.");
}
//validate fill color
if ($option == 'fc' && !preg_match('/^[a-f0-9]{6}$/i', $this->request->getQuery($option))) {
throw new \Exception("Invalid fill color parameter.");
}

$options[$option] = $this->request->getQuery($option);
} //default fill color to white
elseif ($option == 'fc') {
Expand All @@ -140,7 +171,6 @@ function ($v, $k) {
array_keys($options)
));
$cacheFile = $cacheFolder . DS . md5($id . $cacheKey);

if (!file_exists($cacheFile)) {
if (!file_exists($cacheFolder)) {
mkdir($cacheFolder);
Expand All @@ -155,7 +185,11 @@ function ($v, $k) {
if ($attachment->filetype === 'application/pdf') {
$imagePath = "/tmp/" . uniqid();
$imagick = new \Imagick("{$attachment->path}[0]");
$imagick->setResolution(300, 300);
$imagick->setBackgroundColor('white');
$imagick->setImageFormat('jpg');
$imagick->mergeImageLayers(\Imagick::LAYERMETHOD_FLATTEN);
$imagick->setImageAlphaChannel(\Imagick::ALPHACHANNEL_REMOVE);
file_put_contents($imagePath, $imagick);
}
$image = new ImageResize($imagePath);
Expand All @@ -166,7 +200,7 @@ function ($v, $k) {
$image->save($tempImage, IMAGETYPE_JPEG);

$image = new ImageResize($imagePath);
$image->resize($options['w'], $options['h']);
$image->resize($options['w'], $options['h'], true);
$image->addFilter(function ($imageDesc) use ($options, $tempImage) {
list($r, $g, $b) = sscanf($options['fc'], "%02x%02x%02x");
$backgroundColor = imagecolorallocate($imageDesc, $r, $g, $b);
Expand Down Expand Up @@ -202,6 +236,10 @@ function ($v, $k) {
//preserve PNG for transparency
if ($attachment->filetype == 'image/png' && $options['type'] != IMAGETYPE_WEBP) {
$options['type'] = IMAGETYPE_PNG;
//modify quality imagejpeg to imagepng
if (!is_null($options['q'])) {
$options['q'] = (int) round((100 - $options['q']) / 10);
}
}
$image->save($cacheFile, $options['type'], $options['q']);
}
Expand All @@ -211,10 +249,17 @@ function ($v, $k) {
$file = new File($cacheFile);
$response = $this->response->withFile($cacheFile,
['download' => false, 'name' => (isset($attachment) ? $attachment->filename : null)])
->withVary('Accept')
->withType($file->mime())
->withCache('-1 minute', '+1 month')
->withExpires('+1 month')
->withCache('-1 minute', '+6 month')
->withExpires('+6 month')
->withMustRevalidate(false)
->withModified($file->lastChange());

if ($options['type'] == IMAGETYPE_WEBP) {
$response = $response->withSharable(false);
}

if ($response->checkNotModified($this->request)) {
return $response;
}
Expand All @@ -228,8 +273,21 @@ public function file($id, $name = null)
if (!file_exists($attachment->path)) {
throw new \Exception("File {$attachment->path} cannot be read.");
}
$response = $this->response->withType($attachment->filetype)
->withFile($attachment->path, ['download' => false, 'name' => $attachment->filename]);
$file = new File($attachment->filetype);
Copy link

Copilot AI Nov 4, 2025

Choose a reason for hiding this comment

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

File class constructor expects a file path, not a MIME type. This should be 'new File($attachment->path)' instead of 'new File($attachment->filetype)'.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

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

@copilot open a new pull request to apply changes based on this feedback


$response = $this->response->withFile($attachment->path,
['download' => false, 'name' => $attachment->filename])
->withType($attachment->filetype)
->withCache('-1 minute', '+6 month')
->withExpires('+6 month')
->withMustRevalidate(false)
->withModified($file->lastChange())
->withSharable(true);

if ($response->checkNotModified($this->request)) {
return $response;
}

return $response;
}

Expand All @@ -239,8 +297,20 @@ public function download($id, $name = null)
if (!file_exists($attachment->path)) {
throw new \Exception("File {$attachment->path} cannot be read.");
}
$response = $this->response->withType($attachment->filetype)
->withFile($attachment->path, ['download' => true, 'name' => $attachment->filename]);
$file = new File($attachment->filetype);
Copy link

Copilot AI Nov 4, 2025

Choose a reason for hiding this comment

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

File class constructor expects a file path, not a MIME type. This should be 'new File($attachment->path)' instead of 'new File($attachment->filetype)'.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

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

@copilot open a new pull request to apply changes based on this feedback


$response = $this->response->withFile($attachment->path,
['download' => true, 'name' => $attachment->filename])
->withType($attachment->filetype)
->withCache('-1 minute', '+6 month')
->withExpires('+6 month')
->withMustRevalidate(false)
->withModified($file->lastChange());

if ($response->checkNotModified($this->request)) {
return $response;
}

return $response;
}

Expand Down Expand Up @@ -314,13 +384,13 @@ public function stream($id, $name = null)
ob_get_clean();
header("Content-Type: video/mp4");
header("Cache-Control: max-age=311040000, public");
header("Expires: ".gmdate('D, d M Y H:i:s', time()+311040000) . ' GMT');
header("Last-Modified: ".gmdate('D, d M Y H:i:s', @filemtime($attachment->path)) . ' GMT' );
header("Expires: " . gmdate('D, d M Y H:i:s', time() + 311040000) . ' GMT');
header("Last-Modified: " . gmdate('D, d M Y H:i:s', @filemtime($attachment->path)) . ' GMT');
$this->start = 0;
$this->size = filesize($attachment->path);
$this->end = $this->size - 1;
$this->size = filesize($attachment->path);
$this->end = $this->size - 1;

header("Accept-Ranges: 0-".$this->end);
header("Accept-Ranges: 0-" . $this->end);
//set header
if (isset($_SERVER['HTTP_RANGE'])) {

Expand All @@ -335,7 +405,7 @@ public function stream($id, $name = null)
}
if ($range == '-') {
$c_start = $this->size - substr($range, 1);
}else{
} else {
$range = explode('-', $range);
$c_start = $range[0];
$c_end = (isset($range[1]) && is_numeric($range[1])) ? $range[1] : $c_end;
Expand All @@ -351,19 +421,17 @@ public function stream($id, $name = null)
$length = $this->end - $this->start + 1;
fseek($this->stream, $this->start);
header('HTTP/1.1 206 Partial Content');
header("Content-Length: ".$length);
header("Content-Range: bytes $this->start-$this->end/".$this->size);
}
else
{
header("Content-Length: ".$this->size);
header("Content-Length: " . $length);
header("Content-Range: bytes $this->start-$this->end/" . $this->size);
} else {
header("Content-Length: " . $this->size);
}
//stream
$i = $this->start;
set_time_limit(0);
while(!feof($this->stream) && $i <= $this->end) {
while (!feof($this->stream) && $i <= $this->end) {
$bytesToRead = $this->buffer;
if(($i+$bytesToRead) > $this->end) {
if (($i + $bytesToRead) > $this->end) {
$bytesToRead = $this->end - $i + 1;
}
$data = @stream_get_contents($this->stream, $bytesToRead, intval($i));
Expand All @@ -389,14 +457,13 @@ public function editImage($id)
if ($this->Attachments->replaceFile($id, $tempPath)) {
$this->Flash->success(__('Image modified.'));
$redirectTo = $this->getRequest()->getSession()->consume('Attachment.redirectAfter');
if($redirectTo) {
if ($redirectTo) {
return $this->redirect($redirectTo);
}
} else {
$this->Flash->error(__('Image could not be saved. Please, try again.'));
}
}
else {
} else {
$this->getRequest()->getSession()->write('Attachment.redirectAfter', $this->referer());
}
$this->set('image', $image);
Expand Down
83 changes: 76 additions & 7 deletions src/Model/Entity/Attachment.php
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
<?php

namespace Uskur\Attachments\Model\Entity;

use Cake\ORM\Entity;
use Cake\Filesystem\File;
use Cake\Filesystem\Folder;
use Cake\I18n\Number;
use Cake\Core\Configure;
use JeremyHarris\LazyLoad\ORM\LazyLoadEntityTrait;
use Uskur\Attachments\Model\Entity\DetailsTrait;

/**
Expand All @@ -18,11 +20,17 @@
* @property \Cake\I18n\Time $created
* @property string $article_id
* @property \App\Model\Entity\Article $article
* @property string $path
* @property string $extension
* @property string $s3_path
* @property array $s3_attributes
* @property int $sequence
*/
class Attachment extends Entity
{
use DetailsTrait;
use LazyLoadEntityTrait;


/**
* Fields that can be mass assigned using newEntity() or patchEntity().
Expand All @@ -38,17 +46,46 @@ class Attachment extends Entity
'id' => false,
];

protected $_virtual = ['details_array','readable_size','readable_created'];
protected $_virtual = ['details_array', 'readable_size', 'readable_created'];

protected function _getPath()
{
$targetDir = Configure::read('Attachment.path').DS.substr($this->_properties['md5'],0,2);
$folder = new Folder();
if (!$folder->create($targetDir)) {
throw new \Exception("Folder {$targetDir} could not be created.");
}
$targetDir = Configure::read('Attachment.path') . DS . substr($this->_properties['md5'], 0, 2);

$filePath = $targetDir . DS . $this->_properties['md5'];

return $targetDir.DS.$this->_properties['md5'];
$folder = new Folder();
if (!file_exists($filePath) && !$folder->create($targetDir)) {
throw new \Exception("Folder {$targetDir} could not be created.");
}

if (!file_exists($filePath) && Configure::read('Attachment.s3-endpoint') && is_null($this->tmpPath)) {
$config =
[
'version' => 'latest',
'region' => Configure::read('Attachment.s3-region'),
'endpoint' => Configure::read('Attachment.s3-endpoint'),
'credentials' =>
[
'key' => Configure::read('Attachment.s3-key'),
'secret' => Configure::read('Attachment.s3-secret'),
],
];
$s3client = new \Aws\S3\S3Client($config);
try {
$s3client->getObject(
[
'Bucket' => Configure::read('Attachment.s3-bucket'),
'Key' => $this->s3_path,
'SaveAs' => $filePath,
]
);
} catch (\Exception $e) {
return false;
}
}

return $filePath;
}

protected function _getReadableSize()
Expand All @@ -66,4 +103,36 @@ protected function _getExtension()
$pathinfo = pathinfo($this->filename);
return $pathinfo['extension'];
}

//s3 path
protected function _getS3Path()
{
return substr($this->_properties['md5'], 0, 2) . '/' . $this->_properties['md5'];
}

protected function _getS3Attributes()
{
if (Configure::read('Attachment.s3-endpoint')) {
$config =
[
'version' => 'latest',
'region' => Configure::read('Attachment.s3-region'),
'endpoint' => Configure::read('Attachment.s3-endpoint'),
'credentials' =>
[
'key' => Configure::read('Attachment.s3-key'),
'secret' => Configure::read('Attachment.s3-secret'),
],
];
$s3client = new \Aws\S3\S3Client($config);
try {
return $s3client->getObjectAttributes(
Configure::read('Attachment.s3-bucket'),
$this->s3_path
);
} catch (\Exception $e) {
return false;
}
}
}
}
Loading