redmine_dmsf/lib/redmine_dmsf/webdav/dmsf_resource.rb
2016-11-08 10:15:24 +01:00

739 lines
29 KiB
Ruby

# encoding: utf-8
#
# Redmine plugin for Document Management System "Features"
#
# Copyright (C) 2012 Daniel Munn <dan.munn@munnster.co.uk>
# Copyright (C) 2011-16 Karel Pičman <karel.picman@kontron.com>
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
require 'uuidtools'
require 'addressable/uri'
module RedmineDmsf
module Webdav
class DmsfResource < BaseResource
def initialize(*args)
@folder = nil
@file = nil
super(*args)
end
# Here we make sure our folder and file methods are not aliased - it should shave a few cycles off of processing
def setup
@skip_alias |= [ :folder, :file ]
end
# Here we hook into the fact that resources can have a pre-execution routine run for them
# Our sole job here is to ensure that any write functionality is restricted to relevent configuration
before do |resource, method_name|
# If our method is not one of the following, there is no point continuing.
if [ :put, :make_collection, :move, :copy, :delete, :lock, :unlock, :set_property ].include?(method_name)
webdav_setting = Setting.plugin_redmine_dmsf['dmsf_webdav_strategy']
webdav_setting = 'WEBDAV_READ_ONLY' unless webdav_setting
raise BadGateway if webdav_setting == 'WEBDAV_READ_ONLY'
end
end
# Gather collection of objects that denote current entities child entities
# Used for listing directories etc, implemented basic caching because otherwise
# Our already quite heavy usage of DB would just get silly every time we called
# this method.
def children
unless @children
@children = []
if collection?
folder.dmsf_folders.select(:title).visible.map do |p|
@children.push child(p.title)
end
folder.dmsf_files.select(:name).visible.map do |p|
@children.push child(p.name)
end
end
end
@children
end
# Does the object exist?
# If it is either a folder or a file, then it exists
# - 2012-06-15: Only if you're allowed to browse the project
# - 2012-06-18: Issue #5, ensure item is only listed if project is enabled for dmsf
def exist?
return project && project.module_enabled?('dmsf') && (folder || file) &&
(User.current.admin? || User.current.allowed_to?(:view_dmsf_folders, project))
end
# Is this entity a folder?
def collection?
folder.present? # No need to check if entity exists, as false is returned if entity does not exist anyways
end
# Check if current entity is a folder and return DmsfFolder object if found (nil if not)
def folder
unless @folder
return nil unless project # If the project doesn't exist, this entity can't exist
# Note: Folder is searched for as a generic search to prevent SQL queries being generated:
# if we were to look within parent, we'd have to go all the way up the chain as part of the
# existence check, and although I'm sure we'd love to access the hierarchy, I can't yet
# see a practical need for it
folders = DmsfFolder.visible.where(:project_id => project.id, :title => basename).order('title ASC').to_a
return nil unless folders.length > 0
if (folders.length > 1)
folders.delete_if { |x| '/' + x.dmsf_path_str != projectless_path }
return nil unless folders.length > 0
@folder = folders[0]
else
if (('/' + folders[0].dmsf_path_str) == projectless_path)
@folder = folders[0]
end
end
end
@folder
end
# Check if current entity exists as a file (DmsfFile), and returns corresponding object if found (nil otherwise)
# Currently has a dual search approach (depending on if parent can be determined)
def file
unless @file
return nil unless project # Again if entity project is nil, it cannot exist in context of this object
# Hunt for files parent path
f = false
if (parent.projectless_path != '/')
f = parent.folder if parent.folder
else
f = nil
end
if f || f.nil?
# f has a value other than false? - lets use traditional
# DMSF file search by name.
@file = DmsfFile.visible.find_file_by_name(project, f, basename)
else
# If folder is false, means it couldn't pick up parent,
# as such its probably fine to bail out, however we'll
# perform a search in this scenario
files = DmsfFile.visible.where(:project_id => project.id, :name => basename).order('name ASC').to_a
files.delete_if { |x| File.dirname('/' + x.dmsf_path_str) != File.dirname(projectless_path) }
@file = files[0] if files.length > 0
end
end
@file
end
# Return the content type of file
# will return inode/directory for any collections, and appropriate for File entities
def content_type
if folder
'inode/directory'
elsif file && file.last_revision
file.last_revision.detect_content_type
else
NotFound
end
end
def creation_date
if folder
folder.created_at
elsif file
file.created_at
else
NotFound
end
end
def last_modified
if folder
folder.updated_at
elsif file && file.last_revision
file.last_revision.updated_at
else
NotFound
end
end
def etag
filesize = file ? file.size : 4096;
fileino = (file && file.last_revision && File.exist?(file.last_revision.disk_file)) ? File.stat(file.last_revision.disk_file).ino : 2;
sprintf('%x-%x-%x', fileino, filesize, last_modified.to_i)
end
def content_length
file ? file.size : 4096;
end
def special_type
l(:field_folder) if folder
end
# Process incoming GET request
#
# If instance is a collection, calls html_display (defined in base_resource.rb) which cycles through children for display
# File will only be presented for download if user has permission to view files
##
def get(request, response)
raise NotFound unless exist?
if collection?
html_display
response['Content-Length'] = response.body.bytesize.to_s
else
raise Forbidden unless User.current.admin? || User.current.allowed_to?(:view_dmsf_files, project)
response.body = download # Rack based provider
end
OK
end
# Process incoming HEAD request
#
# MsOFfice uses anonymous HEAD requests, so always return a response.
# See https://support.microsoft.com/en-us/kb/2019105
##
def head(request, response)
raise NotFound unless project && project.module_enabled?('dmsf') && (folder || file)
if collection?
html_display(true)
response['Content-Length'] = response.body.bytesize.to_s
else
response.body = ''
end
OK
end
# Process incoming MKCOL request
#
# Create a DmsfFolder at location requested, only if parent is a folder (or root)
# - 2012-06-18: Ensure item is only functional if project is enabled for dmsf
def make_collection
if request.body.read.to_s.empty?
raise NotFound unless project && project.module_enabled?('dmsf')
raise Forbidden unless User.current.admin? || User.current.allowed_to?(:folder_manipulation, project)
return MethodNotAllowed if exist? # If we already exist, why waste the time trying to save?
parent_folder = nil
if (parent.projectless_path != '/')
return Conflict unless parent.folder
parent_folder = parent.folder.id
end
f = DmsfFolder.new
f.title = basename
f.dmsf_folder_id = parent_folder
f.project = project
f.user = User.current
f.save ? OK : Conflict
else
UnsupportedMediaType
end
end
# Process incoming DELETE request
#
# <instance> should be of entity to be deleted, we simply follow the Dmsf entity method
# for deletion and return of appropriate status based on outcome.
def delete
if file
raise Forbidden unless User.current.admin? || User.current.allowed_to?(:file_delete, project)
if file.name.match(/.\.tmp$/i)
# .tmp files should be destroyed (MsOffice file)
destroy = true
elsif file.name.match(/^\~\$/i)
# Files starting with ~$ should be destroyed (MsOffice file)
destroy = true
elsif file.last_revision.size == 0
# Zero-sized files should be destroyed
destroy = true
else
destroy = false
end
file.delete(destroy) ? NoContent : Conflict
elsif folder
raise Forbidden unless User.current.admin? || User.current.allowed_to?(:folder_manipulation, project)
folder.delete(false) ? NoContent : Conflict
else
MethodNotAllowed
end
end
# Process incoming MOVE request
#
# Behavioural differences between collection and single entity
# TODO: Support overwrite between both types of entity, and implement better checking
def move(dest, overwrite)
# All of this should carry accrross the ResourceProxy frontend, we ensure this to
# prevent unexpected errors
resource = dest.is_a?(ResourceProxy) ? dest.resource : dest
return PreconditionFailed if !resource.is_a?(DmsfResource) || resource.project.nil? || resource.project.id == 0
parent = resource.parent
if collection?
# At the moment we don't support cross project destinations
return MethodNotImplemented unless project.id == resource.project.id
raise Forbidden unless User.current.admin? || User.current.allowed_to?(:folder_manipulation, project)
# Current object is a folder, so now we need to figure out information about Destination
if dest.exist?
MethodNotAllowed
else
if(parent.projectless_path == '/') #Project root
folder.dmsf_folder_id = nil
else
return PreconditionFailed unless parent.exist? && parent.folder
folder.dmsf_folder_id = parent.folder.id
end
folder.title = resource.basename
folder.save ? Created : PreconditionFailed
end
else
raise Forbidden unless User.current.admin? ||
User.current.allowed_to?(:folder_manipulation, project) ||
User.current.allowed_to?(:folder_manipulation, resource.project)
if dest.exist?
if (project == resource.project) && file.name.match(/.\.tmp$/i)
# Renaming a *.tmp file to an existing file in the same project, probably Office that is saving a file.
Rails.logger.info "WebDAV MOVE: #{file.name} -> #{resource.basename} (exists), possible MSOffice rename from .tmp when saving"
if resource.file.last_revision.size == 0
# Last revision in the destination has zero size so reuse that revision
new_revision = resource.file.last_revision
else
# Create a new revison by cloning the last revision in the destination
new_revision = resource.file.last_revision.clone
new_revision.increase_version(1)
end
# The file on disk must be renamed from .tmp to the correct filetype or else Xapian won't know how to index.
# Copy file.last_revision.disk_file to new_revision.disk_file
new_revision.size = file.last_revision.size
new_revision.disk_filename = new_revision.new_storage_filename
Rails.logger.info "WebDAV MOVE: Copy file #{file.last_revision.disk_filename} -> #{new_revision.disk_filename}"
File.open(file.last_revision.disk_file, 'rb') do |f|
new_revision.copy_file_content(f)
end
# Save
new_revision.save && resource.file.save
# Delete (and destroy) the file that should have been renamed and return what should have been returned in case of a copy
file.delete(true) ? Created : PreconditionFailed
else
# Files cannot be merged at this point, until a decision is made on how to merge them
# ideally, we would merge revision history for both, ensuring the origin file wins with latest revision.
MethodNotAllowed
end
else
if(parent.projectless_path == '/') #Project root
f = nil
else
return PreconditionFailed unless parent.exist? && parent.folder
f = parent.folder
end
return PreconditionFailed unless exist? && file
if (project == resource.project) && resource.basename.match(/.\.tmp$/i)
Rails.logger.info "WebDAV MOVE: #{file.name} -> #{resource.basename}, possible MSOffice rename to .tmp when saving."
# Renaming the file to X.tmp, might be Office that is saving a file. Keep the original file.
return InternalServerError unless file.copy_to(resource.project, f)
else
return InternalServerError unless file.move_to(resource.project, f)
# Update Revision and names of file [We can link to old physical resource, as it's not changed]
if file.last_revision
file.last_revision.name = resource.basename
file.last_revision.title = DmsfFileRevision.filename_to_title(resource.basename)
end
file.name = resource.basename
# Save Changes
(file.last_revision.save! && file.save!) ? Created : PreconditionFailed
end
end
end
end
# Process incoming COPY request
#
# Behavioural differences between collection and single entity
# TODO: Support overwrite between both types of entity, and an integrative copy where destination exists for collections
def copy(dest, overwrite)
# All of this should carry across the ResourceProxy frontend, we ensure this to
# prevent unexpected errors
if dest.is_a?(ResourceProxy)
resource = dest.resource
else
resource = dest
end
return PreconditionFailed if !resource.is_a?(DmsfResource) || resource.project.nil? || resource.project.id == 0
parent = resource.parent
if collection?
# Current object is a folder, so now we need to figure out information about Destination
return MethodNotAllowed if(dest.exist?)
# Permission check if they can manipulate folders and view folders
# Can they:
# Manipulate folders on destination project :folder_manipulation
# View folders on destination project :view_dmsf_folders
# View files on the source project :view_dmsf_files
# View fodlers on the source project :view_dmsf_folders
raise Forbidden unless User.current.admin? ||
(User.current.allowed_to?(:folder_manipulation, resource.project) &&
User.current.allowed_to?(:view_dmsf_folders, resource.project) &&
User.current.allowed_to?(:view_dmsf_files, project) &&
User.current.allowed_to?(:view_dmsf_folders, project))
return PreconditionFailed if (parent.projectless_path != '/' && !parent.folder)
folder.title = resource.basename
new_folder = folder.copy_to(resource.project, parent.folder)
return PreconditionFailed if new_folder.nil? || new_folder.id.nil?
Created
else
if dest.exist?
methodNotAllowed
# Files cannot be merged at this point, until a decision is made on how to merge them
# ideally, we would merge revision history for both, ensuring the origin file wins with latest revision.
else
# Permission check if they can manipulate folders and view folders
# Can they:
# Manipulate files on destination project :file_manipulation
# View files on destination project :view_dmsf_files
# View files on the source project :view_dmsf_files
raise Forbidden unless User.current.admin? ||
(User.current.allowed_to?(:file_manipulation, resource.project) &&
User.current.allowed_to?(:view_dmsf_files, resource.project) &&
User.current.allowed_to?(:view_dmsf_files, project))
if(parent.projectless_path == '/') #Project root
f = nil
else
return PreconditionFailed unless parent.exist? && parent.folder
f = parent.folder
end
return PreconditionFailed unless exist? && file
return InternalServerError unless file.copy_to(resource.project, f)
# Update Revision and names of file [We can link to old physical resource, as it's not changed]
file.last_revision.name = resource.basename if file.last_revision
file.name = resource.basename
# Save Changes
(file.last_revision.save! && file.save!) ? Created : PreconditionFailed
end
end
end
# Lock Check
# Check for the existence of locks
# At present as deletions of folders are not recursive, we do not need to extend
# this to cover every file, just queried
def lock_check(lock_scope = nil)
if file
raise Locked if file.locked_for_user?
elsif folder
raise Locked if folder.locked_for_user?
end
end
# Lock
def lock(args)
unless (parent.projectless_path == '/' || parent_exists?)
e = DAV4Rack::LockFailure.new
e.add_failure @path, Conflict
raise e
end
unless self.exist?
e = DAV4Rack::LockFailure.new
e.add_failure @path, NotFound
raise e
end
lock_check(args[:scope])
entity = file ? file : folder
begin
if (entity.locked? && entity.locked_for_user?)
raise DAV4Rack::LockFailure.new("Failed to lock: #{@path}")
else
# If scope and type are not defined, the only thing we can
# logically assume is that the lock is being refreshed (office loves
# to do this for example, so we do a few checks, try to find the lock
# and ultimately extend it, otherwise we return Conflict for any failure
if (!args[:scope] && !args[:type]) #Perhaps a lock refresh
http_if = request.env['HTTP_IF']
if http_if.nil?
e = DAV4Rack::LockFailure.new
e.add_failure @path, Conflict
raise e
end
http_if = http_if.slice(1, http_if.length - 2)
l = DmsfLock.find(http_if)
unless l
e = DAV4Rack::LockFailure.new
e.add_failure @path, Conflict
raise e
end
l.expires_at = Time.now + 1.hour
l.save!
@response['Lock-Token'] = l.uuid
return [1.hours.to_i, l.uuid]
end
scope = "scope_#{(args[:scope] || 'exclusive')}".to_sym
type = "type_#{(args[:type] || 'write')}".to_sym
#l should be the instance of the lock we've just created
l = entity.lock!(scope, type, Time.now + 1.hours)
@response['Lock-Token'] = l.uuid
[1.hours.to_i, l.uuid]
end
rescue DmsfLockError
e = DAV4Rack::LockFailure.new
e.add_failure @path, Conflict
raise e
end
end
# Unlock
# Token based unlock (authenticated) will ensure that a correct token is sent, further ensuring
# ownership of token before permitting unlock
def unlock(token)
return NotFound unless exist?
if (token.nil? || token.empty? || token == "<(null)>" || User.current.anonymous?)
BadRequest
else
token = token.slice(1, token.length - 2)
begin
entity = file ? file : folder
l = DmsfLock.find(token)
return NoContent unless l
# Additional case: if a user tries to unlock the file instead of the folder that's locked
# This should throw forbidden as only the lock at level initiated should be unlocked
return NoContent unless entity.locked?
l_entity = l.file || l.folder
if (entity.locked_for_user? || l_entity != entity)
Forbidden
else
entity.unlock!
NoContent
end
rescue
BadRequest
end
end
end
# HTTP POST request.
#
# Forbidden, as method should not be utilized.
def post(request, response)
raise Forbidden
end
# HTTP PUT request.
def put(request, response)
raise BadRequest if collection?
raise Forbidden unless User.current.admin? || User.current.allowed_to?(:file_manipulation, project)
# Ignore Mac OS X resource forks and special Windows files.
if basename.match(/^\._/) || basename.match(/^\.DS_Store$/i) || basename.match(/^Thumbs.db$/i)
Rails.logger.info "#{basename} ignored"
return NoContent
end
new_revision = DmsfFileRevision.new
reuse_revision = false
if exist? # We're over-writing something, so ultimately a new revision
f = file
last_revision = file.last_revision
if last_revision.size == 0
new_revision = last_revision
new_revision.minor_version -= 1
reuse_revision = true
else
new_revision.source_revision = last_revision
if last_revision
new_revision.major_version = last_revision.major_version
new_revision.minor_version = last_revision.minor_version
new_revision.workflow = last_revision.workflow
end
end
else
raise BadRequest unless (parent.projectless_path == '/' || (parent.exist? && parent.folder))
f = DmsfFile.new
f.project = project
f.name = basename
f.dmsf_folder = parent.folder
f.notification = !Setting.plugin_redmine_dmsf['dmsf_default_notifications'].blank?
new_revision.minor_version = 0
new_revision.major_version = 0
end
new_revision.dmsf_file = f
new_revision.user = User.current
new_revision.name = basename
new_revision.title = DmsfFileRevision.filename_to_title(basename)
new_revision.description = nil
new_revision.comment = nil
new_revision.increase_version(1)
new_revision.mime_type = Redmine::MimeType.of(new_revision.name)
# Phusion passenger does not have a method "length" in its model
# however, includes a size method - so we instead use reflection
# to determine best approach to problem
if request.body.respond_to? 'length'
new_revision.size = request.body.length
elsif request.body.respond_to? 'size'
new_revision.size = request.body.size
else
new_revision.size = request.content_length # Bad Guess
end
raise InternalServerError unless new_revision.valid? && f.save
new_revision.disk_filename = new_revision.new_storage_filename unless reuse_revision
if new_revision.save
new_revision.copy_file_content(request.body)
else
raise InternalServerError
end
Created
end
# get_property
# Overriding the base definition (extending it really) with functionality
# for lock information to be presented
def get_property(element)
raise NotImplemented if (element[:ns_href] != 'DAV:')
unless folder
return NotFound unless (file && file.last_revision)
end
case element[:name]
when 'supportedlock'
supportedlock
when 'lockdiscovery'
lockdiscovery
else
super
end
end
# Available properties
def properties
%w(creationdate displayname getlastmodified getetag resourcetype getcontenttype getcontentlength supportedlock lockdiscovery).collect do |prop|
{:name => prop, :ns_href => 'DAV:'}
end
end
def options_req
response["Allow"] = 'OPTIONS,HEAD,GET,PUT,POST,DELETE,PROPFIND,PROPPATCH,MKCOL,COPY,MOVE,LOCK,UNLOCK'
response["Dav"] = '1, 2'
response["Ms-Author-Via"] = "DAV"
OK
end
private
# Prepare file for download using Rack functionality:
# Download (see RedmineDmsf::Webdav::Download) extends Rack::File to allow single-file
# implementation of service for request, which allows for us to pipe a single file through
# also best-utilising DAV4Rack's implementation.
def download
raise NotFound unless (file && file.last_revision && file.last_revision.disk_file)
# If there is no range (start of ranged download, or direct download) then we log the
# file access, so we can properly keep logged information
if @request.env['HTTP_RANGE'].nil?
access = DmsfFileRevisionAccess.new
access.user = User.current
access.dmsf_file_revision = file.last_revision
access.action = DmsfFileRevisionAccess::DownloadAction
access.save!
end
Download.new(file.last_revision.disk_file)
end
# As the name suggests, we're returning lock recovery information for requested resource
def lockdiscovery
x = Nokogiri::XML::DocumentFragment.parse ''
entity = file || folder
return nil unless entity.locked?
if entity.dmsf_folder && entity.dmsf_folder.locked?
locks = entity.lock.reverse[0].dmsf_folder.locks(false) # longwinded way of getting base items locks
else
locks = entity.lock(false)
end
Nokogiri::XML::Builder.with(x) do |doc|
doc.lockdiscovery {
locks.each do |lock|
next if lock.expired?
doc.activelock {
doc.locktype { doc.write }
doc.lockscope {
if lock.lock_scope == :scope_exclusive
doc.exclusive
else
doc.shared
end
}
doc.depth lock.folder.nil? ? '0' : 'infinity'
doc.owner lock.user.to_s
if lock.expires_at.nil?
doc.timeout 'Infinite'
else
doc.timeout "Second-#{(lock.expires_at.to_i - Time.now.to_i)}"
end
lock_entity = lock.folder || lock.file
lock_path = "#{request.scheme}://#{request.host}:#{request.port}#{path_prefix}#{Addressable::URI.escape(lock_entity.project.identifier)}/"
lock_path << lock_entity.dmsf_path.map { |e| Addressable::URI.escape(e.respond_to?('name') ? e.name : e.title) }.join('/')
lock_path << '/' if lock_entity.is_a?(DmsfFolder) && lock_path[-1,1] != '/'
doc.lockroot { doc.href lock_path }
if ((lock.user.id == User.current.id) || User.current.allowed_to?(:force_file_unlock, self.project))
doc.locktoken { doc.href lock.uuid }
end
}
end
}
end
x
end
# As the name suggests, we're returning locks supported by our implementation
def supportedlock
x = Nokogiri::XML::DocumentFragment.parse ''
Nokogiri::XML::Builder.with(x) do |doc|
doc.supportedlock {
doc.lockentry {
doc.lockscope { doc.exclusive }
doc.locktype { doc.write }
}
doc.lockentry {
doc.lockscope { doc.shared }
doc.locktype { doc.write }
}
}
end
x
end
end
end
end