diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..163eb75 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*.cr] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..5eea37e --- /dev/null +++ b/.env.test @@ -0,0 +1,6 @@ +AASA_APP_IDS="ABCDE12345.com.example.app ABCDE12345.com.example.app2" +# REDIS_URL="redis://localhost:6379" +# SAFELIST="fdo.cr github.com" +# DISABLE_DEFENSE="true" +# THROTTLE_LIMIT="1000" +# THROTTLE_PERIOD="3600" \ No newline at end of file diff --git a/.env_sample b/.env_sample deleted file mode 100644 index ed7e56b..0000000 --- a/.env_sample +++ /dev/null @@ -1,2 +0,0 @@ -REDIS_URL="redis://localhost:6379" -UDL_SAFELIST_REGEXP="^https:\/\/visualcosita.com" diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..c258b5d --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: [fdocr] \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2dca57..8e9c124 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,23 +1,40 @@ name: CI + on: push: branches: - main pull_request: branches: - - main + - "*" + jobs: - test: - name: Test Suite + tests: + name: Tests runs-on: ubuntu-latest + strategy: + matrix: + safelist: + - "fdo.cr github.com" + - "" steps: - - name: Checkout - uses: actions/checkout@master - - name: Set up Ruby - uses: ruby/setup-ruby@v1 + - name: Download source + uses: actions/checkout@v3 + + - name: Install Crystal + uses: crystal-lang/install-crystal@v1 + + - name: Cache shards + uses: actions/cache@v3 with: - ruby-version: 3.1.2 - - name: Install dependencies - run: bundle install + path: lib + key: ${{ runner.os }}-shards-${{ hashFiles('**/shard.lock') }} + restore-keys: ${{ runner.os }}-shards- + + - name: Install shards + run: shards update + - name: Run tests - run: bundle exec rspec + run: KEMAL_ENV=test crystal spec --verbose + env: + SAFELIST: ${{ matrix.safelist }} diff --git a/.gitignore b/.gitignore index 77419f4..1b711cb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ .env .DS_Store -.byebug_history + +# Crystal ignores +/docs/ +/lib/ +/bin/ +/.shards/ +*.dwarf diff --git a/.rspec b/.rspec deleted file mode 100644 index c99d2e7..0000000 --- a/.rspec +++ /dev/null @@ -1 +0,0 @@ ---require spec_helper diff --git a/.ruby-version b/.ruby-version deleted file mode 100644 index ef538c2..0000000 --- a/.ruby-version +++ /dev/null @@ -1 +0,0 @@ -3.1.2 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a3d2961..584224f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,14 +10,15 @@ If you implemented a bugfix, a new feature, or updated the docs/tests feel free 1. Fork the repository 1. Install the dependencies & run locally - - `bundle install` - - `bundle exec puma -p 4567` + - `shards install` + - `crystal run src/server.cr` 1. Create your feature branch - `git checkout -b my-new-feature` 1. Work on your fix/feature - Add tests to avoid regressions in the future 1. Run the tests - - `bundle exec rspec` + - `KEMAL_ENV=test crystal spec` + - `SAFELIST="fdo.cr github.com" KEMAL_ENV=test crystal spec` 1. Commit your changes - `git commit -am 'Added some feature'` 1. Push to the branch diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9514327 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +# Build image +FROM crystallang/crystal:1.10.1-alpine as builder +WORKDIR /opt +# Cache dependencies +COPY ./shard.yml ./shard.lock /opt/ +RUN shards install -v +# Build a binary +COPY . /opt/ +RUN crystal build --static --release ./src/server.cr +# =============== +# Result image with one layer +FROM alpine:latest +WORKDIR / +COPY --from=builder /opt/server . +ENTRYPOINT ["./server"] diff --git a/Gemfile b/Gemfile deleted file mode 100644 index c4ed321..0000000 --- a/Gemfile +++ /dev/null @@ -1,18 +0,0 @@ -# coding: utf-8 - -git_source(:github) { |name| "https://github.com/#{name}.git" } -source "https://rubygems.org" -ruby File.read(File.join(File.dirname(__FILE__), ".ruby-version")).strip - -gem 'sinatra', '~> 2.2' -gem 'puma', '~> 5.6' -gem 'rack-attack', '~> 6.5' -gem 'redis-activesupport', '~> 5.2' - -group :test, :development do - gem 'sinatra-reloader', '~> 1.0' - gem 'rack-test', '~> 1.1' - gem 'rspec', '~> 3.10' - gem 'dotenv', '~> 2.7', '>= 2.7.6' - gem 'byebug', '~> 11.1', '>= 11.1.3' -end diff --git a/Gemfile.lock b/Gemfile.lock deleted file mode 100644 index 7f63b11..0000000 --- a/Gemfile.lock +++ /dev/null @@ -1,87 +0,0 @@ -GEM - remote: https://rubygems.org/ - specs: - activesupport (7.0.4.3) - concurrent-ruby (~> 1.0, >= 1.0.2) - i18n (>= 1.6, < 2) - minitest (>= 5.1) - tzinfo (~> 2.0) - byebug (11.1.3) - concurrent-ruby (1.2.2) - diff-lcs (1.5.0) - dotenv (2.7.6) - i18n (1.13.0) - concurrent-ruby (~> 1.0) - minitest (5.18.0) - multi_json (1.15.0) - mustermann (2.0.2) - ruby2_keywords (~> 0.0.1) - nio4r (2.5.9) - puma (5.6.7) - nio4r (~> 2.0) - rack (2.2.7) - rack-attack (6.6.0) - rack (>= 1.0, < 3) - rack-protection (2.2.3) - rack - rack-test (1.1.0) - rack (>= 1.0, < 3) - redis (4.6.0) - redis-activesupport (5.3.0) - activesupport (>= 3, < 8) - redis-store (>= 1.3, < 2) - redis-store (1.9.1) - redis (>= 4, < 5) - rspec (3.11.0) - rspec-core (~> 3.11.0) - rspec-expectations (~> 3.11.0) - rspec-mocks (~> 3.11.0) - rspec-core (3.11.0) - rspec-support (~> 3.11.0) - rspec-expectations (3.11.0) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.11.0) - rspec-mocks (3.11.0) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.11.0) - rspec-support (3.11.0) - ruby2_keywords (0.0.5) - sinatra (2.2.3) - mustermann (~> 2.0) - rack (~> 2.2) - rack-protection (= 2.2.3) - tilt (~> 2.0) - sinatra-contrib (2.2.3) - multi_json - mustermann (~> 2.0) - rack-protection (= 2.2.3) - sinatra (= 2.2.3) - tilt (~> 2.0) - sinatra-reloader (1.0) - sinatra-contrib - tilt (2.1.0) - tzinfo (2.0.6) - concurrent-ruby (~> 1.0) - -PLATFORMS - arm64-darwin-20 - arm64-darwin-21 - x86_64-darwin-20 - x86_64-linux - -DEPENDENCIES - byebug (~> 11.1, >= 11.1.3) - dotenv (~> 2.7, >= 2.7.6) - puma (~> 5.6) - rack-attack (~> 6.5) - rack-test (~> 1.1) - redis-activesupport (~> 5.2) - rspec (~> 3.10) - sinatra (~> 2.2) - sinatra-reloader (~> 1.0) - -RUBY VERSION - ruby 3.1.2p20 - -BUNDLED WITH - 2.3.8 diff --git a/README.md b/README.md index 6de66a5..5fa8316 100644 --- a/README.md +++ b/README.md @@ -1,65 +1,87 @@ # Universal Deep Link (UDL) Server +[![Build Status](https://github.com/fdocr/udl-server/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/fdocr/udl-server/actions/workflows/ci.yml/badge.svg?branch=main) + This is a server that bounces traffic to better leverage Deep Linking in mobile apps. -The project's objectives are to be a simple, effective and lightweight tool that can help any website provide a seamless integration with their mobile apps. +The project's objectives are to be a simple, effective and lightweight tool that can help any website provide a seamless integration with associated mobile apps. -I used to host a server for public use (free of charge) but [due to _"reasons"_](https://github.com/fdocr/udl-server/issues/19#issuecomment-1536587313) it's not available anymore. You can [self host the project](#self-hosting) on most PaaS hosting providers quite easily. [Create an issue](https://github.com/fdocr/udl-server/issues/new) if you need help or have questions. +## How it works -## How it works, and why? +Modern mobile browsers provide developers with [Universal Links (iOS)](https://developer.apple.com/library/archive/documentation/General/Conceptual/AppSearch/UniversalLinks.html) or [Android Intents](https://developer.chrome.com/docs/multidevice/android/intents/) to support deep linking users from a website directly into a mobile app. -It's a dead simple pivot server that will allow for Universal Links to work with you app. +However, **do you share links to your website on social media or other 3rd party apps?** Your website will likely be browsed on a webview (embedded browser) inside a 3rd party app, i.e. Instagram, TikTok, Reddit, etc. This means iOS Universal Links won't open your app automatically because users are clicking links on the same domain. -Modern mobile browsers provide developers with [Universal Links (iOS)](https://developer.apple.com/library/archive/documentation/General/Conceptual/AppSearch/UniversalLinks.html) or [Android Intents](https://developer.chrome.com/docs/multidevice/android/intents/) to support deep linking users from a website directly into a mobile app. However, Operating Systems currently won't trigger these features when the user clicks a link within the same domain or when the user types the URL directly in the address bar. +> When a user is browsing your website in Safari and they tap a universal link to a URL in the same domain as the current webpage, iOS respects the user’s most likely intent and opens the link in Safari. If the user taps a universal link to a URL in a different domain, iOS opens the link in your app. ([reference docs](https://developer.apple.com/library/archive/documentation/General/Conceptual/AppSearch/UniversalLinks.html)) -These and other edge cases make for a less than ideal experience, if your objective is to allow for a seamlessly transition to your mobile app. A custom banner (in your website) that links to an external site will trigger the deep linking though, and this is where the UDL Server comes in. +This is how the UDL Server helps make this a smooth experience for you: -![diagram](udl-server-diagram.png) +![diagram](udl-server-deep-link.png) -## Self-hosting +It's a dead simple pivot server that will allow for Universal Links to trigger wherever your users are browsing from. -Power users will likely need better reliability and scalability than a free service is able to offer. Self-hosting with Heroku (or similar hosting solutions) is as easy as: +## Usage -1. Fork this repository -1. Configure the app to automatically deploy to your Heroku account - - Using a [custom domain with Heroku](https://devcenter.heroku.com/articles/custom-domains) is very simple (i.e. `udl.your-domain.com`) - - Heroku's default subdomain works too (i.e. `my-app.herokuapp.com`) -1. Keep up with upstream (this repo) for future updates - - Use the **"Sync fork"** feature in your GitHub repo - - Or manually with git commands: - - `git remote add upstream git@github.com:fdocr/udl-server.git` - - `git pull upstream main` - - `git push origin main` -1. Configure `AASA_APP_ID` ENV variable to match your App Id - - Use the team ID or app ID prefix, followed by the bundle ID (joined by a dot `.`). - - This will allow your UDL Server to directly serve as a Universal Link target for your app and improve the experience - - Example: `R9SWHSQNV8.com.forem.app` +Following the **"reservation"** example app from the diagram above (assuming you support Universal Links already) all you need is to add a button (link) on your website that reads "open in app" requesting a redirect to your target location: -## Throttling, Safelist and Blocklist +``` +https://udl.fdo.cr/?r=https://reservation.com/restaurants/silvestre +``` -The UDL Server uses [Rack::Attack](https://github.com/rack/rack-attack) to protect itself against abuse. It will respond with a `429` instead of the expected redirect when this happens. +This will bypass the limitation of Safari (embedded webview) that doesn't allow your Universal Links to trigger. You should now have a working "open in app" UX. -[IP based throttling](https://github.com/rack/rack-attack#throttling) is enabled by default with a limit of 3 requests on a 10 second period, but only if you provide access to a Redis to work as cache (via `REDIS_URL` ENV variable). You can override these values by using `UDL_THROTTLE_LIMIT` and `UDL_THROTTLE_PERIOD`. +`https://udl.fdo.cr` is a **public (free to use) UDL Server** instance for anyone to try out and use on your own. It has usage limits (throttling), which should be more than enough for most low-medium traffic websites. -You can further restrict if the server will allow or deny a redirect based on passing in a regular expression via `UDL_SAFELIST_REGEXP` or `UDL_BLOCKLIST_REGEXP`. These regular expressions will be checked against the `r` param and will allow or deny the response (respectively). For example: +If this service adds value to you or your company please consider sponsoring me right here on GitHub. I offer different sponsor tiers too where I will host a private instance without usage limits for ensured reliability. [Read more about this on my profile](https://github.com/sponsors/fdocr) to support the OSS work I do on my free time. -```bash -# All redirect requests for "tiktok.com" will be safelisted -# https://github.com/rack/rack-attack#safelisting -UDL_SAFELIST_REGEXP="^https:\/\/tiktok.com" -``` +## Self host + +The project makes it easy for you to self host a UDL Server. The easiest way to do this is to: -[Read more](https://github.com/rack/rack-attack#how-it-works) about how `Rack::Attack` safelist/blacklist features work. +1. Fork this repository +1. Configure a PaaS to automatically deploy from your fork repository +1. Configure your custom domain/sub-domain +1. Keep up with upstream (this repo) for future updates + - Use the **"Sync fork"** feature in your GitHub repo + - Or manually using git +1. Customize your deployment using ENV variables + - `THROTTLE_LIMIT` + - Number of requests allowed per `THROTTLE_PERIOD` + - i.e. `30` (default is `5`) + - `THROTTLE_PERIOD` + - Period to track requests to be throttled in seconds + - i.e. `60` (default is `30`) + - `SAFELIST` + - Space separated list of domains for private instance usage + - i.e. `"fdo.cr github.com"` (if set it will disable throttling) + - `DISABLE_DEFENSE` + - Disable throttle/safelist feature + - i.e. `"true"` + - `AASA_APP_IDS` + - Enable activity continuation and other [associated domain features](https://developer.apple.com/documentation/xcode/supporting-associated-domains) + - i.e. `ABCDE12345.com.example.app` + +Alternatively you can run the lightweight [`fdocr/udl-server`](https://hub.docker.com/repository/docker/fdocr/udl-server/general) Docker container on your own. At the time of this writing the docker image is only about `26.3 MB` in size (`10.87 MB` compressed). ## Troubleshooting Some common details to keep in mind in case your redirects aren't working properly: - Make sure your redirects are all using `https` -- You will likely need to make this request on a `target="_blank"` anchor tag in order to get Apple's Universal Links to work. +- If links aren't working try to use `target="_blank"` on your anchor tag - Make sure your iOS app has properly configured [Associated Domains](https://developer.apple.com/documentation/safariservices/supporting_associated_domains) for the websites you want to support. - There's a chance it won't work in development mode (i.e. only signed with a Production certificate). I suggest releasing to TestFlight in order to properly test everything. +## Performance & Roadmap + +The project was originally written using [Ruby](https://www.ruby-lang.org/en/) & [Sinatra](https://sinatrarb.com/). It was a joy to write and worked perfectly fine, but I always wanted this "pivot request" to be as instantaneous as possible. It was doing `~40 ms` response times for `P99` on less than 1 rpm but supporting consistent daily traffic. + +The project is now ported to [Crystal](https://crystal-lang.org/) and [Kemal framework](https://kemalcr.com/). I'm now seeing response times drop to microseconds, which is very exciting! + +![diagram](nanosecond-response-times.png) + +I'm aiming to work on adding a bunch of other features to the project and [share blog posts](https://fdo.cr/blog) with walkthroughs/benchmarks/etc. Feel free to tag along and submit feature requests in the issue tracker. + ## Contributing Please check out the [Contributing Guide](https://github.com/fdocr/udl-server/blob/main/CONTRIBUTING.md). diff --git a/config.ru b/config.ru deleted file mode 100644 index 2914c0d..0000000 --- a/config.ru +++ /dev/null @@ -1,5 +0,0 @@ -require "rack/attack" -use Rack::Attack - -require "./server" -run Sinatra::Application diff --git a/config/defense.cr b/config/defense.cr new file mode 100644 index 0000000..d9cd81b --- /dev/null +++ b/config/defense.cr @@ -0,0 +1,34 @@ +require "defense" + +# Use memory store unless REDIS_URL is available in ENV (Redis is default) +Defense.store = Defense::MemoryStore.new if ENV["REDIS_URL"]?.nil? + +# When safelist domains are provided (blank isn't allowed to safelist) +safelist = ENV.fetch("SAFELIST", "") +if safelist.presence + domains = safelist.split(" ").map { |d| "(#{d})" }.join("|") + regex_str = "^https?://#{domains}" + safelist_regex = Regex.new(regex_str, Regex::CompileOptions::IGNORE_CASE) + + Defense.throttle("req/ip", limit: 0, period: 3) do |request| + # Only throttle redirect path with a target (allow empty param for splash) + next unless request.path == "/" && request.query_params["r"]?.presence + + # If there's a safelist match for the target don't throttle at all + next unless (safelist_regex =~ request.query_params["r"]?).nil? + + # Not a match means we block that request - Use a single identifier + # for all requests to avoid DoS by bloating our Defense cache store + "block" + end +else + limit = ENV.fetch("THROTTLE_LIMIT", "5").to_i + period = ENV.fetch("THROTTLE_PERIOD", "30").to_i + + Defense.throttle("req/ip", limit: limit, period: period) do |request| + # Only throttle redirect path + next unless request.path == "/" + + request.query_params["r"]? + end +end diff --git a/nanosecond-response-times.png b/nanosecond-response-times.png new file mode 100644 index 0000000..88cda4e Binary files /dev/null and b/nanosecond-response-times.png differ diff --git a/server.rb b/server.rb deleted file mode 100644 index 9939c5f..0000000 --- a/server.rb +++ /dev/null @@ -1,83 +0,0 @@ -require 'sinatra' -if !production? - require 'sinatra/reloader' - require 'dotenv/load' - require 'byebug' -end - -require 'uri' -require 'json' -require 'redis-activesupport' -require 'rack/attack' - -Rack::Attack.cache.store = ActiveSupport::Cache.lookup_store :redis_store - -if ENV['UDL_THROTTLE_LIMIT'].present? && ENV['UDL_THROTTLE_PERIOD'].present? - limit = ENV['UDL_THROTTLE_LIMIT'].to_i - period = ENV['UDL_THROTTLE_PERIOD'].to_i - Rack::Attack.throttle('requests/ip', limit: limit, period: period) do |request| - request.ip - end -end - -if ENV['UDL_SAFELIST_REGEXP'].present? - safelist_regexp = Regexp.new(ENV['UDL_SAFELIST_REGEXP']) - Rack::Attack.safelist("allow safelist") do |request| - # Requests will be safelisted if the 'r' param matches the regexp - request.params["r"] =~ safelist_regexp - end -end - -if ENV['UDL_BLOCKLIST_REGEXP'].present? - blocklist_regexp = Regexp.new(ENV['UDL_BLOCKLIST_REGEXP']) - Rack::Attack.blocklist("deny blocklist") do |request| - # Requests will be blocklisted if the 'r' param matches the regexp - request.params["r"] =~ blocklist_regexp - end -end - -get '/' do - begin - redirect URI(params[:r]) - rescue => error - @error = error - logger.info @error.inspect - erb :fallback - end -end - -get '/.well-known/apple-app-site-association' do - content_type :json - - aasa_app_id = ENV['AASA_APP_ID'].to_s - if aasa_app_id.present? - { - "applinks": { - "apps": [], - "details":[ - { - "appID": aasa_app_id, - "paths": ["/*"] - } - ] - }, - "activitycontinuation": { - "apps": [aasa_app_id] - } - }.to_json - else - { error: 'AASA_APP_ID not configured' }.to_json - end -end - -get '/*' do - begin - target_url = URI(params['splat'].first.gsub('https:/', 'https://')) - raise 'Invalid redirect URL' unless target_url.host.present? - redirect target_url - rescue => error - @error = error - logger.info @error.inspect - erb :fallback - end -end diff --git a/shard.lock b/shard.lock new file mode 100644 index 0000000..7c0d6c8 --- /dev/null +++ b/shard.lock @@ -0,0 +1,42 @@ +version: 2.0 +shards: + ameba: + git: https://github.com/crystal-ameba/ameba.git + version: 1.5.0 + + backtracer: + git: https://github.com/sija/backtracer.cr.git + version: 1.2.2 + + db: + git: https://github.com/crystal-lang/crystal-db.git + version: 0.12.0 + + defense: + git: https://github.com/defense-cr/defense.git + version: 0.4.0+git.commit.ce4f9b6c69b0fd11e61815e8c17e4c1083a1a4d0 + + dotenv: + git: https://github.com/drum445/dotenv.git + version: 0.5.0 + + exception_page: + git: https://github.com/crystal-loot/exception_page.git + version: 0.3.1 + + kemal: + git: https://github.com/kemalcr/kemal.git + version: 1.4.0 + + radix: + git: https://github.com/luislavena/radix.git + version: 0.4.1 + + redis: + git: https://github.com/jgaskins/redis.git + version: 0.8.0 + + spec-kemal: + git: https://github.com/kemalcr/spec-kemal.git + version: 1.0.0 + diff --git a/shard.yml b/shard.yml new file mode 100644 index 0000000..895eee3 --- /dev/null +++ b/shard.yml @@ -0,0 +1,29 @@ +name: UDL Server +version: 1.0.0.pre1 + +authors: + - Fernando Valverde + +targets: + udl-server: + main: src/server.cr + +dependencies: + kemal: + github: kemalcr/kemal + spec-kemal: + github: kemalcr/spec-kemal + defense: + github: defense-cr/defense + dotenv: + github: drum445/dotenv + version: ~> 0.5.0 + +development_dependencies: + ameba: + github: crystal-ameba/ameba + version: ~> 1.5.0 + +crystal: 1.10.1 + +license: MIT diff --git a/spec/app_spec.rb b/spec/app_spec.rb deleted file mode 100644 index 95ff29e..0000000 --- a/spec/app_spec.rb +++ /dev/null @@ -1,65 +0,0 @@ -ENV['APP_ENV'] = 'test' - -require './server' # <-- your sinatra app -require 'rspec' -require 'rack/test' - -RSpec.describe 'UDL Server' do - include Rack::Test::Methods - - def app - Sinatra::Application - end - - context "success" do - it "redirects to the r parameter if valid" do - target_url = "https://dev.to/fdoxyz" - get "/?r=#{target_url}" - expect(last_response).to be_redirect - expect(last_response.location).to eq(target_url) - end - - it "redirects when passing target in REST first level param" do - target_url = "https://dev.to/fdoxyz" - get "/#{target_url}" - expect(last_response).to be_redirect - expect(last_response.location).to eq(target_url) - end - end - - context "failure" do - after(:each) do - expect(last_response).to be_ok - expect(last_response.body).to include('Something went wrong') - expect(last_response.body).to include('Check out the README for more details') - end - - it "renders fallback page if r parameter isn't available" do - get '/' - end - - it "renders fallback page if requesting anything other than URL redirect" do - get '/about-us' - end - - it "renders fallback page if r parameter is an invalid URL" do - get '/?r=poorthing-ble$$ur<3' - end - end - - context "AASA" do - it "responds with AASA when AASA_APP_ID is configured" do - allow(ENV).to receive(:[]).with('AASA_APP_ID').and_return("R9SWHSQNV8.com.forem.app") - get '/.well-known/apple-app-site-association' - expect(last_response).to be_ok - expect(last_response.body).to eq("{\"applinks\":{\"apps\":[],\"details\":[{\"appID\":\"R9SWHSQNV8.com.forem.app\",\"paths\":[\"/*\"]}]},\"activitycontinuation\":{\"apps\":[\"R9SWHSQNV8.com.forem.app\"]}}") - end - - it "responds with error when AASA_APP_ID isn't configured" do - allow(ENV).to receive(:[]).with('AASA_APP_ID').and_return("") - get '/.well-known/apple-app-site-association' - expect(last_response).to be_ok - expect(last_response.body).to eq("{\"error\":\"AASA_APP_ID not configured\"}") - end - end -end diff --git a/spec/defense_spec.cr b/spec/defense_spec.cr new file mode 100644 index 0000000..661e62b --- /dev/null +++ b/spec/defense_spec.cr @@ -0,0 +1,41 @@ +require "./spec_helper" + +describe "Defense" do + context "throttle" do + it "throttles (with defaults)" do + target_url = "https://example.com/dolan" + + 5.times do + get "/?r=#{target_url}" + + response.status_code.should eq(302) + response.headers["Location"].should eq(target_url) + end + + 5.times do + get "/?r=#{target_url}" + response.status_code.should eq(429) + end + end + end unless ENV.fetch("SAFELIST", "").presence + + context "safelist" do + it "allows domain to bypass throttling" do + target_url = "https://fdo.cr/about" + + 50.times do + get "/?r=#{target_url}" + + response.status_code.should eq(302) + response.headers["Location"].should eq(target_url) + end + end + + it "blocks all domains not safelisted" do + 3.times do + get "/?r=https://example.com/dolan" + response.status_code.should eq(429) + end + end + end if ENV.fetch("SAFELIST", "").presence +end diff --git a/spec/server_spec.cr b/spec/server_spec.cr new file mode 100644 index 0000000..bd0531c --- /dev/null +++ b/spec/server_spec.cr @@ -0,0 +1,59 @@ +require "./spec_helper" + +describe "UDL Server" do + context "success" do + it "redirects to the Target URI parameter if valid" do + target_url = "https://fdo.cr/about" + get "/?r=#{target_url}" + + response.status_code.should eq(302) + response.headers["Location"].should eq(target_url) + + target_url = "http://fdo.cr/about" + get "/?r=#{target_url}" + + response.status_code.should eq(302) + response.headers["Location"].should eq(target_url) + end + + it "populates apple-app-site-association file" do + get "/.well-known/apple-app-site-association" + + result = "{\"applinks\":{\"apps\":[],\"details\":[{\"appID\":\"ABCDE12345.com.example.app\",\"paths\":[\"/*\"]},{\"appID\":\"ABCDE12345.com.example.app2\",\"paths\":[\"/*\"]}]},\"activitycontinuation\":{\"apps\":[\"ABCDE12345.com.example.app\",\"ABCDE12345.com.example.app2\"]}}" + response.body.should eq(result) + response.headers["Content-Type"].should eq("application/json") + end + end + + context "failure" do + it "renders fallback page if target redirect not provided" do + get "/" + + response.status_code.should eq(200) + response.body.should contain("Something went wrong") + response.body.should contain("Check out the README for more details") + end + + it "renders fallback page if requesting any other path" do + get "/about-us" + + response.status_code.should eq(200) + response.body.should contain("Something went wrong") + response.body.should contain("Check out the README for more details") + end + + it "renders fallback page if r parameter is an invalid URL" do + get "/?r=poorthing-ble$$-ur-<3" + + # When safelisting domains it will deny anything other than the provided + # domains. When throttling it will fail and display error on response + if ENV.fetch("SAFELIST", "").presence + response.status_code.should eq(429) + else + response.status_code.should eq(200) + response.body.should contain("Something went wrong") + response.body.should contain("Check out the README for more details") + end + end + end +end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr new file mode 100644 index 0000000..570351b --- /dev/null +++ b/spec/spec_helper.cr @@ -0,0 +1,2 @@ +require "spec-kemal" +require "../src/server" diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb deleted file mode 100644 index d106eba..0000000 --- a/spec/spec_helper.rb +++ /dev/null @@ -1,105 +0,0 @@ -require 'rspec' -require 'rack/test' - -# This file was generated by the `rspec --init` command. Conventionally, all -# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. -# The generated `.rspec` file contains `--require spec_helper` which will cause -# this file to always be loaded, without a need to explicitly require it in any -# files. -# -# Given that it is always loaded, you are encouraged to keep this file as -# light-weight as possible. Requiring heavyweight dependencies from this file -# will add to the boot time of your test suite on EVERY test run, even for an -# individual file that may not need all of that loaded. Instead, consider making -# a separate helper file that requires the additional dependencies and performs -# the additional setup, and require it from the spec files that actually need -# it. -# -# See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration -RSpec.configure do |config| - config.include Rack::Test::Methods - - # rspec-expectations config goes here. You can use an alternate - # assertion/expectation library such as wrong or the stdlib/minitest - # assertions if you prefer. - config.expect_with :rspec do |expectations| - # This option will default to `true` in RSpec 4. It makes the `description` - # and `failure_message` of custom matchers include text for helper methods - # defined using `chain`, e.g.: - # be_bigger_than(2).and_smaller_than(4).description - # # => "be bigger than 2 and smaller than 4" - # ...rather than: - # # => "be bigger than 2" - expectations.include_chain_clauses_in_custom_matcher_descriptions = true - end - - # rspec-mocks config goes here. You can use an alternate test double - # library (such as bogus or mocha) by changing the `mock_with` option here. - config.mock_with :rspec do |mocks| - # Prevents you from mocking or stubbing a method that does not exist on - # a real object. This is generally recommended, and will default to - # `true` in RSpec 4. - mocks.verify_partial_doubles = true - end - - # This option will default to `:apply_to_host_groups` in RSpec 4 (and will - # have no way to turn it off -- the option exists only for backwards - # compatibility in RSpec 3). It causes shared context metadata to be - # inherited by the metadata hash of host groups and examples, rather than - # triggering implicit auto-inclusion in groups with matching metadata. - config.shared_context_metadata_behavior = :apply_to_host_groups - -# The settings below are suggested to provide a good initial experience -# with RSpec, but feel free to customize to your heart's content. -=begin - # This allows you to limit a spec run to individual examples or groups - # you care about by tagging them with `:focus` metadata. When nothing - # is tagged with `:focus`, all examples get run. RSpec also provides - # aliases for `it`, `describe`, and `context` that include `:focus` - # metadata: `fit`, `fdescribe` and `fcontext`, respectively. - config.filter_run_when_matching :focus - - # Allows RSpec to persist some state between runs in order to support - # the `--only-failures` and `--next-failure` CLI options. We recommend - # you configure your source control system to ignore this file. - config.example_status_persistence_file_path = "spec/examples.txt" - - # Limits the available syntax to the non-monkey patched syntax that is - # recommended. For more details, see: - # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/ - # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ - # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode - config.disable_monkey_patching! - - # This setting enables warnings. It's recommended, but in some cases may - # be too noisy due to issues in dependencies. - config.warnings = true - - # Many RSpec users commonly either run the entire suite or an individual - # file, and it's useful to allow more verbose output when running an - # individual spec file. - if config.files_to_run.one? - # Use the documentation formatter for detailed output, - # unless a formatter has already been configured - # (e.g. via a command-line flag). - config.default_formatter = "doc" - end - - # Print the 10 slowest examples and example groups at the - # end of the spec run, to help surface which specs are running - # particularly slow. - config.profile_examples = 10 - - # Run specs in random order to surface order dependencies. If you find an - # order dependency and want to debug it, you can fix the order by providing - # the seed, which is printed after each run. - # --seed 1234 - config.order = :random - - # Seed global randomization in this process using the `--seed` CLI option. - # Setting this allows you to use `--seed` to deterministically reproduce - # test failures related to randomization by passing the same `--seed` value - # as the one that triggered the failure. - Kernel.srand config.seed -=end -end diff --git a/src/server.cr b/src/server.cr new file mode 100644 index 0000000..f4cae00 --- /dev/null +++ b/src/server.cr @@ -0,0 +1,62 @@ +require "uri" +require "kemal" +require "dotenv" + +Dotenv.load if (Kemal.config.env == "development") && File.exists?(".env") +Dotenv.load(path: ".env.test") if Kemal.config.env == "test" + +require "../config/**" + +add_handler Defense::Handler.new unless ENV["DISABLE_DEFENSE"]?.presence + +error_context = "Use the root path instead, i.e. `/?r=TARGET_URL_HERE`" + +get "/" do |env| + begin + target_uri = URI.parse(env.params.query["r"]) + + # Check that it's a valid URL + valid_uri = /https?/ =~ target_uri.scheme && target_uri.host + raise "Invalid redirect URL" unless valid_uri + + # Redirect (bounce back) requested URL + env.redirect target_uri.to_s + rescue udl_error + render "src/views/fallback.ecr" + end +end + +get "/.well-known/apple-app-site-association" do |env| + env.response.content_type = "application/json" + + if aasa_apps = ENV["AASA_APP_IDS"]? + aasa_app_ids = aasa_apps.split(" ") + { + applinks: { + apps: [] of String, + details: aasa_app_ids.map do |id| + {appID: id, paths: ["/*"]} + end, + }, + activitycontinuation: { + apps: aasa_app_ids, + }, + }.to_json + else + {error: "AASA_APP_ID not configured"}.to_json + end +end + +get "/*" do |env| + udl_error = "Invalid path `#{env.request.path}` - #{error_context}" + render "src/views/fallback.ecr" +end + +error 404 do + udl_error = "Resource Not Found - #{error_context}" + render "src/views/fallback.ecr" +end + +serve_static false +Kemal.config.port = ENV.fetch("PORT", "3000").to_i +Kemal.run diff --git a/views/fallback.erb b/src/views/fallback.ecr similarity index 53% rename from views/fallback.erb rename to src/views/fallback.ecr index 894dba9..1b75764 100644 --- a/views/fallback.erb +++ b/src/views/fallback.ecr @@ -7,8 +7,8 @@

Something went wrong

-

<%= @error %>

-

Check out the README for more details

+

<%= udl_error %>

+

Check out the README for more details

diff --git a/udl-server-deep-link.png b/udl-server-deep-link.png new file mode 100644 index 0000000..a14a958 Binary files /dev/null and b/udl-server-deep-link.png differ diff --git a/udl-server-diagram.png b/udl-server-diagram.png deleted file mode 100644 index 577a7a4..0000000 Binary files a/udl-server-diagram.png and /dev/null differ