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.
- 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
|