files: Provide unified download script

This script adds a single download script, 'file.php', which provides access
to files of all types to all users. It uses a HMAC signature system with an
expires time, which allows signed URLs to be sent to external users.

This also fixes an issue with the Http::cacheable() method, where the
last-modified and Etag headers were not properly compared, which resulted in
permanent cache misses by the client.
This commit is contained in:
Jared Hancock
2014-12-29 13:50:10 -06:00
parent 1e686f7d68
commit d9cf38bcee
19 changed files with 140 additions and 248 deletions

View File

@@ -1,36 +0,0 @@
<?php
/*********************************************************************
attachment.php
Attachments interface for clients.
Clients should never see the dir paths.
Peter Rotich <peter@osticket.com>
Copyright (c) 2006-2013 osTicket
http://www.osticket.com
Released under the GNU General Public License WITHOUT ANY WARRANTY.
See LICENSE.TXT for details.
vim: expandtab sw=4 ts=4 sts=4:
**********************************************************************/
require('secure.inc.php');
require_once(INCLUDE_DIR.'class.attachment.php');
//Basic checks
if(!$thisclient
|| !$_GET['id']
|| !$_GET['h']
|| !($attachment=Attachment::lookup($_GET['id']))
|| !($file=$attachment->getFile()))
Http::response(404, __('Unknown or invalid file'));
//Validate session access hash - we want to make sure the link is FRESH! and the user has access to the parent ticket!!
$vhash=md5($attachment->getFileId().session_id().strtolower($file->getKey()));
if(strcasecmp(trim($_GET['h']),$vhash)
|| !($ticket=$attachment->getTicket())
|| !$ticket->checkUserAccess($thisclient))
Http::response(404, __('Unknown or invalid file'));
//Download the file..
$file->download();
?>

39
file.php Normal file
View File

@@ -0,0 +1,39 @@
<?php
/*********************************************************************
file.php
File download facilitator for clients
Peter Rotich <peter@osticket.com>
Jared Hancock <jared@osticket.com>
Copyright (c) 2006-2014 osTicket
http://www.osticket.com
Released under the GNU General Public License WITHOUT ANY WARRANTY.
See LICENSE.TXT for details.
vim: expandtab sw=4 ts=4 sts=4:
**********************************************************************/
require('client.inc.php');
require_once(INCLUDE_DIR.'class.file.php');
//Basic checks
if (!$_GET['key']
|| !$_GET['signature']
|| !$_GET['expires']
|| !($file = AttachmentFile::lookup($_GET['key']))
) {
Http::response(404, __('Unknown or invalid file'));
}
// Validate session access hash - we want to make sure the link is FRESH!
// and the user has access to the parent ticket!!
if ($file->verifySignature($_GET['signature'], $_GET['expires'])) {
if (($s = @$_GET['s']) && strpos($file->getType(), 'image/') === 0)
$file->display($s);
// Download the file..
$file->download(@$_GET['disposition'] ?: false, $_GET['expires']);
}
// else
Http::response(404, __('Unknown or invalid file'));

View File

@@ -1,31 +0,0 @@
<?php
/*********************************************************************
image.php
Simply downloads the file...on hash validation as follows;
* Hash must be 64 chars long.
* First 32 chars is the perm. file hash
* Next 32 chars is md5(file_id.session_id().file_hash)
Peter Rotich <peter@osticket.com>
Copyright (c) 2006-2013 osTicket
http://www.osticket.com
Released under the GNU General Public License WITHOUT ANY WARRANTY.
See LICENSE.TXT for details.
vim: expandtab sw=4 ts=4 sts=4:
**********************************************************************/
require('client.inc.php');
require_once(INCLUDE_DIR.'class.file.php');
$h=trim($_GET['h']);
//basic checks
if(!$h || strlen($h)!=64 //32*2
|| !($file=AttachmentFile::lookup(substr($h,0,32))) //first 32 is the file hash.
|| strcasecmp($h, $file->getDownloadHash())) //next 32 is file id + session hash.
Http::response(404, __('Unknown or invalid file'));
$file->display();
?>

View File

@@ -129,7 +129,7 @@ class DraftAjaxAPI extends AjaxController {
echo JsonDataEncoder::encode(array(
'content_id' => 'cid:'.$f->getKey(),
'filelink' => sprintf('image.php?h=%s', $f->getDownloadHash())
'filelink' => $f->getDownloadUrl(false, 'inline'),
));
}
@@ -309,7 +309,7 @@ class DraftAjaxAPI extends AjaxController {
);
while (list($id, $type) = db_fetch_row($res)) {
$f = AttachmentFile::lookup($id);
$url = 'image.php?h='.$f->getDownloadHash();
$url = $f->getDownloadUrl();
$files[] = array(
'thumb'=>$url.'&s=128',
'image'=>$url,

View File

@@ -171,15 +171,15 @@ class GenericAttachments {
function _getList($separate=false, $inlines=false) {
if(!isset($this->attachments)) {
$this->attachments = array();
$sql='SELECT f.id, f.size, f.`key`, f.name, a.inline '
$sql='SELECT f.id, f.size, f.`key`, f.signature, f.name, a.inline '
.' FROM '.FILE_TABLE.' f '
.' INNER JOIN '.ATTACHMENT_TABLE.' a ON(f.id=a.file_id) '
.' WHERE a.`type`='.db_input($this->getType())
.' AND a.object_id='.db_input($this->getId());
if(($res=db_query($sql)) && db_num_rows($res)) {
while($rec=db_fetch_array($res)) {
$rec['download'] = AttachmentFile::getDownloadForIdAndKey(
$rec['id'], $rec['key']);
$rec['download_url'] = AttachmentFile::generateDownloadUrl(
$rec['id'], $rec['key'], $rec['signature']);
$this->attachments[] = $rec;
}
}

View File

@@ -61,7 +61,7 @@ class Draft {
}
function setBody($body) {
// Change image.php urls back to content-id's
// Change file.php urls back to content-id's
$body = Format::sanitize($body, false);
$this->ht['body'] = $body;

View File

@@ -61,7 +61,7 @@ class FAQ {
function getQuestion() { return $this->ht['question']; }
function getAnswer() { return $this->ht['answer']; }
function getAnswerWithImages() {
return Format::viewableImages($this->ht['answer'], ROOT_PATH.'image.php');
return Format::viewableImages($this->ht['answer']);
}
function getSearchableAnswer() {
return ThreadBody::fromFormattedText($this->ht['answer'], 'html')
@@ -198,12 +198,11 @@ class FAQ {
if(($attachments=$this->attachments->getSeparates())) {
foreach($attachments as $attachment ) {
/* The h key must match validation in file.php */
$hash=$attachment['key'].md5($attachment['id'].session_id().strtolower($attachment['key']));
if($attachment['size'])
$size=sprintf('&nbsp;<small>(<i>%s</i>)</small>',Format::file_size($attachment['size']));
$str.=sprintf('<a class="Icon file no-pjax" href="file.php?h=%s" target="%s">%s</a>%s&nbsp;%s',
$hash, $target, Format::htmlchars($attachment['name']), $size, $separator);
$str.=sprintf('<a class="Icon file no-pjax" href="%s" target="%s">%s</a>%s&nbsp;%s',
$attachment['download_url'], $target, Format::htmlchars($attachment['name']), $size, $separator);
}
}

View File

@@ -107,19 +107,6 @@ class AttachmentFile {
return $this->ht['created'];
}
static function getDownloadForIdAndKey($id, $key) {
return strtolower($key . md5($id.session_id().strtolower($key)));
}
/**
* Retrieve a signature that can be sent to scp/file.php?h= in order to
* download this file
*/
function getDownloadHash() {
return self::getDownloadForIdAndKey($this->getId(), $this->getKey());
}
function open() {
return FileStorageBackend::getInstance($this);
}
@@ -200,13 +187,70 @@ class AttachmentFile {
exit();
}
function download() {
function getDownloadUrl($minage=false, $disposition=false, $handler=false) {
// XXX: Drop this when AttachmentFile goes to ORM
return static::generateDownloadUrl($this->getId(),
strtolower($this->getKey()), $this->getSignature(), $minage,
$disposition, $handler);
}
static function generateDownloadUrl($id, $key, $hash, $minage=false,
$disposition=false, $handler=false
) {
// Expire at the nearest midnight, allowing at least 12 hours access
$minage = $minage ?: 43200;
$gmnow = Misc::gmtime() + $minage;
$expires = $gmnow + 86400 - ($gmnow % 86400);
// Generate a signature based on secret content
$signature = static::_genUrlSignature($id, $key, $hash, $expires);
$handler = $handler ?: ROOT_PATH . 'file.php';
// Return sanitized query string
$args = array(
'key' => $key,
'expires' => $expires,
'signature' => $signature,
);
if ($disposition)
$args['disposition'] = $disposition;
return $handler . '?' . http_build_query($args);
}
function verifySignature($signature, $expires) {
$gmnow = Misc::gmtime();
if ($expires < $gmnow)
return false;
$check = static::_genUrlSignature($this->getId(), $this->getKey(),
$this->getSignature(), $expires);
return $signature == $check;
}
static function _genUrlSignature($id, $key, $signature, $expires) {
$pieces = array(
'Host='.$_SERVER['HTTP_HOST'],
'Path='.ROOT_PATH,
'Id='.$id,
'Key='.strtolower($key),
'Hash='.$signature,
'Expires='.$expires,
);
return hash_hmac('sha1', implode("\n", $pieces), SECRET_SALT);
}
function download($disposition=false, $expires=false) {
$disposition = $disposition ?: 'attachment';
$bk = $this->open();
if ($bk->sendRedirectUrl('inline'))
if ($bk->sendRedirectUrl($disposition))
return;
$this->makeCacheable();
$ttl = ($expires) ? $expires - Misc::gmtime() : false;
$this->makeCacheable($ttl);
Http::download($this->getName(), $this->getType() ?: 'application/octet-stream',
null, 'inline');
null, $disposition);
header('Content-Length: '.$this->getSize());
$this->sendData(false);
exit();

View File

@@ -259,9 +259,10 @@ class Format {
}
function localizeInlineImages($text) {
// Change image.php urls back to content-id's
return preg_replace('/image\\.php\\?h=([\\w.-]{32})\\w{32}/',
'cid:$1', $text);
// Change file.php urls back to content-id's
return preg_replace(
'/src="(?:\/[^"]+?)?\/file\\.php\\?(?:\w+=[^&]+&(?:amp;)?)*?key=([^&]+)[^"]*/',
'src="cid:$1', $text);
}
function sanitize($text, $striptags=false) {
@@ -430,14 +431,14 @@ class Format {
}
function viewableImages($html, $script='image.php') {
function viewableImages($html, $script=false) {
return preg_replace_callback('/"cid:([\w._-]{32})"/',
function($match) use ($script) {
$hash = $match[1];
if (!($file = AttachmentFile::lookup($hash)))
return $match[0];
return sprintf('"%s?h=%s" data-cid="%s"',
$script, $file->getDownloadHash(), $match[1]);
return sprintf('"%s" data-cid="%s"',
$file->getDownloadUrl(false, 'inline', $script), $match[1]);
}, $html);
}

View File

@@ -1643,10 +1643,8 @@ class FileUploadField extends FormField {
function display($value) {
$links = array();
foreach ($this->getFiles() as $f) {
$hash = strtolower($f['key']
. md5($f['id'].session_id().strtolower($f['key'])));
$links[] = sprintf('<a class="no-pjax" href="file.php?h=%s">%s</a>',
$hash, Format::htmlchars($f['name']));
$links[] = sprintf('<a class="no-pjax" href="%s">%s</a>',
Format::htmlchars($f['download_url']), Format::htmlchars($f['name']));
}
return implode('<br/>', $links);
}
@@ -2092,6 +2090,7 @@ class FileUploadWidget extends Widget {
'name' => $file->getName(),
'type' => $file->getType(),
'size' => $file->getSize(),
'download_url' => $file->getDownloadUrl(),
);
}
}

View File

@@ -63,14 +63,16 @@ class Http {
function cacheable($etag, $modified, $ttl=3600) {
// Thanks, http://stackoverflow.com/a/1583753/1025836
$last_modified = Misc::db2gmtime($modified);
header("Last-Modified: ".date('D, d M y H:i:s', $last_modified)." GMT", false);
// Timezone doesn't matter here — but the time needs to be
// consistent round trip to the browser and back.
$last_modified = strtotime($modified." GMT");
header("Last-Modified: ".date('D, d M Y H:i:s', $last_modified)." GMT", false);
header('ETag: "'.$etag.'"');
header("Cache-Control: private, max-age=$ttl");
header('Expires: ' . gmdate(DATE_RFC822, time() + $ttl)." GMT");
header('Expires: ' . gmdate('D, d M Y H:i:s', Misc::gmtime() + $ttl)." GMT");
header('Pragma: private');
if (@strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) == $last_modified ||
@trim($_SERVER['HTTP_IF_NONE_MATCH']) == $etag) {
@trim($_SERVER['HTTP_IF_NONE_MATCH'], '"') == $etag) {
header("HTTP/1.1 304 Not Modified");
exit();
}
@@ -97,9 +99,8 @@ class Http {
function download($filename, $type, $data=null, $disposition='attachment') {
header('Pragma: private');
header('Expires: 0');
header('Cache-Control: must-revalidate, post-check=0, pre-check=0');
header('Cache-Control: private');
header('Cache-Control: private', false);
header('Content-Type: '.$type);
header(sprintf('Content-Disposition: %s; %s',
$disposition,

View File

@@ -71,7 +71,7 @@ class Page {
return $this->ht['body'];
}
function getBodyWithImages() {
return Format::viewableImages($this->getBody(), ROOT_PATH.'image.php');
return Format::viewableImages($this->getBody());
}
function getNotes() {

View File

@@ -579,7 +579,7 @@ Class ThreadEntry {
return $this->attachments;
//XXX: inner join the file table instead?
$sql='SELECT a.attach_id, f.id as file_id, f.size, lower(f.`key`) as file_hash, f.name, a.inline '
$sql='SELECT a.attach_id, f.id as file_id, f.size, lower(f.`key`) as file_hash, f.`signature` as file_sig, f.name, a.inline '
.' FROM '.FILE_TABLE.' f '
.' INNER JOIN '.TICKET_ATTACHMENT_TABLE.' a ON(f.id=a.file_id) '
.' WHERE a.ticket_id='.db_input($this->getTicketId())
@@ -587,19 +587,21 @@ Class ThreadEntry {
$this->attachments = array();
if(($res=db_query($sql)) && db_num_rows($res)) {
while($rec=db_fetch_array($res))
while($rec=db_fetch_array($res)) {
$rec['download_url'] = AttachmentFile::generateDownloadUrl(
$rec['file_id'], $rec['file_hash'], $rec['file_sig']);
$this->attachments[] = $rec;
}
}
return $this->attachments;
}
function getAttachmentUrls($script='image.php') {
function getAttachmentUrls() {
$json = array();
foreach ($this->getAttachments() as $att) {
$json[$att['file_hash']] = array(
'download_url' => sprintf('attachment.php?id=%d&h=%s', $att['attach_id'],
strtolower(md5($att['file_id'].session_id().$att['file_hash']))),
'download_url' => $att['download_url'],
'filename' => $att['name'],
);
}
@@ -612,14 +614,12 @@ Class ThreadEntry {
foreach($this->getAttachments() as $attachment ) {
if ($attachment['inline'])
continue;
/* The hash can be changed but must match validation in @file */
$hash=md5($attachment['file_id'].session_id().$attachment['file_hash']);
$size = '';
if($attachment['size'])
$size=sprintf('<em>(%s)</em>', Format::file_size($attachment['size']));
$str.=sprintf('<a class="Icon file no-pjax" href="%s?id=%d&h=%s" target="%s">%s</a>%s&nbsp;%s',
$file, $attachment['attach_id'], $hash, $target, Format::htmlchars($attachment['name']), $size, $separator);
$str.=sprintf('<a class="Icon file no-pjax" href="%s" target="%s">%s</a>%s&nbsp;%s',
$attachment['download_url'], $target, Format::htmlchars($attachment['name']), $size, $separator);
}
return $str;

View File

@@ -135,7 +135,7 @@ $pages = Page::getPages();
echo $logo->getId(); ?>" <?php
if ($logo->getId() == $current)
echo 'checked="checked"'; ?>/>
<img src="image.php?h=<?php echo $logo->getDownloadHash(); ?>"
<img src="<?php echo $logo->getDownloadUrl(); ?>"
alt="Custom Logo" valign="middle"
style="box-shadow: 0 0 0.5em rgba(0,0,0,0.5);
margin: 0.5em; height: 5em;

View File

@@ -180,11 +180,12 @@
}
if (file.id)
filenode.data('fileId', file.id);
if (file.download)
if (file.download_url) {
filenode.find('.filename').prepend(
$('<a class="no-pjax" target="_blank"></a>').text(file.name)
.attr('href', 'file.php?h='+escape(file.download))
.attr('href', file.download_url)
);
}
else
filenode.find('.filename').prepend($('<span>').text(file.name));
this.$element.parent().find('.files').append(filenode);

View File

@@ -1,30 +0,0 @@
<?php
/*********************************************************************
file.php
Simply downloads the file...on hash validation as follows;
* Hash must be 64 chars long.
* First 32 chars is the perm. file hash
* Next 32 chars is md5(file_id.session_id().file_hash)
Peter Rotich <peter@osticket.com>
Copyright (c) 2006-2013 osTicket
http://www.osticket.com
Released under the GNU General Public License WITHOUT ANY WARRANTY.
See LICENSE.TXT for details.
vim: expandtab sw=4 ts=4 sts=4:
**********************************************************************/
require('kb.inc.php');
require_once(INCLUDE_DIR.'class.file.php');
$h=trim($_GET['h']);
//basic checks
if(!$h || strlen($h)!=64 //32*2
|| !($file=AttachmentFile::lookup(substr($h,0,32))) //first 32 is the file hash.
|| strcasecmp($h, $file->getDownloadHash())) //next 32 is file id + session hash.
Http::response(404, __('Unknown or invalid file'));
$file->download();
?>

View File

@@ -1,31 +0,0 @@
<?php
/*********************************************************************
attachment.php
Handles attachment downloads & access validation.
Peter Rotich <peter@osticket.com>
Copyright (c) 2006-2013 osTicket
http://www.osticket.com
Released under the GNU General Public License WITHOUT ANY WARRANTY.
See LICENSE.TXT for details.
vim: expandtab sw=4 ts=4 sts=4:
**********************************************************************/
require('staff.inc.php');
require_once(INCLUDE_DIR.'class.attachment.php');
//Basic checks
if(!$thisstaff || !$_GET['id'] || !$_GET['h']
|| !($attachment=Attachment::lookup($_GET['id']))
|| !($file=$attachment->getFile()))
Http::response(404, __('Unknown or invalid file'));
//Validate session access hash - we want to make sure the link is FRESH! and the user has access to the parent ticket!!
$vhash=md5($attachment->getFileId().session_id().strtolower($file->getKey()));
if(strcasecmp(trim($_GET['h']),$vhash) || !($ticket=$attachment->getTicket()) || !$ticket->checkStaffAccess($thisstaff)) die(__('Access Denied'));
//Download the file..
$file->download();
?>

View File

@@ -1,30 +0,0 @@
<?php
/*********************************************************************
file.php
Simply downloads the file...on hash validation as follows;
* Hash must be 64 chars long.
* First 32 chars is the perm. file hash
* Next 32 chars is md5(file_id.session_id().file_hash)
Peter Rotich <peter@osticket.com>
Copyright (c) 2006-2013 osTicket
http://www.osticket.com
Released under the GNU General Public License WITHOUT ANY WARRANTY.
See LICENSE.TXT for details.
vim: expandtab sw=4 ts=4 sts=4:
**********************************************************************/
require('staff.inc.php');
require_once(INCLUDE_DIR.'class.file.php');
$h=trim($_GET['h']);
//basic checks
if(!$h || strlen($h)!=64 //32*2
|| !($file=AttachmentFile::lookup(substr($h,0,32))) //first 32 is the file hash.
|| strcasecmp($file->getDownloadHash(), $h)) //next 32 is file id + session hash.
Http::response(404, __('Unknown or invalid file'));
$file->download();
?>

View File

@@ -1,34 +0,0 @@
<?php
/*********************************************************************
image.php
Simply downloads the file...on hash validation as follows;
* Hash must be 64 chars long.
* First 32 chars is the perm. file hash
* Next 32 chars is md5(file_id.session_id().file_hash)
Peter Rotich <peter@osticket.com>
Copyright (c) 2006-2013 osTicket
http://www.osticket.com
Released under the GNU General Public License WITHOUT ANY WARRANTY.
See LICENSE.TXT for details.
vim: expandtab sw=4 ts=4 sts=4:
**********************************************************************/
require('staff.inc.php');
require_once(INCLUDE_DIR.'class.file.php');
$h=trim($_GET['h']);
//basic checks
if(!$h || strlen($h)!=64 //32*2
|| !($file=AttachmentFile::lookup(substr($h,0,32))) //first 32 is the file hash.
|| strcasecmp($h, $file->getDownloadHash())) //next 32 is file id + session hash.
Http::response(404, __('Unknown or invalid file'));
if ($_GET['s'] && is_numeric($_GET['s']))
$file->display($_GET['s']);
else
$file->display();
?>