Bläddra i källkod

Add OAuth flow

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
Chlod Alejandro 1 månad sedan
förälder
incheckning
35dee57a41
Ingen känd nyckel hittad för denna signaturen i databasen GPG-nyckel ID: A1E67C59037B0CC1
13 ändrade filer med 230 tillägg och 6 borttagningar
  1. +1
    -0
      .gitignore
  2. +1
    -0
      README.md
  3. +51
    -2
      app.py
  4. +3
    -0
      copyvios/api.py
  5. +48
    -0
      copyvios/auth.py
  6. +2
    -1
      copyvios/misc.py
  7. +39
    -0
      static/script.js
  8. +1
    -1
      static/script.min.js
  9. +15
    -0
      static/style.css
  10. +1
    -1
      static/style.min.css
  11. +39
    -0
      templates/login.mako
  12. +22
    -0
      templates/logout.mako
  13. +7
    -1
      templates/support/header.mako

+ 1
- 0
.gitignore Visa fil

@@ -7,3 +7,4 @@ __pycache__
.earwigbot
logs/*
!logs/.gitinclude
config.py

+ 1
- 0
README.md Visa fil

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


+ 51
- 2
app.py Visa fil

@@ -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():


+ 3
- 0
copyvios/api.py Visa fil

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


+ 48
- 0
copyvios/auth.py Visa fil

@@ -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()

+ 2
- 1
copyvios/misc.py Visa fil

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


+ 39
- 0
static/script.js Visa fil

@@ -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
static/script.min.js Visa fil

@@ -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()});

+ 15
- 0
static/style.css Visa fil

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


+ 1
- 1
static/style.min.css
Filskillnaden har hållits tillbaka eftersom den är för stor
Visa fil


+ 39
- 0
templates/login.mako Visa fil

@@ -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"/>

+ 22
- 0
templates/logout.mako Visa fil

@@ -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"/>

+ 7
- 1
templates/support/header.mako Visa fil

@@ -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&apos;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>

Laddar…
Avbryt
Spara