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
71 changes: 59 additions & 12 deletions organizer/src/class/ThreadStorageManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,21 +46,29 @@ public function getThreadEmailContent($thread_id, $email_id) {

return stream_get_contents($content);
}
public function getThreadEmailAttachment(Thread $thread, $attachment_location) {
// Get attachment from database
$rows = Database::queryOne(
"SELECT tea.name, tea.filename, tea.filetype, tea.location, tea.status_type, tea.status_text,
tea.content
FROM thread_email_attachments tea
JOIN thread_emails te ON tea.email_id = te.id
WHERE te.thread_id = ? AND tea.location = ?",
[$thread->id, $attachment_location]

public function getThreadEmailContentWithTimestamp($thread_id, $email_id) {
// Get email content and timestamp from database
$row = Database::queryOneOrNone(
"SELECT content, timestamp_received FROM thread_emails WHERE thread_id = ? AND id = ?",
[$thread_id, $email_id]
);

if (empty($rows)) {
throw new Exception("Thread Email Attachment not found [thread_id=$thread->id, attachment_id=$attachment_location]");
if (empty($row)) {
return null;
}


return [
'content' => stream_get_contents($row['content']),
'timestamp' => $row['timestamp_received']
];
}
/**
* Maps database row to ThreadEmailAttachment object
* @param array $rows Database row with attachment data
* @return ThreadEmailAttachment
*/
private function mapRowToAttachment(array $rows) {
$attachment = new ThreadEmailAttachment();
$attachment->name = $rows['name'];
$attachment->filename = $rows['filename'];
Expand All @@ -82,4 +90,43 @@ public function getThreadEmailAttachment(Thread $thread, $attachment_location) {

return $attachment;
}

public function getThreadEmailAttachment(Thread $thread, $attachment_location) {
// Get attachment from database
$rows = Database::queryOne(
"SELECT tea.name, tea.filename, tea.filetype, tea.location, tea.status_type, tea.status_text,
tea.content
FROM thread_email_attachments tea
JOIN thread_emails te ON tea.email_id = te.id
WHERE te.thread_id = ? AND tea.location = ?",
[$thread->id, $attachment_location]
);

if (empty($rows)) {
throw new Exception("Thread Email Attachment not found [thread_id=$thread->id, attachment_id=$attachment_location]");
}

return $this->mapRowToAttachment($rows);
}

public function getThreadEmailAttachmentWithTimestamp(Thread $thread, $attachment_location) {
// Get attachment from database with timestamp
$rows = Database::queryOneOrNone(
"SELECT tea.name, tea.filename, tea.filetype, tea.location, tea.status_type, tea.status_text,
tea.content, tea.created_at
FROM thread_email_attachments tea
JOIN thread_emails te ON tea.email_id = te.id
WHERE te.thread_id = ? AND tea.location = ?",
[$thread->id, $attachment_location]
);

if (empty($rows)) {
throw new Exception("Thread Email Attachment not found [thread_id=$thread->id, attachment_id=$attachment_location]");
}

return [
'attachment' => $this->mapRowToAttachment($rows),
'timestamp' => $rows['created_at']
];
}
}
90 changes: 90 additions & 0 deletions organizer/src/e2e-tests/pages/FilePageTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,101 @@ public function testFileEmailBodyHappy() {

// :: Assert content type header for file download
$this->assertStringContainsString('Content-Type: text/html; charset=UTF-8', $response->headers);

// :: Assert Last-Modified header is present
$this->assertStringContainsString('Last-Modified:', $response->headers);

// :: Assert Cache-Control header is present
$this->assertStringContainsString('Cache-Control:', $response->headers);

// Contain "mailHeaders" as part of the output
$this->assertStringContainsString('HEADERS', $response->body);
$this->assertStringContainsString('From: sender@example.com', $response->body);
}

public function testFileEmailBodyIfModifiedSince() {
$testData = E2ETestSetup::createTestThread();
$file = [
'email_id' => $testData['email_id'],
'thread_id' => $testData['thread']->id,
'entity_id' => $testData['entity_id']
];

// First request - get the Last-Modified header
$response = $this->renderPage('/file?entityId=' . $file['entity_id'] . '&threadId=' . $file['thread_id'] . '&body=' . $file['email_id']);

// Extract Last-Modified header from response
$lastModified = null;
$lines = explode("\n", $response->headers);
foreach($lines as $line) {
if (stripos($line, 'Last-Modified:') !== false) {
$lastModified = trim(substr($line, strlen('Last-Modified:')));
break;
}
}

$this->assertNotNull($lastModified, 'Last-Modified header should be present');

// Second request - with If-Modified-Since header set to the same time
$response2 = $this->renderPage(
'/file?entityId=' . $file['entity_id'] . '&threadId=' . $file['thread_id'] . '&body=' . $file['email_id'],
'dev-user-id',
'GET',
'304 Not Modified',
null,
['If-Modified-Since: ' . $lastModified]
);

// Body should be empty for 304 response
$this->assertEmpty($response2->body, '304 response should have empty body');
}

public function testFileAttachmentIfModifiedSince() {
$testData = E2ETestSetup::createTestThread();
$file = [
'attachment_id' => $testData['attachment_id'],
'thread_id' => $testData['thread']->id,
'entity_id' => $testData['entity_id']
];

// First request - get the Last-Modified header
$response = $this->renderPage('/file?entityId=' . $file['entity_id'] . '&threadId=' . $file['thread_id'] . '&attachmentId=' . $file['attachment_id']);

// :: Assert content type header for attachment download
$this->assertStringContainsString('Content-Type: application/pdf', $response->headers);

// :: Assert Last-Modified header is present
$this->assertStringContainsString('Last-Modified:', $response->headers);

// :: Assert Cache-Control header with immutable directive
$this->assertStringContainsString('Cache-Control:', $response->headers);
$this->assertStringContainsString('immutable', $response->headers);

// Extract Last-Modified header from response
$lastModified = null;
$lines = explode("\n", $response->headers);
foreach($lines as $line) {
if (stripos($line, 'Last-Modified:') !== false) {
$lastModified = trim(substr($line, strlen('Last-Modified:')));
break;
}
}

$this->assertNotNull($lastModified, 'Last-Modified header should be present');

// Second request - with If-Modified-Since header set to the same time
$response2 = $this->renderPage(
'/file?entityId=' . $file['entity_id'] . '&threadId=' . $file['thread_id'] . '&attachmentId=' . $file['attachment_id'],
'dev-user-id',
'GET',
'304 Not Modified',
null,
['If-Modified-Since: ' . $lastModified]
);

// Body should be empty for 304 response
$this->assertEmpty($response2->body, '304 response should have empty body');
}
/*
public function testFilePdfHappy() {
// TODO:
Expand Down
13 changes: 9 additions & 4 deletions organizer/src/e2e-tests/pages/common/E2EPageTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,17 @@ class E2EPageTestCase extends TestCase {

private static $session_cookies = array();

protected function renderPage($path, $user = 'dev-user-id', $method = 'GET', $expected_status = '200 OK', $post_data = null) {
protected function renderPage($path, $user = 'dev-user-id', $method = 'GET', $expected_status = '200 OK', $post_data = null, $extra_headers = array()) {
$url = 'http://localhost:25081' . $path;
if ($user !== null) {
if (!isset(self::$session_cookies[$user])) {
$session_cookie = $this->authenticate($user);
self::$session_cookies[$user] = $session_cookie;
}
$session_cookie = self::$session_cookies[$user];
$response = $this->curl($url, $method, $session_cookie, post_data: $post_data);
$response = $this->curl($url, $method, $session_cookie, post_data: $post_data, extra_headers: $extra_headers);
} else {
$response = $this->curl($url, $method, post_data: $post_data);
$response = $this->curl($url, $method, post_data: $post_data, extra_headers: $extra_headers);
}

if ($expected_status != null) {
Expand Down Expand Up @@ -55,7 +55,7 @@ private function authenticate($user) {
return $response->cookies[0];
}

private function curl($url, $method = 'GET', $session_cookie = null, $headers = array(), $post_data = null) {
private function curl($url, $method = 'GET', $session_cookie = null, $headers = array(), $post_data = null, $extra_headers = array()) {
//echo date('Y-m-d H:i:s') . " - $method $url $session_cookie\n";

$ch = curl_init();
Expand All @@ -68,6 +68,11 @@ private function curl($url, $method = 'GET', $session_cookie = null, $headers =
$headers[] = 'Cookie: ' . $session_cookie;
}
$headers[] = 'User-Agent: Offpost E2E Test';

// Add any extra headers
foreach ($extra_headers as $header) {
$headers[] = $header;
}

if (!empty($headers)) {
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
Expand Down
47 changes: 44 additions & 3 deletions organizer/src/file.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,38 @@

foreach ($thread->emails as $email) {
if (isset($_GET['body']) && $_GET['body'] == $email->id) {
$eml = ThreadStorageManager::getInstance()->getThreadEmailContent($thread->id, $email->id);
$emailData = ThreadStorageManager::getInstance()->getThreadEmailContentWithTimestamp($thread->id, $email->id);
if (!$emailData) {
http_response_code(404);
header('Content-Type: text/plain');
die("Email content not found");
}

$eml = $emailData['content'];
$lastModified = strtotime($emailData['timestamp']);

// Check If-Modified-Since header early for 304 response
if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])) {
$ifModifiedSince = strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']);
if ($ifModifiedSince >= $lastModified) {
// Set headers before 304 response
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $lastModified) . ' GMT');
header('Cache-Control: private, max-age=3600');
http_response_code(304);
exit;
}
}

header('Content-Type: text/html; charset=UTF-8');

// Set Content-Security-Policy header to prevent XSS somewhat
header("Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none';");

$email_content = ThreadEmailExtractorEmailBody::extractContentFromEmail($eml);

// Set caching headers after successful extraction
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $lastModified) . ' GMT');
header('Cache-Control: private, max-age=3600');

$subject = Imap\ImapEmail::getEmailSubject($eml);
echo '<h1 id="email-subject">Subject: ' . htmlescape($subject) . '</h1>' . chr(10);
Expand Down Expand Up @@ -109,9 +132,27 @@
// New format of location
$filename = $att->location;
}
$att = ThreadStorageManager::getInstance()->getThreadEmailAttachment($thread, $att->location);
$attachmentData = ThreadStorageManager::getInstance()->getThreadEmailAttachmentWithTimestamp($thread, $att->location);
$att = $attachmentData['attachment'];
$lastModified = strtotime($attachmentData['timestamp']);

if (empty($att->content)) {
throw new Exception("Attachment content empty: threadId={$threadId}, attachmentId={$att->attachment_id}", 404);
throw new Exception("Attachment content empty: threadId={$threadId}, attachmentId={$att->id}", 404);
}

// Set Last-Modified header
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $lastModified) . ' GMT');

// Set Cache-Control header
header('Cache-Control: private, max-age=31536000, immutable'); // 1 year for attachments

// Check If-Modified-Since header
if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])) {
$ifModifiedSince = strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']);
if ($ifModifiedSince >= $lastModified) {
http_response_code(304);
exit;
}
}

if ($att->filetype == 'pdf') {
Expand Down