ソースを参照

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ヶ月前
コミット
35dee57a41
この署名に対応する既知のキーがデータベースに存在しません GPGキーID: A1E67C59037B0CC1
13個のファイルの変更230行の追加6行の削除
  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 ファイルの表示

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

+ 1
- 0
README.md ファイルの表示

@@ -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 ファイルの表示

@@ -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 ファイルの表示

@@ -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 ファイルの表示

@@ -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 ファイルの表示

@@ -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 ファイルの表示

@@ -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 ファイルの表示

@@ -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 ファイルの表示

@@ -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
ファイル差分が大きすぎるため省略します
ファイルの表示


+ 39
- 0
templates/login.mako ファイルの表示

@@ -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 ファイルの表示

@@ -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 ファイルの表示

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

読み込み中…
キャンセル
保存