cloudprint-0.14/0000755000175000017500000000000012567535353014451 5ustar armoooarmooo00000000000000cloudprint-0.14/cloudprint/0000755000175000017500000000000012567535353016634 5ustar armoooarmooo00000000000000cloudprint-0.14/cloudprint/__init__.py0000644000175000017500000000132012556116425020732 0ustar armoooarmooo00000000000000# Copyright 2014 Jason Michalski # # This file is part of cloudprint. # # cloudprint is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # cloudprint is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with cloudprint. If not, see . cloudprint-0.14/cloudprint/cloudprint.py0000755000175000017500000004376412567237460021410 0ustar armoooarmooo00000000000000#!/usr/bin/env python # Copyright 2014 Jason Michalski # # This file is part of cloudprint. # # cloudprint is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # cloudprint is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with cloudprint. If not, see . from __future__ import absolute_import from __future__ import print_function import argparse import cups import datetime import hashlib import io import json import logging import logging.handlers import os import re import requests import shutil import stat import sys import tempfile import time import uuid from cloudprint import xmpp XMPP_SERVER_HOST = 'talk.google.com' XMPP_SERVER_PORT = 5223 SOURCE = 'Armooo-PrintProxy-1' PRINT_CLOUD_SERVICE_ID = 'cloudprint' CLIENT_LOGIN_URL = '/accounts/ClientLogin' PRINT_CLOUD_URL = 'https://www.google.com/cloudprint/' # period in seconds with which we should poll for new jobs via the HTTP api, # when xmpp is connecting properly. # 'None' to poll only on startup and when we get XMPP notifications. # 'Fast Poll' is used as a workaround when notifications are not working. POLL_PERIOD = 3600.0 FAST_POLL_PERIOD = 30.0 # wait period to retry when xmpp fails FAIL_RETRY = 60 # how often, in seconds, to send a keepalive character over xmpp KEEPALIVE = 600.0 # failed job retries RETRIES = 1 num_retries = 0 LOGGER = logging.getLogger('cloudprint') LOGGER.setLevel(logging.INFO) CLIENT_ID = ('607830223128-rqenc3ekjln2qi4m4ntudskhnsqn82gn' '.apps.googleusercontent.com') CLIENT_KEY = 'T0azsx2lqDztSRyPHQaERJJH' def unicode_escape(string): return string.encode('unicode-escape').decode('ascii') class CloudPrintAuth(object): AUTH_POLL_PERIOD = 10.0 def __init__(self, auth_path): self.auth_path = auth_path self.guid = None self.email = None self.xmpp_jid = None self.exp_time = None self.refresh_token = None self._access_token = None @property def session(self): s = requests.session() s.headers['X-CloudPrint-Proxy'] = 'ArmoooIsAnOEM' s.headers['Authorization'] = 'Bearer {0}'.format(self.access_token) return s @property def access_token(self): if datetime.datetime.now() > self.exp_time: self.refresh() return self._access_token def no_auth(self): return not os.path.exists(self.auth_path) def login(self, name, description, ppd): self.guid = str(uuid.uuid4()) reg_data = requests.post( PRINT_CLOUD_URL + 'register', { 'output': 'json', 'printer': name, 'proxy': self.guid, 'capabilities': ppd.encode('utf-8'), 'defaults': ppd.encode('utf-8'), 'status': 'OK', 'description': description, 'capsHash': hashlib.sha1(ppd.encode('utf-8')).hexdigest(), }, headers={'X-CloudPrint-Proxy': 'ArmoooIsAnOEM'}, ).json() print('Go to {0} to claim this printer'.format( reg_data['complete_invite_url'] )) end = time.time() + int(reg_data['token_duration']) while time.time() < end: time.sleep(self.AUTH_POLL_PERIOD) print('trying for the win') poll = requests.get( reg_data['polling_url'] + CLIENT_ID, headers={'X-CloudPrint-Proxy': 'ArmoooIsAnOEM'}, ).json() if poll['success']: break else: print('The login request timedout') self.xmpp_jid = poll['xmpp_jid'] self.email = poll['user_email'] print('Printer claimed by {0}.'.format(self.email)) token = requests.post( 'https://accounts.google.com/o/oauth2/token', data={ 'redirect_uri': 'oob', 'client_id': CLIENT_ID, 'client_secret': CLIENT_KEY, 'grant_type': 'authorization_code', 'code': poll['authorization_code'], } ).json() self.refresh_token = token['refresh_token'] self.refresh() self.save() def refresh(self): token = requests.post( 'https://accounts.google.com/o/oauth2/token', data={ 'client_id': CLIENT_ID, 'client_secret': CLIENT_KEY, 'grant_type': 'refresh_token', 'refresh_token': self.refresh_token, } ).json() self._access_token = token['access_token'] slop_time = datetime.timedelta(minutes=15) expires_in = datetime.timedelta(seconds=token['expires_in']) self.exp_time = datetime.datetime.now() + (expires_in - slop_time) def load(self): if os.path.exists(self.auth_path): with open(self.auth_path) as auth_file: auth_data = json.load(auth_file) self.guid = auth_data['guid'] self.xmpp_jid = auth_data['xmpp_jid'] self.email = auth_data['email'] self.refresh_token = auth_data['refresh_token'] self.refresh() def delete(self): if os.path.exists(self.auth_path): os.unlink(self.auth_path) def save(self): if not os.path.exists(self.auth_path): with open(self.auth_path, 'w') as auth_file: os.chmod(self.auth_path, stat.S_IRUSR | stat.S_IWUSR) with open(self.auth_path, 'w') as auth_file: json.dump({ 'guid': self.guid, 'email': self.email, 'xmpp_jid': self.xmpp_jid, 'refresh_token': self.refresh_token, }, auth_file ) class CloudPrintProxy(object): def __init__(self, auth): self.auth = auth self.sleeptime = 0 self.include = [] self.exclude = [] def get_printers(self): printers = self.auth.session.post( PRINT_CLOUD_URL + 'list', { 'output': 'json', 'proxy': self.auth.guid, }, ).json() return [ PrinterProxy(self, p['id'], p['name']) for p in printers['printers'] ] def delete_printer(self, printer_id): self.auth.session.post( PRINT_CLOUD_URL + 'delete', { 'output': 'json', 'printerid': printer_id, }, ).raise_for_status() LOGGER.debug('Deleted printer ' + printer_id) def add_printer(self, name, description, ppd): self.auth.session.post( PRINT_CLOUD_URL + 'register', { 'output': 'json', 'printer': name, 'proxy': self.auth.guid, 'capabilities': ppd.encode('utf-8'), 'defaults': ppd.encode('utf-8'), 'status': 'OK', 'description': description, 'capsHash': hashlib.sha1(ppd.encode('utf-8')).hexdigest(), }, ).raise_for_status() LOGGER.debug('Added Printer ' + name) def update_printer(self, printer_id, name, description, ppd): self.auth.session.post( PRINT_CLOUD_URL + 'update', { 'output': 'json', 'printerid': printer_id, 'printer': name, 'proxy': self.auth.guid, 'capabilities': ppd.encode('utf-8'), 'defaults': ppd.encode('utf-8'), 'status': 'OK', 'description': description, 'capsHash': hashlib.sha1(ppd.encode('utf-8')).hexdigest(), }, ).raise_for_status() LOGGER.debug('Updated Printer ' + name) def get_jobs(self, printer_id): docs = self.auth.session.post( PRINT_CLOUD_URL + 'fetch', { 'output': 'json', 'printerid': printer_id, }, ).json() if 'jobs' not in docs: return [] else: return docs['jobs'] def finish_job(self, job_id): self.auth.session.post( PRINT_CLOUD_URL + 'control', { 'output': 'json', 'jobid': job_id, 'status': 'DONE', }, ).json() LOGGER.debug('Finished Job' + job_id) def fail_job(self, job_id): self.auth.session.post( PRINT_CLOUD_URL + 'control', { 'output': 'json', 'jobid': job_id, 'status': 'ERROR', }, ).json() LOGGER.debug('Failed Job' + job_id) class PrinterProxy(object): def __init__(self, cpp, printer_id, name): self.cpp = cpp self.id = printer_id self.name = name def get_jobs(self): LOGGER.info('Polling for jobs on ' + self.name) return self.cpp.get_jobs(self.id) def update(self, description, ppd): return self.cpp.update_printer(self.id, self.name, description, ppd) def delete(self): return self.cpp.delete_printer(self.id) # True if printer name matches *any* of the regular expressions in regexps def match_re(prn, regexps, empty=False): if len(regexps): try: return ( re.match(regexps[0], prn, re.UNICODE) or match_re(prn, regexps[1:]) ) except Exception: sys.stderr.write( 'cloudprint: invalid regular expression: ' + regexps[0] + '\n' ) sys.exit(1) else: return empty def get_printer_info(cups_connection, printer_name): # This is bad it should use the LanguageEncoding in the PPD # But a lot of utf-8 PPDs seem to say they are ISOLatin1 ppd_path = cups_connection.getPPD(printer_name) with io.open(ppd_path, encoding='utf-8') as ppd_file: ppd = ppd_file.read() printer_attrs = cups_connection.getPrinterAttributes(printer_name) description = printer_attrs['printer-info'] return ppd, description def sync_printers(cups_connection, cpp): local_printer_names = set(cups_connection.getPrinters().keys()) remote_printers = dict([(p.name, p) for p in cpp.get_printers()]) remote_printer_names = set(remote_printers) # Include/exclude local printers local_printer_names = set([ prn for prn in local_printer_names if match_re(prn, cpp.include, True) ]) local_printer_names = set([ prn for prn in local_printer_names if not match_re(prn, cpp.exclude) ]) # New printers for printer_name in local_printer_names - remote_printer_names: try: ppd, description = get_printer_info(cups_connection, printer_name) cpp.add_printer(printer_name, description, ppd) except (cups.IPPError, UnicodeDecodeError): LOGGER.exception('Skipping ' + printer_name) # Existing printers for printer_name in local_printer_names & remote_printer_names: ppd, description = get_printer_info(cups_connection, printer_name) remote_printers[printer_name].update(description, ppd) # Printers that have left us for printer_name in remote_printer_names - local_printer_names: remote_printers[printer_name].delete() def process_job(cups_connection, cpp, printer, job): global num_retries try: pdf = cpp.auth.session.get(job['fileUrl'], stream=True) tmp = tempfile.NamedTemporaryFile(delete=False) shutil.copyfileobj(pdf.raw, tmp) tmp.flush() options = cpp.auth.session.get(job['ticketUrl']).json() if 'request' in options: del options['request'] options = dict((str(k), str(v)) for k, v in list(options.items())) # Cap the title length to 255, or cups will complain about invalid # job-name cups_connection.printFile( printer.name, tmp.name, job['title'][:255], options, ) os.unlink(tmp.name) LOGGER.info(unicode_escape('SUCCESS ' + job['title'])) cpp.finish_job(job['id']) num_retries = 0 except Exception: if num_retries >= RETRIES: num_retries = 0 cpp.fail_job(job['id']) LOGGER.error(unicode_escape('ERROR ' + job['title'])) else: num_retries += 1 LOGGER.info( unicode_escape('Job %s failed - Will retry' % job['title']) ) def process_jobs(cups_connection, cpp): xmpp_conn = xmpp.XmppConnection(keepalive_period=KEEPALIVE) while True: process_jobs_once(cups_connection, cpp, xmpp_conn) def process_jobs_once(cups_connection, cpp, xmpp_conn): printers = cpp.get_printers() try: for printer in printers: for job in printer.get_jobs(): process_job(cups_connection, cpp, printer, job) if not xmpp_conn.is_connected(): xmpp_conn.connect(XMPP_SERVER_HOST, XMPP_SERVER_PORT, cpp.auth) xmpp_conn.await_notification(cpp.sleeptime) except Exception: LOGGER.exception( 'ERROR: Could not Connect to Cloud Service. ' 'Will Try again in %d Seconds' % FAIL_RETRY ) time.sleep(FAIL_RETRY) def parse_args(): parser = argparse.ArgumentParser() parser.add_argument( '-d', dest='daemon', action='store_true', help='enable daemon mode (requires the daemon module)', ) parser.add_argument( '-l', dest='logout', action='store_true', help='logout of the google account', ) parser.add_argument( '-p', metavar='pid_file', dest='pidfile', default='cloudprint.pid', help='path to write the pid to (default %(default)s)', ) parser.add_argument( '-a', metavar='account_file', dest='authfile', default=os.path.expanduser('~/.cloudprintauth.json'), help='path to google account ident data (default %(default)s)', ) parser.add_argument( '-c', dest='authonly', action='store_true', help='establish and store login credentials, then exit', ) parser.add_argument( '-f', dest='fastpoll', action='store_true', help='use fast poll if notifications are not working', ) parser.add_argument( '-i', metavar='regexp', dest='include', default=[], action='append', help='include local printers matching %(metavar)s', ) parser.add_argument( '-x', metavar='regexp', dest='exclude', default=[], action='append', help='exclude local printers matching %(metavar)s', ) parser.add_argument( '-v', dest='verbose', action='store_true', help='verbose logging', ) parser.add_argument( '--syslog-address', help='syslog address to use in daemon mode', ) return parser.parse_args() def main(): args = parse_args() if args.syslog_address and not args.daemon: print('syslog_address is only valid in daemon mode') sys.exit(1) # if daemon, log to syslog, otherwise log to stdout if args.daemon: if args.syslog_address: handler = logging.handlers.SysLogHandler( address=args.syslog_address ) else: handler = logging.handlers.SysLogHandler() handler.setFormatter( logging.Formatter(fmt='cloudprint.py: %(message)s') ) else: handler = logging.StreamHandler(sys.stdout) LOGGER.addHandler(handler) if args.verbose: LOGGER.info('Setting DEBUG-level logging') LOGGER.setLevel(logging.DEBUG) try: import http.client as httpclient except ImportError: import httplib as httpclient httpclient.HTTPConnection.debuglevel = 1 requests_log = logging.getLogger("requests.packages.urllib3") requests_log.setLevel(logging.DEBUG) requests_log.propagate = True auth = CloudPrintAuth(args.authfile) if args.logout: auth.delete() LOGGER.info('logged out') return cups_connection = cups.Connection() cpp = CloudPrintProxy(auth) cpp.sleeptime = POLL_PERIOD if args.fastpoll: cpp.sleeptime = FAST_POLL_PERIOD cpp.include = args.include cpp.exclude = args.exclude printers = list(cups_connection.getPrinters().keys()) if not printers: LOGGER.error('No printers found') return if auth.no_auth(): name = printers[0] ppd, description = get_printer_info(cups_connection, name) auth.login(name, description, ppd) else: auth.load() sync_printers(cups_connection, cpp) if args.authonly: sys.exit(0) if args.daemon: try: import daemon import daemon.pidfile except ImportError: print('daemon module required for -d') print( '\tyum install python-daemon, or apt-get install' 'python-daemon, or pip install python-daemon' ) sys.exit(1) pidfile = daemon.pidfile.TimeoutPIDLockFile( path=os.path.abspath(args.pidfile), timeout=5, ) with daemon.DaemonContext(pidfile=pidfile): process_jobs(cups_connection, cpp) else: process_jobs(cups_connection, cpp) if __name__ == '__main__': main() cloudprint-0.14/cloudprint/xmpp.py0000644000175000017500000002043712563751300020164 0ustar armoooarmooo00000000000000# Copyright 2014 Jason Michalski # This file is part of cloudprint. # # cloudprint is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # cloudprint is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License from __future__ import absolute_import from __future__ import print_function import base64 import logging import ssl import socket import select import time from collections import deque from xml.etree.ElementTree import XMLParser, TreeBuilder LOGGER = logging.getLogger('cloudprint.xmpp') class XmppXmlHandler(object): STREAM_TAG = '{http://etherx.jabber.org/streams}stream' def __init__(self): self._stack = 0 self._builder = TreeBuilder() self._builder.start('root', {}) self._results = deque() def data(self, data): self._builder.data(data) def start(self, tag, attrib): if tag == self.STREAM_TAG: return self._builder.start(tag, attrib) self._stack += 1 def end(self, tag): self._stack -= 1 elem = self._builder.end(tag) if self._stack == 0: self._results.append(elem) def get_elem(self): """If a top-level XML element has been completed since the last call to get_elem, return it; else return None.""" try: elem = self._results.popleft() if elem.tag.endswith('failure') or elem.tag.endswith('error'): raise Exception("XMPP Error received - %s" % elem.tag) return elem except IndexError: return None class XmppConnection(object): def __init__(self, keepalive_period=60.0): self._connected = False self._wrappedsock = None self._keepalive_period = keepalive_period self._nextkeepalive = time.time() + self._keepalive_period def _read_socket(self): """read pending data from the socket, and send it to the XML parser. return False if the socket is closed, True if it is ok""" try: self._nextkeepalive = time.time() + self._keepalive_period data = self._wrappedsock.recv(1024) if data is None or len(data) == 0: # socket closed raise Exception("xmpp socket closed") except: self._connected = False raise data = data.decode('utf-8') LOGGER.debug('<<< %s' % data) self._xmlparser.feed(data) def _write_socket(self, msg): """write a message to the XMPP server""" LOGGER.debug('>>> %s' % msg) try: self._nextkeepalive = time.time() + self._keepalive_period self._wrappedsock.sendall(msg.encode('utf-8')) except: self._connected = False raise def _msg(self, msg=None): """send a message to the XMPP server, and wait for a response returns the XML element tree of the response""" if msg is not None: self._write_socket(msg) while True: elem = self._handler.get_elem() if elem is not None: return elem # need more data; block until it becomes available self._read_socket() def _check_for_notification(self): """Check for any notifications which have already been received""" return(self._handler.get_elem() is not None) def _send_keepalive(self): LOGGER.info("Sending XMPP keepalive") self._write_socket(" ") def connect(self, host, port, auth): """Establish a new connection to the XMPP server""" # first close any existing socket self.close() LOGGER.info("Establishing connection to xmpp server %s:%i" % (host, port)) self._xmppsock = socket.socket() self._wrappedsock = self._xmppsock raw_auth_string = '\0{0}\0{1}'.format( auth.xmpp_jid, auth.access_token ).encode('utf-8') auth_string = base64.b64encode(raw_auth_string).decode('utf-8') try: self._wrappedsock = ssl.wrap_socket(self._xmppsock) self._wrappedsock.connect((host, port)) self._handler = XmppXmlHandler() self._xmlparser = XMLParser(target=self._handler) # https://developers.google.com/cloud-print/docs/rawxmpp self._msg( '' ) self._msg( '%s' % auth_string ) self._msg( '' ) iq = self._msg( '' '' 'cloud_print' '' '' ) bare_jid = iq[0][0].text.split('/')[0] self._msg( '' '' '' ) self._msg( '' '' '' '' '' % bare_jid ) except: self.close() raise LOGGER.info("xmpp connection established") self._connected = True def close(self): """Close the connection to the XMPP server""" try: self._wrappedsock.shutdown(socket.SHUT_RDWR) self._wrappedsock.close() except: # close() is best effort. Don't respond to failures LOGGER.debug("Error encountered closing XMPP socket") finally: self._connected = False self._nextkeepalive = 0 self._wrappedsock = None def is_connected(self): """Check if we are connected to the XMPP server returns true if the connection is active; false otherwise""" return self._connected def await_notification(self, timeout): """wait for a timeout or event notification""" now = time.time() timeoutend = None if timeout is not None: timeoutend = now + timeout while True: try: if self._check_for_notification(): return True if timeoutend is not None and timeoutend - now <= 0: # timeout return False waittime = self._nextkeepalive - now LOGGER.debug("%f seconds until next keepalive" % waittime) if timeoutend is not None: remaining = timeoutend - now if remaining < waittime: waittime = remaining LOGGER.debug("%f seconds until timeout" % waittime) if waittime < 0: waittime = 0 sock = self._wrappedsock (r, w, e) = select.select([sock], [], [sock], waittime) now = time.time() if self._nextkeepalive - now <= 0: self._send_keepalive() if sock in r: self._read_socket() if sock in e: LOGGER.warn("Error in xmpp connection") raise Exception("xmpp connection errror") except: self.close() raise cloudprint-0.14/cloudprint.egg-info/0000755000175000017500000000000012567535353020326 5ustar armoooarmooo00000000000000cloudprint-0.14/cloudprint.egg-info/PKG-INFO0000644000175000017500000000711512567535353021427 0ustar armoooarmooo00000000000000Metadata-Version: 1.1 Name: cloudprint Version: 0.14 Summary: Google cloud print proxy for linux/OSX Home-page: https://github.com/armooo/cloudprint Author: Jason Michalski Author-email: armooo@armooo.net License: UNKNOWN Description: Share your CUPS printers with google's cloud print. Works with linux and OS X. This software is a python implementation of a cloud print connector. Unlike Google's linux connector, it does not require chrome to be installed on the server. Requires --------------------------------------------------- - python 2.6 or 2.7 - pycups (can be tricky on OS X) wich depends on libcups2-dev Usage --------------------------------------------------- :: cloudprint [