diff --git a/Gemfile b/Gemfile index 3a1540e0..59ed86e7 100644 --- a/Gemfile +++ b/Gemfile @@ -27,6 +27,7 @@ gem 'zip-zip' gem 'simple_enum' gem 'uuidtools' gem 'dav4rack' +gem 'dalli' group :xapian do gem 'xapian-full-alaveteli', :require => false diff --git a/app/models/dmsf_file.rb b/app/models/dmsf_file.rb index 3ac5897a..a11810da 100644 --- a/app/models/dmsf_file.rb +++ b/app/models/dmsf_file.rb @@ -478,4 +478,33 @@ class DmsfFile < ActiveRecord::Base nil end + def save(*args) + RedmineDmsf::Webdav::Cache.invalidate_item(propfind_cache_key) + super(*args) + end + + def save!(*args) + RedmineDmsf::Webdav::Cache.invalidate_item(propfind_cache_key) + super(*args) + end + + def destroy + RedmineDmsf::Webdav::Cache.invalidate_item(propfind_cache_key) + super + end + + def destroy! + RedmineDmsf::Webdav::Cache.invalidate_item(propfind_cache_key) + super + end + + def propfind_cache_key + if dmsf_folder_id.nil? + # File is in project root + return "PROPFIND/#{project_id}" + else + return "PROPFIND/#{project_id}/#{dmsf_folder_id}" + end + end + end diff --git a/app/models/dmsf_file_revision.rb b/app/models/dmsf_file_revision.rb index 5f8f8958..84a002b3 100644 --- a/app/models/dmsf_file_revision.rb +++ b/app/models/dmsf_file_revision.rb @@ -109,6 +109,7 @@ class DmsfFileRevision < ActiveRecord::Base dependencies = DmsfFileRevision.where(:disk_filename => self.disk_filename).all.count File.delete(self.disk_file) if dependencies <= 1 && File.exist?(self.disk_file) end + RedmineDmsf::Webdav::Cache.invalidate_item(propfind_cache_key) super end @@ -290,5 +291,19 @@ class DmsfFileRevision < ActiveRecord::Base end ActionView::Base.full_sanitizer.sanitize(text) end + + def save(*args) + RedmineDmsf::Webdav::Cache.invalidate_item(propfind_cache_key) + super(*args) + end -end \ No newline at end of file + def save!(*args) + RedmineDmsf::Webdav::Cache.invalidate_item(propfind_cache_key) + super(*args) + end + + def propfind_cache_key + dmsf_file.propfind_cache_key + end + +end diff --git a/app/models/dmsf_folder.rb b/app/models/dmsf_folder.rb index de97c8ad..5bdfc1dd 100644 --- a/app/models/dmsf_folder.rb +++ b/app/models/dmsf_folder.rb @@ -295,7 +295,7 @@ class DmsfFolder < ActiveRecord::Base file_links.visible.count + url_links.visible.count end - + def self.is_column_on?(column) columns = Setting.plugin_redmine_dmsf['dmsf_columns'] columns = DmsfFolder::DEFAULT_COLUMNS unless columns @@ -395,6 +395,35 @@ class DmsfFolder < ActiveRecord::Base nil end + def save(*args) + RedmineDmsf::Webdav::Cache.invalidate_item(propfind_cache_key) + super(*args) + end + + def save!(*args) + RedmineDmsf::Webdav::Cache.invalidate_item(propfind_cache_key) + super(*args) + end + + def destroy + RedmineDmsf::Webdav::Cache.invalidate_item(propfind_cache_key) + super + end + + def destroy! + RedmineDmsf::Webdav::Cache.invalidate_item(propfind_cache_key) + super + end + + def propfind_cache_key + if dmsf_folder_id.nil? + # Folder is in project root + return "PROPFIND/#{project_id}" + else + return "PROPFIND/#{project_id}/#{dmsf_folder_id}" + end + end + private def self.directory_subtree(tree, folder, level, current_folder) @@ -406,4 +435,4 @@ class DmsfFolder < ActiveRecord::Base end end -end \ No newline at end of file +end diff --git a/app/views/settings/_dmsf_settings.html.erb b/app/views/settings/_dmsf_settings.html.erb index b810921a..da62bbb2 100644 --- a/app/views/settings/_dmsf_settings.html.erb +++ b/app/views/settings/_dmsf_settings.html.erb @@ -176,6 +176,13 @@ <%= l(:note_webdav_strategy).html_safe %> <%= l(:label_default) %>: <%= l(:select_option_webdav_readonly) %>

+

+ <%= content_tag(:label, l(:label_memcached_servers)) %> + <%= text_field_tag 'settings[dmsf_memcached_servers]', @settings['dmsf_memcached_servers'], :size => 50 %> + + <%= l(:text_memcached_servers) %> + +

<% end %>
diff --git a/config/locales/en.yml b/config/locales/en.yml index 12f5eac2..afa0d3ea 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -338,4 +338,8 @@ en: label_webdav: WebDAV label_full_text: Full-text search - link_extension: Ext \ No newline at end of file + link_extension: Ext + + label_memcached_servers: "memcached server address" + text_memcached_servers: "Address to memcached server. Only a single server is supported, if empty then caching is disabled. After changing this the server must be restarted!" + diff --git a/init.rb b/init.rb index a7a5090b..eb36c91c 100644 --- a/init.rb +++ b/init.rb @@ -47,7 +47,8 @@ Redmine::Plugin.register :redmine_dmsf do 'dmsf_webdav' => '1', 'dmsf_display_notified_recipients' => 0, 'dmsf_global_title_format' => '', - 'dmsf_columns' => %w(title size modified version workflow author) + 'dmsf_columns' => %w(title size modified version workflow author), + 'dmsf_memcached_servers' => '' } menu :project_menu, :dmsf, { :controller => 'dmsf', :action => 'show' }, :caption => :menu_dmsf, :before => :documents, :param => :id diff --git a/lib/redmine_dmsf.rb b/lib/redmine_dmsf.rb index 9fe7693d..f3484dec 100644 --- a/lib/redmine_dmsf.rb +++ b/lib/redmine_dmsf.rb @@ -35,6 +35,7 @@ require 'redmine_dmsf/patches/user_patch' # Load up classes that make up our WebDAV solution ontop of DAV4Rack require 'redmine_dmsf/webdav/base_resource' require 'redmine_dmsf/webdav/controller' +require 'redmine_dmsf/webdav/cache' require 'redmine_dmsf/webdav/dmsf_resource' require 'redmine_dmsf/webdav/download' require 'redmine_dmsf/webdav/index_resource' @@ -57,3 +58,7 @@ require 'redmine_dmsf/hooks/views/my_account_view_hooks' # Macros require 'redmine_dmsf/macros' + +# Add the plugin view folder into ActionMailer's paths to search +ActionMailer::Base.append_view_path(File.expand_path( + File.dirname(__FILE__) + '/../app/views')) diff --git a/lib/redmine_dmsf/webdav/cache.rb b/lib/redmine_dmsf/webdav/cache.rb new file mode 100644 index 00000000..f985c960 --- /dev/null +++ b/lib/redmine_dmsf/webdav/cache.rb @@ -0,0 +1,83 @@ +# encoding: utf-8 +# +# Redmine plugin for Document Management System "Features" +# +# Copyright (C) 2012 Daniel Munn +# Copyright (C) 2011-16 Karel Picman +# +# 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. + +module RedmineDmsf + module Webdav + class Cache + def self.read(name, options = nil) + init unless defined?(@@WebDAVCache) + @@WebDAVCache.read(name, options) + end + + def self.write(name, value, options = nil) + init unless defined?(@@WebDAVCache) + @@WebDAVCache.write(name, value, options) + end + + def self.delete(name, options = nil) + init unless defined?(@@WebDAVCache) + @@WebDAVCache.delete(name, options) + end + + def self.exist?(name, options = nil) + init unless defined?(@@WebDAVCache) + @@WebDAVCache.exist?(name, options) + end + + def self.invalidate_item(key) + init unless defined?(@@WebDAVCache) + # Write an .invalid entry to notify anyone that is currently creating a response + # that that response is invalid and should not be cached + @@WebDAVCache.write("#{key}.invalid", expires_in: 60.seconds) + # Delete any existing entry in the cache + @@WebDAVCache.delete(key) + end + + def self.cache + @@WebDAVCache + end + + def self.init_testcache + puts "Webdav::Cache: Enable MemoryStore cache." + @@WebDAVCache = ActiveSupport::Cache::MemoryStore.new(options={:namespace => "RedmineDmsfWebDAV"}) + end + + def self.init_nullcache + puts "Webdav::Cache: Disable cache." + @@WebDAVCache = ActiveSupport::Cache::NullStore.new + end + + private + + def self.init + if Setting.plugin_redmine_dmsf['dmsf_memcached_servers'].nil? || Setting.plugin_redmine_dmsf['dmsf_memcached_servers'].empty? + # Disable caching by using a null cache + Rails.logger.info "Webdav::Cache: Cache disabled!" + @@WebDAVCache = ActiveSupport::Cache::NullStore.new + else + # Create cache using the provided server address + Rails.logger.info "Webdav::Cache: Cache enabled, using memcached server '#{Setting.plugin_redmine_dmsf['dmsf_memcached_servers']}'" + @@WebDAVCache = ActiveSupport::Cache::MemCacheStore.new(Setting.plugin_redmine_dmsf['dmsf_memcached_servers'], options={:namespace => "RedmineDmsfWebDAV"}) + end + end + end + end +end \ No newline at end of file diff --git a/lib/redmine_dmsf/webdav/controller.rb b/lib/redmine_dmsf/webdav/controller.rb index a9a70e4e..dfbfaeea 100644 --- a/lib/redmine_dmsf/webdav/controller.rb +++ b/lib/redmine_dmsf/webdav/controller.rb @@ -107,18 +107,44 @@ module RedmineDmsf raise BadRequest end end - multistatus do |xml| - find_resources.each do |resource| - xml.response do - unless(resource.propstat_relative_path) - xml.href "#{scheme}://#{host}:#{port}#{url_format(resource)}" - else - xml.href url_format(resource) - end - propstats(xml, get_properties(resource, properties.empty? ? resource.properties : properties)) + + if depth != 0 + # Only use cache for requests with a depth>0, depth=0 responses are already fast. + pinfo = resource.path.split('/').drop(1) + if (pinfo.length == 0) # If this is the base_path, we're at root + # Don't know when projects are added/removed from the visibility list for this user, + # so don't cache root. + elsif (pinfo.length == 1) #This is first level, and as such, project path + propfind_key = "PROPFIND/#{resource.resource.project_id}" + else # We made it all the way to DMSF Data + if resource.collection? + # Only store collections in the cache since responses to files are simple and fast already. + propfind_key = "PROPFIND/#{resource.resource.project_id}/#{resource.resource.folder.id}" end end - end + end + + if propfind_key.nil? + # This PROPFIND is never cached so always create a new response + create_propfind_response(properties) + else + response.body = RedmineDmsf::Webdav::Cache.read(propfind_key) + if !response.body.nil? + # Found cached PROPFIND, fill in Content-Type and Content-Length + response["Content-Type"] = 'text/xml; charset="utf-8"' + response["Content-Length"] = response.body.size.to_s + else + # No cached PROPFIND found + # Remove .invalid entry for this propfind since we are now creating a new valid propfind + RedmineDmsf::Webdav::Cache.delete("#{propfind_key}.invalid") + create_propfind_response(properties) + + # Cache response.body, but only if no .invalid entry was stored while creating the propfind + RedmineDmsf::Webdav::Cache.write(propfind_key, response.body) unless RedmineDmsf::Webdav::Cache.exist?("#{propfind_key}.invalid") + end + end + # Return HTTP code. + MultiStatus end end @@ -179,6 +205,57 @@ module RedmineDmsf Addressable::URI.unescape str end + private + + def create_propfind_response(properties) + # Generate response, is stored in response.body + render_xml(:multistatus) do |xml| + find_resources.each do |resource| + if resource.collection? + # Index, Project or Folder + # path is unique enough for the key and is available for all three, and the path doesn't change + # for this path as long as it stays. On its path. The path does not stray from its path without + # changing its path. + propstats_key = "PROPSTATS/#{resource.path}" + else + # File + # Use file.id & file.last_revision.id as key + # When revision changes then the key will change and the old cached item will eventually be evicted + propstats_key = "PROPSTATS/#{resource.resource.file.id}-#{resource.resource.file.last_revision.id}" + end + + xml_str = RedmineDmsf::Webdav::Cache.read(propstats_key) + if xml_str.nil? + # Create the complete PROPSTATS response + propstats_builder = Nokogiri::XML::Builder.new do |propstats_xml| + propstats_xml.send('propstat', {'xmlns:D' => 'DAV:'}.merge(resource.root_xml_attributes)) do + propstats_xml.parent.namespace = propstats_xml.parent.namespace_definitions.first + xml2 = propstats_xml['D'] + + xml2.response do + unless(resource.propstat_relative_path) + xml2.href "#{scheme}://#{host}:#{port}#{url_format(resource)}" + else + xml2.href url_format(resource) + end + propstats(xml2, get_properties(resource, properties.empty? ? resource.properties : properties)) + end + end + end + + # Just want to add the <:D:response> so extract it. + # Q: Is there a better/faster way to do this? + xml_str = Nokogiri::XML.parse(propstats_builder.to_xml).xpath('//D:response').first.to_xml + + # Add PROPSTATS to cache + # Caching the PROPSTATS response as xml text string. + RedmineDmsf::Webdav::Cache.write(propstats_key, xml_str) + end + xml << xml_str + end + end + end + end end end diff --git a/lib/redmine_dmsf/webdav/dmsf_resource.rb b/lib/redmine_dmsf/webdav/dmsf_resource.rb index d3fc6cd6..cc0b6440 100644 --- a/lib/redmine_dmsf/webdav/dmsf_resource.rb +++ b/lib/redmine_dmsf/webdav/dmsf_resource.rb @@ -79,6 +79,10 @@ module RedmineDmsf def really_exist? return project && project.module_enabled?('dmsf') && (folder || file) end + + def project_id + project.id unless project.nil? + end # Is this entity a folder? def collection? diff --git a/lib/redmine_dmsf/webdav/project_resource.rb b/lib/redmine_dmsf/webdav/project_resource.rb index 932d7ca3..371f67dc 100644 --- a/lib/redmine_dmsf/webdav/project_resource.rb +++ b/lib/redmine_dmsf/webdav/project_resource.rb @@ -78,6 +78,10 @@ module RedmineDmsf def long_name project.name unless project.nil? end + + def project_id + project.id unless project.nil? + end def content_type 'inode/directory' diff --git a/lib/redmine_dmsf/webdav/resource_proxy.rb b/lib/redmine_dmsf/webdav/resource_proxy.rb index 4eb0c2e4..484bb491 100644 --- a/lib/redmine_dmsf/webdav/resource_proxy.rb +++ b/lib/redmine_dmsf/webdav/resource_proxy.rb @@ -84,6 +84,10 @@ module RedmineDmsf def really_exist? @resource_c.really_exist? end + + def project_id + @resource_c.project_id + end def creation_date @resource_c.creation_date