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> |