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
6 changes: 4 additions & 2 deletions examples/symfony/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ FROM php:8.4-cli

RUN apt-get update && apt-get install -y --no-install-recommends git unzip \
&& rm -rf /var/lib/apt/lists/* \
&& printf "upload_max_filesize=10M\npost_max_size=12M\ndisplay_errors=Off\nlog_errors=On\n" > /usr/local/etc/php/conf.d/uploads.ini
&& printf "upload_max_filesize=100M\npost_max_size=105M\ndisplay_errors=Off\nlog_errors=On\n" > /usr/local/etc/php/conf.d/uploads.ini
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

# The SDK from this repository. Once published, `composer require attachmentav/virus-scan-sdk` is enough.
Expand All @@ -24,7 +24,9 @@ COPY examples/symfony/templates templates
COPY examples/symfony/config config

ENV APP_ENV=prod
RUN composer dump-env prod && php bin/console cache:clear
# APP_SECRET signs the download URLs of the async scan. Generated once per image build.
RUN echo "APP_SECRET=$(php -r 'echo bin2hex(random_bytes(16));')" > .env.prod.local \
&& composer dump-env prod && php bin/console cache:clear

EXPOSE 8000
# PHP's built-in web server is fine for this example; use nginx/php-fpm or FrankenPHP in production.
Expand Down
37 changes: 33 additions & 4 deletions examples/symfony/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# attachmentAV SDK: Symfony example

A minimal Symfony app with a file upload form. The uploaded file is scanned with the attachmentAV SDK, and the page shows the scan result.
A minimal Symfony app with two file upload forms:

* **Sync scan** (`/`): the uploaded file is scanned with the attachmentAV SDK, and the page shows the scan result.
* **Async scan** (`/documents`, SaaS only): the uploaded file is kept in a staging directory. attachmentAV downloads the file through a signed URL, scans it in the background, and sends the scan result to a callback URL.

## Run

Expand All @@ -19,8 +22,34 @@ Open http://localhost:8001 and upload a file. To see a detection, upload the [EI

The image creates a fresh Symfony project, installs the SDK from this repository, and copies the example files into it. It uses PHP's built-in web server, which is fine for trying the example but not for production.

## Run the async scan

attachmentAV must reach the app from the Internet to download the file and to send the callback. Use a tunnel such as [ngrok](https://ngrok.com/).

First, get your tenant ID. It is required to verify the signatures of callbacks.

```bash
ATTACHMENTAV_API_KEY=<API_KEY_PLACEHOLDER> ATTACHMENTAV_REGION=eu docker compose run --rm --build app php -r \
'require "vendor/autoload.php"; echo (new AttachmentAV\AttachmentAVApi(AttachmentAV\Configuration::saas(getenv("ATTACHMENTAV_API_KEY"), getenv("ATTACHMENTAV_REGION"))))->whoami()->getTenantId(), PHP_EOL;'
```

Second, start the app with the tenant ID, and open a tunnel in a second terminal.

```bash
ATTACHMENTAV_API_KEY=<API_KEY_PLACEHOLDER> ATTACHMENTAV_REGION=eu ATTACHMENTAV_TENANT_ID=<TENANT_ID_PLACEHOLDER> docker compose up --build

ngrok http 8001
```

Third, open `https://<your-subdomain>.ngrok-free.app/documents`, not `localhost`: the download and callback URLs are generated from the URL of the page. Upload a file (max. 100 MB). The result page refreshes until the callback has arrived. Clean files are moved to `var/documents`, all other files are deleted.

The example trusts the `X-Forwarded-*` headers of whoever connects, so that Symfony generates the public HTTPS URLs behind the tunnel. In production, set `TRUSTED_PROXIES` to the IP addresses of your reverse proxies.

## Files

* [`config/packages/attachmentav.yaml`](config/packages/attachmentav.yaml): registers `AttachmentAV\AttachmentAVApi` as a service, configured from environment variables.
* [`src/Controller/ScanController.php`](src/Controller/ScanController.php): checks the CSRF token and the upload, then calls `AttachmentAVApi::scanSyncFile()`.
* [`templates/scan.html.twig`](templates/scan.html.twig): the upload form and the scan result.
* [`config/packages/attachmentav.yaml`](config/packages/attachmentav.yaml): registers `AttachmentAV\AttachmentAVApi` and `AttachmentAV\Callback\CallbackVerifier` as services, configured from environment variables.
* [`config/packages/trusted_proxies.yaml`](config/packages/trusted_proxies.yaml): trusts the `X-Forwarded-*` headers of the tunnel or reverse proxy.
* [`src/Controller/ScanController.php`](src/Controller/ScanController.php): sync scan. Checks the CSRF token and the upload, then calls `AttachmentAVApi::scanSyncFile()`.
* [`src/Controller/DocumentController.php`](src/Controller/DocumentController.php): async scan. Moves the upload to the staging directory, signs a download URL with Symfony's `UriSigner`, calls `AttachmentAVApi::scanAsyncDownload()`, and serves the staged file to attachmentAV.
* [`src/Controller/AttachmentAVCallbackController.php`](src/Controller/AttachmentAVCallbackController.php): verifies the callback with `CallbackVerifier`, then moves the clean file out of the staging directory or deletes it.
* [`templates/`](templates): the upload forms and the scan results.
2 changes: 2 additions & 0 deletions examples/symfony/compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,5 @@ services:
# Self-hosted on AWS: set ATTACHMENTAV_DEPLOYMENT=self-hosted and ATTACHMENTAV_BASE_URL
ATTACHMENTAV_DEPLOYMENT: ${ATTACHMENTAV_DEPLOYMENT:-saas}
ATTACHMENTAV_BASE_URL: ${ATTACHMENTAV_BASE_URL:-}
# Async scans (SaaS): required to verify callbacks
ATTACHMENTAV_TENANT_ID: ${ATTACHMENTAV_TENANT_ID:-}
7 changes: 7 additions & 0 deletions examples/symfony/config/packages/attachmentav.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ services:
arguments:
$configuration: '@AttachmentAV\Configuration'

# Verifies the signatures of callbacks (async scans). Get your tenant ID with AttachmentAVApi::whoami().
AttachmentAV\Callback\CallbackVerifier:
factory: ['AttachmentAV\Callback\CallbackVerifier', 'forRegion']
arguments:
$region: '%env(default:attachmentav_default_region:ATTACHMENTAV_REGION)%'
$tenantId: '%env(ATTACHMENTAV_TENANT_ID)%'

parameters:
attachmentav_default_deployment: 'saas'
attachmentav_default_region: 'eu'
10 changes: 10 additions & 0 deletions examples/symfony/config/packages/trusted_proxies.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# The download and callback URLs sent to attachmentAV are generated from the incoming request.
# Behind a tunnel such as ngrok or a reverse proxy, Symfony must trust its X-Forwarded-* headers
# to generate the public HTTPS URLs. This example trusts whoever connects (REMOTE_ADDR), which is fine
# locally. In production, set TRUSTED_PROXIES to the IP addresses of your proxies.
framework:
trusted_proxies: '%env(default:trusted_proxies_default:TRUSTED_PROXIES)%'
trusted_headers: ['x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-port']

parameters:
trusted_proxies_default: 'REMOTE_ADDR'
70 changes: 70 additions & 0 deletions examples/symfony/src/Controller/AttachmentAVCallbackController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

namespace App\Controller;

use AttachmentAV\Callback\CallbackVerifier;
use AttachmentAV\Callback\InvalidSignatureException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;

/**
* Receives the scan results of async scans submitted by DocumentController.
*/
class AttachmentAVCallbackController extends AbstractController
{
public function __construct(
private CallbackVerifier $verifier,
private Filesystem $filesystem,
#[Autowire('%kernel.project_dir%/var/staging')] private string $stagingDir,
#[Autowire('%kernel.project_dir%/var/documents')] private string $documentsDir,
#[Autowire('%kernel.project_dir%/var/results')] private string $resultsDir,
) {
}

#[Route('/attachmentav/callback', name: 'attachmentav_callback', methods: ['POST'])]
public function __invoke(Request $request): Response
{
try {
$payload = $this->verifier->verifyAndParse(
$request->getContent(),
$request->headers->get('X-Signature'),
$request->headers->get('X-Timestamp'),
$this->generateUrl('attachmentav_callback', [], UrlGeneratorInterface::ABSOLUTE_URL)
);
} catch (InvalidSignatureException) {
return new Response('', Response::HTTP_FORBIDDEN);
}

// The signature covers the custom data, so it is exactly what DocumentController sent.
$data = json_decode((string) $payload->getCustomData(), true);
$staged = $this->stagingDir . '/' . $data['id'];

// attachmentAV may deliver a callback more than once.
if (!is_file($staged)) {
return new Response('', Response::HTTP_NO_CONTENT);
}

if ($payload->isClean()) {
$this->filesystem->mkdir($this->documentsDir);
$this->filesystem->rename($staged, $this->documentsDir . '/' . $data['id']);
} else {
// 'infected' or 'no' (could not be scanned)
$this->filesystem->remove($staged);
}

$this->filesystem->dumpFile($this->resultsDir . '/' . $data['id'] . '.json', json_encode([
'filename' => $data['filename'],
'status' => $payload->getStatus(),
'finding' => $payload->getFinding(),
'size' => $payload->getSize(),
'realFileType' => $payload->getRealFileType(),
]));

return new Response('', Response::HTTP_NO_CONTENT);
}
}
120 changes: 120 additions & 0 deletions examples/symfony/src/Controller/DocumentController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
<?php

namespace App\Controller;

use AttachmentAV\AttachmentAVApi;
use AttachmentAV\Exception\ExceptionInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\UriSigner;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;

/**
* Async scan: the upload is kept in the staging directory until attachmentAV has downloaded and scanned it.
* The scan result arrives at AttachmentAVCallbackController.
*/
class DocumentController extends AbstractController
{
public function __construct(
private AttachmentAVApi $attachmentAV,
private UriSigner $uriSigner,
private Filesystem $filesystem,
#[Autowire('%kernel.project_dir%/var/staging')] private string $stagingDir,
#[Autowire('%kernel.project_dir%/var/results')] private string $resultsDir,
) {
}

#[Route('/documents', name: 'document_upload', methods: ['GET', 'POST'])]
public function upload(Request $request): Response
{
if (!$this->attachmentAV->getConfiguration()->isSaas()) {
return $this->render('documents.html.twig', ['error' => 'Async scans are available for the attachmentAV API (SaaS) only.']);
}

// The download and callback URLs are generated from the URL of this page and must be reachable from the Internet.
if (in_array($request->getHost(), ['localhost', '127.0.0.1'], true)) {
return $this->render('documents.html.twig', ['error' => 'attachmentAV cannot reach localhost. Open this page through a public URL, e.g. with ngrok (see README).']);
}

if (!$request->isMethod('POST')) {
return $this->render('documents.html.twig');
}

// PHP discards the whole request body if it exceeds post_max_size, including the CSRF token.
if ($request->request->count() === 0 && $request->files->count() === 0 && (int) $request->server->get('CONTENT_LENGTH') > 0) {
return $this->render('documents.html.twig', ['error' => 'The file must not be larger than 100 MB.'], new Response('', 413));
}

if (!$this->isCsrfTokenValid('documents', (string) $request->request->get('_token'))) {
return $this->render('documents.html.twig', ['error' => 'Invalid CSRF token, please try again.'], new Response('', 400));
}

$file = $request->files->get('file');
if (!$file instanceof UploadedFile || !$file->isValid()) {
$message = $file instanceof UploadedFile ? $file->getErrorMessage() : 'Please select a file.';

return $this->render('documents.html.twig', ['error' => $message], new Response('', 422));
}

$filename = $file->getClientOriginalName();
$id = bin2hex(random_bytes(16));
$file->move($this->stagingDir, $id);
$this->filesystem->dumpFile($this->resultsDir . '/' . $id . '.json', json_encode(['filename' => $filename, 'status' => 'pending']));

// attachmentAV downloads the file from this URL. The signature expires after one hour.
$downloadUrl = $this->uriSigner->sign(
$this->generateUrl('document_staging', ['id' => $id], UrlGeneratorInterface::ABSOLUTE_URL),
new \DateInterval('PT1H')
);

try {
$this->attachmentAV->scanAsyncDownload(
$downloadUrl,
callbackUrl: $this->generateUrl('attachmentav_callback', [], UrlGeneratorInterface::ABSOLUTE_URL),
customData: json_encode(['id' => $id, 'filename' => $filename])
);
} catch (ExceptionInterface $e) {
$this->filesystem->remove([$this->stagingDir . '/' . $id, $this->resultsDir . '/' . $id . '.json']);

return $this->render('documents.html.twig', [
'filename' => $filename,
'error' => $e->getMessage(),
], new Response('', 502));
}

return $this->redirectToRoute('document_show', ['id' => $id]);
}

#[Route('/documents/{id}', name: 'document_show', methods: ['GET'], requirements: ['id' => '[0-9a-f]{32}'])]
public function show(string $id): Response
{
$path = $this->resultsDir . '/' . $id . '.json';
if (!is_file($path)) {
throw $this->createNotFoundException();
}

return $this->render('document.html.twig', ['document' => json_decode((string) file_get_contents($path), true)]);
}

#[Route('/documents/staging/{id}', name: 'document_staging', methods: ['GET'], requirements: ['id' => '[0-9a-f]{32}'])]
public function staging(Request $request, string $id): Response
{
if (!$this->uriSigner->checkRequest($request)) {
throw $this->createAccessDeniedException();
}

$path = $this->stagingDir . '/' . $id;
if (!is_file($path)) {
throw $this->createNotFoundException();
}

// attachmentAV detects the real file type itself, no need to guess the MIME type.
return new BinaryFileResponse($path, headers: ['Content-Type' => 'application/octet-stream']);
}
}
37 changes: 37 additions & 0 deletions examples/symfony/templates/document.html.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
{% if document.status == 'pending' %}<meta http-equiv="refresh" content="3">{% endif %}
<title>attachmentAV – Symfony example (async)</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 40rem; margin: 3rem auto; padding: 0 1rem; color: #1f2937; }
.result { padding: 1rem; border-radius: .5rem; border: 1px solid; }
.pending { background: #f3f4f6; border-color: #9ca3af; }
.clean { background: #ecfdf5; border-color: #10b981; }
.infected { background: #fef2f2; border-color: #ef4444; }
.no { background: #fffbeb; border-color: #f59e0b; }
dt { font-weight: 600; } dd { margin: 0 0 .5rem; }
</style>
</head>
<body>
<h1>Async virus and malware scan</h1>

<div class="result {{ document.status }}">
<dl>
<dt>File</dt><dd>{{ document.filename }}</dd>
<dt>Status</dt><dd>{{ document.status == 'pending' ? 'pending, waiting for the callback …' : document.status }}</dd>
{% if document.finding is defined and document.finding %}
<dt>Finding</dt><dd>{{ document.finding }}</dd>
{% endif %}
{% if document.size is defined %}
<dt>Size</dt><dd>{{ document.size }} bytes</dd>
<dt>Real file type</dt><dd>{{ document.realFileType ?? 'unknown' }}</dd>
{% endif %}
</dl>
</div>

<p><a href="{{ path('document_upload') }}">Upload another file</a></p>
</body>
</html>
33 changes: 33 additions & 0 deletions examples/symfony/templates/documents.html.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>attachmentAV – Symfony example (async)</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 40rem; margin: 3rem auto; padding: 0 1rem; color: #1f2937; }
form { display: flex; gap: .5rem; align-items: center; margin: 1.5rem 0; }
button { padding: .4rem 1rem; cursor: pointer; }
.result { padding: 1rem; border-radius: .5rem; border: 1px solid; }
.error { background: #fffbeb; border-color: #f59e0b; }
</style>
</head>
<body>
<h1>Async virus and malware scan</h1>
<p>Upload a file (max. 100 MB). attachmentAV downloads it from the staging directory, scans it in the background, and sends the scan result to the callback URL.</p>

<form method="post" action="{{ path('document_upload') }}" enctype="multipart/form-data">
<input type="hidden" name="_token" value="{{ csrf_token('documents') }}">
<input type="file" name="file" required>
<button type="submit">Upload</button>
</form>

{% if error is defined %}
<div class="result error">
{% if filename is defined %}<strong>{{ filename }}</strong> could not be submitted: {% endif %}{{ error }}
</div>
{% endif %}

<p><a href="{{ path('scan') }}">Sync scan</a></p>
</body>
</html>
2 changes: 2 additions & 0 deletions examples/symfony/templates/scan.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,7 @@
</dl>
</div>
{% endif %}

<p><a href="{{ path('document_upload') }}">Async scan</a></p>
</body>
</html>
Loading