Skip to content

Commit

Permalink
Merge pull request #52 from earwig/feat/auth
Browse files Browse the repository at this point in the history
Require OAuth for search engine checks
  • Loading branch information
earwig authored Oct 4, 2024
2 parents bb28ba4 + 10add79 commit 5c0a2d4
Show file tree
Hide file tree
Showing 14 changed files with 234 additions and 6 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ __pycache__
.earwigbot
logs/*
!logs/.gitinclude
config.py
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 51 additions & 2 deletions app.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -21,12 +22,14 @@
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 = {}
Expand All @@ -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
Expand Down Expand Up @@ -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():
Expand Down
3 changes: 3 additions & 0 deletions copyvios/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
__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",
Expand Down Expand Up @@ -116,6 +118,7 @@ def _hook_sites(query):

def handle_api_request():
query = Query()
query.api = True
if query.version:
try:
query.version = int(query.version)
Expand Down
48 changes: 48 additions & 0 deletions copyvios/auth.py
Original file line number Diff line number Diff line change
@@ -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 changes: 4 additions & 0 deletions copyvios/checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion copyvios/misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
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

Expand All @@ -19,6 +19,7 @@ def __init__(self, method="GET"):
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)
Expand Down
39 changes: 39 additions & 0 deletions static/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
2 changes: 1 addition & 1 deletion static/script.min.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading

0 comments on commit 5c0a2d4

Please sign in to comment.