From bc03c6b5142f5f25200738fc8532e1b75928f488 Mon Sep 17 00:00:00 2001 From: Clarth Date: Fri, 16 Oct 2026 16:32:19 -0400 Subject: [PATCH] Add index.php --- index.php | 486 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 486 insertions(+) create mode 100644 index.php diff --git a/index.php b/index.php new file mode 100644 index 0000000..4842b6b --- /dev/null +++ b/index.php @@ -0,0 +1,486 @@ + 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) ?> + + + +
+
+

+ · +
+ + +
+ + + +

No printer found

The server can’t see any printer. Check that CUPS is running and a printer is added.

+ +
+ + + +
    + +
    + 1): ?> + + + + + + + + +
    + + +
    + +
    +

    + + +

    + +

    Print uses the copies and options above.

    + +
    + · + + + +
    + + count($files['items'])): ?> +

    Showing of

    + + + +
    + + + +
    + +
    + + +
    +

    Waiting to print

    + +
    + + + +
    · KB
    + +
    + +
    + + + +

    Printed files are saved on the server so you can print them again.

    +
    + + + +