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
8 changes: 8 additions & 0 deletions config/formforge.php
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,14 @@
'direct' => [
'signature_ttl_seconds' => env('FORMFORGE_DIRECT_SIGNATURE_TTL', 900),
],

'antivirus' => [
'enabled' => env('FORMFORGE_CLAMAV_ENABLED', false),
'endpoint' => env('FORMFORGE_CLAMAV_ENDPOINT'),
'username' => env('FORMFORGE_CLAMAV_USERNAME'),
'password' => env('FORMFORGE_CLAMAV_PASSWORD'),
'timeout' => env('FORMFORGE_CLAMAV_TIMEOUT', 30),
],
],

/*
Expand Down
5 changes: 5 additions & 0 deletions lang/en/messages.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@
'staged_only_file_fields' => 'Only file fields can stage uploads.',
'staged_upload_max_size' => 'Staged upload exceeds max_size [:max] bytes.',
'staged_upload_accepted_types_mismatch' => 'Staged upload does not match accepted file types.',
'upload_max_total_size' => 'The combined size of uploaded files exceeds the configured maximum.',
'upload_max_files' => 'The number of uploaded files exceeds the configured maximum.',
'upload_antivirus_endpoint_missing' => 'The antivirus scanner endpoint is not configured.',
'upload_antivirus_scan_failed' => 'The uploaded file could not be scanned for viruses.',
'upload_antivirus_infected' => 'The uploaded file was rejected because it contains malware.',
'upload_token_form_version_mismatch' => 'Upload token does not match the targeted form version.',
'upload_token_field_mismatch' => 'Upload token does not match the targeted file field.',
'upload_token_ownership_mismatch' => 'Upload token ownership mismatch.',
Expand Down
5 changes: 5 additions & 0 deletions lang/fr/messages.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@
'staged_only_file_fields' => 'Seuls les champs fichier peuvent utiliser le staging.',
'staged_upload_max_size' => 'Le fichier en staging dépasse max_size [:max] octets.',
'staged_upload_accepted_types_mismatch' => 'Le fichier en staging ne correspond pas aux types acceptés.',
'upload_max_total_size' => 'La taille cumulée des fichiers uploadés dépasse la limite configurée.',
'upload_max_files' => 'Le nombre de fichiers uploadés dépasse la limite configurée.',
'upload_antivirus_endpoint_missing' => 'Le point de terminaison de l’analyse antivirus n’est pas configuré.',
'upload_antivirus_scan_failed' => 'Le fichier uploadé n’a pas pu être analysé par l’antivirus.',
'upload_antivirus_infected' => 'Le fichier uploadé a été refusé car il contient un logiciel malveillant.',
'upload_token_form_version_mismatch' => 'Le token d’upload ne correspond pas à la version du formulaire ciblée.',
'upload_token_field_mismatch' => 'Le token d’upload ne correspond pas au champ fichier ciblé.',
'upload_token_ownership_mismatch' => 'Le propriétaire du token d’upload ne correspond pas.',
Expand Down
23 changes: 23 additions & 0 deletions src/Definition/FieldBlueprint.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ class FieldBlueprint

private ?int $maxFiles = null;

private ?int $maxTotalSize = null;

private ?string $storageDisk = null;

private ?string $storageDirectory = null;
Expand Down Expand Up @@ -141,6 +143,7 @@ public static function fromSchemaArray(array $schema): self
}
$field->maxSize = isset($schema['max_size']) ? (int) $schema['max_size'] : null;
$field->maxFiles = isset($schema['max_files']) ? (int) $schema['max_files'] : null;
$field->maxTotalSize = isset($schema['max_total_size']) ? (int) $schema['max_total_size'] : null;

if (is_array($schema['storage'] ?? null)) {
$field->storageDisk = isset($schema['storage']['disk']) ? trim((string) $schema['storage']['disk']) : null;
Expand Down Expand Up @@ -402,6 +405,17 @@ public function maxFiles(int $maxFiles): self
return $this;
}

public function maxTotalSize(int $maxTotalSize): self
{
if ($maxTotalSize <= 0) {
throw new InvalidFieldDefinitionException("maxTotalSize must be positive on field [{$this->name}].");
}

$this->maxTotalSize = $maxTotalSize;

return $this;
}

public function storageDisk(string $storageDisk): self
{
$storageDisk = trim($storageDisk);
Expand Down Expand Up @@ -491,6 +505,11 @@ public function maxFilesValue(): ?int
return $this->maxFiles;
}

public function maxTotalSizeValue(): ?int
{
return $this->maxTotalSize;
}

public function storageDiskValue(): ?string
{
return $this->storageDisk;
Expand Down Expand Up @@ -611,6 +630,10 @@ public function toSchemaArray(string $formKey, string $version): array
$schema['max_files'] = $this->maxFiles;
}

if ($this->maxTotalSize !== null) {
$schema['max_total_size'] = $this->maxTotalSize;
}

$schema['storage'] = [
'disk' => $this->storageDisk,
'directory' => $this->storageDirectory,
Expand Down
11 changes: 10 additions & 1 deletion src/FormForgeServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@
use EvanSchleret\FormForge\Submissions\StagedUploadService;
use EvanSchleret\FormForge\Submissions\UploadManager;
use EvanSchleret\FormForge\Support\DefaultFormPublicLinkResolver;
use EvanSchleret\FormForge\Support\Antivirus\ClamAvRestScanner;
use EvanSchleret\FormForge\Support\Antivirus\FileScanner;
use EvanSchleret\FormForge\Support\FormPublicLinkResolver;
use Illuminate\Support\ServiceProvider;

Expand Down Expand Up @@ -89,11 +91,18 @@ public function register(): void
),
);
$this->app->singleton(DraftStateService::class, static fn (): DraftStateService => new DraftStateService());
$this->app->singleton(StagedUploadService::class, static fn (): StagedUploadService => new StagedUploadService());
$this->app->singleton(FileScanner::class, static fn (): FileScanner => new ClamAvRestScanner());
$this->app->singleton(
StagedUploadService::class,
fn (): StagedUploadService => new StagedUploadService(
scanner: $this->app->make(FileScanner::class),
),
);
$this->app->singleton(
UploadManager::class,
fn (): UploadManager => new UploadManager(
stagedUploads: $this->app->make(StagedUploadService::class),
scanner: $this->app->make(FileScanner::class),
),
);
$this->app->singleton(HttpOptionsResolver::class, static fn (): HttpOptionsResolver => new HttpOptionsResolver());
Expand Down
16 changes: 15 additions & 1 deletion src/Submissions/StagedUploadService.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use EvanSchleret\FormForge\Exceptions\FormForgeException;
use EvanSchleret\FormForge\Models\StagedUpload;
use EvanSchleret\FormForge\Support\ModelClassResolver;
use EvanSchleret\FormForge\Support\Antivirus\FileScanner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon;
Expand All @@ -15,6 +16,11 @@

class StagedUploadService
{
public function __construct(
private readonly FileScanner $scanner,
) {
}

public function cleanupExpired(bool $dryRun = false, bool $deleteFiles = true, int $chunk = 500): array
{
$chunk = max(1, $chunk);
Expand Down Expand Up @@ -73,6 +79,7 @@ public function stage(array $form, array $field, UploadedFile $file, ?Model $upl
{
$this->assertFileField($field);
$this->validateUploadedFile($file, $field);
$this->scanFile($file);

$disk = trim((string) config('formforge.uploads.temporary_disk', config('filesystems.default')));
$directory = $this->buildTemporaryDirectory(
Expand Down Expand Up @@ -201,6 +208,13 @@ private function assertFileField(array $field): void
}
}

private function scanFile(UploadedFile $file): void
{
if ((bool) config('formforge.uploads.antivirus.enabled', false)) {
$this->scanner->scan($file);
}
}

private function validateUploadedFile(UploadedFile $file, array $field): void
{
$maxSize = isset($field['max_size']) ? (int) $field['max_size'] : null;
Expand All @@ -209,7 +223,7 @@ private function validateUploadedFile(UploadedFile $file, array $field): void
$size = (int) ($file->getSize() ?? 0);

if ($size > $maxSize) {
throw new FormForgeException("Staged upload exceeds max_size [{$maxSize}] bytes.");
throw new FormForgeException(trans('formforge::messages.staged_upload_max_size', ['max' => $maxSize]));
}
}

Expand Down
48 changes: 44 additions & 4 deletions src/Submissions/SubmissionValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -849,7 +849,7 @@ private function compileFileRules(array &$rules, string $name, array $field, arr
}

$baseRules = $this->sanitizeMetadataBaseRules($baseRules);
$this->compileMetadataFileRules($rules, $name, $baseRules, $multiple);
$this->compileMetadataFileRules($rules, $name, $field, $baseRules, $multiple);
}

private function compileManagedFileRules(array &$rules, string $name, array $field, array $baseRules, bool $multiple): void
Expand All @@ -869,6 +869,20 @@ private function compileManagedFileRules(array &$rules, string $name, array $fie
$rules[$name] = $baseRules;
$rules[$name . '.*'] = array_values(array_unique(['file', ...$acceptRules, ...$sizeRules]));

if (isset($field['max_total_size']) && (int) $field['max_total_size'] > 0) {
$rules[$name][] = function (string $attribute, mixed $value, Closure $fail) use ($field): void {
if (! is_array($value)) {
return;
}

$total = array_reduce($value, static fn (int $sum, mixed $file): int => $sum + (int) ($file?->getSize() ?? 0), 0);

if ($total > (int) $field['max_total_size']) {
$fail(trans('formforge::messages.upload_max_total_size'));
}
};
}

return;
}

Expand All @@ -879,7 +893,7 @@ private function compileManagedFileRules(array &$rules, string $name, array $fie
$rules[$name] = array_values(array_unique([...$baseRules, ...$acceptRules, ...$sizeRules]));
}

private function compileMetadataFileRules(array &$rules, string $name, array $baseRules, bool $multiple): void
private function compileMetadataFileRules(array &$rules, string $name, array $field, array $baseRules, bool $multiple): void
{
if (! in_array('array', $baseRules, true)) {
$baseRules[] = 'array';
Expand All @@ -888,6 +902,10 @@ private function compileMetadataFileRules(array &$rules, string $name, array $ba
$rules[$name] = $baseRules;

if ($multiple) {
if (isset($field['max_files'])) {
$baseRules[] = 'max:' . (int) $field['max_files'];
}

$rules[$name . '.*'] = ['exclude_without:' . $name, 'array'];
$rules[$name . '.*.upload_token'] = ['exclude_without:' . $name, 'nullable', 'string'];
$rules[$name . '.*.path'] = ['exclude_without:' . $name, 'required_without:' . $name . '.*.upload_token', 'string'];
Expand All @@ -896,10 +914,28 @@ private function compileMetadataFileRules(array &$rules, string $name, array $ba
$rules[$name . '.*.stored_name'] = ['exclude_without:' . $name, 'nullable', 'string'];
$rules[$name . '.*.mime_type'] = ['exclude_without:' . $name, 'nullable', 'string'];
$rules[$name . '.*.extension'] = ['exclude_without:' . $name, 'nullable', 'string'];
$rules[$name . '.*.size'] = ['exclude_without:' . $name, 'nullable', 'integer', 'min:0'];
$sizeRules = ['exclude_without:' . $name, 'nullable', 'integer', 'min:0'];
if (isset($field['max_size']) && (int) $field['max_size'] > 0) {
$sizeRules[] = 'max:' . (int) $field['max_size'];
}
$rules[$name . '.*.size'] = $sizeRules;
$rules[$name . '.*.checksum'] = ['exclude_without:' . $name, 'nullable', 'string'];
$rules[$name . '.*.metadata'] = ['exclude_without:' . $name, 'nullable', 'array'];

if (isset($field['max_total_size']) && (int) $field['max_total_size'] > 0) {
$rules[$name][] = function (string $attribute, mixed $value, Closure $fail) use ($field): void {
if (! is_array($value)) {
return;
}

$total = array_reduce($value, static fn (int $sum, mixed $item): int => $sum + (int) (is_array($item) ? ($item['size'] ?? 0) : 0), 0);

if ($total > (int) $field['max_total_size']) {
$fail(trans('formforge::messages.upload_max_total_size'));
}
};
}

return;
}

Expand All @@ -910,7 +946,11 @@ private function compileMetadataFileRules(array &$rules, string $name, array $ba
$rules[$name . '.stored_name'] = ['exclude_without:' . $name, 'nullable', 'string'];
$rules[$name . '.mime_type'] = ['exclude_without:' . $name, 'nullable', 'string'];
$rules[$name . '.extension'] = ['exclude_without:' . $name, 'nullable', 'string'];
$rules[$name . '.size'] = ['exclude_without:' . $name, 'nullable', 'integer', 'min:0'];
$sizeRules = ['exclude_without:' . $name, 'nullable', 'integer', 'min:0'];
if (isset($field['max_size']) && (int) $field['max_size'] > 0) {
$sizeRules[] = 'max:' . (int) $field['max_size'];
}
$rules[$name . '.size'] = $sizeRules;
$rules[$name . '.checksum'] = ['exclude_without:' . $name, 'nullable', 'string'];
$rules[$name . '.metadata'] = ['exclude_without:' . $name, 'nullable', 'array'];
}
Expand Down
74 changes: 72 additions & 2 deletions src/Submissions/UploadManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use EvanSchleret\FormForge\Exceptions\FormForgeException;
use EvanSchleret\FormForge\Exceptions\UnsupportedUploadModeException;
use EvanSchleret\FormForge\Models\StagedUpload;
use EvanSchleret\FormForge\Support\Antivirus\FileScanner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Arr;
Expand All @@ -17,6 +18,7 @@ class UploadManager
{
public function __construct(
private readonly StagedUploadService $stagedUploads,
private readonly FileScanner $scanner,
) {
}

Expand Down Expand Up @@ -79,6 +81,8 @@ private function processDirect(array $field, mixed $value): array
$metadata[] = $this->normalizeExistingMetadata($field, $item);
}

$this->validateMetadataLimits($field, $metadata);

$multiple = (bool) ($field['multiple'] ?? false);

return [
Expand Down Expand Up @@ -185,6 +189,10 @@ private function storeUploadedFile(array $form, array $field, UploadedFile $file
$extension = $file->getClientOriginalExtension() ?: $file->extension() ?: '';
$storedName = $this->storedFilename($originalName, $extension);

if ((bool) config('formforge.uploads.antivirus.enabled', false)) {
$this->scanner->scan($file);
}

$options = [];

if ($visibility !== null) {
Expand Down Expand Up @@ -233,9 +241,9 @@ private function normalizeExistingMetadata(array $field, array $item): array

$storedName = (string) Arr::get($item, 'stored_name', basename($path));
$originalName = (string) Arr::get($item, 'original_name', $storedName);
$mimeType = (string) Arr::get($item, 'mime_type', Storage::disk($disk)->mimeType($path) ?: 'application/octet-stream');
$mimeType = (string) (Storage::disk($disk)->mimeType($path) ?: Arr::get($item, 'mime_type', 'application/octet-stream'));
$extension = (string) Arr::get($item, 'extension', pathinfo($path, PATHINFO_EXTENSION));
$size = (int) Arr::get($item, 'size', Storage::disk($disk)->size($path));
$size = (int) Storage::disk($disk)->size($path);
$checksum = (string) Arr::get($item, 'checksum', $this->safeChecksum($disk, $path));
$metadata = Arr::get($item, 'metadata', []);

Expand All @@ -258,6 +266,68 @@ private function normalizeExistingMetadata(array $field, array $item): array
];
}

private function validateMetadataLimits(array $field, array $metadata): void
{
$maxFiles = isset($field['max_files']) ? (int) $field['max_files'] : null;

if ($maxFiles !== null && $maxFiles > 0 && count($metadata) > $maxFiles) {
throw new FormForgeException(trans('formforge::messages.upload_max_files'));
}

$maxSize = isset($field['max_size']) ? (int) $field['max_size'] : null;
$maxTotalSize = isset($field['max_total_size']) ? (int) $field['max_total_size'] : null;
$totalSize = 0;

foreach ($metadata as $item) {
$size = (int) ($item['size'] ?? 0);
$totalSize += $size;

if ($maxSize !== null && $maxSize > 0 && $size > $maxSize) {
throw new FormForgeException(trans('formforge::messages.staged_upload_max_size', ['max' => $maxSize]));
}

if (! $this->metadataMatchesAccept($item, $field['accept'] ?? [])) {
throw new FormForgeException(trans('formforge::messages.staged_upload_accepted_types_mismatch'));
}
}

if ($maxTotalSize !== null && $maxTotalSize > 0 && $totalSize > $maxTotalSize) {
throw new FormForgeException(trans('formforge::messages.upload_max_total_size'));
}
}

private function metadataMatchesAccept(array $metadata, mixed $accept): bool
{
if (! is_array($accept) || $accept === []) {
return true;
}

$extension = strtolower(ltrim((string) ($metadata['extension'] ?? ''), '.'));
$mimeType = strtolower((string) ($metadata['mime_type'] ?? ''));

foreach ($accept as $rawRule) {
$rule = strtolower(trim((string) $rawRule));

if ($rule === '' || $rule === '*' || ($rule === 'image/*' && str_starts_with($mimeType, 'image/'))) {
return true;
}

if (str_starts_with($rule, '.') && $extension === ltrim($rule, '.')) {
return true;
}

if (str_contains($rule, '/') && $mimeType === $rule) {
return true;
}

if (! str_contains($rule, '/') && $extension === $rule) {
return true;
}
}

return false;
}

private function commitStagedFile(array $form, array $field, array $item, ?Model $submittedBy = null): array
{
$stagedUpload = null;
Expand Down
Loading