Web-Print/index.php
2026-10-16 16:32:19 -04:00

486 lines
25 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* LAN Print Portal — single file, no dependencies, no database, no CDN.
*
* Needs: nginx + php-fpm + CUPS (cups, cups-client) with a printer already set up.
* Works on PHP 7.4 through 8.x.
* Uploaded files are saved in STORE_DIR so they can be searched and printed again.
* There is no login: restrict access with nginx (allow/deny) — see the setup notes.
*/
declare(strict_types=1);
// ---------- Settings (the only part you should ever need to edit) ----------
const SITE_TITLE = 'Print';
const PRINTER = ''; // '' = let people choose / use the CUPS default. Or e.g. 'Brother_HL'
const CUPS_SERVER = ''; // '' = this machine. Or 'host:631' to print via another CUPS server
const MAX_COPIES = 20;
const MAX_FILES = 10;
const MAX_MB = 50; // keep in sync with the nginx / PHP limits
const SHOW_QUEUE = true; // show waiting jobs, with a cancel button
const LP = '/usr/bin/lp';
const LPSTAT = '/usr/bin/lpstat';
const CANCEL = '/usr/bin/cancel';
const STORE_DIR = '/home/clay/Programs/tg_print/printme'; // must be writable by the web user
const RECENT_COUNT = 8; // files shown when not searching
const TIMEZONE = ''; // '' = use the system timezone (/etc/timezone)
// Extension => MIME types accepted (CUPS handles these without extra packages)
const ALLOWED = [
'pdf' => ['application/pdf'],
'txt' => ['text/plain'],
'jpg' => ['image/jpeg'],
'jpeg' => ['image/jpeg'],
'png' => ['image/png'],
'gif' => ['image/gif'],
];
// ---------- Optional add-ons (extensions.php). The portal works fine without it. ----------
if (is_file(__DIR__ . '/extensions.php')) { require __DIR__ . '/extensions.php'; }
if (!function_exists('ext_boot')) { function ext_boot(): void {} }
if (!function_exists('ext_css')) { function ext_css(): string { return ''; } }
if (!function_exists('ext_form_html')) { function ext_form_html(): string { return ''; } }
if (!function_exists('ext_file_html')) { function ext_file_html(string $name): string { return ''; } }
if (!function_exists('ext_lp_args')) { function ext_lp_args(string $ext): array { return []; } }
if (!function_exists('ext_accept')) { function ext_accept(): string { return ''; } }
if (!function_exists('ext_hint')) { function ext_hint(): string { return ''; } }
if (!function_exists('ext_page_html')) { function ext_page_html(): string { return ''; } }
if (!function_exists('ext_convert')) { function ext_convert(string $tmp, string $orig): ?array { return null; } }
// ---------- Helpers ----------
function h(string $s): string { return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
function run(array $cmd): array {
$env = ['PATH' => '/usr/bin:/bin', 'LC_ALL' => 'C'];
if (CUPS_SERVER !== '') { $env['CUPS_SERVER'] = CUPS_SERVER; }
$p = @proc_open($cmd, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, null, $env);
if (!is_resource($p)) { return [1, '', 'Could not start ' . basename($cmd[0])]; }
$out = stream_get_contents($pipes[1]);
$err = stream_get_contents($pipes[2]);
fclose($pipes[1]); fclose($pipes[2]);
return [proc_close($p), (string)$out, trim((string)$err)];
}
function printers(): array {
[, $out] = run([LPSTAT, '-p']);
$list = [];
if (preg_match_all('/^printer (\S+) (.*)$/m', $out, $m, PREG_SET_ORDER)) {
foreach ($m as $row) {
$state = 'Ready';
if (stripos($row[2], 'disabled') !== false) { $state = 'Offline'; }
elseif (stripos($row[2], 'now printing') !== false) { $state = 'Printing'; }
$list[$row[1]] = $state;
}
}
return $list;
}
function default_printer(): string {
[, $out] = run([LPSTAT, '-d']);
return preg_match('/destination: (\S+)/', $out, $m) ? $m[1] : '';
}
function jobs(): array {
[, $out] = run([LPSTAT, '-o']);
$list = [];
foreach (preg_split('/\R/', trim($out)) as $line) {
if (preg_match('/^(\S+-\d+)\s+(\S+)\s+(\d+)/', $line, $m)) {
$list[] = ['id' => $m[1], 'user' => $m[2], 'kb' => max(1, (int)round(((int)$m[3]) / 1024))];
}
}
return $list;
}
function flash(string $type, string $msg): void { $_SESSION['flash'][] = [$type, $msg]; }
function cut(string $s, int $n): string {
return preg_match('/^.{0,' . $n . '}/su', $s, $m) ? $m[0] : substr($s, 0, $n);
}
function fmt_size(int $b): string {
return $b >= 1048576 ? round($b / 1048576, 1) . ' MB' : max(1, (int)round($b / 1024)) . ' KB';
}
function storage_ready(): bool {
return (is_dir(STORE_DIR) || @mkdir(STORE_DIR, 0775, true)) && is_writable(STORE_DIR);
}
function safe_name(string $orig, string $ext): string {
$base = trim((string)preg_replace('/[^\w .()-]+/u', '_', pathinfo($orig, PATHINFO_FILENAME)), " ._-");
return cut($base === '' ? 'file' : $base, 80) . '.' . $ext;
}
function unique_path(string $name): string {
$p = STORE_DIR . '/' . $name;
if (!file_exists($p)) { return $p; }
$base = pathinfo($name, PATHINFO_FILENAME);
$ext = pathinfo($name, PATHINFO_EXTENSION);
for ($i = 2; $i < 1000; $i++) {
$p = STORE_DIR . "/$base-$i.$ext";
if (!file_exists($p)) { return $p; }
}
return STORE_DIR . "/$base-" . bin2hex(random_bytes(3)) . ".$ext";
}
function stored_path(string $name): ?string {
if ($name === '' || $name[0] === '.' || $name !== basename($name) || strpos($name, "\0") !== false) { return null; }
$p = STORE_DIR . '/' . $name;
return is_file($p) ? $p : null;
}
function list_files(string $q, int $limit): array {
$terms = $q === '' ? [] : preg_split('/\s+/', $q);
$out = [];
if (is_dir(STORE_DIR)) {
foreach (new DirectoryIterator(STORE_DIR) as $f) {
$name = $f->getFilename();
if (!$f->isFile() || $name[0] === '.' || !isset(ALLOWED[strtolower($f->getExtension())])) { continue; }
foreach ($terms as $t) {
if (!preg_match('/' . preg_quote($t, '/') . '/iu', $name)) { continue 2; }
}
$out[] = ['name' => $name, 'time' => $f->getMTime(), 'size' => $f->getSize()];
}
}
usort($out, function ($a, $b) { return $b['time'] <=> $a['time']; });
return ['total' => count($out), 'items' => array_slice($out, 0, $limit)];
}
function send_job(string $path, string $dest, int $copies, string $pages, bool $duplex, bool $mono): void {
$name = basename($path);
$label = '“' . cut($name, 60) . '”';
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
if (!isset(ALLOWED[$ext]) || !in_array((new finfo(FILEINFO_MIME_TYPE))->file($path), ALLOWED[$ext], true)) {
flash('err', "$label isn’t a printable file."); return;
}
$cmd = [LP, '-d', $dest, '-n', (string)$copies, '-t', cut((string)preg_replace('/[^\w .-]/u', '_', $name), 80)];
$cmd[] = '-o'; $cmd[] = $duplex ? 'sides=two-sided-long-edge' : 'sides=one-sided';
if ($mono) { $cmd[] = '-o'; $cmd[] = 'print-color-mode=monochrome'; }
if ($pages !== '') { $cmd[] = '-o'; $cmd[] = 'page-ranges=' . $pages; }
if ($ext !== 'pdf' && $ext !== 'txt') {
$actual = isset($_GET['admin']) && ($_POST['actual'] ?? '') === '1'; // admin only: natural size instead of fit
$cmd[] = '-o'; $cmd[] = $actual ? 'print-scaling=none' : 'fit-to-page';
}
foreach (ext_lp_args($ext) as $arg) { $cmd[] = $arg; }
$cmd[] = $path;
[$code, , $err] = run($cmd);
if ($code === 0) { flash('ok', "$label sent to $dest."); }
else { flash('err', "$label failed: " . ($err ?: 'unknown printing error') . '.'); }
}
function upload_error(int $code): string {
switch ($code) {
case UPLOAD_ERR_INI_SIZE:
case UPLOAD_ERR_FORM_SIZE: return 'is too large';
case UPLOAD_ERR_PARTIAL: return 'was only partly received — try again';
case UPLOAD_ERR_NO_TMP_DIR:
case UPLOAD_ERR_CANT_WRITE: return 'could not be saved on the server';
default: return 'could not be uploaded';
}
}
// ---------- Session / CSRF ----------
if (TIMEZONE !== '') { @date_default_timezone_set(TIMEZONE); }
else {
$tzf = trim((string)@file_get_contents('/etc/timezone'));
if ($tzf !== '' && in_array($tzf, timezone_identifiers_list(), true)) { date_default_timezone_set($tzf); }
}
session_name('lanprint');
session_set_cookie_params(['lifetime' => 0, 'path' => '/', 'httponly' => true, 'samesite' => 'Strict']);
session_start();
if (empty($_SESSION['csrf'])) { $_SESSION['csrf'] = bin2hex(random_bytes(16)); }
header('X-Content-Type-Options: nosniff');
/*
* Permit this print portal to load inside Organizr,
* as long as both are accessed from the same scheme/host/port.
header('X-Frame-Options: SAMEORIGIN');
header(
"Content-Security-Policy: "
. "default-src 'none'; "
. "style-src 'unsafe-inline'; "
. "script-src 'unsafe-inline'; "
. "form-action 'self'; "
. "base-uri 'none'; "
. "frame-ancestors 'self'"
);
header('Referrer-Policy: no-referrer');
header('Cache-Control: no-store');
*/
ext_boot();
// ---------- Actions ----------
$q = cut(trim((string)($_POST['q'] ?? $_GET['q'] ?? '')), 100);
$admin = isset($_GET['admin']); // Delete buttons only appear at /print/?admin
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$printers = printers();
$action = (string)($_POST['action'] ?? '');
if ((int)($_SERVER['CONTENT_LENGTH'] ?? 0) > 0 && !$_POST && !$_FILES) {
flash('err', 'That upload was too large. The limit is ' . MAX_MB . ' MB in total.');
} elseif (!hash_equals($_SESSION['csrf'], (string)($_POST['csrf'] ?? ''))) {
flash('err', 'This page expired. Please try again.');
} elseif ($action === 'cancel') {
$job = (string)($_POST['job'] ?? '');
if (preg_match('/^[A-Za-z0-9_.-]+-\d+$/', $job)) {
[$code, , $err] = run([CANCEL, $job]);
$code === 0 ? flash('ok', 'Job cancelled.') : flash('err', 'Could not cancel the job. ' . $err);
}
} elseif ($action === 'delete') {
$path = $admin ? stored_path((string)($_POST['file'] ?? '')) : null;
if ($path !== null && @unlink($path)) { flash('ok', 'Deleted “' . cut(basename($path), 60) . '”.'); }
else { flash('err', 'Could not delete that file.'); }
} else {
$dest = PRINTER !== '' ? PRINTER : (string)($_POST['printer'] ?? default_printer());
$copies = max(1, min(MAX_COPIES, (int)($_POST['copies'] ?? 1)));
$pages = preg_replace('/\s+/', '', (string)($_POST['pages'] ?? ''));
$duplex = ($_POST['duplex'] ?? '') === '1';
$mono = ($_POST['mono'] ?? '') === '1';
$reprint = (string)($_POST['reprint'] ?? '');
$names = (array)($_FILES['files']['name'] ?? []);
if (!isset($printers[$dest])) {
flash('err', 'No printer is available. Ask the person who runs this server.');
} elseif ($pages !== '' && !preg_match('/^\d+(-\d+)?(,\d+(-\d+)?)*$/', $pages)) {
flash('err', 'Pages should look like 1-3,5 — leave empty to print everything.');
} elseif ($reprint !== '') {
$path = stored_path($reprint);
if ($path === null) { flash('err', 'That file is no longer in the folder.'); }
else { @touch($path); send_job($path, $dest, $copies, $pages, $duplex, $mono); }
} elseif (!$names || (string)$names[0] === '') {
flash('err', 'Choose at least one file first.');
} elseif (count($names) > MAX_FILES) {
flash('err', 'Please send at most ' . MAX_FILES . ' files at a time.');
} elseif (!storage_ready()) {
flash('err', 'The save folder is missing or not writable: ' . STORE_DIR);
} else {
$finfo = new finfo(FILEINFO_MIME_TYPE);
foreach ($names as $i => $orig) {
$orig = (string)$orig;
$label = '“' . cut($orig, 60) . '”';
$tmp = (string)$_FILES['files']['tmp_name'][$i];
$errc = (int)$_FILES['files']['error'][$i];
if ($errc !== UPLOAD_ERR_OK) { flash('err', "$label " . upload_error($errc) . '.'); continue; }
$conv = ext_convert($tmp, $orig); // Office files become PDFs (see extensions.php)
if ($conv !== null) {
if (isset($conv['error'])) { flash('err', "$label " . $conv['error']); continue; }
$tmp = $conv['path']; $orig = $conv['name']; $label = '“' . cut($orig, 60) . '”';
}
$ext = strtolower(pathinfo($orig, PATHINFO_EXTENSION));
if (!isset(ALLOWED[$ext])) {
$hint = in_array($ext, ['heic', 'heif'], true) ? ' Photos in HEIC format must be shared as JPEG.' : '';
flash('err', "$label can’t be printed here (accepted: PDF, TXT, JPG, PNG, GIF)." . $hint);
continue;
}
if (!in_array($finfo->file($tmp), ALLOWED[$ext], true)) { flash('err', "$label doesn’t look like a real .$ext file."); continue; }
$path = unique_path(safe_name($orig, $ext));
if (!($conv !== null ? @rename($tmp, $path) : move_uploaded_file($tmp, $path))) { flash('err', "$label could not be saved on the server."); continue; }
@chmod($path, 0664);
send_job($path, $dest, $copies, $pages, $duplex, $mono);
}
}
}
$qs = [];
if ($q !== '') { $qs[] = 'q=' . rawurlencode($q); }
if ($admin) { $qs[] = 'admin'; }
$loc = (strtok($_SERVER['REQUEST_URI'], '?') ?: '/') . ($qs ? '?' . implode('&', $qs) : '');
header('Location: ' . $loc, true, 303);
exit;
}
// ---------- Page data ----------
$printers = printers();
$default = PRINTER !== '' ? PRINTER : default_printer();
if (!isset($printers[$default])) { $default = (string)(array_key_first($printers) ?? ''); }
$jobs = SHOW_QUEUE ? jobs() : [];
$flashes = $_SESSION['flash'] ?? [];
unset($_SESSION['flash']);
$state = $printers[$default] ?? 'Offline';
$files = list_files($q, $q === '' ? RECENT_COUNT : 50);
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light dark">
<title><?= h(SITE_TITLE) ?></title>
<style>
:root{--bg:#eef0f3;--card:#fff;--ink:#16202b;--mute:#5d6b7a;--line:#d5dae1;--accent:#1d4ed8;--accent-ink:#fff;--ok:#157347;--okbg:#e4f4ea;--err:#b3261e;--errbg:#fbe9e7;--r:14px}
@media (prefers-color-scheme:dark){:root{--bg:#10151b;--card:#1a212a;--ink:#e8edf2;--mute:#9aa8b6;--line:#2c3742;--accent:#6e9bff;--accent-ink:#0b1220;--ok:#6fd49a;--okbg:#14301f;--err:#ff9188;--errbg:#3a1b19}}
*{box-sizing:border-box}
html{-webkit-text-size-adjust:100%}
body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;padding:max(16px,env(safe-area-inset-top)) max(16px,env(safe-area-inset-right)) max(24px,env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left))}
main{max-width:520px;margin:0 auto}
header{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:8px 2px 16px}
h1{font-size:1.5rem;margin:0;letter-spacing:-.01em}
.pill{font-size:.85rem;padding:3px 11px;border-radius:99px;border:1px solid var(--line);color:var(--mute);white-space:nowrap}
.pill::before{content:"";display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:7px;background:var(--mute)}
.pill.Ready::before{background:var(--ok)}.pill.Printing::before{background:var(--accent)}.pill.Offline::before{background:var(--err)}
.card{background:var(--card);border:1px solid var(--line);border-radius:var(--r);padding:18px;margin-bottom:16px}
.msg{border-radius:10px;padding:10px 14px;margin-bottom:10px;font-size:.95rem}
.msg.ok{background:var(--okbg);color:var(--ok)}.msg.err{background:var(--errbg);color:var(--err)}
.drop{position:relative;display:block;border:2px dashed var(--line);border-radius:var(--r);padding:30px 16px;text-align:center;color:var(--mute);transition:border-color .15s,background .15s}
.drop:hover,.drop:focus-within,.drop.over{border-color:var(--accent);background:color-mix(in srgb,var(--accent) 7%,transparent)}
.drop input{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer;font-size:0}
.drop strong{display:block;color:var(--ink);font-size:1.1rem;margin-bottom:2px}
.drop svg{width:34px;height:34px;stroke:var(--accent);fill:none;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round;margin-bottom:8px}
#picked{list-style:none;margin:12px 0 0;padding:0;font-size:.92rem;overflow-wrap:anywhere}
#picked li{padding:4px 0;border-bottom:1px solid var(--line)}#picked li:last-child{border:0}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:16px}
.full{grid-column:1/-1}
label.f{display:block;font-size:.9rem;color:var(--mute)}
input[type=number],input[type=text],select{width:100%;margin-top:4px;padding:11px 12px;font:inherit;color:var(--ink);background:var(--bg);border:1px solid var(--line);border-radius:10px}
input:focus-visible,select:focus-visible,button:focus-visible,.drop:focus-within{outline:3px solid color-mix(in srgb,var(--accent) 55%,transparent);outline-offset:2px}
.chk{display:flex;align-items:center;gap:10px;min-height:44px;color:var(--ink);font-size:1rem}
.chk input{width:22px;height:22px;accent-color:var(--accent)}
button{font:inherit;cursor:pointer}
.go{width:100%;margin-top:18px;padding:15px;border:0;border-radius:12px;background:var(--accent);color:var(--accent-ink);font-weight:600;font-size:1.08rem}
.go:disabled{opacity:.6;cursor:progress}
h2{font-size:1rem;margin:0 0 8px}
.job{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 0;border-top:1px solid var(--line);font-size:.92rem}
.job:first-of-type{border-top:0}.job small{color:var(--mute)}
.job button{padding:7px 14px;border:1px solid var(--line);border-radius:8px;background:none;color:var(--err)}
.mute{color:var(--mute);font-size:.88rem;margin:0}
.search{display:flex;gap:8px;margin-bottom:8px}
.search input{flex:1;min-width:0;padding:10px 12px;font:inherit;color:var(--ink);background:var(--bg);border:1px solid var(--line);border-radius:10px}
.mini{display:inline-block;padding:9px 14px;border:1px solid var(--line);border-radius:8px;background:none;color:var(--ink);font:inherit;text-decoration:none}
.mini.p{background:var(--accent);color:var(--accent-ink);border-color:var(--accent)}
.mini.del{color:var(--err)}
.file{display:flex;align-items:center;gap:8px;padding:10px 0;border-top:1px solid var(--line)}
.fn{flex:1;min-width:0;overflow-wrap:anywhere;font-size:.95rem}
.fn small{display:block;color:var(--mute)}
@media (prefers-reduced-motion:reduce){*{transition:none!important}}
<?= ext_css() ?>
</style>
</head>
<body>
<main>
<header>
<h1><?= h(SITE_TITLE) ?></h1>
<?php if ($default !== ''): ?><span class="pill <?= h($state) ?>"><?= h(str_replace('_', ' ', $default)) ?> · <?= h($state) ?></span><?php endif; ?>
</header>
<?php foreach ($flashes as [$type, $text]): ?>
<div class="msg <?= $type === 'ok' ? 'ok' : 'err' ?>" role="status"><?= h($text) ?></div>
<?php endforeach; ?>
<?php if (!$printers): ?>
<div class="card"><h2>No printer found</h2><p class="mute">The server can’t see any printer. Check that CUPS is running and a printer is added.</p></div>
<?php else: ?>
<form class="card" method="post" enctype="multipart/form-data" id="f">
<input type="hidden" name="csrf" value="<?= h($_SESSION['csrf']) ?>">
<input type="hidden" name="q" value="<?= h($q) ?>">
<label class="drop" id="drop">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 9V3h12v6M6 18H4a1 1 0 0 1-1-1v-6a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v6a1 1 0 0 1-1 1h-2"/><rect x="6" y="14" width="12" height="7" rx="1"/></svg>
<strong>Choose files to print</strong>
PDF, text<?= $admin ? ', photo' : '' ?><?= h(ext_hint()) ?> · up to <?= MAX_MB ?> MB
<input type="file" name="files[]" id="files" multiple accept=".pdf,.txt,application/pdf,text/plain<?= h(ext_accept() . ($admin ? ',.jpg,.jpeg,.png,.gif' : '')) ?>" aria-label="Choose files to print">
</label>
<ul id="picked" aria-live="polite"></ul>
<div class="grid">
<?php if (PRINTER === '' && count($printers) > 1): ?>
<label class="f full">Printer
<select name="printer">
<?php foreach ($printers as $name => $st): ?>
<option value="<?= h($name) ?>"<?= $name === $default ? ' selected' : '' ?>><?= h(str_replace('_', ' ', $name)) ?> (<?= h($st) ?>)</option>
<?php endforeach; ?>
</select>
</label>
<?php endif; ?>
<label class="f">Copies
<input type="number" name="copies" value="1" min="1" max="<?= MAX_COPIES ?>" inputmode="numeric">
</label>
<label class="f">Pages (optional)
<input type="text" name="pages" placeholder="e.g. 1-3,5" inputmode="text" autocomplete="off" autocapitalize="off">
</label>
<label class="chk"><input type="checkbox" name="duplex" value="1"> Both sides</label>
<label class="chk"><input type="checkbox" name="mono" value="1"> Black &amp; white</label>
<?= ext_form_html() ?>
<?php if ($admin): ?><label class="chk full"><input type="checkbox" name="actual" value="1"> Images at actual size (not fit to page)</label><?php endif; ?>
</div>
<button class="go" type="submit" id="go">Print</button>
</form>
<section class="card">
<h2><?= $q === '' ? 'Recent files' : 'Search results' ?></h2>
<form class="search" method="get" role="search">
<?php if ($admin): ?><input type="hidden" name="admin" value=""><?php endif; ?>
<input type="search" name="q" value="<?= h($q) ?>" placeholder="Search saved files" aria-label="Search saved files" autocomplete="off">
<button type="submit" class="mini">Search</button>
<?php if ($q !== ''): ?><a class="mini" href="?<?= $admin ? 'admin' : '' ?>">Clear</a><?php endif; ?>
</form>
<?php if (!$files['items']): ?>
<p class="mute"><?= $q === '' ? 'Files you print are saved here so you can print them again.' : 'No saved files match “' . h($q) . '”.' ?></p>
<?php else: ?>
<p class="mute">Print uses the copies and options above.</p>
<?php foreach ($files['items'] as $fl): ?>
<div class="file">
<span class="fn"><?= h($fl['name']) ?><small><?= h(date('M j, Y g:i a', $fl['time'])) ?> · <?= h(fmt_size($fl['size'])) ?></small></span>
<?= ext_file_html($fl['name']) ?>
<button class="mini p" type="submit" form="f" name="reprint" value="<?= h($fl['name']) ?>">Print</button>
<?php if ($admin): ?><button class="mini del" type="submit" form="del" name="file" value="<?= h($fl['name']) ?>" onclick="return confirm('Delete this saved file?')">Delete</button><?php endif; ?>
</div>
<?php endforeach; ?>
<?php if ($files['total'] > count($files['items'])): ?>
<p class="mute">Showing <?= count($files['items']) ?> of <?= (int)$files['total'] ?><?= $q === '' ? ' — search to find older files.' : ' — refine your search.' ?></p>
<?php endif; ?>
<?php endif; ?>
<?php if ($admin): ?>
<form id="del" method="post">
<input type="hidden" name="csrf" value="<?= h($_SESSION['csrf']) ?>">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="q" value="<?= h($q) ?>">
</form>
<?php endif; ?>
</section>
<?php if (SHOW_QUEUE && $jobs): ?>
<section class="card">
<h2>Waiting to print</h2>
<?php foreach ($jobs as $j): ?>
<form class="job" method="post">
<input type="hidden" name="csrf" value="<?= h($_SESSION['csrf']) ?>">
<input type="hidden" name="action" value="cancel">
<input type="hidden" name="job" value="<?= h($j['id']) ?>">
<span><?= h($j['id']) ?><br><small><?= h($j['user']) ?> · <?= (int)$j['kb'] ?> KB</small></span>
<button type="submit" aria-label="Cancel job <?= h($j['id']) ?>">Cancel</button>
</form>
<?php endforeach; ?>
</section>
<?php endif; ?>
<?php endif; ?>
<p class="mute">Printed files are saved on the server so you can print them again.</p>
</main>
<script>
(function(){
var f=document.getElementById('f'); if(!f) return;
var input=document.getElementById('files'), list=document.getElementById('picked'),
go=document.getElementById('go'), drop=document.getElementById('drop');
input.addEventListener('change',function(){
list.innerHTML='';
Array.prototype.forEach.call(input.files,function(file){
var li=document.createElement('li');
li.textContent=file.name+' ('+Math.max(1,Math.round(file.size/1024))+' KB)';
list.appendChild(li);
});
});
['dragenter','dragover'].forEach(function(e){input.addEventListener(e,function(){drop.classList.add('over')})});
['dragleave','drop'].forEach(function(e){input.addEventListener(e,function(){drop.classList.remove('over')})});
f.addEventListener('submit',function(){ go.disabled=true; go.textContent='Sending…'; });
window.addEventListener('pageshow',function(){ go.disabled=false; go.textContent='Print'; });
})();
</script>
<?= ext_page_html() ?>
</body>
</html>