Upload files to "/"

This commit is contained in:
Clarth 2026-10-16 16:33:25 -04:00
parent bc03c6b514
commit 88ea01e723

209
extensions.php Normal file
View file

@ -0,0 +1,209 @@
<?php
/**
* Optional add-ons for print.php. Keep this file next to print.php (index.php).
* Delete it and the portal still works; set any switch below to false to turn a feature off.
*
* Needs for Word/Excel/PowerPoint: sudo apt install libreoffice-writer libreoffice-calc libreoffice-impress
* Needs for preview: pdf.js files in ./vendor/pdfjs/ (see the setup commands). Without them,
* preview falls back to opening the file in a new browser tab.
*/
declare(strict_types=1);
const EXT_OFFICE = true; // convert Word/Excel/PowerPoint/OpenDocument files to PDF, then print
const EXT_OFFICE_TYPES = ['docx', 'doc', 'odt', 'rtf', 'xlsx', 'xls', 'ods', 'pptx', 'ppt', 'odp'];
const EXT_SOFFICE = '/usr/bin/libreoffice';
const EXT_TIMEOUT = 90; // seconds allowed per conversion
const EXT_PREVIEW = true; // in-page preview (PDF, photos, text) before printing
const EXT_VENDOR = __DIR__ . '/vendor/pdfjs';
const EXT_PAGES_PER_SHEET = true; // "Pages per sheet" option (saves paper)
function ext_boot(): void {
if (!EXT_PREVIEW) { return; }
// Widens the page policy just enough for the bundled PDF viewer. Keep in step with print.php.
// header("Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'self'");
if (isset($_GET['view'])) { ext_serve((string)$_GET['view']); }
if (isset($_GET['asset'])) { ext_asset((string)$_GET['asset']); }
}
function ext_accept(): string { return EXT_OFFICE ? ',.' . implode(',.', EXT_OFFICE_TYPES) : ''; }
function ext_hint(): string { return EXT_OFFICE ? ', Word, Excel, slides' : ''; }
function ext_form_html(): string {
if (!EXT_PAGES_PER_SHEET) { return ''; }
$opts = '';
foreach ([1 => '1 (normal)', 2 => '2', 4 => '4', 6 => '6', 9 => '9'] as $n => $label) {
$opts .= '<option value="' . $n . '">' . $label . '</option>';
}
return '<label class="f">Pages per sheet<select name="nup">' . $opts . '</select></label>';
}
function ext_lp_args(string $ext): array {
$n = (int)($_POST['nup'] ?? 1);
return (EXT_PAGES_PER_SHEET && in_array($n, [2, 4, 6, 9], true)) ? ['-o', 'number-up=' . $n] : [];
}
function ext_file_html(string $name): string {
if (!EXT_PREVIEW) { return ''; }
return '<a class="mini" href="?view=' . rawurlencode($name) . '" data-preview="' . htmlspecialchars($name, ENT_QUOTES) . '" target="_blank" rel="noopener">Preview</a>';
}
/**
* Called for every upload. Returns null to leave the file alone, ['error' => msg] on failure,
* or ['path' => converted pdf, 'name' => new filename] after converting an Office file.
*/
function ext_convert(string $tmp, string $orig): ?array {
$ext = strtolower(pathinfo($orig, PATHINFO_EXTENSION));
if (!EXT_OFFICE || !in_array($ext, EXT_OFFICE_TYPES, true)) { return null; }
if (!is_executable(EXT_SOFFICE)) { return ['error' => 'can’t be converted: LibreOffice isn’t installed on the server.']; }
@set_time_limit(EXT_TIMEOUT * 2);
$tmpdir = sys_get_temp_dir();
$profile = $tmpdir . '/print-lo-profile';
$work = $tmpdir . '/print-lo-' . bin2hex(random_bytes(6));
$lock = fopen($tmpdir . '/print-lo.lock', 'c');
if ($lock) { flock($lock, LOCK_EX); } // LibreOffice allows one run per profile at a time
$result = ['error' => 'could not be converted to PDF. Try saving it as a PDF first.'];
if (@mkdir($work, 0700, true) && copy($tmp, $work . '/document.' . $ext)) {
$cmd = ['/usr/bin/timeout', '-k', '5', (string)EXT_TIMEOUT, EXT_SOFFICE,
'-env:UserInstallation=file://' . $profile, '--headless', '--norestore',
'--convert-to', 'pdf', '--outdir', $work, $work . '/document.' . $ext];
$p = @proc_open($cmd, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $work,
['PATH' => '/usr/bin:/bin', 'HOME' => $work, 'LC_ALL' => 'C.UTF-8']);
if (is_resource($p)) {
stream_get_contents($pipes[1]); stream_get_contents($pipes[2]);
fclose($pipes[1]); fclose($pipes[2]); proc_close($p);
}
$pdf = $work . '/document.pdf';
$dest = tempnam($tmpdir, 'prt');
if (is_file($pdf) && filesize($pdf) > 0 && $dest !== false && copy($pdf, $dest)) {
$result = ['path' => $dest, 'name' => cut(pathinfo($orig, PATHINFO_FILENAME), 80) . '.pdf'];
} else {
ext_rmdir($profile); // a crashed run can leave a broken profile; start fresh next time
}
}
ext_rmdir($work);
if ($lock) { flock($lock, LOCK_UN); fclose($lock); }
return $result;
}
function ext_css(): string {
return '[hidden]{display:none!important}'
. '@media (max-width:480px){.file{flex-wrap:wrap}.fn{flex-basis:100%}}'
. '#pv{position:fixed;inset:0;z-index:50;display:flex;flex-direction:column;background:var(--bg)}'
. '.pvbar{display:flex;align-items:center;gap:8px;padding:max(10px,env(safe-area-inset-top)) max(12px,env(safe-area-inset-right)) 10px max(12px,env(safe-area-inset-left));background:var(--card);border-bottom:1px solid var(--line)}'
. '.pvbar b{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}'
. '.pvbar span{color:var(--mute);font-size:.88rem;white-space:nowrap}'
. '#pvbody{flex:1;overflow:auto;padding:12px;text-align:center;-webkit-overflow-scrolling:touch}'
. '#pvbody canvas,#pvbody img{display:block;height:auto;max-width:100%;margin:0 auto 12px;background:#fff;border:1px solid var(--line)}'
. '#pvbody pre{margin:0 auto;max-width:90ch;text-align:left;white-space:pre-wrap;overflow-wrap:anywhere;font:.9rem/1.45 ui-monospace,monospace;color:var(--ink)}';
}
/** Preview overlay + small scripts (remembers each device's option choices). */
function ext_page_html(): string {
$preview = !EXT_PREVIEW ? '' : <<<'HTML'
<div id="pv" hidden role="dialog" aria-modal="true" aria-label="Preview">
<div class="pvbar"><b id="pvname"></b><span id="pvinfo"></span>
<button type="submit" class="mini p" id="pvprint" form="f" name="reprint" value="" hidden>Print</button>
<button type="button" class="mini" id="pvclose">Close</button></div>
<div id="pvbody"></div>
</div>
<script>
(function(){
var pv=document.getElementById('pv'), body=document.getElementById('pvbody'), nm=document.getElementById('pvname'),
info=document.getElementById('pvinfo'), pp=document.getElementById('pvprint'), MAXP=40, lib=null, blobUrl=null, token=0;
function abs(q){return new URL(q,location.href).href;}
function kind(n){var e=(n.split('.').pop()||'').toLowerCase();return e==='pdf'?'pdf':e==='txt'?'txt':/^(jpe?g|png|gif)$/.test(e)?'img':'';}
function pdfjs(){if(!lib){lib=import(abs('?asset=pdf.min.mjs')).then(function(m){m.GlobalWorkerOptions.workerSrc=abs('?asset=pdf.worker.min.mjs');return m;});}return lib;}
function close(){token++;pv.hidden=true;body.innerHTML='';document.body.style.overflow='';if(blobUrl){URL.revokeObjectURL(blobUrl);blobUrl=null;}}
function fail(t,url,saved){
if(t!==token)return; lib=null; body.innerHTML='<p>Preview isn’t available for this file.</p>';
if(saved){var a=document.createElement('a');a.className='mini';a.href=url;a.target='_blank';a.rel='noopener';a.textContent='Open in a new tab';body.appendChild(a);}
}
function show(name,url,saved){
var k=kind(name),t=++token;
nm.textContent=name;info.textContent='';body.innerHTML='';pp.value=saved||'';pp.hidden=!saved;
pv.hidden=false;document.body.style.overflow='hidden';
if(k==='img'){var im=new Image();im.alt=name;im.src=url;body.appendChild(im);return;}
if(k==='txt'){fetch(url).then(function(r){return r.text();}).then(function(s){if(t!==token)return;var pre=document.createElement('pre');pre.textContent=s.slice(0,200000);body.appendChild(pre);}).catch(function(){fail(t,url,saved);});return;}
pdfjs().then(function(m){return m.getDocument({url:url}).promise;}).then(async function(doc){
var n=Math.min(doc.numPages,MAXP);
info.textContent=doc.numPages+(doc.numPages===1?' page':' pages')+(doc.numPages>n?' (showing first '+n+')':'');
for(var i=1;i<=n;i++){
var pg=await doc.getPage(i); if(t!==token)return;
var d=Math.min(window.devicePixelRatio||1,2),w=Math.min(body.clientWidth-24,900),b=pg.getViewport({scale:1}),
vp=pg.getViewport({scale:(w/b.width)*d}),c=document.createElement('canvas');
c.width=vp.width;c.height=vp.height;c.style.width=(vp.width/d)+'px';body.appendChild(c);
await pg.render({canvasContext:c.getContext('2d'),viewport:vp}).promise;
}
}).catch(function(){fail(t,url,saved);});
}
document.addEventListener('click',function(e){
var a=e.target.closest&&e.target.closest('[data-preview]'); if(!a)return;
e.preventDefault(); var n=a.getAttribute('data-preview'); show(n,abs('?view='+encodeURIComponent(n)),n);
});
document.getElementById('pvclose').addEventListener('click',close);
document.addEventListener('keydown',function(e){if(e.key==='Escape'&&!pv.hidden)close();});
var inp=document.getElementById('files'),list=document.getElementById('picked');
if(inp&&list)inp.addEventListener('change',function(){
Array.prototype.forEach.call(inp.files,function(file,i){
var li=list.children[i]; if(!li||!kind(file.name))return;
var b=document.createElement('button');b.type='button';b.className='mini';b.style.marginLeft='8px';b.textContent='Preview';
b.addEventListener('click',function(){if(blobUrl)URL.revokeObjectURL(blobUrl);blobUrl=URL.createObjectURL(file);show(file.name,blobUrl,'');});
li.appendChild(b);
});
});
})();
</script>
HTML;
$remember = <<<'HTML'
<script>
(function(){
var F=document.getElementById('f'); if(!F)return;
try{
['duplex','mono'].forEach(function(n){var el=F.elements[n]; if(!el)return;
var v=localStorage.getItem('lanprint:'+n); if(v!==null)el.checked=(v==='1');
el.addEventListener('change',function(){try{localStorage.setItem('lanprint:'+n,el.checked?'1':'0');}catch(e){}});});
['nup','printer'].forEach(function(n){var el=F.elements[n]; if(!el)return;
var v=localStorage.getItem('lanprint:'+n);
if(v!==null)Array.prototype.forEach.call(el.options,function(o){if(o.value===v)el.value=v;});
el.addEventListener('change',function(){try{localStorage.setItem('lanprint:'+n,el.value);}catch(e){}});});
}catch(e){}
})();
</script>
HTML;
return $preview . "\n" . $remember;
}
// ---------- Internals ----------
function ext_serve(string $name): void {
$path = stored_path($name);
$ext = $path === null ? '' : strtolower(pathinfo($path, PATHINFO_EXTENSION));
if ($path === null || !isset(ALLOWED[$ext])) { http_response_code(404); exit('Not found'); }
header_remove('Content-Security-Policy');
header('Content-Type: ' . ALLOWED[$ext][0] . ($ext === 'txt' ? '; charset=utf-8' : ''));
header('Content-Length: ' . (string)filesize($path));
header("Content-Disposition: inline; filename*=UTF-8''" . rawurlencode(basename($path)));
readfile($path);
exit;
}
function ext_asset(string $name): void {
$file = EXT_VENDOR . '/' . $name;
if (!in_array($name, ['pdf.min.mjs', 'pdf.worker.min.mjs'], true) || !is_file($file)) { http_response_code(404); exit('Not found'); }
header_remove('Content-Security-Policy');
header('Content-Type: text/javascript; charset=utf-8');
header('Cache-Control: public, max-age=86400');
header('Content-Length: ' . (string)filesize($file));
readfile($file);
exit;
}
function ext_rmdir(string $dir): void {
if (!is_dir($dir)) { return; }
$it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST);
foreach ($it as $f) { ($f->isDir() && !$f->isLink()) ? @rmdir($f->getPathname()) : @unlink($f->getPathname()); }
@rmdir($dir);
}