cloudkit-jruby 0.11.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/CHANGES +47 -0
- data/COPYING +20 -0
- data/README +84 -0
- data/Rakefile +42 -0
- data/TODO +21 -0
- data/cloudkit.gemspec +89 -0
- data/doc/curl.html +388 -0
- data/doc/images/example-code.gif +0 -0
- data/doc/images/json-title.gif +0 -0
- data/doc/images/oauth-discovery-logo.gif +0 -0
- data/doc/images/openid-logo.gif +0 -0
- data/doc/index.html +90 -0
- data/doc/main.css +151 -0
- data/doc/rest-api.html +467 -0
- data/examples/1.ru +3 -0
- data/examples/2.ru +3 -0
- data/examples/3.ru +6 -0
- data/examples/4.ru +5 -0
- data/examples/5.ru +9 -0
- data/examples/6.ru +11 -0
- data/examples/TOC +17 -0
- data/lib/cloudkit.rb +92 -0
- data/lib/cloudkit/constants.rb +34 -0
- data/lib/cloudkit/exceptions.rb +10 -0
- data/lib/cloudkit/flash_session.rb +20 -0
- data/lib/cloudkit/oauth_filter.rb +266 -0
- data/lib/cloudkit/oauth_store.rb +48 -0
- data/lib/cloudkit/openid_filter.rb +236 -0
- data/lib/cloudkit/openid_store.rb +100 -0
- data/lib/cloudkit/rack/builder.rb +120 -0
- data/lib/cloudkit/rack/router.rb +20 -0
- data/lib/cloudkit/request.rb +177 -0
- data/lib/cloudkit/service.rb +162 -0
- data/lib/cloudkit/store.rb +349 -0
- data/lib/cloudkit/store/memory_table.rb +99 -0
- data/lib/cloudkit/store/resource.rb +269 -0
- data/lib/cloudkit/store/response.rb +52 -0
- data/lib/cloudkit/store/response_helpers.rb +84 -0
- data/lib/cloudkit/templates/authorize_request_token.erb +19 -0
- data/lib/cloudkit/templates/oauth_descriptor.erb +43 -0
- data/lib/cloudkit/templates/oauth_meta.erb +8 -0
- data/lib/cloudkit/templates/openid_login.erb +31 -0
- data/lib/cloudkit/templates/request_authorization.erb +23 -0
- data/lib/cloudkit/templates/request_token_denied.erb +18 -0
- data/lib/cloudkit/uri.rb +88 -0
- data/lib/cloudkit/user_store.rb +37 -0
- data/lib/cloudkit/util.rb +25 -0
- data/spec/ext_spec.rb +76 -0
- data/spec/flash_session_spec.rb +20 -0
- data/spec/memory_table_spec.rb +86 -0
- data/spec/oauth_filter_spec.rb +326 -0
- data/spec/oauth_store_spec.rb +10 -0
- data/spec/openid_filter_spec.rb +81 -0
- data/spec/openid_store_spec.rb +101 -0
- data/spec/rack_builder_spec.rb +39 -0
- data/spec/request_spec.rb +191 -0
- data/spec/resource_spec.rb +310 -0
- data/spec/service_spec.rb +1039 -0
- data/spec/spec_helper.rb +32 -0
- data/spec/store_spec.rb +10 -0
- data/spec/uri_spec.rb +93 -0
- data/spec/user_store_spec.rb +10 -0
- data/spec/util_spec.rb +11 -0
- metadata +180 -0
@@ -0,0 +1,48 @@
|
|
1
|
+
module CloudKit
|
2
|
+
|
3
|
+
# An OAuthStore is a thin abstraction around CloudKit::Store, providing
|
4
|
+
# consistent collection names, and allowing automatic upgrades in later
|
5
|
+
# releases if needed.
|
6
|
+
class OAuthStore
|
7
|
+
@@store = nil
|
8
|
+
|
9
|
+
# Initialize a Store for use with OAuth middleware. Load the static consumer
|
10
|
+
# resource if it does not exist.
|
11
|
+
def initialize
|
12
|
+
@@store = Store.new(
|
13
|
+
:collections => [
|
14
|
+
:cloudkit_oauth_nonces,
|
15
|
+
:cloudkit_oauth_tokens,
|
16
|
+
:cloudkit_oauth_request_tokens,
|
17
|
+
:cloudkit_oauth_consumers]
|
18
|
+
) unless @@store
|
19
|
+
load_static_consumer
|
20
|
+
end
|
21
|
+
|
22
|
+
def get(uri, options={}) #:nodoc:
|
23
|
+
@@store.get(CloudKit::URI.new(uri), options)
|
24
|
+
end
|
25
|
+
|
26
|
+
def post(uri, options={}) #:nodoc:
|
27
|
+
@@store.post(CloudKit::URI.new(uri), options)
|
28
|
+
end
|
29
|
+
|
30
|
+
def put(uri, options={}) #:nodoc:
|
31
|
+
@@store.put(CloudKit::URI.new(uri), options)
|
32
|
+
end
|
33
|
+
|
34
|
+
def delete(uri, options={}) #:nodoc:
|
35
|
+
@@store.delete(CloudKit::URI.new(uri), options)
|
36
|
+
end
|
37
|
+
|
38
|
+
# Return the version number for this store.
|
39
|
+
def version; 1; end
|
40
|
+
|
41
|
+
# Load the static consumer entity if it does not already exist.
|
42
|
+
# See the OAuth Discovery spec for more info on static consumers.
|
43
|
+
def load_static_consumer
|
44
|
+
json = JSON.generate(:secret => '')
|
45
|
+
@@store.put(CloudKit::URI.new('/cloudkit_oauth_consumers/cloudkitconsumer'), :json => json)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,236 @@
|
|
1
|
+
module CloudKit
|
2
|
+
|
3
|
+
# An OpenIDFilter provides OpenID authentication, listening for upstream
|
4
|
+
# OAuth authentication and bypassing if already authorized.
|
5
|
+
#
|
6
|
+
# The root URI, "/", is always bypassed. More URIs can also be bypassed using
|
7
|
+
# the :allow option:
|
8
|
+
#
|
9
|
+
# use Rack::Session::Pool
|
10
|
+
# use OpenIDFilter, :allow => ['/foo', '/bar']
|
11
|
+
#
|
12
|
+
# In addition to the :allow option, a block can also be used for more complex
|
13
|
+
# decisions:
|
14
|
+
#
|
15
|
+
# use Rack::Session::Pool
|
16
|
+
# use OpenIDFilter, :allow => ['/foo'] do |url|
|
17
|
+
# bar(url) # some method returning true or false
|
18
|
+
# end
|
19
|
+
#
|
20
|
+
# Responds to the following URIs:
|
21
|
+
# /login
|
22
|
+
# /logout
|
23
|
+
# /openid_complete
|
24
|
+
#
|
25
|
+
class OpenIDFilter
|
26
|
+
include Util
|
27
|
+
|
28
|
+
@@lock = Mutex.new
|
29
|
+
@@store = nil
|
30
|
+
|
31
|
+
def initialize(app, options={}, &bypass_route_callback)
|
32
|
+
@app = app
|
33
|
+
@options = options
|
34
|
+
@bypass_route_callback = bypass_route_callback || Proc.new {|url| url == '/'}
|
35
|
+
end
|
36
|
+
|
37
|
+
def call(env)
|
38
|
+
@@lock.synchronize do
|
39
|
+
@@store = OpenIDStore.new
|
40
|
+
@users = UserStore.new
|
41
|
+
end unless @@store
|
42
|
+
|
43
|
+
request = Request.new(env)
|
44
|
+
request.announce_auth(CLOUDKIT_OPENID_FILTER_KEY)
|
45
|
+
|
46
|
+
case request
|
47
|
+
when r(:get, request.login_url); request_login(request)
|
48
|
+
when r(:post, request.login_url); begin_openid_login(request)
|
49
|
+
when r(:get, '/openid_complete'); complete_openid_login(request)
|
50
|
+
when r(:post, request.logout_url); logout(request)
|
51
|
+
else
|
52
|
+
if bypass?(request)
|
53
|
+
@app.call(env)
|
54
|
+
else
|
55
|
+
if request.env[CLOUDKIT_AUTH_CHALLENGE]
|
56
|
+
store_location(request)
|
57
|
+
erb(
|
58
|
+
request,
|
59
|
+
:openid_login,
|
60
|
+
request.env[CLOUDKIT_AUTH_CHALLENGE].merge('Content-Type' => 'text/html'),
|
61
|
+
401)
|
62
|
+
elsif !request.via.include?(CLOUDKIT_OAUTH_FILTER_KEY)
|
63
|
+
store_location(request)
|
64
|
+
login_redirect(request)
|
65
|
+
else
|
66
|
+
Rack::Response.new('server misconfigured', 500).finish
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def logout(request)
|
73
|
+
user_uri = request.session.delete('user_uri')
|
74
|
+
result = @users.get(user_uri)
|
75
|
+
user = result.parsed_content
|
76
|
+
user.delete('remember_me_token')
|
77
|
+
user.delete('remember_me_expiration')
|
78
|
+
json = JSON.generate(user)
|
79
|
+
@users.put(user_uri, :etag => result.etag, :json => json)
|
80
|
+
|
81
|
+
request.env[CLOUDKIT_AUTH_KEY] = nil
|
82
|
+
request.flash['info'] = 'You have been logged out.'
|
83
|
+
response = Rack::Response.new(
|
84
|
+
[],
|
85
|
+
302,
|
86
|
+
{'Location' => request.login_url, 'Content-Type' => 'text/html'})
|
87
|
+
response.delete_cookie('remember_me')
|
88
|
+
response.finish
|
89
|
+
end
|
90
|
+
|
91
|
+
def request_login(request)
|
92
|
+
erb(request, :openid_login)
|
93
|
+
end
|
94
|
+
|
95
|
+
def begin_openid_login(request)
|
96
|
+
begin
|
97
|
+
response = openid_consumer(request).begin(request[:openid_url])
|
98
|
+
rescue => e
|
99
|
+
request.flash[:error] = e
|
100
|
+
return login_redirect(request)
|
101
|
+
end
|
102
|
+
|
103
|
+
redirect_url = response.redirect_url(base_url(request), full_url(request))
|
104
|
+
Rack::Response.new([], 302, {'Location' => redirect_url}).finish
|
105
|
+
end
|
106
|
+
|
107
|
+
def complete_openid_login(request)
|
108
|
+
begin
|
109
|
+
idp_response = openid_consumer(request).complete(request.params, full_url(request))
|
110
|
+
rescue => e
|
111
|
+
request.flash[:error] = e
|
112
|
+
return login_redirect(request)
|
113
|
+
end
|
114
|
+
|
115
|
+
if idp_response.is_a?(OpenID::Consumer::FailureResponse)
|
116
|
+
request.flash[:error] = idp_response.message
|
117
|
+
return login_redirect(request)
|
118
|
+
end
|
119
|
+
|
120
|
+
result = @users.get(
|
121
|
+
'/cloudkit_users',
|
122
|
+
# '/cloudkit_login_view',
|
123
|
+
:identity_url => idp_response.endpoint.claimed_id)
|
124
|
+
user_uris = result.parsed_content['uris']
|
125
|
+
|
126
|
+
if user_uris.empty?
|
127
|
+
json = JSON.generate(:identity_url => idp_response.endpoint.claimed_id)
|
128
|
+
result = @users.post('/cloudkit_users', :json => json)
|
129
|
+
user_uri = result.parsed_content['uri']
|
130
|
+
else
|
131
|
+
user_uri = user_uris.first
|
132
|
+
end
|
133
|
+
user_result = @users.resolve_uris([user_uri]).first
|
134
|
+
user = user_result.parsed_content
|
135
|
+
|
136
|
+
if request.session['user_uri'] = user_uri
|
137
|
+
request.current_user = user_uri
|
138
|
+
user['remember_me_expiration'] = two_weeks_from_now
|
139
|
+
user['remember_me_token'] = Base64.encode64(
|
140
|
+
OpenSSL::Random.random_bytes(32)).gsub(/\W/,'')
|
141
|
+
url = request.session.delete('return_to')
|
142
|
+
response = Rack::Response.new(
|
143
|
+
[],
|
144
|
+
302,
|
145
|
+
{'Location' => (url || '/'), 'Content-Type' => 'text/html'})
|
146
|
+
response.set_cookie(
|
147
|
+
'remember_me', {
|
148
|
+
:value => user['remember_me_token'],
|
149
|
+
:expires => Time.at(user['remember_me_expiration']).utc})
|
150
|
+
json = JSON.generate(user)
|
151
|
+
@users.put(user_uri, :etag => user_result.etag, :json => json)
|
152
|
+
request.flash[:notice] = 'You have been logged in.'
|
153
|
+
response.finish
|
154
|
+
else
|
155
|
+
request.flash[:error] = 'Could not log on with your OpenID.'
|
156
|
+
login_redirect(request)
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
def login_redirect(request)
|
161
|
+
Rack::Response.new([], 302, {'Location' => request.login_url}).finish
|
162
|
+
end
|
163
|
+
|
164
|
+
def base_url(request)
|
165
|
+
"#{request.scheme}://#{request.env['HTTP_HOST']}/"
|
166
|
+
end
|
167
|
+
|
168
|
+
def full_url(request)
|
169
|
+
base_url(request) + 'openid_complete'
|
170
|
+
end
|
171
|
+
|
172
|
+
def logged_in?(request)
|
173
|
+
logged_in = user_in_session?(request) || valid_remember_me_token?(request)
|
174
|
+
request.current_user = request.session['user_uri'] if logged_in
|
175
|
+
logged_in
|
176
|
+
end
|
177
|
+
|
178
|
+
def user_in_session?(request)
|
179
|
+
request.session['user_uri'] != nil
|
180
|
+
end
|
181
|
+
|
182
|
+
def store_location(request)
|
183
|
+
request.session['return_to'] = request.url
|
184
|
+
end
|
185
|
+
|
186
|
+
def root_request?(request)
|
187
|
+
request.path_info == '/' || request.path_info == '/favicon.ico'
|
188
|
+
end
|
189
|
+
|
190
|
+
def valid_auth_key?(request)
|
191
|
+
request.env[CLOUDKIT_AUTH_KEY] && request.env[CLOUDKIT_AUTH_KEY] != ''
|
192
|
+
end
|
193
|
+
|
194
|
+
def openid_consumer(request)
|
195
|
+
@openid_consumer ||= OpenID::Consumer.new(
|
196
|
+
request.session, OpenIDStore.new)
|
197
|
+
end
|
198
|
+
|
199
|
+
def valid_remember_me_token?(request)
|
200
|
+
return false unless token = request.cookies['remember_me']
|
201
|
+
|
202
|
+
# result = @users.get('/cloudkit_login_view', :remember_me_token => token)
|
203
|
+
result = @users.get('/cloudkit_users', :remember_me_token => token)
|
204
|
+
return false unless result.status == 200
|
205
|
+
|
206
|
+
user_uris = result.parsed_content['uris']
|
207
|
+
return false unless user_uris.try(:size) == 1
|
208
|
+
|
209
|
+
user_uri = user_uris.first
|
210
|
+
user_result = @users.resolve_uris([user_uri]).first
|
211
|
+
user = user_result.parsed_content
|
212
|
+
return false unless Time.now.to_i < user['remember_me_expiration']
|
213
|
+
|
214
|
+
user['remember_me_expiration'] = two_weeks_from_now
|
215
|
+
json = JSON.generate(user)
|
216
|
+
@users.put(user_uri, :etag => user_result.etag, :json => json)
|
217
|
+
request.session['user_uri'] = user_uri
|
218
|
+
true
|
219
|
+
end
|
220
|
+
|
221
|
+
def two_weeks_from_now
|
222
|
+
Time.now.to_i+1209600
|
223
|
+
end
|
224
|
+
|
225
|
+
def allow?(uri)
|
226
|
+
@bypass_route_callback.call(uri) ||
|
227
|
+
@options[:allow] && @options[:allow].include?(uri)
|
228
|
+
end
|
229
|
+
|
230
|
+
def bypass?(request)
|
231
|
+
allow?(request.path_info) ||
|
232
|
+
valid_auth_key?(request) ||
|
233
|
+
logged_in?(request)
|
234
|
+
end
|
235
|
+
end
|
236
|
+
end
|
@@ -0,0 +1,100 @@
|
|
1
|
+
require 'openid/store/interface'
|
2
|
+
module CloudKit
|
3
|
+
|
4
|
+
# An OpenIDStore provides the interface expected by the ruby-openid gem,
|
5
|
+
# mapping it to a CloudKit::Store instance.
|
6
|
+
class OpenIDStore < OpenID::Store::Interface
|
7
|
+
@@store = nil
|
8
|
+
|
9
|
+
# Initialize an OpenIDStore.
|
10
|
+
def initialize
|
11
|
+
unless @@store
|
12
|
+
@@store = Store.new(
|
13
|
+
:collections => [:cloudkit_openid_associations, :cloudkit_openid_nonces])
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def get_association(server_url, handle=nil) #:nodoc:
|
18
|
+
options = {:server_url => server_url}
|
19
|
+
options.merge!(:handle => Base64.encode64(handle)) if (handle && handle != '')
|
20
|
+
result = @@store.get(CloudKit::URI.new('/cloudkit_openid_associations'), options)
|
21
|
+
return nil unless result.status == 200
|
22
|
+
return nil if result.parsed_content['total'] == 0
|
23
|
+
|
24
|
+
ignore, associations = resolve_associations(result.parsed_content)
|
25
|
+
return nil if associations.empty?
|
26
|
+
|
27
|
+
associations.sort_by{|a| a['issued']}
|
28
|
+
a = associations[-1]
|
29
|
+
OpenID::Association.new(
|
30
|
+
Base64.decode64(a['handle']),
|
31
|
+
Base64.decode64(a['secret']),
|
32
|
+
Time.at(a['issued']),
|
33
|
+
a['lifetime'],
|
34
|
+
a['assoc_type'])
|
35
|
+
end
|
36
|
+
|
37
|
+
def remove_association(server_url, handle) #:nodoc:
|
38
|
+
result = @@store.get(
|
39
|
+
CloudKit::URI.new('/cloudkit_openid_associations'),
|
40
|
+
:server_url => server_url,
|
41
|
+
:handle => Base64.encode64(handle))
|
42
|
+
return nil unless result.status == 200
|
43
|
+
|
44
|
+
responses, associations = resolve_associations(result.parsed_content)
|
45
|
+
return nil if associations.empty?
|
46
|
+
|
47
|
+
uris = result.parsed_content['uris']
|
48
|
+
responses.each_with_index do |r, index|
|
49
|
+
@@store.delete(CloudKit::URI.new(uris[index]), :etag => r.etag)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def store_association(server_url, association) #:nodoc:
|
54
|
+
remove_association(server_url, association.handle)
|
55
|
+
json = JSON.generate(
|
56
|
+
:server_url => server_url,
|
57
|
+
:handle => Base64.encode64(association.handle),
|
58
|
+
:secret => Base64.encode64(association.secret),
|
59
|
+
:issued => association.issued.to_i,
|
60
|
+
:lifetime => association.lifetime,
|
61
|
+
:assoc_type => association.assoc_type)
|
62
|
+
result = @@store.post(CloudKit::URI.new('/cloudkit_openid_associations'), :json => json)
|
63
|
+
return (result.status == 201)
|
64
|
+
end
|
65
|
+
|
66
|
+
def use_nonce(server_url, timestamp, salt) #:nodoc:
|
67
|
+
return false if (timestamp - Time.now.to_i).abs > OpenID::Nonce.skew
|
68
|
+
|
69
|
+
fragment = ::URI.escape(
|
70
|
+
[server_url, timestamp, salt].join('-'),
|
71
|
+
Regexp.union(::URI::REGEXP::UNSAFE, '/', ':'))
|
72
|
+
uri = "/cloudkit_openid_nonces/#{fragment}"
|
73
|
+
result = @@store.put(CloudKit::URI.new(uri), :json => '{}')
|
74
|
+
return (result.status == 201)
|
75
|
+
end
|
76
|
+
|
77
|
+
def self.cleanup #:nodoc:
|
78
|
+
# TODO
|
79
|
+
end
|
80
|
+
|
81
|
+
def self.cleanup_associations #:nodoc:
|
82
|
+
# TODO
|
83
|
+
end
|
84
|
+
|
85
|
+
def self.cleanup_nonces #:nodoc:
|
86
|
+
# TODO
|
87
|
+
end
|
88
|
+
|
89
|
+
# Return the version number for this store.
|
90
|
+
def version; 1; end
|
91
|
+
|
92
|
+
protected
|
93
|
+
|
94
|
+
def resolve_associations(parsed_content) #:nodoc:
|
95
|
+
uri_list = parsed_content['uris'].map! { |u| CloudKit::URI.new(u) }
|
96
|
+
association_responses = @@store.resolve_uris(uri_list)
|
97
|
+
return association_responses, association_responses.map{|a| a.parsed_content}
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
@@ -0,0 +1,120 @@
|
|
1
|
+
module Rack #:nodoc:
|
2
|
+
class Builder
|
3
|
+
alias_method :cloudkit_to_app, :to_app
|
4
|
+
|
5
|
+
# Extends Rack::Builder's to_app method to detect if the last piece of
|
6
|
+
# middleware in the stack is a CloudKit shortcut (contain or expose), adding
|
7
|
+
# a default developer page at the root and a 404 everywhere else.
|
8
|
+
def to_app
|
9
|
+
default_app = lambda do |env|
|
10
|
+
if (env['PATH_INFO'] == '/')
|
11
|
+
Rack::Response.new(welcome).finish
|
12
|
+
else
|
13
|
+
Rack::Response.new('not found', 404).finish
|
14
|
+
end
|
15
|
+
end
|
16
|
+
@ins << default_app if @last_cloudkit_id == @ins.last.object_id
|
17
|
+
cloudkit_to_app
|
18
|
+
end
|
19
|
+
|
20
|
+
# Setup resource collections hosted behind OAuth and OpenID auth filters.
|
21
|
+
#
|
22
|
+
# ===Example
|
23
|
+
# contain :notes, :projects
|
24
|
+
#
|
25
|
+
def contain(*args)
|
26
|
+
@ins << lambda do |app|
|
27
|
+
Rack::Session::Pool.new(
|
28
|
+
CloudKit::OAuthFilter.new(
|
29
|
+
CloudKit::OpenIDFilter.new(
|
30
|
+
CloudKit::Service.new(app, :collections => args.to_a))))
|
31
|
+
end
|
32
|
+
@last_cloudkit_id = @ins.last.object_id
|
33
|
+
end
|
34
|
+
|
35
|
+
# Setup resource collections without authentication.
|
36
|
+
#
|
37
|
+
# ===Example
|
38
|
+
# expose :notes, :projects
|
39
|
+
#
|
40
|
+
def expose(*args)
|
41
|
+
@ins << lambda do |app|
|
42
|
+
CloudKit::Service.new(app, :collections => args.to_a)
|
43
|
+
end
|
44
|
+
@last_cloudkit_id = @ins.last.object_id
|
45
|
+
end
|
46
|
+
|
47
|
+
def welcome #:nodoc:
|
48
|
+
doc = <<HTML
|
49
|
+
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
|
50
|
+
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
51
|
+
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
|
52
|
+
<head>
|
53
|
+
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
|
54
|
+
<title>CloudKit</title>
|
55
|
+
<style type="text/css">
|
56
|
+
body {
|
57
|
+
font-family: 'Helvetica', 'Arial', san-serif;
|
58
|
+
font-size: 15px;
|
59
|
+
margin: 0;
|
60
|
+
padding: 0;
|
61
|
+
color: #222222;
|
62
|
+
}
|
63
|
+
h1 {
|
64
|
+
font-family: 'Helvetica Neue', 'Helvetica', 'Arial', san-serif;
|
65
|
+
font-size: 73px;
|
66
|
+
font-weight: bold;
|
67
|
+
line-height: 28px;
|
68
|
+
margin: 20px 0px 20px 0px;
|
69
|
+
}
|
70
|
+
.wrapper {
|
71
|
+
width: 500px;
|
72
|
+
margin: 0 auto;
|
73
|
+
clear: both;
|
74
|
+
}
|
75
|
+
p {
|
76
|
+
margin-top: 0px;
|
77
|
+
line-height: 1.5em;
|
78
|
+
}
|
79
|
+
#header {
|
80
|
+
background-color: #ffffcc;
|
81
|
+
display: block;
|
82
|
+
padding: 2px 0;
|
83
|
+
margin: 35px 0px 10px 0px;
|
84
|
+
border-top: 1px solid #ffcc66;
|
85
|
+
border-bottom: 1px solid #ffcc66;
|
86
|
+
}
|
87
|
+
a {
|
88
|
+
color: #6b8df2;
|
89
|
+
text-decoration: none;
|
90
|
+
}
|
91
|
+
.meta {
|
92
|
+
padding: 7px 7px 7px 7px;
|
93
|
+
background-color: #ffccff;
|
94
|
+
border-top: 1px solid #cc99ff;
|
95
|
+
border-bottom: 1px solid #cc99ff;
|
96
|
+
font-size: 14px;
|
97
|
+
display: block;
|
98
|
+
margin: 10px 0px 10px 0px;
|
99
|
+
}
|
100
|
+
</style>
|
101
|
+
</head>
|
102
|
+
<body>
|
103
|
+
<div id="header">
|
104
|
+
<div class="wrapper">
|
105
|
+
<h1>CloudKit</h1>
|
106
|
+
</div>
|
107
|
+
</div>
|
108
|
+
<div class="meta">
|
109
|
+
<p class="wrapper">
|
110
|
+
This page is appearing because you have not set up a default app in your
|
111
|
+
rackup file. To learn more about CloudKit, check out
|
112
|
+
<a href="http://getcloudkit.com">the site</a>.
|
113
|
+
</p>
|
114
|
+
</div>
|
115
|
+
</body>
|
116
|
+
</html>
|
117
|
+
HTML
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|