httpimagestore 1.2.0 → 1.3.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.
@@ -1,4 +1,6 @@
1
1
  require 'mime/types'
2
+ require 'digest/sha2'
3
+ require 'securerandom'
2
4
 
3
5
  module Configuration
4
6
  class ImageNotLoadedError < ConfigurationError
@@ -13,7 +15,31 @@ module Configuration
13
15
  end
14
16
  end
15
17
 
16
- class RequestState
18
+ class VariableNotDefinedError < ConfigurationError
19
+ def initialize(name)
20
+ super "variable '#{name}' not defined"
21
+ end
22
+ end
23
+
24
+ class NoRequestBodyToGenerateMetaVariableError < ConfigurationError
25
+ def initialize(meta_value)
26
+ super "need not empty request body to generate value for '#{meta_value}'"
27
+ end
28
+ end
29
+
30
+ class NoVariableToGenerateMetaVariableError < ConfigurationError
31
+ def initialize(value_name, meta_value)
32
+ super "need '#{value_name}' variable to generate value for '#{meta_value}'"
33
+ end
34
+ end
35
+
36
+ class NoImageDataForVariableError < ConfigurationError
37
+ def initialize(image_name, meta_value)
38
+ super "image '#{image_name}' does not have data for variable '#{meta_value}'"
39
+ end
40
+ end
41
+
42
+ class RequestState < Hash
17
43
  include ClassLogging
18
44
 
19
45
  class Images < Hash
@@ -35,24 +61,33 @@ module Configuration
35
61
  end
36
62
 
37
63
  def initialize(body = '', matches = {}, path = '', query_string = {}, memory_limit = MemoryLimit.new)
38
- @locals = {}
39
- @locals.merge! query_string
40
- @locals[:path] = path
41
- @locals.merge! matches
42
- @locals[:query_string_options] = query_string.sort.map{|kv| kv.join(':')}.join(',')
43
- log.debug "processing request with body length: #{body.bytesize} bytes and locals: #{@locals} "
64
+ super() do |request_state, name|
65
+ # note that request_state may be different object when useing with_locals that creates duplicate
66
+ request_state[name] = request_state.generate_meta_variable(name) or raise VariableNotDefinedError.new(name)
67
+ end
68
+
69
+ merge! query_string
70
+ self[:path] = path
71
+ merge! matches
72
+ self[:query_string_options] = query_string.sort.map{|kv| kv.join(':')}.join(',')
44
73
 
45
- @locals[:_body] = body
74
+ log.debug "processing request with body length: #{body.bytesize} bytes and variables: #{self} "
46
75
 
76
+ @body = body
47
77
  @images = Images.new(memory_limit)
48
78
  @memory_limit = memory_limit
49
79
  @output_callback = nil
50
80
  end
51
81
 
82
+ attr_reader :body
52
83
  attr_reader :images
53
- attr_reader :locals
54
84
  attr_reader :memory_limit
55
85
 
86
+ def with_locals(locals)
87
+ log.debug "using additional local variables: #{locals}"
88
+ self.dup.merge!(locals)
89
+ end
90
+
56
91
  def output(&callback)
57
92
  @output_callback = callback
58
93
  end
@@ -60,6 +95,62 @@ module Configuration
60
95
  def output_callback
61
96
  @output_callback or fail 'no output callback'
62
97
  end
98
+
99
+ def fetch_base_variable(name, base_name)
100
+ fetch(base_name, nil) or generate_meta_variable(base_name) or raise NoVariableToGenerateMetaVariableError.new(base_name, name)
101
+ end
102
+
103
+ def generate_meta_variable(name)
104
+ log.debug "generating meta variable: #{name}"
105
+ val = case name
106
+ when :basename
107
+ path = Pathname.new(fetch_base_variable(name, :path))
108
+ path.basename(path.extname).to_s
109
+ when :dirname
110
+ Pathname.new(fetch_base_variable(name, :path)).dirname.to_s
111
+ when :extension
112
+ Pathname.new(fetch_base_variable(name, :path)).extname.delete('.')
113
+ when :digest # deprecated
114
+ @body.empty? and raise NoRequestBodyToGenerateMetaVariableError.new(name)
115
+ Digest::SHA2.new.update(@body).to_s[0,16]
116
+ when :input_digest
117
+ @body.empty? and raise NoRequestBodyToGenerateMetaVariableError.new(name)
118
+ Digest::SHA2.new.update(@body).to_s[0,16]
119
+ when :input_sha256
120
+ @body.empty? and raise NoRequestBodyToGenerateMetaVariableError.new(name)
121
+ Digest::SHA2.new.update(@body).to_s
122
+ when :input_image_width
123
+ @images['input'].width or raise NoImageDataForVariableError.new('input', name)
124
+ when :input_image_height
125
+ @images['input'].height or raise NoImageDataForVariableError.new('input', name)
126
+ when :input_image_mime_extension
127
+ @images['input'].mime_extension or raise NoImageDataForVariableError.new('input', name)
128
+ when :image_digest
129
+ Digest::SHA2.new.update(@images[fetch_base_variable(name, :image_name)].data).to_s[0,16]
130
+ when :image_sha256
131
+ Digest::SHA2.new.update(@images[fetch_base_variable(name, :image_name)].data).to_s
132
+ when :mimeextension # deprecated
133
+ image_name = fetch_base_variable(name, :image_name)
134
+ @images[image_name].mime_extension or raise NoImageDataForVariableError.new(image_name, name)
135
+ when :image_mime_extension
136
+ image_name = fetch_base_variable(name, :image_name)
137
+ @images[image_name].mime_extension or raise NoImageDataForVariableError.new(image_name, name)
138
+ when :image_width
139
+ image_name = fetch_base_variable(name, :image_name)
140
+ @images[image_name].width or raise NoImageDataForVariableError.new(image_name, name)
141
+ when :image_height
142
+ image_name = fetch_base_variable(name, :image_name)
143
+ @images[image_name].height or raise NoImageDataForVariableError.new(image_name, name)
144
+ when :uuid
145
+ SecureRandom.uuid
146
+ end
147
+ if val
148
+ log.debug "generated meta variable '#{name}': #{val}"
149
+ else
150
+ log.debug "could not generated meta variable '#{name}'"
151
+ end
152
+ val
153
+ end
63
154
  end
64
155
 
65
156
  module ImageMetaData
@@ -75,14 +166,14 @@ module Configuration
75
166
  end
76
167
  end
77
168
 
78
- class Image < Struct.new(:data, :mime_type)
169
+ class Image < Struct.new(:data, :mime_type, :width, :height)
79
170
  include ImageMetaData
80
171
  end
81
172
 
82
173
  class InputSource
83
174
  def realize(request_state)
84
- request_state.locals[:_body].empty? and raise ZeroBodyLengthError
85
- request_state.images['input'] = Image.new(request_state.locals[:_body])
175
+ request_state.body.empty? and raise ZeroBodyLengthError
176
+ request_state.images['input'] = Image.new(request_state.body)
86
177
  end
87
178
  end
88
179
 
@@ -102,7 +193,7 @@ module Configuration
102
193
 
103
194
  def included?(request_state)
104
195
  return true if not @template
105
- @template.render(request_state.locals).split(',').include? @value
196
+ @template.render(request_state).split(',').include? @value
106
197
  end
107
198
  end
108
199
 
@@ -129,8 +220,11 @@ module Configuration
129
220
  def initialize(global, image_name, matcher)
130
221
  @global = global
131
222
  @image_name = image_name
132
- @locals = {imagename: @image_name}
223
+ @locals = {}
224
+
133
225
  inclusion_matcher matcher
226
+ local :imagename, @image_name # deprecated
227
+ local :image_name, @image_name
134
228
  end
135
229
 
136
230
  private
@@ -143,7 +237,7 @@ module Configuration
143
237
 
144
238
  def rendered_path(request_state)
145
239
  path = @global.paths[@path_spec]
146
- Pathname.new(path.render(@locals.merge(request_state.locals))).cleanpath.to_s
240
+ Pathname.new(path.render(request_state.with_locals(@locals))).cleanpath.to_s
147
241
  end
148
242
 
149
243
  def put_sourced_named_image(request_state)
@@ -157,8 +251,6 @@ module Configuration
157
251
 
158
252
  def get_named_image_for_storage(request_state)
159
253
  image = request_state.images[@image_name]
160
- local :mimeextension, image.mime_extension
161
-
162
254
  rendered_path = rendered_path(request_state)
163
255
  image.store_path = rendered_path
164
256
 
@@ -193,7 +285,8 @@ module Configuration
193
285
  :global,
194
286
  :http_method,
195
287
  :uri_matchers,
196
- :image_sources,
288
+ :sources,
289
+ :processors,
197
290
  :stores,
198
291
  :output
199
292
  ).new
@@ -245,14 +338,15 @@ module Configuration
245
338
  end
246
339
  end
247
340
  end
248
- handler_configuration.image_sources = []
341
+ handler_configuration.sources = []
342
+ handler_configuration.processors = []
249
343
  handler_configuration.stores = []
250
344
  handler_configuration.output = nil
251
345
 
252
346
  node.grab_attributes
253
347
 
254
348
  if handler_configuration.http_method != 'get'
255
- handler_configuration.image_sources << InputSource.new
349
+ handler_configuration.sources << InputSource.new
256
350
  end
257
351
 
258
352
  configuration.handlers << handler_configuration
@@ -0,0 +1,56 @@
1
+ require 'httpthumbnailer-client'
2
+ require 'httpimagestore/ruby_string_template'
3
+ require 'httpimagestore/configuration/handler'
4
+
5
+ module Configuration
6
+ class Identify
7
+ include ClassLogging
8
+
9
+ extend Stats
10
+ def_stats(
11
+ :total_identify_requests,
12
+ :total_identify_requests_bytes
13
+ )
14
+
15
+ include ConditionalInclusion
16
+
17
+ def self.match(node)
18
+ node.name == 'identify'
19
+ end
20
+
21
+ def self.parse(configuration, node)
22
+ image_name = node.grab_values('image name').first
23
+ if_image_name_on = node.grab_attributes('if-image-name-on').first
24
+
25
+ matcher = InclusionMatcher.new(image_name, if_image_name_on) if if_image_name_on
26
+
27
+ configuration.processors << self.new(configuration.global, image_name, matcher)
28
+ end
29
+
30
+ def initialize(global, image_name, matcher = nil)
31
+ @global = global
32
+ @image_name = image_name
33
+ inclusion_matcher matcher if matcher
34
+ end
35
+
36
+ def realize(request_state)
37
+ client = @global.thumbnailer or fail 'thumbnailer configuration'
38
+ image = request_state.images[@image_name]
39
+
40
+ log.info "identifying '#{@image_name}'"
41
+
42
+ Identify.stats.incr_total_identify_requests
43
+ Identify.stats.incr_total_identify_requests_bytes image.data.bytesize
44
+
45
+ id = client.identify(image.data)
46
+
47
+ image.mime_type = id.mime_type if id.mime_type
48
+ image.width = id.width if id.width
49
+ image.height = id.height if id.height
50
+ log.info "image '#{@image_name}' identified as '#{id.mime_type}' #{image.width}x#{image.height}"
51
+ end
52
+ end
53
+ Handler::register_node_parser Identify
54
+ StatsReporter << Identify.stats
55
+ end
56
+
@@ -15,14 +15,8 @@ module Configuration
15
15
  end
16
16
 
17
17
  class NoValueForPathTemplatePlaceholerError < PathRenderingError
18
- def initialize(path_name, template, value_name)
19
- super path_name, template, "no value for '\#{#{value_name}}'"
20
- end
21
- end
22
-
23
- class NoMetaValueForPathTemplatePlaceholerError < PathRenderingError
24
- def initialize(path_name, template, value_name, meta_value)
25
- super path_name, template, "need '#{value_name}' to generate value for '\#{#{meta_value}}'"
18
+ def initialize(path_name, template, placeholder)
19
+ super path_name, template, "no value for '\#{#{placeholder}}'"
26
20
  end
27
21
  end
28
22
 
@@ -49,26 +43,11 @@ module Configuration
49
43
 
50
44
  def initialize(path_name, template)
51
45
  super(template) do |locals, name|
52
- case name
53
- when :basename
54
- path = locals[:path] or raise NoMetaValueForPathTemplatePlaceholerError.new(path_name, template, :path, name)
55
- path = Pathname.new(path)
56
- path.basename(path.extname).to_s
57
- when :dirname
58
- path = locals[:path] or raise NoMetaValueForPathTemplatePlaceholerError.new(path_name, template, :path, name)
59
- Pathname.new(path).dirname.to_s
60
- when :extension
61
- path = locals[:path] or raise NoMetaValueForPathTemplatePlaceholerError.new(path_name, template, :path, name)
62
- Pathname.new(path).extname.delete('.')
63
- when :digest
64
- return locals[:_digest] if locals.include? :_digest
65
- data = locals[:_body] or raise NoMetaValueForPathTemplatePlaceholerError.new(path_name, template, :body, name)
66
- digest = Digest::SHA2.new.update(data).to_s[0,16]
67
- # cache digest in request locals
68
- locals[:_digest] = digest
69
- else
70
- locals[name] or raise NoValueForPathTemplatePlaceholerError.new(path_name, template, name)
71
- end
46
+ begin
47
+ locals[name]
48
+ rescue ConfigurationError => error
49
+ raise PathRenderingError.new(path_name, template, error.message)
50
+ end or raise NoValueForPathTemplatePlaceholerError.new(path_name, template, name)
72
51
  end
73
52
  end
74
53
  end
@@ -1,4 +1,6 @@
1
1
  require 'aws-sdk'
2
+ require 'digest/sha2'
3
+ require 'msgpack'
2
4
  require 'httpimagestore/aws_sdk_regions_hack'
3
5
  require 'httpimagestore/configuration/path'
4
6
  require 'httpimagestore/configuration/handler'
@@ -61,6 +63,190 @@ module Configuration
61
63
  class S3SourceStoreBase < SourceStoreBase
62
64
  include ClassLogging
63
65
 
66
+ class CacheRoot
67
+ CacheRootError = Class.new ArgumentError
68
+ class CacheRootNotDirError < CacheRootError
69
+ def initialize(root_dir)
70
+ super "S3 object cache directory '#{root_dir}' does not exist or not a directory"
71
+ end
72
+ end
73
+
74
+ class CacheRootNotWritableError < CacheRootError
75
+ def initialize(root_dir)
76
+ super "S3 object cache directory '#{root_dir}' is not writable"
77
+ end
78
+ end
79
+
80
+ class CacheRootNotAccessibleError < CacheRootError
81
+ def initialize(root_dir)
82
+ super "S3 object cache directory '#{root_dir}' is not readable"
83
+ end
84
+ end
85
+
86
+ def initialize(root_dir)
87
+ @root = Pathname.new(root_dir)
88
+ @root.directory? or raise CacheRootNotDirError.new(root_dir)
89
+ @root.executable? or raise CacheRootNotAccessibleError.new(root_dir)
90
+ @root.writable? or raise CacheRootNotWritableError.new(root_dir)
91
+ end
92
+
93
+ def cache_file(bucket, key)
94
+ File.join(Digest::SHA2.new.update("#{bucket}/#{key}").to_s[0,32].match(/(..)(..)(.*)/).captures)
95
+ end
96
+
97
+ def open(bucket, key)
98
+ # TODO: locking
99
+ file = @root + cache_file(bucket, key)
100
+
101
+ file.dirname.directory? or file.dirname.mkpath
102
+ if file.exist?
103
+ file.open('r+') do |io|
104
+ yield io
105
+ end
106
+ else
107
+ file.open('w+') do |io|
108
+ yield io
109
+ end
110
+ end
111
+ end
112
+ end
113
+
114
+ class S3Object
115
+ def initialize(client, bucket, key)
116
+ @client = client
117
+ @bucket = bucket
118
+ @key = key
119
+ end
120
+
121
+ def s3_object
122
+ return @s3_object if @s3_object
123
+ @s3_object = @client.buckets[@bucket].objects[@key]
124
+ end
125
+
126
+ def read(max_bytes = nil)
127
+ options = {}
128
+ options[:range] = 0..max_bytes if max_bytes
129
+ s3_object.read(options)
130
+ end
131
+
132
+ def write(data, options = {})
133
+ s3_object.write(data, options)
134
+ end
135
+
136
+ def private_url
137
+ s3_object.url_for(:read, expires: 60 * 60 * 24 * 365 * 20).to_s # expire in 20 years
138
+ end
139
+
140
+ def public_url
141
+ s3_object.public_url.to_s
142
+ end
143
+
144
+ def content_type
145
+ s3_object.head[:content_type]
146
+ end
147
+ end
148
+
149
+ class CacheObject < S3Object
150
+ include ClassLogging
151
+
152
+ def initialize(io, client, bucket, key)
153
+ @io = io
154
+ super(client, bucket, key)
155
+
156
+ @header = {}
157
+ @have_cache = false
158
+ @dirty = false
159
+
160
+ begin
161
+ head_length = @io.read(4)
162
+
163
+ if head_length and head_length.length == 4
164
+ head_length = head_length.unpack('L').first
165
+ @header = MessagePack.unpack(@io.read(head_length))
166
+ @have_cache = true
167
+
168
+ log.debug{"S3 object cache hit; bucket: '#{@bucket}' key: '#{@key}' [#{@io.path}]: header: #{@header}"}
169
+ else
170
+ log.debug{"S3 object cache miss; bucket: '#{@bucket}' key: '#{@key}' [#{@io.path}]"}
171
+ end
172
+ rescue => error
173
+ log.warn "cannot use cached S3 object; bucket: '#{@bucket}' key: '#{@key}' [#{@io.path}]: #{error}"
174
+ # not usable
175
+ io.seek 0
176
+ io.truncate 0
177
+ end
178
+
179
+ yield self
180
+
181
+ # save object as was used if no error happened and there were changes
182
+ write_cache if dirty?
183
+ end
184
+
185
+ def read(max_bytes = nil)
186
+ if @have_cache
187
+ data_location = @io.seek(0, IO::SEEK_CUR)
188
+ begin
189
+ return @data = @io.read(max_bytes)
190
+ ensure
191
+ @io.seek(data_location, IO::SEEK_SET)
192
+ end
193
+ else
194
+ dirty! :read
195
+ return @data = super
196
+ end
197
+ end
198
+
199
+ def write(data, options = {})
200
+ out = super
201
+ @data = data
202
+ dirty! :write
203
+ out
204
+ end
205
+
206
+ def private_url
207
+ @header['private_url'] ||= (dirty! :private_url; super)
208
+ end
209
+
210
+ def public_url
211
+ @header['public_url'] ||= (dirty! :public_url; super)
212
+ end
213
+
214
+ def content_type
215
+ @header['content_type'] ||= (dirty! :content_type; super)
216
+ end
217
+
218
+ private
219
+
220
+ def write_cache
221
+ begin
222
+ log.debug{"S3 object is dirty, wirting cache file; bucket: '#{@bucket}' key: '#{@key}' [#{@io.path}]; header: #{@header}"}
223
+
224
+ raise 'nil data!' unless @data
225
+ # rewrite
226
+ @io.seek(0, IO::SEEK_SET)
227
+ @io.truncate 0
228
+
229
+ header = MessagePack.pack(@header)
230
+ @io.write [header.length].pack('L') # header length
231
+ @io.write header
232
+ @io.write @data
233
+ rescue => error
234
+ log.warn "cannot store S3 object in cache: bucket: '#{@bucket}' key: '#{@key}' [#{@io.path}]: #{error}"
235
+ ensure
236
+ @dirty = false
237
+ end
238
+ end
239
+
240
+ def dirty!(reason = :unknown)
241
+ log.debug{"marking cache dirty for reason: #{reason}"}
242
+ @dirty = true
243
+ end
244
+
245
+ def dirty?
246
+ @dirty
247
+ end
248
+ end
249
+
64
250
  extend Stats
65
251
  def_stats(
66
252
  :total_s3_store,
@@ -75,8 +261,8 @@ module Configuration
75
261
  node.required_attributes('bucket', 'path')
76
262
  node.valid_attribute_values('public_access', true, false, nil)
77
263
 
78
- bucket, path_spec, public_access, cache_control, prefix, if_image_name_on =
79
- *node.grab_attributes('bucket', 'path', 'public', 'cache-control', 'prefix', 'if-image-name-on')
264
+ bucket, path_spec, public_access, cache_control, prefix, cache_root, if_image_name_on =
265
+ *node.grab_attributes('bucket', 'path', 'public', 'cache-control', 'prefix', 'cache-root', 'if-image-name-on')
80
266
  public_access = false if public_access.nil?
81
267
  prefix = '' if prefix.nil?
82
268
 
@@ -88,17 +274,31 @@ module Configuration
88
274
  path_spec,
89
275
  public_access,
90
276
  cache_control,
91
- prefix
277
+ prefix,
278
+ cache_root
92
279
  )
93
280
  end
94
281
 
95
- def initialize(global, image_name, matcher, bucket, path_spec, public_access, cache_control, prefix)
282
+ def initialize(global, image_name, matcher, bucket, path_spec, public_access, cache_control, prefix, cache_root)
96
283
  super global, image_name, matcher
97
284
  @bucket = bucket
98
285
  @path_spec = path_spec
99
286
  @public_access = public_access
100
287
  @cache_control = cache_control
101
288
  @prefix = prefix
289
+
290
+ @cache_root = nil
291
+ begin
292
+ if cache_root
293
+ @cache_root = CacheRoot.new(cache_root)
294
+ log.info "using S3 object cache directory '#{cache_root}' for image '#{image_name}'"
295
+ else
296
+ log.info "S3 object cache not configured (no cache-root) for image '#{image_name}'"
297
+ end
298
+ rescue CacheRoot::CacheRootNotDirError => error
299
+ log.warn "not using S3 object cache for image '#{image_name}': #{error}"
300
+ end
301
+
102
302
  local :bucket, @bucket
103
303
  end
104
304
 
@@ -108,16 +308,31 @@ module Configuration
108
308
 
109
309
  def url(object)
110
310
  if @public_access
111
- object.public_url.to_s
311
+ object.public_url
112
312
  else
113
- object.url_for(:read, expires: 60 * 60 * 24 * 365 * 20).to_s # expire in 20 years
313
+ object.private_url
114
314
  end
115
315
  end
116
316
 
117
317
  def object(path)
118
318
  begin
119
- bucket = client.buckets[@bucket]
120
- yield bucket.objects[@prefix + path]
319
+ key = @prefix + path
320
+ image = nil
321
+
322
+ if @cache_root
323
+ begin
324
+ @cache_root.open(@bucket, key) do |cahce_file_io|
325
+ CacheObject.new(cahce_file_io, client, @bucket, key) do |obj|
326
+ image = yield obj
327
+ end
328
+ end
329
+ rescue IOError => error
330
+ log.warn "cannot use S3 object cache '#{@cache_root.cache_file(@bucket, key)}': #{error}"
331
+ image = yield obj
332
+ end
333
+ else
334
+ image = yield S3Object.new(client, @bucket, key)
335
+ end
121
336
  rescue AWS::S3::Errors::AccessDenied
122
337
  raise S3AccessDenied.new(@bucket, path)
123
338
  rescue AWS::S3::Errors::NoSuchBucket
@@ -125,7 +340,11 @@ module Configuration
125
340
  rescue AWS::S3::Errors::NoSuchKey
126
341
  raise S3NoSuchKeyError.new(@bucket, path)
127
342
  end
343
+ image
128
344
  end
345
+
346
+ S3SourceStoreBase.logger = Handler.logger_for(S3SourceStoreBase)
347
+ CacheObject.logger = S3SourceStoreBase.logger_for(CacheObject)
129
348
  end
130
349
 
131
350
  class S3Source < S3SourceStoreBase
@@ -134,7 +353,7 @@ module Configuration
134
353
  end
135
354
 
136
355
  def self.parse(configuration, node)
137
- configuration.image_sources << super
356
+ configuration.sources << super
138
357
  end
139
358
 
140
359
  def realize(request_state)
@@ -143,12 +362,12 @@ module Configuration
143
362
 
144
363
  object(rendered_path) do |object|
145
364
  data = request_state.memory_limit.get do |limit|
146
- object.read range: 0..(limit + 1)
365
+ object.read(limit + 1)
147
366
  end
148
367
  S3SourceStoreBase.stats.incr_total_s3_source
149
368
  S3SourceStoreBase.stats.incr_total_s3_source_bytes(data.bytesize)
150
369
 
151
- image = Image.new(data, object.head[:content_type])
370
+ image = Image.new(data, object.content_type)
152
371
  image.source_url = url(object)
153
372
  image
154
373
  end
@@ -194,4 +413,3 @@ module Configuration
194
413
  StatsReporter << S3SourceStoreBase.stats
195
414
  end
196
415
 
197
-