corpshort 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +11 -0
- data/.rspec +3 -0
- data/.travis.yml +5 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +83 -0
- data/LICENSE.txt +21 -0
- data/README.md +59 -0
- data/Rakefile +6 -0
- data/app/public/style.css +320 -0
- data/app/views/edit.erb +26 -0
- data/app/views/index.erb +24 -0
- data/app/views/layout.erb +40 -0
- data/app/views/list.erb +11 -0
- data/app/views/show.erb +91 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/config.ru +40 -0
- data/corpshort.gemspec +39 -0
- data/lib/corpshort.rb +7 -0
- data/lib/corpshort/app.rb +349 -0
- data/lib/corpshort/backends/base.rb +34 -0
- data/lib/corpshort/backends/dynamodb.rb +114 -0
- data/lib/corpshort/backends/memory.rb +61 -0
- data/lib/corpshort/backends/redis.rb +121 -0
- data/lib/corpshort/horizontal_pdf.rb +105 -0
- data/lib/corpshort/link.rb +83 -0
- data/lib/corpshort/version.rb +3 -0
- data/lib/corpshort/vertical_pdf.rb +107 -0
- metadata +240 -0
@@ -0,0 +1,34 @@
|
|
1
|
+
module Corpshort
|
2
|
+
module Backends
|
3
|
+
class Base
|
4
|
+
class ConflictError < StandardError; end
|
5
|
+
|
6
|
+
def initialize()
|
7
|
+
end
|
8
|
+
|
9
|
+
def put_link(link, create_only: false)
|
10
|
+
raise NotImplementedError
|
11
|
+
end
|
12
|
+
|
13
|
+
def get_link(name)
|
14
|
+
raise NotImplementedError
|
15
|
+
end
|
16
|
+
|
17
|
+
def delete_link(link_or_name)
|
18
|
+
raise NotImplementedError
|
19
|
+
end
|
20
|
+
|
21
|
+
def rename_link(link, new_name)
|
22
|
+
raise NotImplementedError
|
23
|
+
end
|
24
|
+
|
25
|
+
def list_links_by_url(url)
|
26
|
+
raise NotImplementedError
|
27
|
+
end
|
28
|
+
|
29
|
+
def list_links(token: nil, limit: 30)
|
30
|
+
raise NotImplementedError
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,114 @@
|
|
1
|
+
require 'aws-sdk-dynamodb'
|
2
|
+
require 'date'
|
3
|
+
require 'time'
|
4
|
+
require 'corpshort/backends/base'
|
5
|
+
require 'corpshort/link'
|
6
|
+
|
7
|
+
module Corpshort
|
8
|
+
module Backends
|
9
|
+
class Dynamodb < Base
|
10
|
+
def initialize(table:, region:)
|
11
|
+
@table_name = table
|
12
|
+
@region = region
|
13
|
+
end
|
14
|
+
|
15
|
+
def table
|
16
|
+
@table ||= dynamodb.table(@table_name)
|
17
|
+
end
|
18
|
+
|
19
|
+
def dynamodb
|
20
|
+
@dynamodb ||= Aws::DynamoDB::Resource.new(region: @region)
|
21
|
+
end
|
22
|
+
|
23
|
+
def put_link(link, create_only: false)
|
24
|
+
table.update_item(
|
25
|
+
key: {
|
26
|
+
'name' => link.name,
|
27
|
+
},
|
28
|
+
update_expression: 'SET #u = :url, updated_at_partition = :ts_partition, updated_at = :updated_at',
|
29
|
+
condition_expression: create_only ? 'attribute_not_exists(#n)' : nil,
|
30
|
+
expression_attribute_names: create_only ? {'#u' => 'url', '#n' => 'name'} : {'#u' => 'url'},
|
31
|
+
expression_attribute_values: {
|
32
|
+
':url' => link.url,
|
33
|
+
':ts_partition' => ts_partition(link.updated_at),
|
34
|
+
':updated_at' => link.updated_at.iso8601,
|
35
|
+
},
|
36
|
+
)
|
37
|
+
rescue Aws::DynamoDB::Errors::ConditionalCheckFailedException
|
38
|
+
raise ConflictError
|
39
|
+
end
|
40
|
+
|
41
|
+
def get_link(name)
|
42
|
+
item = table.query(
|
43
|
+
limit: 1,
|
44
|
+
select: 'ALL_ATTRIBUTES',
|
45
|
+
key_condition_expression: '#n = :name',
|
46
|
+
expression_attribute_names: {'#n' => 'name'},
|
47
|
+
expression_attribute_values: {":name" => name},
|
48
|
+
).items.first
|
49
|
+
|
50
|
+
if item && !item.empty?
|
51
|
+
Link.new(item, backend: self)
|
52
|
+
else
|
53
|
+
nil
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def delete_link(link)
|
58
|
+
name = link.is_a?(String) ? link : link.name
|
59
|
+
table.delete_item(
|
60
|
+
key: {
|
61
|
+
'name' => name,
|
62
|
+
},
|
63
|
+
)
|
64
|
+
end
|
65
|
+
|
66
|
+
def list_links_by_url(url)
|
67
|
+
table.query(
|
68
|
+
index_name: 'url-updated_at-index',
|
69
|
+
select: 'ALL_PROJECTED_ATTRIBUTES',
|
70
|
+
key_condition_expression: '#u = :url',
|
71
|
+
expression_attribute_names: {"#u" => 'url'},
|
72
|
+
expression_attribute_values: {":url" => url},
|
73
|
+
).items.map { |_| _['name'] }
|
74
|
+
end
|
75
|
+
|
76
|
+
def list_links(token: nil, limit: 30)
|
77
|
+
partition, last_key = parse_token(token)
|
78
|
+
limit.times do
|
79
|
+
result = table.query(
|
80
|
+
index_name: 'updated_at_partition-updated_at-index',
|
81
|
+
select: 'ALL_PROJECTED_ATTRIBUTES',
|
82
|
+
scan_index_forward: false,
|
83
|
+
exclusive_start_key: last_key ? last_key : nil,
|
84
|
+
key_condition_expression: 'updated_at_partition = :partition',
|
85
|
+
expression_attribute_values: {":partition" => partition.strftime('%Y-%m')},
|
86
|
+
limit: 1 || limit,
|
87
|
+
)
|
88
|
+
|
89
|
+
unless result.items.empty?
|
90
|
+
return [result.items.map{ |_| _['name'] }, result.last_evaluated_key&.values_at('updated_at_partition', 'name', 'updated_at')&.join(?:)]
|
91
|
+
end
|
92
|
+
|
93
|
+
partition = partition.to_date.prev_month
|
94
|
+
last_key = nil
|
95
|
+
sleep 0.05
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
private
|
100
|
+
|
101
|
+
def parse_token(token)
|
102
|
+
if token.nil?
|
103
|
+
return [Time.now, nil]
|
104
|
+
end
|
105
|
+
partition, name, ts = token.split(?:,3)
|
106
|
+
[Time.strptime(partition, '%Y-%m'), ts ? {"updated_at_partition" => partition, "updated_at" => ts, "name" => name} : nil]
|
107
|
+
end
|
108
|
+
|
109
|
+
def ts_partition(time)
|
110
|
+
time.strftime('%Y-%m')
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
require 'thread'
|
2
|
+
require 'corpshort/backends/base'
|
3
|
+
require 'corpshort/link'
|
4
|
+
|
5
|
+
module Corpshort
|
6
|
+
module Backends
|
7
|
+
class Memory < Base
|
8
|
+
def initialize()
|
9
|
+
@lock = Mutex.new
|
10
|
+
@links = {}
|
11
|
+
@links_by_url = {}
|
12
|
+
end
|
13
|
+
|
14
|
+
attr_reader :links, :links_by_url
|
15
|
+
|
16
|
+
def put_link(link, create_only: false)
|
17
|
+
@lock.synchronize do
|
18
|
+
old_link = @links[link.name]
|
19
|
+
if create_only && old_link
|
20
|
+
raise ConflictError
|
21
|
+
end
|
22
|
+
old_url = old_link&.fetch(:url, nil)
|
23
|
+
if old_link && link.url != old_url
|
24
|
+
@links_by_url[old_url].delete(link.name)
|
25
|
+
end
|
26
|
+
@links[link.name] = link.to_h
|
27
|
+
(@links_by_url[link.url] ||= {})[link.name] = true
|
28
|
+
end
|
29
|
+
nil
|
30
|
+
end
|
31
|
+
|
32
|
+
def get_link(name)
|
33
|
+
data = @links[name]
|
34
|
+
if data && !data.empty?
|
35
|
+
Link.new(data, backend: self)
|
36
|
+
else
|
37
|
+
nil
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def delete_link(link)
|
42
|
+
@lock.synchronize do
|
43
|
+
name = link.is_a?(String) ? link : link.name
|
44
|
+
data = @links[name]
|
45
|
+
if @links[name]
|
46
|
+
@links.delete name
|
47
|
+
@links_by_url[data[:url]].delete name
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def list_links_by_url(url)
|
53
|
+
@links_by_url[url].keys
|
54
|
+
end
|
55
|
+
|
56
|
+
def list_links(token: nil, limit: 30)
|
57
|
+
[@links.keys, nil]
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,121 @@
|
|
1
|
+
require 'digest/sha2'
|
2
|
+
require 'redis'
|
3
|
+
|
4
|
+
require 'corpshort/backends/base'
|
5
|
+
require 'corpshort/link'
|
6
|
+
|
7
|
+
module Corpshort
|
8
|
+
module Backends
|
9
|
+
class Redis < Base
|
10
|
+
def initialize(redis: ::Redis.method(:current), prefix: "corpshort:")
|
11
|
+
@redis = redis
|
12
|
+
@prefix = prefix
|
13
|
+
end
|
14
|
+
|
15
|
+
attr_reader :prefix
|
16
|
+
|
17
|
+
def put_link(link, create_only: false)
|
18
|
+
key = link_key(link)
|
19
|
+
|
20
|
+
redis.watch(key) do
|
21
|
+
old_url = redis.hget(key, 'url')
|
22
|
+
|
23
|
+
if create_only && old_url
|
24
|
+
redis.unwatch(key)
|
25
|
+
raise ConflictError, "#{link.name} already exists"
|
26
|
+
end
|
27
|
+
|
28
|
+
redis.multi do |m|
|
29
|
+
m.del(key)
|
30
|
+
m.mapped_hmset(key, link.as_json)
|
31
|
+
m.zadd(links_key, link.updated_at.to_i, link.name)
|
32
|
+
|
33
|
+
if old_url && link.url != old_url
|
34
|
+
m.zrem(url_key(old_url), link.name)
|
35
|
+
end
|
36
|
+
m.zadd(url_key(link.url), link.updated_at.to_i, link.name)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def get_link(name)
|
42
|
+
data = redis.hgetall(link_key(name))
|
43
|
+
if data && !data.empty?
|
44
|
+
Link.new(data, backend: self)
|
45
|
+
else
|
46
|
+
nil
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def delete_link(link)
|
51
|
+
key = link_key(link)
|
52
|
+
redis.watch(key) do
|
53
|
+
url = redis.hget(key, 'url')
|
54
|
+
link_url_key = url_key(url)
|
55
|
+
|
56
|
+
redis.multi do |m|
|
57
|
+
m.zrem(links_key, link)
|
58
|
+
m.zrem(link_url_key, link)
|
59
|
+
m.del(key)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def rename_link(link, new_name)
|
65
|
+
link_key = link_key(link)
|
66
|
+
new_key = link_key(new_name)
|
67
|
+
|
68
|
+
redis.watch(link_key) do
|
69
|
+
url = redis.hget(link_key, 'url')
|
70
|
+
link_url_key = url_key(url)
|
71
|
+
|
72
|
+
redis.multi do |m|
|
73
|
+
m.renamenx(link_key, new_key)
|
74
|
+
m.hset(new_key, 'name', new_name)
|
75
|
+
m.zrem(links_key, link.name)
|
76
|
+
m.zadd(links_key, link.updated_at.to_i, new_name)
|
77
|
+
m.zrem(link_url_key, link.name)
|
78
|
+
m.zadd(link_url_key, link.updated_at.to_i, new_name)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def list_links_by_url(url)
|
84
|
+
redis.zrevrangebyscore(url_key(url), '+inf', '-inf')
|
85
|
+
end
|
86
|
+
|
87
|
+
def list_links(token: nil, limit: 30)
|
88
|
+
names = if token
|
89
|
+
redis.zrevrangebyscore(links_key, "(#{token}", '-inf', limit: [0, limit], with_scores: true)
|
90
|
+
else
|
91
|
+
redis.zrevrangebyscore(links_key, '+inf', '-inf', limit: [0, limit], with_scores: true)
|
92
|
+
end
|
93
|
+
|
94
|
+
[names.map(&:first), names[-1]&.last]
|
95
|
+
end
|
96
|
+
|
97
|
+
def redis
|
98
|
+
Thread.current[redis_thread_key] ||= @redis.call
|
99
|
+
end
|
100
|
+
|
101
|
+
private
|
102
|
+
|
103
|
+
def url_key(url)
|
104
|
+
"#{@prefix}url:#{Digest::SHA384.hexdigest(url)}"
|
105
|
+
end
|
106
|
+
|
107
|
+
def links_key
|
108
|
+
"#{@prefix}links"
|
109
|
+
end
|
110
|
+
|
111
|
+
def link_key(link)
|
112
|
+
name = link.is_a?(String) ? link : link.name
|
113
|
+
"#{@prefix}link:#{name}"
|
114
|
+
end
|
115
|
+
|
116
|
+
def redis_thread_key
|
117
|
+
@redis_thread_key ||= :"corpshort_backend_redis_#{self.__id__}"
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
@@ -0,0 +1,105 @@
|
|
1
|
+
require 'prawn'
|
2
|
+
require 'prawn/qrcode'
|
3
|
+
|
4
|
+
module Corpshort
|
5
|
+
class HorizontalPdf
|
6
|
+
include Prawn::Measurements
|
7
|
+
|
8
|
+
def initialize(url:, base_url:, name:, flex: false)
|
9
|
+
@url = url
|
10
|
+
@base_url = base_url
|
11
|
+
@name = name
|
12
|
+
|
13
|
+
@flex = flex
|
14
|
+
|
15
|
+
if @flex
|
16
|
+
@width = code_size + required_width_for_url_box + padding + padding + padding
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def w
|
21
|
+
@width || cm2pt(7)
|
22
|
+
end
|
23
|
+
|
24
|
+
def h
|
25
|
+
cm2pt(3)
|
26
|
+
end
|
27
|
+
|
28
|
+
def code_size
|
29
|
+
cm2pt(3)
|
30
|
+
end
|
31
|
+
|
32
|
+
def padding
|
33
|
+
cm2pt(0.2)
|
34
|
+
end
|
35
|
+
|
36
|
+
def text
|
37
|
+
[
|
38
|
+
{link: @url, text: @base_url},
|
39
|
+
{link: @url, text: '/'},
|
40
|
+
{link: @url, styles: [:bold], text: @name},
|
41
|
+
]
|
42
|
+
end
|
43
|
+
|
44
|
+
def required_width_for_url_box
|
45
|
+
doc = Prawn::Document.new(page_size: [cm2pt(5),cm2pt(5)], margin: 0)
|
46
|
+
doc.font_size = 12
|
47
|
+
text.inject(0) do |r,t|
|
48
|
+
r + doc.width_of(t.fetch(:text), style: t[:styles]&.first)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def url_box
|
53
|
+
Prawn::Text::Formatted::Box.new(
|
54
|
+
text,
|
55
|
+
document: pdf,
|
56
|
+
at: [code_size, code_size],
|
57
|
+
width: w - code_size - padding - padding,
|
58
|
+
height: h,
|
59
|
+
overflow: :shrink_to_fit,
|
60
|
+
min_font_size: nil,
|
61
|
+
disable_wrap_by_char: true,
|
62
|
+
align: :left,
|
63
|
+
valign: :center,
|
64
|
+
kerning: true,
|
65
|
+
)
|
66
|
+
end
|
67
|
+
|
68
|
+
def render
|
69
|
+
@pdf = nil
|
70
|
+
|
71
|
+
pdf.fill_color 'FFFFFF'
|
72
|
+
pdf.fill { pdf.rounded_rectangle [0, code_size], code_size, code_size, 10 }
|
73
|
+
pdf.print_qr_code(@url, level: :m, extent: code_size, stroke: false)
|
74
|
+
|
75
|
+
[true, false].each do |dry_run|
|
76
|
+
box = url_box()
|
77
|
+
box.render(dry_run: dry_run)
|
78
|
+
if dry_run
|
79
|
+
pdf.fill_color 'FFFFFF'
|
80
|
+
pdf.fill do
|
81
|
+
pdf.rounded_rectangle(
|
82
|
+
[box.at[0] - padding, box.at[1] + padding],
|
83
|
+
box.available_width + padding + padding,
|
84
|
+
box.height + padding + padding,
|
85
|
+
5,
|
86
|
+
)
|
87
|
+
end
|
88
|
+
pdf.fill_color '000000'
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
pdf
|
93
|
+
end
|
94
|
+
|
95
|
+
def document
|
96
|
+
render
|
97
|
+
end
|
98
|
+
|
99
|
+
def pdf
|
100
|
+
@pdf ||= Prawn::Document.new(page_size: [w,h], margin: 0).tap do |pdf|
|
101
|
+
pdf.font_size = 12
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
require 'time'
|
2
|
+
require 'json'
|
3
|
+
|
4
|
+
module Corpshort
|
5
|
+
class Link
|
6
|
+
class NoBackendError < StandardError; end
|
7
|
+
class ValidationError < StandardError; end
|
8
|
+
|
9
|
+
NAME_REGEXP = %r{\A[a-zA-Z0-9./\-_]+\z}
|
10
|
+
|
11
|
+
def self.validate_name(name)
|
12
|
+
raise ValidationError, "@name should satisfy #{NAME_REGEXP}" unless name.match?(NAME_REGEXP)
|
13
|
+
end
|
14
|
+
|
15
|
+
def initialize(data, backend: nil)
|
16
|
+
@backend = backend
|
17
|
+
|
18
|
+
@name = data[:name] || data['name']
|
19
|
+
@url = data[:url] || data['url']
|
20
|
+
@parsed_url_point = nil
|
21
|
+
self.updated_at = data[:updated_at] || data['updated_at']
|
22
|
+
|
23
|
+
validate!
|
24
|
+
end
|
25
|
+
|
26
|
+
def validate!
|
27
|
+
raise ValidationError, "@name, @url are required" unless name && url
|
28
|
+
raise ValidationError, "invalid @url (URL needs scheme and host to be considered valid)" unless parsed_url.scheme && parsed_url.host
|
29
|
+
self.class.validate_name(name)
|
30
|
+
end
|
31
|
+
|
32
|
+
def save!(backend = nil, create_only: false)
|
33
|
+
@backend = backend if backend
|
34
|
+
raise NoBackendError unless @backend
|
35
|
+
validate!
|
36
|
+
self.updated_at = Time.now
|
37
|
+
@backend.put_link(self, create_only: create_only)
|
38
|
+
end
|
39
|
+
|
40
|
+
attr_reader :backend
|
41
|
+
attr_reader :name, :updated_at
|
42
|
+
attr_accessor :url
|
43
|
+
|
44
|
+
def parsed_url
|
45
|
+
@parsed_url = nil if @parsed_url_point != url
|
46
|
+
@parsed_url ||= url.yield_self do |u|
|
47
|
+
@parsed_url_point = u
|
48
|
+
URI.parse(u)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def updated_at=(ts)
|
53
|
+
@updated_at = case ts
|
54
|
+
when Time
|
55
|
+
ts
|
56
|
+
when String
|
57
|
+
Time.iso8601(ts)
|
58
|
+
when nil
|
59
|
+
nil
|
60
|
+
else
|
61
|
+
raise TypeError, "link.updated_at must be a Time or a String (ISO 8601 formatted)"
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def to_h
|
66
|
+
{
|
67
|
+
name: name,
|
68
|
+
url: url,
|
69
|
+
updated_at: updated_at,
|
70
|
+
}
|
71
|
+
end
|
72
|
+
|
73
|
+
def as_json
|
74
|
+
to_h.tap do |h|
|
75
|
+
h[:updated_at] = h[:updated_at].iso8601 if h[:updated_at]
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
def to_json
|
80
|
+
as_json.to_json
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|