imgproxy 0.0.2
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/LICENSE +21 -0
- data/README.md +100 -0
- data/lib/imgproxy/builder.rb +113 -0
- data/lib/imgproxy/config.rb +41 -0
- data/lib/imgproxy/options.rb +118 -0
- data/lib/imgproxy/version.rb +3 -0
- data/lib/imgproxy.rb +72 -0
- metadata +49 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 0b6f43dc8ff8bc89edf3015f8ebac74c4c8b4f785a0c886209a66133d44c7754
|
4
|
+
data.tar.gz: 68749943510a2c6b41608ebb9fdd132d5339ad35486019710588f5ec3aa49d35
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: a320137aa833b9af94bb9de211b297e49fe2bb515a2b6a08d6cfae9b512173653ec82c1bf85964e126a56e39fe92073049e4d382d789d6a0c70e8d89d551ca20
|
7
|
+
data.tar.gz: 9851452726dcadfd6b84fa6018b4d3791650a69c52860fb79a5d9d2ee48b1f275d9bebfd6e8174da69acb0968d411e03ad890c26979ab2ec1b6007c412b06d29
|
data/LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2019 imgproxy
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,100 @@
|
|
1
|
+
# imgproxy.rb
|
2
|
+
|
3
|
+
[](https://rubygems.org/gems/imgproxy) [](https://www.rubydoc.info/gems/imgproxy/)
|
4
|
+
|
5
|
+
Gem for [imgproxy](https://github.com/DarthSim/imgproxy) URLs generation.
|
6
|
+
|
7
|
+
[imgproxy](https://github.com/DarthSim/imgproxy) is a fast and secure standalone server for resizing and converting remote images. The main principles of imgproxy are simplicity, speed, and security.
|
8
|
+
|
9
|
+
## Installation
|
10
|
+
|
11
|
+
```
|
12
|
+
gem install imgproxy
|
13
|
+
```
|
14
|
+
|
15
|
+
or add it to your `Gemfile`:
|
16
|
+
|
17
|
+
```ruby
|
18
|
+
gem "imgproxy"
|
19
|
+
```
|
20
|
+
|
21
|
+
## Configuration
|
22
|
+
|
23
|
+
```ruby
|
24
|
+
# config/initializers/imgproxy.rb
|
25
|
+
|
26
|
+
Imgproxy.configure do |config|
|
27
|
+
# imgproxy endpoint
|
28
|
+
config.endpoint = "http://imgproxy.example.com"
|
29
|
+
# hex-encoded signature key
|
30
|
+
config.hex_key = "your_key"
|
31
|
+
# hex-encoded signature salt
|
32
|
+
config.hex_salt = "your_salt"
|
33
|
+
# signature size. Defaults to 32
|
34
|
+
config.signature_size = 5
|
35
|
+
# use short processing option names (`rs` for `resize`, `g` for `gravity`, etc).
|
36
|
+
# Defaults to true
|
37
|
+
config.use_short_options = false
|
38
|
+
end
|
39
|
+
```
|
40
|
+
|
41
|
+
## Usage
|
42
|
+
|
43
|
+
```ruby
|
44
|
+
Imgproxy.url_for(
|
45
|
+
"http://images.example.com/images/image.jpg",
|
46
|
+
width: 500,
|
47
|
+
height: 400,
|
48
|
+
resizing_type: :fill,
|
49
|
+
sharpen: 0.5
|
50
|
+
)
|
51
|
+
# => http://imgproxy.example.com/2tjGMpWqjO/rs:fill:500:400/sh:0.5/plain/http://images.example.com/images/image.jpg
|
52
|
+
```
|
53
|
+
|
54
|
+
You can reuse processing options by using `Imgproxy::Builder`:
|
55
|
+
|
56
|
+
```ruby
|
57
|
+
builder = Imgproxy::Builder.new(
|
58
|
+
width: 500,
|
59
|
+
height: 400,
|
60
|
+
resizing_type: :fill,
|
61
|
+
sharpen: 0.5
|
62
|
+
)
|
63
|
+
|
64
|
+
builder.url_for("http://images.example.com/images/image1.jpg")
|
65
|
+
builder.url_for("http://images.example.com/images/image2.jpg")
|
66
|
+
```
|
67
|
+
|
68
|
+
Available options are:
|
69
|
+
|
70
|
+
* `resizing_type`
|
71
|
+
* `width`
|
72
|
+
* `height`
|
73
|
+
* `dpr`
|
74
|
+
* `enlarge`
|
75
|
+
* `extend`
|
76
|
+
* `gravity`
|
77
|
+
* `gravity_x`
|
78
|
+
* `gravity_y`
|
79
|
+
* `quality`
|
80
|
+
* `background`
|
81
|
+
* `blur`
|
82
|
+
* `sharpen`
|
83
|
+
* `watermark_opacity`
|
84
|
+
* `watermark_position`
|
85
|
+
* `watermark_x_offset`
|
86
|
+
* `watermark_y_offset`
|
87
|
+
* `watermark_scale`
|
88
|
+
* `preset`
|
89
|
+
* `cachebuster`
|
90
|
+
* `format`
|
91
|
+
* `use_short_options`
|
92
|
+
|
93
|
+
_See [imgproxy URL format guide](https://github.com/DarthSim/imgproxy/blob/master/docs/generating_the_url_advanced.md) for more info_
|
94
|
+
|
95
|
+
## Contributing
|
96
|
+
|
97
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/imgproxy/imgproxy.rb.
|
98
|
+
|
99
|
+
## License
|
100
|
+
The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
|
@@ -0,0 +1,113 @@
|
|
1
|
+
require 'openssl'
|
2
|
+
require 'base64'
|
3
|
+
|
4
|
+
require 'imgproxy/options'
|
5
|
+
|
6
|
+
module Imgproxy
|
7
|
+
# Builds imgproxy URL
|
8
|
+
#
|
9
|
+
# builder = Imgproxy::Builder.new(
|
10
|
+
# width: 500,
|
11
|
+
# height: 400,
|
12
|
+
# resizing_type: :fill,
|
13
|
+
# sharpen: 0.5
|
14
|
+
# )
|
15
|
+
#
|
16
|
+
# builder.url_for("http://images.example.com/images/image1.jpg")
|
17
|
+
# builder.url_for("http://images.example.com/images/image2.jpg")
|
18
|
+
class Builder
|
19
|
+
# @param [Hash] options Processing options
|
20
|
+
# @see Imgproxy.url_for
|
21
|
+
def initialize(options = {})
|
22
|
+
@use_short_options = options.delete(:use_short_options)
|
23
|
+
@use_short_options = config.use_short_options if @use_short_options.nil?
|
24
|
+
|
25
|
+
@options = Imgproxy::Options.new(options)
|
26
|
+
end
|
27
|
+
|
28
|
+
# Genrates imgproxy URL
|
29
|
+
#
|
30
|
+
# @return [String] imgproxy URL
|
31
|
+
# @param [String] image Source image URL
|
32
|
+
# @see Imgproxy.url_for
|
33
|
+
def url_for(image)
|
34
|
+
path = [*processing_options, 'plain', escape_url(image)].join('/')
|
35
|
+
path = "#{path}@#{options[:format]}" if @options[:format]
|
36
|
+
|
37
|
+
signature = sign_path(path)
|
38
|
+
|
39
|
+
"#{Imgproxy.config.endpoint}/#{signature}/#{path}"
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
OPTIONS_ALIASES = {
|
45
|
+
resize: :rs,
|
46
|
+
size: :s,
|
47
|
+
resizing_type: :rt,
|
48
|
+
width: :w,
|
49
|
+
height: :h,
|
50
|
+
enlarge: :en,
|
51
|
+
extend: :ex,
|
52
|
+
gravity: :g,
|
53
|
+
quality: :q,
|
54
|
+
background: :bg,
|
55
|
+
blur: :bl,
|
56
|
+
sharpen: :sh,
|
57
|
+
watermark: :wm,
|
58
|
+
preset: :pr,
|
59
|
+
cachebuster: :cb
|
60
|
+
}.freeze
|
61
|
+
|
62
|
+
NEED_ESCAPE_RE = /@\?%/.freeze
|
63
|
+
|
64
|
+
def processing_options
|
65
|
+
@processing_options ||=
|
66
|
+
@options.build.map do |key, value|
|
67
|
+
"#{option_alias(key)}:#{wrap_array(value).join(':')}"
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def option_alias(name)
|
72
|
+
return name unless config.use_short_options
|
73
|
+
|
74
|
+
OPTIONS_ALIASES.fetch(name, name)
|
75
|
+
end
|
76
|
+
|
77
|
+
def wrap_array(value)
|
78
|
+
value.is_a?(Array) ? value : [value]
|
79
|
+
end
|
80
|
+
|
81
|
+
def escape_url(url)
|
82
|
+
url =~ NEED_ESCAPE_RE ? CGI.escape(url) : url
|
83
|
+
end
|
84
|
+
|
85
|
+
def sign_path(path)
|
86
|
+
return 'unsafe' if signature_key.nil? || signature_salt.nil?
|
87
|
+
|
88
|
+
digest = OpenSSL::HMAC.digest(
|
89
|
+
OpenSSL::Digest.new('sha256'),
|
90
|
+
signature_key,
|
91
|
+
"#{signature_salt}/#{path}"
|
92
|
+
)[0, signature_size]
|
93
|
+
|
94
|
+
Base64.urlsafe_encode64(digest).tr('=', '')
|
95
|
+
end
|
96
|
+
|
97
|
+
def signature_key
|
98
|
+
config.key
|
99
|
+
end
|
100
|
+
|
101
|
+
def signature_salt
|
102
|
+
config.salt
|
103
|
+
end
|
104
|
+
|
105
|
+
def signature_size
|
106
|
+
config.signature_size
|
107
|
+
end
|
108
|
+
|
109
|
+
def config
|
110
|
+
Imgproxy.config
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
module Imgproxy
|
2
|
+
# Imgproxy config
|
3
|
+
# @see Imgproxy.configure
|
4
|
+
class Config
|
5
|
+
# @return [String] imgproxy endpoint
|
6
|
+
attr_reader :endpoint
|
7
|
+
# @return [String] imgproxy signature key
|
8
|
+
attr_accessor :key
|
9
|
+
# @return [String] imgproxy signature salt
|
10
|
+
attr_accessor :salt
|
11
|
+
# @return [Integer] imgproxy signature size. Defaults to 32
|
12
|
+
attr_accessor :signature_size
|
13
|
+
# @return [Boolean] use short processing option names
|
14
|
+
# (`rs` for `resize`, `g` for `gravity`, etc).
|
15
|
+
# Defaults to true
|
16
|
+
attr_accessor :use_short_options
|
17
|
+
|
18
|
+
def initialize
|
19
|
+
self.signature_size = 32
|
20
|
+
self.use_short_options = true
|
21
|
+
end
|
22
|
+
|
23
|
+
# Decodes hex-encoded key and sets it to {#key}
|
24
|
+
#
|
25
|
+
# @param value [String] hex-encoded signature key
|
26
|
+
def hex_key=(value)
|
27
|
+
self.key = [value].pack('H*')
|
28
|
+
end
|
29
|
+
|
30
|
+
# Decodes hex-encoded salt and sets it to {#salt}
|
31
|
+
#
|
32
|
+
# @param value [String] hex-encoded signature salt
|
33
|
+
def hex_salt=(value)
|
34
|
+
self.salt = [value].pack('H*')
|
35
|
+
end
|
36
|
+
|
37
|
+
def endpoint=(value)
|
38
|
+
@endpoint = value.end_with?('/') ? value[0..-2] : value
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,118 @@
|
|
1
|
+
require 'ostruct'
|
2
|
+
|
3
|
+
module Imgproxy
|
4
|
+
# Formats and regroups processing options
|
5
|
+
class Options < Hash
|
6
|
+
STRING_OPTS = %i[resizing_type gravity watermark_position style cachebuster
|
7
|
+
format].freeze
|
8
|
+
|
9
|
+
INT_OPTS = %i[width height quality watermark_x_offset
|
10
|
+
watermark_y_offset].freeze
|
11
|
+
|
12
|
+
FLOAT_OPTS = %i[dpr gravity_x gravity_y blur sharpen watermark_opacity
|
13
|
+
watermark_scale].freeze
|
14
|
+
|
15
|
+
BOOL_OPTS = %i[enlarge extend].freeze
|
16
|
+
|
17
|
+
ARRAY_OPTS = %i[background preset].freeze
|
18
|
+
|
19
|
+
ALL_OPTS =
|
20
|
+
(STRING_OPTS + INT_OPTS + FLOAT_OPTS + BOOL_OPTS + ARRAY_OPTS).freeze
|
21
|
+
|
22
|
+
OPTS_PRIORITY = { resize: 1, size: 2 }.freeze
|
23
|
+
|
24
|
+
# @param options [Hash] raw processing options
|
25
|
+
def initialize(options)
|
26
|
+
merge!(options.slice(*ALL_OPTS))
|
27
|
+
typecast
|
28
|
+
freeze
|
29
|
+
end
|
30
|
+
|
31
|
+
# @return [Hash] formatted and regrouped processing options
|
32
|
+
def build
|
33
|
+
opts = dup
|
34
|
+
|
35
|
+
group_resizing_opts(opts)
|
36
|
+
group_gravity_opts(opts)
|
37
|
+
group_watermark_opts(opts)
|
38
|
+
encode_style(opts)
|
39
|
+
|
40
|
+
Hash[opts.sort_by { |k, _| OPTS_PRIORITY.fetch(k, 99) }]
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
def typecast
|
46
|
+
compact.each do |key, value|
|
47
|
+
self[key] =
|
48
|
+
case key
|
49
|
+
when *STRING_OPTS then value.to_s
|
50
|
+
when *INT_OPTS then value.to_i
|
51
|
+
when *FLOAT_OPTS then value.to_f
|
52
|
+
when *BOOL_OPTS then bool(value)
|
53
|
+
when *ARRAY_OPTS then wrap_array(value)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def bool(value)
|
59
|
+
value && value != 0 && value != '0' ? 1 : 0
|
60
|
+
end
|
61
|
+
|
62
|
+
def wrap_array(value)
|
63
|
+
value.is_a?(Array) ? value : [value]
|
64
|
+
end
|
65
|
+
|
66
|
+
def group_resizing_opts(opts)
|
67
|
+
return opts unless opts[:width] && opts[:height]
|
68
|
+
|
69
|
+
opts[:size] = trim_nils(
|
70
|
+
[opts.delete(:width), opts.delete(:height),
|
71
|
+
opts.delete(:enlarge), opts.delete(:extend)]
|
72
|
+
)
|
73
|
+
|
74
|
+
if opts[:resizing_type]
|
75
|
+
opts[:resize] = [opts.delete(:resizing_type), *opts.delete(:size)]
|
76
|
+
end
|
77
|
+
|
78
|
+
opts
|
79
|
+
end
|
80
|
+
|
81
|
+
def group_gravity_opts(opts)
|
82
|
+
gravity = trim_nils(
|
83
|
+
[
|
84
|
+
opts.delete(:gravity),
|
85
|
+
opts.delete(:gravity_x),
|
86
|
+
opts.delete(:gravity_y)
|
87
|
+
]
|
88
|
+
)
|
89
|
+
|
90
|
+
opts[:gravity] = gravity unless gravity[0].nil?
|
91
|
+
end
|
92
|
+
|
93
|
+
def group_watermark_opts(opts)
|
94
|
+
watermark = trim_nils(
|
95
|
+
[
|
96
|
+
opts.delete(:watermark_opacity),
|
97
|
+
opts.delete(:watermark_position),
|
98
|
+
opts.delete(:watermark_x_offset),
|
99
|
+
opts.delete(:watermark_y_offset),
|
100
|
+
opts.delete(:watermark_scale)
|
101
|
+
]
|
102
|
+
)
|
103
|
+
|
104
|
+
opts[:watermark] = watermark unless watermark[0].nil?
|
105
|
+
end
|
106
|
+
|
107
|
+
def encode_style(opts)
|
108
|
+
return if opts[:style].nil?
|
109
|
+
|
110
|
+
opts[:style] = Base64.urlsafe_encode64(opts[:style]).tr('=', '')
|
111
|
+
end
|
112
|
+
|
113
|
+
def trim_nils(value)
|
114
|
+
value.delete_at(-1) while !value.empty? && value[-1].nil?
|
115
|
+
value
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
data/lib/imgproxy.rb
ADDED
@@ -0,0 +1,72 @@
|
|
1
|
+
require 'imgproxy/version'
|
2
|
+
require 'imgproxy/config'
|
3
|
+
require 'imgproxy/builder'
|
4
|
+
|
5
|
+
# @see Imgproxy::ClassMethods
|
6
|
+
module Imgproxy
|
7
|
+
class << self
|
8
|
+
# Imgproxy config
|
9
|
+
#
|
10
|
+
# @return [Config]
|
11
|
+
def config
|
12
|
+
@config ||= Imgproxy::Config.new
|
13
|
+
end
|
14
|
+
|
15
|
+
# Yields Imgproxy config
|
16
|
+
#
|
17
|
+
# Imgproxy.configure do |config|
|
18
|
+
# config.endpoint = "http://imgproxy.example.com"
|
19
|
+
# config.hex_key = "your_key"
|
20
|
+
# config.hex_salt = "your_salt"
|
21
|
+
# config.use_short_options = true
|
22
|
+
# end
|
23
|
+
#
|
24
|
+
# @yieldparam config [Config]
|
25
|
+
# @return [Config]
|
26
|
+
def configure
|
27
|
+
yield config
|
28
|
+
config
|
29
|
+
end
|
30
|
+
|
31
|
+
# Genrates imgproxy URL
|
32
|
+
#
|
33
|
+
# Imgproxy.url_for(
|
34
|
+
# "http://images.example.com/images/image.jpg",
|
35
|
+
# width: 500,
|
36
|
+
# height: 400,
|
37
|
+
# resizing_type: :fill,
|
38
|
+
# sharpen: 0.5
|
39
|
+
# )
|
40
|
+
#
|
41
|
+
# @return [String] imgproxy URL
|
42
|
+
# @param [String] image Source image URL
|
43
|
+
# @param [Hash] options Processing options
|
44
|
+
# @option options [String] :resizing_type
|
45
|
+
# @option options [Integer] :width
|
46
|
+
# @option options [Integer] :height
|
47
|
+
# @option options [Float] :dpr
|
48
|
+
# @option options [Boolean] :enlarge
|
49
|
+
# @option options [Boolean] :extend
|
50
|
+
# @option options [String] :gravity
|
51
|
+
# @option options [Float] :gravity_x
|
52
|
+
# @option options [Float] :gravity_y
|
53
|
+
# @option options [Integer] :quality
|
54
|
+
# @option options [Array] :background
|
55
|
+
# @option options [Float] :blur
|
56
|
+
# @option options [Float] :sharpen
|
57
|
+
# @option options [Float] :watermark_opacity
|
58
|
+
# @option options [String] :watermark_position
|
59
|
+
# @option options [Integer] :watermark_x_offset
|
60
|
+
# @option options [Integer] :watermark_y_offset
|
61
|
+
# @option options [Float] :watermark_scale
|
62
|
+
# @option options [Array] :preset
|
63
|
+
# @option options [String] :cachebuster
|
64
|
+
# @option options [String] :format
|
65
|
+
# @option options [Boolean] :use_short_options
|
66
|
+
# @see https://github.com/DarthSim/imgproxy/blob/master/docs/generating_the_url_advanced.md
|
67
|
+
# imgproxy URL format documentation
|
68
|
+
def url_for(image, options = {})
|
69
|
+
Imgproxy::Builder.new(options).url_for(image)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
metadata
ADDED
@@ -0,0 +1,49 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: imgproxy
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.2
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Sergey Alexandrovich
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2019-03-25 00:00:00.000000000 Z
|
12
|
+
dependencies: []
|
13
|
+
description: A gem that easily generates imgproxy URLs for your images
|
14
|
+
email: darthsim@gmail.com
|
15
|
+
executables: []
|
16
|
+
extensions: []
|
17
|
+
extra_rdoc_files: []
|
18
|
+
files:
|
19
|
+
- LICENSE
|
20
|
+
- README.md
|
21
|
+
- lib/imgproxy.rb
|
22
|
+
- lib/imgproxy/builder.rb
|
23
|
+
- lib/imgproxy/config.rb
|
24
|
+
- lib/imgproxy/options.rb
|
25
|
+
- lib/imgproxy/version.rb
|
26
|
+
homepage: https://github.com/imgproxy/imgproxy.rb
|
27
|
+
licenses:
|
28
|
+
- MIT
|
29
|
+
metadata: {}
|
30
|
+
post_install_message:
|
31
|
+
rdoc_options: []
|
32
|
+
require_paths:
|
33
|
+
- lib
|
34
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
35
|
+
requirements:
|
36
|
+
- - ">="
|
37
|
+
- !ruby/object:Gem::Version
|
38
|
+
version: '0'
|
39
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
40
|
+
requirements:
|
41
|
+
- - ">="
|
42
|
+
- !ruby/object:Gem::Version
|
43
|
+
version: '0'
|
44
|
+
requirements: []
|
45
|
+
rubygems_version: 3.0.2
|
46
|
+
signing_key:
|
47
|
+
specification_version: 4
|
48
|
+
summary: imgproxy URL generator
|
49
|
+
test_files: []
|