diff --git a/include/class.orm.php b/include/class.orm.php index 12bc89072444fbbcb44032f2afb5818d9fe3619a..53ea936477c95464b25e66c737cf8222ab09af2d 100644 --- a/include/class.orm.php +++ b/include/class.orm.php @@ -206,9 +206,9 @@ class VerySimpleModel { foreach ($this->dirty as $field=>$old) { if ($this->__new__ or !in_array($field, $pk)) { if (@get_class($this->get($field)) == 'SqlFunction') - $fields[] = $field.' = '.$this->get($field)->toSql(); + $fields[] = "`$field` = ".$this->get($field)->toSql(); else - $fields[] = $field.' = '.db_input($this->get($field)); + $fields[] = "`$field` = ".db_input($this->get($field)); } } $sql .= ' SET '.implode(', ', $fields); diff --git a/setup/cli/modules/class.module.php b/setup/cli/modules/class.module.php index a1647ce3cac8d2be9a57e29ab0fe60fe1eb82ce5..b91b020eaeda7512bcd1fa439d6f91cea5047922 100644 --- a/setup/cli/modules/class.module.php +++ b/setup/cli/modules/class.module.php @@ -92,11 +92,10 @@ class Option { class OutputStream { var $stream; - function OutputStream() { - call_user_func_array(array($this, '__construct'), func_get_args()); - } function __construct($stream) { - $this->stream = fopen($stream, 'w'); + if (!($this->stream = fopen($stream, 'w'))) + throw new Exception(sprintf('%s: Cannot open for writing', + $stream)); } function write($what) { diff --git a/setup/cli/modules/file.php b/setup/cli/modules/file.php index ee83169958c3e25a9c514e979564b7f6b699d588..f5057230ac293cae0b23b0944e0b419aca033369 100644 --- a/setup/cli/modules/file.php +++ b/setup/cli/modules/file.php @@ -12,6 +12,7 @@ class FileManager extends Module { 'list' => 'List files matching criteria', 'export' => 'Export files from the system', 'import' => 'Load files exported via `export`', + 'zip' => 'Create a zip file of the matching files', 'dump' => 'Dump file content to stdout', 'load' => 'Load file contents from stdin', 'migrate' => 'Migrate a file to another backend', @@ -176,11 +177,224 @@ class FileManager extends Module { $this->stdout->write("Migrated $count files\n"); break; + /** + * export + * + * Export file contents to a stream file. The format of the stream + * will be a continuous stream of file information in the following + * format: + * + * AFIL<meta-length><data-length><meta><data>EOF\x1c + * + * Where + * A is the version code of the export + * "FIL" is the literal text 'FIL' + * meta-length is 'V' packed header length (bytes) + * data-length is 'V' packed data length (bytes) + * meta is the %file record, php serialized + * data is the raw content of the file + * "EOF" is the literal text 'EOF' + * \x1c is an ASCII 0x1c byte (file separator) + * + * Options: + * --file File to which to direct the stream output, default + * is stdout + */ case 'export': - // Create a temporary ZIP file $files = FileModel::objects(); $this->_applyCriteria($options, $files); + if (!$options['file'] || $options['file'] == '-') + $options['file'] = 'php://stdout'; + + if (!($stream = fopen($options['file'], 'wb'))) + $this->fail($options['file'].': Unable to open file for export stream'); + + foreach ($files as $m) { + $f = AttachmentFile::lookup($m->id); + if ($options['verbose']) + $this->stderr->write($m->name."\n"); + + // TODO: Log %attachment and %ticket_attachment entries + $info = array('file' => $f->getInfo()); + $header = serialize($info); + fwrite($stream, 'AFIL'.pack('VV', strlen($header), $f->getSize())); + fwrite($stream, $header); + $FS = $f->open(); + while ($block = $FS->read()) + fwrite($stream, $block); + fwrite($stream, "EOF\x1c"); + } + fclose($stream); + break; + + /** + * import + * + * Import a collection of file contents exported by the `export`. + * See the export function above for details about the stream + * format. + * + * Options: + * --file File from which to read the export stream, default + * is stdin + * --to Backend to receive the contents (@see `backends`) + * --verbose Show file names while importing + */ + case 'import': + if (!$options['file'] || $options['file'] == '-') + $options['file'] = 'php://stdin'; + + if (!($stream = fopen($options['file'], 'rb'))) + $this->fail($options['file'].': Unable to open import stream'); + + while (true) { + // Read the file header + // struct file_data_header { + // char[4] marker; // Four chars, 'AFIL' + // int lenMeta; + // int lenData; + // }; + if (!($header = fread($stream, 12))) + break; // EOF + + list(, $mark, $hlen, $dlen) = unpack('V3', $header); + + // AFIL written as little-endian 4-byte int is 0x4c4946xx (LIFA), + // where 'A' is the version code of the export + $version = $mark & 0xff; + if (($mark >> 8) != 0x4c4946) + $this->fail('Bad file record'); + + // Read the header + $header = fread($stream, $hlen); + if (strlen($header) != $hlen) + $this->fail('Short read getting header info'); + + $header = unserialize($header); + if (!$header) + $this->fail('Unable to decipher file header'); + + // Find or create the file record + $finfo = $header['file']; + // TODO: Consider the $version code + $f = AttachmentFile::lookup($finfo['id']); + if ($f) { + // Verify file information + if ($f->getSize() != $finfo['size'] + || $f->getSignature() != $finfo['signature'] + ) { + $this->fail(sprintf( + '%s: File data does not match existing file record', + $finfo['name'] + )); + } + // Drop existing file contents, if any + try { + if ($bk = $f->open()) + $bk->unlink(); + } + catch (Exception $e) {} + } + // Create a new file + else { + $fm = FileModel::create($finfo); + if (!$fm->save() || !($f = AttachmentFile::lookup($fm->id))) { + $this->fail(sprintf( + '%s: Unable to create new file record', + $finfo['name'])); + } + } + + // Determine the backend to recieve the file contents + if ($options['to']) { + $bk = FileStorageBackend::lookup($options['to'], $f); + } + // Use the system default + else { + $bk = AttachmentFile::getBackendForFile($f); + } + + if ($options['verbose']) + $this->stdout->write('Importing '.$f->getName()."\n"); + + // Write file contents to the backend + $md5 = hash_init('md5'); + $sha1 = hash_init('sha1'); + $written = 0; + + // Handle exceptions by dropping imported file contents and + // then returning the error to the error output stream. + try { + while ($dlen > 0) { + $read_size = min($dlen, $bk->getBlockSize()); + $contents = ''; + // reading from the stream will likely return an amount of + // data different from the backend requested block size. Loop + // until $read_size bytes are recieved. + while ($read_size > 0 && ($block = fread($stream, $read_size))) { + $contents .= $block; + $read_size -= strlen($block); + } + if ($read_size != 0) { + // short read + throw new Exception(sprintf( + '%s: Some contents are missing from the stream', + $f->getName() + )); + } + // Calculate MD5 and SHA1 hashes of the file to verify + // contents after successfully written to backend + if (!$bk->write($contents)) + throw new Exception( + 'Unable to send file contents to backend'); + hash_update($md5, $contents); + hash_update($sha1, $contents); + $dlen -= strlen($contents); + $written += strlen($contents); + } + // Some backends cannot handle flush() without a + // corresponding write() call. + if ($written && !$bk->flush()) + throw new Exception( + 'Unable to commit file contents to backend'); + + // Check the signature hash + if ($finfo['signature']) { + $md5 = base64_encode(hash_final($md5, true)); + $sha1 = base64_encode(hash_final($sha1, true)); + $sig = str_replace( + array('=','+','/'), + array('','-','_'), + substr($sha1, 0, 16) . substr($md5, 0, 16)); + if ($sig != $finfo['signature']) { + throw new Exception(sprintf( + '%s: Signature verification failed', + $f->getName() + )); + } + } + } // end try + catch (Exception $ex) { + if ($bk) $bk->unlink(); + $this->fail($ex->getMessage()); + } + + // Read file record footer + $footer = fread($stream, 4); + if (strlen($footer) != 4) + $this->fail('Unable to read file EOF marker'); + list(, $footer) = unpack('N', $footer); + // Footer should be EOF\x1c as an int + if ($footer != 0x454f461c) + $this->fail('Incorrect file EOF marker'); + } + break; + + case 'zip': + // Create a temporary ZIP file + $files = FileModel::objects(); + $this->_applyCriteria($options, $files); if (!$options['file']) $this->fail('Please specify zip file with `-f`'); @@ -189,30 +403,33 @@ class FileManager extends Module { ZipArchive::CREATE))) $this->fail($reason.': Unable to create zip file'); - $manifest = array(); foreach ($files as $m) { $f = AttachmentFile::lookup($m->id); - $zip->addFromString($f->getId(), $f->getData()); - $zip->setCommentName($f->getId(), $f->getName()); - // TODO: Log %attachment and %ticket_attachment entries - $info = array('file' => $f->getInfo()); - foreach ($m->tickets as $t) - $info['tickets'][] = $t->ht; - - $manifest[$f->getId()] = $info; + if ($options['verbose']) + $this->stderr->write($m->name."\n"); + $name = Format::encode(sprintf( + '%d-%s', $f->getId(), $f->getName() + ), 'utf-8', 'cp437'); + $zip->addFromString($name, $f->getData()); } - $zip->addFromString('MANIFEST', serialize($manifest)); $zip->close(); break; case 'expunge': - // Create a temporary ZIP file $files = FileModel::objects(); $this->_applyCriteria($options, $files); - foreach ($files as $f) { - $f->tickets->expunge(); - $f->unlink() && $f->delete(); + foreach ($files as $m) { + // Drop associated attachment links + $m->tickets->expunge(); + $f = AttachmentFile::lookup($m->id); + + // Drop file contents + if ($bk = $f->open()) + $bk->unlink(); + + // Drop file record + $f->delete(); } } }