Adds the initial backend requirements for the OAuth flow, which will be made required for all search engine checks. This adds three new routes: - `/login` (GET, POST) - for logging in - `/logout` (GET, POST) - for logging out - `/oauth-callback` - OAuth 1.0a callback route Login/logout state can be checked through the header. By default, a separate page navigation is not required when the link is clicked by the user. When following a link to `/log(in|out)`, however, an extra button will be shown to prevent inadvertent logins/logouts.pull/52/head
| @@ -7,3 +7,4 @@ __pycache__ | |||
| .earwigbot | |||
| logs/* | |||
| !logs/.gitinclude | |||
| config.py | |||
| @@ -13,6 +13,7 @@ Dependencies | |||
| * [flask-mako](https://pythonhosted.org/Flask-Mako/) >= 0.3 | |||
| * [mako](https://www.makotemplates.org/) >= 0.7.2 | |||
| * [mwparserfromhell](https://github.com/earwig/mwparserfromhell) >= 0.3 | |||
| * [mwoauth](https://github.com/mediawiki-utilities/python-mwoauth) == 0.3.8 | |||
| * [oursql](https://pythonhosted.org/oursql/) >= 0.9.3.1 | |||
| * [requests](https://requests.readthedocs.io/) >= 2.9.1 | |||
| * [SQLAlchemy](https://www.sqlalchemy.org/) >= 0.9.6 | |||
| @@ -1,18 +1,19 @@ | |||
| #! /usr/bin/env python | |||
| # -*- coding: utf-8 -*- | |||
| from functools import wraps | |||
| from hashlib import md5 | |||
| from json import dumps | |||
| from logging import DEBUG, INFO, getLogger | |||
| from logging.handlers import TimedRotatingFileHandler | |||
| from multiprocessing import Value | |||
| from os import path | |||
| from time import asctime | |||
| from traceback import format_exc | |||
| from urllib import quote_plus, quote | |||
| from earwigbot.bot import Bot | |||
| from earwigbot.wiki.copyvios import globalize | |||
| from flask import Flask, g, make_response, request | |||
| from flask import Flask, g, make_response, request, redirect, session | |||
| from flask_mako import MakoTemplates, render_template, TemplateError | |||
| from copyvios.api import format_api_error, handle_api_request | |||
| @@ -21,12 +22,14 @@ from copyvios.cookies import parse_cookies | |||
| from copyvios.misc import cache, get_notice | |||
| from copyvios.settings import process_settings | |||
| from copyvios.sites import update_sites | |||
| from copyvios.auth import oauth_login_start, oauth_login_end, clear_login_session | |||
| app = Flask(__name__) | |||
| MakoTemplates(app) | |||
| hand = TimedRotatingFileHandler("logs/app.log", when="midnight", backupCount=7) | |||
| hand.setLevel(DEBUG) | |||
| app.config.from_pyfile("config.py", True) | |||
| app.logger.addHandler(hand) | |||
| app.logger.info(u"Flask server started " + asctime()) | |||
| app._hash_cache = {} | |||
| @@ -52,6 +55,12 @@ def setup_app(): | |||
| cache.background_data = {} | |||
| cache.last_background_updates = {} | |||
| oauth_config = cache.bot.config.wiki.get('copyvios', {}).get('oauth', {}) | |||
| if oauth_config.get('consumer_token') is None: | |||
| raise ValueError("No OAuth consumer token is configured (config.wiki.copyvios.oauth.consumer_token).") | |||
| if oauth_config.get('consumer_secret') is None: | |||
| raise ValueError("No OAuth consumer secret is configured (config.wiki.copyvios.oauth.consumer_secret).") | |||
| globalize(num_workers=8) | |||
| @app.before_request | |||
| @@ -101,10 +110,50 @@ def index(): | |||
| notice = get_notice() | |||
| update_sites() | |||
| query = do_check() | |||
| if query.submitted and query.error == "not logged in": | |||
| return redirect("/login?next=" + quote("/?" + request.query_string), 302) | |||
| return render_template( | |||
| "index.mako", notice=notice, query=query, result=query.result, | |||
| turnitin_result=query.turnitin_result) | |||
| @app.route("/login", methods=["GET", "POST"]) | |||
| @catch_errors | |||
| def login(): | |||
| try: | |||
| redirect_url = oauth_login_start() if request.method == "POST" else None | |||
| if redirect_url: | |||
| return redirect(redirect_url, 302) | |||
| except Exception as e: | |||
| app.log_exception(e) | |||
| print e.message | |||
| kwargs = {"error": e.message} | |||
| else: | |||
| if session.get("username") is not None: | |||
| return redirect("/", 302) | |||
| kwargs = {"error": request.args.get("error")} | |||
| return render_template("login.mako", **kwargs) | |||
| @app.route("/logout", methods=["GET", "POST"]) | |||
| @catch_errors | |||
| def logout(): | |||
| if request.method == "POST": | |||
| clear_login_session() | |||
| return redirect("/", 302) | |||
| else: | |||
| return render_template("logout.mako") | |||
| @app.route("/oauth-callback") | |||
| @catch_errors | |||
| def oauth_callback(): | |||
| try: | |||
| next_url = oauth_login_end() | |||
| except Exception as e: | |||
| app.log_exception(e) | |||
| return redirect("/login?error=" + quote_plus(e.message), 302) | |||
| else: | |||
| return redirect(next_url, 302) | |||
| @app.route("/settings", methods=["GET", "POST"]) | |||
| @catch_errors | |||
| def settings(): | |||
| @@ -10,6 +10,8 @@ from .sites import update_sites | |||
| __all__ = ["format_api_error", "handle_api_request"] | |||
| _CHECK_ERRORS = { | |||
| "not logged in": "You are required to log in with your Wikipedia account " | |||
| "to perform checks with the search engine", | |||
| "no search method": "Either 'use_engine' or 'use_links' must be true", | |||
| "bad oldid": "The revision ID is invalid", | |||
| "no URL": "The parameter 'url' is required for URL comparisons", | |||
| @@ -116,6 +118,7 @@ _HOOKS = { | |||
| def handle_api_request(): | |||
| query = Query() | |||
| query.api = True | |||
| if query.version: | |||
| try: | |||
| query.version = int(query.version) | |||
| @@ -0,0 +1,48 @@ | |||
| import mwoauth | |||
| from flask import session, request | |||
| from .misc import cache | |||
| __all__ = ["oauth_login_start", "oauth_login_end", "clear_login_session"] | |||
| def oauth_login_start(): | |||
| consumer_token = mwoauth.ConsumerToken( | |||
| cache.bot.config.wiki["copyvios"]["oauth"]["consumer_token"], | |||
| cache.bot.config.wiki["copyvios"]["oauth"]["consumer_secret"]) | |||
| redirect, request_token = mwoauth.initiate( | |||
| "https://meta.wikimedia.org/w/index.php", consumer_token) | |||
| session["request_token"] = dict(zip(request_token._fields, request_token)) | |||
| # Take note of where to send the user after logging in | |||
| next_url = (request.form if request.method == "POST" else request.args).get("next", "/") | |||
| if next_url[0] == "/": | |||
| # Only allow internal redirects | |||
| session["next"] = next_url | |||
| return redirect | |||
| def oauth_login_end(): | |||
| if "request_token" not in session: | |||
| raise ValueError("OAuth request token not found in session.") | |||
| consumer_token = mwoauth.ConsumerToken( | |||
| cache.bot.config.wiki["copyvios"]["oauth"]["consumer_token"], | |||
| cache.bot.config.wiki["copyvios"]["oauth"]["consumer_secret"]) | |||
| access_token = mwoauth.complete( | |||
| "https://meta.wikimedia.org/w/index.php", | |||
| consumer_token, | |||
| mwoauth.RequestToken(**session["request_token"]), | |||
| request.query_string) | |||
| identity = mwoauth.identify( | |||
| "https://meta.wikimedia.org/w/index.php", | |||
| consumer_token, | |||
| access_token) | |||
| session["access_token"] = dict(zip(access_token._fields, access_token)) | |||
| session["username"] = identity["username"] | |||
| return session.get("next", "/") | |||
| def clear_login_session(): | |||
| session.clear() | |||
| @@ -5,7 +5,7 @@ import datetime | |||
| from os.path import expanduser, join | |||
| import apsw | |||
| from flask import g, request | |||
| from flask import g, request, session | |||
| import oursql | |||
| from sqlalchemy.pool import manage | |||
| @@ -19,6 +19,7 @@ class Query(object): | |||
| data = request.form if method == "POST" else request.args | |||
| for key in data: | |||
| self.query[key] = data.getlist(key)[-1] | |||
| self.query["requester_username"] = session.get("username") | |||
| def __getattr__(self, key): | |||
| return self.query.get(key) | |||
| @@ -148,5 +148,44 @@ $(document).ready(function() { | |||
| }); | |||
| } | |||
| if ($(".login-link").length >= 0) { | |||
| $(".login-link").click(function(e) { | |||
| e.preventDefault(); | |||
| var $loginForm = $("<form>") | |||
| .attr("action", "/login") | |||
| .attr("method", "POST"); | |||
| // Tell `/login` where to go after logging in | |||
| $loginForm.append( | |||
| $("<input>") | |||
| .attr("type", "hidden") | |||
| .attr("name", "next") | |||
| .attr("value", window.location.pathname + window.location.search) | |||
| ) | |||
| $("body").after($loginForm); | |||
| $loginForm.trigger("submit"); | |||
| $loginForm.remove(); | |||
| return false; | |||
| }); | |||
| } | |||
| if ($(".logout-link").length >= 0) { | |||
| $(".logout-link").click(function(e) { | |||
| e.preventDefault(); | |||
| if (!confirm("Are you sure you want to log out?")) { | |||
| return; | |||
| } | |||
| var $logoutForm = $("<form>") | |||
| .attr("action", "/logout") | |||
| .attr("method", "POST"); | |||
| $("body").after($logoutForm); | |||
| $logoutForm.trigger("submit"); | |||
| $logoutForm.remove(); | |||
| return false; | |||
| }); | |||
| } | |||
| install_notice(); | |||
| }); | |||
| @@ -1 +1 @@ | |||
| function update_screen_size(){var cache=cache_cookie(),data={width:window.screen.availWidth,height:window.screen.availHeight};cache&&cache.width==data.width&&cache.height==data.height||set_cookie("CopyviosScreenCache",JSON.stringify(data),1095)}function cache_cookie(){var cookie=get_cookie("CopyviosScreenCache");if(cookie)try{var width=(data=JSON.parse(cookie)).width,height=data.height;if(width&&height)return{width:width,height:height}}catch(SyntaxError){}return!1}function get_cookie(name){for(var nameEQ=name+"=",ca=document.cookie.split(";"),i=0;i<ca.length;i++){for(var c=ca[i];" "==c.charAt(0);)c=c.substring(1,c.length);if(0==c.indexOf(nameEQ)){var value=window.atob(c.substring(nameEQ.length,c.length));if(0==value.indexOf("--cpv2"))return value.substring("--cpv2".length,value.length)}}return null}function set_cookie_with_date(name,value,date){value=window.btoa("--cpv2"+value);var path=window.location.pathname.split("/",2)[1];date=date?"; expires="+date.toUTCString():"",document.cookie=name+"="+value+date+"; path=/"+path}function set_cookie(name,value,days){var date;days?((date=new Date).setTime(date.getTime()+24*days*60*60*1e3),set_cookie_with_date(name,value,date)):set_cookie_with_date(name,value)}function delete_cookie(name){set_cookie(name,"",-1)}function toggle_notice(){var details=$("#notice-collapse-box"),trigger=$("#notice-collapse-trigger");details.is(":hidden")?(details.show(),trigger.text("[hide]")):(details.hide(),trigger.text("[show]"))}function install_notice(){var details=$("#notice-collapse-box"),trigger=$("#notice-collapse-trigger");0<=details.length&&0<=trigger.length&&(trigger.replaceWith($("<a/>",{id:"notice-collapse-trigger",href:"#",text:"[show]",click:function(){return toggle_notice(),!1}})),details.hide())}$(document).ready(function(){$("#action-search").change(function(){$(".cv-search").prop("disabled",!1),$(".cv-compare").prop("disabled",!0),$(".cv-search-oo-ui").addClass("oo-ui-widget-enabled").removeClass("oo-ui-widget-disabled"),$(".cv-compare-oo-ui").addClass("oo-ui-widget-disabled").removeClass("oo-ui-widget-enabled")}),$("#action-compare").change(function(){$(".cv-search").prop("disabled",!0),$(".cv-compare").prop("disabled",!1),$(".cv-search-oo-ui").addClass("oo-ui-widget-disabled").removeClass("oo-ui-widget-enabled"),$(".cv-compare-oo-ui").addClass("oo-ui-widget-enabled").removeClass("oo-ui-widget-disabled")}),$("#action-search").is(":checked")&&$("#action-search").change(),$("#action-compare").is(":checked")&&$("#action-compare").change(),$("#cv-form").submit(function(){$("#action-search").is(":checked")&&$.each([["engine","use_engine"],["links","use_links"],["turnitin","turnitin"]],function(i,val){$("#cv-cb-"+val[0]).is(":checked")&&$("#cv-form input[type='hidden'][name='"+val[1]+"']").prop("disabled",!0)}),$("#cv-form button[type='submit']").prop("disabled",!0).css("cursor","progress").parent().addClass("oo-ui-widget-disabled").removeClass("oo-ui-widget-enabled")}),0<=$("#cv-additional").length&&($("#cv-additional").css("display","block"),$(".source-default-hidden").css("display","none"),$("#show-additional-sources").click(function(){return $(".source-default-hidden").css("display",""),$("#cv-additional").css("display","none"),!1})),install_notice()}); | |||
| function update_screen_size(){var cache=cache_cookie(),data={width:window.screen.availWidth,height:window.screen.availHeight};cache&&cache.width==data.width&&cache.height==data.height||set_cookie("CopyviosScreenCache",JSON.stringify(data),1095)}function cache_cookie(){var cookie=get_cookie("CopyviosScreenCache");if(cookie)try{var width=(data=JSON.parse(cookie)).width,height=data.height;if(width&&height)return{width:width,height:height}}catch(SyntaxError){}return!1}function get_cookie(name){for(var nameEQ=name+"=",ca=document.cookie.split(";"),i=0;i<ca.length;i++){for(var c=ca[i];" "==c.charAt(0);)c=c.substring(1,c.length);if(0==c.indexOf(nameEQ)){var value=window.atob(c.substring(nameEQ.length,c.length));if(0==value.indexOf("--cpv2"))return value.substring("--cpv2".length,value.length)}}return null}function set_cookie_with_date(name,value,date){value=window.btoa("--cpv2"+value);var path=window.location.pathname.split("/",2)[1];date=date?"; expires="+date.toUTCString():"",document.cookie=name+"="+value+date+"; path=/"+path}function set_cookie(name,value,days){var date;days?((date=new Date).setTime(date.getTime()+24*days*60*60*1e3),set_cookie_with_date(name,value,date)):set_cookie_with_date(name,value)}function delete_cookie(name){set_cookie(name,"",-1)}function toggle_notice(){var details=$("#notice-collapse-box"),trigger=$("#notice-collapse-trigger");details.is(":hidden")?(details.show(),trigger.text("[hide]")):(details.hide(),trigger.text("[show]"))}function install_notice(){var details=$("#notice-collapse-box"),trigger=$("#notice-collapse-trigger");0<=details.length&&0<=trigger.length&&(trigger.replaceWith($("<a/>",{id:"notice-collapse-trigger",href:"#",text:"[show]",click:function(){return toggle_notice(),!1}})),details.hide())}$(document).ready(function(){$("#action-search").change(function(){$(".cv-search").prop("disabled",!1),$(".cv-compare").prop("disabled",!0),$(".cv-search-oo-ui").addClass("oo-ui-widget-enabled").removeClass("oo-ui-widget-disabled"),$(".cv-compare-oo-ui").addClass("oo-ui-widget-disabled").removeClass("oo-ui-widget-enabled")}),$("#action-compare").change(function(){$(".cv-search").prop("disabled",!0),$(".cv-compare").prop("disabled",!1),$(".cv-search-oo-ui").addClass("oo-ui-widget-disabled").removeClass("oo-ui-widget-enabled"),$(".cv-compare-oo-ui").addClass("oo-ui-widget-enabled").removeClass("oo-ui-widget-disabled")}),$("#action-search").is(":checked")&&$("#action-search").change(),$("#action-compare").is(":checked")&&$("#action-compare").change(),$("#cv-form").submit(function(){$("#action-search").is(":checked")&&$.each([["engine","use_engine"],["links","use_links"],["turnitin","turnitin"]],function(i,val){$("#cv-cb-"+val[0]).is(":checked")&&$("#cv-form input[type='hidden'][name='"+val[1]+"']").prop("disabled",!0)}),$("#cv-form button[type='submit']").prop("disabled",!0).css("cursor","progress").parent().addClass("oo-ui-widget-disabled").removeClass("oo-ui-widget-enabled")}),0<=$("#cv-additional").length&&($("#cv-additional").css("display","block"),$(".source-default-hidden").css("display","none"),$("#show-additional-sources").click(function(){return $(".source-default-hidden").css("display",""),$("#cv-additional").css("display","none"),!1})),0<=$(".login-link").length&&$(".login-link").click(function(e){e.preventDefault();e=$("<form>").attr("action","/login").attr("method","POST");return e.append($("<input>").attr("type","hidden").attr("name","next").attr("value",window.location.pathname+window.location.search)),$("body").after(e),e.trigger("submit"),e.remove(),!1}),0<=$(".logout-link").length&&$(".logout-link").click(function(e){if(e.preventDefault(),confirm("Are you sure you want to log out?"))return e=$("<form>").attr("action","/logout").attr("method","POST"),$("body").after(e),e.trigger("submit"),e.remove(),!1}),install_notice()}); | |||
| @@ -86,6 +86,21 @@ header h1 { | |||
| } | |||
| } | |||
| header .login-link, header .logout-link { | |||
| margin-right: 1em; | |||
| } | |||
| header .login-link::before, header .logout-link::before { | |||
| content: ' '; | |||
| font-size: 0.85em; | |||
| color: black; | |||
| opacity: 0.6; | |||
| padding-left: 1.67em; | |||
| background-image: linear-gradient(transparent,transparent), url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%3E%3Ctitle%3Euser%20avatar%3C%2Ftitle%3E%3Cpath%20d%3D%22M10%2011c-5.92%200-8%203-8%205v3h16v-3c0-2-2.08-5-8-5%22%2F%3E%3Ccircle%20cx%3D%2210%22%20cy%3D%225.5%22%20r%3D%224.5%22%2F%3E%3C%2Fsvg%3E"); | |||
| background-repeat: no-repeat; | |||
| background-size: contain; | |||
| } | |||
| #settings-link::before { | |||
| content: ' '; | |||
| font-size: 0.85em; | |||
| @@ -0,0 +1,39 @@ | |||
| <%! | |||
| from json import dumps, loads | |||
| from flask import g, request | |||
| from copyvios.misc import cache | |||
| from urlparse import parse_qsl | |||
| %>\ | |||
| <%include file="/support/header.mako" args="title='Login | Earwig\'s Copyvio Detector', splash=True"/> | |||
| % if error: | |||
| <div id="info-box" class="red-box"> | |||
| <p>Error trying to log in: ${error | h}</p> | |||
| </div> | |||
| % endif | |||
| <h2>Login</h2> | |||
| <p>You are required to log in with your Wikimedia account to perform checks with the search engine.</p> | |||
| %if request.args.get('next'): | |||
| <p> | |||
| After logging in, | |||
| % if request.args["next"][0:2] == "/?" and dict(parse_qsl(request.args["next"])).get("action") == "search": | |||
| your check will be run. | |||
| % else: | |||
| you will be redirected to: ${request.args.get('next') | h} | |||
| % endif | |||
| </p> | |||
| %endif | |||
| <form action="${request.script_root}/login" method="post"> | |||
| <input type="hidden" name="next" value="${request.args.get('next', '/') | h}" /> | |||
| <div class="oo-ui-layout oo-ui-fieldLayout oo-ui-fieldLayout-align-left"> | |||
| <div class="oo-ui-fieldLayout-body"> | |||
| <span class="oo-ui-fieldLayout-field"> | |||
| <span class="oo-ui-widget oo-ui-widget-enabled oo-ui-inputWidget oo-ui-buttonElement oo-ui-buttonElement-framed oo-ui-labelElement oo-ui-flaggedElement-primary oo-ui-flaggedElement-progressive oo-ui-labelElement oo-ui-buttonInputWidget"> | |||
| <button type="submit" class="oo-ui-inputWidget-input oo-ui-buttonElement-button"> | |||
| <span class="oo-ui-labelElement-label">Login</span> | |||
| </button> | |||
| </span> | |||
| </span> | |||
| </div> | |||
| </div> | |||
| </form> | |||
| <%include file="/support/footer.mako"/> | |||
| @@ -0,0 +1,22 @@ | |||
| <%! | |||
| from json import dumps, loads | |||
| from flask import g, request | |||
| from copyvios.misc import cache | |||
| %>\ | |||
| <%include file="/support/header.mako" args="title='Logout | Earwig\'s Copyvio Detector', splash=True"/> | |||
| <h2>Logout</h2> | |||
| <p>Logging out will prevent you from making search engine checks.</p> | |||
| <form action="${request.script_root}/logout" method="post"> | |||
| <div class="oo-ui-layout oo-ui-fieldLayout oo-ui-fieldLayout-align-left"> | |||
| <div class="oo-ui-fieldLayout-body"> | |||
| <span class="oo-ui-fieldLayout-field"> | |||
| <span class="oo-ui-widget oo-ui-widget-enabled oo-ui-inputWidget oo-ui-buttonElement oo-ui-buttonElement-framed oo-ui-labelElement oo-ui-flaggedElement-primary oo-ui-flaggedElement-progressive oo-ui-labelElement oo-ui-buttonInputWidget"> | |||
| <button type="submit" class="oo-ui-inputWidget-input oo-ui-buttonElement-button"> | |||
| <span class="oo-ui-labelElement-label">Logout</span> | |||
| </button> | |||
| </span> | |||
| </span> | |||
| </div> | |||
| </div> | |||
| </form> | |||
| <%include file="/support/footer.mako"/> | |||
| @@ -1,6 +1,6 @@ | |||
| <%page args="title, splash=False"/>\ | |||
| <%! | |||
| from flask import g, request, url_for | |||
| from flask import g, request, session, url_for | |||
| from copyvios.background import set_background | |||
| %>\ | |||
| <!DOCTYPE html> | |||
| @@ -25,6 +25,12 @@ | |||
| <div id="content"> | |||
| <header> | |||
| <h1><a href="/">Earwig's <strong>Copyvio Detector</strong></a></h1> | |||
| <% logged_in_user = session.get("username") %>\ | |||
| % if logged_in_user: | |||
| <a class="logout-link" href="/logout" title="Log out">${logged_in_user | h}</a> | |||
| % else: | |||
| <a class="login-link" href="/login" title="Log in">Login</a> | |||
| % endif | |||
| <a id="settings-link" href="/settings">Settings</a> | |||
| </header> | |||
| <main> | |||