def discover(): source = util.load_source() # validate URL, find silo post url = request.form['url'] domain = util.domain_from_link(url) path = urllib.parse.urlparse(url).path msg = 'Discovering now. Refresh in a minute to see the results!' gr_source = source.gr_source if domain == gr_source.DOMAIN: post_id = gr_source.post_id(url) if post_id: type = 'event' if path.startswith('/events/') else None util.add_discover_task(source, post_id, type=type) else: msg = f"Sorry, that doesn't look like a {gr_source.NAME} post URL." elif util.domain_or_parent_in(domain, source.domains): synd_links = original_post_discovery.process_entry( source, url, {}, False, []) if synd_links: for link in synd_links: util.add_discover_task(source, gr_source.post_id(link)) source.updates = {'last_syndication_url': util.now_fn()} models.Source.put_updates(source) else: msg = f'Failed to fetch {util.pretty_link(url)} or find a {gr_source.NAME} syndication link.' else: msg = f'Please enter a URL on either your web site or {gr_source.NAME}.' flash(msg) return redirect(source.bridgy_url())
def finish(self, auth_entity, state=None): self.state = util.decode_oauth_state(state) if not state: self.error( 'If you want to publish or preview, please approve the prompt.' ) return redirect('/') source = ndb.Key(urlsafe=self.state['source_key']).get() if auth_entity is None: self.error( 'If you want to publish or preview, please approve the prompt.' ) elif not auth_entity.is_authority_for(source.auth_entity): self.error( f'Please log into {source.GR_CLASS.NAME} as {source.name} to publish that page.' ) else: result = self._run() if result and result.content: flash( f"Done! <a href=\"{self.entity.published.get('url')}\">Click here to view.</a>" ) granary_message = self.entity.published.get('granary_message') if granary_message: flash(granary_message) # otherwise error() added an error message return redirect(source.bridgy_url())
def new(auth_entity=None, blog_id=None, **kwargs): """Creates and returns a Blogger for the logged in user. Args: auth_entity: :class:`oauth_dropins.blogger.BloggerV2Auth` blog_id: which blog. optional. if not provided, uses the first available. """ urls, domains = Blogger.urls_and_domains(auth_entity, blog_id=blog_id) if not urls or not domains: flash('Blogger blog not found. Please create one first!') return None if blog_id is None: for blog_id, hostname in zip(auth_entity.blog_ids, auth_entity.blog_hostnames): if domains[0] == hostname: break else: assert False, "Internal error, shouldn't happen" return Blogger(id=blog_id, auth_entity=auth_entity.key, url=urls[0], name=auth_entity.user_display_name(), domains=domains, domain_urls=urls, picture=auth_entity.picture_url, superfeedr_secret=util.generate_secret(), **kwargs)
def retry(): entity = util.load_source() if not isinstance(entity, Webmentions): error(f'Unexpected key kind {entity.key.kind()}') source = entity.source.get() # run OPD to pick up any new SyndicatedPosts. note that we don't refetch # their h-feed, so if they've added a syndication URL since we last crawled, # retry won't make us pick it up. background in #524. if entity.key.kind() == 'Response': source = entity.source.get() for activity in [json_loads(a) for a in entity.activities_json]: originals, mentions = original_post_discovery.discover( source, activity, fetch_hfeed=False, include_redirect_sources=False) entity.unsent += original_post_discovery.targets_for_response( json_loads(entity.response_json), originals=originals, mentions=mentions) entity.restart() flash('Retrying. Refresh in a minute to see the results!') return redirect(request.values.get('redirect_to') or source.bridgy_url())
def create_new(cls, user_url=None, **kwargs): """Creates and saves a new :class:`Source` and adds a poll task for it. Args: user_url: a string, optional. if provided, supersedes other urls when determining the author_url **kwargs: passed to :meth:`new()` Returns: newly created :class:`Source` """ source = cls.new(**kwargs) if source is None: return None if not source.domain_urls: # defer to the source if it already set this auth_entity = kwargs.get('auth_entity') if auth_entity and hasattr(auth_entity, 'user_json'): source.domain_urls, source.domains = source.urls_and_domains( auth_entity, user_url) logger.debug(f'URLs/domains: {source.domain_urls} {source.domains}') # check if this source already exists existing = source.key.get() if existing: # merge some fields source.features = set(source.features + existing.features) source.populate(**existing.to_dict(include=( 'created', 'last_hfeed_refetch', 'last_poll_attempt', 'last_polled', 'last_syndication_url', 'last_webmention_sent', 'superfeedr_secret', 'webmention_endpoint'))) verb = 'Updated' else: verb = 'Added' author_urls = source.get_author_urls() link = ('http://indiewebify.me/send-webmentions/?url=' + author_urls[0] if author_urls else 'http://indiewebify.me/#send-webmentions') feature = source.features[0] if source.features else 'listen' blurb = '%s %s. %s' % ( verb, source.label(), 'Try previewing a post from your web site!' if feature == 'publish' else '<a href="%s">Try a webmention!</a>' % link if feature == 'webmention' else "Refresh in a minute to see what we've found!") logger.info(f'{blurb} {source.bridgy_url()}') source.verify() if source.verified(): flash(blurb) source.put() if 'webmention' in source.features: superfeedr.subscribe(source) if 'listen' in source.features and source.AUTO_POLL: util.add_poll_task(source, now=True) util.add_poll_task(source) return source
def add_or_update_domain(): domain = Domain.get_or_insert( util.domain_from_link( util.replace_test_domains_with_localhost( auth_entity.key.id()))) domain.auth = auth_entity.key if state not in domain.tokens: domain.tokens.append(state) domain.put() flash(f'Authorized you for {domain.key.id()}.')
def error(self, error, html=None, status=400, data=None, report=False, **kwargs): logging.info(f'publish: {error}') error = html or util.linkify(error) flash(f'{error}') if report: self.report_error(error, status=status)
def dispatch_request(self): token = request.form['token'] try: to_url = self.redirect_url(state=token) except Exception as e: if util.is_connection_failure(e) or util.interpret_http_exception( e)[0]: flash(f"Couldn't fetch your web site: {e}") return redirect('/') raise return redirect(to_url)
def redirect_url(self, *args, **kwargs): features = (request.form.get('feature') or '').split(',') starter = util.oauth_starter(StartBase)( '/mastodon/callback', scopes=PUBLISH_SCOPES if 'publish' in features else LISTEN_SCOPES) try: return starter.redirect_url(*args, instance=request.form['instance'], **kwargs) except ValueError as e: logger.warning('Bad Mastodon instance', exc_info=True) flash(util.linkify(str(e), pretty=True)) redirect(request.path)
def crawl_now(): source = None @ndb.transactional() def setup_refetch_hfeed(): nonlocal source source = util.load_source() source.last_hfeed_refetch = models.REFETCH_HFEED_TRIGGER source.last_feed_syndication_url = None source.put() setup_refetch_hfeed() util.add_poll_task(source, now=True) flash("Crawling now. Refresh in a minute to see what's new!") return redirect(source.bridgy_url())
def finish(self, auth_entity, state=None): if auth_entity: if int(auth_entity.blog_id) == 0: flash('Please try again and choose a blog before clicking Authorize.') return redirect('/') # Check if this is a self-hosted WordPress blog site_info = WordPress.get_site_info(auth_entity) if site_info is None: return elif site_info.get('jetpack'): logger.info(f'This is a self-hosted WordPress blog! {auth_entity.key_id()} {auth_entity.blog_id}') return render_template('confirm_self_hosted_wordpress.html', auth_entity_key=auth_entity.key.urlsafe().decode(), state=state) util.maybe_add_or_delete_source(WordPress, auth_entity, state)
def get_site_info(cls, auth_entity): """Fetches the site info from the API. Args: auth_entity: :class:`oauth_dropins.wordpress_rest.WordPressAuth` Returns: site info dict, or None if API calls are disabled for this blog """ try: return cls.urlopen(auth_entity, API_SITE_URL % auth_entity.blog_id) except urllib.error.HTTPError as e: code, body = util.interpret_http_exception(e) if (code == '403' and '"API calls to this blog have been disabled."' in body): flash(f'You need to <a href="http://jetpack.me/support/json-api/">enable the Jetpack JSON API</a> in {util.pretty_link(auth_entity.blog_url)}\'s WordPress admin console.') redirect('/') return None raise
def oauth_callback(): """OAuth callback handler. Both the add and delete flows have to share this because Blogger's oauth-dropin doesn't yet allow multiple callback handlers. :/ """ auth_entity = None auth_entity_str_key = request.values.get('auth_entity') if auth_entity_str_key: auth_entity = ndb.Key(urlsafe=auth_entity_str_key).get() if not auth_entity.blog_ids or not auth_entity.blog_hostnames: auth_entity = None if not auth_entity: flash("Couldn't fetch your blogs. Maybe you're not a Blogger user?") state = request.values.get('state') if not state: state = util.construct_state_param_for_add(feature='webmention') if not auth_entity: util.maybe_add_or_delete_source(Blogger, auth_entity, state) return vars = { 'action': '/blogger/add', 'state': state, 'operation': util.decode_oauth_state(state).get('operation'), 'auth_entity_key': auth_entity.key.urlsafe().decode(), 'blogs': [{ 'id': id, 'title': title, 'domain': host } for id, title, host in zip(auth_entity.blog_ids, auth_entity. blog_titles, auth_entity.blog_hostnames)], } logger.info(f'Rendering choose_blog.html with {vars}') return render_template('choose_blog.html', **vars)
def new(auth_entity=None, blog_name=None, **kwargs): """Creates and returns a :class:`Tumblr` for the logged in user. Args: auth_entity: :class:`oauth_dropins.tumblr.TumblrAuth` blog_name: which blog. optional. passed to urls_and_domains. """ urls, domains = Tumblr.urls_and_domains(auth_entity, blog_name=blog_name) if not urls or not domains: flash('Tumblr blog not found. Please create one first!') return None id = domains[0] return Tumblr(id=id, auth_entity=auth_entity.key, domains=domains, domain_urls=urls, name=auth_entity.user_display_name(), picture=TUMBLR_AVATAR_URL % id, superfeedr_secret=util.generate_secret(), **kwargs)
def delete_start(): source = util.load_source() kind = source.key.kind() feature = request.form['feature'] state = util.encode_oauth_state({ 'operation': 'delete', 'feature': feature, 'source': source.key.urlsafe().decode(), 'callback': request.values.get('callback'), }) # Blogger don't support redirect_url() yet if kind == 'Blogger': return redirect(f'/blogger/delete/start?state={state}') path = ('/reddit/callback' if kind == 'Reddit' else '/wordpress/add' if kind == 'WordPress' else f'/{source.SHORT_NAME}/delete/finish') kwargs = {} if kind == 'Twitter': kwargs['access_type'] = 'read' if feature == 'listen' else 'write' try: return redirect(source.OAUTH_START(path).redirect_url(state=state)) except werkzeug.exceptions.HTTPException: # raised by us, probably via self.error() raise except Exception as e: code, body = util.interpret_http_exception(e) if not code and util.is_connection_failure(e): code = '-' body = str(e) if code: flash(f'{source.GR_CLASS.NAME} API error {code}: {body}') return redirect(source.bridgy_url()) else: raise
def edit_websites_post(): source = util.load_source() redirect_url = f'{request.path}?{urllib.parse.urlencode({"source_key": source.key.urlsafe().decode()})}' add = request.values.get('add') delete = request.values.get('delete') if (add and delete) or (not add and not delete): error('Either add or delete param (but not both) required') link = util.pretty_link(add or delete) if add: resolved = Source.resolve_profile_url(add) if resolved: if resolved in source.domain_urls: flash(f'{link} already exists.') else: source.domain_urls.append(resolved) domain = util.domain_from_link(resolved) source.domains.append(domain) source.put() flash(f'Added {link}.') else: flash(f"{link} doesn't look like your web site. Try again?") else: assert delete try: source.domain_urls.remove(delete) except ValueError: error( f"{delete} not found in {source.label()}'s current web sites") domain = util.domain_from_link(delete) if domain not in { util.domain_from_link(url) for url in source.domain_urls }: source.domains.remove(domain) source.put() flash(f'Removed {link}.') return redirect(redirect_url)
def maybe_add_or_delete_source(source_cls, auth_entity, state, **kwargs): """Adds or deletes a source if auth_entity is not None. Used in each source's oauth-dropins :meth:`Callback.finish()` and :meth:`Callback.get()` methods, respectively. Args: source_cls: source class, e.g. :class:`instagram.Instagram` auth_entity: ouath-dropins auth entity state: string, OAuth callback state parameter. a JSON serialized dict with operation, feature, and an optional callback URL. For deletes, it will also include the source key kwargs: passed through to the source_cls constructor Returns: source entity if it was created or updated, otherwise None """ state_obj = util.decode_oauth_state(state) operation = state_obj.get('operation', 'add') feature = state_obj.get('feature') callback = state_obj.get('callback') user_url = state_obj.get('user_url') logger.debug( 'maybe_add_or_delete_source with operation=%s, feature=%s, callback=%s', operation, feature, callback) logins = None if operation == 'add': # this is an add/update if not auth_entity: # TODO: only show if we haven't already flashed another message? # get_flashed_messages() caches so it's dangerous to call to check; # use eg session.get('_flashes', []) instead. # https://stackoverflow.com/a/17243946/186123 flash("OK, you're not signed up. Hope you reconsider!") if callback: callback = util.add_query_params(callback, {'result': 'declined'}) logger.debug( f'user declined adding source, redirect to external callback {callback}' ) redirect(callback) else: redirect('/') logger.info( f'{source_cls.__class__.__name__}.create_new with {auth_entity.key}, {state}, {kwargs}' ) source = source_cls.create_new( auth_entity=auth_entity, features=feature.split(',') if feature else [], user_url=user_url, **kwargs) if source: # if we're normalizing username case to lower case to make the key id, check # if there's and old Source with a capitalized key id, and if so, disable it # https://github.com/snarfed/bridgy/issues/884 if source.USERNAME_KEY_ID and source.username != source.key_id(): @ndb.transactional() def maybe_disable_original(): orig = source_cls.get_by_id(source.username) if orig: logging.info( f'Disabling {orig.bridgy_url()} for lower case {source.bridgy_url()}' ) orig.features = [] orig.put() maybe_disable_original() # add to login cookie logins = get_logins() logins.append( Login(path=source.bridgy_path(), site=source.SHORT_NAME, name=source.label_name())) if callback: callback = util.add_query_params( callback, { 'result': 'success', 'user': source.bridgy_url(), 'key': source.key.urlsafe().decode(), } if source else {'result': 'failure'}) logger.debug( 'finished adding source, redirect to external callback %s', callback) redirect(callback, logins=logins) elif not source.domains: redirect('/edit-websites?' + urllib.parse.urlencode({ 'source_key': source.key.urlsafe().decode(), }), logins=logins) else: redirect(source.bridgy_url(), logins=logins) # no source redirect('/') else: # this is a delete if auth_entity: # TODO: remove from logins cookie redirect( f'/delete/finish?auth_entity={auth_entity.key.urlsafe().decode()}&state={state}' ) else: flash( f'If you want to disable, please approve the {source_cls.GR_CLASS.NAME} prompt.' ) source_key = state_obj.get('source') if source_key: source = ndb.Key(urlsafe=source_key).get() if source: redirect(source.bridgy_url()) redirect('/')
def delete_finish(): parts = util.decode_oauth_state(request.values.get('state') or '') callback = parts and parts.get('callback') if request.values.get('declined'): # disable declined means no change took place if callback: callback = util.add_query_params(callback, {'result': 'declined'}) return redirect(callback) else: flash('If you want to disable, please approve the prompt.') return redirect('/') return if not parts or 'feature' not in parts or 'source' not in parts: error('state query parameter must include "feature" and "source"') feature = parts['feature'] if feature not in (Source.FEATURES): error(f'cannot delete unknown feature {feature}') logged_in_as = ndb.Key(urlsafe=request.args['auth_entity']).get() source = ndb.Key(urlsafe=parts['source']).get() logins = None if logged_in_as and logged_in_as.is_authority_for(source.auth_entity): # TODO: remove credentials if feature in source.features: source.features.remove(feature) source.put() # remove login cookie logins = util.get_logins() login = util.Login(path=source.bridgy_path(), site=source.SHORT_NAME, name=source.label_name()) if login in logins: logins.remove(login) if callback: callback = util.add_query_params( callback, { 'result': 'success', 'user': source.bridgy_url(), 'key': source.key.urlsafe().decode(), }) else: nouns = { 'webmention': 'webmentions', 'listen': 'backfeed', 'publish': 'publishing', } msg = f'Disabled {nouns[feature]} for {source.label()}.' if not source.features: msg += ' Sorry to see you go!' flash(msg) elif callback: callback = util.add_query_params(callback, {'result': 'failure'}) else: flash( f'Please log into {source.GR_CLASS.NAME} as {source.name} to disable it here.' ) url = callback if callback else source.bridgy_url( ) if source.features else '/' return redirect(url, logins=logins)
def logout(): """Redirect to the front page.""" flash('Logged out.') return redirect('/', logins=[])
def poll_now(): source = util.load_source() util.add_poll_task(source, now=True) flash("Polling now. Refresh in a minute to see what's new!") return redirect(source.bridgy_url())