#!/usr/bin/python3 """ A one-file shared PDF viewer ("presentation player"). Basically: Execute the script with the -a option, point your browser to http://localhost:8900, upload a PDF and spread the link you're getting. Realistic deployment scenarios: see README. Everything here is kept in memory and will be lost after a restart. We also clear everything after ~ two days if there's no restart before that. In the default config (key name length=4), there's also no reasonable expectation of privacy. License: Public domain, except for the twisted websocket code (near the top). Look for "End stuff" to get to the actual code. Sorry that all the python, javascript, html, css, and image is mushed together a bit; on the other hand, installation is trivial in this way... """ # length of the URL segment distinguishing the PDFs KEY_NAME_LENGTH = 4 # slide expiry interval, in seconds (slides will be marked at the first pass, # removed at the second) EXPIRY_INTERVAL = 80000 # the welcome/upload page UPLOAD_FORM = """
Poatmyp (“POint AT MY Pdf”) let multiple people look at the same PDF (slides, most often) and share a pointer on the slides. Everyone can go back and forth in the document. It's like screen sharing, except it's clear what you share and what you don't share.
To use it, upload a PDF. You will be redirected to a URL, at which you will see (after a brief while) the first page and a sidebar with thumbnails of all the pages; click on one to jump to the page; you can also go forward an backward using the space bar and the backspace key.
Distribute this URL, typically in a chat. People going to this URL with a javascript/websocket-enabled browsers will then see the current slide and a pointer (click on the main image), and any changes made by anyone will be reflected in everyone else's browsers.
People without javascript don't have the pointer, and they'll have to reload manually when the slide changes.
You can self-host this program. The (public domain) source is at poatmyp. See the README there.
Data protection: Except for a brief moment during rendering, all data put in here is in RAM and will be discarded about a day after upload. We don't keep logs on disk (it's conceivable someone might look at console output, for instance during debugging). Don't share confidential slides (unless self-hosting): The URLs here are too short to not be guessable.
""" # HTML for the shared PDF view VIEW_PAGE = """The thing you wanted to move is not on this server. In all likelihood, this is because we discard uploaded data after something like two days.
Uploaded data also get lost when we restart the server program. If that's happened while you were still using the PDF, apologies. Can we ask you to upload again?
""" # base64-encoded PNG of the shared marker MARKER_IMAGE = "iVBORw0KGgoAAAANSUhEUgAAAB8AAAAgAgMAAACX7EvSAAAACVBMVEUAABD/Q/v/VvpBXLjAAAAAAXRSTlMAQObYZgAAAAFiS0dEAIgFHUgAAAAJcEhZcwAAEzkAABM5AY/CVgEAAAAHdElNRQfkBAYSASCsPWy/AAAAdklEQVQY012P0QkAIQxD9aMjdB9H8KOB23+SS9oqxwniw4ZnHBi9gAu4UAQDdsJo4r0jCJ65ReCemgmGR4MxaYIp2DqYJhgWAxb0bAZmUPMBigSPAOcGnaHaD5SHxjJLnfWkztezlfqo6Kx/OLKzZuezXpP/egF3+iGpnuC1iQAAAABJRU5ErkJggg==" # base64 of PNG shown while rendering PLEASE_WAIT_PNG = b'iVBORw0KGgoAAAANSUhEUgAAAyAAAADIAQMAAAAqQRdZAAAABlBMVEUAAAD///+l2Z/dAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH5AQWDxcPX5ODjQAAAgxJREFUeNrt2j1q3EAUB3AJDFvqAsu+K/gAQu8IOUJyjC3Mjl2581xg0VxlIQQ1wbqCjBy2cyRURIJBLyMRAnYVgt+Al/8MAqn68b6GKZRIhJUAAQIECBAgQIAAAQIECBAgQIAAeR9k/vPlwsN6CEVAzH0M5PwX0auJaWMgw8VE0nqTm9b1MhR7ObMOUnV0TbfudPO1+MJ3OkiStTYjSw+5LXa8U0JMVZMLO+cibB3kYOozOXZ8NAExSgjfbgJSJltFxLAVKrlkH5BcC3FuRYwXRcSGSNwSiSimq65lLfyKFFrDWMnawiuiNCfm3IYWtmSPAeGUSecU7jJy4VjJF+ReCek9k2ulW9LFooK8XSo1ebPmCEg4ySJEksRA7nAXBgIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECJDLRHz6+s+sSQVJOAIyUwRE7CvkX5T/QOoYSHVBSC/VTKMIs6umJtOpyemQjptm+Xlmlyoh1j0U4/SrIiEKL6NKusgR+2Hp5PIz+2lSmnh2vhdXS/lJC0mlTMiPkmRSPholRKRkaXxGIt8fTaOGmHkrGw7Iy2GrF4n30i6RPN+opcstyBDm/tuz90otLI4WxIr80ETscT+0VS1Pnd9P409SQU75VbdpSJ56fzWtfaaAdELhgDQy9ELTzO+P4N4FBAgQIECAAAECBAiQj4z8BqUQAFkn98otAAAAAElFTkSuQmCC' ############## stuff taken from the remotes/origin/websocket-4173-4 # of https://github.com/twisted/twisted.git # This can be removed when websocket support is merged into twisted. # # The material until "End of twisted stuff" is under the twisted license: #Copyright (c) 2001-2013 #Allen Short #Andy Gayton #Andrew Bennetts #Antoine Pitrou #Apple Computer, Inc. #Benjamin Bruheim #Bob Ippolito #Canonical Limited #Christopher Armstrong #David Reid #Donovan Preston #Eric Mangold #Eyal Lotem #Itamar Turner-Trauring #James Knight #Jason A. Mobarak #Jean-Paul Calderone #Jessica McKellar #Jonathan Jacobs #Jonathan Lange #Jonathan D. Simms #Jürgen Hermann #Kevin Horn #Kevin Turner #Mary Gardiner #Matthew Lefkowitz #Massachusetts Institute of Technology #Moshe Zadka #Paul Swartz #Pavel Pergamenshchik #Ralph Meijer #Sean Riley #Software Freedom Conservancy #Travis B. Hartwell #Thijs Triemstra #Thomas Herve #Timothy Allen # #Permission is hereby granted, free of charge, to any person obtaining #a copy of this software and associated documentation files (the #"Software"), to deal in the Software without restriction, including #without limitation the rights to use, copy, modify, merge, publish, #distribute, sublicense, and/or sell copies of the Software, and to #permit persons to whom the Software is furnished to do so, subject to #the following conditions: # #The above copyright notice and this permission notice shall be #included in all copies or substantial portions of the Software. # #THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, #EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF #MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND #NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE #LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION #OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION #WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. from hashlib import sha1 from struct import pack, unpack from zope.interface import implementer, providedBy, directlyProvides, Interface from twisted.python import log from twisted.python.constants import Values, ValueConstant from twisted.internet.protocol import Protocol from twisted.protocols.tls import TLSMemoryBIOProtocol from twisted.web.resource import IResource from twisted.web.server import NOT_DONE_YET class _WSException(Exception): """ Internal exception for control flow inside the WebSockets frame parser. """ class CONTROLS(Values): """ Control frame specifiers. @since: 13.2 """ CONTINUE = ValueConstant(0) TEXT = ValueConstant(1) BINARY = ValueConstant(2) CLOSE = ValueConstant(8) PING = ValueConstant(9) PONG = ValueConstant(10) class STATUSES(Values): """ Closing status codes. @since: 13.2 """ NORMAL = ValueConstant(1000) GOING_AWAY = ValueConstant(1001) PROTOCOL_ERROR = ValueConstant(1002) UNSUPPORTED_DATA = ValueConstant(1003) NONE = ValueConstant(1005) ABNORMAL_CLOSE = ValueConstant(1006) INVALID_PAYLOAD = ValueConstant(1007) POLICY_VIOLATION = ValueConstant(1008) MESSAGE_TOO_BIG = ValueConstant(1009) MISSING_EXTENSIONS = ValueConstant(1010) INTERNAL_ERROR = ValueConstant(1011) TLS_HANDSHAKE_FAILED = ValueConstant(1056) # The GUID for WebSockets, from RFC 6455. _WS_GUID = b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11" def _makeAccept(key): """ Create an B{accept} response for a given key. @type key: C{str} @param key: The key to respond to. @rtype: C{str} @return: An encoded response. """ return base64.b64encode(sha1(b"%s%s" % (key, _WS_GUID)).digest()) def _mask(buf, key): """ Mask or unmask a buffer of bytes with a masking key. @type buf: C{str} @param buf: A buffer of bytes. @type key: C{str} @param key: The masking key. Must be exactly four bytes. @rtype: C{str} @return: A masked buffer of bytes. """ key = list(key) buf = list(buf) for i, char in enumerate(buf): buf[i] = char ^ key[i % 4] return bytes(buf) def _makeFrame(buf, opcode, fin, mask=None): """ Make a frame. This function always creates unmasked frames, and attempts to use the smallest possible lengths. @type buf: C{str} @param buf: A buffer of bytes. @type opcode: C{CONTROLS} @param opcode: Which type of frame to create. @type fin: C{bool} @param fin: Whether or not we're creating a final frame. @type mask: C{int} or C{NoneType} @param mask: If specified, the masking key to apply on the created frame. @rtype: C{str} @return: A packed frame. """ bufferLength = len(buf) if mask is not None: lengthMask = 0x80 else: lengthMask = 0 if bufferLength > 0xffff: length = bytes([lengthMask | 0x7f])+pack(">Q", bufferLength) elif bufferLength > 0x7d: length = bytes([lengthMask | 0x7e])+pack(">H", bufferLength) else: length = bytes([lengthMask | bufferLength]) if fin: header = 0x80 else: header = 0x01 header = bytes([header | opcode.value]) if mask is not None: buf = bytes([mask])+_mask(buf, mask) frame = header+length+buf return frame def _parseFrames(frameBuffer, needMask=True): """ Parse frames in a highly compliant manner. It modifies C{frameBuffer} removing the parsed content from it. @param frameBuffer: A buffer of bytes. @type frameBuffer: C{list} @param needMask: If C{True}, refuse any frame which is not masked. @type needMask: C{bool} """ start = 0 payload = b"".join(frameBuffer) while True: # If there's not at least two bytes in the buffer, bail. if len(payload) - start < 2: break # Grab the header. This single byte holds some flags and an opcode header = payload[start] if header & 0x70: # At least one of the reserved flags is set. Pork chop sandwiches! raise _WSException("Reserved flag in frame (%d)" % (header,)) fin = header & 0x80 # Get the opcode, and translate it to a local enum which we actually # care about. opcode = header & 0xf try: opcode = CONTROLS.lookupByValue(opcode) except ValueError: raise _WSException("Unknown opcode %d in frame" % opcode) # Get the payload length and determine whether we need to look for an # extra length. length = payload[start + 1] masked = length & 0x80 if not masked and needMask: # The client must mask the data sent raise _WSException("Received data not masked") length &= 0x7f # The offset we'll be using to walk through the frame. We use this # because the offset is variable depending on the length and mask. offset = 2 # Extra length fields. if length == 0x7e: if len(payload) - start < 4: break length = payload[start + 2:start + 4] length = unpack(">H", length)[0] offset += 2 elif length == 0x7f: if len(payload) - start < 10: break # Protocol bug: The top bit of this long long *must* be cleared; # that is, it is expected to be interpreted as signed. length = payload[start + 2:start + 10] length = unpack(">Q", length)[0] offset += 8 if masked: if len(payload) - (start + offset) < 4: # This is not strictly necessary, but it's more explicit so # that we don't create an invalid key. break key = payload[start + offset:start + offset + 4] offset += 4 if len(payload) - (start + offset) < length: break data = payload[start + offset:start + offset + length] if masked: data = _mask(data, key) if opcode == CONTROLS.CLOSE: if len(data) >= 2: # Gotta unpack the opcode and return usable data here. code = STATUSES.lookupByValue(unpack(">H", data[:2])[0]) data = code, data[2:] else: # No reason given; use generic data. data = STATUSES.NONE, b"" yield opcode, data, bool(fin) start += offset + length if len(payload) > start: frameBuffer[:] = [payload[start:]] else: frameBuffer[:] = [] class IWebSocketsFrameReceiver(Interface): """ An interface for receiving WebSockets frames. @since: 13.2 """ def makeConnection(transport): """ Notification about the connection. @param transport: A L{WebSocketsTransport} instance wrapping an underlying transport. @type transport: L{WebSocketsTransport}. """ def frameReceived(opcode, data, fin): """ Callback when a frame is received. @type opcode: C{CONTROLS} @param opcode: The type of frame received. @type data: C{bytes} @param data: The content of the frame received. @type fin: C{bool} @param fin: Whether or not the frame is final. """ class WebSocketsTransport(object): """ A frame transport for WebSockets. @ivar _transport: A reference to the real transport. @since: 13.2 """ _disconnecting = False def __init__(self, transport): self._transport = transport def sendFrame(self, opcode, data, fin): """ Build a frame packet and send it over the wire. @type opcode: C{CONTROLS} @param opcode: The type of frame to send. @type data: C{bytes} @param data: The content of the frame to send. @type fin: C{bool} @param fin: Whether or not we're sending a final frame. """ packet = _makeFrame(data, opcode, fin) self._transport.write(packet) def loseConnection(self, code=STATUSES.NORMAL, reason=""): """ Close the connection. This includes telling the other side we're closing the connection. If the other side didn't signal that the connection is being closed, then we might not see their last message, but since their last message should, according to the spec, be a simple acknowledgement, it shouldn't be a problem. @param code: The closing frame status code. @type code: L{STATUSES} @param reason: Optionally, a utf-8 encoded text explaining why the connection was closed. @param reason: C{bytes} """ # Send a closing frame. It's only polite. (And might keep the browser # from hanging.) if not self._disconnecting: data = b"%s%s" % (pack(">H", code.value), reason) frame = _makeFrame(data, CONTROLS.CLOSE, True) self._transport.write(frame) self._disconnecting = True self._transport.loseConnection() class WebSocketsProtocol(Protocol): """ A protocol parsing WebSockets frames and interacting with a L{IWebSocketsFrameReceiver} instance. @ivar _receiver: The L{IWebSocketsFrameReceiver} provider handling the frames. @type _receiver: L{IWebSocketsFrameReceiver} provider @ivar _buffer: The pending list of frames not processed yet. @type _buffer: C{list} @since: 13.2 """ _buffer = None def __init__(self, receiver): self._receiver = receiver def connectionMade(self): """ Log the new connection and initialize the buffer list. """ peer = self.transport.getPeer() log.msg(format="Opening connection with %(peer)s", peer=peer) self._buffer = [] self._receiver.makeConnection(WebSocketsTransport(self.transport)) def _parseFrames(self): """ Find frames in incoming data and pass them to the underlying protocol. """ for opcode, data, fin in _parseFrames(self._buffer): self._receiver.frameReceived(opcode, data, fin) if opcode == CONTROLS.CLOSE: # The other side wants us to close. code, reason = data msgFormat = "Closing connection: %(code)r" if reason: msgFormat += " (%(reason)r)" log.msg(format=msgFormat, reason=reason, code=code) # Close the connection. self.transport.loseConnection() return elif opcode == CONTROLS.PING: # 5.5.2 PINGs must be responded to with PONGs. # 5.5.3 PONGs must contain the data that was sent with the # provoking PING. self.transport.write(_makeFrame(data, CONTROLS.PONG, True)) def dataReceived(self, data): """ Append the data to the buffer list and parse the whole. @type data: C{bytes} @param data: The buffer received. """ self._buffer.append(data) try: self._parseFrames() except _WSException: # Couldn't parse all the frames, something went wrong, let's bail. log.err() self.transport.loseConnection() @implementer(IWebSocketsFrameReceiver) class _WebSocketsProtocolWrapperReceiver(): """ A L{IWebSocketsFrameReceiver} which accumulates data frames and forwards the payload to its C{wrappedProtocol}. @ivar _wrappedProtocol: The connected protocol @type _wrappedProtocol: C{IProtocol} provider. @ivar _transport: A reference to the L{WebSocketsTransport} @type _transport: L{WebSocketsTransport} @ivar _messages: The pending list of payloads received. @types _messages: C{list} """ def __init__(self, wrappedProtocol): self._wrappedProtocol = wrappedProtocol def makeConnection(self, transport): """ Keep a reference to the given C{transport} and instantiate the list of messages. """ self._transport = transport self._messages = [] def frameReceived(self, opcode, data, fin): """ For each frame received, accumulate the data (ignoring the opcode), and forwarding the messages if C{fin} is set. @type opcode: C{CONTROLS} @param opcode: The type of frame received. @type data: C{bytes} @param data: The content of the frame received. @type fin: C{bool} @param fin: Whether or not the frame is final. """ if not opcode in (CONTROLS.BINARY, CONTROLS.TEXT, CONTROLS.CONTINUE): return self._messages.append(data) if fin: content = b"".join(self._messages) self._messages[:] = [] self._wrappedProtocol.dataReceived(content) class WebSocketsProtocolWrapper(WebSocketsProtocol): """ A L{WebSocketsProtocol} which wraps a regular C{IProtocol} provider, ignoring the frame mechanism. @ivar _wrappedProtocol: The connected protocol @type _wrappedProtocol: C{IProtocol} provider. @ivar defaultOpcode: The opcode used when C{transport.write} is called. Defaults to L{CONTROLS.TEXT}, can be L{CONTROLS.BINARY}. @type defaultOpcode: L{CONTROLS} @since: 13.2 """ def __init__(self, wrappedProtocol, defaultOpcode=CONTROLS.TEXT): self.wrappedProtocol = wrappedProtocol self.defaultOpcode = defaultOpcode WebSocketsProtocol.__init__( self, _WebSocketsProtocolWrapperReceiver(wrappedProtocol)) def makeConnection(self, transport): """ Upon connection, provides the transport interface, and forwards ourself as the transport to C{self.wrappedProtocol}. @type transport: L{twisted.internet.interfaces.ITransport} provider. @param transport: The transport to use for the protocol. """ directlyProvides(self, providedBy(transport)) WebSocketsProtocol.makeConnection(self, transport) self.wrappedProtocol.makeConnection(self) def write(self, data): """ Write to the websocket protocol, transforming C{data} in a frame. @type data: C{bytes} @param data: Data buffer used for the frame content. """ self._receiver._transport.sendFrame(self.defaultOpcode, data, True) def writeSequence(self, data): """ Send all chunks from C{data} using C{write}. @type data: C{list} of C{bytes} @param data: Data buffers used for the frames content. """ for chunk in data: self.write(chunk) def loseConnection(self): """ Try to lose the connection gracefully when closing by sending a close frame. """ self._receiver._transport.loseConnection() def __getattr__(self, name): """ Forward all non-local attributes and methods to C{self.transport}. """ return getattr(self.transport, name) def connectionLost(self, reason): """ Forward C{connectionLost} to C{self.wrappedProtocol}. @type reason: L{twisted.python.failure.Failure} @param reason: A failure instance indicating the reason why the connection was lost. """ self.wrappedProtocol.connectionLost(reason) @implementer(IResource) class WebSocketsResource(object): """ A resource for serving a protocol through WebSockets. This class wraps a factory and connects it to WebSockets clients. Each connecting client will be connected to a new protocol of the factory. Due to unresolved questions of logistics, this resource cannot have children. @param lookupProtocol: A callable returning a tuple of (protocol instance, matched protocol name or C{None}) when called with a valid connection. It's called with a list of asked protocols from the client and the connecting client request. If the returned protocol name is specified, it is used as I{Sec-WebSocket-Protocol} value. If the protocol is a L{WebSocketsProtocol} instance, it will be connected directly, otherwise it will be wrapped by L{WebSocketsProtocolWrapper}. For simple use cases using a factory, you can use L{lookupProtocolForFactory}. @type lookupProtocol: C{callable}. @since: 13.2 """ isLeaf = True def __init__(self, lookupProtocol): self._lookupProtocol = lookupProtocol def getChildWithDefault(self, name, request): """ Reject attempts to retrieve a child resource. All path segments beyond the one which refers to this resource are handled by the WebSocket connection. @type name: C{bytes} @param name: A single path component from a requested URL. @type request: L{twisted.web.iweb.IRequest} provider @param request: The request received. """ raise RuntimeError( "Cannot get IResource children from WebSocketsResource") def putChild(self, path, child): """ Reject attempts to add a child resource to this resource. The WebSocket connection handles all path segments beneath this resource, so L{IResource} children can never be found. @type path: C{bytes} @param path: A single path component. @type child: L{IResource} provider @param child: A resource to put underneat this one. """ raise RuntimeError( "Cannot put IResource children under WebSocketsResource") def render(self, request): """ Render a request. We're not actually rendering a request. We are secretly going to handle a WebSockets connection instead. @param request: The connecting client request. @type request: L{RequestCannot create viewer: {}
'.format(str(ex)) ).encode("utf-8") def getChild(self, name, request): if name==b"": return self elif name==b"favicon.ico": return self.fav_icon elif name in self.children: return self.children[name] return NOT_FOUND_RESOURCE def parse_command_line(): import argparse parser = argparse.ArgumentParser( description="A little web server for looking" " at PDFs from multiple browsers.") parser.add_argument('-a', '--all-interfaces', action="store_true", dest="bind_to_all", help="Bind to all interfaces (rather than" " just locahost).") return parser.parse_args() def main(): args = parse_command_line() srv_opts = {} if args.bind_to_all: srv_opts["interface"] = "" else: srv_opts["interface"] = "localhost" srv = reactor.listenTCP(8900, server.Site(Root(), timeout=300), **srv_opts) log.startLogging(sys.stdout) reactor.run() if __name__=="__main__": main()