Skip to content

Commit

Permalink
Initial commit.
Browse files Browse the repository at this point in the history
  • Loading branch information
m2w committed Jan 10, 2012
0 parents commit eae8956
Show file tree
Hide file tree
Showing 36 changed files with 1,654 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
deps/*
ebin/*
priv/log/*
priv/mnesia.store/*
.eunit/*
4 changes: 4 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Copyright (c) 2012 Moritz Windelen
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
25 changes: 25 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
ERL ?= erl
APP := erli

.PHONY: deps

all: deps
@./rebar compile

deps:
@./rebar get-deps

clean:
@./rebar clean

distclean: clean
@./rebar delete-deps

docs:
@erl -noshell -run edoc_run application '$(APP)' '"."' '[]'

test:
@./rebar skip_deps=true eunit

compile:
@./rebar compile
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# erli

**erli** is an Erlang based URL-shortener, with statistics and 'nsfw' functionality.

## statistics

Every shortened URL keeps track of visitor counts (non-real time total and unique visit counts) and provides estimates for the geographic location of visitors.

## 'nsfw' functionality

Appending `?landing=true` to a shortened URL, prevents the automatic redirect, instead a landing page, showing the full target URL and an "are you sure you want to proceed" button, is displayed.

Reporting inappropriate URLs is kept extremely simple, just add `/report` to the URL. URLs that reach a certain threshold - this mechanism will be expanded in the future (pull requests are most welcome ;)) - are permanetly banned.

## demo

Live demo will be up as soon as I get around to it.

To play around with it on localhost, just clone, `make compile`, `./start.sh` and open [http://localhost:8000](http://localhost:8000).

## todo

security:

+ implement throttling via 503 with `retry-after` (based on a simple request/interval scheme?)

usability:

+ timeline of visits by hour
+ thumbnails on the landing pages?
+ ensure that no matter the `STATS_COLLECT_INTERVAL`, statistics don't get corrupted

code quality:

+ eunit tests

## the api

### root_resource.erl

GET / -> displays the index
POST / -> create a new shortened URL with a generated path

### path_resource.erl

GET /path -> grab the redirect to the target URL
DELETE /path -> report the target
PUT /path -> create a new shortened url with a preferred path
-----------------
GET /path/report -> report the target URL
-----------------
GET /path/stats -> view stats for the path
-----------------
GET /path/check -> utility URL to facility 'low' overhead checking whether a path is already taken via ajax (@ W3C please give us an option to disable the automatic redirect on 30x!)
11 changes: 11 additions & 0 deletions priv/dispatch.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
%%-*- mode: erlang -*-
{[], root_resource, []}.
{["static", '*'], static_resource, ["static"]}.
{[path, '*'], path_resource, []}.







46 changes: 46 additions & 0 deletions priv/scripts/parse_eval.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import datetime
import os
import subprocess

from erlport import Atom, Port, Protocol, String
import pygeoip


class ParseEval(Protocol):
"""Parses the webmachine access log for usage statistics of shortened URLs"""

def __init__(self, log_dir=None):
super(ParseEval, self).__init__()
self.log_dir = log_dir or os.path.join(os.path.abspath(os.getcwd()),
'priv',
'log')

def handle_path(self, path):
# parse the log files from the last hour
log_timestamp = datetime.datetime.utcnow() - datetime.timedelta(hours=1)
log_fname = 'access.log.%d_%02d_%02d_%02d' % (log_timestamp.year,
log_timestamp.month,
log_timestamp.day,
log_timestamp.hour)
log_fpath = os.path.join(self.log_dir, log_fname)
if not os.path.exists(log_fpath):
return Atom('reschedule'), log_timestamp.minute + 1

parse_cmd = 'grep -e \'GET /%s \' %s | awk \'{ print $1 }\'' % (String(path),
log_fpath)
gi = pygeoip.GeoIP('/usr/local/GeoIP/GeoIP.dat')
proc = subprocess.Popen(parse_cmd, shell=True, stdout=subprocess.PIPE)
stdout, stderr = proc.communicate()
total_visits = 0
countries = set()
ips = set()
for ip in stdout.splitlines():
countries.add(gi.country_code_by_addr(ip))
ips.add(ip)
total_visits += 1
return list(countries), list(ips), total_visits


if __name__ == "__main__":
pe = ParseEval()
pe.run(Port(use_stdio=True))
34 changes: 34 additions & 0 deletions priv/static/erli.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
h1 a:link {
text-decoration: none;
color: #404040;
}
h1 a:visited {
text-decoration: none;
color: #404040;
}
h1 a:hover {
text-decoration: none;
color: #404040;
}
body {
text-align: center;
padding-top: 40px;
}
label {
float: none;
}
.container {
width: 820px;
}
.alert-message {
margin-bottom: 0px;
}
form .input {
margin-left: 0px;
}
form .clearfix.error input, form .clearfix.error textarea {
color: gray;
}
#about_popover, #url_structure_popup, #path_help_popup {
color: #BFBFBF;
}
144 changes: 144 additions & 0 deletions priv/static/erli.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/* global vars */
var re = /^(?:[a-zA-Z0-9]+:\/\/)(?:(?:(?:[a-zA-Z0-9]+\.)+(?:[a-zA-Z]+))|(?:(?:[0-9]+\.){3}(?:[0-9]+))|(?:(?:[a-f0-9]+\:)+(?:[a-f0-9]+)))(?:(?:\s*$)|(?:(?:[:\/?]).+$))/;

/* utility functions */
function show_error_and_helptext(E) {
E.parent().addClass("error");
E.siblings('.help-inline').show();
}

function hide_error_and_helptext(E) {
E.parent().removeClass("error");
E.siblings('.help-inline').hide();
}

$(document).ready(function() {
$(".alert-message.warning").remove(); // close the banner for non-js enabled visitors
$('.alert-message.error').alert();
$('#url_structure_popup').popover();
$('#path_help_popup').popover();

/* Modal ToU button click handlers */
$(".modal-footer a.primary").click(function(e){
$("#tou_checkbox").attr("checked", "checked");
$("#modal-tou").modal("hide");
e.preventDefault();
});
$(".modal-footer a.secondary").click(function(e){
$("#modal-tou").modal("hide");
e.preventDefault();
});

/* validation functions for the URL submission form */
$('#tou_checkbox').change(function(e){
var parDiv = $(this).parents('div.clearfix.input');
parDiv.removeClass("error");
});
$('#url_input').keyup(function(){
hide_error_and_helptext($(this));
});
$('#pref_path_input').keyup(function() {
$.ajax({
type: 'GET',
url: $(this).val() + '/check', // need to append the check call to guarantee that the status code is either 200 or 404
statusCode:
{
404: function() {
// path available
hide_error_and_helptext($('#pref_path_input'));
},
200: function() {
// path taken
show_error_and_helptext($('#pref_path_input'));
}
}
});
});

$('#url_input').blur(function(){
if (!$('#url_input').val().match(re)){
show_error_and_helptext($('#url_input'));
}
});

/* form submission */
$("#shorten_url_form").submit(function() {
var url = $("#url_input").val();
var checked = $("#tou_checkbox").attr("checked") ? true : false;
var pref_path = $('#pref_path_input').val();
if (!checked || !url) {
if (!url) {
show_error_and_helptext($('#url_input'));
}
if (!checked) {
$('#tou_checkbox').parents('div.clearfix.input').addClass("error");
}
return false;
}

if (!url.match(re)){
show_error_and_helptext($('#url_input'));
return false;
}
if (pref_path) {
$.ajax({
type: 'PUT',
url: pref_path,
contentType: 'application/json',
data: JSON.stringify({
url: url,
tou_checked: checked
}),
statusCode: {
204: function(){
$('#path').attr('href', pref_path).text(pref_path);
$('#path_stats').attr('href', pref_path + '/stats').text(pref_path + '/stats');
$('#success_banner').show();
},
400: function(){
// bad request - should never occur
},
409: function(){
// conflict, pref_path is already taken
show_error_and_helptext($('#pref_path_input'));
},
410: function(){
// target url is permanently banned
$('#banned_url').text($('#url_input').val());
$('.alert-message.error').show();
}
}
});
}
else {
$.ajax({
type:'POST',
url:'/',
data: JSON.stringify({
url: url,
tou_checked: checked
}),
contentType: 'application/json',
dataType: 'json',
processData: false, // we want the raw json object to be transmitted
statusCode: {
201: function(data, textStatus, jqXHR) {
var location = jqXHR.getResponseHeader('Location');
$('#path').attr('href', location).text(location);
$('#path_stats').attr('href', location + '/stats').text(location + '/stats');
$('#success_banner').show();
},
400: function(){
},
410: function(){
$('#banned_url').text($('#url_input').val());
$('.alert-message.error').show();
},
500: function(){
}
}
});
}
return false;
});
});
Loading

0 comments on commit eae8956

Please sign in to comment.