diff --git a/js/qz-tray.js b/js/qz-tray.js index 3dcfb884d..e68006465 100644 --- a/js/qz-tray.js +++ b/js/qz-tray.js @@ -1369,6 +1369,39 @@ var qz = (function() { return _qz.websocket.dataPromise('printers.find', { query: query }, signature, signingTimestamp); }, + /** + * @param {Object} [server] Server object returned from qz.printers.addServer(...) + * @param {string} [query] Search for a specific printer. All printers are returned if not provided. + * + * @returns {Promise|Object|Error>} The matched printer object if query is provided. + * Otherwise an array of printer objects names found on the remote system. + * + * @since 2.2.6 + * @see qz.printers.addServer + * + * @memberof qz.printers + */ + findRemote: function(server, query) { + return _qz.websocket.dataPromise('printers.findRemote', { server: server, query: query }); + }, + + /** + * @param {Object} [options] + * @param {string} [options.uri=null] Address of the remote ipp server. Defaults to localhost if not provided. + * @param {string} [options.username=null] Username for ipp connection + * @param {string} [options.password=null] Password for ipp connection + * + * + * @returns {Promise|Error>} Remote server connection info + * + * @since 2.2.6 + * + * @memberof qz.printers + */ + addServer: function(options) { + return _qz.websocket.dataPromise('printers.addServer', options); + }, + /** * Provides a list, with additional information, for each printer available to QZ. * diff --git a/lib/communication/ipp-client-3.2.jar b/lib/communication/ipp-client-3.2.jar new file mode 100644 index 000000000..46e84372a Binary files /dev/null and b/lib/communication/ipp-client-3.2.jar differ diff --git a/lib/kotlin-stdlib-2.0.0.jar b/lib/kotlin-stdlib-2.0.0.jar new file mode 100644 index 000000000..9a89ae1c1 Binary files /dev/null and b/lib/kotlin-stdlib-2.0.0.jar differ diff --git a/src/qz/common/SecurityInfo.java b/src/qz/common/SecurityInfo.java index 4533f53c0..10a9903a4 100644 --- a/src/qz/common/SecurityInfo.java +++ b/src/qz/common/SecurityInfo.java @@ -107,12 +107,12 @@ public static SortedMap getLibVersions() { // Fallback to maven manifest information HashMap mavenVersions = getMavenVersions(); - String[] mavenLibs = {"jetty-servlet", "jetty-io", "websocket-common", + String[] mavenLibs = {"jetty-servlet", "jetty-io", "websocket-common", "kotlin-stdlib", "usb4java-javax", "java-semver", "commons-pool2", "websocket-server", "jettison", "commons-codec", "log4j-api", "log4j-core", "websocket-servlet", "jetty-http", "commons-lang3", "javax-websocket-server-impl", "javax.servlet-api", "hid4java", "usb4java", "websocket-api", "jetty-util", "websocket-client", - "javax.websocket-api", "commons-io", "jetty-security"}; + "javax.websocket-api", "commons-io", "jetty-security", "ipp-client-kotlin"}; for(String lib : mavenLibs) { libVersions.put(lib, mavenVersions.get(lib)); diff --git a/src/qz/printer/PrintOutput.java b/src/qz/printer/PrintOutput.java index 50b464565..91b18ab7f 100644 --- a/src/qz/printer/PrintOutput.java +++ b/src/qz/printer/PrintOutput.java @@ -2,13 +2,16 @@ import org.codehaus.jettison.json.JSONException; import org.codehaus.jettison.json.JSONObject; +import qz.printer.action.ipp.Ipp; import qz.printer.info.NativePrinter; import qz.utils.FileUtilities; import javax.print.PrintService; import javax.print.attribute.standard.Media; import java.io.File; +import java.net.URI; import java.nio.file.Paths; +import java.util.UUID; public class PrintOutput { @@ -17,6 +20,9 @@ public class PrintOutput { private File file = null; private String host = null; + private UUID serverUuid = null; + private URI printerUri = null; + private Ipp.ServerEntry server; private int port = -1; @@ -30,6 +36,12 @@ public PrintOutput(JSONObject configPrinter) throws JSONException, IllegalArgume } } + if (configPrinter.has("serverUuid")) { + serverUuid = UUID.fromString(configPrinter.getString("serverUuid")); + //todo basic error handeling. make this error helpful + printerUri = URI.create(configPrinter.getString("uri")); + } + if (configPrinter.has("file")) { String filename = configPrinter.getString("file"); if (!FileUtilities.isGoodExtension(Paths.get(filename))) { @@ -77,10 +89,22 @@ public boolean isSetHost() { return host != null; } + public boolean isRemoteIpp() { + return serverUuid != null; + } + public String getHost() { return host; } + public UUID getServerUuid() { + return serverUuid; + } + + public URI getPrinterUri() { + return printerUri; + } + public int getPort() { return port; } @@ -89,4 +113,11 @@ public Media[] getSupportedMedia() { return (Media[])getPrintService().getSupportedAttributeValues(Media.class, null, null); } + public Ipp.ServerEntry getServer() { + return server; + } + + public void setServer(Ipp.ServerEntry server) { + this.server = server; + } } diff --git a/src/qz/printer/action/PrintIPP.java b/src/qz/printer/action/PrintIPP.java new file mode 100644 index 000000000..fe6581a5e --- /dev/null +++ b/src/qz/printer/action/PrintIPP.java @@ -0,0 +1,85 @@ +package qz.printer.action; + +import de.gmuth.ipp.attributes.TemplateAttributes; +import de.gmuth.ipp.client.CupsClient; +import de.gmuth.ipp.client.IppClient; +import de.gmuth.ipp.client.IppJob; +import de.gmuth.ipp.client.IppPrinter; +import org.codehaus.jettison.json.JSONArray; +import org.codehaus.jettison.json.JSONException; +import qz.printer.action.ipp.Ipp; +import qz.printer.PrintOptions; +import qz.printer.PrintOutput; +import qz.utils.PrintingUtilities; + +import javax.print.PrintException; +import java.awt.print.PrinterException; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; + +public class PrintIPP implements PrintProcessor{ + private JSONArray data; + @Override + public PrintingUtilities.Format getFormat() { + return PrintingUtilities.Format.IPP; + } + + @Override + public void parseData(JSONArray printData, PrintOptions options) throws UnsupportedOperationException { + data = printData; + } + + @Override + public void print(PrintOutput output, PrintOptions options) throws PrintException, PrinterException { + URI requestedUri = output.getPrinterUri(); + + IppClient ippClient = new IppClient(); + Ipp.ServerEntry serverEntry = output.getServer(); + CupsClient cupsClient = new CupsClient(serverEntry.serverUri, ippClient); + + // requestedUri is user provided, we must make sure it belongs to the claimed server + if(!serverEntry.serverUri.getScheme().equals(requestedUri.getScheme()) || + !serverEntry.serverUri.getAuthority().equals(requestedUri.getAuthority())) { + throw new PrinterException(serverEntry.serverUri + " Is not a printer of the server " + requestedUri); + + } + + //todo: this would also be a good time to raise a prompt + + IppPrinter ippPrinter = new IppPrinter(requestedUri.toString()); + + // todo: match this to PrintServiceMatcher.getPrintersJSON syntax + if (!serverEntry.uname.isEmpty() && !serverEntry.pass.isEmpty()) { + cupsClient.basicAuth(serverEntry.uname, serverEntry.pass); + } + + // todo: for testing, assume all data is just plaintext. There are a lot of things to discuss about filetype and format. + + IppJob job = ippPrinter.createJob(TemplateAttributes.jobName("test")); + String dataString = null; + try { + dataString = data.getJSONObject(0).getString("data"); + } + catch(JSONException e) { + throw new RuntimeException(e); + } + + byte[] dataBytes = dataString.getBytes(StandardCharsets.UTF_8); + try (InputStream in = new ByteArrayInputStream(dataBytes)) { + job.sendDocument(in); + } + catch(IOException e) { + throw new RuntimeException(e); + } + // todo: I assume we wait? + job.waitForTermination(); + } + + @Override + public void cleanup() { + + } +} diff --git a/src/qz/printer/action/ProcessorFactory.java b/src/qz/printer/action/ProcessorFactory.java index 73164b69a..0889cf07e 100644 --- a/src/qz/printer/action/ProcessorFactory.java +++ b/src/qz/printer/action/ProcessorFactory.java @@ -15,6 +15,7 @@ public PooledObject makeObject(PrintingUtilities.Format key) thr case IMAGE: processor = new PrintImage(); break; case PDF: processor = new PrintPDF(); break; case DIRECT: processor = new PrintDirect(); break; + case IPP: processor = new PrintIPP(); break; case COMMAND: default: processor = new PrintRaw(); break; } diff --git a/src/qz/printer/action/ipp/Ipp.java b/src/qz/printer/action/ipp/Ipp.java new file mode 100644 index 000000000..864b6f2df --- /dev/null +++ b/src/qz/printer/action/ipp/Ipp.java @@ -0,0 +1,244 @@ +package qz.printer.action.ipp; + +import de.gmuth.ipp.attributes.TemplateAttributes; +import de.gmuth.ipp.client.CupsClient; +import de.gmuth.ipp.client.IppClient; +import de.gmuth.ipp.client.IppJob; +import de.gmuth.ipp.client.IppPrinter; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.codehaus.jettison.json.JSONArray; +import org.codehaus.jettison.json.JSONException; +import org.codehaus.jettison.json.JSONObject; +import org.eclipse.jetty.websocket.api.Session; + +import java.awt.print.PrinterException; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.util.*; + +public class Ipp { + private static final Logger log = LogManager.getLogger(Ipp.class); + private static final HashMap> servers = new HashMap<>(); + private static final String[] SUPPORTED_SCHEMES = { "ipp", "ipps", "http", "https" }; + + public enum Type { + IPP_SERVER("ipp-server"), + IPP_PRINTER("ipp-printer"), + UNKNOWN("ipp-unknown"); + + private String id; + + Type(String id) { + this.id = id; + } + + public String getId() { + return id; + } + + public static Type parse(String id) { + for(Type type : values()) { + if(type.name().equalsIgnoreCase(id)) { + return type; + } + } + return UNKNOWN; + } + } + + public static JSONObject addServer(Session session, JSONObject params) throws JSONException, URISyntaxException { + String serverUriString = params.optString("uri", ""); + URI uri; + try { + // TODO: Add URI allow list support + uri = sanitizeUri(schemeFilter(URI.create(serverUriString))); + } catch(IllegalArgumentException e) { + if(e.getCause() instanceof URISyntaxException) { + throw (URISyntaxException)e.getCause(); + } else { + throw e; + } + } + + ServerEntry server = new ServerEntry(uri, params.optString("username", ""), params.optString("password", "")); + if (!servers.containsKey(session)) { + servers.put(session, new HashMap<>()); + } + + // If we already have this server, just do a reverse lookup and give them the old UUID + if (servers.get(session).containsValue(server)) { + for (Map.Entry entry : servers.get(session).entrySet()) { + if (entry.getValue().equals(server)) { + return makeJson(Type.IPP_SERVER, entry.getKey().toString(), uri); + } + } + } + + // Otherwise, slap a new UUID on the server and send the UUID to the user + UUID serverId = UUID.randomUUID(); + servers.get(session).put(serverId, server); + return makeJson(Type.IPP_SERVER, serverId.toString(), uri); + } + + public static void print(Session session, String UID, JSONObject params) throws JSONException, IOException { + log.warn(params); + JSONArray printData = params.getJSONArray("data"); + JSONObject printer = params.optJSONObject("printer"); + JSONObject options = params.optJSONObject("options"); + + UUID uuid = UUID.fromString(printer.getString("serverUuid")); + URI requestedUri = URI.create(printer.optString("uri")); + + IppClient ippClient = new IppClient(); + ServerEntry serverEntry = servers.get(session).get(uuid); + CupsClient cupsClient = new CupsClient(serverEntry.serverUri, ippClient); + + // requestedUri is user provided, we must make sure it belongs to the claimed server + if(!serverEntry.serverUri.getScheme().equals(requestedUri.getScheme()) || + !serverEntry.serverUri.getAuthority().equals(requestedUri.getAuthority())) { + throw new UnknownHostException(serverEntry.serverUri + " Is not a printer of the server " + requestedUri); + } + + //todo: this would also be a good time to raise a prompt + + IppPrinter ippPrinter = new IppPrinter(requestedUri.toString()); + + // todo: match this to PrintServiceMatcher.getPrintersJSON syntax + if (!serverEntry.uname.isEmpty() && !serverEntry.pass.isEmpty()) { + cupsClient.basicAuth(serverEntry.uname, serverEntry.pass); + } + + // todo: for testing, assume all data is just plaintext. There are a lot of things to discuss about filetype and format. + + IppJob job = ippPrinter.createJob(TemplateAttributes.jobName("test")); + String dataString = printData.getJSONObject(0).getString("data"); + + byte[] dataBytes = dataString.getBytes(StandardCharsets.UTF_8); + try (InputStream in = new ByteArrayInputStream(dataBytes)) { + job.sendDocument(in); + } + // todo: I assume we wait? + job.waitForTermination(); + } + + public static Object find(Session session, JSONObject params) throws PrinterException, JSONException { + JSONObject server = params.getJSONObject("server"); + Type type = Type.parse(server.getString("type")); + switch(type) { + case IPP_SERVER: + return findRemote(session, + server.getString("uuid"), + params.optString("query", "") + ); + default: + throw new UnsupportedOperationException("Type " + type + " is not yet supported."); + } + } + + public static Object findRemote(Session session, String uuidString, String query) throws PrinterException, JSONException { + HashMap serverEntrySet = servers.get(session); + if(serverEntrySet == null) throw new PrinterException("A server must be added before attempting to find a printer"); + + UUID uuid = UUID.fromString(uuidString); + ServerEntry serverEntry = serverEntrySet.get(uuid); + // The client has sent us a UUID for a server that doesn't exist. This could occur if a server is used from an earlier connection. + if (serverEntry == null) throw new PrinterException("Server object is invalid or from a different connection"); + + IppClient ippClient = new IppClient(); + CupsClient cupsClient = new CupsClient(serverEntry.serverUri, ippClient); + //cupsClient.setUserName(server.uname); // Requesting username, may not be needed + + if (!serverEntry.uname.isEmpty() && !serverEntry.pass.isEmpty()) { + cupsClient.basicAuth(serverEntry.uname, serverEntry.pass); + } + + // If there is no query, list all printers + if (query.isEmpty()) { + JSONArray names = new JSONArray(); + for(IppPrinter p: cupsClient.getPrinters()) { + names.put(makeJson(p, uuid)); + } + + return names; + } else { + return makeJson(cupsClient.getPrinter(query), uuid); + } + } + + public static JSONObject makeJson(IppPrinter printer, UUID uuid) throws JSONException { + return new JSONObject() + .put("name", printer.getName()) + .put("uri", printer.getPrinterUri()) + .put("info", printer.getInfo()) + .put("uuid", uuid.toString() + ); + } + + public static JSONObject makeJson(Type type, String uuid, URI uri) throws JSONException { + return new JSONObject() + .put("type", type.getId()) + .put("uuid", uuid) + .put("uri", uri.toString() + ); + } + + + + public static ServerEntry getServerEntry(Session session, UUID uuid) { + return servers.get(session).get(uuid); + } + + public static class ServerEntry { + public final URI serverUri; + public final String uname; + public final String pass; + + ServerEntry(URI serverUri, String uname, String pass) { + this.serverUri = serverUri; + this.uname = uname; + this.pass = pass; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof ServerEntry)) return false; + ServerEntry that = (ServerEntry)o; + return serverUri.equals(that.serverUri) && + uname.equals(that.uname) && + pass.equals(that.pass); + } + + @Override + public int hashCode() { + return Objects.hash(serverUri, uname, pass); + } + } + + /** + * The IPP protocol prohibits query parameters and anchor links, strip them out + */ + public static URI sanitizeUri(URI uri) throws URISyntaxException { + return new URI( + uri.getScheme(), + uri.getAuthority(), + uri.getPath() + ); + } + + public static URI schemeFilter(URI uri) throws URISyntaxException { + String scheme = uri.getScheme(); + if(scheme != null) { + for(String supportedScheme : SUPPORTED_SCHEMES) { + if (supportedScheme.equalsIgnoreCase(scheme)) { + return uri; + } + } + } + throw new URISyntaxException(uri.toString(), "URI contains an invalid or blank scheme"); + } +} \ No newline at end of file diff --git a/src/qz/utils/PrintingUtilities.java b/src/qz/utils/PrintingUtilities.java index 061543cc3..38b97939e 100644 --- a/src/qz/utils/PrintingUtilities.java +++ b/src/qz/utils/PrintingUtilities.java @@ -10,6 +10,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import qz.common.Constants; +import qz.printer.action.ipp.Ipp; import qz.communication.WinspoolEx; import qz.printer.PrintOptions; import qz.printer.PrintOutput; @@ -42,7 +43,7 @@ public enum Type { } public enum Format { - COMMAND, DIRECT, HTML, IMAGE, PDF + COMMAND, DIRECT, HTML, IMAGE, IPP, PDF } /** @@ -195,6 +196,7 @@ public static void processPrintRequest(Session session, String UID, JSONObject p JSONObject firstData = printData.optJSONObject(0); Type type = getPrintType(firstData); Format format = getPrintFormat(type, firstData); + if (params.optJSONObject("printer").has("serverUuid")) format = Format.IPP; PrintProcessor processor = PrintingUtilities.getPrintProcessor(format); log.debug("Using {} to print", processor.getClass().getName()); @@ -203,6 +205,10 @@ public static void processPrintRequest(Session session, String UID, JSONObject p PrintOutput output = new PrintOutput(params.optJSONObject("printer")); PrintOptions options = new PrintOptions(params.optJSONObject("options"), output, format); + if (output.isRemoteIpp()) { + output.setServer(Ipp.getServerEntry(session, output.getServerUuid())); + } + if(type != Type.RAW && !output.isSetService()) { throw new Exception(String.format("%s cannot print to a raw %s", type, output.isSetFile() ? "file" : "host")); } diff --git a/src/qz/ws/PrintSocketClient.java b/src/qz/ws/PrintSocketClient.java index d213ec1f5..5aded7f6f 100644 --- a/src/qz/ws/PrintSocketClient.java +++ b/src/qz/ws/PrintSocketClient.java @@ -19,6 +19,7 @@ import qz.common.TrayManager; import qz.communication.*; import qz.printer.PrintServiceMatcher; +import qz.printer.action.ipp.Ipp; import qz.printer.status.StatusMonitor; import qz.utils.*; import qz.ws.substitutions.Substitutions; @@ -29,6 +30,7 @@ import java.io.IOException; import java.io.Reader; import java.net.InetSocketAddress; +import java.net.URISyntaxException; import java.nio.channels.ClosedChannelException; import java.nio.file.*; import java.security.cert.CertificateException; @@ -182,6 +184,10 @@ public void onMessage(Session session, Reader reader) throws IOException { log.error("FileIO exception occurred", e); sendError(session, tUID, String.format("FileIO exception occurred: %s: %s", e.getClass().getSimpleName(), e.getMessage())); } + catch(URISyntaxException e) { + log.error("Invalid URI syntax", e); + sendError(session, tUID, String.format("Invalid URI syntax: %s: %s", e.getClass().getSimpleName(), e.getMessage())); + } catch(Exception e) { log.error("Problem processing message", e); sendError(session, tUID, e); @@ -225,7 +231,7 @@ private boolean validSignature(Certificate certificate, JSONObject message) thro * @param session WebSocket session * @param json JSON received from web API */ - private void processMessage(Session session, JSONObject json, SocketConnection connection, RequestState request) throws JSONException, SerialPortException, DeviceException, IOException { + private void processMessage(Session session, JSONObject json, SocketConnection connection, RequestState request) throws JSONException, SerialPortException, DeviceException, IOException, URISyntaxException { // perform client-side substitutions if(Substitutions.areActive()) { Substitutions substitutions = Substitutions.getInstance(); @@ -295,6 +301,12 @@ private void processMessage(Session session, JSONObject json, SocketConnection c sendResult(session, UID, names); } break; + case PRINTERS_FIND_REMOTE: + sendResult(session, UID, Ipp.find(session, params)); + break; + case PRINTERS_ADD_SERVER: + sendResult(session, UID, Ipp.addServer(session, params)); + break; case PRINTERS_DETAIL: sendResult(session, UID, PrintServiceMatcher.getPrintersJSON(true)); break; diff --git a/src/qz/ws/SocketConnection.java b/src/qz/ws/SocketConnection.java index 9e4e1c079..6cd10123f 100644 --- a/src/qz/ws/SocketConnection.java +++ b/src/qz/ws/SocketConnection.java @@ -5,12 +5,15 @@ import org.apache.logging.log4j.Logger; import qz.auth.Certificate; import qz.communication.*; +import qz.printer.action.ipp.Ipp; import qz.printer.status.StatusMonitor; import qz.utils.FileWatcher; import java.io.IOException; import java.nio.file.Path; import java.util.HashMap; +import java.util.Map; +import java.util.UUID; public class SocketConnection { @@ -58,7 +61,6 @@ public void removeSerialPort(String port) { openSerialPorts.remove(port); } - public void addNetworkSocket(String location, SocketIO io) { openNetworkSockets.put(location, io); } diff --git a/src/qz/ws/SocketMethod.java b/src/qz/ws/SocketMethod.java index e2e2e0558..ab5ff89d4 100644 --- a/src/qz/ws/SocketMethod.java +++ b/src/qz/ws/SocketMethod.java @@ -3,6 +3,8 @@ public enum SocketMethod { PRINTERS_GET_DEFAULT("printers.getDefault", true, "access connected printers"), PRINTERS_FIND("printers.find", true, "access connected printers"), + PRINTERS_FIND_REMOTE("printers.findRemote", true, "access remote IPP print server"), + PRINTERS_ADD_SERVER("printers.addServer", true, "access remote IPP print server"), PRINTERS_DETAIL("printers.detail", true, "access connected printers"), PRINTERS_START_LISTENING("printers.startListening", true, "listen for printer status"), PRINTERS_CLEAR_QUEUE("printers.clearQueue", true, "cancel all pending jobs for a given printer"), diff --git a/test.html b/test.html new file mode 100644 index 000000000..6974e1699 --- /dev/null +++ b/test.html @@ -0,0 +1,44 @@ + + + + + QZ Tray Print Test + + + + + + + + +