Browse Source

Merge pull request #52 from earwig/feat/auth

Require OAuth for search engine checks
main
Ben Kurtovic 2 months ago
committed by GitHub
parent
commit
5c0a2d45b4
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
14 changed files with 234 additions and 6 deletions
  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. +4
    -0
      copyvios/checker.py
  7. +2
    -1
      copyvios/misc.py
  8. +39
    -0
      static/script.js
  9. +1
    -1
      static/script.min.js
  10. +15
    -0
      static/style.css
  11. +1
    -1
      static/style.min.css
  12. +39
    -0
      templates/login.mako
  13. +22
    -0
      templates/logout.mako
  14. +7
    -1
      templates/support/header.mako

+ 1
- 0
.gitignore View File

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

+ 1
- 0
README.md View File

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

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

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

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

+ 4
- 0
copyvios/checker.py View File

@@ -144,6 +144,10 @@ def _perform_check(query, page, use_engine, use_links):
_LOGGER.exception("Failed to retrieve cached results")

if not query.result:
if use_engine and not query.requester_username and not query.api:
query.error = "not logged in"
return

try:
query.result = page.copyvio_check(
min_confidence=T_SUSPECT, max_queries=8, max_time=30,


+ 2
- 1
copyvios/misc.py View File

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

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

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

@@ -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
File diff suppressed because it is too large
View File


+ 39
- 0
templates/login.mako View File

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

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

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

Loading…
Cancel
Save