calligraphy 0.2.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 8ca4ef1090301769811a4e4e9582826338ebcc7d
4
+ data.tar.gz: 6453585dca9f697419f6f5e8b4c73e2f664d5b10
5
+ SHA512:
6
+ metadata.gz: 5055f8ed11cda5dcc4bdaf6d1e821b989fd57f2e2869e9e316a8a9ed49e40a6da5962614dac6a8f6448d155a5de743f9ca3360aaa8700af736c7e989a8a66f91
7
+ data.tar.gz: 7de86141b36bd5bef0bd915a8eed30c524568c1e409bf0ecdc1bf5d7f41a22fa887cbbc69964ef404f8f5f0de094474cb4d5eea10e68278d20d1e573e8dc90f5
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2017 Brandon Robins
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,64 @@
1
+ # Calligraphy
2
+
3
+ Calligraphy is a Web Distributed Authoring and Versioning (WebDAV) solution for Rails that:
4
+
5
+ * Provides a framework for handling WebDAV requests (e.g. `PROPFIND`, `PROPPATCH`)
6
+ * Allows you to extend WedDAV functionality to any type of resource
7
+ * Passes all of the [Litmus](https://github.com/eanlain/litmus) tests (using `Calligraphy::FileResource` and digest authentication)
8
+
9
+ ## Getting Started
10
+
11
+ Add the following line to your Gemfile:
12
+
13
+ ```ruby
14
+ gem 'calligraphy', :git => 'https://github.com/eanlain/calligraphy'
15
+ ```
16
+
17
+ Then run `bundle install`
18
+
19
+ Next, set up a `calligraphy_resource` route in `config/routes.rb` with a `resource_class`.
20
+
21
+ ```ruby
22
+ calligraphy_resource :webdav, resource_class: Calligraphy::FileResource
23
+ ```
24
+
25
+ The above will create a route, `/webdav` that will be able to handle the following HTTP request methods:
26
+
27
+ * `OPTIONS`
28
+ * `GET`
29
+ * `PUT`
30
+ * `DELETE`
31
+ * `COPY`
32
+ * `MOVE`
33
+ * `MKCOL`
34
+ * `PROPFIND`
35
+ * `PROPPATCH`
36
+ * `LOCK`
37
+ * `UNLOCK`
38
+
39
+ The routes will also use the `Calligraphy::FileResource`, enabling Rails to carry out WebDAV actions on files.
40
+
41
+ ## Extensibility
42
+
43
+ The `Calligraphy::Resource` class exposes all the methods used in the various `Calligraphy::WebDavRequest` classes.
44
+ To create a custom resource, simply inherit from `Calligraphy::Resource` and redefine the public methods you'd like to customize.
45
+
46
+ For example, to create a `CalendarResource`:
47
+
48
+ ```ruby
49
+ module Calligraphy
50
+ class CalendarResource < Resource
51
+
52
+ def propfind(nodes)
53
+ # custom implementation of propfind for CalendarResource
54
+ end
55
+
56
+ ...
57
+ end
58
+ end
59
+ ```
60
+
61
+ ## License
62
+
63
+ Calligraphy is Copyright © 2017 Brandon Robins.
64
+ It is free software, and may be redistributed under the terms specified in the [LICENSE](/LICENSE) file.
@@ -0,0 +1,58 @@
1
+ require 'calligraphy/rails/mapper'
2
+ require 'calligraphy/rails/web_dav_requests_controller'
3
+
4
+ require 'calligraphy/xml/builder'
5
+ require 'calligraphy/xml/namespace'
6
+ require 'calligraphy/xml/node'
7
+ require 'calligraphy/xml/utils'
8
+
9
+ require 'calligraphy/utils'
10
+ require 'calligraphy/resource'
11
+ require 'calligraphy/file_resource'
12
+
13
+ require 'calligraphy/web_dav_request'
14
+ require 'calligraphy/copy'
15
+ require 'calligraphy/delete'
16
+ require 'calligraphy/get'
17
+ require 'calligraphy/lock'
18
+ require 'calligraphy/mkcol'
19
+ require 'calligraphy/move'
20
+ require 'calligraphy/propfind'
21
+ require 'calligraphy/proppatch'
22
+ require 'calligraphy/put'
23
+ require 'calligraphy/unlock'
24
+
25
+ module Calligraphy
26
+ DAV_NS = 'DAV:'
27
+ DAV_NO_LOCK_REGEX = /DAV:no-lock/i
28
+ DAV_NOT_NO_LOCK_REGEX = /Not\s+<DAV:no-lock>/i
29
+ ETAG_IF_REGEX = /\[(.+?)\]/
30
+ LOCK_TOKEN_ANGLE_REGEX = /[<>]/
31
+ LOCK_TOKEN_REGEX = /<(urn:uuid:.+?)>/
32
+ RESOURCE_REGEX = /^<+(.+?)>\s/
33
+ TAGGED_LIST_REGEX = /\)\s</
34
+ UNTAGGAGED_LIST_REGEX = /\)\s\(/
35
+
36
+ mattr_accessor :allowed_methods
37
+ @@allowed_methods = %w(
38
+ options head get put delete copy move mkcol propfind proppatch lock unlock
39
+ )
40
+
41
+ mattr_accessor :digest_password_procedure
42
+ @@digest_password_procedure = Proc.new { |x| 'changeme!' }
43
+
44
+ mattr_accessor :enable_digest_authentication
45
+ @@enable_digest_authentication = false
46
+
47
+ mattr_accessor :lock_timeout_period
48
+ @@lock_timeout_period = 24 * 60 * 60
49
+
50
+ mattr_accessor :web_dav_actions
51
+ @@web_dav_actions = %i(
52
+ options get put delete copy move mkcol propfind proppatch lock unlock
53
+ )
54
+
55
+ def self.configure
56
+ yield self
57
+ end
58
+ end
@@ -0,0 +1,37 @@
1
+ module Calligraphy
2
+ class Copy < WebDavRequest
3
+ def request
4
+ options = copy_move_options
5
+ can_copy = @resource.can_copy? options
6
+
7
+ if can_copy[:ancestor_exist]
8
+ return :precondition_failed
9
+ else
10
+ return :conflict
11
+ end unless can_copy[:can_copy]
12
+
13
+ return :locked if can_copy[:locked]
14
+
15
+ overwritten = @resource.copy options
16
+ return overwritten ? :no_content : :created
17
+ end
18
+
19
+ private
20
+
21
+ def copy_move_options
22
+ {
23
+ depth: @headers['Depth'],
24
+ destination: remove_trailing_slash(destination_header),
25
+ overwrite: @headers['Overwrite'] || true
26
+ }
27
+ end
28
+
29
+ def destination_header
30
+ @headers['Destination'].split(@headers['Host'])[-1]
31
+ end
32
+
33
+ def remove_trailing_slash(input)
34
+ input[-1] == '/' ? input[0..-2] : input
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,17 @@
1
+ module Calligraphy
2
+ class Delete < WebDavRequest
3
+ def request
4
+ return :locked if @resource.locked_to_user? @headers
5
+
6
+ if @resource.collection?
7
+ @resource.delete_collection
8
+
9
+ return :no_content
10
+ else
11
+ return :not_found unless @resource.exists?
12
+ end
13
+
14
+ return :no_content
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,487 @@
1
+ require 'pstore'
2
+
3
+ module Calligraphy
4
+ class FileResource < Resource
5
+ include Calligraphy::Utils
6
+
7
+ def initialize(resource: nil, req: nil, mount: nil, root_dir: Dir.pwd)
8
+ super
9
+
10
+ @root_dir = root_dir || Dir.pwd
11
+ @src_path = join_paths @root_dir, @request_path
12
+
13
+ if exists?
14
+ @name = File.basename @src_path
15
+ init_pstore
16
+ set_file_stats
17
+ end
18
+
19
+ set_ancestors
20
+ end
21
+
22
+ def ancestor_exist?
23
+ File.exist? @ancestor_path
24
+ end
25
+
26
+ def can_copy?(options)
27
+ copy_options = { can_copy: false, ancestor_exist: false, locked: false }
28
+
29
+ overwrite = is_true? options[:overwrite]
30
+ destination = options[:destination].tap { |s| s.slice! @mount_point }
31
+ copy_options[:ancestor_exist] = File.exist? parent_path(destination)
32
+
33
+ to_path = join_paths @root_dir, destination
34
+ to_path_exist = File.exist? to_path
35
+
36
+ copy_options[:locked] = if to_path_exist
37
+ if destination_locked? to_path
38
+ true
39
+ else
40
+ to_path_parent = split_and_pop(path: to_path).join '/'
41
+ common_ancestor = common_path_ancestors(to_path, @ancestors).first
42
+ to_path_ancestors = ancestors_from_path_to_ancestor to_path, common_ancestor
43
+
44
+ locking_ancestor? to_path_parent, to_path_ancestors
45
+ end
46
+ else
47
+ false
48
+ end
49
+
50
+ if copy_options[:ancestor_exist]
51
+ if !overwrite && to_path_exist
52
+ copy_options[:can_copy] = false
53
+ else
54
+ copy_options[:can_copy] = true
55
+ end
56
+ end
57
+
58
+ copy_options
59
+ end
60
+
61
+ def collection?
62
+ File.directory? @src_path
63
+ end
64
+
65
+ def copy(options)
66
+ destination = options[:destination].tap { |s| s.slice! @mount_point }
67
+ preserve_existing = is_false? options[:overwrite]
68
+
69
+ to_path = join_paths @root_dir, destination
70
+ to_path_exists = File.exist? to_path
71
+
72
+ if collection?
73
+ FileUtils.cp_r @src_path, to_path, preserve: preserve_existing
74
+ else
75
+ FileUtils.cp @src_path, to_path, preserve: preserve_existing
76
+ end
77
+
78
+ if store_exist? && preserve_existing
79
+ dest_store_path = collection? ? "#{to_path}/#{@name}" : to_path
80
+ dest_store_path += ".pstore"
81
+
82
+ FileUtils.cp @store_path, dest_store_path, preserve: preserve_existing
83
+ end
84
+
85
+ to_path_exists
86
+ end
87
+
88
+ def create_collection
89
+ Dir.mkdir @src_path
90
+ end
91
+
92
+ def delete_collection
93
+ FileUtils.rm_r @src_path
94
+ FileUtils.rm_r @store_path if store_exist?
95
+ end
96
+
97
+ def etag
98
+ [@updated_at.to_i, @stats[:inode], @stats[:size]].join('-').to_s
99
+ end
100
+
101
+ def exists?
102
+ File.exist? @src_path
103
+ end
104
+
105
+ def lock(nodes, depth='infinity')
106
+ properties = {}
107
+
108
+ nodes.each do |node|
109
+ next unless node.is_a? Nokogiri::XML::Element
110
+ properties[node.name.to_sym] = node
111
+ end
112
+
113
+ unless exists?
114
+ write ''
115
+ @name = File.basename @src_path
116
+ init_pstore
117
+ end
118
+
119
+ create_lock properties, depth
120
+ end
121
+
122
+ def lock_is_exclusive?
123
+ lockscope == 'exclusive'
124
+ end
125
+
126
+ def lock_tokens
127
+ get_lock_info
128
+ @lock_info&.each { |x| x }&.map { |k, v| k[:locktoken].children[0].text }
129
+ end
130
+
131
+ def locked?
132
+ get_lock_info
133
+ obj_exists_and_is_not_type? obj: @lock_info, type: []
134
+ end
135
+
136
+ def locked_to_user?(headers=nil)
137
+ if locked?
138
+ !can_unlock? headers
139
+ else
140
+ locking_ancestor? @ancestor_path, @ancestors.dup, headers
141
+ end
142
+ end
143
+
144
+ def propfind(nodes)
145
+ properties = { found: [], not_found: [] }
146
+
147
+ nodes.each do |node|
148
+ node.children.each do |prop|
149
+ next unless prop.is_a? Nokogiri::XML::Element
150
+
151
+ value = get_property prop
152
+
153
+ if value.nil?
154
+ properties[:not_found].push prop
155
+ elsif value.is_a? Hash
156
+ value.each_key do |key|
157
+ properties[:found].push value[key]
158
+ end
159
+ else
160
+ properties[:found].push value
161
+ end
162
+ end
163
+ end
164
+
165
+ properties
166
+ end
167
+
168
+ def proppatch(nodes)
169
+ actions = { set: [], remove: [] }
170
+
171
+ @store.transaction do
172
+ @store[:properties] = {} unless @store[:properties].is_a? Hash
173
+
174
+ nodes.each do |node|
175
+ if node.name == 'set'
176
+ node.children.each do |prop|
177
+ prop.children.each do |property|
178
+ prop_sym = property.name.to_sym
179
+ node = Calligraphy::XML::Node.new property
180
+
181
+ if @store[:properties][prop_sym]
182
+ if @store[:properties][prop_sym].is_a? Array
183
+ unless matching_namespace? @store[:properties][prop_sym], node
184
+ @store[:properties][prop_sym].push node
185
+ end
186
+ else
187
+ if !same_namespace? @store[:properties][prop_sym], node
188
+ @store[:properties][prop_sym] = [@store[:properties][prop_sym]]
189
+ @store[:properties][prop_sym].push node
190
+ else
191
+ @store[:properties][prop_sym] = node
192
+ end
193
+ end
194
+ else
195
+ @store[:properties][prop_sym] = node
196
+ end
197
+
198
+ actions[:set].push property
199
+ end
200
+ end
201
+ elsif node.name == 'remove'
202
+ node.children.each do |prop|
203
+ prop.children.each do |property|
204
+ @store[:properties].delete property.name.to_sym
205
+
206
+ actions[:remove].push property
207
+ end
208
+ end
209
+ end
210
+ end
211
+ end
212
+
213
+ get_custom_property nil
214
+ actions
215
+ end
216
+
217
+ def read
218
+ @contents ||= File.read @src_path if readable?
219
+ end
220
+
221
+ def refresh_lock
222
+ if locked?
223
+ @store.transaction do
224
+ @store[:lockdiscovery][-1][:timeout] = timeout_node
225
+ end
226
+
227
+ get_lock_info
228
+ else
229
+ refresh_ancestor_locks @ancestor_path, @ancestors.dup
230
+ end
231
+ end
232
+
233
+ def unlock(token)
234
+ if lock_tokens.include? token
235
+ remove_lock token
236
+ :no_content
237
+ else
238
+ :forbidden
239
+ end
240
+ end
241
+
242
+ def write(contents=@request_body.to_s)
243
+ @contents = contents
244
+
245
+ File.open(@src_path, 'w') do |file|
246
+ file.write @contents
247
+ end
248
+ end
249
+
250
+ private
251
+
252
+ def init_pstore
253
+ pstore_path = collection? ? "#{@src_path}/#{@name}" : @src_path
254
+ @store = PStore.new "#{pstore_path}.pstore"
255
+ @store_path = @store.instance_variable_get :@filename
256
+ end
257
+
258
+ def set_file_stats
259
+ file_stats = File.stat @src_path
260
+
261
+ @stats = {
262
+ created_at: file_stats.ctime,
263
+ inode: file_stats.ino,
264
+ size: file_stats.size,
265
+ }
266
+ @updated_at = file_stats.mtime
267
+ end
268
+
269
+ def set_ancestors
270
+ @ancestors = split_and_pop path: @request_path
271
+ @ancestor_path = join_paths @root_dir, @ancestors.join('/')
272
+ end
273
+
274
+ def parent_path(path)
275
+ join_paths @root_dir, split_and_pop(path: path)
276
+ end
277
+
278
+ def destination_locked?(path)
279
+ store = PStore.new "#{path}.pstore"
280
+ lock = store.transaction(true) { store[:lockdiscovery] }
281
+
282
+ obj_exists_and_is_not_type? obj: lock, type: {}
283
+ end
284
+
285
+ def common_path_ancestors(path, ancestors)
286
+ [].tap do |common|
287
+ ancestors.each do |ancestor|
288
+ split_path = path.split ancestor
289
+ common.push ancestor if split_path.length > 1
290
+ end
291
+ end
292
+ end
293
+
294
+ def ancestors_from_path_to_ancestor(path, stop_at_ancestor)
295
+ path = split_and_pop path: path
296
+ ancestors = []
297
+
298
+ until path.last == stop_at_ancestor
299
+ ancestors.push path.pop
300
+ end
301
+
302
+ ancestors.push stop_at_ancestor
303
+ ancestors.reverse
304
+ end
305
+
306
+ def store_exist?
307
+ File.exist? @store_path
308
+ end
309
+
310
+ def create_lock(properties, depth)
311
+ @store.transaction do
312
+ @store[:lockcreator] = client_nonce
313
+ @store[:lockdiscovery] = [] unless @store[:lockdiscovery].is_a? Array
314
+ @store[:lockdepth] = depth
315
+
316
+ activelock = {}
317
+ activelock[:locktoken] = create_lock_token
318
+ activelock[:timeout] = timeout_node
319
+
320
+ properties.each_key do |prop|
321
+ activelock[prop] = Calligraphy::XML::Node.new properties[prop]
322
+ end
323
+
324
+ @store[:lockdiscovery].push activelock
325
+ end
326
+
327
+ get_lock_info
328
+ end
329
+
330
+ def create_lock_token
331
+ token = Calligraphy::XML::Node.new
332
+ token.name = 'locktoken'
333
+
334
+ href = Calligraphy::XML::Node.new
335
+ href.name = 'href'
336
+ href.text = ['urn', 'uuid', SecureRandom.uuid].join ':'
337
+
338
+ token.children = [href]
339
+ token
340
+ end
341
+
342
+ def timeout_node
343
+ Calligraphy::XML::Node.new.tap do |node|
344
+ node.name = 'timeout'
345
+ node.text = ['Second', Calligraphy.lock_timeout_period].join '-'
346
+ end
347
+ end
348
+
349
+ def get_lock_info
350
+ return nil if @store.nil?
351
+
352
+ @lock_info = @store.transaction(true) { @store[:lockdiscovery] }
353
+ @lock_info.nil? ? nil : map_array_of_hashes(@lock_info)
354
+ end
355
+
356
+ def lockscope
357
+ @lock_info[-1][:lockscope].children[0].name
358
+ end
359
+
360
+ def can_unlock?(headers=nil)
361
+ token = unless headers.nil?
362
+ extract_lock_token(headers['If']) if headers['If']
363
+ end
364
+
365
+ lock_tokens.include? token
366
+ end
367
+
368
+ def locking_ancestor?(ancestor_path, ancestors, headers=nil)
369
+ ancestor_store_path = "#{ancestor_path}/#{ancestors[-1]}.pstore"
370
+ check_lock_creator = Calligraphy.enable_digest_authentication
371
+ blocking_lock = false
372
+ unlockable = true
373
+
374
+ ancestors.pop
375
+
376
+ if File.exist? ancestor_store_path
377
+ ancestor_store = PStore.new ancestor_store_path
378
+ ancestor_lock_depth = ancestor_store.transaction(true) do
379
+ ancestor_store[:lockdepth]
380
+ end
381
+
382
+ ancestor_lock = ancestor_store.transaction(true) do
383
+ ancestor_store[:lockdiscovery]
384
+ end
385
+
386
+ ancestor_lock_creator = ancestor_store.transaction(true) do
387
+ ancestor_store[:lockcreator]
388
+ end if check_lock_creator
389
+
390
+ blocking_lock = obj_exists_and_is_not_type? obj: ancestor_lock, type: []
391
+
392
+ if blocking_lock
393
+ token = unless headers.nil?
394
+ extract_lock_token(headers['If']) if headers['If']
395
+ end
396
+
397
+ ancestor_lock_tokens = ancestor_lock
398
+ .each { |x| x }
399
+ .map { |k, v| k[:locktoken].children[0].text }
400
+
401
+ unlockable = ancestor_lock_tokens.include?(token) ||
402
+ (check_lock_creator && (ancestor_lock_creator == client_nonce))
403
+ end
404
+ end
405
+
406
+ if blocking_lock || ancestors.empty?
407
+ @locking_ancestor = {
408
+ depth: ancestor_lock_depth,
409
+ info: ancestor_lock
410
+ }
411
+
412
+ return unlockable ? false : true
413
+ end
414
+
415
+ next_ancestor = split_and_pop(path: ancestor_path).join '/'
416
+ locking_ancestor? next_ancestor, ancestors, headers
417
+ end
418
+
419
+ def get_property(prop)
420
+ case prop.name
421
+ when 'creationdate'
422
+ prop.content = @stats[:created_at]
423
+ when 'displayname'
424
+ prop.content = @name
425
+ when 'getcontentlength'
426
+ prop.content = @stats[:size]
427
+ when 'getlastmodified'
428
+ prop.content = @updated_at
429
+ when 'resourcetype'
430
+ prop.content = 'collection'
431
+ when 'lockdiscovery'
432
+ return get_lock_info
433
+ else
434
+ return get_custom_property prop.name
435
+ end
436
+
437
+ prop
438
+ end
439
+
440
+ def get_custom_property(prop)
441
+ @store_properties ||= @store.transaction(true) { @store[:properties] }
442
+ @store_properties[prop.to_sym] unless @store_properties.nil? || prop.nil?
443
+ end
444
+
445
+ def matching_namespace?(node_arr, node)
446
+ node_arr.select { |x| x.namespace.href == node.namespace.href }.length > 0
447
+ end
448
+
449
+ def same_namespace?(node1, node2)
450
+ node1.namespace.href == node2.namespace.href
451
+ end
452
+
453
+ def refresh_ancestor_locks(ancestor_path, ancestors)
454
+ ancestor_store_path = "#{ancestor_path}/#{ancestors[-1]}.pstore"
455
+ ancestors.pop
456
+
457
+ if File.exist? ancestor_store_path
458
+ ancestor_store = PStore.new ancestor_store_path
459
+ ancestor_lock = ancestor_store.transaction(true) do
460
+ ancestor_store[:lockdiscovery][-1][:timeout] = timeout_node
461
+ ancestor_store[:lockdiscovery]
462
+ end
463
+
464
+ return map_array_of_hashes ancestor_lock
465
+ end
466
+
467
+ next_ancestor = split_and_pop(path: ancestor_path).join '/'
468
+ refresh_ancestor_locks next_ancestor, ancestors
469
+ end
470
+
471
+ def remove_lock(token)
472
+ @store.transaction do
473
+ @store.delete :lockcreator
474
+
475
+ if @store[:lockdiscovery].length == 1
476
+ @store.delete :lockdiscovery
477
+ else
478
+ @store[:lockdiscovery] = @store[:lockdiscovery].reject do |activelock|
479
+ activelock[:locktoken].children[0].text == token
480
+ end
481
+ end
482
+ end
483
+
484
+ @lock_info = nil
485
+ end
486
+ end
487
+ end