#!/bin/sh # Copyright (c) 2026 Mentor GmbH, Paderborn (https://mentor-group.de) – PDFMy, https://pdfmy.de # SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 # pdfmy Scan für Linux – Einrichtung. Im Terminal: sh pdfmy-scan-linux.sh (oder: curl -fsSL https://pdfmy.de/download/pdfmy-scan-linux.sh | sh) # Richtet ein: den Scan-Dienst (systemd-Benutzerdienst, startet mit der Anmeldung), den Eintrag „PDFMy“ im Anwendungsmenü # (öffnet PDFs per Doppelklick in PDFMy) und auf Wunsch PDFMy als Standard für PDF-Dateien. # Kein sudo nötig, alles nur für den angemeldeten Benutzer. Der Dienst ist nur vom eigenen Rechner aus erreichbar (127.0.0.1). set -u DATA="${XDG_DATA_HOME:-$HOME/.local/share}" CONF="${XDG_CONFIG_HOME:-$HOME/.config}" DIR="$DATA/pdfmy-scan" UNIT="$CONF/systemd/user/pdfmy-scan.service" APPS="$DATA/applications" echo "" echo " pdfmy Scan für Linux 1.7.0 wird eingerichtet …" echo "" if [ "$(id -u)" = "0" ]; then echo " ✗ Bitte ohne sudo starten – pdfmy Scan wird nur für Ihren Benutzer eingerichtet."; exit 1; fi PERL="$(command -v perl || true)" if [ -z "$PERL" ] || ! command -v curl >/dev/null 2>&1; then echo " ✗ Es fehlen Perl oder curl. Bitte installieren und erneut starten, z. B.:" echo " Debian/Ubuntu/Mint: sudo apt install perl curl" echo " Fedora: sudo dnf install perl curl" echo " openSUSE: sudo zypper install perl curl" echo " Arch: sudo pacman -S perl curl" exit 1 fi if ! "$PERL" -MJSON::PP -MIO::Socket::INET -MIO::Select -MMIME::Base64 -MFile::Temp -MEncode -e1 >/dev/null 2>&1; then echo " ✗ Perl ist unvollständig (Module wie JSON::PP fehlen). Bitte nachinstallieren, z. B.:" echo " Fedora: sudo dnf install perl-JSON-PP perl-IO perl-MIME-Base64 perl-Encode" echo " andere: sudo apt install perl bzw. sudo zypper install perl" exit 1 fi mkdir -p "$DIR" "$APPS" "$CONF/systemd/user" # 1. Scan-Dienst cat > "$DIR/pdfmy-scan.pl" <<'PDFMY_PERL_END' #!/usr/bin/perl # Copyright (c) 2026 Mentor GmbH, Paderborn (https://mentor-group.de) – PDFMy, https://pdfmy.de # SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 # pdfmy Scan für Mac und Linux – macht Scanner für PDFMy (pdfmy.de) im Browser verfügbar. # Spricht dieselbe Schnittstelle wie pdfmy Scan für Windows (http://127.0.0.1:52880), nur vom eigenen Rechner und nur für den eigenen Benutzer # erreichbar. Ist 52880 von einem anderen Benutzer belegt (mehrere Konten gleichzeitig angemeldet), nimmt es den nächsten freien Port bis 52889. # Protokolle: eSCL/AirScan (fast alle Geräte ab ca. 2015) und WSD-Scan (z. B. Dell Color MFP E525w, viele ältere Geräte) – ohne Treiber. # Linux zusätzlich: SANE (scanimage) für USB- und ältere Geräte, sofern installiert. # Nutzt nur Bordmittel: Perl, curl, dns-sd (Mac) bzw. avahi-browse (Linux). Scans gehen direkt an PDFMy im Browser – nichts ins Internet. use File::Copy (); use Unicode::Normalize (); use strict; use warnings; use utf8; use Encode qw(decode); use IO::Socket::INET; use IO::Select; use Socket qw(sockaddr_in inet_aton AF_INET6 SOCK_DGRAM getaddrinfo unpack_sockaddr_in6); use JSON::PP; use MIME::Base64; use POSIX qw(strftime :sys_wait_h); use Time::HiRes qw(time); use File::Path qw(make_path remove_tree); use File::Temp qw(tempfile); our $VERSION = '1.7.0'; my $IS_MAC = $^O eq 'darwin'; my $OS = $IS_MAC ? 'Mac' : 'Linux'; my $PORT = $ENV{PDFMY_PORT} || 52880; # tatsächlich belegter Port steht nach dem Start in $DIR/port my $WSD_PORT = $ENV{PDFMY_WSD_PORT} || 3702; my $DIR = $ENV{PDFMY_DIR} || ($IS_MAC ? "$ENV{HOME}/Library/Application Support/pdfmy-scan" : ($ENV{XDG_DATA_HOME} || "$ENV{HOME}/.local/share") . '/pdfmy-scan'); # Entwicklungsadressen nur mit PDFMY_DEV=1 – sonst gälte jeder fremde lokale Server auf Port 8765/8766 als PDFMy my @ALLOWED = ('https://pdfmy.de', 'https://www.pdfmy.de', $ENV{PDFMY_DEV} ? ('http://localhost:8765', 'http://localhost:8766', 'http://127.0.0.1:8765', 'http://127.0.0.1:8766') : ()); my $J = JSON::PP->new->utf8->canonical; make_path("$DIR/jobs"); $SIG{CHLD} = 'IGNORE'; $SIG{PIPE} = 'IGNORE'; # ---------------- Hilfen ---------------- sub logl { my $m = shift; $m =~ s/\s+/ /g; if (open my $f, '>>:utf8', "$DIR/log.txt") { print $f strftime('%Y-%m-%d %H:%M:%S ', localtime) . "$m\n"; close $f; } } sub slurp { my $f = shift; open my $h, '<:raw', $f or return undef; local $/; my $d = <$h>; close $h; return $d; } sub spit { my ($f, $d) = @_; open my $h, '>:raw', "$f.tmp" or return; print $h $d; close $h; rename "$f.tmp", $f; } sub trim { my $s = shift // ''; $s =~ s/^\s+|\s+$//g; return $s; } sub esc { my $s = shift // ''; $s =~ s/&/&/g; $s =~ s//>/g; return $s; } sub tagv { my ($x, $t) = @_; return $x =~ /<(?:[\w-]+:)?$t\b[^>]*>\s*([^<]*?)\s* 399 ? $#z - 399 : 0) .. $#z]) . "\n") if @z > 400; } # HTTP-Client über curl (auf jedem Mac vorhanden). Gibt (Statuscode, Kopfzeilen, Inhalt) zurück. sub http { my %o = @_; my ($bf, $bn) = tempfile(DIR => $DIR); close $bf; my ($hf, $hn) = tempfile(DIR => $DIR); close $hf; my @a = ('curl', '-s', '-m', ($o{timeout} || 30), '--connect-timeout', ($o{connect} || 5), '-o', $bn, '-D', $hn, '-w', '%{http_code}', '-H', 'Expect:', '-X', ($o{method} || 'GET')); push @a, '-H', "Content-Type: $o{ctype}" if $o{ctype}; push @a, '-k' if $o{url} =~ m{^https://}; # Scanner im eigenen Netz haben selbstsignierte Zertifikate push @a, '-H', "Host: $o{hosthdr}" if $o{hosthdr}; if (!$o{hosthdr} && $o{porthost} && $o{url} =~ m{^(https?)://(\[[^\]]+\]|[^/:]+)(?::(\d+))?}) { push @a, '-H', "Host: $2:" . ($3 // ($1 eq 'https' ? 443 : 80)); } # Epson: Port im Host-Kopf my $qn; if (defined $o{body}) { my $qf; ($qf, $qn) = tempfile(DIR => $DIR); binmode $qf; print $qf $o{body}; close $qf; push @a, '--data-binary', "\@$qn"; } push @a, $o{url}; my $code = 0; if (open my $p, '-|', @a) { local $/; $code = <$p> // 0; close $p; } my $body = slurp($bn) // ''; my $hdr = slurp($hn) // ''; unlink grep { defined } $bn, $hn, $qn; $code =~ s/\D//g; return (($code || 0) + 0, $hdr, $body); } sub hdr { my ($h, $name) = @_; my @m = $h =~ /^\Q$name\E:[ \t]*([^\r\n]*)/mig; return $m[-1]; } # Befehl mit Zeitlimit ausführen und die Ausgabe sammeln (für dns-sd, das sonst endlos läuft) sub run_for { my ($secs, @cmd) = @_; my $pid = open(my $fh, '-|', @cmd) or return ''; my $sel = IO::Select->new($fh); my $out = ''; my $end = time + $secs; while ((my $left = $end - time) > 0) { last unless $sel->can_read($left); my $n = sysread($fh, my $buf, 65536); last unless $n; $out .= $buf; } kill 'TERM', $pid; close $fh; return $out; } # Eigene IPv4-Adressen aller aktiven Netzwerkkarten (WLAN, LAN, VPN …) – für die Suche über jede Karte sub local_ipv4 { my $o = run_for(2, $IS_MAC ? ('/sbin/ifconfig') : (which('ip') ? (which('ip'), '-4', '-o', 'addr', 'show') : ('hostname', '-I'))); my @a = $o =~ /\binet\s/ ? ($o =~ /\binet\s+(?:addr:)?(\d{1,3}(?:\.\d{1,3}){3})/g) : ($o =~ /(\d{1,3}(?:\.\d{1,3}){3})/g); # ifconfig/ip: nur die Adresse nach „inet“ my %ip = map { $_ => 1 } grep { !/^127\./ && !/^169\.254\./ } @a; return sort keys %ip; } # Netzsuche: welche Adressen im eigenen Netz (privat, /24) haben Port 80 oder 443 offen? Gleichzeitig, in Gruppen (macOS erlaubt nur 256 offene Dateien) sub subnet_hosts { my %t; for my $lip (local_ipv4()) { my @b = split /\./, $lip; next unless $b[0] == 10 || ($b[0] == 192 && $b[1] == 168) || ($b[0] == 172 && $b[1] >= 16 && $b[1] <= 31); $t{"$b[0].$b[1].$b[2].$_"} = 1 for grep { "$b[0].$b[1].$b[2].$_" ne $lip } 1 .. 254; } my @targets = map { my $ip = $_; map { [$ip, $_] } 80, 443, 631, 5357, 9100 } sort keys %t; my %open; # Web und Druckerports (IPP, WSD, RAW) while (my @batch = splice @targets, 0, 150) { my %pending; for my $tp (@batch) { my $s = IO::Socket::INET->new(PeerAddr => $tp->[0], PeerPort => $tp->[1], Proto => 'tcp', Blocking => 0) or next; $pending{fileno $s} = [$s, @$tp]; } my $sel = IO::Select->new(map { $_->[0] } values %pending); my $end = time + 1.2; while ($sel->count && (my $left = $end - time) > 0) { for my $s ($sel->can_write($left)) { my $e = getsockopt($s, Socket::SOL_SOCKET(), Socket::SO_ERROR()); my ($ip, $port) = @{ $pending{fileno $s} }[1, 2]; $open{$ip}{$port} = 1 if $e && unpack('i', $e) == 0 && $s->connected; $sel->remove($s); close $s; } } close $_->[0] for values %pending; } return \%open; # IP → { 80 => 1, 443 => 1, 631 => 1, … } } # ---------------- Geräteliste (Datei, damit alle Prozesse dieselbe sehen) ---------------- sub devices { my $d = slurp("$DIR/devices.json"); my $l = $d ? eval { $J->decode($d) } : undef; return ref $l eq 'ARRAY' ? $l : []; } sub save_devices { spit("$DIR/devices.json", $J->encode($_[0])); } sub added_ips { my $d = slurp("$DIR/added.json"); my $l = $d ? eval { $J->decode($d) } : undef; return ref $l eq 'ARRAY' ? $l : []; } sub find_dev { my $id = shift; for (@{ devices() }) { return $_ if $_->{id} eq $id; } return undef; } # ---------------- eSCL / AirScan ---------------- sub escl_probe { my $base = shift; $base =~ s{/+$}{}; my ($c, $h, $x) = http(url => "$base/ScannerCapabilities", timeout => 5, connect => 3); return undef unless $c == 200 && $x =~ /<(?:[\w-]+:)?ScannerCapabilities\b/; $x = decode('UTF-8', $x); my %d = (kind => 'escl', base => $base, id => "escl:$base"); $d{name} = trim(tagv($x, 'MakeAndModel')) || 'Scanner ' . host_of($base); $d{feeder} = $x =~ /<(?:[\w-]+:)?Adf\b/ ? 1 : 0; $d{duplex} = $x =~ /AdfDuplexInputCaps/ ? 1 : 0; $d{platen} = $x =~ /<(?:[\w-]+:)?Platen\b/ ? 1 : 0; $d{platen} = 1 unless $d{feeder} || $d{platen}; $d{jpeg} = $x =~ m{image/jpeg} ? 1 : 0; $d{pdf} = $x =~ m{application/pdf} ? 1 : 0; $d{ver} = trim(tagv($x, 'Version')) || '2.0'; my %r; $r{$1} = 1 while $x =~ /<(?:[\w-]+:)?DiscreteResolution>.*?<(?:[\w-]+:)?XResolution>(\d+) $b } keys %r]; my %m; $m{trim($1)} = 1 while $x =~ /<(?:[\w-]+:)?ColorMode>([^<]+){res} || [] }; return $want unless @r; my ($b) = sort { abs($a - $want) <=> abs($b - $want) || $b <=> $a } @r; return $b; } sub pick_mode { my ($modes, $color, @map) = @_; my $want = $color eq 'color' ? $map[0] : $color eq 'bw' ? $map[2] : $map[1]; return $want if !@$modes || grep { $_ eq $want } @$modes; for my $alt ($map[1], $map[0], $map[2]) { return $alt if grep { $_ eq $alt } @$modes; } return $modes->[0]; } sub escl_settings { my ($d, $source, $mode, $dpi, $fmt, $duplex, $full) = @_; my $s = qq{\n\n$d->{ver}\n}; $s .= qq{Document\n3508escl:ThreeHundredthsOfInches248000\n} if $full; $s .= qq{$source\n}; $s .= '' . ($duplex ? 'true' : 'false') . "\n" if $source eq 'Feeder'; $s .= qq{$mode\n$dpi$dpi\n$fmt\n}; $s .= qq{$fmt\n} if $full && $d->{fmtext}; return $s . ''; } # Geräte-Eigenheiten (wie sane-airscan): Epson verlangt den Port im Host-Kopf, Brother braucht beim Einzug eine Pause zwischen den Seiten sub epson { my $d = shift; return ($d->{name} // '') =~ /^EPSON/i ? 1 : 0; } sub escl_status { my $d = shift; my ($c, $h, $x) = http(url => "$d->{base}/ScannerStatus", timeout => 5, porthost => epson($d)); return $c == 200 ? $x : ''; } sub escl_jobs { my $st = shift // ''; my @l; while ($st =~ /<(?:\w+:)?JobInfo>(.*?)<\/(?:\w+:)?JobInfo>/gs) { my $b = $1; my $u = tagv($b, 'JobUri'); push @l, [trim($u), trim(tagv($b, 'JobState') // '')] if $u; } return @l; } sub escl_scan { my ($d, $r, $page, $trace) = @_; my @sources; if ($r->{source} eq 'platen') { @sources = ('Platen'); } elsif ($r->{source} eq 'feeder') { @sources = ('Feeder'); } else { # automatisch: Papier im Einzug → Einzug, sonst Glas (manche Geräte melden den Einzug nicht in den Fähigkeiten) my $st = escl_status($d); my $adf = tagv($st, 'AdfState'); my $loaded = !defined $adf ? undef : $adf =~ /Loaded/ ? 1 : $adf =~ /Empty/ ? 0 : undef; push @$trace, "Einzug: $adf" if defined $adf; push @sources, 'Feeder' if ($loaded // 0) || (!defined $loaded && $d->{feeder}); push @sources, 'Platen' if $d->{platen} || !($loaded // 0); } @sources = ('Platen') unless @sources; my $dpi = pick_dpi($d, $r->{dpi} || 300); my $mode = pick_mode($d->{modes}, $r->{color} || 'gray', 'RGB24', 'Grayscale8', 'BlackAndWhite1'); my @formats = (($d->{jpeg} ? 'image/jpeg' : ()), ($d->{pdf} ? 'application/pdf' : ())); @formats = ('image/jpeg') unless @formats; my $n = 0; SOURCE: for my $source (@sources) { my ($loc, $fmt); VARIANT: for my $f (@formats) { for my $full (1, 0) { for my $try (1 .. 4) { my %before = map { $_->[0] => 1 } escl_jobs(escl_status($d)); my ($c, $h, $b) = http(method => 'POST', url => "$d->{base}/ScanJobs", ctype => 'text/xml', body => escl_settings($d, $source, $mode, $dpi, $f, $r->{duplex} && $d->{duplex}, $full), timeout => 30, porthost => epson($d), hosthdr => ($d->{localhost} && host_of($d->{base}) !~ /^(?:127\.|::1$|localhost$)/ ? 'localhost' : undef)); push @$trace, "$source $f " . ($full ? 'voll' : 'einfach') . " ${dpi}dpi $mode → $c"; if ($c == 503 || $c == 409) { sleep 2; next; } last unless $c >= 200 && $c < 300; $loc = hdr($h, 'Location') || hdr($h, 'Content-Location'); if (!$loc && $b =~ m{((?:https?://[^\s<"']+)?/eSCL/ScanJobs/[\w.\-]+)}) { $loc = $1; push @$trace, 'Adresse aus Antwort'; } if (!$loc) { for (1 .. 4) { sleep 1; my @fresh = grep { !$before{ $_->[0] } } escl_jobs(escl_status($d)); if (@fresh) { $loc = $fresh[-1][0]; push @$trace, 'Adresse aus ScannerStatus'; last; } } } if (!$loc) { push @$trace, 'keine Auftragsadresse'; last; } $fmt = $f; last VARIANT; } } } next SOURCE unless $loc; my ($root) = $d->{base} =~ m{^(\w+://[^/]+)}; unless ($loc =~ m{^https?://}) { $loc = $loc =~ m{^/} ? "$root$loc" : "$d->{base}/$loc"; } $loc =~ s{^\w+://[^/]+}{$root}; # immer über den Weg, auf dem wir das Gerät erreichen (HP meldet „localhost“) $loc =~ s{/+$}{}; escl_status($d); # Ricoh: ohne Statusabfrage vor der ersten Seite bleibt der Auftrag „wartend“ my $xeroxb = ($d->{name} eq 'B205' || $d->{name} eq 'B215') ? 1 : 0; my $r404 = 0; # Xerox B205/B215: 404/410 nur vorübergehend my $busy = 0; my $last = 0; while (1) { if ($source eq 'Feeder' && $n && ($d->{name} // '') =~ /^Brother /i) { my $w = $last + 1 - time; select(undef, undef, undef, $w > 1 ? 1 : $w) if $w > 0; } # Brother: mind. ca. 1 s zwischen den Seiten $last = time; my ($c, $h, $data) = http(url => "$loc/NextDocument", timeout => 120, porthost => epson($d)); if ($c == 503 && $busy++ < 90) { sleep 1; next; } if (($c == 404 || $c == 410) && $source eq 'Feeder' && !$n && $busy++ < 20) { sleep 1; next; } # zieht noch ein if (($c == 404 || $c == 410) && $xeroxb && $r404++ < 5) { sleep 1; next; } last if $c == 404 || $c == 410; if ($c != 200) { push @$trace, "NextDocument → $c"; last; } if (length($data) < 100) { push @$trace, 'leere Seite vom Gerät'; last; } $page->($data, $fmt); $n++; $busy = 0; last if $source eq 'Platen'; } last SOURCE if $n; push @$trace, "$source: keine Seiten"; } return $n; } # ---------------- WSD-Scan (Web Services for Devices, z. B. Dell E525w) ---------------- my $NS = 'xmlns:soap="http://www.w3.org/2003/05/soap-envelope" xmlns:wsa="http://schemas.xmlsoap.org/ws/2004/08/addressing" xmlns:wsd="http://schemas.xmlsoap.org/ws/2005/04/discovery" xmlns:sca="http://schemas.microsoft.com/windows/2006/08/wdp/scan" xmlns:wscn="http://schemas.microsoft.com/windows/2006/08/wdp/scan"'; my $SCA = 'http://schemas.microsoft.com/windows/2006/08/wdp/scan'; sub soap { my ($to, $action, $body) = @_; return qq{} . esc($to) . qq{$action} . uuid() . qq{http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous$body}; } sub wsd_call { my ($url, $action, $body, $timeout) = @_; return http(method => 'POST', url => $url, ctype => 'application/soap+xml; charset=utf-8', body => soap($url, $action, $body), timeout => $timeout || 15); } # WS-Discovery: Probe per Multicast (oder direkt an eine IP), Antwort enthält die Geräteadresse(n) sub wsd_discover { my ($target, $secs) = @_; if (ref $target eq 'ARRAY') { # mehrere IPv4-Adressen über einen Socket my $s = IO::Socket::INET->new(Proto => 'udp', LocalAddr => '0.0.0.0', LocalPort => 0) or return (); my $probe = qq{urn:schemas-xmlsoap-org:ws:2005:04:discoveryhttp://schemas.xmlsoap.org/ws/2005/04/discovery/Probe} . uuid() . qq{wscn:ScanDeviceType}; for my $ip (@$target) { my $a = inet_aton($ip) or next; send($s, $probe, 0, sockaddr_in($WSD_PORT, $a)); } return wsd_collect([$s], $secs); } $target =~ s/^\[|\]$//g if defined $target; my $v6 = defined $target && $target =~ /:/; my @socks; my $sock = $v6 ? undef : IO::Socket::INET->new(Proto => 'udp', LocalAddr => '0.0.0.0', LocalPort => 0); my $probe = qq{urn:schemas-xmlsoap-org:ws:2005:04:discoveryhttp://schemas.xmlsoap.org/ws/2005/04/discovery/Probe} . uuid() . qq{wscn:ScanDeviceType}; if ($sock) { my $ip = inet_aton($target || '239.255.255.250'); if ($ip) { send($sock, $probe, 0, sockaddr_in($WSD_PORT, $ip)) for 1 .. 2; push @socks, $sock; } } # Ohne Ziel: zusätzlich über jede Netzwerkkarte (sonst nur über die Standardroute – Scanner im WLAN wird bei aktivem LAN/VPN nicht gefunden) if (!defined $target && (my $mif = eval { Socket::IP_MULTICAST_IF() })) { for my $lip (local_ipv4()) { my $s = IO::Socket::INET->new(Proto => 'udp', LocalAddr => $lip, LocalPort => 0) or next; setsockopt($s, Socket::IPPROTO_IP(), $mif, inet_aton($lip)) or next; send($s, $probe, 0, sockaddr_in($WSD_PORT, inet_aton('239.255.255.250'))) for 1 .. 2; push @socks, $s; } } # IPv6: an die Adresse bzw. an die WSD-Gruppe ff02::c (Netze ohne IPv4 oder Geräte, die nur über IPv6 antworten) if ($v6 || !defined $target) { my ($err, @ai) = getaddrinfo($v6 ? $target : 'ff02::c', $WSD_PORT, { family => AF_INET6, socktype => SOCK_DGRAM }); if (!$err && @ai && socket(my $s6, AF_INET6, SOCK_DGRAM, 0)) { send($s6, $probe, 0, $ai[0]{addr}) for 1 .. 2; push @socks, $s6; } } return () unless @socks; return wsd_collect(\@socks, $secs); } sub wsd_collect { my ($socks, $secs) = @_; my @socks = @$socks; my $sel = IO::Select->new(@socks); my %found; my $end = time + ($secs || 3); while ((my $left = $end - time) > 0) { my @r = $sel->can_read($left); last unless @r; for my $sk (@r) { my $from = recv($sk, my $buf, 65535, 0); next unless defined $from; next unless $buf =~ /ProbeMatch/; my $epr = trim(tagv($buf, 'Address')) // ''; my @x = grep { m{^http://} } split /\s+/, (tagv($buf, 'XAddrs') // ''); # Link-lokale IPv6-Adresse (fe80::) braucht die Netzwerkschnittstelle – aus dem Absender übernehmen if (length $from > 16) { my (undef, undef, $scope) = eval { unpack_sockaddr_in6($from) }; s{^http://\[(fe80:[^\]%]+)\]}{http://[$1%25$scope]}i for grep { $scope } @x; } my $u = pick_url(@x); $found{$epr} //= { epr => $epr, xaddr => $u } if $u; $found{$epr}{xaddr} = $u if $u && $found{$epr}{xaddr} =~ m{^\w+://\[} && $u !~ m{^\w+://\[}; # IPv4 ersetzt IPv6 } } return values %found; } # Gerätebeschreibung holen (WS-Transfer Get) und den Scan-Dienst finden sub wsd_probe { my $m = shift; my ($c, $h, $x) = http(method => 'POST', url => $m->{xaddr}, ctype => 'application/soap+xml; charset=utf-8', timeout => 6, connect => 3, body => soap($m->{epr} || $m->{xaddr}, 'http://schemas.xmlsoap.org/ws/2004/09/transfer/Get', '')); return undef unless $c == 200; $x = decode('UTF-8', $x); my $svc; while ($x =~ /<(?:[\w-]+:)?Hosted>(.*?)<\/(?:[\w-]+:)?Hosted>/gs) { my $hb = $1; next unless $hb =~ /ScannerServiceType/; my $a = pick_url(map { trim($_) } ($hb =~ /<(?:[\w-]+:)?Address>([^<]+){xaddr} =~ /%25([\w.]+)\]/)[0]; $z ? "$1%25$z]" : "$1]"}ie; last; } } return undef unless $svc; my $name = trim(join ' ', grep { defined && length } (tagv($x, 'Manufacturer'), tagv($x, 'ModelName'))); $name = trim(tagv($x, 'FriendlyName')) if !$name || length $name < 3; my %d = (kind => 'wsd', id => "wsd:$svc", svc => $svc, name => ($name || 'Scanner ' . host_of($svc))); # Fähigkeiten: Einzug, beidseitig, Glas, Formate, Farben, Auflösungen my ($c2, $h2, $e) = wsd_call($svc, "$SCA/GetScannerElements", 'sca:ScannerDescriptionsca:ScannerConfiguration', 8); if ($c2 == 200) { $e = decode('UTF-8', $e); $d{name} = trim(tagv($e, 'ScannerName')) if $name eq '' && tagv($e, 'ScannerName'); $d{feeder} = $e =~ /<(?:[\w-]+:)?ADF\b/ ? 1 : 0; $d{duplex} = ($e =~ /<(?:[\w-]+:)?ADFSupportsDuplex>\s*(true|1)\s*([^<]+)([^<]+)(.*?)<\/(?:[\w-]+:)?(?:Platen|ADF|ADFFront)Resolutions>/gs) { my $rb = $1; $r{$1} = 1 while $rb =~ /<(?:[\w-]+:)?Width>\s*(\d+)\s* $b } grep { $_ >= 75 && $_ <= 1200 } keys %r]; } $d{platen} = 1 unless $d{feeder} || $d{platen}; return \%d; } # Mehrteilige Antwort (MTOM/XOP) zerlegen: das Bild ist der Binärteil sub wsd_image { my ($h, $b) = @_; my $ct = hdr($h, 'Content-Type') // ''; if ($ct =~ /boundary="?([^";]+)"?/i) { my $bd = $1; my ($best, $bestlen) = (undef, 0); for my $part (split /--\Q$bd\E/, $b) { my ($ph, $pb) = split /\r?\n\r?\n/, $part, 2; next unless defined $pb; next if $ph =~ m{application/(?:soap\+)?xop\+xml|text/xml|application/soap\+xml}i; $pb =~ s/\r?\n$//; ($best, $bestlen) = ($pb, length $pb) if length $pb > $bestlen; } return $best; } if ($b =~ /<(?:[\w-]+:)?ScanData>\s*([A-Za-z0-9+\/=\s]{200,})\s*{name} // '') eq 'RICOH Aficio MP 201' ? 100 : 0; # Ricoh MP 201 versteht 0 nicht my $side = "$mode$dpi$dpi"; my $t = 'PDFMyPDFMyPDFMy'; $t .= "$fmt"; $t .= "$itt" unless $simple; $t .= "$src"; $t .= '826811693' unless $simple; # A4 in 1/1000 Zoll $t .= "$side" . ($back ? "$side" : '') . ''; return $t . ''; } sub wsd_scan { my ($d, $r, $page, $trace) = @_; my @sources = $r->{source} eq 'platen' ? ('Platen') : $r->{source} eq 'feeder' ? ('ADF') : (($d->{feeder} ? 'ADF' : ()), ($d->{platen} ? 'Platen' : ())); @sources = ('Platen') unless @sources; my @f = @{ $d->{formats} || [] }; my ($fmt) = (grep({ $_ eq 'jfif' } @f), grep({ $_ eq 'exif' } @f), grep({ /pdf/ } @f), grep({ $_ eq 'png' } @f), 'jfif'); my $mime = $fmt =~ /pdf/ ? 'application/pdf' : $fmt eq 'png' ? 'image/png' : 'image/jpeg'; my $mode = pick_mode($d->{modes} || [], $r->{color} || 'gray', 'RGB24', 'Grayscale8', 'BlackAndWhite1'); my $dpi = pick_dpi($d, $r->{dpi} || 300); my $n = 0; my $notacc = 0; SOURCE: for my $src0 (@sources) { my $src = $src0 eq 'ADF' && $r->{duplex} && $d->{duplex} ? 'ADFDuplex' : $src0; my ($id, $tok); for my $simple (0, 1) { for my $try (1 .. 3) { my ($c, $h, $x) = wsd_call($d->{svc}, "$SCA/CreateScanJob", wsd_ticket($d, $src, $fmt, $mode, $dpi, $src eq 'ADFDuplex', $simple), 20); my $fault = trim(tagv($x, 'Value') // ''); $fault = trim(tagv($x, 'Subcode') // $fault) if $x =~ /Subcode/; push @$trace, "$src $fmt " . ($simple ? 'einfach' : 'voll') . " ${dpi}dpi $mode → $c" . ($c == 200 ? '' : " $fault"); # Lexmark/Xerox melden Fehler auch mit HTTP 200 – nur mit Auftragsnummer und ohne Fault ist es ein Erfolg my $jid = trim(tagv($x, 'JobId') // ''); if ($c == 200 && length $jid && !wsd_fault($x)) { $id = $jid; $tok = trim(tagv($x, 'JobToken')); last; } $notacc = 1 if $x =~ /NotAcceptingJobs/i; last unless $x =~ /Busy|NotAccepting|ServerErrorTemporary|Calibrating|LampWarming/i; sleep 2; } last if $id; } next SOURCE unless $id; my $busy = 0; while (1) { my ($c, $h, $b) = wsd_call($d->{svc}, "$SCA/RetrieveImage", "$id" . esc($tok) . 'PDFMy-' . ($n + 1) . '', 180); if ($c != 200 || wsd_fault($b)) { last if $b =~ /NoImagesAvailable|JobCompleted|ClientErrorNoImages/i || ($src0 eq 'Platen' && $n); if ($b =~ /Busy|Temporary|NotReady/i && $busy++ < 60) { sleep 1; next; } push @$trace, "RetrieveImage → $c " . trim(tagv($b, 'Text') // tagv($b, 'Value') // ''); last; } my $img = wsd_image($h, $b); if (!$img || length $img < 100) { push @$trace, 'kein Bild in der Antwort'; last; } $page->($img, $mime); $n++; $busy = 0; last if $src0 eq 'Platen'; } last SOURCE if $n; push @$trace, "$src: keine Seiten"; } die "Der Scanner nimmt gerade keine Aufträge an. Bei Kyocera, Ricoh und manchen anderen Geräten muss WSD-Scan am Gerät gestartet werden: am Bedienfeld „Senden“ bzw. „Scanner“ → „WSD-Scan“ → „Vom Computer“ wählen und dann erneut „Scannen“ klicken.\n" if !$n && $notacc; return $n; } sub wsd_fault { my $x = shift // ''; return $x =~ m{addressing/fault|<(?:[\w-]+:)?Fault\b} ? 1 : 0; } # ---------------- SANE (nur Linux): USB- und ältere Geräte über scanimage ---------------- sub sane_caps { my ($si, $dev) = @_; my $o = run_for(12, $si, '-d', $dev, '-A'); my %d; my $list = sub { my $k = shift; return () unless $o =~ /--\Q$k\E\s+([^\[\n]+?)\s*(?:\[|\n)/; my $v = $1; $v =~ s/\s*dpi\s*$//i; return map { trim($_) } split /\|/, $v; }; $d{sources} = [$list->('source')]; $d{modes} = [$list->('mode')]; my @r = $list->('resolution'); if (@r == 1 && $r[0] =~ /^(\d+)\.\.(\d+)/) { $d{range} = [$1 + 0, $2 + 0]; $d{res} = [grep { $_ >= $1 && $_ <= $2 } 75, 100, 150, 200, 300, 400, 600]; } else { $d{res} = [sort { $a <=> $b } grep { /^\d+$/ } @r]; } return \%d; } sub sane_discover { my $known = shift; # bereits über eSCL/WSD gefundene Geräte – nicht doppelt anzeigen my $si = which('scanimage') or return (); my @l; for (split /\n/, run_for(15, $si, '-f', "%d\t%v %m\n")) { my ($dev, $name) = split /\t/; next unless $dev && $dev =~ /^[\w-]+:/; next if $dev =~ /^(?:v4l|test|pnm|net):/; # Webcams, Testtreiber $name = trim($name); $name =~ s/^Unknown\s+//i; if ($dev =~ /^(?:airscan|escl):/) { my $n = lc $name; next if grep { my $k = lc $_->{name}; index($k, $n) >= 0 || index($n, $k) >= 0 } @$known; } my $caps = sane_caps($si, $dev); my @s = @{ $caps->{sources} }; my %d = (kind => 'sane', id => "sane:$dev", dev => $dev, name => $name || $dev, %$caps); $d{feeder} = (grep { /adf|feeder|einzug|automatic|document/i } @s) ? 1 : 0; $d{duplex} = (grep { /duplex/i } @s) ? 1 : 0; $d{platen} = (grep { /flatbed|platen|glas|normal/i } @s) || !@s ? 1 : 0; push @l, \%d; } return @l; } sub sane_mode { my ($modes, $color) = @_; return undef unless @$modes; my @pref = $color eq 'color' ? (qr/colou?r|rgb|farb/i) : $color eq 'bw' ? (qr/lineart|binary|black|halftone|s\/w/i, qr/gr[ae]y/i) : (qr/gr[ae]y|grau/i, qr/colou?r/i); for my $re (@pref) { my ($m) = grep { $_ =~ $re } @$modes; return $m if defined $m; } return $modes->[0]; } sub sane_scan { my ($d, $r, $page, $trace) = @_; my $si = which('scanimage') or do { push @$trace, 'scanimage fehlt'; return 0; }; my @s = @{ $d->{sources} || [] }; my ($flat) = grep { /flatbed|platen|glas|normal/i } @s; my ($dup) = grep { /duplex/i } @s; my ($adf) = grep { /adf|feeder|einzug|automatic|document/i && !/duplex|back|rück/i } @s; my $feed = $r->{duplex} && $dup ? $dup : ($adf // $dup); my @try = $r->{source} eq 'platen' ? ($flat) : $r->{source} eq 'feeder' ? ($feed) : (($feed // ()), ($flat // ())); @try = (undef) unless grep { defined } @try; my $dpi = $r->{dpi} || 300; if ($d->{range}) { $dpi = $d->{range}[0] if $dpi < $d->{range}[0]; $dpi = $d->{range}[1] if $dpi > $d->{range}[1]; } else { $dpi = pick_dpi($d, $dpi); } my $mode = sane_mode($d->{modes} || [], $r->{color} || 'gray'); my $n = 0; SRC: for my $src (@try) { my $isflat = !defined $src || (defined $flat && $src eq $flat); for my $fmt ('jpeg', 'png') { my $dir = "$DIR/jobs/sane-$$"; remove_tree($dir); make_path($dir); my @cmd = ($si, '-d', $d->{dev}, "--format=$fmt", '--resolution', $dpi, (defined $mode ? ('--mode', $mode) : ()), (defined $src ? ('--source', $src) : ()), "--batch=$dir/p%04d.$fmt", '--batch-print', ($isflat ? ('--batch-count=1') : ())); my $pid = open(my $fh, '-|'); if (!defined $pid) { push @$trace, 'scanimage startet nicht'; return $n; } if ($pid == 0) { open STDERR, '>', "$dir/err.txt"; { exec @cmd }; POSIX::_exit(127); } my $got = 0; while (my $line = <$fh>) { chomp $line; next unless -f $line; my $data = slurp($line); unlink $line; next unless defined $data && length $data > 100; $page->($data, $fmt eq 'png' ? 'image/png' : 'image/jpeg'); $n++; $got++; } close $fh; my $err = trim(slurp("$dir/err.txt") // ''); $err =~ s/\s+/ /g; remove_tree($dir); push @$trace, 'SANE ' . ($src // 'Standard') . " $fmt ${dpi}dpi " . ($mode // '') . " → $got Seite(n)" . ($err ? ' · ' . substr($err, 0, 160) : ''); last unless !$got && $err =~ /format|unrecognized|not supported|unbekannt/i; # nur bei fehlendem JPEG-Format mit PNG wiederholen } last SRC if $n; } return $n; } # ---------------- Suche ---------------- # Einzelne Geräteabfrage absichern: ein Fehler (z. B. ungewöhnliche WSD-Antwort) darf die Suche nicht abbrechen sub safe { my $r = eval { $_[0]->() }; if ($@) { (my $e = "$@") =~ s/\s+$//; logl("Suche: $e"); } return $r; } # Schlafende Geräte wecken: kurze Verbindungsversuche an die bekannten Adressen (Web, IPP, RAW-Druck, WSD) und Wake-on-LAN, # wenn die Hardware-Adresse im ARP-Speicher steht. Viele Drucker/MFP antworten im Tiefschlaf nicht auf den WSD-Rundruf. sub mac_of { my $ip = shift; return undef unless $ip =~ /^\d+\.\d+\.\d+\.\d+$/; my $o = $IS_MAC ? run_for(2, '/usr/sbin/arp', '-n', $ip) : (slurp('/proc/net/arp') // ''); if (!$IS_MAC) { for (split /\n/, $o) { my @f = split ' '; return $f[3] if @f > 3 && $f[0] eq $ip && $f[3] =~ /^([0-9a-f]{2}:){5}[0-9a-f]{2}$/i && $f[3] ne '00:00:00:00:00:00'; } return undef; } my ($m) = $o =~ /\bat\s+([0-9a-f]{1,2}(?::[0-9a-f]{1,2}){5})\b/i; return undef unless $m; return join ':', map { sprintf '%02x', hex } split /:/, $m; } sub wake_host { my $h = shift; $h =~ s/^\[|\]$//g; for my $port (80, 443, 631, 9100, 5357, 8080) { my $s = IO::Socket::INET->new(PeerAddr => $h, PeerPort => $port, Proto => 'tcp', Timeout => 0.4); close $s if $s; } my $mac = mac_of($h) or return; my $pkt = ("\xFF" x 6) . (pack('H12', join '', split /:/, $mac) x 16); for my $to ('255.255.255.255', $h) { my $u = IO::Socket::INET->new(Proto => 'udp', PeerAddr => $to, PeerPort => 9, Broadcast => 1); if ($u) { $u->send($pkt); close $u; } } } sub wake_known { my %h; for my $d (@{ devices() }) { my $x = host_of($d->{base} // $d->{svc} // $d->{xaddr} // ''); $h{$x} = 1 if $x; } for (@{ added_ips() }) { my ($ip) = split /\|/; $h{$ip} = 1 if $ip; } delete @h{ '127.0.0.1', 'localhost', '' }; my @l = sort keys %h; return 0 unless @l; my @kids; for my $x (@l) { my $k = fork(); if (defined $k && $k == 0) { eval { wake_host($x) }; POSIX::_exit(0); } push @kids, $k if $k; } waitpid($_, 0) for @kids; logl('Wecken: ' . scalar(@l) . ' Gerät(e) angesprochen (' . join(', ', @l) . ')'); return scalar @l; } sub discover { my %dev; my @t; select(undef, undef, undef, 1.5) if !$ENV{PDFMY_WOKEN} && safe(sub { wake_known() }); # schlafende Geräte zuerst wecken (nicht doppelt nach /wake) # eSCL über Bonjour (macOS: dns-sd) if ($IS_MAC && -x '/usr/bin/dns-sd') { my %names; for my $type ('_uscan._tcp') { for (split /\n/, run_for(2.5, '/usr/bin/dns-sd', '-B', $type, 'local.')) { $names{$1} = $type if /\sAdd\s+\d+\s+\d+\s+\S+\s+\Q$type\E\.\s+(.+?)\s*$/; } } for my $inst (keys %names) { my $o = run_for(2, '/usr/bin/dns-sd', '-L', $inst, $names{$inst}, 'local.'); my ($host, $port) = $o =~ /can be reached at\s+(\S+?)\.?:(\d+)/; next unless $host; my ($rs) = $o =~ /\brs=([^\s"\\]+)/; $rs //= 'eSCL'; my $d = safe(sub { escl_probe("http://$host:$port/$rs") }); $dev{ $d->{id} } = $d if $d; } } my @diag; push @diag, 'Netz: ' . (join(', ', local_ipv4()) || 'keine IPv4-Adresse'); push @diag, 'Bonjour: ' . scalar(keys %dev) if $IS_MAC; # eSCL über Avahi (Linux): -r auflösen, -p maschinenlesbar, -t nach dem ersten Durchlauf beenden push @diag, 'avahi-browse fehlt (Paket avahi-utils)' if !$IS_MAC && !which('avahi-browse'); if (!$IS_MAC && (my $ab = which('avahi-browse'))) { my (%svc, %seen); for my $type ('_uscan._tcp', '_uscans._tcp') { for (split /\n/, run_for(5, $ab, '-rpt', $type)) { my @f = split /;/, $_, 10; next unless @f >= 9 && $f[0] eq '='; my ($if, $fam, $name, $addr, $port, $txt) = @f[1, 2, 3, 7, 8, 9]; $txt //= ''; $addr .= "%$if" if $addr =~ /^fe80:/i && $if; # link-lokal: Schnittstelle angeben my ($rs) = $txt =~ /"rs=([^"]*)"/; $rs = 'eSCL' unless defined $rs && length $rs; push @{ $svc{"$type|$name"} }, [$fam, (($type eq '_uscans._tcp' ? 'https' : 'http') . '://' . hosturl($addr) . ":$port/$rs")]; } } # je Gerät: IPv4 zuerst, IPv6 nur als Rückfall; http vor https for my $k (sort keys %svc) { my ($name) = $k =~ /\|(.*)$/; next if $seen{$name}; for my $u (map { $_->[1] } sort { ($a->[0] eq 'IPv4' ? 0 : 1) <=> ($b->[0] eq 'IPv4' ? 0 : 1) } @{ $svc{$k} }) { my $d = safe(sub { escl_probe($u) }); if ($d) { $dev{ $d->{id} } = $d; $seen{$name} = 1; last; } } } } push @diag, 'avahi: ' . scalar(keys %dev) if !$IS_MAC && which('avahi-browse'); # WSD (Multicast) my @wm = @{ safe(sub { [wsd_discover(undef, 3)] }) || [] }; push @diag, 'WSD-Rundruf: ' . scalar(@wm) . ' Antwort(en)'; for my $m (@wm) { my $d = safe(sub { wsd_probe($m) }); $dev{ $d->{id} } = $d if $d && !grep { lc $_->{name} eq lc $d->{name} && $_->{kind} eq 'escl' } values %dev; } # Netzsuche (wie pdfmy Scan für Windows): jede Adresse im eigenen Netz direkt fragen – findet Geräte, wenn Bonjour/avahi fehlen # oder eine Firewall die Antworten auf den Rundruf verwirft (unter Linux häufig: firewalld/ufw) my %known = map { host_of($_->{base} // $_->{svc} // $_->{xaddr} // '') => 1 } values %dev; my $open = safe(sub { subnet_hosts() }) || {}; my @hosts = grep { !$known{$_} } sort keys %$open; my @wsdHosts; for my $ip (@hosts) { my $d; for my $base (($open->{$ip}{80} ? "http://$ip/eSCL" : ()), ($open->{$ip}{443} ? "https://$ip/eSCL" : ())) { $d = safe(sub { escl_probe($base) }) and last; } if ($d) { $dev{ $d->{id} } = $d; } else { push @wsdHosts, $ip; } } my @wu = @wsdHosts ? @{ safe(sub { [wsd_discover(\@wsdHosts, 2)] }) || [] } : (); for my $m (@wu) { my $d = safe(sub { wsd_probe($m) }); $dev{ $d->{id} } = $d if $d && !grep { lc $_->{name} eq lc $d->{name} } values %dev; } push @diag, 'Netzsuche: ' . scalar(keys %$open) . ' Gerät(e) mit Web-/Druckerport' . (@hosts ? ' (' . join(', ', @hosts[0 .. ($#hosts > 11 ? 11 : $#hosts)]) . ')' : '') . ', WSD direkt: ' . scalar(@wu); # Von Hand hinzugefügte Geräte (IP-Adresse) for my $ip (@{ added_ips() }) { my $d = safe(sub { probe_ip($ip) }); $dev{ $d->{id} } = $d if $d; } # Linux: SANE für USB- und ältere Geräte (nur was nicht schon über eSCL/WSD gefunden wurde) if (!$IS_MAC) { for my $d (@{ safe(sub { [sane_discover([values %dev])] }) || [] }) { $dev{ $d->{id} } = $d; } } my @l = sort { ($a->{kind} cmp $b->{kind}) || ($a->{name} cmp $b->{name}) } values %dev; save_devices(\@l); logl('Suche: ' . scalar(@l) . ' Scanner – ' . join(', ', map { "$_->{name} ($_->{kind})" } @l) . ' · ' . join(' · ', @diag)); return \@l; } sub probe_ip { my ($ip, $port) = split /\|/, shift; # Gerätename ohne Punkt (z. B. EPSON2CBBCB): Windows löst ihn selbst auf, Mac/Linux nur als Name.local (Bonjour) if ($ip =~ /^[A-Za-z][\w-]*$/ && !gethostbyname($ip)) { my $l = "$ip.local"; my $r = gethostbyname($l) ? $l : undef; if (!$r && (my $o = $IS_MAC ? run_for(2, '/usr/bin/dscacheutil', '-q', 'host', '-a', 'name', $l) : (which('avahi-resolve') ? run_for(3, which('avahi-resolve'), '-4', '-n', $l) : ''))) { ($r) = $o =~ /(\d{1,3}(?:\.\d{1,3}){3})/; } $ip = $r if $r; logl("Gerätename $l → " . ($r // 'nicht gefunden')); } my $h = hosturl($ip); for my $base ($port ? ("http://$h:$port/eSCL", "https://$h:$port/eSCL") : ("http://$h/eSCL", "https://$h/eSCL", "http://$h:8080/eSCL")) { my $d = escl_probe($base); return $d if $d; } for my $m (wsd_discover($ip, 2)) { my $d = wsd_probe($m); return $d if $d; } return undef; } # ---------------- Scanaufträge (laufen in eigenem Prozess, Zustand als Dateien) ---------------- sub job_state { my $id = shift; my $s = slurp("$DIR/jobs/$id/state.json"); return $s ? eval { $J->decode($s) } : undef; } sub start_scan { my ($d, $r) = @_; my $lock = slurp("$DIR/busy"); return (409, { error => 'Es läuft bereits ein Scan.' }) if $lock && alive(trim($lock)); for my $old (glob "$DIR/jobs/*") { remove_tree($old) if -M $old > 0.02; } # älter als ca. 30 Minuten my $id = substr(join('', map { sprintf '%02x', int rand 256 } 1 .. 8), 0, 12); make_path("$DIR/jobs/$id"); my $mime = $d->{kind} eq 'escl' && !$d->{jpeg} && $d->{pdf} ? 'application/pdf' : 'image/jpeg'; # SANE/WSD melden das Format je Seite spit("$DIR/jobs/$id/state.json", $J->encode({ state => 'running', pages => 0, mime => $mime })); my $pid = fork(); return (500, { error => 'Scan konnte nicht gestartet werden.' }) unless defined $pid; if ($pid == 0) { spit("$DIR/busy", $$); my @trace; my $n = 0; my $page = sub { my ($data, $m) = @_; spit("$DIR/jobs/$id/$n.bin", $data); $n++; spit("$DIR/jobs/$id/state.json", $J->encode({ state => 'running', pages => $n, mime => $m || $mime })); $mime = $m || $mime; }; my $ok = eval { $d->{kind} eq 'wsd' ? wsd_scan($d, $r, $page, \@trace) : $d->{kind} eq 'sane' ? sane_scan($d, $r, $page, \@trace) : escl_scan($d, $r, $page, \@trace) }; my $err = $@ ? "$@" : ''; my $trace = join ' | ', @trace; logl("Scan $d->{name}: $n Seite(n) · $trace" . ($err ? " · Fehler: $err" : '')); my %s = (state => 'done', pages => $n, mime => $mime, detail => $trace); if (!$n) { %s = (%s, state => 'error', error => $err || 'Der Scanner hat keine Seiten geliefert. Liegt Papier im Einzug oder auf dem Glas? Ist das Gerät gerade frei (kein Kopier- oder Druckauftrag)?'); } spit("$DIR/jobs/$id/state.json", $J->encode(\%s)); unlink "$DIR/busy"; POSIX::_exit(0); } return (200, { job => $id, device => $d->{name} }); } # ---------------- PDFs per Doppelklick (Finder bzw. Dateimanager) in PDFMy öffnen ---------------- # Die App „PDFMy“ (vom Installer angelegt) schickt den Dateipfad an /open-local. Die Datei bekommt ein Einmal-Kennwort # (5 Minuten gültig), der Browser öffnet pdfmy.de#open= und holt sie über 127.0.0.1 ab. Die Datei verlässt den Rechner nicht. # Linux: Der Dienst hat keinen Zugriff auf die Bildschirmsitzung – die Adresse geht an das Startskript zurück, das den Browser öffnet. my $BUNDLE = 'de.pdfmy.open'; sub app_url { my $u = trim(slurp("$DIR/appurl.txt") // ''); return (grep { $u =~ /^\Q$_\E\// } @ALLOWED) ? $u : 'https://pdfmy.de/'; } # Ist PDFMy in Chrome oder Edge als App installiert (macOS legt sie unter ~/Applications/… Apps.localized ab), # öffnet die PDF direkt im App-Fenster – der Browser übergibt die Datei an PDFMy (launchQueue). sub pwa_app { for my $d ('Chrome Apps.localized', 'Microsoft Edge Apps.localized', 'Edge Apps.localized', 'Chromium Apps.localized') { my $a = "$ENV{HOME}/Applications/$d/PDFMy.app"; return $a if -d $a; } return undef; } # Anhänge aus Outlook/Mail liegen im geschützten Ordner der jeweiligen App (~/Library/Containers/…, den Chrome nicht lesen darf) # oder in Temp-Ordnern, die die App bald wieder leert. Sofort eine Kopie im eigenen Ordner anlegen # (je Kopie ein Unterordner, der Dateiname bleibt; nach einem Tag entfernt). sub volatile { my $p = shift; return $p =~ m{/Library/(?:Group )?Containers/|^(?:/private)?/var/folders/|^(?:/private)?/tmp/} || (length($ENV{TMPDIR} // '') && index($p, $ENV{TMPDIR}) == 0); } sub secure_copy { my $path = shift; my $base = "$DIR/open-copies"; return $path if !volatile($path) || index($path, "$base/") == 0; make_path($base); for my $old (glob "\"$base\"/*") { remove_tree($old) if -M $old > 1; } my $dir = "$base/" . int(time) . "-$$"; make_path($dir); my ($nm) = $path =~ m{([^/]+)$}; my $dst = "$dir/$nm"; if (File::Copy::copy($path, $dst) && -s $dst) { logl("Kopie für PDFMy: $dst"); return $dst; } logl("Kopie nicht möglich: $path ($!)"); return $path; } # PDFMy (Browser-App) durfte eine übergebene Datei nicht lesen – z. B. weil die Chrome-App „PDFMy“ selbst Standard für PDFs ist und # Outlook ihr den Pfad im geschützten Ordner gibt: frisch abgelegte Datei gleichen Namens in den Anhang-Ordnern suchen # (eigene Kopien, Outlook, Apple Mail, Thunderbird, Temp) – nur PDF/Bilder, höchstens 30 Minuten alt. sub find_recent { my $name = shift // ''; $name =~ s{.*/}{}; return undef unless length $name && $name =~ /\.(?:pdf|jpe?g|png|tiff?|webp|bmp)$/i; my $nfc = sub { my $s = shift; utf8::decode($s); return Unicode::Normalize::NFC($s); }; # macOS legt Namen oft zerlegt (NFD) ab my $want = $nfc->($name); my $H = $ENV{HOME} // ''; # Nur Ordner, in denen Mail-Programme Anhänge zum Öffnen ablegen – nicht das Postfach selbst (zu groß); [Ordner, Tiefe] my $O = "$H/Library/Containers/com.microsoft.Outlook/Data"; my @roots = (["$DIR/open-copies", 2]); if ($IS_MAC) { push @roots, ["$O/tmp", 6], ["$O/Library/Caches", 6], ["$O/Downloads", 3], ["$H/Library/Containers/com.apple.mail/Data/Library/Mail Downloads", 3], ["$H/Library/Mail Downloads", 3], [$ENV{TMPDIR} // '', 4], ["$H/Downloads", 1]; } else { push @roots, (map { [$_, 4] } glob('/tmp/mozilla_*')), ["$H/.cache/thunderbird", 4], [$ENV{TMPDIR} // '', 3], ['/tmp', 3]; } my $cut = time - 1800; my ($best, $bt); my %seen; my %denied; my $stop = time + 8; my $n = 0; # Zeitgrenze: höchstens 8 Sekunden suchen my $walk; $walk = sub { my ($d, $left) = @_; return if time > $stop || ++$n > 100000; opendir(my $dh, $d) or do { $denied{$d} = 1 if $!{EPERM} || $!{EACCES}; return }; my @e = grep { $_ ne '.' && $_ ne '..' } readdir $dh; closedir $dh; for my $e (@e) { last if time > $stop; next if $left <= 0 && $nfc->($e) ne $want; # unterste Ebene: nur der gesuchte Name zählt (spart Dateiabfragen) my $p = "$d/$e"; next if -l $p; if (-d _) { $walk->($p, $left - 1) if $left > 0; next; } next unless $nfc->($e) eq $want; my @s = stat $p; ($best, $bt) = ($p, $s[9]) if @s && $s[9] >= $cut && $s[7] > 0 && (!$bt || $s[9] > $bt); } }; for my $r (grep { length $_->[0] && -d $_->[0] && !$seen{ $_->[0] }++ } @roots) { $walk->(@$r); last if $best; } # Ordner nach Wahrscheinlichkeit: erster Treffer genügt our %FIND_DENIED = %denied; # für die Antwort an PDFMy (genaue Meldung bei macOS-Datenschutz) if ($best) { logl("Anhang für PDFMy nachgereicht: $best"); } else { logl("Anhang nicht gefunden: $name – gesucht in " . join('; ', grep { -d } map { $_->[0] } @roots) . (%denied ? ' – kein Zugriff (macOS-Datenschutz) auf: ' . join('; ', sort keys %denied) : '')); } # fürs Protokoll / „Problem melden“ return $best; } sub open_local { my $path = shift; return 0 unless defined $path && -f $path && -r _; $path = secure_copy($path); if ($IS_MAC && -x '/usr/bin/open' && !$ENV{PDFMY_TEST_NOBROWSER} and my $app = pwa_app()) { if (system('/usr/bin/open', '-a', $app, $path) == 0) { logl("Öffnen in der PDFMy-App: $path"); return 'app'; } } make_path("$DIR/open"); for my $old (glob "$DIR/open/*") { unlink $old if -M $old > 0.0035; } # älter als 5 Minuten my $tok = join '', map { sprintf '%02x', int rand 256 } 1 .. 16; spit("$DIR/open/$tok", $path); my $url = app_url() . "#open=$tok"; system('/usr/bin/open', $url) if $IS_MAC && -x '/usr/bin/open'; logl("Öffnen: $path"); return $url; } sub take_file { my $tok = shift; my $f = "$DIR/open/$tok"; return (undef, 'Der Link ist abgelaufen – bitte die Datei noch einmal doppelt anklicken.') unless $tok =~ /^[0-9a-f]{32}$/ && -f $f && -M $f <= 0.0035; my $path = slurp($f); unlink $f; utf8::decode($path); my $data = slurp($path); return (undef, 'Die Datei ist nicht mehr vorhanden.') unless defined $data; my ($name) = $path =~ m{([^/]+)$}; return ($data, $name); } # Ist PDFMy das Standardprogramm für PDFs? (macOS LaunchServices, über JavaScript for Automation; 60 Sekunden zwischengespeichert) # „In Office-Programm öffnen“: installierte Programme und was man damit machen kann (der Nutzer wählt in PDFMy). # Mac: Programme-Ordner, Linux: Befehle im Pfad. Die PDF liegt danach in $DIR/office (lokal, nicht in iCloud/OneDrive). sub office_apps { my @l; my $t = sub { $ENV{ 'PDFMY_TEST_OFFICE_' . uc $_[0] } }; # nur für Tests if ($IS_MAC) { my $app = sub { for my $d ('/Applications', "$ENV{HOME}/Applications") { return "$d/$_[0].app" if -d "$d/$_[0].app"; } return undef; }; if (my $a = $t->('onlyoffice') || $app->('ONLYOFFICE')) { push @l, { id => 'onlyoffice', app => 'ONLYOFFICE', what => 'pdf', cmd => ['/usr/bin/open', '-a', $a] }; } for ([ 'libreoffice', 'LibreOffice', 'LibreOffice' ], [ 'collabora', 'Collabora Office', 'Collabora Office' ]) { my ($id, $name, $label) = @$_; my $a = $t->($id) || $app->($name) or next; my $so = -x "$a/Contents/MacOS/soffice" ? "$a/Contents/MacOS/soffice" : $a; push @l, { id => "$id-writer", app => "$label Writer", what => 'docx', fmt => 'docx', cmd => ['/usr/bin/open', '-a', $a] }, { id => "$id-draw", app => "$label Draw", what => 'draw', cmd => ['/usr/bin/open', '-a', $a] }; } # SoftMaker FreeOffice / SoftMaker Office (TextMaker), Apache OpenOffice: Word-Dokument aus PDFMy; Inkscape: Grafik my $glob = sub { for my $d ('/Applications', "$ENV{HOME}/Applications") { my ($a) = reverse sort glob("'$d/'*$_[0]*.app"); return $a if $a && -d $a; } return undef; }; if (my $a = $t->('textmaker') || $glob->('TextMaker')) { push @l, { id => 'textmaker', app => ($a =~ /FreeOffice/i ? 'FreeOffice TextMaker' : 'SoftMaker TextMaker'), what => 'docx', fmt => 'docx', cmd => ['/usr/bin/open', '-a', $a] }; } if (my $a = $t->('openoffice') || $app->('OpenOffice')) { push @l, { id => 'openoffice-writer', app => 'Apache OpenOffice Writer', what => 'docx', fmt => 'docx', cmd => ['/usr/bin/open', '-a', $a] }; } if (my $a = $t->('inkscape') || $app->('Inkscape')) { push @l, { id => 'inkscape', app => 'Inkscape', what => 'graphic', cmd => ['/usr/bin/open', '-a', $a] }; } if (my $a = $t->('word') || $app->('Microsoft Word')) { push @l, { id => 'word', app => 'Microsoft Word', what => 'word', cmd => ['/usr/bin/open', '-a', $a] }; } } else { my $oo = $t->('onlyoffice') || which('onlyoffice-desktopeditors') || which('desktopeditors') || (-x '/opt/onlyoffice/desktopeditors/DesktopEditors' ? '/opt/onlyoffice/desktopeditors/DesktopEditors' : undef); push @l, { id => 'onlyoffice', app => 'ONLYOFFICE', what => 'pdf', cmd => [$oo] } if $oo; if (my $lo = $t->('libreoffice') || which('libreoffice') || which('soffice')) { push @l, { id => 'libreoffice-writer', app => 'LibreOffice Writer', what => 'docx', fmt => 'docx', cmd => [$lo, '--norestore'] }, { id => 'libreoffice-draw', app => 'LibreOffice Draw', what => 'draw', cmd => [$lo, '--norestore'] }; } if (my $tm = $t->('textmaker') || which('textmaker24free') || which('textmaker24') || which('textmaker21free') || which('textmaker21')) { push @l, { id => 'textmaker', app => ($tm =~ /free/ ? 'FreeOffice TextMaker' : 'SoftMaker TextMaker'), what => 'docx', fmt => 'docx', cmd => [$tm] }; } if (my $ao = $t->('openoffice') || which('openoffice4')) { push @l, { id => 'openoffice-writer', app => 'Apache OpenOffice Writer', what => 'docx', fmt => 'docx', cmd => [$ao, '-writer'] }; } if (my $ik = $t->('inkscape') || which('inkscape')) { push @l, { id => 'inkscape', app => 'Inkscape', what => 'graphic', cmd => [$ik] }; } } return @l; } sub office_open { my ($id, $name, $pdf) = @_; my ($c) = grep { $_->{id} eq ($id // '') } office_apps(); return (undef, 'Dieses Programm ist auf diesem Rechner nicht (mehr) installiert.') unless $c; my $docx = ($c->{fmt} // 'pdf') eq 'docx'; my $ext = $docx ? '.docx' : '.pdf'; return (undef, $docx ? 'Kein gültiges Word-Dokument.' : 'Keine gültige PDF-Datei.') unless defined $pdf && substr($pdf, 0, $docx ? 2 : 4) eq ($docx ? 'PK' : '%PDF'); my $dir = "$DIR/office"; make_path($dir); my $base = $name // ''; $base =~ s/\.(pdf|docx)$//i; $base =~ s{[/\\:*?"<>|\x00-\x1f]+}{ }g; $base =~ s/\s+/ /g; $base = trim($base); $base = 'Dokument' unless length $base; $base = substr($base, 0, 80); my $f = "$dir/$base$ext"; for (my $n = 2; -e $f; $n++) { $f = "$dir/$base ($n)$ext"; } open my $h, '>:raw', $f or return (undef, "Datei lässt sich nicht anlegen: $!"); print $h $pdf; close $h; my $k = fork(); return (undef, "$c->{app} ließ sich nicht starten.") unless defined $k; if ($k == 0) { POSIX::setsid(); open STDIN, '<', '/dev/null'; open STDOUT, '>', '/dev/null'; open STDERR, '>', '/dev/null'; exec(@{ $c->{cmd} }, $f) or POSIX::_exit(1); } logl("Office: $base$ext → $c->{app}"); return ($f, undef); } # „In Cloud-Ordner speichern“: Sync-Ordner der Programme, die der Nutzer selbst installiert hat (Nextcloud, ownCloud, Seafile, Dropbox, # OneDrive, Google Drive, iCloud). pdfmy Scan legt die PDF im Unterordner „PDFMy“ ab – hochladen tut das Sync-Programm des Nutzers. sub cloud_folders { my (@l, %seen); my $H = $ENV{HOME} // ''; my $add = sub { my ($id, $name, $p) = @_; return unless defined $p && length $p; $p =~ s{/+$}{}; return unless -d $p && !$seen{$p}++; my $n = grep { $_->{id} eq $id || index($_->{id}, "$id-") == 0 } @l; push @l, { id => $n ? "$id-" . ($n + 1) : $id, name => $name, path => $p }; }; if (my $t = $ENV{PDFMY_TEST_CLOUD}) { for (split /;/, $t) { my ($n, $p) = split /\|/, $_, 2; $add->('test', $n, $p); } return @l; } # nur für Tests my $cfg = sub { # Nextcloud/ownCloud-Programm: „0\Folders\1\localPath=/Users/…/Nextcloud/“ my ($f) = @_; open my $h, '<:encoding(UTF-8)', $f or return (); my @p; while (<$h>) { push @p, $1 if /^\d+\\Folders(?:WithPlaceholders)?\\\d+\\localPath=(.+?)\s*$/; } close $h; return @p; }; my $conf = $IS_MAC ? "$H/Library/Preferences" : ($ENV{XDG_CONFIG_HOME} || "$H/.config"); $add->('nextcloud', 'Nextcloud', $_) for $cfg->("$conf/Nextcloud/nextcloud.cfg"); $add->('owncloud', 'ownCloud', $_) for $cfg->("$conf/ownCloud/owncloud.cfg"); $add->('seafile', 'Seafile', "$H/Seafile"); if (open my $h, '<', "$H/.dropbox/info.json") { # Dropbox: { "personal": { "path": … }, "business": { … } } local $/; my $o = eval { $J->decode(<$h>) } || {}; close $h; for my $k (sort keys %$o) { $add->('dropbox', $k eq 'business' ? 'Dropbox (Firma)' : 'Dropbox', $o->{$k}{path}) if ref $o->{$k} eq 'HASH'; } } if ($IS_MAC) { # macOS: Datei-Anbieter unter ~/Library/CloudStorage (OneDrive, Google Drive, Dropbox, Nextcloud …) und iCloud Drive for my $d (sort glob("'$H/Library/CloudStorage/'*")) { my $b = (split m{/}, $d)[-1]; my ($k) = $b =~ /^([A-Za-z]+)/; next unless $k; my %nm = (OneDrive => 'OneDrive', GoogleDrive => 'Google Drive', Dropbox => 'Dropbox', Nextcloud => 'Nextcloud', ownCloud => 'ownCloud', Box => 'Box', pCloud => 'pCloud'); my $p = $d; if ($k eq 'GoogleDrive') { ($p) = grep { -d } ("$d/My Drive", "$d/Meine Ablage"); $p //= $d; } $add->(lc $k, ($nm{$k} // $k) . ($b =~ /^OneDrive-(?!Personal)(.+)$/ ? " ($1)" : ''), $p); } $add->('icloud', 'iCloud Drive', "$H/Library/Mobile Documents/com~apple~CloudDocs"); } else { $add->('onedrive', 'OneDrive', "$H/OneDrive"); } return @l; } sub cloud_save { my ($id, $name, $pdf) = @_; my ($c) = grep { $_->{id} eq ($id // '') } cloud_folders(); return (undef, 'Diesen Cloud-Ordner gibt es auf diesem Rechner nicht (mehr).') unless $c; return (undef, 'Keine gültige PDF-Datei.') unless defined $pdf && substr($pdf, 0, 4) eq '%PDF'; my $dir = "$c->{path}/PDFMy"; eval { make_path($dir) }; return (undef, "Ordner lässt sich nicht anlegen: $!") unless -d $dir; my $base = $name // ''; $base =~ s/\.pdf$//i; $base =~ s{[/\\:*?"<>|\x00-\x1f]+}{ }g; $base =~ s/\s+/ /g; $base = trim($base); $base =~ s/\.+$//; $base = 'Dokument' unless length $base; $base = substr($base, 0, 120); my $f = "$dir/$base.pdf"; for (my $n = 2; -e $f; $n++) { $f = "$dir/$base ($n).pdf"; } open my $h, '>:raw', $f or return (undef, "Speichern nicht möglich: $!"); print $h $pdf; close $h; logl("Cloud-Ordner: $base.pdf → $c->{name}"); return ($f, undef); } sub cloud_show { # abgelegte Datei im Finder bzw. Dateimanager zeigen – nur eigene Ablagen my ($f) = @_; return unless defined $f && -f $f; return unless grep { index($f, "$_->{path}/PDFMy/") == 0 } cloud_folders(); my @cmd = $IS_MAC ? ('/usr/bin/open', '-R', $f) : ((which('xdg-open') // return), ($f =~ m{^(.*)/[^/]+$})[0]); my $k = fork(); return unless defined $k; if ($k == 0) { POSIX::setsid(); open STDIN, '<', '/dev/null'; open STDOUT, '>', '/dev/null'; open STDERR, '>', '/dev/null'; exec(@cmd) or POSIX::_exit(1); } } # Mac: Eintrag „In PDFMy öffnen“ im PDF-Menü des Druckdialogs (Skript in ~/Library/PDF Services, legt die Einrichtung an) sub print_service { return 'none' unless $IS_MAC; opendir(my $d, "$ENV{HOME}/Library/PDF Services") or return 'off'; my $on = grep { /PDFMy/ } readdir $d; closedir $d; return $on ? 'on' : 'off'; } # Linux: Freedesktop-Standard ~/.config/mimeapps.list, Abschnitt [Default Applications] (gilt für GNOME, KDE, Xfce, Cinnamon …) my $DESKTOP = 'pdfmy.desktop'; sub mimeapps { return ($ENV{XDG_CONFIG_HOME} || "$ENV{HOME}/.config") . '/mimeapps.list'; } sub linux_default { my $f = slurp(mimeapps()) // ''; my $sec = ''; for (split /\n/, $f) { if (/^\s*\[(.+)\]/) { $sec = $1; next; } return (trim((split /;/, $1)[0] // '') eq $DESKTOP ? 1 : 0) if $sec eq 'Default Applications' && /^\s*application\/pdf\s*=\s*(.*)$/; } return 0; } sub linux_set_default { my $p = mimeapps(); my @l = split /\n/, (slurp($p) // ''); my ($sec, $done, @out) = ('', 0); for (@l) { if (/^\s*\[(.+)\]/) { push @out, "application/pdf=$DESKTOP;" if $sec eq 'Default Applications' && !$done++; $sec = $1; push @out, $_; next; } if ($sec eq 'Default Applications' && /^\s*application\/pdf\s*=/) { push @out, "application/pdf=$DESKTOP;" unless $done++; next; } push @out, $_; } push @out, "application/pdf=$DESKTOP;" if $sec eq 'Default Applications' && !$done++; push @out, (@out ? '' : ()), '[Default Applications]', "application/pdf=$DESKTOP;" unless $done; make_path(($p =~ m{^(.*)/[^/]+$})[0]); spit($p, join("\n", @out) . "\n"); return linux_default(); } sub pdf_default { my $c = "$DIR/pdfdefault.txt"; return trim(slurp($c)) eq '1' if -f $c && -M $c < 0.0007; if (!$IS_MAC) { my $is = linux_default(); spit($c, $is); return $is; } return 0 unless -x '/usr/bin/osascript'; my $o = run_for(3, '/usr/bin/osascript', '-l', 'JavaScript', '-e', 'ObjC.import("CoreServices"); ObjC.unwrap($.LSCopyDefaultRoleHandlerForContentType($("com.adobe.pdf"), 0xFFFFFFFF)) || ""'); my $is = lc(trim($o)) eq $BUNDLE ? 1 : 0; spit($c, $is); return $is; } sub set_pdf_default { if (!$IS_MAC) { my $ok = linux_set_default(); spit("$DIR/pdfdefault.txt", $ok); logl('Standard für PDFs setzen (mimeapps.list): ' . ($ok ? 'ok' : 'fehlgeschlagen')); return $ok; } return 0 unless -x '/usr/bin/osascript'; my $o = run_for(5, '/usr/bin/osascript', '-l', 'JavaScript', '-e', qq{ObjC.import("CoreServices"); \$.LSSetDefaultRoleHandlerForContentType(\$("com.adobe.pdf"), 0xFFFFFFFF, \$("$BUNDLE"))}); unlink "$DIR/pdfdefault.txt"; logl('Standard für PDFs setzen: ' . trim($o)); return pdf_default(); } # ---------------- HTTP-Server (nur 127.0.0.1) ---------------- sub send_resp { my ($c, $code, $type, $data, $extra) = @_; my %txt = (200 => 'OK', 204 => 'No Content', 403 => 'Forbidden', 404 => 'Not Found', 409 => 'Conflict'); my $h = "HTTP/1.1 $code " . ($txt{$code} || 'Error') . "\r\n"; $h .= "Content-Type: $type\r\n" if $type; $h .= 'Content-Length: ' . length($data) . "\r\nCache-Control: no-store\r\nConnection: close\r\n"; $h .= "$_: $extra->{$_}\r\n" for sort keys %{ $extra || {} }; print $c $h, "\r\n", $data; } sub send_json { my ($c, $code, $o, $extra) = @_; send_resp($c, $code, 'application/json; charset=utf-8', $J->encode($o), $extra); } # Wem gehört das andere Ende der Verbindung? Auf Rechnern mit mehreren Konten erreichen alle Benutzer 127.0.0.1 – # zugelassen werden nur Prozesse des eigenen Benutzers (KONZEPT.md, 25.10.2026). Nicht ermittelbar → abgelehnt. sub peer_own { my $cport = shift; return 0 unless $cport; if ($IS_MAC) { # lsof sieht als normaler Benutzer nur eigene Prozesse; -a -u schränkt zusätzlich darauf ein my $lsof = -x '/usr/sbin/lsof' ? '/usr/sbin/lsof' : which('lsof'); return 0 unless $lsof; my $o = run_for(5, $lsof, '-nP', '-a', '-u', $<, "-iTCP\@127.0.0.1:$cport", '-sTCP:ESTABLISHED', '-Fn'); return $o =~ /^n127\.0\.0\.1:$cport->127\.0\.0\.1:$PORT$/m ? 1 : 0; } # Linux: /proc/net/tcp nennt zu jeder Verbindung den Besitzer (Spalte uid); gesucht ist das Ende cport → PORT im Zustand ESTABLISHED for my $f ($ENV{PDFMY_TEST_PROC_TCP} // ('/proc/net/tcp', '/proc/net/tcp6')) { open my $h, '<', $f or next; while (<$h>) { my @c = split; next unless @c > 7 && $c[3] eq '01' && $c[1] =~ /:([0-9A-Fa-f]{4})$/ && hex($1) == $cport && $c[2] =~ /:([0-9A-Fa-f]{4})$/ && hex($1) == $PORT; close $h; return $c[7] == $< ? 1 : 0; } close $h; } return 0; } our %CORS_NOW; sub handle { my $c = shift; binmode $c; local $SIG{ALRM} = sub { die "timeout\n" }; alarm 15; my $head = ''; while ($head !~ /\r\n\r\n/) { my $n = sysread($c, my $b, 4096); last unless $n; $head .= $b; return if length $head > 32768; } my ($hdrs, $rest) = split /\r\n\r\n/, $head, 2; $rest //= ''; my ($line, @hl) = split /\r\n/, $hdrs; my ($method, $path) = split / /, $line // ''; return unless $method; my %h; for (@hl) { $h{ lc $1 } = $2 if /^([^:]+):\s*(.*)$/; } # Nur Prozesse des eigenen Benutzers – vor jeder anderen Prüfung, auch für /open-local und OPTIONS if (!peer_own($c->peerport)) { my ($p0) = split /\?/, $path // ''; logl("Abgelehnt: $method $p0 (anderer Benutzer oder nicht ermittelbar)"); send_resp($c, 403, 'text/plain', 'forbidden'); # Rest der Anfrage verwerfen (höchstens 1 MB) und sauber schließen – sonst sieht der Absender statt 403 einen Verbindungsabbruch shutdown($c, 1); alarm 2; my $got = 0; eval { while ($got < 1_000_000) { my $n = sysread($c, my $b, 16384); last unless $n; $got += $n; } }; alarm 0; return; } # Host-Kopf nur die eigene Adresse (Schutz gegen DNS-Rebinding), Herkunft nur PDFMy – geprüft, bevor der Inhalt gelesen wird my $origin = $h{origin}; my $hostok = defined $h{host} && grep { lc $h{host} eq $_ } ("127.0.0.1:$PORT", "localhost:$PORT"); my $ok = $hostok && (!defined $origin || grep { lc $_ eq lc $origin } @ALLOWED); if (!$ok) { alarm 0; return send_resp($c, 403, 'text/plain', 'forbidden'); } my $len = $h{'content-length'} || 0; return if $len > ($path =~ m{^/(office|cloud)$} ? 300_000_000 : 1_000_000); # PDFs für „In Office-Programm öffnen“ dürfen groß sein my $body = $rest; while (length $body < $len) { my $n = sysread($c, my $b, $len - length $body); last unless $n; $body .= $b; } alarm 0; my %cors; if (defined $origin && $ok) { %cors = ('Access-Control-Allow-Origin' => $origin, 'Vary' => 'Origin'); } %CORS_NOW = %cors; if ($method eq 'OPTIONS') { return send_resp($c, 403, 'text/plain', 'forbidden') unless $ok; return send_resp($c, 204, undef, '', { %cors, 'Access-Control-Allow-Methods' => 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers' => 'Content-Type, X-Pdfmy', 'Access-Control-Allow-Private-Network' => 'true', 'Access-Control-Max-Age' => '600' }); } my ($p) = split /\?/, $path; # Doppelklick im Finder: nur vom eigenen Mac (ohne Origin, also nicht aus einer Webseite) if ($p eq '/open-local' && $method eq 'POST' && !defined $origin) { my $path = $body =~ /^\s*\{/ ? (eval { $J->decode($body)->{path} } // '') : $body; $path =~ s/[\r\n]+$//; my $url = open_local($path); return send_json($c, $url ? 200 : 404, $url ? { ok => JSON::PP::true, url => $url } : { error => 'Datei nicht gefunden' }); } # Schutz: nur PDFMy (Origin) und nur mit eigenem Kopf – fremde Seiten können nichts auslösen oder lesen return send_resp($c, 403, 'text/plain', 'forbidden', \%cors) if !$ok || ($p ne '/status' && !exists $h{'x-pdfmy'}); my $in = sub { my $o = eval { $J->decode($body) }; return ref $o eq 'HASH' ? $o : {}; }; if ($p eq '/status') { # PDFMy meldet seine Adresse (pdfmy.de oder Testversion): dorthin öffnet ein Doppelklick die PDF if ($path =~ /[?&]app=([^&]+)/) { my $u = $1; $u =~ s/%([0-9A-Fa-f]{2})/chr hex $1/ge; spit("$DIR/appurl.txt", $u) if grep { $u =~ /^\Q$_\E\// } @ALLOWED; } my $l = devices(); # /status muss sofort antworten: Standard-PDF-Programm nur aus dem Zwischenspeicher, Auffrischen im Hintergrund my $cf = "$DIR/pdfdefault.txt"; my $def = -f $cf && trim(slurp($cf)) eq '1'; if (!-f $cf || -M $cf >= 0.0007) { my $k = fork(); if (defined $k && $k == 0) { pdf_default(); POSIX::_exit(0); } } return send_json($c, 200, { app => 'pdfmy-scan', version => $VERSION, ownUser => JSON::PP::true, platform => lc $OS, tsa => JSON::PP::true, searching => JSON::PP::false, devices => scalar(@$l), printer => print_service(), pdfDefault => $def ? JSON::PP::true : JSON::PP::false }, \%cors); } if ($p =~ m{^/file/(\w+)$} && $method eq 'GET') { my ($data, $name) = take_file($1); return send_json($c, 404, { error => $name }, \%cors) unless defined $data; my $enc = $name; utf8::encode($enc); $enc =~ s/([^A-Za-z0-9._~-])/sprintf '%%%02X', ord $1/ge; return send_resp($c, 200, 'application/pdf', $data, { %cors, 'Access-Control-Expose-Headers' => 'X-Pdfmy-Name', 'X-Pdfmy-Name' => $enc }); } if ($p eq '/attachment' && $method eq 'GET') { # PDFMy durfte eine übergebene Datei nicht lesen (z. B. Outlook-Anhang): hier nachreichen my ($q) = $path =~ /[?&]name=([^&]*)/; $q //= ''; $q =~ s/\+/ /g; $q =~ s/%([0-9A-Fa-f]{2})/chr hex $1/ge; my $f = safe(sub { find_recent($q) }); our %FIND_DENIED; return send_json($c, 404, { error => 'Datei nicht gefunden', denied => %FIND_DENIED ? JSON::PP::true : JSON::PP::false }, \%cors) unless $f; return send_json($c, 413, { error => 'Die Datei ist zu groß (über 300 MB).' }, \%cors) if -s $f > 300 * 1024 * 1024; my ($name) = $f =~ m{([^/]+)$}; my $enc = $name; $enc =~ s/([^A-Za-z0-9._~-])/sprintf '%%%02X', ord $1/ge; return send_resp($c, 200, 'application/octet-stream', slurp($f), { %cors, 'Access-Control-Expose-Headers' => 'X-Pdfmy-Name', 'X-Pdfmy-Name' => $enc }); } if ($p eq '/tsa' && $method eq 'POST') { # Zeitstempel (RFC 3161) für Signaturen: Browser dürfen Zeitstempeldienste nicht direkt abfragen (kein CORS) my $o = $in->(); my $url = $o->{url} // ''; # nur bekannte, öffentliche Zeitstempeldienste – kein offener Proxy (gleiche Liste wie im Windows-Programm) my ($host) = $url =~ m{^https?://([^/:?#]+)(?:/[^\s]*)?$}i; return send_json($c, 400, { error => 'Unbekannter Zeitstempeldienst' }, \%cors) unless $host && grep { lc $host eq $_ } qw(timestamp.digicert.com timestamp.sectigo.com zeitstempel.dfn.de freetsa.org timestamp.globalsign.com ts.ssl.com timestamp.entrust.net timestamp.identrust.com tsa.swisssign.net); my $req = eval { decode_base64($o->{data} // '') } // ''; return send_json($c, 400, { error => 'Ungültige Daten' }, \%cors) if !length $req || length $req > 4096; my ($fh, $qf) = tempfile(DIR => $DIR, SUFFIX => '.tsq', UNLINK => 1); binmode $fh; print $fh $req; close $fh; my ($rh, $rf) = tempfile(DIR => $DIR, SUFFIX => '.tsr', UNLINK => 1); close $rh; my $code = system('curl', '-s', '-f', '-m', '20', '--max-filesize', '65536', '-H', 'Content-Type: application/timestamp-query', '--data-binary', "\@$qf", '-o', $rf, $url); my $d = slurp($rf) // ''; unlink $qf, $rf; if ($code != 0 || !length $d) { logl("Zeitstempel: Dienst $host antwortet nicht"); return send_json($c, 502, { error => "Zeitstempeldienst $host antwortet nicht" }, \%cors); } logl("Zeitstempel von $host (" . length($d) . ' Bytes)'); return send_json($c, 200, { data => encode_base64($d, '') }, \%cors); } if ($p eq '/office' && $method eq 'GET') { return send_json($c, 200, { apps => [ map { +{ id => $_->{id}, app => $_->{app}, what => $_->{what}, fmt => $_->{fmt} // 'pdf' } } office_apps() ] }, \%cors); } # installierte Office-Programme if ($p eq '/office' && $method eq 'POST') { # PDF mit dem gewählten Office-Programm öffnen (Datei bleibt auf dem Rechner) my $o = $in->(); my ($f, $err) = office_open($o->{id}, $o->{name}, eval { decode_base64($o->{data} // '') }); return send_json($c, $err ? 409 : 200, { ok => $err ? JSON::PP::false : JSON::PP::true, error => $err, path => $f }, \%cors); } if ($p eq '/cloud' && $method eq 'GET') { return send_json($c, 200, { folders => [ cloud_folders() ] }, \%cors); } # Sync-Ordner (Nextcloud, Dropbox, OneDrive …) if ($p eq '/cloud' && $method eq 'POST') { # PDF im Unterordner „PDFMy“ des Cloud-Ordners ablegen – hochladen tut das Sync-Programm my $o = $in->(); my ($f, $err) = cloud_save($o->{id}, $o->{name}, eval { decode_base64($o->{data} // '') }); return send_json($c, $err ? 409 : 200, { ok => $err ? JSON::PP::false : JSON::PP::true, error => $err, path => $f }, \%cors); } if ($p eq '/cloud/show' && $method eq 'POST') { my $o = $in->(); cloud_show($o->{path}); return send_json($c, 200, { ok => JSON::PP::true }, \%cors); } if ($p eq '/defaultapps' && $method eq 'POST') { my $ok = set_pdf_default(); return send_json($c, 200, { ok => JSON::PP::true, registered => $ok ? JSON::PP::true : JSON::PP::false, isDefault => $ok ? JSON::PP::true : JSON::PP::false }, \%cors); } if ($p eq '/wake' && $method eq 'POST') { # „Scanner aufwecken“: wecken, kurz warten, neu suchen my $n = safe(sub { wake_known() }) || 0; sleep 3 if $n; my $l = eval { local $ENV{PDFMY_WOKEN} = 1; discover() } || devices(); return send_json($c, 200, { woken => $n, devices => scalar(@$l) }, \%cors); } if ($p eq '/scanners' && $method eq 'GET') { my $l = devices(); my $err; if ($path =~ /refresh=1/ || !@$l) { my $n = eval { discover() }; if ($n) { $l = $n; } else { ($err = "$@") =~ s/\s+$//; logl("Suche fehlgeschlagen: $err"); } } return send_json($c, 200, { searching => JSON::PP::false, ($err ? (error => "Die Scannersuche ist abgebrochen: $err") : ()), scanners => [map { { id => $_->{id}, name => $_->{name}, kind => $_->{kind}, feeder => $_->{feeder} ? JSON::PP::true : JSON::PP::false, duplex => $_->{duplex} ? JSON::PP::true : JSON::PP::false, platen => $_->{platen} ? JSON::PP::true : JSON::PP::false } } @$l] }, \%cors); } if ($p eq '/scanners/add' && $method eq 'POST') { # IPv4, IPv6 (auch [fe80::1%eth0]) oder Gerätename (z. B. EPSON2CBBCB oder EPSON2CBBCB.local) my $ip = trim($in->()->{ip} // ''); $ip =~ s{^\w+://}{}; $ip =~ s{^(\[[^\]]+\][^/]*|[^/]+)/.*$}{$1}; my $port; if ($ip =~ /^\[([^\]]+)\](?::(\d+))?$/) { ($ip, $port) = ($1, $2); } # [IPv6] oder [IPv6]:Port elsif ($ip =~ /^([^:]+):(\d+)$/) { ($ip, $port) = ($1, $2); } # IPv4/Name:Port return send_json($c, 404, { error => 'Bitte eine gültige IP-Adresse oder einen Gerätenamen angeben.' }, \%cors) unless $ip =~ /^[\w.\-]+$/ || $ip =~ /^[0-9a-f:.]+(?:%[\w.\-]+)?$/i; $ip .= "|$port" if $port; my $d = probe_ip($ip); return send_json($c, 404, { error => 'Unter dieser Adresse antwortet kein Scanner (eSCL oder WSD). Bitte am Gerät „AirPrint/eSCL/Mopria“ oder „WSD-Scan“ aktivieren.' . ($IS_MAC ? '' : ' Für USB-Scanner unter Linux: SANE installieren (z. B. sudo apt install sane-utils sane-airscan ipp-usb).') }, \%cors) unless $d; my $a = added_ips(); push @$a, $ip unless grep { $_ eq $ip } @$a; spit("$DIR/added.json", $J->encode($a)); my $l = [grep { $_->{id} ne $d->{id} } @{ devices() }]; push @$l, $d; save_devices($l); logl("Hinzugefügt: $ip → $d->{name} ($d->{kind})"); return send_json($c, 200, { id => $d->{id}, name => $d->{name}, kind => $d->{kind}, feeder => $d->{feeder} ? JSON::PP::true : JSON::PP::false, duplex => $d->{duplex} ? JSON::PP::true : JSON::PP::false }, \%cors); } if ($p eq '/scan' && $method eq 'POST') { my $r = $in->(); my $d = find_dev($r->{id} // ''); return send_json($c, 404, { error => 'Kein Scanner gefunden.' }, \%cors) unless $d; my ($code, $o) = start_scan($d, { source => $r->{source} || 'auto', duplex => $r->{duplex} ? 1 : 0, color => $r->{color} || 'gray', dpi => $r->{dpi} || 300 }); return send_json($c, $code, $o, \%cors); } if ($p =~ m{^/scan/([0-9a-f]{6,16})(?:/page/(\d+))?$}) { my ($id, $idx) = ($1, $2); my $s = job_state($id); return send_json($c, 404, { error => 'Unbekannter Scanauftrag.' }, \%cors) unless $s; return send_json($c, 200, { state => $s->{state}, pages => $s->{pages} + 0, error => $s->{error}, mime => $s->{mime}, detail => $s->{detail}, note => '' }, \%cors) unless defined $idx; my $data = slurp("$DIR/jobs/$id/$idx.bin"); return send_json($c, 404, { error => 'Seite nicht vorhanden.' }, \%cors) unless defined $data; return send_resp($c, 200, $s->{mime} || 'image/jpeg', $data, \%cors); } # net: interne IP-Adressen dieses Rechners – für den Fehlerbericht (Scanner im selben Netz?) if ($p eq '/log') { my @z = split /\n/, decode('UTF-8', slurp("$DIR/log.txt") // ''); return send_json($c, 200, { version => $VERSION, lines => [@z[($#z > 39 ? $#z - 39 : 0) .. $#z]], net => join(', ', local_ipv4()), host => eval { require Sys::Hostname; Sys::Hostname::hostname() } // '' }, \%cors); } if ($p eq '/certs') { return send_json($c, 200, { certs => [] }, \%cors); } return send_json($c, 404, { error => 'Nicht gefunden' }, \%cors); } # Ersten freien Port ab 52880 nehmen: sind mehrere Benutzer angemeldet, hat jeder seinen eigenen pdfmy Scan. # Die Web-App sucht 52880–52889 ab; die Helfer der anderen Benutzer antworten ihr mit 403. my $srv; for my $p ($ENV{PDFMY_PORT} ? ($PORT) : (52880 .. 52889)) { $srv = IO::Socket::INET->new(LocalAddr => '127.0.0.1', LocalPort => $p, Proto => 'tcp', Listen => 20, ReuseAddr => 1) and do { $PORT = $p; last }; } $srv or do { logl("Start nicht möglich (Ports 52880–52889 belegt?): $!"); die "pdfmy Scan: Ports belegt\n"; }; spit("$DIR/port", "$PORT\n"); # Doppelklick-App, Druck-Dienst und Einrichtung lesen hier den eigenen Port logl("pdfmy Scan für $OS $VERSION gestartet (Port $PORT" . ($PORT != 52880 ? ' – 52880 ist belegt, anderer Benutzer oder anderes Programm' : '') . ')'); while (1) { my $c = $srv->accept or next; my $pid = fork(); if (!defined $pid) { eval { handle($c) }; close $c; next; } if ($pid == 0) { close $srv; eval { handle($c) }; # Unerwarteter Fehler: verständliche Antwort statt abgebrochener Verbindung (Browser: „Failed to fetch“) if ($@ && $@ ne "timeout\n") { (my $e = "$@") =~ s/\s+$//; logl("HTTP: $e"); eval { send_json($c, 500, { error => "pdfmy Scan: interner Fehler – $e" }, \%CORS_NOW) }; } close $c; POSIX::_exit(0); } close $c; } PDFMY_PERL_END if ! "$PERL" -c "$DIR/pdfmy-scan.pl" >/dev/null 2>&1; then echo " ✗ Das Programm ist beschädigt. Bitte neu herunterladen."; exit 1; fi ok="" if command -v systemctl >/dev/null 2>&1 && systemctl --user show-environment >/dev/null 2>&1; then cat > "$UNIT" </dev/null 2>&1 systemctl --user restart pdfmy-scan.service STARTER="systemctl --user restart pdfmy-scan.service" else # Ohne systemd (z. B. manche Minimal-Systeme): Start bei der Anmeldung über den Autostart der Arbeitsoberfläche mkdir -p "$CONF/autostart" cat > "$CONF/autostart/pdfmy-scan.desktop" </dev/null 2>&1; sleep 1 nohup "$PERL" "$DIR/pdfmy-scan.pl" >"$DIR/fehler.txt" 2>&1 & STARTER="( nohup $PERL \"$DIR/pdfmy-scan.pl\" >/dev/null 2>&1 & )" fi for i in 1 2 3 4 5 6 7 8; do sleep 1; if curl -s -m 2 "http://127.0.0.1:$(cat "$DIR/port" 2>/dev/null || echo 52880)/status" | grep -q pdfmy-scan; then ok=1; break; fi; done if [ -n "$ok" ]; then echo " ✓ Scan-Dienst läuft (startet künftig mit der Anmeldung)"; else echo " ✗ Der Scan-Dienst startet nicht. Details: journalctl --user -u pdfmy-scan bzw. $DIR/log.txt"; fi # 2. „PDFMy“ im Anwendungsmenü: öffnet PDFs per Doppelklick in PDFMy (die Datei bleibt auf dem Rechner) cat > "$DIR/pdfmy-open" </dev/null || echo 52880)/open-local"; } # eigener Port (bei mehreren angemeldeten Benutzern ggf. 52881–52889) # Als App installiertes PDFMy (Chrome/Edge legen dafür eine .desktop-Datei mit --app-id an): Datei direkt im App-Fenster öffnen pwa() { for d in "\${XDG_DATA_HOME:-\$HOME/.local/share}"/applications/*.desktop; do [ -f "\$d" ] && grep -q '^Name=PDFMy\$' "\$d" && grep -q -- '--app-id=' "\$d" && grep -q 'application/pdf' "\$d" && { printf '%s' "\$d"; return 0; } done return 1 } app=\$(pwa) for f in "\$@"; do p=\$(readlink -f -- "\$f" 2>/dev/null || printf '%s' "\$f") if [ -n "\$app" ]; then e=\$(sed -n 's/^Exec=//p' "\$app" | head -n 1 | sed 's/ %[UuFf]//g') if [ -n "\$e" ]; then sh -c "\$e \"\\\$1\"" pdfmy "\$p" >/dev/null 2>&1 & continue; fi fi r=\$(send "\$p") || { $STARTER; sleep 2; r=\$(send "\$p"); } u=\$(printf '%s' "\$r" | sed -n 's/.*"url":"\\([^"]*\\)".*/\\1/p') if [ -n "\$u" ]; then xdg-open "\$u" >/dev/null 2>&1 & else m="PDFMy konnte die Datei nicht öffnen, weil pdfmy Scan nicht läuft. Bitte die Einrichtung (pdfmy-scan-linux.sh) noch einmal starten." command -v notify-send >/dev/null 2>&1 && notify-send "PDFMy" "\$m" || { command -v zenity >/dev/null 2>&1 && zenity --error --text="\$m"; } || echo "\$m" >&2 fi done OPEN chmod +x "$DIR/pdfmy-open" curl -sf -m 10 -o "$DIR/pdfmy.png" https://pdfmy.de/icons/app-512.png || true cat > "$APPS/pdfmy.desktop" </dev/null 2>&1 && update-desktop-database "$APPS" >/dev/null 2>&1 echo " ✓ „PDFMy“ im Anwendungsmenü und unter „Öffnen mit“ angelegt" # 3. Standard für PDF-Dateien? ans="" if { : /dev/null; then printf " Sollen PDF-Dateien künftig per Doppelklick in PDFMy geöffnet werden? [J/n] "; read -r ans /dev/null 2>&1; then xdg-mime default pdfmy.desktop application/pdf >/dev/null 2>&1; fi curl -s -m 5 -X POST -H 'Origin: https://pdfmy.de' -H 'X-Pdfmy: 1' "http://127.0.0.1:$(cat "$DIR/port" 2>/dev/null || echo 52880)/defaultapps" >/dev/null 2>&1 echo " ✓ PDFMy ist jetzt Ihr Standard für PDF-Dateien (änderbar im Dateimanager: Rechtsklick → Öffnen mit)";; esac # 4. Hinweise zu Scannern echo "" if ! command -v avahi-browse >/dev/null 2>&1; then echo " ℹ Für die automatische Suche nach Netzwerkscannern empfohlen: avahi-utils (Paket „avahi-utils“ bzw. „avahi-tools“)."; fi if ! command -v scanimage >/dev/null 2>&1; then echo " ℹ Netzwerkscanner (eSCL/AirScan, WSD) funktionieren ohne Treiber. Für USB-Scanner und ältere Geräte zusätzlich SANE:" if command -v apt >/dev/null 2>&1; then echo " sudo apt install sane-utils sane-airscan ipp-usb" elif command -v dnf >/dev/null 2>&1; then echo " sudo dnf install sane-backends sane-airscan ipp-usb" elif command -v zypper >/dev/null 2>&1; then echo " sudo zypper install sane-backends sane-airscan ipp-usb" elif command -v pacman >/dev/null 2>&1; then echo " sudo pacman -S sane sane-airscan ipp-usb" else echo " Pakete: sane-backends (scanimage), sane-airscan, ipp-usb"; fi fi echo "" if [ -n "$ok" ]; then echo " Fertig. In PDFMy heißt der Knopf oben jetzt „Scannen“." if [ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]; then xdg-open "https://pdfmy.de/" >/dev/null 2>&1 & fi fi echo " Entfernen: sh pdfmy-scan-linux-entfernen.sh (von pdfmy.de/scanner.html#linux)" echo ""