corpshort 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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