diff --git a/src/qz/App.java b/src/qz/App.java index 39f31a81e..8d4c38904 100644 --- a/src/qz/App.java +++ b/src/qz/App.java @@ -21,6 +21,7 @@ import qz.utils.*; import qz.ws.PrintSocketServer; import qz.ws.SingleInstanceChecker; +import qz.ws.substitutions.Substitutions; import java.io.File; import java.security.cert.X509Certificate; @@ -43,6 +44,8 @@ public static void main(String ... args) { log.info(Constants.ABOUT_TITLE + " vendor: {}", Constants.ABOUT_COMPANY); log.info("Java version: {}", Constants.JAVA_VERSION.toString()); log.info("Java vendor: {}", Constants.JAVA_VENDOR); + Substitutions.setEnabled(PrefsSearch.getBoolean(ArgValue.SECURITY_SUBSTITUTIONS_ENABLE)); + Substitutions.setStrict(PrefsSearch.getBoolean(ArgValue.SECURITY_SUBSTITUTIONS_STRICT)); CertificateManager certManager = null; try { diff --git a/src/qz/common/TrayManager.java b/src/qz/common/TrayManager.java index edb860381..ba1ac2b53 100644 --- a/src/qz/common/TrayManager.java +++ b/src/qz/common/TrayManager.java @@ -26,6 +26,7 @@ import qz.utils.*; import qz.ws.PrintSocketServer; import qz.ws.SingleInstanceChecker; +import qz.ws.substitutions.Substitutions; import javax.swing.*; import java.awt.*; @@ -98,6 +99,9 @@ public TrayManager(boolean isHeadless) { // Set strict certificate mode preference Certificate.setTrustBuiltIn(!getPref(TRAY_STRICTMODE)); + // Configures JSON websocket messages + Substitutions.getInstance(); + // Set FileIO security FileUtilities.setFileIoEnabled(getPref(SECURITY_FILE_ENABLED)); FileUtilities.setFileIoStrict(getPref(SECURITY_FILE_STRICT)); diff --git a/src/qz/ui/AboutDialog.java b/src/qz/ui/AboutDialog.java index df1bce22b..e9ea8d696 100644 --- a/src/qz/ui/AboutDialog.java +++ b/src/qz/ui/AboutDialog.java @@ -9,18 +9,27 @@ import qz.ui.component.EmLabel; import qz.ui.component.IconCache; import qz.ui.component.LinkLabel; +import qz.utils.FileUtilities; +import qz.utils.SystemUtilities; import qz.ws.PrintSocketServer; +import qz.ws.substitutions.Substitutions; import javax.swing.*; +import javax.swing.border.Border; import javax.swing.border.EmptyBorder; import java.awt.*; -import java.awt.event.ActionEvent; -import java.awt.event.ActionListener; -import java.awt.font.TextAttribute; +import java.awt.datatransfer.DataFlavor; +import java.awt.dnd.*; +import java.io.File; import java.net.URI; import java.net.URL; -import java.util.HashMap; -import java.util.Map; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.List; +import java.util.Timer; +import java.util.TimerTask; +import java.util.concurrent.atomic.AtomicBoolean; /** * Created by Tres on 2/26/2015. @@ -29,16 +38,17 @@ public class AboutDialog extends BasicDialog implements Themeable { private static final Logger log = LogManager.getLogger(AboutDialog.class); - + private final boolean limitedDisplay; private Server server; - - private boolean limitedDisplay; - private JLabel lblUpdate; private JButton updateButton; + private JPanel contentPanel; + private JToolBar headerBar; + private Border dropBorder; + // Use allows word wrapping on a standard JLabel - class TextWrapLabel extends JLabel { + static class TextWrapLabel extends JLabel { TextWrapLabel(String text) { super("" + text + ""); } @@ -63,18 +73,10 @@ public void initComponents() { JPanel infoPanel = new JPanel(); infoPanel.setLayout(new BoxLayout(infoPanel, BoxLayout.Y_AXIS)); - LinkLabel linkLibrary = new LinkLabel("Detailed library information"); - if(server != null && server.isRunning() && !server.isStopping()) { - // Some OSs (e.g. FreeBSD) return null for server.getURI(), fallback to sane values - URI uri = server.getURI(); - String scheme = uri == null ? "http" : uri.getScheme(); - int port = uri == null ? PrintSocketServer.getInsecurePortInUse(): uri.getPort(); - linkLibrary.setLinkLocation(String.format("%s://%s:%s", scheme, AboutInfo.getPreferredHostname(), port)); - } + LinkLabel linkLibrary = getLinkLibrary(); Box versionBox = Box.createHorizontalBox(); versionBox.setAlignmentX(Component.LEFT_ALIGNMENT); - versionBox.add(new JLabel(String.format("%s (Java)", Constants.VERSION.toString()))); - + versionBox.add(new JLabel(String.format("%s (Java)", Constants.VERSION))); JPanel aboutPanel = new JPanel(new FlowLayout(FlowLayout.CENTER)); JLabel logo = new JLabel(getIcon(IconCache.Icon.LOGO_ICON)); @@ -126,30 +128,120 @@ public void initComponents() { aboutPanel.add(infoPanel); - JPanel panel = new JPanel(); - panel.setLayout(new BoxLayout(panel, BoxLayout.PAGE_AXIS)); - panel.add(aboutPanel); - panel.add(new JSeparator()); + contentPanel = new JPanel(); + contentPanel.setLayout(new BoxLayout(contentPanel, BoxLayout.PAGE_AXIS)); + contentPanel.add(aboutPanel); + contentPanel.add(new JSeparator()); if (!limitedDisplay) { - LinkLabel lblLicensing = new LinkLabel("Licensing Information", 0.9f, false); - lblLicensing.setLinkLocation(Constants.ABOUT_LICENSING_URL); + contentPanel.add(getSupportPanel()); + } - LinkLabel lblSupport = new LinkLabel("Support Information", 0.9f, false); - lblSupport.setLinkLocation(Constants.ABOUT_SUPPORT_URL); + setContent(contentPanel, true); + contentPanel.setDropTarget(createDropTarget()); + setHeader(headerBar = getHeaderBar()); + refreshHeader(); + } + + private static JPanel getSupportPanel() { + LinkLabel lblLicensing = new LinkLabel("Licensing Information", 0.9f, false); + lblLicensing.setLinkLocation(Constants.ABOUT_LICENSING_URL); - LinkLabel lblPrivacy = new LinkLabel("Privacy Policy", 0.9f, false); - lblPrivacy.setLinkLocation(Constants.ABOUT_PRIVACY_URL); + LinkLabel lblSupport = new LinkLabel("Support Information", 0.9f, false); + lblSupport.setLinkLocation(Constants.ABOUT_SUPPORT_URL); - JPanel supportPanel = new JPanel(new FlowLayout(FlowLayout.CENTER, 80, 10)); - supportPanel.add(lblLicensing); - supportPanel.add(lblSupport); - supportPanel.add(lblPrivacy); + LinkLabel lblPrivacy = new LinkLabel("Privacy Policy", 0.9f, false); + lblPrivacy.setLinkLocation(Constants.ABOUT_PRIVACY_URL); - panel.add(supportPanel); + JPanel supportPanel = new JPanel(new FlowLayout(FlowLayout.CENTER, 80, 10)); + supportPanel.add(lblLicensing); + supportPanel.add(lblSupport); + supportPanel.add(lblPrivacy); + return supportPanel; + } + + private LinkLabel getLinkLibrary() { + LinkLabel linkLibrary = new LinkLabel("Detailed library information"); + if(server != null && server.isRunning() && !server.isStopping()) { + // Some OSs (e.g. FreeBSD) return null for server.getURI(), fallback to sane values + URI uri = server.getURI(); + String scheme = uri == null ? "http" : uri.getScheme(); + int port = uri == null ? PrintSocketServer.getInsecurePortInUse(): uri.getPort(); + linkLibrary.setLinkLocation(String.format("%s://%s:%s", scheme, AboutInfo.getPreferredHostname(), port)); } + return linkLibrary; + } + + private JToolBar getHeaderBar() { + JToolBar headerBar = new JToolBar(); + headerBar.setBorderPainted(false); + headerBar.setLayout(new FlowLayout()); + headerBar.setOpaque(true); + headerBar.setFloatable(false); + + LinkLabel substitutionsLabel = new LinkLabel("Substitutions are in effect for this machine"); + JButton refreshButton = new JButton("", getIcon(IconCache.Icon.RELOAD_ICON)); + refreshButton.setOpaque(false); + refreshButton.addActionListener(e -> { + Substitutions.getInstance(true); + refreshHeader(); + }); + + substitutionsLabel.setLinkLocation(FileUtilities.SHARED_DIR.toFile()); + + headerBar.add(substitutionsLabel); + headerBar.add(refreshButton); + return headerBar; + } - setContent(panel, true); + private DropTarget createDropTarget() { + return new DropTarget() { + public synchronized void drop(DropTargetDropEvent evt) { + processDroppedFile(evt); + } + + @Override + public synchronized void dragEnter(DropTargetDragEvent dtde) { + super.dragEnter(dtde); + setDropBorder(true); + } + + @Override + public synchronized void dragExit(DropTargetEvent dte) { + super.dragExit(dte); + setDropBorder(false); + } + }; + } + + private void processDroppedFile(DropTargetDropEvent evt) { + try { + evt.acceptDrop(DnDConstants.ACTION_COPY); + Object dropped = evt.getTransferable().getTransferData(DataFlavor.javaFileListFlavor); + if(dropped instanceof List) { + List droppedFiles = (List)dropped; + for (File file : droppedFiles) { + if(file.getName().equals(Substitutions.FILE_NAME)) { + blinkDropBorder(true); + log.info("File drop accepted: {}", file); + Path source = file.toPath(); + Path dest = FileUtilities.SHARED_DIR.resolve(file.getName()); + Files.copy(source, dest, StandardCopyOption.REPLACE_EXISTING); + FileUtilities.inheritParentPermissions(dest); + Substitutions.getInstance(true); + refreshHeader(); + break; + } else { + blinkDropBorder(false); + break; + } + } + } + evt.dropComplete(true); + } catch (Exception ex) { + log.warn(ex); + } + setDropBorder(false); } private void checkForUpdate() { @@ -157,12 +249,12 @@ private void checkForUpdate() { if (latestVersion.greaterThan(Constants.VERSION)) { lblUpdate.setText("An update is available:"); - updateButton.setText("Download " + latestVersion.toString()); + updateButton.setText("Download " + latestVersion); updateButton.setVisible(true); } else if (latestVersion.lessThan(Constants.VERSION)) { lblUpdate.setText("You are on a beta release."); - updateButton.setText("Revert to stable " + latestVersion.toString()); + updateButton.setText("Revert to stable " + latestVersion); updateButton.setVisible(true); } else { lblUpdate.setText("You have the latest version."); @@ -171,6 +263,43 @@ private void checkForUpdate() { } } + private void setDropBorder(boolean isShown) { + if(isShown) { + if(contentPanel.getBorder() == null) { + dropBorder = BorderFactory.createDashedBorder(Constants.TRUSTED_COLOR, 3, 5, 5, true); + contentPanel.setBorder(dropBorder); + } + } else { + contentPanel.setBorder(null); + } + } + + private void blinkDropBorder(boolean success) { + Color borderColor = success ? Color.GREEN : Constants.WARNING_COLOR; + dropBorder = BorderFactory.createDashedBorder(borderColor, 3, 5, 5, true); + AtomicBoolean toggled = new AtomicBoolean(true); + int blinkCount = 3; + int blinkDelay = 100; // ms + for(int i = 0; i < blinkCount * 2; i++) { + Timer timer = new Timer("blink" + i); + timer.schedule(new TimerTask() { + @Override + public void run() { + SwingUtilities.invokeLater(() -> { + contentPanel.setBorder(toggled.getAndSet(!toggled.get())? dropBorder:null); + }); + } + }, i * blinkDelay); + } + } + + private void refreshHeader() { + headerBar.setBackground(SystemUtilities.isDarkDesktop() ? + Constants.TRUSTED_COLOR.darker().darker() : Constants.TRUSTED_COLOR_DARK); + headerBar.setVisible(Substitutions.areActive()); + pack(); + } + @Override public void setVisible(boolean visible) { @@ -181,5 +310,9 @@ public void setVisible(boolean visible) { super.setVisible(visible); } - + @Override + public void refresh() { + refreshHeader(); + super.refresh(); + } } diff --git a/src/qz/utils/ArgParser.java b/src/qz/utils/ArgParser.java index 1d7512dd2..dae8854b3 100644 --- a/src/qz/utils/ArgParser.java +++ b/src/qz/utils/ArgParser.java @@ -51,7 +51,7 @@ public int getCode() { private static final String USAGE_COMMAND = String.format("java -jar %s.jar", PROPS_FILE); private static final String USAGE_COMMAND_PARAMETER = String.format("java -Dfoo.bar= -jar %s.jar", PROPS_FILE); - private static final int DESCRIPTION_COLUMN = 30; + private static final int DESCRIPTION_COLUMN = 35; private static final int INDENT_SIZE = 2; private List args; diff --git a/src/qz/utils/ArgValue.java b/src/qz/utils/ArgValue.java index cfe0856d7..818a4e11d 100644 --- a/src/qz/utils/ArgValue.java +++ b/src/qz/utils/ArgValue.java @@ -1,6 +1,7 @@ package qz.utils; import qz.common.Constants; +import qz.ws.substitutions.Substitutions; import java.util.ArrayList; import java.util.Arrays; @@ -71,6 +72,12 @@ public enum ArgValue { "security.file.enabled"), SECURITY_FILE_STRICT(PREFERENCES, "Enable/disable signing requirements for File Communications features", null, true, "security.file.strict"), + + SECURITY_SUBSTITUTIONS_ENABLE(PREFERENCES, "Enable/disable client-side JSON data substitutions via \"" + Substitutions.FILE_NAME + "\" file", null, true, + "security.substitutions.enable"), + SECURITY_SUBSTITUTIONS_STRICT(PREFERENCES, "Enable/disable restrictions for materially changing JSON substitutions such as \"copies\":, \"data\": { \"data\": ... } blobs", null, true, + "security.substitutions.strict"), + SECURITY_DATA_PROTOCOLS(PREFERENCES, "URL protocols allowed for print, serial, hid, etc", null, "http,https", "security.data.protocols"), SECURITY_PRINT_TOFILE(PREFERENCES, "Enable/disable printing directly to file paths", null, false, diff --git a/src/qz/utils/ByteUtilities.java b/src/qz/utils/ByteUtilities.java index 935a6d7ce..acba8baf5 100644 --- a/src/qz/utils/ByteUtilities.java +++ b/src/qz/utils/ByteUtilities.java @@ -272,4 +272,23 @@ public static int[] unwind(int bitwiseCode) { return matches; } + public static boolean numberEquals(Object val1, Object val2) { + try { + if(val1 == null || val2 == null) { + return val1 == val2; + } else if(val1.getClass() == val2.getClass()) { + return val1.equals(val2); + } else if(val1 instanceof Long) { + return val1.equals(Long.parseLong(val2.toString())); + } else if(val2 instanceof Long) { + return val2.equals(Long.parseLong(val1.toString())); + } else { + return Double.parseDouble(val1.toString()) == Double.parseDouble(val2.toString()); + } + } catch(NumberFormatException nfe) { + log.warn("Cannot not compare [{} = '{}']. Reason: {} {}", val1, val2, nfe.getClass().getName(), nfe.getMessage()); + } + return false; + } + } diff --git a/src/qz/ws/PrintSocketClient.java b/src/qz/ws/PrintSocketClient.java index faa46383b..b6594ff13 100644 --- a/src/qz/ws/PrintSocketClient.java +++ b/src/qz/ws/PrintSocketClient.java @@ -21,6 +21,7 @@ import qz.printer.PrintServiceMatcher; import qz.printer.status.StatusMonitor; import qz.utils.*; +import qz.ws.substitutions.Substitutions; import javax.usb.util.UsbUtil; import java.awt.*; @@ -44,6 +45,7 @@ public class PrintSocketClient { private static final Logger log = LogManager.getLogger(PrintSocketClient.class); private final TrayManager trayManager = PrintSocketServer.getTrayManager(); + private static final Semaphore dialogAvailable = new Semaphore(1, true); //websocket port -> Connection @@ -224,6 +226,14 @@ private boolean validSignature(Certificate certificate, JSONObject message) thro * @param json JSON received from web API */ private void processMessage(Session session, JSONObject json, SocketConnection connection, RequestState request) throws JSONException, SerialPortException, DeviceException, IOException { + // perform client-side substitutions + if(Substitutions.areActive()) { + Substitutions substitutions = Substitutions.getInstance(); + if (substitutions != null) { + json = substitutions.replace(json); + } + } + String UID = json.optString("uid"); SocketMethod call = SocketMethod.findFromCall(json.optString("call")); JSONObject params = json.optJSONObject("params"); diff --git a/src/qz/ws/substitutions/SubstitutionException.java b/src/qz/ws/substitutions/SubstitutionException.java new file mode 100644 index 000000000..e2fa3fe85 --- /dev/null +++ b/src/qz/ws/substitutions/SubstitutionException.java @@ -0,0 +1,9 @@ +package qz.ws.substitutions; + +import org.codehaus.jettison.json.JSONException; + +public class SubstitutionException extends JSONException { + public SubstitutionException(String message) { + super(message); + } +} diff --git a/src/qz/ws/substitutions/Substitutions.java b/src/qz/ws/substitutions/Substitutions.java new file mode 100644 index 000000000..e119c48ed --- /dev/null +++ b/src/qz/ws/substitutions/Substitutions.java @@ -0,0 +1,351 @@ +package qz.ws.substitutions; + +import org.apache.commons.io.IOUtils; +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 qz.utils.ArgValue; +import qz.utils.ByteUtilities; +import qz.utils.FileUtilities; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; + +public class Substitutions { + protected static final Logger log = LogManager.getLogger(Substitutions.class); + + public static final String FILE_NAME = "substitutions.json"; + + + private static final Path DEFAULT_SUBSTITUTIONS_PATH = FileUtilities.SHARED_DIR.resolve(FILE_NAME); + + // Global toggle (should match ArgValue.SECURITY_SUBSTITUTIONS_ENABLE) + private static boolean enabled = true; + + // Subkeys that are restricted for writing because they can materially impact the content + private static boolean strict = true; + private static final HashMap parlous = new HashMap<>(); + static { + parlous.put(Type.OPTIONS, new String[] {"copies"}); + parlous.put(Type.DATA, new String[] {"data"}); + } + private final ArrayList rules; + private static Substitutions INSTANCE; + + public Substitutions(Path path) throws IOException, JSONException { + this(Files.newInputStream(path.toFile().toPath())); + } + + public Substitutions(InputStream in) throws IOException, JSONException { + this(IOUtils.toString(in, StandardCharsets.UTF_8)); + } + + public Substitutions(String serialized) throws JSONException { + rules = new ArrayList<>(); + + JSONArray instructions = new JSONArray(serialized); + for(int i = 0; i < instructions.length(); i++) { + JSONObject step = instructions.optJSONObject(i); + if(step != null) { + rules.add(new Rule(step)); + } + } + } + + public JSONObject replace(InputStream in) throws IOException, JSONException { + return replace(new JSONObject(IOUtils.toString(in, StandardCharsets.UTF_8))); + } + + public JSONObject replace(JSONObject base) throws JSONException { + for(Rule rule : rules) { + if (find(base, rule.match, rule.caseSensitive)) { + log.debug("Matched {}JSON substitution rule: {}", rule.caseSensitive ? "case-sensitive " : "", rule); + replace(base, rule.replace); + } else { + log.debug("Unable to match {}JSON substitution rule: {}", rule.caseSensitive ? "case-sensitive " : "", rule); + } + } + return base; + } + + public static boolean isPrimitive(Object o) { + if(o instanceof JSONObject || o instanceof JSONArray) { + return false; + } + return true; + } + + public static void replace(JSONObject base, JSONObject replace) throws JSONException { + JSONObject jsonBase = base.optJSONObject("params"); + JSONObject jsonReplace = replace.optJSONObject("params"); + if(jsonBase == null) { + // skip, invalid base format for replacement + return; + } + if(jsonReplace == null) { + throw new SubstitutionException("Replacement JSON is missing \"params\": and is malformed"); + } + + if (strict) { + // Second pass of sanitization before we replace + for(Iterator it = jsonReplace.keys(); it.hasNext(); ) { + Type type = Type.parse(it.next()); + if(type == null || type.isReadOnly()) continue; + // Good, let's make sure there are no exceptions + switch(type) { + case DATA: + // Special handling for arrays + JSONArray jsonArray = jsonReplace.optJSONArray(type.getKey()); + removeRestrictedSubkeys(jsonArray, type); + break; + default: + removeRestrictedSubkeys(jsonReplace, type); + } + } + } + find(base, replace, false, true); + } + + private static void removeRestrictedSubkeys(JSONObject jsonObject, Type type) { + if(jsonObject == null) { + return; + } + + String[] parlousFieldNames = parlous.get(type); + if(parlousFieldNames == null) return; + + for (String parlousFieldName : parlousFieldNames) { + JSONObject toCheck = jsonObject.optJSONObject(type.getKey()); + if (toCheck != null && toCheck.has(parlousFieldName)) { + log.warn("Use of { \"{}\": { \"{}\": ... } } is restricted, removing", type.getKey(), parlousFieldName); + jsonObject.remove(parlousFieldName); + } + } + } + + private static void removeRestrictedSubkeys(JSONArray jsonArray, Type type) { + if(jsonArray == null) { + return; + } + + ArrayList toRemove = new ArrayList<>(); + for(int i = 0; i < jsonArray.length(); i++) { + JSONObject jsonObject; + if ((jsonObject = jsonArray.optJSONObject(i)) != null) { + String[] parlousFieldNames = parlous.get(type); + for (String parlousFieldName : parlousFieldNames) { + if (jsonObject.has(parlousFieldName)) { + log.warn("Use of { \"{}\": { \"{}\": ... } } is restricted, removing", type.getKey(), parlousFieldName); + toRemove.add(jsonObject); + } + } + } + } + for(Object o : toRemove) { + jsonArray.remove(o); + } + } + + public static void sanitize(JSONObject match) throws JSONException { + // "options" ~= "config" + Object cache; + + JSONObject nested = new JSONObject(); + for(Type key : Type.values()) { + // If any alts/aliases key are used, switch them out for the standard key + for(String alt : key.getAlts()) { + if ((cache = match.optJSONObject(alt)) != null) { + match.put(key.getKey(), cache); + match.remove(alt); + break; + } + } + + // Special handling for nesting of "printer", "options", "data" within "params" + if((cache = match.opt(key.getKey())) != null) { + switch(key) { + case PRINTER: + JSONObject name = new JSONObject(); + name.put("name", cache); + nested.put(key.getKey(), name); + break; + default: + nested.put(key.getKey(), cache); + } + match.remove(key.getKey()); + } + } + if(nested.length() > 0) { + match.put("params", nested); + } + + // Special handling for "data" being provided as an object instead of an array + if((cache = match.opt("params")) != null) { + if (cache instanceof JSONObject) { + JSONObject params = (JSONObject)cache; + if((cache = params.opt("data")) != null) { + if (cache instanceof JSONArray) { + // If "data" is already an array, skip + } else { + JSONArray wrapped = new JSONArray(); + wrapped.put(cache); + params.put("data", wrapped); + } + } + } + } + } + + private static boolean find(Object base, Object match, boolean caseSensitive) throws JSONException { + return find(base, match, caseSensitive, false); + } + private static boolean find(Object base, Object match, boolean caseSensitive, boolean replace) throws JSONException { + if(base instanceof JSONObject) { + if(match instanceof JSONObject) { + JSONObject jsonMatch = (JSONObject)match; + JSONObject jsonBase = (JSONObject)base; + for(Iterator it = jsonMatch.keys(); it.hasNext(); ) { + String nextKey = it.next().toString(); + Object newMatch = jsonMatch.get(nextKey); + + // Check if the key exists, recurse if needed + if(jsonBase.has(nextKey)) { + Object newBase = jsonBase.get(nextKey); + + if(replace && isPrimitive(newMatch)) { + // Overwrite value, don't recurse + jsonBase.put(nextKey, newMatch); + continue; + } else if(find(newBase, newMatch, caseSensitive, replace)) { + continue; + } + } else if(replace) { + // Key doesn't exist, so we'll merge it in + jsonBase.put(nextKey, newMatch); + } + return false; // wasn't found + } + return true; // assume found + } else { + return false; // mismatched types + } + } else if (base instanceof JSONArray) { + boolean found = false; + if(match instanceof JSONArray) { + JSONArray matchArray = (JSONArray)match; + JSONArray baseArray = (JSONArray)base; + for(int i = 0; i < matchArray.length(); i++) { + Object newMatch = matchArray.get(i); + for(int j = 0; j < baseArray.length(); j++) { + Object newBase = baseArray.get(j); + if(find(newBase, newMatch, caseSensitive, replace)) { + found = true; + if(!replace) { + return true; + } + } + } + } + } + return found; + } else { + // Treat as primitives + if(match instanceof Number || base instanceof Number) { + return ByteUtilities.numberEquals(match, base); + } + // Fallback: Cast both to String + if(caseSensitive) { + return match.toString().equals(base.toString()); + } + return match.toString().equalsIgnoreCase(base.toString()); + } + } + + public static void setEnabled(boolean enabled) { + Substitutions.enabled = enabled; + } + + public static void setStrict(boolean strict) { + Substitutions.strict = strict; + } + + /** + * Returns a new instance of the Substitutions object from the default + * substitutions.json file at DEFAULT_SUBSTITUTIONS_PATH, + * or null if an error occurred. + */ + public static Substitutions newInstance() { + return newInstance(DEFAULT_SUBSTITUTIONS_PATH); + } + + /** + * Returns a new instance of the Substitutions object from the provided + * json substitutions file, or null if an error occurred. + */ + public static Substitutions newInstance(Path path) { + Substitutions substitutions = null; + try { + substitutions = new Substitutions(path); + log.info("Successfully parsed new substitutions file."); + } catch(JSONException e) { + log.warn("Unable to parse substitutions file, skipping", e); + } catch(IOException e) { + log.info("Substitutions file missing, skipping: {}", e.getMessage()); + } + return substitutions; + } + + public static Substitutions getInstance() { + return getInstance(false); + } + + public static Substitutions getInstance(boolean forceRefresh) { + if(INSTANCE == null || forceRefresh) { + INSTANCE = Substitutions.newInstance(); + if(!enabled) { + log.warn("Substitution file was found, but substitutions are currently disabled via \"{}=false\"", ArgValue.SECURITY_SUBSTITUTIONS_ENABLE.getMatch()); + } + } + return INSTANCE; + } + + public static boolean areActive() { + return Substitutions.getInstance() != null && enabled; + } + + private class Rule { + private boolean caseSensitive; + private JSONObject match, replace; + + Rule(JSONObject json) throws JSONException { + JSONObject replaceJSON = json.optJSONObject("use"); + if(replaceJSON != null) { + sanitize(replaceJSON); + replace = replaceJSON; + } + + JSONObject matchJSON = json.optJSONObject("for"); + if(matchJSON != null) { + caseSensitive = matchJSON.optBoolean("caseSensitive", false); + matchJSON.remove("caseSensitive"); + sanitize(matchJSON); + match = matchJSON; + } + + if(match == null || replace == null) { + throw new SubstitutionException("Mismatched instructions; Each \"use\" must have a matching \"for\"."); + } + } + + @Override + public String toString() { + return "for: " + match + ", use: " + replace; + } + } +} diff --git a/src/qz/ws/substitutions/Type.java b/src/qz/ws/substitutions/Type.java new file mode 100644 index 000000000..49656642c --- /dev/null +++ b/src/qz/ws/substitutions/Type.java @@ -0,0 +1,42 @@ +package qz.ws.substitutions; + +public enum Type { + OPTIONS("options", "config"), + PRINTER("printer"), + DATA("data"), + QUERY("query"); + + private String key; + private boolean readOnly; + private String[] alts; + + Type(String key, String ... alts) { + this(key, false, alts); + } + Type(String key, boolean readOnly, String... alts) { + this.key = key; + this.readOnly = readOnly; + this.alts = alts; + } + + public boolean isReadOnly() { + return readOnly; + } + + public static Type parse(Object o) { + for(Type root : values()) { + if (root.key.equals(o)) { + return root; + } + } + return null; + } + + public String getKey() { + return key; + } + + public String[] getAlts() { + return alts; + } +} \ No newline at end of file diff --git a/test/qz/ws/substitutions/SubstitutionsTests.java b/test/qz/ws/substitutions/SubstitutionsTests.java new file mode 100644 index 000000000..fca998d29 --- /dev/null +++ b/test/qz/ws/substitutions/SubstitutionsTests.java @@ -0,0 +1,19 @@ +package qz.ws.substitutions; + +import org.codehaus.jettison.json.JSONException; +import org.codehaus.jettison.json.JSONObject; + +import java.io.IOException; + +public class SubstitutionsTests { + public static void main(String ... args) throws JSONException, IOException { + Substitutions substitutions = new Substitutions( + SubstitutionsTests.class.getResourceAsStream("resources/substitutions.json") + ); + JSONObject base = substitutions.replace( + SubstitutionsTests.class.getResourceAsStream("resources/printRequest.json") + ); + + System.out.println(base); + } +} diff --git a/test/qz/ws/substitutions/resources/printRequest.json b/test/qz/ws/substitutions/resources/printRequest.json new file mode 100644 index 000000000..98612c289 --- /dev/null +++ b/test/qz/ws/substitutions/resources/printRequest.json @@ -0,0 +1,67 @@ +{ + "call": "print", + "params": { + "printer": { + "name": "PDFwriter" + }, + "options": { + "bounds": null, + "colorType": "color", + "copies": 1, + "density": 0, + "duplex": false, + "fallbackDensity": null, + "interpolation": "bicubic", + "jobName": null, + "legacy": false, + "margins": 0, + "orientation": null, + "paperThickness": null, + "printerTray": null, + "rasterize": false, + "rotation": 0, + "scaleContent": true, + "size": { + "width": "4", + "height": "6" + }, + "units": "in", + "forceRaw": false, + "encoding": null, + "spool": {} + }, + "data": [ + { + "type": "pixel", + "format": "pdf", + "flavor": "file", + "data": "https://demo.qz.io/assets/pdf_sample.pdf", + "options": { + "pageWidth": "8.5", + "pageHeight": "11", + "pageRanges": "", + "ignoreTransparency": false, + "altFontRendering": false + } + }, + { + "type": "pixel", + "format": "image", + "flavor": "file", + "data": "https://demo.qz.io/assets/img/image_sample.png", + }, + "^XA\n", + "^FO50,50^ADN,36,20^FDPRINTED WITH QZ 2.2.4-SNAPSHOT\n", + "^FS\n", + "^XZ\n" + ] + }, + "signature": "", + "timestamp": 1713895560783, + "uid": "64t63d", + "position": { + "x": 720, + "y": 462.5 + }, + "signAlgorithm": "SHA512" +} \ No newline at end of file diff --git a/test/qz/ws/substitutions/resources/substitutions.json b/test/qz/ws/substitutions/resources/substitutions.json new file mode 100644 index 000000000..274cfe5a3 --- /dev/null +++ b/test/qz/ws/substitutions/resources/substitutions.json @@ -0,0 +1,109 @@ +[ + { + "use":{ + "config": { + "size": { + "width": 100, + "height": 150 + }, + "units": "mm" + } + }, + "for": { + "config": { + "size": { + "width": 4, + "height": 6 + }, + "units": "in" + } + } + }, + { + "use": { + "printer": "PDF" + }, + "for": { + "printer": "PDFwriter" + } + }, + { + "use": { + "data": { + "options": { + "pageWidth": 8.5, + "pageHeight": 14 + } + } + }, + "for": { + "data": { + "options": { + "pageWidth": "8.5", + "pageHeight": "11" + } + } + } + }, + { + "use": { + "query": "pdf" + }, + "for": { + "query": "zzz" + } + }, + { + "use": { + "config": { + "copies": 3 + } + }, + "for": { + "config": { + "copies": 1 + } + } + }, + { + "use": { + "printer": "PDFwriter" + }, + "for": { + "caseSensitive": true, + "printer": "xps document writer" + } + }, + { + "use": { + "data": { + "data": "https://yahoo.com" + } + }, + "for": { + "data": { + "data": "https://demo.qz.io/assets/pdf_sample.pdf" + } + } + }, + { + "use": { + "printer": "ZDesigner" + }, + "for": { + "data": [ "^XA\n" ] + } + }, + { + "use": { + "data": { + "type": "PIXEL" + } + }, + "for": { + "data": { + "type": "pixel" + } + } + } +] \ No newline at end of file