calligraphy 0.2.0

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