├── .gitignore ├── Gemfile ├── Gemfile.lock ├── LICENSE.md ├── README.md ├── Rakefile ├── app.rb ├── config.ru ├── images ├── lgtm.gif └── lgtm_with_comments.gif ├── lib └── cache_configuration.rb ├── public ├── favicon.ico └── robots.txt ├── spec └── spec.rb └── views └── index.haml /.gitignore: -------------------------------------------------------------------------------- 1 | ### https://raw.github.com/github/gitignore//Ruby.gitignore 2 | 3 | *.gem 4 | *.rbc 5 | /.config 6 | /coverage/ 7 | /InstalledFiles 8 | /pkg/ 9 | /spec/reports/ 10 | /spec/examples.txt 11 | /test/tmp/ 12 | /test/version_tmp/ 13 | /tmp/ 14 | 15 | # Used by dotenv library to load environment variables. 16 | # .env 17 | 18 | # Ignore Byebug command history file. 19 | .byebug_history 20 | 21 | ## Specific to RubyMotion: 22 | .dat* 23 | .repl_history 24 | build/ 25 | *.bridgesupport 26 | build-iPhoneOS/ 27 | build-iPhoneSimulator/ 28 | 29 | ## Specific to RubyMotion (use of CocoaPods): 30 | # 31 | # We recommend against adding the Pods directory to your .gitignore. However 32 | # you should judge for yourself, the pros and cons are mentioned at: 33 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 34 | # 35 | # vendor/Pods/ 36 | 37 | ## Documentation cache and generated files: 38 | /.yardoc/ 39 | /_yardoc/ 40 | /doc/ 41 | /rdoc/ 42 | 43 | ## Environment normalization: 44 | /.bundle/ 45 | /vendor/bundle 46 | /lib/bundler/man/ 47 | 48 | # for a library or gem, you might want to ignore these files since the code is 49 | # intended to run in multiple environments; otherwise, check them in: 50 | # Gemfile.lock 51 | # .ruby-version 52 | # .ruby-gemset 53 | 54 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 55 | .rvmrc 56 | 57 | 58 | tags 59 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'sinatra' 4 | gem 'sinatra-contrib' 5 | gem 'rack' 6 | gem 'rack-cache' 7 | gem 'haml' 8 | gem 'rmagick', require: 'RMagick' 9 | gem 'dalli' 10 | gem 'pry' 11 | gem 'rest-client' 12 | gem 'morito' 13 | 14 | group :production do 15 | gem 'heroku-deflater', group: :production 16 | end 17 | 18 | group :test do 19 | gem 'rspec' 20 | gem 'rspec-core' 21 | gem 'rspec-mocks' 22 | end 23 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | backports (3.14.0) 5 | coderay (1.1.2) 6 | dalli (2.7.10) 7 | diff-lcs (1.3) 8 | domain_name (0.5.20180417) 9 | unf (>= 0.0.5, < 1.0.0) 10 | faraday (0.15.4) 11 | multipart-post (>= 1.2, < 3) 12 | haml (5.0.4) 13 | temple (>= 0.8.0) 14 | tilt 15 | heroku-deflater (0.6.3) 16 | rack (>= 1.4.5) 17 | http-cookie (1.0.3) 18 | domain_name (~> 0.5) 19 | method_source (0.9.2) 20 | mime-types (3.2.2) 21 | mime-types-data (~> 3.2015) 22 | mime-types-data (3.2019.0331) 23 | morito (0.0.5) 24 | faraday 25 | multi_json (1.13.1) 26 | multipart-post (2.0.0) 27 | mustermann (1.0.3) 28 | netrc (0.11.0) 29 | pry (0.12.2) 30 | coderay (~> 1.1.0) 31 | method_source (~> 0.9.0) 32 | rack (2.0.7) 33 | rack-cache (1.9.0) 34 | rack (>= 0.4) 35 | rack-protection (2.0.5) 36 | rack 37 | rest-client (2.0.2) 38 | http-cookie (>= 1.0.2, < 2.0) 39 | mime-types (>= 1.16, < 4.0) 40 | netrc (~> 0.8) 41 | rmagick (3.1.0) 42 | rspec (3.8.0) 43 | rspec-core (~> 3.8.0) 44 | rspec-expectations (~> 3.8.0) 45 | rspec-mocks (~> 3.8.0) 46 | rspec-core (3.8.0) 47 | rspec-support (~> 3.8.0) 48 | rspec-expectations (3.8.3) 49 | diff-lcs (>= 1.2.0, < 2.0) 50 | rspec-support (~> 3.8.0) 51 | rspec-mocks (3.8.0) 52 | diff-lcs (>= 1.2.0, < 2.0) 53 | rspec-support (~> 3.8.0) 54 | rspec-support (3.8.0) 55 | sinatra (2.0.5) 56 | mustermann (~> 1.0) 57 | rack (~> 2.0) 58 | rack-protection (= 2.0.5) 59 | tilt (~> 2.0) 60 | sinatra-contrib (2.0.5) 61 | backports (>= 2.8.2) 62 | multi_json 63 | mustermann (~> 1.0) 64 | rack-protection (= 2.0.5) 65 | sinatra (= 2.0.5) 66 | tilt (>= 1.3, < 3) 67 | temple (0.8.1) 68 | tilt (2.0.9) 69 | unf (0.1.4) 70 | unf_ext 71 | unf_ext (0.0.7.6) 72 | 73 | PLATFORMS 74 | ruby 75 | 76 | DEPENDENCIES 77 | dalli 78 | haml 79 | heroku-deflater 80 | morito 81 | pry 82 | rack 83 | rack-cache 84 | rest-client 85 | rmagick 86 | rspec 87 | rspec-core 88 | rspec-mocks 89 | sinatra 90 | sinatra-contrib 91 | 92 | BUNDLED WITH 93 | 1.17.2 94 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) Yoshiteru Negishi 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [lgtm](http://lgtm.herokuapp.com/) 2 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | Bundler.require 3 | require './lib/cache_configuration' 4 | 5 | task :clear_cache do 6 | desc 'clear cache' 7 | CacheConfiguration.client.flush 8 | puts 'clear cache' 9 | end 10 | 11 | task :spec do 12 | sh 'rspec', './spec/spec.rb' 13 | end 14 | -------------------------------------------------------------------------------- /app.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'bundler/setup' 4 | Bundler.require 5 | require 'uri' 6 | 7 | module Lgtm 8 | module Requestable 9 | USER_AGENT = "lgtm web app - http://lgtm.herokuapp.com/ - mailto: #{ENV['MAIL_ADDRESS']}" 10 | MAX_CONTENT_LENGTH = (ENV['MAX_CONTENT_LENGTH'] || 2_097_152).to_i 11 | 12 | def morito_client 13 | @morito_client ||= Morito::Client.new(USER_AGENT) 14 | end 15 | 16 | def fetch(raw_uri) 17 | raise NotUrlException unless URI.regexp === raw_uri 18 | raise NotAllowedUrlException unless morito_client.allowed?(raw_uri) 19 | content_length = RestClient.head(raw_uri, user_aget: USER_AGENT).headers[:content_length] 20 | raise OverMaxContentLengthException if content_length.to_i > MAX_CONTENT_LENGTH 21 | RestClient.get(raw_uri, user_agent: USER_AGENT) 22 | end 23 | 24 | def raw_uri_by_path_info 25 | uri = request.path_info.sub(/\A\/(?:(?:glitch|with_comments|blur)\/)?/, '') 26 | uri.sub(/\A(https?):\//) do 27 | "#{$1}://" 28 | end 29 | end 30 | 31 | class NotUrlException < Exception; end 32 | class NotAllowedUrlException < Exception; end 33 | class OverMaxContentLengthException < Exception; end 34 | end 35 | 36 | class App < Sinatra::Application 37 | CACHE_MAX_AGE = 10 * 24 * 60 * 60 # 10 days 38 | 39 | include Requestable 40 | class NotLgtmableImageException < Exception; end 41 | 42 | error RestClient::ResourceNotFound do 43 | status 404 44 | 'image not found' 45 | end 46 | 47 | error RestClient::Forbidden do 48 | status 403 49 | 'image forbidden' 50 | end 51 | 52 | error NotUrlException do 53 | status 403 54 | 'not url' 55 | end 56 | 57 | error NotAllowedUrlException do 58 | status 403 59 | 'image not accessable' 60 | end 61 | 62 | error NotLgtmableImageException do 63 | status 400 64 | 'only animated gif supported' 65 | end 66 | 67 | error OverMaxContentLengthException do 68 | status 403 69 | 'over max content_length' 70 | end 71 | 72 | get '/' do 73 | @domain = [ 74 | request.host, 75 | [80, 8000].include?(request.port) ? nil : request.port 76 | ].join(':') 77 | haml :index 78 | end 79 | 80 | get '/*' do 81 | cache_control :public, max_age: CACHE_MAX_AGE 82 | 83 | response = fetch(raw_uri_by_path_info) 84 | 85 | unless /gif/ === response.headers[:content_type] 86 | raise NotLgtmableImageException 87 | end 88 | 89 | content_type response.headers[:content_type] 90 | Lgtm::ImageBuilder.new( 91 | response.body, 92 | glitch: glitch?, 93 | blur: blur?, 94 | with_comments: with_comments? 95 | ).build 96 | end 97 | 98 | def glitch? 99 | /\A\/glitch\// === request.path_info 100 | end 101 | 102 | def blur? 103 | /\A\/blur\// === request.path_info 104 | end 105 | 106 | def with_comments? 107 | /\A\/with_comments\// === request.path_info 108 | end 109 | end 110 | 111 | class ImageBuilder 112 | LGTM_IMAGE_WIDTH = 1_000 113 | 114 | def initialize(blob, options = {}) 115 | @sources = ::Magick::ImageList.new.from_blob(blob).coalesce 116 | @options = options.reverse_merge( 117 | glitch: false, 118 | blur: false, 119 | with_comments: false, 120 | ) 121 | end 122 | 123 | def build 124 | images = ::Magick::ImageList.new 125 | 126 | @sources.each_with_index do |source, index| 127 | target = source 128 | target = blur(target) if @options[:blur] 129 | target = lgtmify(target) 130 | target = glitch(target) if @options[:glitch] 131 | target.delay = source.delay 132 | images << target 133 | end 134 | 135 | images.iterations = 0 136 | 137 | images. 138 | optimize_layers(Magick::OptimizeLayer). 139 | to_blob 140 | end 141 | 142 | private 143 | 144 | def width 145 | @sources.first.columns 146 | end 147 | 148 | def height 149 | @sources.first.rows 150 | end 151 | 152 | def lgtm_image 153 | return @lgtm_image if @lgtm_image 154 | 155 | scale = width.to_f / LGTM_IMAGE_WIDTH 156 | if @options[:with_comments] 157 | path = './images/lgtm_with_comments.gif' 158 | else 159 | path = './images/lgtm.gif' 160 | end 161 | @lgtm_image = ::Magick::ImageList.new(path).scale(scale) 162 | end 163 | 164 | def glitch(source) 165 | colors = [] 166 | color_size = source.colors 167 | blob = source.to_blob 168 | color_size.times do |index| 169 | colors << blob[20 + index, 3] 170 | end 171 | color_size.times do |index| 172 | blob[20 + index, 3] = colors.sample 173 | end 174 | Magick::Image.from_blob(blob).first 175 | end 176 | 177 | def blur(source) 178 | source.blur_image(0.0, 5.0) 179 | end 180 | 181 | def lgtmify(source) 182 | source.composite!( 183 | lgtm_image, 184 | ::Magick::CenterGravity, 185 | ::Magick::OverCompositeOp 186 | ) 187 | end 188 | end 189 | end 190 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | require 'rack' 2 | require './app' 3 | require './lib/cache_configuration' 4 | 5 | if CacheConfiguration.available? 6 | use Rack::Cache, CacheConfiguration.options 7 | end 8 | 9 | run Lgtm::App 10 | -------------------------------------------------------------------------------- /images/lgtm.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/negipo/lgtm/9a1b54676cbe3465f8b138f7dced946e6e68096a/images/lgtm.gif -------------------------------------------------------------------------------- /images/lgtm_with_comments.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/negipo/lgtm/9a1b54676cbe3465f8b138f7dced946e6e68096a/images/lgtm_with_comments.gif -------------------------------------------------------------------------------- /lib/cache_configuration.rb: -------------------------------------------------------------------------------- 1 | class CacheConfiguration 2 | class << self 3 | def options 4 | { 5 | metastore: client, 6 | entitystore: client 7 | } 8 | end 9 | 10 | def available? 11 | !servers.nil? 12 | end 13 | 14 | def client 15 | @client ||= Dalli::Client.new( 16 | servers, 17 | auth_options.merge(value_max_bytes: 10485760) 18 | ) 19 | end 20 | 21 | private 22 | 23 | def auth_options 24 | if ENV["MEMCACHEDCLOUD_USERNAME"] 25 | { 26 | username: ENV["MEMCACHEDCLOUD_USERNAME"], 27 | password: ENV["MEMCACHEDCLOUD_PASSWORD"] 28 | } 29 | else 30 | {} 31 | end 32 | end 33 | 34 | def servers 35 | if ENV["MEMCACHE_SERVERS"] 36 | ENV["MEMCACHE_SERVERS"].split(',') 37 | elsif ENV["MEMCACHEDCLOUD_SERVERS"] 38 | ENV["MEMCACHEDCLOUD_SERVERS"].split(',') 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/negipo/lgtm/9a1b54676cbe3465f8b138f7dced946e6e68096a/public/favicon.ico -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: /http 3 | -------------------------------------------------------------------------------- /spec/spec.rb: -------------------------------------------------------------------------------- 1 | require 'rest_client' 2 | require 'pry' 3 | 4 | describe 'localhost' do 5 | before(:all) do 6 | puts 'You should run "RACK_ENV=production rackup config.ru -p 4567" before spec' 7 | end 8 | 9 | it 'converts with redirect link' do 10 | response = RestClient.get('http://localhost:4567/http://bit.ly/1jB2nmf') 11 | response.size.should > 10000 12 | response.code.should == 200 13 | end 14 | 15 | it 'converts https' do 16 | response = RestClient.get('http://localhost:4567/https://24.media.tumblr.com/b963175d1d3632506a8bafd9ea5029eb/tumblr_n3x2kv7QZo1tq47ppo1_500.gif') 17 | response.size.should > 10000 18 | response.code.should == 200 19 | end 20 | 21 | it 'enables glitch mode' do 22 | response = RestClient.get('http://localhost:4567/glitch/https://24.media.tumblr.com/b963175d1d3632506a8bafd9ea5029eb/tumblr_n3x2kv7QZo1tq47ppo1_500.gif') 23 | response.size.should > 10000 24 | response.code.should == 200 25 | end 26 | 27 | it 'enables blur mode' do 28 | response = RestClient.get('http://localhost:4567/glitch/https://24.media.tumblr.com/b963175d1d3632506a8bafd9ea5029eb/tumblr_n3x2kv7QZo1tq47ppo1_500.gif') 29 | response.size.should > 10000 30 | response.code.should == 200 31 | end 32 | 33 | it 'enables with_comments mode' do 34 | response = RestClient.get('http://localhost:4567/with_comments/https://24.media.tumblr.com/b963175d1d3632506a8bafd9ea5029eb/tumblr_n3x2kv7QZo1tq47ppo1_500.gif') 35 | response.size.should > 10000 36 | response.code.should == 200 37 | end 38 | 39 | it 'does not convert png' do 40 | expect { 41 | RestClient.get('http://localhost:4567/http://37.media.tumblr.com/b8e0f7522f720f859569d4ae8068fc20/tumblr_n5loilzE0n1qz529lo1_400.png') 42 | }.to raise_error(RestClient::BadRequest) 43 | end 44 | 45 | it 'does not convert 404' do 46 | expect { 47 | RestClient.get('http://localhost:4567/http://example.com/hoge') 48 | }.to raise_error(RestClient::ResourceNotFound) 49 | end 50 | 51 | it 'does not convert that not robots.txt allowed' do 52 | expect { 53 | RestClient.get('http://localhost:4567/http://yelp.com/') 54 | }.to raise_error(RestClient::Forbidden) 55 | end 56 | 57 | it 'does not convert image having over max content_length' do 58 | expect { 59 | RestClient.get('http://localhost:4567/http://giant.gfycat.com/UnfinishedSelfreliantAnkole.gif') 60 | }.to raise_error(RestClient::Forbidden) 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /views/index.haml: -------------------------------------------------------------------------------- 1 | !!! 2 | %html 3 | %head 4 | %meta{ content: "text/html; charset=utf-8", "http-equiv" => "content-type" } 5 | %title 6 | lgtm 7 | %body 8 | #description 9 | http://#{@domain}/:url_of_animated_gif 10 | 11 | #bookmarklet_wrapper 12 | bookmarklet: 13 | %a{ href: "javascript:(function() {location.href = 'http://#{@domain}/' + location.href;})()" } 14 | LGTM 15 | 16 | #ext_wrapper 17 | Also you can copy link / markdown easily with 18 | %a{ href: "https://chrome.google.com/webstore/detail/lgtmify/nbbfoappojcjaihpopkiekdleojmmffe", target: '_blank' } 19 | Chrome Extension 20 | 21 | %img{ src: '/http://24.media.tumblr.com/ce21b5fa061fe8f71d500a6a2115bc83/tumblr_n5e7ftG1321qz4e26o1_500.gif', id: 'demo' } 22 | 23 | #github_link 24 | %a{ href: 'https://github.com/negipo/lgtm' } 25 | github 26 | --------------------------------------------------------------------------------