corpshort 0.1.0

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.
@@ -0,0 +1,26 @@
1
+ <section>
2
+ <h2>Edit: <%= @link.name %></h2>
3
+
4
+ <form action='<%= update_path(@link) %>' method='POST'>
5
+ <input type='hidden' name='_method' value='PUT'>
6
+ <div class='form-row'>
7
+ <label for='edit_new_name'>Name</label>
8
+ <input type='text' id='edit_new_name' name='new_name' placeholder='short-name' value="<%= @link.name %>">
9
+ </div>
10
+ <div class='form-row'>
11
+ <label for='edit_url'>URL</label>
12
+ <input type='text' id='edit_url' name='url' placeholder='https://...' value="<%= @link.url %>">
13
+ </div>
14
+ <input type='submit' value='Save'>
15
+ </form>
16
+
17
+ <p>Entering a new name will create an another link for the URL. To rename completely, manually delete the old link; Be careful with renaming existing links.</p>
18
+
19
+ <hr>
20
+
21
+ <form action='<%= update_path(@link) %>' method='POST'>
22
+ <input type='hidden' name='_method' value='DELETE'>
23
+ <p><input type='submit' onclick="return confirm('Are you sure?')" class='btn-danger' value='Delete'></p>
24
+ </form>
25
+ </section>
26
+
@@ -0,0 +1,24 @@
1
+ <section class='new-link'>
2
+ <form action='/+/links' method='POST'>
3
+ <p><input type='text' name='url' placeholder='https://...' class='new-link-url'></p>
4
+ <p class='new-link-down'>⬇︎</p>
5
+ <p><input type='text' name='name' placeholder='Short name' class='new-link-name'></p>
6
+ <p><input type='submit' value='Shorten'></p>
7
+ </form>
8
+ </section>
9
+
10
+ <div class='infos'>
11
+ <% if notice_message %>
12
+ <section>
13
+ <h3>Notice</h3>
14
+ <%== notice_message %>
15
+ </section>
16
+ <% end %>
17
+ <section>
18
+ <h3>Usage</h3>
19
+ <form action='/+' method='GET'>
20
+ <p>Edit a link: <input type='text' name='show' placeholder='name...'> <input type='submit' value='Go'></p>
21
+ </form>
22
+ <p>Or add + (plus) sign after the URL like: <%= base_url %>/something → /something+</p>
23
+ </section>
24
+ </div>
@@ -0,0 +1,40 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset='utf-8'>
5
+ <meta name='viewport' content='width=device-width, minimum-scale=1'>
6
+ <title>Corpshort</title>
7
+ <link rel='stylesheet' href='/style.css' type="text/css">
8
+ </head>
9
+
10
+ <body>
11
+ <div class="container">
12
+ <header class="header">
13
+ <h1 class='logo'><a href="/">Corpshort</a></h1>
14
+ <nav>
15
+ <a href="/">Create</a>
16
+ <a href="/+/links">Recent</a>
17
+ </nav>
18
+ </header>
19
+ <% notice ||= session.delete(:notice); error ||= session.delete(:error) %>
20
+ <% if notice %>
21
+ <div class="notice"><%= notice %></div>
22
+ <% end %>
23
+
24
+ <% if error %>
25
+ <div class="error"><%= error %></div>
26
+ <% end %>
27
+
28
+ <div class="box">
29
+ <%== yield %>
30
+ </div>
31
+
32
+ <footer>
33
+ <div class="credit">
34
+ Powered by <a href="https://github.com/sorah/corpshort">sorah/corpshort</a>
35
+ </div>
36
+ </footer>
37
+ </div>
38
+ </body>
39
+ </html>
40
+
@@ -0,0 +1,11 @@
1
+ <h1><%= @title %></h1>
2
+
3
+ <ul>
4
+ <% @links.each do |name| %>
5
+ <li><a href='/<%= name %>+'><%= name %></li>
6
+ <% end %>
7
+ </ul>
8
+
9
+ <% if @next_token %>
10
+ <a href="<%= request.path %>?token=<%= URI.encode_www_form_component(@next_token) %>">More...</a>
11
+ <% end %>
@@ -0,0 +1,91 @@
1
+ <section class='link'>
2
+ <header>
3
+ <h2><a href="<%= short_link_url(@link) %>"><%= short_base_url.gsub(%r{\A.+://}, '') %>/<%= @link.name %></a></h2>
4
+ <a href='#' class='btn-link copy_btn'><input class='copy_btn_input' tabindex='-1' value="<%= short_link_url(@link) %>"><span class='copy_btn_text'>Copy</span></a>
5
+ <a href="<%= edit_path(@link) %>" class='btn-link'>Edit</a>
6
+ </header>
7
+
8
+ <p><a class='link-url' href="<%= @link.url %>"><%= @link.url %></a></p>
9
+
10
+ <div class='infos'>
11
+ <section>
12
+ <h3>Embed</h3>
13
+ <p>PDF is recommended for Keynote</p>
14
+ <div class='barcode-style'>
15
+ <div class='barcode-style-diagram barcode-style-diagram-cu-horizontal'>
16
+ <div class='barcode-style-diagram-code'></div>
17
+ <div class='barcode-style-diagram-url'></div>
18
+ </div>
19
+ <div class='barcode-style-links'>
20
+ <h4>Code + URL (Horizontal)</h4>
21
+ <ul>
22
+ <li><a href="<%= barcode_path(@link, 'horizontal', 'pdf', flex: true) %>">PDF</a></li>
23
+ <li><a href="<%= barcode_path(@link, 'horizontal', 'pdf') %>">PDF (Short)</a></li>
24
+ </ul>
25
+ </div>
26
+ </div>
27
+ <div class='barcode-style'>
28
+ <div class='barcode-style-diagram barcode-style-diagram-cu-vertical'>
29
+ <div class='barcode-style-diagram-code'></div>
30
+ <div class='barcode-style-diagram-url'></div>
31
+ </div>
32
+ <div class='barcode-style-links'>
33
+ <h4>Code + URL (Vertical)</h4>
34
+ <ul>
35
+ <li><a href="<%= barcode_path(@link, 'vertical', 'pdf', flex: true) %>">PDF</a></li>
36
+ <li><a href="<%= barcode_path(@link, 'vertical', 'pdf') %>">PDF (Short)</a></li>
37
+ </ul>
38
+ </div>
39
+ </div>
40
+ <div class='barcode-style'>
41
+ <div class='barcode-style-diagram barcode-style-diagram-codeonly'>
42
+ <div class='barcode-style-diagram-code'></div>
43
+ </div>
44
+ <div class='barcode-style-links'>
45
+ <h4>Code Only</h4>
46
+ <small>Code + URL is recommended</small>
47
+ <ul>
48
+ <li><a href="<%= barcode_path(@link, 'small', 'pdf') %>">PDF</a></li>
49
+ <li><a href="<%= barcode_path(@link, 'small', 'svg') %>">SVG</a></li>
50
+ <li><a href="<%= barcode_path(@link, 'small', 'png') %>">PNG</a></li>
51
+ </ul>
52
+ </div>
53
+ </div>
54
+ </section>
55
+ <section>
56
+ <h3>Metadata</h3>
57
+ <ul>
58
+ <li>Updated at: <%= @link.updated_at.utc.iso8601 %></li>
59
+ </ul>
60
+ </section>
61
+ <section>
62
+ <h3>Actions</h3>
63
+ <nav>
64
+ <p><a href="<%= urls_path(@link.url) %>">List all links for this URL</a></p>
65
+ </nav>
66
+ </section>
67
+
68
+ <script type='text/javascript'>
69
+ "use strict";
70
+ document.addEventListener("DOMContentLoaded", function() {
71
+ const copySupported = document.queryCommandSupported("copy");
72
+ document.querySelectorAll(".copy_btn").forEach(function(elem) {
73
+ if (!copySupported) {
74
+ elem.className += ' hidden';
75
+ return;
76
+ }
77
+ elem.addEventListener("click", function(e) {
78
+ const value = e.currentTarget.querySelector(".copy_btn_input");
79
+ value.focus();
80
+ value.select();
81
+ document.execCommand("copy");
82
+ e.currentTarget.querySelector(".copy_btn_text").innerHTML = "Copied!";
83
+ e.preventDefault();
84
+ });
85
+ elem.addEventListener("mouseleave", function(e) {
86
+ e.currentTarget.querySelector(".copy_btn_text").innerHTML = "Copy";
87
+ });
88
+ });
89
+ });
90
+ </script>
91
+ </section>
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "corpshort"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,40 @@
1
+ require 'bundler/setup'
2
+ require 'securerandom'
3
+
4
+ require 'corpshort'
5
+
6
+ if ENV['RACK_ENV'] == 'production'
7
+ raise 'Set $SECRET_KEY_BASE' unless ENV['SECRET_KEY_BASE']
8
+ end
9
+
10
+ config = {
11
+ base_url: ENV['CORPSHORT_BASE_URL'],
12
+ short_base_url: ENV['CORPSHORT_SHORT_BASE_URL'],
13
+ }
14
+
15
+ case ENV.fetch('CORPSHORT_BACKEND', 'redis')
16
+ when 'redis'
17
+ require 'corpshort/backends/redis'
18
+ config[:backend] = Corpshort::Backends::Redis.new(
19
+ redis: ENV.key?('REDIS_URL') ? lambda { Redis.new(url: ENV['REDIS_URL']) } : Redis.method(:current),
20
+ prefix: ENV.fetch('CORPSHORT_REDIS_PREFIX', 'corpshort:'),
21
+ )
22
+ when 'dynamodb'
23
+ require 'corpshort/backends/dynamodb'
24
+ config[:backend] = Corpshort::Backends::Dynamodb.new(
25
+ region: ENV.fetch('CORPSHORT_DYNAMODB_REGION'),
26
+ table: ENV.fetch('CORPSHORT_DYNAMODB_TABLE'),
27
+ )
28
+ else
29
+ raise ArgumentError, "Unsupported $CORPSHORT_BACKEND"
30
+ end
31
+
32
+ use(
33
+ Rack::Session::Cookie,
34
+ key: 'corpshortsess',
35
+ expire_after: 86400,
36
+ secure: ENV.fetch('CORPSHORT_SECURE_SESSION', ENV['RACK_ENV'] == 'production' ? '1' : nil) == '1',
37
+ secret: ENV.fetch('SECRET_KEY_BASE', SecureRandom.base64(256)),
38
+ )
39
+
40
+ run Corpshort.app(config)
@@ -0,0 +1,39 @@
1
+
2
+ lib = File.expand_path("../lib", __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require "corpshort/version"
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "corpshort"
8
+ spec.version = Corpshort::VERSION
9
+ spec.authors = ["Sorah Fukumori"]
10
+ spec.email = ["sorah@cookpad.com"]
11
+
12
+ spec.summary = %q{"go/" like private link shortener for internal purpose}
13
+ spec.homepage = "https://github.com/sorah/corpshort"
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
17
+ f.match(%r{^(test|spec|features)/})
18
+ end
19
+ spec.bindir = "exe"
20
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
21
+ spec.require_paths = ["lib"]
22
+
23
+ spec.add_dependency "sinatra"
24
+ spec.add_dependency "rack-protection"
25
+
26
+ spec.add_dependency "erubi"
27
+
28
+ spec.add_dependency "redis"
29
+ spec.add_dependency "aws-sdk-dynamodb"
30
+
31
+ spec.add_dependency "rqrcode"
32
+ spec.add_dependency "prawn"
33
+ spec.add_dependency "prawn-qrcode"
34
+
35
+ spec.add_development_dependency "bundler"
36
+ spec.add_development_dependency "rake"
37
+ spec.add_development_dependency "rspec", "~> 3.0"
38
+ spec.add_development_dependency "rack-test"
39
+ end
@@ -0,0 +1,7 @@
1
+ require "corpshort/version"
2
+ require "corpshort/link"
3
+ require "corpshort/app"
4
+
5
+ module Corpshort
6
+ # Your code goes here...
7
+ end
@@ -0,0 +1,349 @@
1
+ require 'rqrcode'
2
+ require 'prawn'
3
+ require 'prawn/qrcode'
4
+
5
+ require 'json'
6
+ require 'erubi'
7
+ require 'sinatra/base'
8
+ require 'rack/protection'
9
+
10
+ require 'corpshort/link'
11
+ require 'corpshort/vertical_pdf'
12
+ require 'corpshort/horizontal_pdf'
13
+
14
+ require 'uri'
15
+
16
+ module Corpshort
17
+ def self.app(*args)
18
+ App.rack(*args)
19
+ end
20
+
21
+ class App < Sinatra::Base
22
+ CONTEXT_RACK_ENV_NAME = 'corpshort.ctx'
23
+
24
+ def self.initialize_context(config)
25
+ {
26
+ config: config,
27
+ }
28
+ end
29
+
30
+ def self.rack(config={})
31
+ klass = App
32
+
33
+ test = config[:test]
34
+ session = {}
35
+ context = initialize_context(config)
36
+ lambda { |env|
37
+ env['rack.session'] = session if test # FIXME:
38
+ env[CONTEXT_RACK_ENV_NAME] = context
39
+ klass.call(env)
40
+ }
41
+ end
42
+
43
+ configure do
44
+ enable :logging
45
+ end
46
+
47
+ set :root, File.expand_path(File.join(__dir__, '..', '..', 'app'))
48
+ set :erb, :escape_html => true
49
+
50
+ use Rack::Protection::FrameOptions
51
+ use Rack::Protection::HttpOrigin
52
+ use Rack::Protection::IPSpoofing
53
+ use Rack::Protection::JsonCsrf
54
+ use Rack::Protection::PathTraversal
55
+ use Rack::Protection::RemoteToken, only_if: -> (env) { ! env['PATH_INFO'].start_with?('/+api') }
56
+ use Rack::Protection::SessionHijacking
57
+ use Rack::Protection::XSSHeader
58
+
59
+ use Rack::MethodOverride
60
+
61
+ helpers do
62
+ include Prawn::Measurements
63
+
64
+ def context
65
+ request.env[CONTEXT_RACK_ENV_NAME]
66
+ end
67
+
68
+ def conf
69
+ context.fetch(:config)
70
+ end
71
+
72
+ def notice_message
73
+ conf[:notice_message]
74
+ end
75
+
76
+ def base_url
77
+ conf[:base_url] || request.base_url
78
+ end
79
+
80
+ def short_base_url
81
+ conf[:short_base_url] || base_url
82
+ end
83
+
84
+
85
+ def backend
86
+ @backend ||= conf.fetch(:backend)
87
+ end
88
+
89
+ def link_name(name = params[:name])
90
+ name.tr('_', '-')
91
+ end
92
+
93
+ def short_link_url(link, **kwargs)
94
+ link_url(link, base_url: short_base_url, **kwargs)
95
+ end
96
+
97
+ def link_url(link, protocol: true, base_url: self.base_url())
98
+ name = link.is_a?(String) ? link_name(link) : link.name
99
+ "#{base_url}/#{name}".yield_self do |url|
100
+ if protocol
101
+ url
102
+ else
103
+ url.gsub(/\Ahttps?:\/\//, '')
104
+ end
105
+ end
106
+ end
107
+
108
+ def edit_path(link)
109
+ "/+/links/#{URI.encode_www_form_component(link.name)}/edit"
110
+ end
111
+
112
+ def update_path(link)
113
+ "/+/links/#{URI.encode_www_form_component(link.name)}"
114
+ end
115
+
116
+ def urls_path(url)
117
+ "/+/urls/#{url}"
118
+ end
119
+
120
+ def barcode_path(link, kind, ext, flex: nil)
121
+ "/+/links/#{URI.encode_www_form_component(link.name)}/#{kind}.#{ext}#{flex.nil? ? nil : "?flex=#{flex}"}"
122
+ end
123
+ end
124
+
125
+ get '/' do
126
+ erb :index
127
+ end
128
+
129
+ ## Pages
130
+
131
+ get '/+' do
132
+ if params[:show]
133
+ redirect "/+/links/#{link_name(params[:show])}"
134
+ end
135
+ halt 404
136
+ end
137
+
138
+ post '/+/links' do
139
+ unless params[:name] && params[:url]
140
+ session[:error] = "Name and URL are required"
141
+ redirect '/'
142
+ end
143
+
144
+ begin
145
+ link = Link.new({name: link_name, url: params[:url]})
146
+ link.save!(backend, create_only: true)
147
+ rescue Corpshort::Link::ValidationError, Corpshort::Backends::Base::ConflictError
148
+ session[:error] = $!.message
149
+ redirect '/'
150
+ end
151
+
152
+ redirect "/#{link.name}+"
153
+ end
154
+
155
+ get '/+/links' do
156
+ @links, @next_token = backend.list_links(token: params[:token])
157
+ @title = "Recent links"
158
+ erb :list
159
+ end
160
+
161
+ get '/+/links/*name/small.svg' do
162
+ @link = backend.get_link(params[:name])
163
+ halt 404, "not found" unless @link
164
+
165
+ content_type :svg
166
+ RQRCode::QRCode.new(link_url(@link), level: :m).as_svg(module_size: 6)
167
+ end
168
+ get '/+/links/*name/small.png' do
169
+ @link = backend.get_link(params[:name])
170
+
171
+ halt 404, "not found" unless @link
172
+ content_type :png
173
+ RQRCode::QRCode.new(link_url(@link), level: :m).as_png(size: 120).to_datastream.to_s
174
+ end
175
+ get '/+/links/*name/small.pdf' do
176
+ @link = backend.get_link(params[:name])
177
+ halt 404, "not found" unless @link
178
+
179
+ content_type :pdf
180
+ Prawn::Document.new(page_size: [cm2pt(2), cm2pt(2)], margin: 0) do |pdf|
181
+ pdf.fill_color 'FFFFFF'
182
+ pdf.fill { pdf.rounded_rectangle [cm2pt(2), cm2pt(2)], cm2pt(2), cm2pt(2), 10 }
183
+ pdf.print_qr_code(link_url(@link), level: :m, extent: cm2pt(2), stroke: false)
184
+ end.render
185
+ end
186
+
187
+ get '/+/links/*name/vertical.pdf' do
188
+ @link = backend.get_link(params[:name])
189
+ halt 404, "not found" unless @link
190
+
191
+ content_type :pdf
192
+
193
+ VerticalPdf.new(
194
+ url: link_url(@link),
195
+ base_url: short_base_url.sub(%r{\A.+://}, ''),
196
+ name: @link.name,
197
+ flex: params[:flex],
198
+ ).document.render
199
+ end
200
+
201
+ get '/+/links/*name/horizontal.pdf' do
202
+ @link = backend.get_link(params[:name])
203
+ halt 404, "not found" unless @link
204
+
205
+ content_type :pdf
206
+ HorizontalPdf.new(
207
+ url: link_url(@link),
208
+ base_url: short_base_url.sub(%r{\A.+://}, ''),
209
+ name: @link.name,
210
+ flex: params[:flex],
211
+ ).document.render
212
+ end
213
+
214
+
215
+ get '/+/links/*name/edit' do
216
+ @link = backend.get_link(params[:name])
217
+ if @link
218
+ erb :edit
219
+ else
220
+ halt 404, "not found"
221
+ end
222
+ end
223
+
224
+ get '/+/links/*name' do
225
+ redirect "/#{params[:name]}+"
226
+ end
227
+
228
+ put '/+/links/*name' do
229
+ @link = backend.get_link(params[:name])
230
+ halt 404, "not found" unless @link
231
+
232
+ @link.url = params[:url] if params[:url]
233
+
234
+ rename = params[:new_name] && @link.name != params[:new_name]
235
+ if rename
236
+ new_name = link_name(params[:new_name])
237
+ @link = Link.new(name: new_name, url: @link.url)
238
+ # Link.validate_name(new_name)
239
+ # backend.rename_link(@link, new_name)
240
+ end
241
+
242
+ begin
243
+ @link.save!(backend, create_only: rename)
244
+ rescue Corpshort::Link::ValidationError, Corpshort::Backends::Base::ConflictError
245
+ session[:error] = $!.message
246
+ redirect "/+/links/#{@link.name}/edit"
247
+ end
248
+
249
+ redirect "/#{@link.name}+"
250
+ end
251
+
252
+ delete '/+/links/*name' do
253
+ backend.delete_link(params[:name])
254
+ redirect "/"
255
+ end
256
+
257
+ get '/+/urls/*url' do
258
+ url = env['REQUEST_URI'][8..-1]
259
+ @links, @next_token = backend.list_links_by_url(url), nil
260
+ @title = "Links for URL #{url}"
261
+ erb :list
262
+ end
263
+
264
+ ## API
265
+
266
+ get '/+api/links' do
267
+ content_type :json
268
+ links, next_token = backend.list_links(token: params[:token])
269
+ {links: links, next_token: next_token}.to_json
270
+ end
271
+
272
+ post '/+api/links' do
273
+ content_type :json
274
+
275
+ unless params[:name] && params[:url]
276
+ halt 400, '{"error": "missing_params", "error_message": "name and url are required"}'
277
+ end
278
+
279
+ begin
280
+ link = Link.new({name: link_name, url: params[:url]})
281
+ link.save!(backend, create_only: true)
282
+ rescue Corpshort::Link::ValidationError => e
283
+ halt(400, {error: :validation_error, error_message: e.message}.to_json)
284
+ rescue Corpshort::Backends::Base::ConflictError
285
+ halt(409, {error: :conflict, error_message: e.message}.to_json)
286
+ end
287
+
288
+ link.to_json
289
+ end
290
+
291
+ get '/+api/links/*name' do
292
+ content_type :json
293
+ link = backend.get_link(link_name)
294
+ halt 404, '{"error": "not_found"}' unless link
295
+ link.to_json
296
+ end
297
+
298
+ put '/+api/links/*name' do
299
+ content_type :json
300
+ link = backend.get_link(link_name)
301
+ halt 404, '{"error": "not_found"}' unless link
302
+ link.url = params[:url] if params[:url]
303
+
304
+ begin
305
+ link.save!(backend)
306
+ rescue Corpshort::Link::ValidationError => e
307
+ halt(400, {error: :validation_error, error_message: e.message}.to_json)
308
+ rescue Corpshort::Backends::Base::ConflictError
309
+ halt(409, {error: :conflict, error_message: e.message}.to_json)
310
+ end
311
+ link.to_json
312
+ end
313
+
314
+ delete '/+api/links/*name' do
315
+ backend.delete_link(link_name)
316
+ status 202
317
+ ""
318
+ end
319
+
320
+ get '/+api/urls/*url' do
321
+ content_type :json
322
+ url = env['REQUEST_URI'][8..-1]
323
+ links, next_token = backend.list_links_by_url(url), nil
324
+ {links: links, next_token: next_token}.to_json
325
+ end
326
+
327
+ ## Shortlink
328
+
329
+ get '/*name' do
330
+ name = params[:name]
331
+ show = name.end_with?('+')
332
+ if show
333
+ name = name[0..-2]
334
+ end
335
+
336
+ @link = backend.get_link(link_name(name))
337
+
338
+ unless @link
339
+ halt 404, 'not found'
340
+ end
341
+
342
+ if show
343
+ erb :show
344
+ else
345
+ redirect @link.url
346
+ end
347
+ end
348
+ end
349
+ end