Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Rewrote imgio to use sinatra-synchrony

Introduced sintra-synchrony
Adapted tests to run on sinatra-synchrony
Adapted Procfile to run on thin
Added test for caching headers
Modified tests to test for content type response headers
Elevated sinatra paradigms (i.e. settings) to reduce complexity
Reduced complexity here and there
Removed unused files and folders
  • Loading branch information...
commit 038fd5d9fec6b69f415e696433f9cd35da157fc0 1 parent b99bea9
@Overbryd Overbryd authored
View
1  .gitignore
@@ -0,0 +1 @@
+.rbenv*
View
18 Gemfile
@@ -1,19 +1,11 @@
source :rubygems
-group :sinatra do
- gem "sinatra"
- gem "shotgun"
-end
-
+gem "sinatra-synchrony"
gem "rmagick"
+gem "thin"
+gem "sinatra-contrib", group: :development
group :test do
- gem "rack-test"
- gem "image_size"
-end
-
-group :goliath do
- gem 'goliath', :git => 'git://github.com/postrank-labs/goliath.git'
- gem 'em-http-request', :git => 'git://github.com/igrigorik/em-http-request.git'
- gem 'em-synchrony', :git => 'git://github.com/igrigorik/em-synchrony.git'
+ gem "rack-test", require: 'rack/test'
+ gem "image_size"
end
View
109 Gemfile.lock
@@ -1,83 +1,60 @@
-GIT
- remote: git://github.com/igrigorik/em-http-request.git
- revision: add8ad4f6ee85fe55659272ade91a93ba805e2d9
- specs:
- em-http-request (1.0.1)
- addressable (>= 2.2.3)
- cookiejar
- em-socksify
- eventmachine (>= 1.0.0.beta.4)
- http_parser.rb (>= 0.5.3)
-
-GIT
- remote: git://github.com/igrigorik/em-synchrony.git
- revision: 07181cb26c4be37c1f7dae25bd7a7d9e3d1aed84
- specs:
- em-synchrony (1.0.0)
- eventmachine (>= 1.0.0.beta.1)
-
-GIT
- remote: git://github.com/postrank-labs/goliath.git
- revision: 9c1e0383bc91c6088986a0f71e063b95f804aba2
- specs:
- goliath (0.9.4)
- async-rack
- em-synchrony (>= 1.0.0)
- em-websocket
- eventmachine (>= 1.0.0.beta.3)
- http_parser.rb (~> 0.5.3)
- http_router (~> 0.9.0)
- log4r
- multi_json
- rack (>= 1.2.2)
- rack-contrib
- rack-respond_to
-
GEM
remote: http://rubygems.org/
specs:
addressable (2.2.6)
- async-rack (0.5.1)
- rack (~> 1.1)
- cookiejar (0.3.0)
+ backports (2.3.0)
+ daemons (1.1.8)
+ em-http-request (1.0.0)
+ addressable (>= 2.2.3)
+ em-socksify
+ eventmachine (>= 1.0.0.beta.3)
+ http_parser.rb (>= 0.5.2)
+ em-resolv-replace (1.1.2)
em-socksify (0.1.0)
eventmachine
- em-websocket (0.3.6)
- addressable (>= 2.1.1)
- eventmachine (>= 0.12.9)
- eventmachine (1.0.0.beta.4)
+ em-synchrony (1.0.0)
+ eventmachine (>= 1.0.0.beta.1)
+ eventmachine (1.0.0.beta.3)
http_parser.rb (0.5.3)
- http_router (0.9.7)
- rack (>= 1.0.0)
- url_mount (~> 0.2.1)
- image_size (1.0.3)
- log4r (1.1.10)
- multi_json (1.0.4)
+ image_size (1.0.6)
rack (1.4.1)
- rack-accept-media-types (0.9)
- rack-contrib (1.1.0)
- rack (>= 0.9.1)
- rack-respond_to (0.9.8)
- rack-accept-media-types (>= 0.6)
- rack-test (0.5.7)
- rack (>= 1.0)
- rmagick (2.11.0)
- shotgun (0.9)
- rack (>= 1.0)
- sinatra (1.0)
- rack (>= 1.0)
- url_mount (0.2.1)
+ rack-fiber_pool (0.9.2)
+ rack-protection (1.2.0)
rack
+ rack-test (0.6.1)
+ rack (>= 1.0)
+ rmagick (2.13.1)
+ sinatra (1.3.2)
+ rack (~> 1.3, >= 1.3.6)
+ rack-protection (~> 1.2)
+ tilt (~> 1.3, >= 1.3.3)
+ sinatra-contrib (1.3.1)
+ backports (>= 2.0)
+ eventmachine
+ rack-protection
+ rack-test
+ sinatra (~> 1.3.0)
+ tilt (~> 1.3)
+ sinatra-synchrony (0.3.0)
+ em-http-request (= 1.0.0)
+ em-resolv-replace (>= 1.1.1)
+ em-synchrony (= 1.0.0)
+ eventmachine (= 1.0.0.beta.3)
+ rack-fiber_pool (= 0.9.2)
+ sinatra (>= 1.0)
+ thin (1.3.1)
+ daemons (>= 1.0.9)
+ eventmachine (>= 0.12.6)
+ rack (>= 1.0.0)
+ tilt (1.3.3)
PLATFORMS
ruby
DEPENDENCIES
- em-http-request!
- em-synchrony!
- goliath!
image_size
rack-test
rmagick
- shotgun
- sinatra
+ sinatra-contrib
+ sinatra-synchrony
+ thin
View
2  Procfile
@@ -1 +1 @@
-web: bundle exec ruby app-goliath.rb -sv -e prod -p $PORT
+web: bundle exec thin start -p $PORT
View
1  Procfile.goliath
@@ -1 +0,0 @@
-web: bundle exec ruby app-goliath.rb -sv -e prod -p $PORT
View
2  Procfile.sinatra
@@ -1,2 +0,0 @@
-web: bundle exec rackup config.ru -p $PORT
-
View
13 app-goliath.rb
@@ -1,13 +0,0 @@
-require "goliath"
-require "#{File.dirname(__FILE__)}/config"
-require "radio"
-
-class App < Goliath::API
- # parse query params
- use Goliath::Rack::Params
- use Goliath::Rack::Render
-
- def response(env)
- Radio::Request.new(env).process
- end
-end
View
51 app-sinatra.rb
@@ -1,51 +0,0 @@
-require "#{File.dirname(__FILE__)}/config"
-
-# --- URL parsing -------------------------------------------------------------
-
-#
-# Say hi!
-get '/' do
- 'Hello, world!'
-end
-
-get %r{/fit(/(jpg|png)([0-9]{1,3})?)?(/([0-9]{1,4})(/([0-9]{1,4}))?)/((http|https)://.*)} do
- # get parameters
- _,format,quality,_,width,_,height,url,_ = *params[:captures]
- process :fit, format, quality, width, height, url
-end
-
-get %r{/fill(/(jpg|png)([0-9]{1,3})?)?(/([0-9]{1,4})(/([0-9]{1,4}))?)/((http|https)://.*)} do
- # get parameters
- _,format,quality,_,width,_,height,url,_ = *params[:captures]
- process :fill, format, quality, width, height, url
-end
-
-get %r{(/(jpg|png)([0-9]{1,3})?)?(/([0-9]{1,4})(/([0-9]{1,4}))?)/((http|https)://.*)} do
- # get parameters
- _,format,quality,_,width,_,height,url,_ = *params[:captures]
- process :scale_down, format, quality, width, height, url
-end
-
-# -----------------------------------------------------------------------------
-
-# --- various helpers ---------------------------------------------------------
-
-helpers do
- def process(mode, format, quality, width, height, url)
- # set defaults
- format ||= "jpg"
- quality ||= 85
-
- blob = Magick.process mode, format, quality, width, height, url
-
- content_type mime_type_for(format)
- response['Content-Length'] = blob.length.to_s
-
- # see http://devcenter.heroku.com/articles/http-caching
- expires TIME_TO_LIVE, :public
-
- blob
- end
-end
-
-# -----------------------------------------------------------------------------
View
85 app.rb
@@ -0,0 +1,85 @@
+# coding: utf-8
+require "sinatra"
+require "sinatra/synchrony"
+if development?
+ require "sinatra/reloader"
+ also_reload 'lib/**/*'
+end
+# RMagick spits out a ton of warnings "already initialized constant"
+verbose_was = $VERBOSE
+$VERBOSE=nil; require 'RMagick'
+$VERBOSE = verbose_was
+
+require_relative "lib/magick_processor"
+require_relative "lib/http"
+
+disable :threaded
+disable :run
+set :mime_types, {
+ 'jpg' => 'image/jpeg',
+ 'gif' => 'image/gif',
+ 'png' => 'image/png'
+ }.tap {|h| h.default = "application/octet-stream" }
+
+before do
+ expires 24*3600, :public
+end
+
+helpers do
+ def parse_formatstring(formatstring)
+ quality = nil
+ format = formatstring.sub(/([a-z]+)(\d+)/) do
+ quality = $2.to_i || 85
+ $1
+ end
+ [format, quality]
+ end
+end
+
+# GET [mode]/[format[quality]]/width/height/uri
+get %r{/(?:(scale_down|fit|fill)/)?(?:((?:jpg(?:\d{1,3})?|png))/)?(\d+)/(\d+)/(https?.+)} do |mode, formatstring, width, height, uri|
+ # TODO: fix this abnormality. Sinatra eats the second slash from http://.
+ # No, I am serious, it is just gone in the captures.
+ uri.sub!(/(https?):\/\/?/) { "#{$1}://" }
+
+ mode ||= :scale_down
+ formatstring ||= 'jpg85'
+ format, quality = parse_formatstring(formatstring)
+ content_type settings.mime_types[format]
+ Magick.process(mode, format, quality, width, height, uri)
+end
+
+get '/*' do
+ content_type "text/plain"
+ <<-USAGE
+ Welcome to imgio! „Probably the most elegant way
+ to get rid of your image resizing problems.“
+
+ FORK ME: https://github.com/radiospiel/imgio
+
+ EXAMPLES:
+
+ GET http://#{request.host_with_port}/120/90/http://www.google.de/images/srpr/logo3w.png
+ # => responds with the image scaled down to 120x90
+
+ GET http://#{request.host_with_port}/fill/120/90/http://www.google.de/images/srpr/logo3w.png
+ # => responds with the image filling a rectangle of 120x90
+
+ GET http://#{request.host_with_port}/fit/80/80/http://www.google.de/images/srpr/logo3w.png
+ # => responds with the image fitting a rectangle of 80x80
+
+ DOCUMENTATION:
+
+ GET [mode]/[format[quality]]/width/height/uri
+
+ width, height must be a positive integer
+ uri must adhere to URI specification
+
+ Defaults:
+
+ mode: scale_down
+ format: jpg
+ quality: 85
+
+ USAGE
+end
View
19 config.rb
@@ -1,19 +0,0 @@
-ROOT = File.dirname(__FILE__)
-$LOAD_PATH.unshift "#{ROOT}/lib"
-
-#
-# Verbosity: spit out more messages when set
-VERBOSE=true
-
-#
-# TTL value ins seconds for Caching headers
-TIME_TO_LIVE = 24 * 3600 # default expiration time: 1 day
-
-#
-#
-ASYNC=defined?(Goliath)
-
-require "logging"
-require "magick_processor"
-require "mime_types"
-require "http"
View
9 config.ru
@@ -1,12 +1,5 @@
require 'rubygems'
require 'bundler/setup'
-require 'sinatra'
-
-require "#{File.dirname(__FILE__)}/app-sinatra"
-
-set :environment, ENV['RACK_ENV'].to_sym
-set :root, ROOT
-set :app_file, File.join(ROOT, 'app-sinatra.rb')
-disable :run
+load 'app.rb'
run Sinatra::Application
View
57 lib/http.rb
@@ -1,55 +1,16 @@
-if ASYNC
- require 'em-synchrony/em-http'
-else
- require "net/http"
- require "uri"
-end
+require 'em-synchrony/em-http'
-#
# The Http module exports Http.get, which returns the body of an URL, and is able to follow
# a certain number of redirections.
+#
module Http
- MAX_REDIRECTIONS = 10
-
- def self.get(url, max_redirections = 10)
- start = Time.now
- status, body = I.get(url)
-
- dlog "#{url}: #{status}, #{body.length} byte, #{((Time.now - start) * 1000).to_i} msecs via #{I}"
-
- body
- end
-
- module Sync
- def self.get(url, max_redirections = MAX_REDIRECTIONS)
- raise ArgumentError, 'HTTP redirect too deep' if max_redirections == 0
-
- response = Net::HTTP.get_response(URI.parse(url))
-
- case response
- when Net::HTTPSuccess then [ response['status'], response.body ]
- when Net::HTTPRedirection then get(response['location'], max_redirections - 1)
- else response.error!
- end
+ def self.get(url)
+ connection = EM::HttpRequest.new(url).get(:redirects => 10)
+ status = connection.response_header.status
+ if status >= 200 && status < 300
+ connection.response
+ else
+ raise "Failed to fetch #{url}, status: #{connection.response_header.status}"
end
end
-
- module Async
- def self.get(url, max_redirections = MAX_REDIRECTIONS)
- raise ArgumentError, 'HTTP redirect too deep' if max_redirections == 0
-
- connection = EM::HttpRequest.new(url).get
-
- case status = connection.response_header.status
- when 300, 301, 302, 303, 304, 305, 307
- get connection.response_header['location'], max_redirections - 1
- when 200
- [status, connection.response]
- else
- raise RuntimeError, "#{url}: #{connection.response_header.status}"
- end
- end
- end
-
- I = ASYNC ? Async : Sync
end
View
7 lib/logging.rb
@@ -1,7 +0,0 @@
-def rlog(msg)
- puts msg
-end
-
-def dlog(msg)
- puts msg if VERBOSE
-end
View
9 lib/magick_code.rb
@@ -1,7 +1,3 @@
-require 'RMagick' if !defined?(Magick)
-
-# --- RMagick extensions ------------------------------------------------------
-
class Magick::Image
# scale image down, but never up.
@@ -48,9 +44,4 @@ def fit(width, height, format)
}
dst.composite(self, Magick::CenterGravity, Magick::OverCompositeOp)
end
-
- # load an image from an URL
- def self.from_url(url)
- Magick::Image.from_blob Http.get(url)
- end
end
View
16 lib/magick_processor.rb
@@ -1,17 +1,9 @@
-require 'RMagick' if !defined?(Magick)
-
require "#{File.dirname(__FILE__)}/magick_code"
module Magick
def self.process(mode, format, quality, width, height, url)
- rlog "processing #{url}: #{mode} #{format}#{quality} #{width}x#{height}"
-
- # try to parse url. Failure -> exception
- uri = URI.parse(url)
-
# fetch image from URL
- img = Magick::Image.from_url(url).first
- dlog " #{url}: original geometry: #{img.columns}x#{img.rows}"
+ img = Magick::Image.from_blob(Http.get(url)).first
# get requested image size, fill in height default to match image's aspect ratio.
width, height = width.to_i, height.to_i
@@ -23,13 +15,9 @@ def self.process(mode, format, quality, width, height, url)
img = img.send mode, width.to_i, height.to_i, format
# write out image
- blob = img.to_blob do |img|
+ img.to_blob do |img|
img.format = format.upcase
img.quality = quality.to_i
end
-
- dlog " #{url}: resulting length: #{blob.length} byte"
-
- blob
end
end
View
9 lib/mime_types.rb
@@ -1,9 +0,0 @@
-MIME_TYPES = {
- 'jpg' => 'image/jpeg',
- 'gif' => 'image/gif',
- 'png' => 'image/png'
-}
-
-def mime_type_for(format)
- MIME_TYPES[format] || "application/octet-stream"
-end
View
16 lib/radio.rb
@@ -1,16 +0,0 @@
-module Radio
- def self.development?
- true
- end
-
- def self.production?
- !development?
- end
-end
-
-require "radio/loader.rb"
-require "radio/controller.rb"
-require "radio/request.rb"
-require "radio/utils.rb"
-
-Radio::Loader.setup
View
42 lib/radio/controller.rb
@@ -1,42 +0,0 @@
-
-class Radio::Controller
- attr :status, true
- attr :headers, true
- attr :body, true
- attr :request
-
- def initialize(request)
- @status = 200
- @headers = {}
- @body = nil
-
- @request = request
- end
-
- def process!
- # @body can be set in process, or as the result from process_request
- body = process
- @body ||= body
- self
- end
-
- protected
-
- # Override this method!
- def process
- content_type "text/plain"
- @request.uri
- end
-
- def content_type(content_type)
- headers['Content-Type'] = content_type.to_s
- end
-
- def content_length(content_length)
- headers['Content-Length'] = content_length.to_s
- end
-
- def expires(max_age, mode)
- headers['Cache-Control'] = "#{mode}, max-age=#{max_age}"
- end
-end
View
89 lib/radio/loader.rb
@@ -1,89 +0,0 @@
-#
-# A Path foo/bar/baz is handled by the FooBarBazController, the FooBarController, or the BarController.
-# As a preparation we load the radio/foo/bar/baz.rb, radio/foo/bar.rb, and radio/foo.rb files first.
-class Radio::Loader
- def self.load_class(name)
- const_get(name)
- rescue NameError
- end
-
- module Development
- def load_file(name)
- fullname = "#{ROOT}/radio/#{name}.rb"
- puts "loading: #{fullname}"
- load fullname
- rescue LoadError
- end
-
- def setup
- end
-
- def controller_class_for(path)
- loader = PathLoader.new(path)
- loader.load_files
- loader.load_class || load_root_controller
- end
- end
-
- module Production
- def load_file(name); end
-
- def setup
- Dir.glob("#{ROOT}/radio/**/*.rb").sort.each do |file|
- puts "loading: #{ROOT}/radio/#{name}.rb"
- load file
- end
- end
-
- def controller_class_for(path)
- loader = PathLoader.new(path)
- loader.load_class || load_root_controller
- end
- end
-
- if Radio.development?
- extend Development
- else
- extend Production
- end
-
- def self.load_root_controller
- load_file("root")
- load_class("RootController")
- end
-
- # -- A loader object is used to load the class for a specific path.
-
- class PathLoader
- def initialize(path)
- @path = path
- end
-
- def load_class
- p = parts.map { |part| part[0,1].upcase + part[1..-1] }
- while p.length > 0 do
- klass = Radio::Loader.load_class parts.join("") + "Controller"
- return klass if klass
- p.pop
- end
- nil
- end
-
- def load_files
- p = parts
- while p.length > 0 do
- Radio::Loader.load_file p.join("/")
- p.pop
- end
- end
-
- private
-
- def parts
- @path.split(/\//).map do |part|
- next unless part =~ /^([a-zA-Z_])([a-zA-Z_0-9]*)$/
- part.downcase
- end.compact
- end
- end
-end
View
40 lib/radio/request.rb
@@ -1,40 +0,0 @@
-
-class Radio::Request
- attr_reader :env, :uri
-
- def initialize(env)
- @env = env
- @uri = "http://#{env["SERVER_NAME"]}:#{env["SERVER_PORT"]}#{env["REQUEST_URI"]}"
- end
-
- def params
- env["params"]
- end
-
- private
-
- def request_method; env["REQUEST_METHOD"]; end
-
- public
-
- def get?; request_method == "GET"; end
- def put?; request_method == "PUT"; end
- def post?; request_method == "POST"; end
-
- def path; env["PATH_INFO"]; end
-
- def process
- controller_klass = Radio::Loader.controller_class_for(path) || Radio::LogController
- dlog "Performing using #{controller_klass}"
-
- c = controller_klass.new(self)
- c.process!
- [ c.status, c.headers, c.body ]
- end
-
- def inspect
- env.map do |k,v|
- "#{k}: #{v}" if k == k.upcase
- end.compact.sort.join("\n")
- end
-end
View
17 lib/radio/utils.rb
@@ -1,17 +0,0 @@
-class Radio::LogController < Radio::Controller
- # Override this method!
- def process
- content_type "text/plain"
- request.env.map do |k,v|
- "#{k}: #{v}"
- end.compact.sort.join("\n")
- end
-end
-
-class Radio::RootController < Radio::Controller
- def process
- content_type "text/plain"
- self.status = 404
- nil
- end
-end
View
0  log/.empty
No changes.
View
50 radio/root.rb
@@ -1,50 +0,0 @@
-class RootController < Radio::Controller
- def params
- request.params
- end
-
- def process
- if request.get?
- case request.path
- when %r{/fit(/(jpg|png)([0-9]{1,3})?)?(/([0-9]{1,4})(/([0-9]{1,4}))?)/((http|https)://.*)}
- captures = $~.captures
-
- _,format,quality,_,width,_,height,url,_ = *captures
- return process_img_io :fit, format, quality, width, height, url
-
- when %r{/fill(/(jpg|png)([0-9]{1,3})?)?(/([0-9]{1,4})(/([0-9]{1,4}))?)/((http|https)://.*)}
- captures = $~.captures
-
- _,format,quality,_,width,_,height,url,_ = *captures
- return process_img_io :fill, format, quality, width, height, url
-
- when %r{(/(jpg|png)([0-9]{1,3})?)?(/([0-9]{1,4})(/([0-9]{1,4}))?)/((http|https)://.*)}
- captures = $~.captures
-
- _,format,quality,_,width,_,height,url,_ = *captures
- return process_img_io :scale_down, format, quality, width, height, url
- end
- end
-
- error404
- end
-
- def error404
- self.status = 404
- "Don't know how to handle #{request.path}"
- end
-
- def process_img_io(mode, format, quality, width, height, url)
- # set defaults
- format ||= "jpg"
- quality ||= 85
-
- blob = Magick.process mode, format, quality, width, height, url
-
- content_type mime_type_for(format)
- content_length blob.length
- expires TIME_TO_LIVE, :public
-
- blob
- end
-end
View
3  script/server
@@ -1,3 +0,0 @@
-#!/usr/bin/env ruby
-exec "shotgun app.rb"
-
View
58 tests/resizing_test.rb
@@ -1,10 +1,10 @@
-Bundler.require(:default, :test)
-
-require "#{File.expand_path File.dirname(__FILE__)}/../app.rb"
+ENV['RACK_ENV'] = 'test'
+Bundler.setup(:default)
+require_relative "../app.rb"
require 'test/unit'
-require 'rack/test'
+Bundler.require(:test)
-ENV['RACK_ENV'] = 'test'
+Sinatra::Synchrony.patch_tests!
class ImgioTest < Test::Unit::TestCase
include Rack::Test::Methods
@@ -13,34 +13,44 @@ def app
Sinatra::Application
end
- def test_it_says_hello_world
+ def image
+ ImageSize.new(last_response.body)
+ end
+
+ def test_be_nice_to_your_users
get '/'
assert last_response.ok?
- assert_equal 'Hello, world!', last_response.body
+ assert_match /Welcome to imgio/, last_response.body
end
- def test_it_fills_100_50
+ def test_fill_a_rectangle_with_the_image
get '/fill/100/50/http://www.rubycgi.org/image/ruby_gtk_book_title.jpg'
- img = ImageSize.new(last_response.body)
- assert_equal 100, img.width
- assert_equal 50, img.height
+ assert_equal 100, image.width
+ assert_equal 50, image.height
end
-
- def test_it_fits_100_50
+
+ def test_fit_the_image_into_a_rectangle
get '/fill/100/50/http://www.rubycgi.org/image/ruby_gtk_book_title.jpg'
- img = ImageSize.new(last_response.body)
- assert_equal 100, img.width or assert_equal 50, img.height
+ assert_equal 100, image.width
+ assert_equal 50, image.height
end
-
- def test_it_returns_jpg
- get '/fill/jpg/100/50/http://www.rubycgi.org/image/ruby_gtk_book_title.jpg'
- img = ImageSize.new(last_response.body)
- assert_equal :jpeg, img.format
+
+ def test_respond_with_jpg_by_default
+ get '/fill/100/50/http://www.rubycgi.org/image/ruby_gtk_book_title.jpg'
+ assert_equal :jpeg, image.format
+ assert_equal 'image/jpeg', last_response.headers['Content-Type']
+ end
+
+ def test_respond_with_png_if_requested
+ get '/fill/png/100/50/http://www.rubycgi.org/image/ruby_gtk_book_title.jpg'
+ assert_equal :png, image.format
+ assert_equal 'image/png', last_response.headers['Content-Type']
end
- def test_it_returns_png
+ def test_set_caching_headers
get '/fill/png/100/50/http://www.rubycgi.org/image/ruby_gtk_book_title.jpg'
- img = ImageSize.new(last_response.body)
- assert_equal :png, img.format
+ assert_equal "public, max-age=86400", last_response.headers['Cache-Control']
+ assert_not_nil last_response.headers['Expires']
end
-end
+
+end
View
0  tmp/restart.txt
No changes.
Please sign in to comment.
Something went wrong with that request. Please try again.