1*29ed7b46Stracker-user<?php 2*29ed7b46Stracker-user/** 3*29ed7b46Stracker-user * Site Backup admin plugin for DokuWiki. 4*29ed7b46Stracker-user * 5*29ed7b46Stracker-user * Renders a small form letting an admin pick which parts of the wiki to 6*29ed7b46Stracker-user * include in a tar.gz, builds it on the server side, then streams it to 7*29ed7b46Stracker-user * the browser as a download. Uses DokuWiki's bundled splitbrain/php-archive 8*29ed7b46Stracker-user * (no external dependencies). 9*29ed7b46Stracker-user * 10*29ed7b46Stracker-user * Intentionally admin-only (forAdminOnly() = true). The archive can contain 11*29ed7b46Stracker-user * password hashes (conf/users.auth.php), ACLs, and any credentials stored in 12*29ed7b46Stracker-user * conf/local.php (DB, SMTP, etc.), so treat the download as sensitive. 13*29ed7b46Stracker-user */ 14*29ed7b46Stracker-user 15*29ed7b46Stracker-useruse dokuwiki\Extension\AdminPlugin; 16*29ed7b46Stracker-useruse dokuwiki\Form\Form; 17*29ed7b46Stracker-useruse splitbrain\PHPArchive\Tar; 18*29ed7b46Stracker-useruse splitbrain\PHPArchive\Archive; 19*29ed7b46Stracker-useruse splitbrain\PHPArchive\ArchiveIOException; 20*29ed7b46Stracker-user 21*29ed7b46Stracker-userclass admin_plugin_sitebackup extends AdminPlugin 22*29ed7b46Stracker-user{ 23*29ed7b46Stracker-user /** @var array list of [absolute path, archive-relative path] of files to include */ 24*29ed7b46Stracker-user protected $fileList = []; 25*29ed7b46Stracker-user 26*29ed7b46Stracker-user /** @var int total uncompressed size of selected files */ 27*29ed7b46Stracker-user protected $totalBytes = 0; 28*29ed7b46Stracker-user 29*29ed7b46Stracker-user public function forAdminOnly() 30*29ed7b46Stracker-user { 31*29ed7b46Stracker-user return true; 32*29ed7b46Stracker-user } 33*29ed7b46Stracker-user 34*29ed7b46Stracker-user public function getMenuSort() 35*29ed7b46Stracker-user { 36*29ed7b46Stracker-user return 1000; 37*29ed7b46Stracker-user } 38*29ed7b46Stracker-user 39*29ed7b46Stracker-user public function getMenuText($language) 40*29ed7b46Stracker-user { 41*29ed7b46Stracker-user return 'Site Backup'; 42*29ed7b46Stracker-user } 43*29ed7b46Stracker-user 44*29ed7b46Stracker-user /** 45*29ed7b46Stracker-user * Dispatch based on the submitted action. 46*29ed7b46Stracker-user * Actions: "preview" (default - show file list + sizes), "download" (stream tar.gz). 47*29ed7b46Stracker-user */ 48*29ed7b46Stracker-user public function handle() 49*29ed7b46Stracker-user { 50*29ed7b46Stracker-user global $INPUT; 51*29ed7b46Stracker-user if (!$INPUT->has('sitebackup_action')) return; 52*29ed7b46Stracker-user if (!checkSecurityToken()) return; 53*29ed7b46Stracker-user 54*29ed7b46Stracker-user $action = $INPUT->str('sitebackup_action'); 55*29ed7b46Stracker-user if ($action !== 'preview' && $action !== 'download') return; 56*29ed7b46Stracker-user 57*29ed7b46Stracker-user $this->collectFiles(); 58*29ed7b46Stracker-user 59*29ed7b46Stracker-user if ($action === 'download') { 60*29ed7b46Stracker-user $this->streamArchive(); 61*29ed7b46Stracker-user // streamArchive exits when successful; if it returns, fall through to html() 62*29ed7b46Stracker-user } 63*29ed7b46Stracker-user // For 'preview', html() will render the file list + a download button. 64*29ed7b46Stracker-user } 65*29ed7b46Stracker-user 66*29ed7b46Stracker-user public function html() 67*29ed7b46Stracker-user { 68*29ed7b46Stracker-user global $INPUT; 69*29ed7b46Stracker-user 70*29ed7b46Stracker-user echo '<h1>Site Backup</h1>'; 71*29ed7b46Stracker-user echo '<p>Select what to include, click <em>Preview</em> to see the file list and total size, ' 72*29ed7b46Stracker-user . 'then <em>Download</em> to get a tar.gz archive.</p>'; 73*29ed7b46Stracker-user echo '<p style="background:#fff3cd;border:1px solid #ffeeba;padding:8px;border-radius:4px;">' 74*29ed7b46Stracker-user . '<strong>Sensitive content warning.</strong> The archive can contain password hashes ' 75*29ed7b46Stracker-user . '(<code>conf/users.auth.php</code>), ACL rules, and any secrets stored in ' 76*29ed7b46Stracker-user . '<code>conf/local.php</code> (DB, SMTP, API keys). Treat it like a credential.' 77*29ed7b46Stracker-user . '</p>'; 78*29ed7b46Stracker-user 79*29ed7b46Stracker-user $this->renderForm(); 80*29ed7b46Stracker-user 81*29ed7b46Stracker-user if ($this->fileList) { 82*29ed7b46Stracker-user $this->renderPreview(); 83*29ed7b46Stracker-user } 84*29ed7b46Stracker-user } 85*29ed7b46Stracker-user 86*29ed7b46Stracker-user /* ----------------------------------------------------------------- * 87*29ed7b46Stracker-user * Form 88*29ed7b46Stracker-user * ----------------------------------------------------------------- */ 89*29ed7b46Stracker-user 90*29ed7b46Stracker-user protected function renderForm() 91*29ed7b46Stracker-user { 92*29ed7b46Stracker-user global $INPUT; 93*29ed7b46Stracker-user 94*29ed7b46Stracker-user // Read current selections (defaulting to "everything sensible" on first load). 95*29ed7b46Stracker-user $hasSubmitted = $INPUT->has('sitebackup_action'); 96*29ed7b46Stracker-user $defaults = [ 97*29ed7b46Stracker-user 'pages' => true, 98*29ed7b46Stracker-user 'media' => true, 99*29ed7b46Stracker-user 'meta' => true, 100*29ed7b46Stracker-user 'media_meta' => true, 101*29ed7b46Stracker-user 'attic' => false, 102*29ed7b46Stracker-user 'media_attic' => false, 103*29ed7b46Stracker-user 'index' => false, 104*29ed7b46Stracker-user 'conf' => true, 105*29ed7b46Stracker-user 'plugins' => true, 106*29ed7b46Stracker-user 'tpl' => true, 107*29ed7b46Stracker-user ]; 108*29ed7b46Stracker-user $sel = []; 109*29ed7b46Stracker-user foreach ($defaults as $k => $def) { 110*29ed7b46Stracker-user $sel[$k] = $hasSubmitted ? $INPUT->bool('sb_' . $k, false) : $def; 111*29ed7b46Stracker-user } 112*29ed7b46Stracker-user 113*29ed7b46Stracker-user $form = new Form(['method' => 'POST', 'id' => 'sitebackup_form']); 114*29ed7b46Stracker-user $form->setHiddenField('do', 'admin'); 115*29ed7b46Stracker-user $form->setHiddenField('page', 'sitebackup'); 116*29ed7b46Stracker-user 117*29ed7b46Stracker-user $form->addFieldsetOpen('Wiki content'); 118*29ed7b46Stracker-user $this->addCheckboxRow($form, 'sb_pages', 'Pages (data/pages)', $sel['pages']); 119*29ed7b46Stracker-user $this->addCheckboxRow($form, 'sb_media', 'Media files (data/media)', $sel['media']); 120*29ed7b46Stracker-user $this->addCheckboxRow($form, 'sb_meta', 'Page metadata (data/meta)', $sel['meta']); 121*29ed7b46Stracker-user $this->addCheckboxRow($form, 'sb_media_meta', 'Media metadata (data/media_meta)', $sel['media_meta']); 122*29ed7b46Stracker-user $this->addCheckboxRow($form, 'sb_attic', 'Page revisions (data/attic) - can be large', $sel['attic']); 123*29ed7b46Stracker-user $this->addCheckboxRow($form, 'sb_media_attic', 'Media revisions (data/media_attic)', $sel['media_attic']); 124*29ed7b46Stracker-user $this->addCheckboxRow($form, 'sb_index', 'Search index (data/index) - rebuildable', $sel['index']); 125*29ed7b46Stracker-user $form->addFieldsetClose(); 126*29ed7b46Stracker-user 127*29ed7b46Stracker-user $form->addFieldsetOpen('Configuration & code'); 128*29ed7b46Stracker-user $this->addCheckboxRow($form, 'sb_conf', 'Configuration (conf/) - includes secrets', $sel['conf']); 129*29ed7b46Stracker-user $this->addCheckboxRow($form, 'sb_plugins', 'Plugins source (lib/plugins/)', $sel['plugins']); 130*29ed7b46Stracker-user $this->addCheckboxRow($form, 'sb_tpl', 'Templates source (lib/tpl/)', $sel['tpl']); 131*29ed7b46Stracker-user $form->addFieldsetClose(); 132*29ed7b46Stracker-user 133*29ed7b46Stracker-user $form->addTagOpen('p'); 134*29ed7b46Stracker-user $form->addButton('sitebackup_action', 'Preview')->val('preview'); 135*29ed7b46Stracker-user $form->addHTML(' '); 136*29ed7b46Stracker-user $form->addButton('sitebackup_action', 'Download tar.gz')->val('download'); 137*29ed7b46Stracker-user $form->addTagClose('p'); 138*29ed7b46Stracker-user 139*29ed7b46Stracker-user echo $form->toHTML(); 140*29ed7b46Stracker-user } 141*29ed7b46Stracker-user 142*29ed7b46Stracker-user protected function addCheckboxRow(Form $form, $name, $label, $checked) 143*29ed7b46Stracker-user { 144*29ed7b46Stracker-user $form->addTagOpen('div')->attr('style', 'margin:4px 0;'); 145*29ed7b46Stracker-user $cb = $form->addCheckbox($name, ' ' . $label); 146*29ed7b46Stracker-user $cb->val('1'); 147*29ed7b46Stracker-user if ($checked) $cb->attr('checked', 'checked'); 148*29ed7b46Stracker-user $form->addTagClose('div'); 149*29ed7b46Stracker-user } 150*29ed7b46Stracker-user 151*29ed7b46Stracker-user /* ----------------------------------------------------------------- * 152*29ed7b46Stracker-user * File collection 153*29ed7b46Stracker-user * ----------------------------------------------------------------- */ 154*29ed7b46Stracker-user 155*29ed7b46Stracker-user /** 156*29ed7b46Stracker-user * Walk every selected root and build $this->fileList + $this->totalBytes. 157*29ed7b46Stracker-user */ 158*29ed7b46Stracker-user protected function collectFiles() 159*29ed7b46Stracker-user { 160*29ed7b46Stracker-user global $INPUT, $conf; 161*29ed7b46Stracker-user 162*29ed7b46Stracker-user // Map of (form-field => [absolute source path, archive-relative path]). 163*29ed7b46Stracker-user // Use $conf[...] for data dirs so we handle relocated savedir installs correctly. 164*29ed7b46Stracker-user $roots = [ 165*29ed7b46Stracker-user 'sb_pages' => [$conf['datadir'], 'data/pages'], 166*29ed7b46Stracker-user 'sb_media' => [$conf['mediadir'], 'data/media'], 167*29ed7b46Stracker-user 'sb_meta' => [$conf['metadir'], 'data/meta'], 168*29ed7b46Stracker-user 'sb_media_meta' => [$conf['mediametadir'], 'data/media_meta'], 169*29ed7b46Stracker-user 'sb_attic' => [$conf['olddir'], 'data/attic'], 170*29ed7b46Stracker-user 'sb_media_attic' => [$conf['mediaolddir'], 'data/media_attic'], 171*29ed7b46Stracker-user 'sb_index' => [$conf['indexdir'], 'data/index'], 172*29ed7b46Stracker-user 'sb_conf' => [rtrim(DOKU_CONF, '/'), 'conf'], 173*29ed7b46Stracker-user 'sb_plugins' => [rtrim(DOKU_PLUGIN, '/'), 'lib/plugins'], 174*29ed7b46Stracker-user 'sb_tpl' => [DOKU_INC . 'lib/tpl', 'lib/tpl'], 175*29ed7b46Stracker-user ]; 176*29ed7b46Stracker-user 177*29ed7b46Stracker-user foreach ($roots as $field => $pair) { 178*29ed7b46Stracker-user if (!$INPUT->bool($field, false)) continue; 179*29ed7b46Stracker-user [$srcAbs, $archiveRel] = $pair; 180*29ed7b46Stracker-user $this->walkInto($srcAbs, $archiveRel); 181*29ed7b46Stracker-user } 182*29ed7b46Stracker-user } 183*29ed7b46Stracker-user 184*29ed7b46Stracker-user /** 185*29ed7b46Stracker-user * Recursively walk a directory (or single file) and append to $fileList. 186*29ed7b46Stracker-user */ 187*29ed7b46Stracker-user protected function walkInto($srcAbs, $archiveRel) 188*29ed7b46Stracker-user { 189*29ed7b46Stracker-user if (!file_exists($srcAbs)) return; 190*29ed7b46Stracker-user 191*29ed7b46Stracker-user if (is_file($srcAbs)) { 192*29ed7b46Stracker-user $this->appendFile($srcAbs, $archiveRel); 193*29ed7b46Stracker-user return; 194*29ed7b46Stracker-user } 195*29ed7b46Stracker-user 196*29ed7b46Stracker-user try { 197*29ed7b46Stracker-user $it = new RecursiveIteratorIterator( 198*29ed7b46Stracker-user new RecursiveDirectoryIterator($srcAbs, FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS), 199*29ed7b46Stracker-user RecursiveIteratorIterator::LEAVES_ONLY 200*29ed7b46Stracker-user ); 201*29ed7b46Stracker-user } catch (Exception $e) { 202*29ed7b46Stracker-user return; 203*29ed7b46Stracker-user } 204*29ed7b46Stracker-user 205*29ed7b46Stracker-user $srcRoot = rtrim($srcAbs, '/'); 206*29ed7b46Stracker-user $rootLen = strlen($srcRoot) + 1; 207*29ed7b46Stracker-user foreach ($it as $info) { 208*29ed7b46Stracker-user try { 209*29ed7b46Stracker-user if (!$info->isFile()) continue; 210*29ed7b46Stracker-user if (!$info->isReadable()) continue; 211*29ed7b46Stracker-user $abs = $info->getPathname(); 212*29ed7b46Stracker-user $rel = substr($abs, $rootLen); 213*29ed7b46Stracker-user // Normalize Windows-style separators just in case. 214*29ed7b46Stracker-user $rel = str_replace('\\', '/', $rel); 215*29ed7b46Stracker-user 216*29ed7b46Stracker-user if ($this->isIgnored($rel)) continue; 217*29ed7b46Stracker-user 218*29ed7b46Stracker-user $this->appendFile($abs, $archiveRel . '/' . $rel); 219*29ed7b46Stracker-user } catch (Exception $e) { 220*29ed7b46Stracker-user // Skip unreadable / vanished files silently. 221*29ed7b46Stracker-user continue; 222*29ed7b46Stracker-user } 223*29ed7b46Stracker-user } 224*29ed7b46Stracker-user } 225*29ed7b46Stracker-user 226*29ed7b46Stracker-user /** 227*29ed7b46Stracker-user * Per-tree filename ignores. Cache/lock/tmp/log are noisy and not useful for a restore. 228*29ed7b46Stracker-user * `_dummy` are placeholder files DokuWiki ships to keep empty dirs in tarballs. 229*29ed7b46Stracker-user */ 230*29ed7b46Stracker-user protected function isIgnored($relPath) 231*29ed7b46Stracker-user { 232*29ed7b46Stracker-user $base = basename($relPath); 233*29ed7b46Stracker-user if ($base === '_dummy') return true; 234*29ed7b46Stracker-user if ($base === '.DS_Store') return true; 235*29ed7b46Stracker-user if ($base === 'Thumbs.db') return true; 236*29ed7b46Stracker-user // The plugin's own scratch file - shouldn't exist, but belt and suspenders. 237*29ed7b46Stracker-user if (strpos($base, 'sitebackup_tmp_') === 0) return true; 238*29ed7b46Stracker-user return false; 239*29ed7b46Stracker-user } 240*29ed7b46Stracker-user 241*29ed7b46Stracker-user protected function appendFile($abs, $archiveRel) 242*29ed7b46Stracker-user { 243*29ed7b46Stracker-user $size = @filesize($abs); 244*29ed7b46Stracker-user if ($size === false) $size = 0; 245*29ed7b46Stracker-user $this->fileList[] = [$abs, $archiveRel, $size]; 246*29ed7b46Stracker-user $this->totalBytes += $size; 247*29ed7b46Stracker-user } 248*29ed7b46Stracker-user 249*29ed7b46Stracker-user /* ----------------------------------------------------------------- * 250*29ed7b46Stracker-user * Preview 251*29ed7b46Stracker-user * ----------------------------------------------------------------- */ 252*29ed7b46Stracker-user 253*29ed7b46Stracker-user protected function renderPreview() 254*29ed7b46Stracker-user { 255*29ed7b46Stracker-user echo '<h2>Preview</h2>'; 256*29ed7b46Stracker-user echo '<p>' . count($this->fileList) . ' files, ' 257*29ed7b46Stracker-user . hsc($this->humanBytes($this->totalBytes)) . ' uncompressed.</p>'; 258*29ed7b46Stracker-user 259*29ed7b46Stracker-user // Per-top-level summary so the user can see what each section costs. 260*29ed7b46Stracker-user $perRoot = []; 261*29ed7b46Stracker-user foreach ($this->fileList as [$abs, $rel, $size]) { 262*29ed7b46Stracker-user $parts = explode('/', $rel, 4); 263*29ed7b46Stracker-user $top = isset($parts[1]) ? ($parts[0] . '/' . $parts[1]) : $parts[0]; 264*29ed7b46Stracker-user if (!isset($perRoot[$top])) $perRoot[$top] = ['count' => 0, 'bytes' => 0]; 265*29ed7b46Stracker-user $perRoot[$top]['count']++; 266*29ed7b46Stracker-user $perRoot[$top]['bytes'] += $size; 267*29ed7b46Stracker-user } 268*29ed7b46Stracker-user ksort($perRoot); 269*29ed7b46Stracker-user 270*29ed7b46Stracker-user echo '<table class="inline"><thead><tr><th>Section</th><th>Files</th><th>Size</th></tr></thead><tbody>'; 271*29ed7b46Stracker-user foreach ($perRoot as $section => $stats) { 272*29ed7b46Stracker-user echo '<tr><td><code>' . hsc($section) . '</code></td>' 273*29ed7b46Stracker-user . '<td style="text-align:right;">' . (int)$stats['count'] . '</td>' 274*29ed7b46Stracker-user . '<td style="text-align:right;">' . hsc($this->humanBytes($stats['bytes'])) . '</td></tr>'; 275*29ed7b46Stracker-user } 276*29ed7b46Stracker-user echo '</tbody></table>'; 277*29ed7b46Stracker-user echo '<p>Click <em>Download tar.gz</em> above to create and download the archive ' 278*29ed7b46Stracker-user . '(the compressed size will typically be smaller).</p>'; 279*29ed7b46Stracker-user } 280*29ed7b46Stracker-user 281*29ed7b46Stracker-user protected function humanBytes($bytes) 282*29ed7b46Stracker-user { 283*29ed7b46Stracker-user $units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; 284*29ed7b46Stracker-user $i = 0; 285*29ed7b46Stracker-user $n = (float)$bytes; 286*29ed7b46Stracker-user while ($n >= 1024 && $i < count($units) - 1) { 287*29ed7b46Stracker-user $n /= 1024; 288*29ed7b46Stracker-user $i++; 289*29ed7b46Stracker-user } 290*29ed7b46Stracker-user return sprintf($i === 0 ? '%d %s' : '%.2f %s', $n, $units[$i]); 291*29ed7b46Stracker-user } 292*29ed7b46Stracker-user 293*29ed7b46Stracker-user /* ----------------------------------------------------------------- * 294*29ed7b46Stracker-user * Archive creation + streaming 295*29ed7b46Stracker-user * ----------------------------------------------------------------- */ 296*29ed7b46Stracker-user 297*29ed7b46Stracker-user protected function streamArchive() 298*29ed7b46Stracker-user { 299*29ed7b46Stracker-user global $conf; 300*29ed7b46Stracker-user 301*29ed7b46Stracker-user if (!$this->fileList) { 302*29ed7b46Stracker-user // Fall through to html() which will just show the form again. 303*29ed7b46Stracker-user return; 304*29ed7b46Stracker-user } 305*29ed7b46Stracker-user 306*29ed7b46Stracker-user @set_time_limit(0); 307*29ed7b46Stracker-user @ignore_user_abort(true); 308*29ed7b46Stracker-user // PHP 8.x: gzopen and tar building don't need huge memory; bump modestly just in case. 309*29ed7b46Stracker-user @ini_set('memory_limit', '256M'); 310*29ed7b46Stracker-user 311*29ed7b46Stracker-user $tmpDir = $conf['tmpdir']; 312*29ed7b46Stracker-user if (!is_dir($tmpDir) || !is_writable($tmpDir)) { 313*29ed7b46Stracker-user msg('Site Backup: temp directory is not writable: ' . hsc($tmpDir), -1); 314*29ed7b46Stracker-user return; 315*29ed7b46Stracker-user } 316*29ed7b46Stracker-user 317*29ed7b46Stracker-user // Hostname for filename - sanitize aggressively. 318*29ed7b46Stracker-user $host = $_SERVER['HTTP_HOST'] ?? 'wiki'; 319*29ed7b46Stracker-user $host = preg_replace('/[^a-zA-Z0-9._-]+/', '_', $host); 320*29ed7b46Stracker-user $stamp = date('Ymd-His'); 321*29ed7b46Stracker-user $prefix = $host . '-backup-' . $stamp; // dir name inside the archive 322*29ed7b46Stracker-user $filename = $prefix . '.tar.gz'; // download filename 323*29ed7b46Stracker-user $tmpFile = $tmpDir . '/sitebackup_tmp_' . bin2hex(random_bytes(8)) . '.tar.gz'; 324*29ed7b46Stracker-user 325*29ed7b46Stracker-user try { 326*29ed7b46Stracker-user $tar = new Tar(); 327*29ed7b46Stracker-user $tar->setCompression(6, Archive::COMPRESS_GZIP); 328*29ed7b46Stracker-user $tar->create($tmpFile); 329*29ed7b46Stracker-user 330*29ed7b46Stracker-user foreach ($this->fileList as [$abs, $rel, $size]) { 331*29ed7b46Stracker-user try { 332*29ed7b46Stracker-user $tar->addFile($abs, $prefix . '/' . $rel); 333*29ed7b46Stracker-user } catch (Exception $e) { 334*29ed7b46Stracker-user // Skip individual broken files rather than aborting the whole backup. 335*29ed7b46Stracker-user continue; 336*29ed7b46Stracker-user } 337*29ed7b46Stracker-user } 338*29ed7b46Stracker-user $tar->close(); 339*29ed7b46Stracker-user } catch (ArchiveIOException $e) { 340*29ed7b46Stracker-user @unlink($tmpFile); 341*29ed7b46Stracker-user msg('Site Backup: could not create archive: ' . hsc($e->getMessage()), -1); 342*29ed7b46Stracker-user return; 343*29ed7b46Stracker-user } 344*29ed7b46Stracker-user 345*29ed7b46Stracker-user if (!is_file($tmpFile) || filesize($tmpFile) === 0) { 346*29ed7b46Stracker-user @unlink($tmpFile); 347*29ed7b46Stracker-user msg('Site Backup: archive was empty or could not be written.', -1); 348*29ed7b46Stracker-user return; 349*29ed7b46Stracker-user } 350*29ed7b46Stracker-user 351*29ed7b46Stracker-user // Stream out. We bypass DokuWiki's normal output by sending headers and 352*29ed7b46Stracker-user // exiting after writing the file body. 353*29ed7b46Stracker-user $size = filesize($tmpFile); 354*29ed7b46Stracker-user 355*29ed7b46Stracker-user // Clear any output buffering DokuWiki / extensions may have started. 356*29ed7b46Stracker-user while (ob_get_level() > 0) { 357*29ed7b46Stracker-user @ob_end_clean(); 358*29ed7b46Stracker-user } 359*29ed7b46Stracker-user 360*29ed7b46Stracker-user header('Content-Type: application/gzip'); 361*29ed7b46Stracker-user header('Content-Disposition: attachment; filename="' . $filename . '"'); 362*29ed7b46Stracker-user header('Content-Length: ' . $size); 363*29ed7b46Stracker-user header('Cache-Control: no-store, no-cache, must-revalidate'); 364*29ed7b46Stracker-user header('Pragma: no-cache'); 365*29ed7b46Stracker-user 366*29ed7b46Stracker-user $fp = fopen($tmpFile, 'rb'); 367*29ed7b46Stracker-user if ($fp) { 368*29ed7b46Stracker-user while (!feof($fp)) { 369*29ed7b46Stracker-user $chunk = fread($fp, 1024 * 256); 370*29ed7b46Stracker-user if ($chunk === false) break; 371*29ed7b46Stracker-user echo $chunk; 372*29ed7b46Stracker-user @flush(); 373*29ed7b46Stracker-user } 374*29ed7b46Stracker-user fclose($fp); 375*29ed7b46Stracker-user } 376*29ed7b46Stracker-user @unlink($tmpFile); 377*29ed7b46Stracker-user exit; 378*29ed7b46Stracker-user } 379*29ed7b46Stracker-user} 380