# -*- test-case-name: xmantissa.test.test_website -*- """ This module defines the basic engine for web sites and applications using Mantissa. It defines the basic in-database web server, and an authentication binding using nevow.guard. To interact with the code defined here, create a web site using the command-line 'axiomatic' program using the 'web' subcommand. """ import socket, warnings from zope.interface import implements from twisted.cred.portal import IRealm, Portal from twisted.cred.checkers import ICredentialsChecker, AllowAnonymousAccess from twisted.protocols import policies from twisted.internet import defer from twisted.application.service import IService from nevow.inevow import IResource from nevow.rend import NotFound, Page, Fragment from nevow import inevow from nevow.appserver import NevowSite, NevowRequest from nevow.static import File from nevow.url import URL from nevow import athena from axiom import upgrade from axiom.item import Item, _PowerupConnector, declareLegacyItem from axiom.attributes import AND, integer, inmemory, text, reference, bytes, boolean from axiom.userbase import LoginSystem from axiom.dependency import installOn from xmantissa.ixmantissa import ( ISiteRootPlugin, ISessionlessSiteRootPlugin, IProtocolFactoryFactory) from xmantissa import websession from xmantissa.port import TCPPort, SSLPort class WebConfigurationError(RuntimeError): """You specified some invalid configuration. """ class SiteRootMixin(object): implements(inevow.IResource) powerupInterface = ISiteRootPlugin def renderHTTP(self, ctx): raise NotImplementedError( "This _must_ be installed at the root of a server.") def locateChild(self, ctx, segments): if segments[0] == 'live': return athena.LivePage(None, None), segments[1:] self.hitCount += 1 shortcut = getattr(self, 'child_'+segments[0], None) if shortcut: # what is it, like the 80th implementation of this? res = shortcut(ctx) if res is not None: return res, segments[1:] s = self.store P = self.powerupInterface for plg in s.powerupsFor(P): childAndSegments = plg.resourceFactory(segments) if childAndSegments is not None: child, segments = childAndSegments # sanity # check/documentation; feel # free to remove return child, segments return NotFound class UnguardedWrapper(SiteRootMixin): implements(inevow.IResource) powerupInterface = ISessionlessSiteRootPlugin hitCount = 0 def __init__(self, store, guardedRoot): self.store = store self.guardedRoot = guardedRoot def locateChild(self, ctx, segments): request = inevow.IRequest(ctx) if segments[0] == 'login': securePort = inevow.IResource(self.store).securePort if not request.isSecure() and securePort is not None: url = URL.fromContext(ctx) newurl = url.secure(port=securePort.getHost().port) return newurl.click("/login"), () else: # This should be eliminated by having a regular child_login in # publicweb instead, I think, but for now we can eliminate a # confusing circular import --glyph from xmantissa.publicweb import LoginPage return LoginPage(self, self.store), segments[1:] x = SiteRootMixin.locateChild(self, ctx, segments) if x is not NotFound: return x def maybeSecure((child, segments)): if getattr(child, 'needsSecure', None): request = inevow.IRequest(ctx) if not request.isSecure(): url = URL.fromContext(ctx) newurl = url.secure(port=inevow.IResource( self.store).securePort.getHost().port) return newurl.click('/'.join(segments)), () return child, segments return defer.maybeDeferred(self.guardedRoot.locateChild, ctx, segments ).addCallback(maybeSecure) JUST_SLASH = ('',) class PrefixURLMixin(object): """ Mixin for use by I[Sessionlesss]SiteRootPlugin implementors; provides a resourceFactory method which looks for an C{prefixURL} string on self, and calls and returns self.createResource(). C{prefixURL} is a '/'-separated unicode string; it must be set before calling installOn. To respond to the url C{http://example.com/foo/bar}, use the prefixURL attribute u'foo/bar'. @ivar sessioned: Boolean indicating whether this object should powerup for L{ISiteRootPlugin}. Note: this is only tested when L{installOn} is called. If you change it later, it will have no impact. @ivar sessionless: Boolean indicating whether this object should powerup for ISessionlessSiteRootPlugin. This is tested at the same time as L{sessioned}. """ sessioned = False sessionless = False def __str__(self): return '/%s => item(%s)' % (self.prefixURL, self.__class__.__name__) def createResource(self): """ Create and return an IResource. This will only be invoked if the request matches the prefixURL specified on this object. May also return None to indicate that this object does not actually want to handle this request. """ raise NotImplementedError( "PrefixURLMixin.createResource() should be " "implemented by subclasses (%r didn't)" % ( self.__class__.__name__,)) def resourceFactory(self, segments): """Return a C{(resource, subsegments)} tuple or None, depending on whether I wish to return an IResource provider for the given set of segments or not. """ if not self.prefixURL: needle = () else: needle = tuple(self.prefixURL.split('/')) S = len(needle) if segments[:S] == needle: if segments == JUST_SLASH: # I *HATE* THE WEB subsegments = segments else: subsegments = segments[S:] res = self.createResource() # Even though the URL matched up, sometimes we might still # decide to not handle this request (eg, some prerequisite # for our function is not met by the store). Allow None # to be returned by createResource to indicate this case. if res is not None: return res, subsegments def __getPowerupInterfaces__(self, powerups): """ Install me on something (probably a Store) that will be queried for ISiteRootPlugin providers. """ #First, all the other powerups for x in powerups: yield x # Only 256 segments are allowed in URL paths. We want to make sure # that static powerups always lose priority ordering to dynamic # powerups, since dynamic powerups will have information pURL = self.prefixURL priority = (pURL.count('/') - 256) if pURL == '': # Did I mention I hate the web? Plugins at / are special in 2 # ways. Their segment length is kinda-sorta like 0 most of the # time, except when it isn't. We subtract from the priority here # to make sure that [''] is lower-priority than ['foo'] even though # they are technically the same number of segments; the reason for # this is that / is special in that it pretends to be the parent of # everything and will score a hit for *any* URL in the hierarchy. # Above, we special-case JUST_SLASH to make sure that the other # half of this special-casing holds true. priority -= 1 if not self.sessioned and not self.sessionless: warnings.warn( "Set either sessioned or sessionless on %r! Falling back to " "deprecated providedBy() behavior" % (self.__class__.__name__,), DeprecationWarning, stacklevel=2) for iface in ISessionlessSiteRootPlugin, ISiteRootPlugin: if iface.providedBy(self): yield (iface, priority) else: if self.sessioned: yield (ISiteRootPlugin, priority) if self.sessionless: yield (ISessionlessSiteRootPlugin, priority) class StaticSite(PrefixURLMixin, Item): implements(ISessionlessSiteRootPlugin, # implements both so that it ISiteRootPlugin) # works in both super and sub # stores. typeName = 'static_web_site' schemaVersion = 2 prefixURL = text() staticContentPath = text() sessioned = boolean(default=False) sessionless = boolean(default=True) def __str__(self): return '/%s => file(%s)' % (self.prefixURL, self.staticContentPath) def createResource(self): return File(self.staticContentPath) def installSite(self): """ Not using the dependency system for this class because it's only installed via the command line, and multiple instances can be installed. """ for iface, priority in self.__getPowerupInterfaces__([]): self.store.powerUp(self, iface, priority) def upgradeStaticSite1To2(oldSite): newSite = oldSite.upgradeVersion( 'static_web_site', 1, 2, staticContentPath=oldSite.staticContentPath, prefixURL=oldSite.prefixURL, sessionless=True) for pc in newSite.store.query(_PowerupConnector, AND(_PowerupConnector.powerup == newSite, _PowerupConnector.interface == u'xmantissa.ixmantissa.ISiteRootPlugin')): pc.item.powerDown(newSite, ISiteRootPlugin) return newSite upgrade.registerUpgrader(upgradeStaticSite1To2, 'static_web_site', 1, 2) class StaticRedirect(Item, PrefixURLMixin): implements(inevow.IResource, ISessionlessSiteRootPlugin, ISiteRootPlugin) schemaVersion = 2 typeName = 'web_static_redirect' targetURL = text(allowNone=False) prefixURL = text(allowNone=False) sessioned = boolean(default=True) sessionless = boolean(default=True) def __str__(self): return '/%s => url(%s)' % (self.prefixURL, self.targetURL) def locateChild(self, ctx, segments): return self, () def renderHTTP(self, ctx): return URL.fromContext(ctx).click(self.targetURL) def createResource(self): return self def upgradeStaticRedirect1To2(oldRedirect): newRedirect = oldRedirect.upgradeVersion( 'web_static_redirect', 1, 2, targetURL=oldRedirect.targetURL, prefixURL=oldRedirect.prefixURL) if newRedirect.prefixURL == u'': newRedirect.sessionless = False for pc in newRedirect.store.query(_PowerupConnector, AND(_PowerupConnector.powerup == newRedirect, _PowerupConnector.interface == u'xmantissa.ixmantissa.ISessionlessSiteRootPlugin')): pc.item.powerDown(newRedirect, ISessionlessSiteRootPlugin) return newRedirect upgrade.registerUpgrader(upgradeStaticRedirect1To2, 'web_static_redirect', 1, 2) class AxiomRequest(NevowRequest): def __init__(self, store, *a, **kw): NevowRequest.__init__(self, *a, **kw) self.store = store def process(self, *a, **kw): return self.store.transact(NevowRequest.process, self, *a, **kw) class AxiomSite(NevowSite): def __init__(self, store, *a, **kw): NevowSite.__init__(self, *a, **kw) self.store = store self.requestFactory = lambda *a, **kw: AxiomRequest(self.store, *a, **kw) class AxiomPage(Page): def renderHTTP(self, ctx): return self.store.transact(Page.renderHTTP, self, ctx) class AxiomFragment(Fragment): def rend(self, ctx, data): return self.store.transact(Fragment.rend, self, ctx, data) class WebSite(Item, SiteRootMixin): """ Govern an HTTP server which binds a port on startup and tears it down at shutdown using the Twisted Service system. Unfortunately, also provide web pages. These two tasks should be the responsibility of two separate Items, but writing the upgrader to fix this won't be fun so I don't want to do it. Someone else should though. """ implements(IProtocolFactoryFactory, IResource) powerupInterfaces = (IProtocolFactoryFactory, IResource) typeName = 'mantissa_web_powerup' schemaVersion = 5 hitCount = integer(default=0) installedOn = reference() hostname = text(doc=""" The primary hostname by which this website will be accessible. If set to C{None}, a guess will be made using L{socket.getfqdn}. """, default=None) httpLog = bytes(default=None) site = inmemory() debug = False def securePort(): """ Define a backwards compatibility property for the IListeningPort for the HTTPS server which used to be an attribute on WebSite. """ def get(self): warnings.warn( "WebSite.securePort is deprecated!", stacklevel=3, category=DeprecationWarning) port = self._getPort(SSLPort) if port is not None: return port.listeningPort return None return get, securePort = property(*securePort()) def activate(self): self.site = None def _root(self, scheme, hostname, portObj, standardPort): # TODO - real unicode support (but punycode is so bad) if portObj is None: return None portNumber = portObj.portNumber port = portObj.listeningPort if hostname is None: if self.hostname is None: hostname = socket.getfqdn() else: hostname = self.hostname.encode('ascii') else: hostname = hostname.split(':')[0].encode('ascii') if portNumber == 0: if port is None: return None else: portNumber = port.getHost().port if portNumber == standardPort: return URL(scheme, hostname, ['']) else: return URL(scheme, '%s:%d' % (hostname, portNumber), ['']) def _getPort(self, portType): return self.store.findFirst( portType, portType.factory == self, default=None) def cleartextRoot(self, hostname=None): """ Return a string representing the HTTP URL which is at the root of this site. @param hostname: An optional unicode string which, if specified, will be used as the hostname in the resulting URL, regardless of the C{hostname} attribute of this item. """ return self._root('http', hostname, self._getPort(TCPPort), 80) def encryptedRoot(self, hostname=None): """ Return a string representing the HTTPS URL which is at the root of this site. @param hostname: An optional unicode string which, if specified, will be used as the hostname in the resulting URL, regardless of the C{hostname} attribute of this item. """ return self._root('https', hostname, self._getPort(SSLPort), 443) def maybeEncryptedRoot(self, hostname=None): """ Returning a string representing the HTTPS URL which is at the root of this site, falling back to HTTP if HTTPS service is not available. @param hostname: An optional unicode string which, if specified, will be used as the hostname in the resulting URL, regardless of the C{hostname} attribute of this item. """ root = self.encryptedRoot(hostname) if root is None: root = self.cleartextRoot(hostname) return root def child_by(self, ctx): from xmantissa.websharing import UserIndexPage ls = self.store.findUnique(LoginSystem, default=None) if ls is None: return None return UserIndexPage(ls) child_users = child_by def child_resetPassword(self, ctx): from xmantissa.signup import PasswordResetResource return PasswordResetResource(self.store) # IProtocolFactoryFactory def getFactory(self): """ Create an L{AxiomSite} which supports authenticated and anonymous access. """ if self.site is None: realm = IRealm(self.store, None) if realm is None: raise WebConfigurationError( 'No realm: ' 'you need to install a userbase before using this service.') chkr = ICredentialsChecker(self.store, None) if chkr is None: raise WebConfigurationError( 'No checkers: ' 'you need to install a userbase before using this service.') guardedRoot = websession.PersistentSessionWrapper( self.store, Portal(realm, [chkr, AllowAnonymousAccess()])) self.site = AxiomSite( self.store, UnguardedWrapper(self.store, guardedRoot), logPath=self.httpLog) if self.debug: self.site = policies.TrafficLoggingFactory(self.site, 'http') return self.site def setServiceParent(self, parent): """ Compatibility hack necessary to prevent the Axiom service startup mechanism from barfing. Even though this Item is no longer an IService powerup, it will still be found as one one more time and this method will be called on it. """ def upgradeWebSite1To2(oldSite): newSite = oldSite.upgradeVersion( 'mantissa_web_powerup', 1, 2, hitCount=oldSite.hitCount, installedOn=oldSite.installedOn, portNumber=oldSite.portNumber, securePortNumber=oldSite.securePortNumber, certificateFile=oldSite.certificateFile, httpLog=None) return newSite upgrade.registerUpgrader(upgradeWebSite1To2, 'mantissa_web_powerup', 1, 2) def upgradeWebSite2to3(oldSite): # This is dumb and we should have a way to run procedural upgraders. newSite = oldSite.upgradeVersion( 'mantissa_web_powerup', 2, 3, hitCount=oldSite.hitCount, installedOn=oldSite.installedOn, portNumber=oldSite.portNumber, securePortNumber=oldSite.securePortNumber, certificateFile=oldSite.certificateFile, httpLog=oldSite.httpLog) staticMistake = newSite.store.findUnique(StaticSite, StaticSite.prefixURL == u'static/mantissa', default=None) if staticMistake is not None: # Ugh, need cascading deletes staticMistake.store.powerDown(staticMistake, ISessionlessSiteRootPlugin) staticMistake.deleteFromStore() return newSite upgrade.registerUpgrader(upgradeWebSite2to3, 'mantissa_web_powerup', 2, 3) def upgradeWebsite3to4(oldSite): """ Add a C{None} hostname attribute. """ newSite = oldSite.upgradeVersion( 'mantissa_web_powerup', 3, 4, installedOn=oldSite.installedOn, portNumber=oldSite.portNumber, securePortNumber=oldSite.securePortNumber, certificateFile=oldSite.certificateFile, httpLog=oldSite.httpLog, hitCount=oldSite.hitCount, hostname=None) return newSite upgrade.registerUpgrader(upgradeWebsite3to4, 'mantissa_web_powerup', 3, 4) declareLegacyItem( WebSite.typeName, 4, dict(hitCount=integer(default=0), installedOn=reference(), hostname=text(default=None), portNumber=integer(default=0), securePortNumber=integer(default=0), certificateFile=bytes(default=0), httpLog=bytes(default=None))) def upgradeWebsite4to5(oldSite): """ Create TCPPort and SSLPort items as appropriate. """ newSite = oldSite.upgradeVersion( 'mantissa_web_powerup', 4, 5, installedOn=oldSite.installedOn, httpLog=oldSite.httpLog, hitCount=oldSite.hitCount, hostname=oldSite.hostname) if oldSite.portNumber is not None: port = TCPPort(store=newSite.store, portNumber=oldSite.portNumber, factory=newSite) installOn(port, newSite.store) securePortNumber = oldSite.securePortNumber certificateFile = oldSite.certificateFile if securePortNumber is not None and certificateFile: oldCertPath = newSite.store.dbdir.preauthChild(certificateFile) if oldCertPath.exists(): newCertPath = newSite.store.newFilePath('server.pem') oldCertPath.copyTo(newCertPath) port = SSLPort(store=newSite.store, portNumber=oldSite.securePortNumber, factory=newSite, certificatePath=newCertPath) installOn(port, newSite.store) try: newSite.store.powerDown(newSite, IService) except ValueError: #maybe it wasn't powered up? pass return newSite upgrade.registerUpgrader(upgradeWebsite4to5, 'mantissa_web_powerup', 4, 5)