-
Notifications
You must be signed in to change notification settings - Fork 54
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
1d80687
commit 228ff3d
Showing
5 changed files
with
266 additions
and
35 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,53 +1,35 @@ | ||
require 'sinatra/base' | ||
|
||
module Guillotine | ||
# Essentially herds Sinatra input to Guillotine::Service, and ensures the | ||
# output is fit Sinatra to return. | ||
class App < Sinatra::Base | ||
set :required_host, nil | ||
set :service, nil | ||
|
||
get "/:code" do | ||
code = params[:code] | ||
if url = settings.db.find(Addressable::URI.escape(code)) | ||
redirect settings.db.parse_url(url).to_s | ||
else | ||
halt 404, simple_escape("No url found for #{code}") | ||
end | ||
escaped = Addressable::URI.escape(params[:code]) | ||
status, head, body = settings.service.get(escaped) | ||
[status, head, simple_escape(body)] | ||
end | ||
|
||
post "/" do | ||
url = settings.db.parse_url params[:url].to_s | ||
|
||
if !(url && url.scheme =~ /^https?$/) | ||
halt 422, simple_escape("Invalid url: #{url}") | ||
end | ||
status, head, body = settings.service.create(params[:url], params[:code]) | ||
|
||
case settings.required_host | ||
when String | ||
if url.host != settings.required_host | ||
halt 422, simple_escape("URL must be from #{settings.required_host}") | ||
end | ||
when Regexp | ||
if url.host.to_s !~ settings.required_host | ||
halt 422, simple_escape("URL must match #{settings.required_host.inspect}") | ||
end | ||
if loc = head && head['Location'] | ||
head['Location'] = File.join(request.url, loc) | ||
end | ||
|
||
begin | ||
if code = settings.db.add(url.to_s, params[:code]) | ||
redirect code, 201 | ||
else | ||
halt 422, simple_escape("Unable to shorten #{url}") | ||
end | ||
rescue Guillotine::DuplicateCodeError => err | ||
halt 422, simple_escape(err.to_s) | ||
end | ||
[status, head, simple_escape(body)] | ||
end | ||
|
||
# Guillotine output is supposed to be text/plain friendly, so only strip | ||
# /<|>/. Broken tie fighter :( If you're passing these characters in, | ||
# you're probably doing something naughty. | ||
def simple_escape(s) | ||
s = s.to_s | ||
s.gsub! /<|>/, '' | ||
s | ||
end | ||
end | ||
end | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,131 @@ | ||
module Guillotine | ||
class Service | ||
class NullChecker | ||
def call(url) | ||
end | ||
end | ||
|
||
# This is the public API to the Guillotine service. Wire this up to Sinatra | ||
# or whatever. Every public method should return a compatible Rack Response: | ||
# [Integer Status, Hash headers, String body]. | ||
# | ||
# db - A Guillotine::Adapter instance. | ||
# required_host - Either a String or Regex limiting which domains the | ||
# shortened URLs can come from. | ||
# | ||
def initialize(db, required_host = nil) | ||
@db = db | ||
build_host_check(required_host) | ||
end | ||
|
||
# Public: Gets the full URL for a shortened code. | ||
# | ||
# code - A String short code. | ||
# | ||
# Returns 302 with the Location header pointing to the URL on a hit, | ||
# or 404 on a miss. | ||
def get(code) | ||
if url = @db.find(code) | ||
[302, {"Location" => @db.parse_url(url).to_s}] | ||
else | ||
[404, nil, "No url found for #{code}"] | ||
end | ||
end | ||
|
||
# Public: Maps a URL to a shortened code. | ||
# | ||
# url - A String or Addressable::URI URL to shorten. | ||
# code - Optional String code to use. Defaults to a random String. | ||
# | ||
# Returns 201 with the Location pointing to the code, or 422. | ||
def create(url, code = nil) | ||
url = ensure_url(url) | ||
if !(url && url.scheme.to_s =~ /^https?$/) | ||
return [422, nil, "Invalid url: #{url}"] | ||
end | ||
|
||
if resp = check_host(url) | ||
return resp | ||
end | ||
|
||
begin | ||
if code = @db.add(url.to_s, code) | ||
[201, {"Location" => code}] | ||
else | ||
[422, nil, "Unable to shorten #{url}"] | ||
end | ||
rescue DuplicateCodeError => err | ||
[422, nil, err.to_s] | ||
end | ||
end | ||
|
||
# Checks to see if the input URL is using a valid host. You can constrain | ||
# the hosts with the `required_host` argument of the Service constructor. | ||
# | ||
# url - An Addressible::URI instance to check. | ||
# | ||
# Returns a 422 Rack::Response if the host is invalid, or nil. | ||
def check_host(url) | ||
@host_check.call url | ||
end | ||
|
||
# Converts the `required_host` argument to a lambda for #check_host. | ||
# | ||
# host_check - Either a String or Regex limiting which domains the | ||
# shortened URLs can come from. | ||
# | ||
# Returns nothing. | ||
def build_host_check(host_check) | ||
case host_check | ||
when nil | ||
@host_check = NullChecker.new | ||
when Regexp | ||
build_host_regex_check(host_check) | ||
else | ||
build_host_string_check(host_check.to_s) | ||
end | ||
end | ||
|
||
# Builds the host check lambda for regexes. | ||
# | ||
# regex - The Regexp that limits allowable URL hosts. | ||
# | ||
# Returns a Lambda that verifies an Addressible::URI. | ||
def build_host_regex_check(regex) | ||
@host_check = lambda do |url| | ||
if url.host.to_s !~ regex | ||
[422, nil, "URL must match #{regex.inspect}"] | ||
end | ||
end | ||
end | ||
|
||
# Builds the host check lambda for Strings. | ||
# | ||
# hostname - The String that limits allowable URL hosts. | ||
# | ||
# Returns a Lambda that verifies an Addressible::URI. | ||
def build_host_string_check(hostname) | ||
@host_check = lambda do |url| | ||
if url.host != hostname | ||
[422, nil, "URL must be from #{hostname}"] | ||
end | ||
end | ||
end | ||
|
||
# Ensures that the argument is an Addressable::URI. | ||
# | ||
# str - A String URL or an Addressable::URI. | ||
# | ||
# Returns an Addressable::URI. | ||
def ensure_url(str) | ||
if str.respond_to?(:scheme) | ||
str | ||
else | ||
str = str.to_s | ||
str.gsub! /\s/, '' | ||
str.gsub! /(\#|\?).*/, '' | ||
Addressable::URI.parse str | ||
end | ||
end | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,115 @@ | ||
require File.expand_path('../helper', __FILE__) | ||
|
||
module Guillotine | ||
class ServiceTest < TestCase | ||
def setup | ||
@db = Adapters::MemoryAdapter.new | ||
@service = Service.new @db | ||
end | ||
|
||
def test_adding_a_link_returns_code | ||
url = 'http://github.com' | ||
status, head, body = @service.create(url + '?a=1') | ||
assert_equal 201, status | ||
assert code_url = head['Location'] | ||
code = code_url.gsub(/.*\//, '') | ||
|
||
status, head, body = @service.get(code) | ||
assert_equal 302, status | ||
assert_equal url, head['Location'] | ||
end | ||
|
||
def test_adding_duplicate_link_returns_same_code | ||
url = 'http://github.com' | ||
code = @db.add url | ||
|
||
status, head, body = @service.create(url + '#a=1') | ||
assert code_url = head['Location'] | ||
assert_equal code, code_url.gsub(/.*\//, '') | ||
end | ||
|
||
def test_adds_url_with_custom_code | ||
url = 'http://github.com/abc' | ||
|
||
status, head, body = @service.create(url, 'code') | ||
assert code_url = head['Location'] | ||
assert_equal 'code', code_url | ||
|
||
status, head, body = @service.get('code') | ||
assert_equal 302, status | ||
assert_equal url, head['Location'] | ||
end | ||
|
||
def test_adds_url_with_custom_unicode | ||
url = 'http://github.com/abc' | ||
status, head, body = @service.create(url, '%E2%9C%88') | ||
assert code_url = head['Location'] | ||
assert_match /^%E2%9C%88$/, code_url | ||
|
||
status, head, body = @service.get("%E2%9C%88") | ||
assert_equal 302, status | ||
assert_equal url, head['Location'] | ||
end | ||
|
||
def test_redirects_to_split_url | ||
url = "http://abc.com\nhttp//def.com" | ||
@db.hash['split'] = url | ||
@db.urls[url] = 'split' | ||
|
||
status, head, body = @service.get('split') | ||
assert_equal "http://abc.comhttp//def.com", head['Location'] | ||
end | ||
|
||
def test_clashing_urls_raises_error | ||
code = @db.add 'http://github.com/123' | ||
status, head, body = @service.create('http://github.com/456', code) | ||
assert_equal 422, status | ||
end | ||
|
||
def test_add_without_url | ||
status, head, body = @service.create(nil) | ||
assert_equal 422, status | ||
end | ||
|
||
def test_add_url_with_linebreak | ||
status, head, body = @service.create("https://abc.com\n") | ||
assert_equal 'SWtBvQ', head['Location'] | ||
end | ||
|
||
def test_adds_split_url | ||
status, head, body = @service.create("https://abc.com\nhttp://abc.com") | ||
assert_equal 'cb5CNA', head['Location'] | ||
|
||
assert_equal 'https://abc.comhttp//abc.com', @db.find('cb5CNA') | ||
end | ||
|
||
def test_rejects_non_http_urls | ||
status, head, body = @service.create('ftp://abc.com') | ||
assert_equal 422, status | ||
end | ||
|
||
def test_reject_shortened_url_from_other_domain | ||
service = Service.new @db, 'abc.com' | ||
status, head, body = service.create('http://github.com') | ||
assert_equal 422, status | ||
assert_match /must be from abc\.com/, body | ||
|
||
status, head, body = service.create('http://abc.com/def') | ||
assert_equal 201, status | ||
end | ||
|
||
def test_reject_shortened_url_from_other_domain_by_regex | ||
service = Service.new @db, /abc\.com$/ | ||
status, head, body = service.create('http://github.com') | ||
assert_equal 422, status | ||
assert_match /must match \/abc\\.com/, body | ||
|
||
status, head, body = service.create('http://abc.com/def') | ||
assert_equal 201, status | ||
|
||
status, head, body = service.create('http://www.abc.com/def') | ||
assert_equal 201, status | ||
end | ||
end | ||
end | ||
|