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);
?>
= h(SITE_TITLE) ?>
= h(SITE_TITLE) ?>
= h(str_replace('_', ' ', $default)) ?> · = h($state) ?>
= h($text) ?>
No printer found
The server can’t see any printer. Check that CUPS is running and a printer is added.
= $q === '' ? 'Recent files' : 'Search results' ?>
= $q === '' ? 'Files you print are saved here so you can print them again.' : 'No saved files match “' . h($q) . '”.' ?>
Print uses the copies and options above.
= h($fl['name']) ?>= h(date('M j, Y g:i a', $fl['time'])) ?> · = h(fmt_size($fl['size'])) ?>
= ext_file_html($fl['name']) ?>
count($files['items'])): ?>
Showing = count($files['items']) ?> of = (int)$files['total'] ?>= $q === '' ? ' — search to find older files.' : ' — refine your search.' ?>
Printed files are saved on the server so you can print them again.
= ext_page_html() ?>