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