Files
reddit/scripts/tracker.py
Neil Williams af09fa8dee Update license headers to 2015.
The highlight of each year for me.
2015-01-08 13:35:03 -08:00

156 lines
4.9 KiB
Python

#!/usr/bin/python
# The contents of this file are subject to the Common Public Attribution
# License Version 1.0. (the "License"); you may not use this file except in
# compliance with the License. You may obtain a copy of the License at
# http://code.reddit.com/LICENSE. The License is based on the Mozilla Public
# License Version 1.1, but Sections 14 and 15 have been added to cover use of
# software over a computer network and provide for limited attribution for the
# Original Developer. In addition, Exhibit A has been modified to be consistent
# with Exhibit B.
#
# Software distributed under the License is distributed on an "AS IS" basis,
# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for
# the specific language governing rights and limitations under the License.
#
# The Original Code is reddit.
#
# The Original Developer is the Initial Developer. The Initial Developer of
# the Original Code is reddit Inc.
#
# All portions of the code written by reddit are Copyright (c) 2006-2015 reddit
# Inc. All Rights Reserved.
###############################################################################
"""
This is a tiny Flask app used for a couple of self-serve ad tracking
mechanisms. The URLs it provides are:
/click
Promoted links have their URL replaced with a /click URL by the JS
(after a call to /fetch-trackers). Redirect to the actual URL after logging
the click. This must be run in a place whose logs are stored for traffic
analysis.
For convenience, the script can compile itself into a Zip archive suitable for
use on Amazon Elastic Beanstalk (and possibly other systems).
"""
import cStringIO
import hashlib
import hmac
import time
import urllib
from urlparse import parse_qsl, urlparse, urlunparse
from ConfigParser import RawConfigParser
from wsgiref.handlers import format_date_time
from flask import Flask, request, json, make_response, abort, redirect
application = Flask(__name__)
REQUIRED_PACKAGES = [
"flask",
]
class ApplicationConfig(object):
"""A thin wrapper around ConfigParser that remembers what we read.
The remembered settings can then be written out to a minimal config file
when building the Elastic Beanstalk zipfile.
"""
def __init__(self):
self.input = RawConfigParser()
with open("production.ini") as f:
self.input.readfp(f)
self.output = RawConfigParser()
def get(self, section, key):
value = self.input.get(section, key)
# remember that we needed this configuration value
if (section.upper() != "DEFAULT" and
not self.output.has_section(section)):
self.output.add_section(section)
self.output.set(section, key, value)
return value
def to_config(self):
io = cStringIO.StringIO()
self.output.write(io)
return io.getvalue()
config = ApplicationConfig()
tracking_secret = config.get('DEFAULT', 'tracking_secret')
@application.route("/")
def healthcheck():
return "I am healthy."
@application.route('/click')
def click_redirect():
destination = request.args['url'].encode('utf-8')
fullname = request.args['id'].encode('utf-8')
observed_mac = request.args['hash']
expected_hashable = ''.join((destination, fullname))
expected_mac = hmac.new(
tracking_secret, expected_hashable, hashlib.sha1).hexdigest()
if not constant_time_compare(expected_mac, observed_mac):
abort(403)
# fix encoding in the query string of the destination
destination = urllib.unquote(destination)
u = urlparse(destination)
if u.query:
query_dict = dict(parse_qsl(u.query))
# this effectively calls urllib.quote_plus on every query value
query = urllib.urlencode(query_dict)
destination = urlunparse(
(u.scheme, u.netloc, u.path, u.params, query, u.fragment))
now = format_date_time(time.time())
response = redirect(destination)
response.headers['Cache-control'] = 'no-cache'
response.headers['Pragma'] = 'no-cache'
response.headers['Date'] = now
response.headers['Expires'] = now
return response
# copied from r2.lib.utils
def constant_time_compare(actual, expected):
"""
Returns True if the two strings are equal, False otherwise
The time taken is dependent on the number of characters provided
instead of the number of characters that match.
"""
actual_len = len(actual)
expected_len = len(expected)
result = actual_len ^ expected_len
if expected_len > 0:
for i in xrange(actual_len):
result |= ord(actual[i]) ^ ord(expected[i % expected_len])
return result == 0
if __name__ == "__main__":
# package up for elastic beanstalk
import zipfile
with zipfile.ZipFile("/tmp/tracker.zip", "w", zipfile.ZIP_DEFLATED) as zip:
zip.write(__file__, "application.py")
zip.writestr("production.ini", config.to_config())
zip.writestr("requirements.txt", "\n".join(REQUIRED_PACKAGES) + "\n")