##// END OF EJS Templates
Add inline image preview/display for attachments and repository entries (#22058)....
Jean-Philippe Lang -
r14942:2fbce6515d6f
parent child
Show More
@@ -0,0 +1,3
1 <%= render :layout => 'layouts/file' do %>
2 <%= render :partial => 'common/image', :locals => {:path => download_named_attachment_url(@attachment, @attachment.filename), :alt => @attachment.filename} %>
3 <% end %>
@@ -0,0 +1,1
1 <%= image_tag path, :alt => alt, :class => 'filecontent image' %>
@@ -1,191 +1,193
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class AttachmentsController < ApplicationController
18 class AttachmentsController < ApplicationController
19 before_filter :find_attachment, :only => [:show, :download, :thumbnail, :destroy]
19 before_filter :find_attachment, :only => [:show, :download, :thumbnail, :destroy]
20 before_filter :find_editable_attachments, :only => [:edit, :update]
20 before_filter :find_editable_attachments, :only => [:edit, :update]
21 before_filter :file_readable, :read_authorize, :only => [:show, :download, :thumbnail]
21 before_filter :file_readable, :read_authorize, :only => [:show, :download, :thumbnail]
22 before_filter :delete_authorize, :only => :destroy
22 before_filter :delete_authorize, :only => :destroy
23 before_filter :authorize_global, :only => :upload
23 before_filter :authorize_global, :only => :upload
24
24
25 accept_api_auth :show, :download, :thumbnail, :upload
25 accept_api_auth :show, :download, :thumbnail, :upload
26
26
27 def show
27 def show
28 respond_to do |format|
28 respond_to do |format|
29 format.html {
29 format.html {
30 if @attachment.is_diff?
30 if @attachment.is_diff?
31 @diff = File.read(@attachment.diskfile, :mode => "rb")
31 @diff = File.read(@attachment.diskfile, :mode => "rb")
32 @diff_type = params[:type] || User.current.pref[:diff_type] || 'inline'
32 @diff_type = params[:type] || User.current.pref[:diff_type] || 'inline'
33 @diff_type = 'inline' unless %w(inline sbs).include?(@diff_type)
33 @diff_type = 'inline' unless %w(inline sbs).include?(@diff_type)
34 # Save diff type as user preference
34 # Save diff type as user preference
35 if User.current.logged? && @diff_type != User.current.pref[:diff_type]
35 if User.current.logged? && @diff_type != User.current.pref[:diff_type]
36 User.current.pref[:diff_type] = @diff_type
36 User.current.pref[:diff_type] = @diff_type
37 User.current.preference.save
37 User.current.preference.save
38 end
38 end
39 render :action => 'diff'
39 render :action => 'diff'
40 elsif @attachment.is_text? && @attachment.filesize <= Setting.file_max_size_displayed.to_i.kilobyte
40 elsif @attachment.is_text? && @attachment.filesize <= Setting.file_max_size_displayed.to_i.kilobyte
41 @content = File.read(@attachment.diskfile, :mode => "rb")
41 @content = File.read(@attachment.diskfile, :mode => "rb")
42 render :action => 'file'
42 render :action => 'file'
43 elsif @attachment.is_image?
44 render :action => 'image'
43 else
45 else
44 download
46 download
45 end
47 end
46 }
48 }
47 format.api
49 format.api
48 end
50 end
49 end
51 end
50
52
51 def download
53 def download
52 if @attachment.container.is_a?(Version) || @attachment.container.is_a?(Project)
54 if @attachment.container.is_a?(Version) || @attachment.container.is_a?(Project)
53 @attachment.increment_download
55 @attachment.increment_download
54 end
56 end
55
57
56 if stale?(:etag => @attachment.digest)
58 if stale?(:etag => @attachment.digest)
57 # images are sent inline
59 # images are sent inline
58 send_file @attachment.diskfile, :filename => filename_for_content_disposition(@attachment.filename),
60 send_file @attachment.diskfile, :filename => filename_for_content_disposition(@attachment.filename),
59 :type => detect_content_type(@attachment),
61 :type => detect_content_type(@attachment),
60 :disposition => (@attachment.image? ? 'inline' : 'attachment')
62 :disposition => (@attachment.image? ? 'inline' : 'attachment')
61 end
63 end
62 end
64 end
63
65
64 def thumbnail
66 def thumbnail
65 if @attachment.thumbnailable? && tbnail = @attachment.thumbnail(:size => params[:size])
67 if @attachment.thumbnailable? && tbnail = @attachment.thumbnail(:size => params[:size])
66 if stale?(:etag => tbnail)
68 if stale?(:etag => tbnail)
67 send_file tbnail,
69 send_file tbnail,
68 :filename => filename_for_content_disposition(@attachment.filename),
70 :filename => filename_for_content_disposition(@attachment.filename),
69 :type => detect_content_type(@attachment),
71 :type => detect_content_type(@attachment),
70 :disposition => 'inline'
72 :disposition => 'inline'
71 end
73 end
72 else
74 else
73 # No thumbnail for the attachment or thumbnail could not be created
75 # No thumbnail for the attachment or thumbnail could not be created
74 render :nothing => true, :status => 404
76 render :nothing => true, :status => 404
75 end
77 end
76 end
78 end
77
79
78 def upload
80 def upload
79 # Make sure that API users get used to set this content type
81 # Make sure that API users get used to set this content type
80 # as it won't trigger Rails' automatic parsing of the request body for parameters
82 # as it won't trigger Rails' automatic parsing of the request body for parameters
81 unless request.content_type == 'application/octet-stream'
83 unless request.content_type == 'application/octet-stream'
82 render :nothing => true, :status => 406
84 render :nothing => true, :status => 406
83 return
85 return
84 end
86 end
85
87
86 @attachment = Attachment.new(:file => request.raw_post)
88 @attachment = Attachment.new(:file => request.raw_post)
87 @attachment.author = User.current
89 @attachment.author = User.current
88 @attachment.filename = params[:filename].presence || Redmine::Utils.random_hex(16)
90 @attachment.filename = params[:filename].presence || Redmine::Utils.random_hex(16)
89 @attachment.content_type = params[:content_type].presence
91 @attachment.content_type = params[:content_type].presence
90 saved = @attachment.save
92 saved = @attachment.save
91
93
92 respond_to do |format|
94 respond_to do |format|
93 format.js
95 format.js
94 format.api {
96 format.api {
95 if saved
97 if saved
96 render :action => 'upload', :status => :created
98 render :action => 'upload', :status => :created
97 else
99 else
98 render_validation_errors(@attachment)
100 render_validation_errors(@attachment)
99 end
101 end
100 }
102 }
101 end
103 end
102 end
104 end
103
105
104 def edit
106 def edit
105 end
107 end
106
108
107 def update
109 def update
108 if params[:attachments].is_a?(Hash)
110 if params[:attachments].is_a?(Hash)
109 if Attachment.update_attachments(@attachments, params[:attachments])
111 if Attachment.update_attachments(@attachments, params[:attachments])
110 redirect_back_or_default home_path
112 redirect_back_or_default home_path
111 return
113 return
112 end
114 end
113 end
115 end
114 render :action => 'edit'
116 render :action => 'edit'
115 end
117 end
116
118
117 def destroy
119 def destroy
118 if @attachment.container.respond_to?(:init_journal)
120 if @attachment.container.respond_to?(:init_journal)
119 @attachment.container.init_journal(User.current)
121 @attachment.container.init_journal(User.current)
120 end
122 end
121 if @attachment.container
123 if @attachment.container
122 # Make sure association callbacks are called
124 # Make sure association callbacks are called
123 @attachment.container.attachments.delete(@attachment)
125 @attachment.container.attachments.delete(@attachment)
124 else
126 else
125 @attachment.destroy
127 @attachment.destroy
126 end
128 end
127
129
128 respond_to do |format|
130 respond_to do |format|
129 format.html { redirect_to_referer_or project_path(@project) }
131 format.html { redirect_to_referer_or project_path(@project) }
130 format.js
132 format.js
131 end
133 end
132 end
134 end
133
135
134 private
136 private
135
137
136 def find_attachment
138 def find_attachment
137 @attachment = Attachment.find(params[:id])
139 @attachment = Attachment.find(params[:id])
138 # Show 404 if the filename in the url is wrong
140 # Show 404 if the filename in the url is wrong
139 raise ActiveRecord::RecordNotFound if params[:filename] && params[:filename] != @attachment.filename
141 raise ActiveRecord::RecordNotFound if params[:filename] && params[:filename] != @attachment.filename
140 @project = @attachment.project
142 @project = @attachment.project
141 rescue ActiveRecord::RecordNotFound
143 rescue ActiveRecord::RecordNotFound
142 render_404
144 render_404
143 end
145 end
144
146
145 def find_editable_attachments
147 def find_editable_attachments
146 klass = params[:object_type].to_s.singularize.classify.constantize rescue nil
148 klass = params[:object_type].to_s.singularize.classify.constantize rescue nil
147 unless klass && klass.reflect_on_association(:attachments)
149 unless klass && klass.reflect_on_association(:attachments)
148 render_404
150 render_404
149 return
151 return
150 end
152 end
151
153
152 @container = klass.find(params[:object_id])
154 @container = klass.find(params[:object_id])
153 if @container.respond_to?(:visible?) && !@container.visible?
155 if @container.respond_to?(:visible?) && !@container.visible?
154 render_403
156 render_403
155 return
157 return
156 end
158 end
157 @attachments = @container.attachments.select(&:editable?)
159 @attachments = @container.attachments.select(&:editable?)
158 if @container.respond_to?(:project)
160 if @container.respond_to?(:project)
159 @project = @container.project
161 @project = @container.project
160 end
162 end
161 render_404 if @attachments.empty?
163 render_404 if @attachments.empty?
162 rescue ActiveRecord::RecordNotFound
164 rescue ActiveRecord::RecordNotFound
163 render_404
165 render_404
164 end
166 end
165
167
166 # Checks that the file exists and is readable
168 # Checks that the file exists and is readable
167 def file_readable
169 def file_readable
168 if @attachment.readable?
170 if @attachment.readable?
169 true
171 true
170 else
172 else
171 logger.error "Cannot send attachment, #{@attachment.diskfile} does not exist or is unreadable."
173 logger.error "Cannot send attachment, #{@attachment.diskfile} does not exist or is unreadable."
172 render_404
174 render_404
173 end
175 end
174 end
176 end
175
177
176 def read_authorize
178 def read_authorize
177 @attachment.visible? ? true : deny_access
179 @attachment.visible? ? true : deny_access
178 end
180 end
179
181
180 def delete_authorize
182 def delete_authorize
181 @attachment.deletable? ? true : deny_access
183 @attachment.deletable? ? true : deny_access
182 end
184 end
183
185
184 def detect_content_type(attachment)
186 def detect_content_type(attachment)
185 content_type = attachment.content_type
187 content_type = attachment.content_type
186 if content_type.blank? || content_type == "application/octet-stream"
188 if content_type.blank? || content_type == "application/octet-stream"
187 content_type = Redmine::MimeType.of(attachment.filename)
189 content_type = Redmine::MimeType.of(attachment.filename)
188 end
190 end
189 content_type.to_s
191 content_type.to_s
190 end
192 end
191 end
193 end
@@ -1,439 +1,441
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require 'SVG/Graph/Bar'
18 require 'SVG/Graph/Bar'
19 require 'SVG/Graph/BarHorizontal'
19 require 'SVG/Graph/BarHorizontal'
20 require 'digest/sha1'
20 require 'digest/sha1'
21 require 'redmine/scm/adapters'
21 require 'redmine/scm/adapters'
22
22
23 class ChangesetNotFound < Exception; end
23 class ChangesetNotFound < Exception; end
24 class InvalidRevisionParam < Exception; end
24 class InvalidRevisionParam < Exception; end
25
25
26 class RepositoriesController < ApplicationController
26 class RepositoriesController < ApplicationController
27 menu_item :repository
27 menu_item :repository
28 menu_item :settings, :only => [:new, :create, :edit, :update, :destroy, :committers]
28 menu_item :settings, :only => [:new, :create, :edit, :update, :destroy, :committers]
29 default_search_scope :changesets
29 default_search_scope :changesets
30
30
31 before_filter :find_project_by_project_id, :only => [:new, :create]
31 before_filter :find_project_by_project_id, :only => [:new, :create]
32 before_filter :find_repository, :only => [:edit, :update, :destroy, :committers]
32 before_filter :find_repository, :only => [:edit, :update, :destroy, :committers]
33 before_filter :find_project_repository, :except => [:new, :create, :edit, :update, :destroy, :committers]
33 before_filter :find_project_repository, :except => [:new, :create, :edit, :update, :destroy, :committers]
34 before_filter :find_changeset, :only => [:revision, :add_related_issue, :remove_related_issue]
34 before_filter :find_changeset, :only => [:revision, :add_related_issue, :remove_related_issue]
35 before_filter :authorize
35 before_filter :authorize
36 accept_rss_auth :revisions
36 accept_rss_auth :revisions
37
37
38 rescue_from Redmine::Scm::Adapters::CommandFailed, :with => :show_error_command_failed
38 rescue_from Redmine::Scm::Adapters::CommandFailed, :with => :show_error_command_failed
39
39
40 def new
40 def new
41 scm = params[:repository_scm] || (Redmine::Scm::Base.all & Setting.enabled_scm).first
41 scm = params[:repository_scm] || (Redmine::Scm::Base.all & Setting.enabled_scm).first
42 @repository = Repository.factory(scm)
42 @repository = Repository.factory(scm)
43 @repository.is_default = @project.repository.nil?
43 @repository.is_default = @project.repository.nil?
44 @repository.project = @project
44 @repository.project = @project
45 end
45 end
46
46
47 def create
47 def create
48 attrs = pickup_extra_info
48 attrs = pickup_extra_info
49 @repository = Repository.factory(params[:repository_scm])
49 @repository = Repository.factory(params[:repository_scm])
50 @repository.safe_attributes = params[:repository]
50 @repository.safe_attributes = params[:repository]
51 if attrs[:attrs_extra].keys.any?
51 if attrs[:attrs_extra].keys.any?
52 @repository.merge_extra_info(attrs[:attrs_extra])
52 @repository.merge_extra_info(attrs[:attrs_extra])
53 end
53 end
54 @repository.project = @project
54 @repository.project = @project
55 if request.post? && @repository.save
55 if request.post? && @repository.save
56 redirect_to settings_project_path(@project, :tab => 'repositories')
56 redirect_to settings_project_path(@project, :tab => 'repositories')
57 else
57 else
58 render :action => 'new'
58 render :action => 'new'
59 end
59 end
60 end
60 end
61
61
62 def edit
62 def edit
63 end
63 end
64
64
65 def update
65 def update
66 attrs = pickup_extra_info
66 attrs = pickup_extra_info
67 @repository.safe_attributes = attrs[:attrs]
67 @repository.safe_attributes = attrs[:attrs]
68 if attrs[:attrs_extra].keys.any?
68 if attrs[:attrs_extra].keys.any?
69 @repository.merge_extra_info(attrs[:attrs_extra])
69 @repository.merge_extra_info(attrs[:attrs_extra])
70 end
70 end
71 @repository.project = @project
71 @repository.project = @project
72 if @repository.save
72 if @repository.save
73 redirect_to settings_project_path(@project, :tab => 'repositories')
73 redirect_to settings_project_path(@project, :tab => 'repositories')
74 else
74 else
75 render :action => 'edit'
75 render :action => 'edit'
76 end
76 end
77 end
77 end
78
78
79 def pickup_extra_info
79 def pickup_extra_info
80 p = {}
80 p = {}
81 p_extra = {}
81 p_extra = {}
82 params[:repository].each do |k, v|
82 params[:repository].each do |k, v|
83 if k =~ /^extra_/
83 if k =~ /^extra_/
84 p_extra[k] = v
84 p_extra[k] = v
85 else
85 else
86 p[k] = v
86 p[k] = v
87 end
87 end
88 end
88 end
89 {:attrs => p, :attrs_extra => p_extra}
89 {:attrs => p, :attrs_extra => p_extra}
90 end
90 end
91 private :pickup_extra_info
91 private :pickup_extra_info
92
92
93 def committers
93 def committers
94 @committers = @repository.committers
94 @committers = @repository.committers
95 @users = @project.users.to_a
95 @users = @project.users.to_a
96 additional_user_ids = @committers.collect(&:last).collect(&:to_i) - @users.collect(&:id)
96 additional_user_ids = @committers.collect(&:last).collect(&:to_i) - @users.collect(&:id)
97 @users += User.where(:id => additional_user_ids).to_a unless additional_user_ids.empty?
97 @users += User.where(:id => additional_user_ids).to_a unless additional_user_ids.empty?
98 @users.compact!
98 @users.compact!
99 @users.sort!
99 @users.sort!
100 if request.post? && params[:committers].is_a?(Hash)
100 if request.post? && params[:committers].is_a?(Hash)
101 # Build a hash with repository usernames as keys and corresponding user ids as values
101 # Build a hash with repository usernames as keys and corresponding user ids as values
102 @repository.committer_ids = params[:committers].values.inject({}) {|h, c| h[c.first] = c.last; h}
102 @repository.committer_ids = params[:committers].values.inject({}) {|h, c| h[c.first] = c.last; h}
103 flash[:notice] = l(:notice_successful_update)
103 flash[:notice] = l(:notice_successful_update)
104 redirect_to settings_project_path(@project, :tab => 'repositories')
104 redirect_to settings_project_path(@project, :tab => 'repositories')
105 end
105 end
106 end
106 end
107
107
108 def destroy
108 def destroy
109 @repository.destroy if request.delete?
109 @repository.destroy if request.delete?
110 redirect_to settings_project_path(@project, :tab => 'repositories')
110 redirect_to settings_project_path(@project, :tab => 'repositories')
111 end
111 end
112
112
113 def show
113 def show
114 @repository.fetch_changesets if @project.active? && Setting.autofetch_changesets? && @path.empty?
114 @repository.fetch_changesets if @project.active? && Setting.autofetch_changesets? && @path.empty?
115
115
116 @entries = @repository.entries(@path, @rev)
116 @entries = @repository.entries(@path, @rev)
117 @changeset = @repository.find_changeset_by_name(@rev)
117 @changeset = @repository.find_changeset_by_name(@rev)
118 if request.xhr?
118 if request.xhr?
119 @entries ? render(:partial => 'dir_list_content') : render(:nothing => true)
119 @entries ? render(:partial => 'dir_list_content') : render(:nothing => true)
120 else
120 else
121 (show_error_not_found; return) unless @entries
121 (show_error_not_found; return) unless @entries
122 @changesets = @repository.latest_changesets(@path, @rev)
122 @changesets = @repository.latest_changesets(@path, @rev)
123 @properties = @repository.properties(@path, @rev)
123 @properties = @repository.properties(@path, @rev)
124 @repositories = @project.repositories
124 @repositories = @project.repositories
125 render :action => 'show'
125 render :action => 'show'
126 end
126 end
127 end
127 end
128
128
129 alias_method :browse, :show
129 alias_method :browse, :show
130
130
131 def changes
131 def changes
132 @entry = @repository.entry(@path, @rev)
132 @entry = @repository.entry(@path, @rev)
133 (show_error_not_found; return) unless @entry
133 (show_error_not_found; return) unless @entry
134 @changesets = @repository.latest_changesets(@path, @rev, Setting.repository_log_display_limit.to_i)
134 @changesets = @repository.latest_changesets(@path, @rev, Setting.repository_log_display_limit.to_i)
135 @properties = @repository.properties(@path, @rev)
135 @properties = @repository.properties(@path, @rev)
136 @changeset = @repository.find_changeset_by_name(@rev)
136 @changeset = @repository.find_changeset_by_name(@rev)
137 end
137 end
138
138
139 def revisions
139 def revisions
140 @changeset_count = @repository.changesets.count
140 @changeset_count = @repository.changesets.count
141 @changeset_pages = Paginator.new @changeset_count,
141 @changeset_pages = Paginator.new @changeset_count,
142 per_page_option,
142 per_page_option,
143 params['page']
143 params['page']
144 @changesets = @repository.changesets.
144 @changesets = @repository.changesets.
145 limit(@changeset_pages.per_page).
145 limit(@changeset_pages.per_page).
146 offset(@changeset_pages.offset).
146 offset(@changeset_pages.offset).
147 includes(:user, :repository, :parents).
147 includes(:user, :repository, :parents).
148 to_a
148 to_a
149
149
150 respond_to do |format|
150 respond_to do |format|
151 format.html { render :layout => false if request.xhr? }
151 format.html { render :layout => false if request.xhr? }
152 format.atom { render_feed(@changesets, :title => "#{@project.name}: #{l(:label_revision_plural)}") }
152 format.atom { render_feed(@changesets, :title => "#{@project.name}: #{l(:label_revision_plural)}") }
153 end
153 end
154 end
154 end
155
155
156 def raw
156 def raw
157 entry_and_raw(true)
157 entry_and_raw(true)
158 end
158 end
159
159
160 def entry
160 def entry
161 entry_and_raw(false)
161 entry_and_raw(false)
162 end
162 end
163
163
164 def entry_and_raw(is_raw)
164 def entry_and_raw(is_raw)
165 @entry = @repository.entry(@path, @rev)
165 @entry = @repository.entry(@path, @rev)
166 (show_error_not_found; return) unless @entry
166 (show_error_not_found; return) unless @entry
167
167
168 # If the entry is a dir, show the browser
168 # If the entry is a dir, show the browser
169 (show; return) if @entry.is_dir?
169 (show; return) if @entry.is_dir?
170
170
171 @content = @repository.cat(@path, @rev)
171 @content = @repository.cat(@path, @rev)
172 (show_error_not_found; return) unless @content
172 (show_error_not_found; return) unless @content
173 if is_raw ||
173 if !is_raw && Redmine::MimeType.is_type?('image', @path)
174 # simply render
175 elsif is_raw ||
174 (@content.size && @content.size > Setting.file_max_size_displayed.to_i.kilobyte) ||
176 (@content.size && @content.size > Setting.file_max_size_displayed.to_i.kilobyte) ||
175 ! is_entry_text_data?(@content, @path)
177 ! is_entry_text_data?(@content, @path)
176 # Force the download
178 # Force the download
177 send_opt = { :filename => filename_for_content_disposition(@path.split('/').last) }
179 send_opt = { :filename => filename_for_content_disposition(@path.split('/').last) }
178 send_type = Redmine::MimeType.of(@path)
180 send_type = Redmine::MimeType.of(@path)
179 send_opt[:type] = send_type.to_s if send_type
181 send_opt[:type] = send_type.to_s if send_type
180 send_opt[:disposition] = (Redmine::MimeType.is_type?('image', @path) && !is_raw ? 'inline' : 'attachment')
182 send_opt[:disposition] = (Redmine::MimeType.is_type?('image', @path) && !is_raw ? 'inline' : 'attachment')
181 send_data @content, send_opt
183 send_data @content, send_opt
182 else
184 else
183 # Prevent empty lines when displaying a file with Windows style eol
185 # Prevent empty lines when displaying a file with Windows style eol
184 # TODO: UTF-16
186 # TODO: UTF-16
185 # Is this needs? AttachmentsController reads file simply.
187 # Is this needs? AttachmentsController reads file simply.
186 @content.gsub!("\r\n", "\n")
188 @content.gsub!("\r\n", "\n")
187 @changeset = @repository.find_changeset_by_name(@rev)
189 @changeset = @repository.find_changeset_by_name(@rev)
188 end
190 end
189 end
191 end
190 private :entry_and_raw
192 private :entry_and_raw
191
193
192 def is_entry_text_data?(ent, path)
194 def is_entry_text_data?(ent, path)
193 # UTF-16 contains "\x00".
195 # UTF-16 contains "\x00".
194 # It is very strict that file contains less than 30% of ascii symbols
196 # It is very strict that file contains less than 30% of ascii symbols
195 # in non Western Europe.
197 # in non Western Europe.
196 return true if Redmine::MimeType.is_type?('text', path)
198 return true if Redmine::MimeType.is_type?('text', path)
197 # Ruby 1.8.6 has a bug of integer divisions.
199 # Ruby 1.8.6 has a bug of integer divisions.
198 # http://apidock.com/ruby/v1_8_6_287/String/is_binary_data%3F
200 # http://apidock.com/ruby/v1_8_6_287/String/is_binary_data%3F
199 return false if ent.is_binary_data?
201 return false if ent.is_binary_data?
200 true
202 true
201 end
203 end
202 private :is_entry_text_data?
204 private :is_entry_text_data?
203
205
204 def annotate
206 def annotate
205 @entry = @repository.entry(@path, @rev)
207 @entry = @repository.entry(@path, @rev)
206 (show_error_not_found; return) unless @entry
208 (show_error_not_found; return) unless @entry
207
209
208 @annotate = @repository.scm.annotate(@path, @rev)
210 @annotate = @repository.scm.annotate(@path, @rev)
209 if @annotate.nil? || @annotate.empty?
211 if @annotate.nil? || @annotate.empty?
210 (render_error l(:error_scm_annotate); return)
212 (render_error l(:error_scm_annotate); return)
211 end
213 end
212 ann_buf_size = 0
214 ann_buf_size = 0
213 @annotate.lines.each do |buf|
215 @annotate.lines.each do |buf|
214 ann_buf_size += buf.size
216 ann_buf_size += buf.size
215 end
217 end
216 if ann_buf_size > Setting.file_max_size_displayed.to_i.kilobyte
218 if ann_buf_size > Setting.file_max_size_displayed.to_i.kilobyte
217 (render_error l(:error_scm_annotate_big_text_file); return)
219 (render_error l(:error_scm_annotate_big_text_file); return)
218 end
220 end
219 @changeset = @repository.find_changeset_by_name(@rev)
221 @changeset = @repository.find_changeset_by_name(@rev)
220 end
222 end
221
223
222 def revision
224 def revision
223 respond_to do |format|
225 respond_to do |format|
224 format.html
226 format.html
225 format.js {render :layout => false}
227 format.js {render :layout => false}
226 end
228 end
227 end
229 end
228
230
229 # Adds a related issue to a changeset
231 # Adds a related issue to a changeset
230 # POST /projects/:project_id/repository/(:repository_id/)revisions/:rev/issues
232 # POST /projects/:project_id/repository/(:repository_id/)revisions/:rev/issues
231 def add_related_issue
233 def add_related_issue
232 issue_id = params[:issue_id].to_s.sub(/^#/,'')
234 issue_id = params[:issue_id].to_s.sub(/^#/,'')
233 @issue = @changeset.find_referenced_issue_by_id(issue_id)
235 @issue = @changeset.find_referenced_issue_by_id(issue_id)
234 if @issue && (!@issue.visible? || @changeset.issues.include?(@issue))
236 if @issue && (!@issue.visible? || @changeset.issues.include?(@issue))
235 @issue = nil
237 @issue = nil
236 end
238 end
237
239
238 if @issue
240 if @issue
239 @changeset.issues << @issue
241 @changeset.issues << @issue
240 end
242 end
241 end
243 end
242
244
243 # Removes a related issue from a changeset
245 # Removes a related issue from a changeset
244 # DELETE /projects/:project_id/repository/(:repository_id/)revisions/:rev/issues/:issue_id
246 # DELETE /projects/:project_id/repository/(:repository_id/)revisions/:rev/issues/:issue_id
245 def remove_related_issue
247 def remove_related_issue
246 @issue = Issue.visible.find_by_id(params[:issue_id])
248 @issue = Issue.visible.find_by_id(params[:issue_id])
247 if @issue
249 if @issue
248 @changeset.issues.delete(@issue)
250 @changeset.issues.delete(@issue)
249 end
251 end
250 end
252 end
251
253
252 def diff
254 def diff
253 if params[:format] == 'diff'
255 if params[:format] == 'diff'
254 @diff = @repository.diff(@path, @rev, @rev_to)
256 @diff = @repository.diff(@path, @rev, @rev_to)
255 (show_error_not_found; return) unless @diff
257 (show_error_not_found; return) unless @diff
256 filename = "changeset_r#{@rev}"
258 filename = "changeset_r#{@rev}"
257 filename << "_r#{@rev_to}" if @rev_to
259 filename << "_r#{@rev_to}" if @rev_to
258 send_data @diff.join, :filename => "#{filename}.diff",
260 send_data @diff.join, :filename => "#{filename}.diff",
259 :type => 'text/x-patch',
261 :type => 'text/x-patch',
260 :disposition => 'attachment'
262 :disposition => 'attachment'
261 else
263 else
262 @diff_type = params[:type] || User.current.pref[:diff_type] || 'inline'
264 @diff_type = params[:type] || User.current.pref[:diff_type] || 'inline'
263 @diff_type = 'inline' unless %w(inline sbs).include?(@diff_type)
265 @diff_type = 'inline' unless %w(inline sbs).include?(@diff_type)
264
266
265 # Save diff type as user preference
267 # Save diff type as user preference
266 if User.current.logged? && @diff_type != User.current.pref[:diff_type]
268 if User.current.logged? && @diff_type != User.current.pref[:diff_type]
267 User.current.pref[:diff_type] = @diff_type
269 User.current.pref[:diff_type] = @diff_type
268 User.current.preference.save
270 User.current.preference.save
269 end
271 end
270 @cache_key = "repositories/diff/#{@repository.id}/" +
272 @cache_key = "repositories/diff/#{@repository.id}/" +
271 Digest::MD5.hexdigest("#{@path}-#{@rev}-#{@rev_to}-#{@diff_type}-#{current_language}")
273 Digest::MD5.hexdigest("#{@path}-#{@rev}-#{@rev_to}-#{@diff_type}-#{current_language}")
272 unless read_fragment(@cache_key)
274 unless read_fragment(@cache_key)
273 @diff = @repository.diff(@path, @rev, @rev_to)
275 @diff = @repository.diff(@path, @rev, @rev_to)
274 show_error_not_found unless @diff
276 show_error_not_found unless @diff
275 end
277 end
276
278
277 @changeset = @repository.find_changeset_by_name(@rev)
279 @changeset = @repository.find_changeset_by_name(@rev)
278 @changeset_to = @rev_to ? @repository.find_changeset_by_name(@rev_to) : nil
280 @changeset_to = @rev_to ? @repository.find_changeset_by_name(@rev_to) : nil
279 @diff_format_revisions = @repository.diff_format_revisions(@changeset, @changeset_to)
281 @diff_format_revisions = @repository.diff_format_revisions(@changeset, @changeset_to)
280 end
282 end
281 end
283 end
282
284
283 def stats
285 def stats
284 end
286 end
285
287
286 def graph
288 def graph
287 data = nil
289 data = nil
288 case params[:graph]
290 case params[:graph]
289 when "commits_per_month"
291 when "commits_per_month"
290 data = graph_commits_per_month(@repository)
292 data = graph_commits_per_month(@repository)
291 when "commits_per_author"
293 when "commits_per_author"
292 data = graph_commits_per_author(@repository)
294 data = graph_commits_per_author(@repository)
293 end
295 end
294 if data
296 if data
295 headers["Content-Type"] = "image/svg+xml"
297 headers["Content-Type"] = "image/svg+xml"
296 send_data(data, :type => "image/svg+xml", :disposition => "inline")
298 send_data(data, :type => "image/svg+xml", :disposition => "inline")
297 else
299 else
298 render_404
300 render_404
299 end
301 end
300 end
302 end
301
303
302 private
304 private
303
305
304 def find_repository
306 def find_repository
305 @repository = Repository.find(params[:id])
307 @repository = Repository.find(params[:id])
306 @project = @repository.project
308 @project = @repository.project
307 rescue ActiveRecord::RecordNotFound
309 rescue ActiveRecord::RecordNotFound
308 render_404
310 render_404
309 end
311 end
310
312
311 REV_PARAM_RE = %r{\A[a-f0-9]*\Z}i
313 REV_PARAM_RE = %r{\A[a-f0-9]*\Z}i
312
314
313 def find_project_repository
315 def find_project_repository
314 @project = Project.find(params[:id])
316 @project = Project.find(params[:id])
315 if params[:repository_id].present?
317 if params[:repository_id].present?
316 @repository = @project.repositories.find_by_identifier_param(params[:repository_id])
318 @repository = @project.repositories.find_by_identifier_param(params[:repository_id])
317 else
319 else
318 @repository = @project.repository
320 @repository = @project.repository
319 end
321 end
320 (render_404; return false) unless @repository
322 (render_404; return false) unless @repository
321 @path = params[:path].is_a?(Array) ? params[:path].join('/') : params[:path].to_s
323 @path = params[:path].is_a?(Array) ? params[:path].join('/') : params[:path].to_s
322 @rev = params[:rev].blank? ? @repository.default_branch : params[:rev].to_s.strip
324 @rev = params[:rev].blank? ? @repository.default_branch : params[:rev].to_s.strip
323 @rev_to = params[:rev_to]
325 @rev_to = params[:rev_to]
324
326
325 unless @rev.to_s.match(REV_PARAM_RE) && @rev_to.to_s.match(REV_PARAM_RE)
327 unless @rev.to_s.match(REV_PARAM_RE) && @rev_to.to_s.match(REV_PARAM_RE)
326 if @repository.branches.blank?
328 if @repository.branches.blank?
327 raise InvalidRevisionParam
329 raise InvalidRevisionParam
328 end
330 end
329 end
331 end
330 rescue ActiveRecord::RecordNotFound
332 rescue ActiveRecord::RecordNotFound
331 render_404
333 render_404
332 rescue InvalidRevisionParam
334 rescue InvalidRevisionParam
333 show_error_not_found
335 show_error_not_found
334 end
336 end
335
337
336 def find_changeset
338 def find_changeset
337 if @rev.present?
339 if @rev.present?
338 @changeset = @repository.find_changeset_by_name(@rev)
340 @changeset = @repository.find_changeset_by_name(@rev)
339 end
341 end
340 show_error_not_found unless @changeset
342 show_error_not_found unless @changeset
341 end
343 end
342
344
343 def show_error_not_found
345 def show_error_not_found
344 render_error :message => l(:error_scm_not_found), :status => 404
346 render_error :message => l(:error_scm_not_found), :status => 404
345 end
347 end
346
348
347 # Handler for Redmine::Scm::Adapters::CommandFailed exception
349 # Handler for Redmine::Scm::Adapters::CommandFailed exception
348 def show_error_command_failed(exception)
350 def show_error_command_failed(exception)
349 render_error l(:error_scm_command_failed, exception.message)
351 render_error l(:error_scm_command_failed, exception.message)
350 end
352 end
351
353
352 def graph_commits_per_month(repository)
354 def graph_commits_per_month(repository)
353 @date_to = Date.today
355 @date_to = Date.today
354 @date_from = @date_to << 11
356 @date_from = @date_to << 11
355 @date_from = Date.civil(@date_from.year, @date_from.month, 1)
357 @date_from = Date.civil(@date_from.year, @date_from.month, 1)
356 commits_by_day = Changeset.
358 commits_by_day = Changeset.
357 where("repository_id = ? AND commit_date BETWEEN ? AND ?", repository.id, @date_from, @date_to).
359 where("repository_id = ? AND commit_date BETWEEN ? AND ?", repository.id, @date_from, @date_to).
358 group(:commit_date).
360 group(:commit_date).
359 count
361 count
360 commits_by_month = [0] * 12
362 commits_by_month = [0] * 12
361 commits_by_day.each {|c| commits_by_month[(@date_to.month - c.first.to_date.month) % 12] += c.last }
363 commits_by_day.each {|c| commits_by_month[(@date_to.month - c.first.to_date.month) % 12] += c.last }
362
364
363 changes_by_day = Change.
365 changes_by_day = Change.
364 joins(:changeset).
366 joins(:changeset).
365 where("#{Changeset.table_name}.repository_id = ? AND #{Changeset.table_name}.commit_date BETWEEN ? AND ?", repository.id, @date_from, @date_to).
367 where("#{Changeset.table_name}.repository_id = ? AND #{Changeset.table_name}.commit_date BETWEEN ? AND ?", repository.id, @date_from, @date_to).
366 group(:commit_date).
368 group(:commit_date).
367 count
369 count
368 changes_by_month = [0] * 12
370 changes_by_month = [0] * 12
369 changes_by_day.each {|c| changes_by_month[(@date_to.month - c.first.to_date.month) % 12] += c.last }
371 changes_by_day.each {|c| changes_by_month[(@date_to.month - c.first.to_date.month) % 12] += c.last }
370
372
371 fields = []
373 fields = []
372 12.times {|m| fields << month_name(((Date.today.month - 1 - m) % 12) + 1)}
374 12.times {|m| fields << month_name(((Date.today.month - 1 - m) % 12) + 1)}
373
375
374 graph = SVG::Graph::Bar.new(
376 graph = SVG::Graph::Bar.new(
375 :height => 300,
377 :height => 300,
376 :width => 800,
378 :width => 800,
377 :fields => fields.reverse,
379 :fields => fields.reverse,
378 :stack => :side,
380 :stack => :side,
379 :scale_integers => true,
381 :scale_integers => true,
380 :step_x_labels => 2,
382 :step_x_labels => 2,
381 :show_data_values => false,
383 :show_data_values => false,
382 :graph_title => l(:label_commits_per_month),
384 :graph_title => l(:label_commits_per_month),
383 :show_graph_title => true
385 :show_graph_title => true
384 )
386 )
385
387
386 graph.add_data(
388 graph.add_data(
387 :data => commits_by_month[0..11].reverse,
389 :data => commits_by_month[0..11].reverse,
388 :title => l(:label_revision_plural)
390 :title => l(:label_revision_plural)
389 )
391 )
390
392
391 graph.add_data(
393 graph.add_data(
392 :data => changes_by_month[0..11].reverse,
394 :data => changes_by_month[0..11].reverse,
393 :title => l(:label_change_plural)
395 :title => l(:label_change_plural)
394 )
396 )
395
397
396 graph.burn
398 graph.burn
397 end
399 end
398
400
399 def graph_commits_per_author(repository)
401 def graph_commits_per_author(repository)
400 #data
402 #data
401 stats = repository.stats_by_author
403 stats = repository.stats_by_author
402 fields, commits_data, changes_data = [], [], []
404 fields, commits_data, changes_data = [], [], []
403 stats.each do |name, hsh|
405 stats.each do |name, hsh|
404 fields << name
406 fields << name
405 commits_data << hsh[:commits_count]
407 commits_data << hsh[:commits_count]
406 changes_data << hsh[:changes_count]
408 changes_data << hsh[:changes_count]
407 end
409 end
408
410
409 #expand to 10 values if needed
411 #expand to 10 values if needed
410 fields = fields + [""]*(10 - fields.length) if fields.length<10
412 fields = fields + [""]*(10 - fields.length) if fields.length<10
411 commits_data = commits_data + [0]*(10 - commits_data.length) if commits_data.length<10
413 commits_data = commits_data + [0]*(10 - commits_data.length) if commits_data.length<10
412 changes_data = changes_data + [0]*(10 - changes_data.length) if changes_data.length<10
414 changes_data = changes_data + [0]*(10 - changes_data.length) if changes_data.length<10
413
415
414 # Remove email address in usernames
416 # Remove email address in usernames
415 fields = fields.collect {|c| c.gsub(%r{<.+@.+>}, '') }
417 fields = fields.collect {|c| c.gsub(%r{<.+@.+>}, '') }
416
418
417 #prepare graph
419 #prepare graph
418 graph = SVG::Graph::BarHorizontal.new(
420 graph = SVG::Graph::BarHorizontal.new(
419 :height => 30 * commits_data.length,
421 :height => 30 * commits_data.length,
420 :width => 800,
422 :width => 800,
421 :fields => fields,
423 :fields => fields,
422 :stack => :side,
424 :stack => :side,
423 :scale_integers => true,
425 :scale_integers => true,
424 :show_data_values => false,
426 :show_data_values => false,
425 :rotate_y_labels => false,
427 :rotate_y_labels => false,
426 :graph_title => l(:label_commits_per_author),
428 :graph_title => l(:label_commits_per_author),
427 :show_graph_title => true
429 :show_graph_title => true
428 )
430 )
429 graph.add_data(
431 graph.add_data(
430 :data => commits_data,
432 :data => commits_data,
431 :title => l(:label_revision_plural)
433 :title => l(:label_revision_plural)
432 )
434 )
433 graph.add_data(
435 graph.add_data(
434 :data => changes_data,
436 :data => changes_data,
435 :title => l(:label_change_plural)
437 :title => l(:label_change_plural)
436 )
438 )
437 graph.burn
439 graph.burn
438 end
440 end
439 end
441 end
@@ -1,402 +1,406
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require "digest/md5"
18 require "digest/md5"
19 require "fileutils"
19 require "fileutils"
20
20
21 class Attachment < ActiveRecord::Base
21 class Attachment < ActiveRecord::Base
22 belongs_to :container, :polymorphic => true
22 belongs_to :container, :polymorphic => true
23 belongs_to :author, :class_name => "User"
23 belongs_to :author, :class_name => "User"
24
24
25 validates_presence_of :filename, :author
25 validates_presence_of :filename, :author
26 validates_length_of :filename, :maximum => 255
26 validates_length_of :filename, :maximum => 255
27 validates_length_of :disk_filename, :maximum => 255
27 validates_length_of :disk_filename, :maximum => 255
28 validates_length_of :description, :maximum => 255
28 validates_length_of :description, :maximum => 255
29 validate :validate_max_file_size, :validate_file_extension
29 validate :validate_max_file_size, :validate_file_extension
30 attr_protected :id
30 attr_protected :id
31
31
32 acts_as_event :title => :filename,
32 acts_as_event :title => :filename,
33 :url => Proc.new {|o| {:controller => 'attachments', :action => 'download', :id => o.id, :filename => o.filename}}
33 :url => Proc.new {|o| {:controller => 'attachments', :action => 'download', :id => o.id, :filename => o.filename}}
34
34
35 acts_as_activity_provider :type => 'files',
35 acts_as_activity_provider :type => 'files',
36 :permission => :view_files,
36 :permission => :view_files,
37 :author_key => :author_id,
37 :author_key => :author_id,
38 :scope => select("#{Attachment.table_name}.*").
38 :scope => select("#{Attachment.table_name}.*").
39 joins("LEFT JOIN #{Version.table_name} ON #{Attachment.table_name}.container_type='Version' AND #{Version.table_name}.id = #{Attachment.table_name}.container_id " +
39 joins("LEFT JOIN #{Version.table_name} ON #{Attachment.table_name}.container_type='Version' AND #{Version.table_name}.id = #{Attachment.table_name}.container_id " +
40 "LEFT JOIN #{Project.table_name} ON #{Version.table_name}.project_id = #{Project.table_name}.id OR ( #{Attachment.table_name}.container_type='Project' AND #{Attachment.table_name}.container_id = #{Project.table_name}.id )")
40 "LEFT JOIN #{Project.table_name} ON #{Version.table_name}.project_id = #{Project.table_name}.id OR ( #{Attachment.table_name}.container_type='Project' AND #{Attachment.table_name}.container_id = #{Project.table_name}.id )")
41
41
42 acts_as_activity_provider :type => 'documents',
42 acts_as_activity_provider :type => 'documents',
43 :permission => :view_documents,
43 :permission => :view_documents,
44 :author_key => :author_id,
44 :author_key => :author_id,
45 :scope => select("#{Attachment.table_name}.*").
45 :scope => select("#{Attachment.table_name}.*").
46 joins("LEFT JOIN #{Document.table_name} ON #{Attachment.table_name}.container_type='Document' AND #{Document.table_name}.id = #{Attachment.table_name}.container_id " +
46 joins("LEFT JOIN #{Document.table_name} ON #{Attachment.table_name}.container_type='Document' AND #{Document.table_name}.id = #{Attachment.table_name}.container_id " +
47 "LEFT JOIN #{Project.table_name} ON #{Document.table_name}.project_id = #{Project.table_name}.id")
47 "LEFT JOIN #{Project.table_name} ON #{Document.table_name}.project_id = #{Project.table_name}.id")
48
48
49 cattr_accessor :storage_path
49 cattr_accessor :storage_path
50 @@storage_path = Redmine::Configuration['attachments_storage_path'] || File.join(Rails.root, "files")
50 @@storage_path = Redmine::Configuration['attachments_storage_path'] || File.join(Rails.root, "files")
51
51
52 cattr_accessor :thumbnails_storage_path
52 cattr_accessor :thumbnails_storage_path
53 @@thumbnails_storage_path = File.join(Rails.root, "tmp", "thumbnails")
53 @@thumbnails_storage_path = File.join(Rails.root, "tmp", "thumbnails")
54
54
55 before_create :files_to_final_location
55 before_create :files_to_final_location
56 after_rollback :delete_from_disk, :on => :create
56 after_rollback :delete_from_disk, :on => :create
57 after_commit :delete_from_disk, :on => :destroy
57 after_commit :delete_from_disk, :on => :destroy
58
58
59 # Returns an unsaved copy of the attachment
59 # Returns an unsaved copy of the attachment
60 def copy(attributes=nil)
60 def copy(attributes=nil)
61 copy = self.class.new
61 copy = self.class.new
62 copy.attributes = self.attributes.dup.except("id", "downloads")
62 copy.attributes = self.attributes.dup.except("id", "downloads")
63 copy.attributes = attributes if attributes
63 copy.attributes = attributes if attributes
64 copy
64 copy
65 end
65 end
66
66
67 def validate_max_file_size
67 def validate_max_file_size
68 if @temp_file && self.filesize > Setting.attachment_max_size.to_i.kilobytes
68 if @temp_file && self.filesize > Setting.attachment_max_size.to_i.kilobytes
69 errors.add(:base, l(:error_attachment_too_big, :max_size => Setting.attachment_max_size.to_i.kilobytes))
69 errors.add(:base, l(:error_attachment_too_big, :max_size => Setting.attachment_max_size.to_i.kilobytes))
70 end
70 end
71 end
71 end
72
72
73 def validate_file_extension
73 def validate_file_extension
74 if @temp_file
74 if @temp_file
75 extension = File.extname(filename)
75 extension = File.extname(filename)
76 unless self.class.valid_extension?(extension)
76 unless self.class.valid_extension?(extension)
77 errors.add(:base, l(:error_attachment_extension_not_allowed, :extension => extension))
77 errors.add(:base, l(:error_attachment_extension_not_allowed, :extension => extension))
78 end
78 end
79 end
79 end
80 end
80 end
81
81
82 def file=(incoming_file)
82 def file=(incoming_file)
83 unless incoming_file.nil?
83 unless incoming_file.nil?
84 @temp_file = incoming_file
84 @temp_file = incoming_file
85 if @temp_file.size > 0
85 if @temp_file.size > 0
86 if @temp_file.respond_to?(:original_filename)
86 if @temp_file.respond_to?(:original_filename)
87 self.filename = @temp_file.original_filename
87 self.filename = @temp_file.original_filename
88 self.filename.force_encoding("UTF-8")
88 self.filename.force_encoding("UTF-8")
89 end
89 end
90 if @temp_file.respond_to?(:content_type)
90 if @temp_file.respond_to?(:content_type)
91 self.content_type = @temp_file.content_type.to_s.chomp
91 self.content_type = @temp_file.content_type.to_s.chomp
92 end
92 end
93 self.filesize = @temp_file.size
93 self.filesize = @temp_file.size
94 end
94 end
95 end
95 end
96 end
96 end
97
97
98 def file
98 def file
99 nil
99 nil
100 end
100 end
101
101
102 def filename=(arg)
102 def filename=(arg)
103 write_attribute :filename, sanitize_filename(arg.to_s)
103 write_attribute :filename, sanitize_filename(arg.to_s)
104 filename
104 filename
105 end
105 end
106
106
107 # Copies the temporary file to its final location
107 # Copies the temporary file to its final location
108 # and computes its MD5 hash
108 # and computes its MD5 hash
109 def files_to_final_location
109 def files_to_final_location
110 if @temp_file && (@temp_file.size > 0)
110 if @temp_file && (@temp_file.size > 0)
111 self.disk_directory = target_directory
111 self.disk_directory = target_directory
112 self.disk_filename = Attachment.disk_filename(filename, disk_directory)
112 self.disk_filename = Attachment.disk_filename(filename, disk_directory)
113 logger.info("Saving attachment '#{self.diskfile}' (#{@temp_file.size} bytes)") if logger
113 logger.info("Saving attachment '#{self.diskfile}' (#{@temp_file.size} bytes)") if logger
114 path = File.dirname(diskfile)
114 path = File.dirname(diskfile)
115 unless File.directory?(path)
115 unless File.directory?(path)
116 FileUtils.mkdir_p(path)
116 FileUtils.mkdir_p(path)
117 end
117 end
118 md5 = Digest::MD5.new
118 md5 = Digest::MD5.new
119 File.open(diskfile, "wb") do |f|
119 File.open(diskfile, "wb") do |f|
120 if @temp_file.respond_to?(:read)
120 if @temp_file.respond_to?(:read)
121 buffer = ""
121 buffer = ""
122 while (buffer = @temp_file.read(8192))
122 while (buffer = @temp_file.read(8192))
123 f.write(buffer)
123 f.write(buffer)
124 md5.update(buffer)
124 md5.update(buffer)
125 end
125 end
126 else
126 else
127 f.write(@temp_file)
127 f.write(@temp_file)
128 md5.update(@temp_file)
128 md5.update(@temp_file)
129 end
129 end
130 end
130 end
131 self.digest = md5.hexdigest
131 self.digest = md5.hexdigest
132 end
132 end
133 @temp_file = nil
133 @temp_file = nil
134
134
135 if content_type.blank? && filename.present?
135 if content_type.blank? && filename.present?
136 self.content_type = Redmine::MimeType.of(filename)
136 self.content_type = Redmine::MimeType.of(filename)
137 end
137 end
138 # Don't save the content type if it's longer than the authorized length
138 # Don't save the content type if it's longer than the authorized length
139 if self.content_type && self.content_type.length > 255
139 if self.content_type && self.content_type.length > 255
140 self.content_type = nil
140 self.content_type = nil
141 end
141 end
142 end
142 end
143
143
144 # Deletes the file from the file system if it's not referenced by other attachments
144 # Deletes the file from the file system if it's not referenced by other attachments
145 def delete_from_disk
145 def delete_from_disk
146 if Attachment.where("disk_filename = ? AND id <> ?", disk_filename, id).empty?
146 if Attachment.where("disk_filename = ? AND id <> ?", disk_filename, id).empty?
147 delete_from_disk!
147 delete_from_disk!
148 end
148 end
149 end
149 end
150
150
151 # Returns file's location on disk
151 # Returns file's location on disk
152 def diskfile
152 def diskfile
153 File.join(self.class.storage_path, disk_directory.to_s, disk_filename.to_s)
153 File.join(self.class.storage_path, disk_directory.to_s, disk_filename.to_s)
154 end
154 end
155
155
156 def title
156 def title
157 title = filename.to_s
157 title = filename.to_s
158 if description.present?
158 if description.present?
159 title << " (#{description})"
159 title << " (#{description})"
160 end
160 end
161 title
161 title
162 end
162 end
163
163
164 def increment_download
164 def increment_download
165 increment!(:downloads)
165 increment!(:downloads)
166 end
166 end
167
167
168 def project
168 def project
169 container.try(:project)
169 container.try(:project)
170 end
170 end
171
171
172 def visible?(user=User.current)
172 def visible?(user=User.current)
173 if container_id
173 if container_id
174 container && container.attachments_visible?(user)
174 container && container.attachments_visible?(user)
175 else
175 else
176 author == user
176 author == user
177 end
177 end
178 end
178 end
179
179
180 def editable?(user=User.current)
180 def editable?(user=User.current)
181 if container_id
181 if container_id
182 container && container.attachments_editable?(user)
182 container && container.attachments_editable?(user)
183 else
183 else
184 author == user
184 author == user
185 end
185 end
186 end
186 end
187
187
188 def deletable?(user=User.current)
188 def deletable?(user=User.current)
189 if container_id
189 if container_id
190 container && container.attachments_deletable?(user)
190 container && container.attachments_deletable?(user)
191 else
191 else
192 author == user
192 author == user
193 end
193 end
194 end
194 end
195
195
196 def image?
196 def image?
197 !!(self.filename =~ /\.(bmp|gif|jpg|jpe|jpeg|png)$/i)
197 !!(self.filename =~ /\.(bmp|gif|jpg|jpe|jpeg|png)$/i)
198 end
198 end
199
199
200 def thumbnailable?
200 def thumbnailable?
201 image?
201 image?
202 end
202 end
203
203
204 # Returns the full path the attachment thumbnail, or nil
204 # Returns the full path the attachment thumbnail, or nil
205 # if the thumbnail cannot be generated.
205 # if the thumbnail cannot be generated.
206 def thumbnail(options={})
206 def thumbnail(options={})
207 if thumbnailable? && readable?
207 if thumbnailable? && readable?
208 size = options[:size].to_i
208 size = options[:size].to_i
209 if size > 0
209 if size > 0
210 # Limit the number of thumbnails per image
210 # Limit the number of thumbnails per image
211 size = (size / 50) * 50
211 size = (size / 50) * 50
212 # Maximum thumbnail size
212 # Maximum thumbnail size
213 size = 800 if size > 800
213 size = 800 if size > 800
214 else
214 else
215 size = Setting.thumbnails_size.to_i
215 size = Setting.thumbnails_size.to_i
216 end
216 end
217 size = 100 unless size > 0
217 size = 100 unless size > 0
218 target = File.join(self.class.thumbnails_storage_path, "#{id}_#{digest}_#{size}.thumb")
218 target = File.join(self.class.thumbnails_storage_path, "#{id}_#{digest}_#{size}.thumb")
219
219
220 begin
220 begin
221 Redmine::Thumbnail.generate(self.diskfile, target, size)
221 Redmine::Thumbnail.generate(self.diskfile, target, size)
222 rescue => e
222 rescue => e
223 logger.error "An error occured while generating thumbnail for #{disk_filename} to #{target}\nException was: #{e.message}" if logger
223 logger.error "An error occured while generating thumbnail for #{disk_filename} to #{target}\nException was: #{e.message}" if logger
224 return nil
224 return nil
225 end
225 end
226 end
226 end
227 end
227 end
228
228
229 # Deletes all thumbnails
229 # Deletes all thumbnails
230 def self.clear_thumbnails
230 def self.clear_thumbnails
231 Dir.glob(File.join(thumbnails_storage_path, "*.thumb")).each do |file|
231 Dir.glob(File.join(thumbnails_storage_path, "*.thumb")).each do |file|
232 File.delete file
232 File.delete file
233 end
233 end
234 end
234 end
235
235
236 def is_text?
236 def is_text?
237 Redmine::MimeType.is_type?('text', filename)
237 Redmine::MimeType.is_type?('text', filename)
238 end
238 end
239
239
240 def is_image?
241 Redmine::MimeType.is_type?('image', filename)
242 end
243
240 def is_diff?
244 def is_diff?
241 self.filename =~ /\.(patch|diff)$/i
245 self.filename =~ /\.(patch|diff)$/i
242 end
246 end
243
247
244 # Returns true if the file is readable
248 # Returns true if the file is readable
245 def readable?
249 def readable?
246 File.readable?(diskfile)
250 File.readable?(diskfile)
247 end
251 end
248
252
249 # Returns the attachment token
253 # Returns the attachment token
250 def token
254 def token
251 "#{id}.#{digest}"
255 "#{id}.#{digest}"
252 end
256 end
253
257
254 # Finds an attachment that matches the given token and that has no container
258 # Finds an attachment that matches the given token and that has no container
255 def self.find_by_token(token)
259 def self.find_by_token(token)
256 if token.to_s =~ /^(\d+)\.([0-9a-f]+)$/
260 if token.to_s =~ /^(\d+)\.([0-9a-f]+)$/
257 attachment_id, attachment_digest = $1, $2
261 attachment_id, attachment_digest = $1, $2
258 attachment = Attachment.where(:id => attachment_id, :digest => attachment_digest).first
262 attachment = Attachment.where(:id => attachment_id, :digest => attachment_digest).first
259 if attachment && attachment.container.nil?
263 if attachment && attachment.container.nil?
260 attachment
264 attachment
261 end
265 end
262 end
266 end
263 end
267 end
264
268
265 # Bulk attaches a set of files to an object
269 # Bulk attaches a set of files to an object
266 #
270 #
267 # Returns a Hash of the results:
271 # Returns a Hash of the results:
268 # :files => array of the attached files
272 # :files => array of the attached files
269 # :unsaved => array of the files that could not be attached
273 # :unsaved => array of the files that could not be attached
270 def self.attach_files(obj, attachments)
274 def self.attach_files(obj, attachments)
271 result = obj.save_attachments(attachments, User.current)
275 result = obj.save_attachments(attachments, User.current)
272 obj.attach_saved_attachments
276 obj.attach_saved_attachments
273 result
277 result
274 end
278 end
275
279
276 # Updates the filename and description of a set of attachments
280 # Updates the filename and description of a set of attachments
277 # with the given hash of attributes. Returns true if all
281 # with the given hash of attributes. Returns true if all
278 # attachments were updated.
282 # attachments were updated.
279 #
283 #
280 # Example:
284 # Example:
281 # Attachment.update_attachments(attachments, {
285 # Attachment.update_attachments(attachments, {
282 # 4 => {:filename => 'foo'},
286 # 4 => {:filename => 'foo'},
283 # 7 => {:filename => 'bar', :description => 'file description'}
287 # 7 => {:filename => 'bar', :description => 'file description'}
284 # })
288 # })
285 #
289 #
286 def self.update_attachments(attachments, params)
290 def self.update_attachments(attachments, params)
287 params = params.transform_keys {|key| key.to_i}
291 params = params.transform_keys {|key| key.to_i}
288
292
289 saved = true
293 saved = true
290 transaction do
294 transaction do
291 attachments.each do |attachment|
295 attachments.each do |attachment|
292 if p = params[attachment.id]
296 if p = params[attachment.id]
293 attachment.filename = p[:filename] if p.key?(:filename)
297 attachment.filename = p[:filename] if p.key?(:filename)
294 attachment.description = p[:description] if p.key?(:description)
298 attachment.description = p[:description] if p.key?(:description)
295 saved &&= attachment.save
299 saved &&= attachment.save
296 end
300 end
297 end
301 end
298 unless saved
302 unless saved
299 raise ActiveRecord::Rollback
303 raise ActiveRecord::Rollback
300 end
304 end
301 end
305 end
302 saved
306 saved
303 end
307 end
304
308
305 def self.latest_attach(attachments, filename)
309 def self.latest_attach(attachments, filename)
306 attachments.sort_by(&:created_on).reverse.detect do |att|
310 attachments.sort_by(&:created_on).reverse.detect do |att|
307 filename.casecmp(att.filename) == 0
311 filename.casecmp(att.filename) == 0
308 end
312 end
309 end
313 end
310
314
311 def self.prune(age=1.day)
315 def self.prune(age=1.day)
312 Attachment.where("created_on < ? AND (container_type IS NULL OR container_type = '')", Time.now - age).destroy_all
316 Attachment.where("created_on < ? AND (container_type IS NULL OR container_type = '')", Time.now - age).destroy_all
313 end
317 end
314
318
315 # Moves an existing attachment to its target directory
319 # Moves an existing attachment to its target directory
316 def move_to_target_directory!
320 def move_to_target_directory!
317 return unless !new_record? & readable?
321 return unless !new_record? & readable?
318
322
319 src = diskfile
323 src = diskfile
320 self.disk_directory = target_directory
324 self.disk_directory = target_directory
321 dest = diskfile
325 dest = diskfile
322
326
323 return if src == dest
327 return if src == dest
324
328
325 if !FileUtils.mkdir_p(File.dirname(dest))
329 if !FileUtils.mkdir_p(File.dirname(dest))
326 logger.error "Could not create directory #{File.dirname(dest)}" if logger
330 logger.error "Could not create directory #{File.dirname(dest)}" if logger
327 return
331 return
328 end
332 end
329
333
330 if !FileUtils.mv(src, dest)
334 if !FileUtils.mv(src, dest)
331 logger.error "Could not move attachment from #{src} to #{dest}" if logger
335 logger.error "Could not move attachment from #{src} to #{dest}" if logger
332 return
336 return
333 end
337 end
334
338
335 update_column :disk_directory, disk_directory
339 update_column :disk_directory, disk_directory
336 end
340 end
337
341
338 # Moves existing attachments that are stored at the root of the files
342 # Moves existing attachments that are stored at the root of the files
339 # directory (ie. created before Redmine 2.3) to their target subdirectories
343 # directory (ie. created before Redmine 2.3) to their target subdirectories
340 def self.move_from_root_to_target_directory
344 def self.move_from_root_to_target_directory
341 Attachment.where("disk_directory IS NULL OR disk_directory = ''").find_each do |attachment|
345 Attachment.where("disk_directory IS NULL OR disk_directory = ''").find_each do |attachment|
342 attachment.move_to_target_directory!
346 attachment.move_to_target_directory!
343 end
347 end
344 end
348 end
345
349
346 # Returns true if the extension is allowed, otherwise false
350 # Returns true if the extension is allowed, otherwise false
347 def self.valid_extension?(extension)
351 def self.valid_extension?(extension)
348 extension = extension.downcase.sub(/\A\.+/, '')
352 extension = extension.downcase.sub(/\A\.+/, '')
349
353
350 denied, allowed = [:attachment_extensions_denied, :attachment_extensions_allowed].map do |setting|
354 denied, allowed = [:attachment_extensions_denied, :attachment_extensions_allowed].map do |setting|
351 Setting.send(setting).to_s.split(",").map {|s| s.strip.downcase.sub(/\A\.+/, '')}.reject(&:blank?)
355 Setting.send(setting).to_s.split(",").map {|s| s.strip.downcase.sub(/\A\.+/, '')}.reject(&:blank?)
352 end
356 end
353 if denied.present? && denied.include?(extension)
357 if denied.present? && denied.include?(extension)
354 return false
358 return false
355 end
359 end
356 unless allowed.blank? || allowed.include?(extension)
360 unless allowed.blank? || allowed.include?(extension)
357 return false
361 return false
358 end
362 end
359 true
363 true
360 end
364 end
361
365
362 private
366 private
363
367
364 # Physically deletes the file from the file system
368 # Physically deletes the file from the file system
365 def delete_from_disk!
369 def delete_from_disk!
366 if disk_filename.present? && File.exist?(diskfile)
370 if disk_filename.present? && File.exist?(diskfile)
367 File.delete(diskfile)
371 File.delete(diskfile)
368 end
372 end
369 end
373 end
370
374
371 def sanitize_filename(value)
375 def sanitize_filename(value)
372 # get only the filename, not the whole path
376 # get only the filename, not the whole path
373 just_filename = value.gsub(/\A.*(\\|\/)/m, '')
377 just_filename = value.gsub(/\A.*(\\|\/)/m, '')
374
378
375 # Finally, replace invalid characters with underscore
379 # Finally, replace invalid characters with underscore
376 just_filename.gsub(/[\/\?\%\*\:\|\"\'<>\n\r]+/, '_')
380 just_filename.gsub(/[\/\?\%\*\:\|\"\'<>\n\r]+/, '_')
377 end
381 end
378
382
379 # Returns the subdirectory in which the attachment will be saved
383 # Returns the subdirectory in which the attachment will be saved
380 def target_directory
384 def target_directory
381 time = created_on || DateTime.now
385 time = created_on || DateTime.now
382 time.strftime("%Y/%m")
386 time.strftime("%Y/%m")
383 end
387 end
384
388
385 # Returns an ASCII or hashed filename that do not
389 # Returns an ASCII or hashed filename that do not
386 # exists yet in the given subdirectory
390 # exists yet in the given subdirectory
387 def self.disk_filename(filename, directory=nil)
391 def self.disk_filename(filename, directory=nil)
388 timestamp = DateTime.now.strftime("%y%m%d%H%M%S")
392 timestamp = DateTime.now.strftime("%y%m%d%H%M%S")
389 ascii = ''
393 ascii = ''
390 if filename =~ %r{^[a-zA-Z0-9_\.\-]*$}
394 if filename =~ %r{^[a-zA-Z0-9_\.\-]*$}
391 ascii = filename
395 ascii = filename
392 else
396 else
393 ascii = Digest::MD5.hexdigest(filename)
397 ascii = Digest::MD5.hexdigest(filename)
394 # keep the extension if any
398 # keep the extension if any
395 ascii << $1 if filename =~ %r{(\.[a-zA-Z0-9]+)$}
399 ascii << $1 if filename =~ %r{(\.[a-zA-Z0-9]+)$}
396 end
400 end
397 while File.exist?(File.join(storage_path, directory.to_s, "#{timestamp}_#{ascii}"))
401 while File.exist?(File.join(storage_path, directory.to_s, "#{timestamp}_#{ascii}"))
398 timestamp.succ!
402 timestamp.succ!
399 end
403 end
400 "#{timestamp}_#{ascii}"
404 "#{timestamp}_#{ascii}"
401 end
405 end
402 end
406 end
@@ -1,42 +1,42
1 <div class="attachments">
1 <div class="attachments">
2 <div class="contextual">
2 <div class="contextual">
3 <%= link_to(l(:label_edit_attachments),
3 <%= link_to(l(:label_edit_attachments),
4 container_attachments_edit_path(container),
4 container_attachments_edit_path(container),
5 :title => l(:label_edit_attachments),
5 :title => l(:label_edit_attachments),
6 :class => 'icon-only icon-edit'
6 :class => 'icon-only icon-edit'
7 ) if options[:editable] %>
7 ) if options[:editable] %>
8 </div>
8 </div>
9 <% for attachment in attachments %>
9 <% for attachment in attachments %>
10 <p><%= link_to_attachment attachment, :class => 'icon icon-attachment', :download => true -%>
10 <p><%= link_to_attachment attachment, :class => 'icon icon-attachment', :download => true -%>
11 <% if attachment.is_text? %>
11 <% if attachment.is_text? || attachment.is_image? %>
12 <%= link_to l(:button_view),
12 <%= link_to l(:button_view),
13 { :controller => 'attachments', :action => 'show',
13 { :controller => 'attachments', :action => 'show',
14 :id => attachment, :filename => attachment.filename },
14 :id => attachment, :filename => attachment.filename },
15 :class => 'icon-only icon-magnifier',
15 :class => 'icon-only icon-magnifier',
16 :title => l(:button_view) %>
16 :title => l(:button_view) %>
17 <% end %>
17 <% end %>
18 <%= " - #{attachment.description}" unless attachment.description.blank? %>
18 <%= " - #{attachment.description}" unless attachment.description.blank? %>
19 <span class="size">(<%= number_to_human_size attachment.filesize %>)</span>
19 <span class="size">(<%= number_to_human_size attachment.filesize %>)</span>
20 <% if options[:deletable] %>
20 <% if options[:deletable] %>
21 <%= link_to l(:button_delete), attachment_path(attachment),
21 <%= link_to l(:button_delete), attachment_path(attachment),
22 :data => {:confirm => l(:text_are_you_sure)},
22 :data => {:confirm => l(:text_are_you_sure)},
23 :method => :delete,
23 :method => :delete,
24 :class => 'delete icon-only icon-del',
24 :class => 'delete icon-only icon-del',
25 :title => l(:button_delete) %>
25 :title => l(:button_delete) %>
26 <% end %>
26 <% end %>
27 <% if options[:author] %>
27 <% if options[:author] %>
28 <span class="author"><%= attachment.author %>, <%= format_time(attachment.created_on) %></span>
28 <span class="author"><%= attachment.author %>, <%= format_time(attachment.created_on) %></span>
29 <% end %>
29 <% end %>
30 </p>
30 </p>
31 <% end %>
31 <% end %>
32 <% if defined?(thumbnails) && thumbnails %>
32 <% if defined?(thumbnails) && thumbnails %>
33 <% images = attachments.select(&:thumbnailable?) %>
33 <% images = attachments.select(&:thumbnailable?) %>
34 <% if images.any? %>
34 <% if images.any? %>
35 <div class="thumbnails">
35 <div class="thumbnails">
36 <% images.each do |attachment| %>
36 <% images.each do |attachment| %>
37 <div><%= thumbnail_tag(attachment) %></div>
37 <div><%= thumbnail_tag(attachment) %></div>
38 <% end %>
38 <% end %>
39 </div>
39 </div>
40 <% end %>
40 <% end %>
41 <% end %>
41 <% end %>
42 </div>
42 </div>
@@ -1,15 +1,19
1 <%= call_hook(:view_repositories_show_contextual, { :repository => @repository, :project => @project }) %>
1 <%= call_hook(:view_repositories_show_contextual, { :repository => @repository, :project => @project }) %>
2
2
3 <div class="contextual">
3 <div class="contextual">
4 <%= render :partial => 'navigation' %>
4 <%= render :partial => 'navigation' %>
5 </div>
5 </div>
6
6
7 <h2><%= render :partial => 'breadcrumbs', :locals => { :path => @path, :kind => 'file', :revision => @rev } %></h2>
7 <h2><%= render :partial => 'breadcrumbs', :locals => { :path => @path, :kind => 'file', :revision => @rev } %></h2>
8
8
9 <%= render :partial => 'link_to_functions' %>
9 <%= render :partial => 'link_to_functions' %>
10
10
11 <% if Redmine::MimeType.is_type?('image', @path) %>
12 <%= render :partial => 'common/image', :locals => {:path => url_for(params.merge(:action => 'raw')), :alt => @path} %>
13 <% else %>
11 <%= render :partial => 'common/file', :locals => {:filename => @path, :content => @content} %>
14 <%= render :partial => 'common/file', :locals => {:filename => @path, :content => @content} %>
15 <% end %>
12
16
13 <% content_for :header_tags do %>
17 <% content_for :header_tags do %>
14 <%= stylesheet_link_tag "scm" %>
18 <%= stylesheet_link_tag "scm" %>
15 <% end %>
19 <% end %>
@@ -1,107 +1,109
1
1
2 table.revision-info td {
2 table.revision-info td {
3 margin: 0px;
3 margin: 0px;
4 padding: 0px;
4 padding: 0px;
5 }
5 }
6
6
7 div.revision-graph { position: absolute; min-width: 1px; }
7 div.revision-graph { position: absolute; min-width: 1px; }
8
8
9 div.changeset-changes ul { margin: 0; padding: 0; }
9 div.changeset-changes ul { margin: 0; padding: 0; }
10 div.changeset-changes ul > ul { margin-left: 18px; padding: 0; }
10 div.changeset-changes ul > ul { margin-left: 18px; padding: 0; }
11
11
12 li.change {
12 li.change {
13 list-style-type:none;
13 list-style-type:none;
14 background-image: url(../images/bullet_black.png);
14 background-image: url(../images/bullet_black.png);
15 background-position: 1px 1px;
15 background-position: 1px 1px;
16 background-repeat: no-repeat;
16 background-repeat: no-repeat;
17 padding-top: 1px;
17 padding-top: 1px;
18 padding-bottom: 1px;
18 padding-bottom: 1px;
19 padding-left: 20px;
19 padding-left: 20px;
20 margin: 0;
20 margin: 0;
21 }
21 }
22 li.change.folder { background-image: url(../images/folder_open.png); }
22 li.change.folder { background-image: url(../images/folder_open.png); }
23 li.change.folder.change-A { background-image: url(../images/folder_open_add.png); }
23 li.change.folder.change-A { background-image: url(../images/folder_open_add.png); }
24 li.change.folder.change-M { background-image: url(../images/folder_open_orange.png); }
24 li.change.folder.change-M { background-image: url(../images/folder_open_orange.png); }
25 li.change.change-A { background-image: url(../images/bullet_add.png); }
25 li.change.change-A { background-image: url(../images/bullet_add.png); }
26 li.change.change-M { background-image: url(../images/bullet_orange.png); }
26 li.change.change-M { background-image: url(../images/bullet_orange.png); }
27 li.change.change-C { background-image: url(../images/bullet_blue.png); }
27 li.change.change-C { background-image: url(../images/bullet_blue.png); }
28 li.change.change-R { background-image: url(../images/bullet_purple.png); }
28 li.change.change-R { background-image: url(../images/bullet_purple.png); }
29 li.change.change-D { background-image: url(../images/bullet_delete.png); }
29 li.change.change-D { background-image: url(../images/bullet_delete.png); }
30
30
31 li.change .copied-from { font-style: italic; color: #999; font-size: 0.9em; }
31 li.change .copied-from { font-style: italic; color: #999; font-size: 0.9em; }
32 li.change .copied-from:before { content: " - "}
32 li.change .copied-from:before { content: " - "}
33
33
34 #changes-legend { float: right; font-size: 0.8em; margin: 0; }
34 #changes-legend { float: right; font-size: 0.8em; margin: 0; }
35 #changes-legend li { float: left; background-position: 5px 0; }
35 #changes-legend li { float: left; background-position: 5px 0; }
36
36
37 table.filecontent { border: 1px solid #e2e2e2; border-collapse: collapse; width:98%; background-color: #fafafa; }
37 table.filecontent { border: 1px solid #e2e2e2; border-collapse: collapse; width:98%; background-color: #fafafa; }
38 table.filecontent tbody {font-family:Consolas, Menlo, "Liberation Mono", Courier, monospace; font-size:12px;}
38 table.filecontent tbody {font-family:Consolas, Menlo, "Liberation Mono", Courier, monospace; font-size:12px;}
39 table.filecontent th { border: 1px solid #e2e2e2; background-color: #eee; }
39 table.filecontent th { border: 1px solid #e2e2e2; background-color: #eee; }
40 table.filecontent th.filename { background-color: #e4e4d4; text-align: left; padding:5px;}
40 table.filecontent th.filename { background-color: #e4e4d4; text-align: left; padding:5px;}
41 table.filecontent tr.spacing th { text-align:center; }
41 table.filecontent tr.spacing th { text-align:center; }
42 table.filecontent tr.spacing td { height: 0.4em; background: #EAF2F5;}
42 table.filecontent tr.spacing td { height: 0.4em; background: #EAF2F5;}
43 table.filecontent th.line-num {
43 table.filecontent th.line-num {
44 border: 1px solid #e2e2e2;
44 border: 1px solid #e2e2e2;
45 text-align: right;
45 text-align: right;
46 width: 2%;
46 width: 2%;
47 padding: 0 3px 0 0;
47 padding: 0 3px 0 0;
48 color: #999;
48 color: #999;
49 user-select: none;
49 user-select: none;
50 -moz-user-select: none;
50 -moz-user-select: none;
51 -o-user-select: none;
51 -o-user-select: none;
52 -ms-user-select: none;
52 -ms-user-select: none;
53 -webkit-user-select: none;
53 -webkit-user-select: none;
54 font-weight:normal;
54 font-weight:normal;
55 }
55 }
56 table.filecontent th.line-num a {
56 table.filecontent th.line-num a {
57 text-decoration: none;
57 text-decoration: none;
58 color: inherit;
58 color: inherit;
59 }
59 }
60 table.filecontent td.line-code {padding: 0 0 0 4px;}
60 table.filecontent td.line-code {padding: 0 0 0 4px;}
61 table.filecontent td.line-code pre {
61 table.filecontent td.line-code pre {
62 margin: 0px;
62 margin: 0px;
63 white-space: pre-wrap;
63 white-space: pre-wrap;
64 font-family:Consolas, Menlo, "Liberation Mono", Courier, monospace; font-size:12px;
64 font-family:Consolas, Menlo, "Liberation Mono", Courier, monospace; font-size:12px;
65 }
65 }
66
66
67 table.filecontent tr:target th.line-num { background-color:#E0E0E0; color: #777; }
67 table.filecontent tr:target th.line-num { background-color:#E0E0E0; color: #777; }
68 table.filecontent tr:target td.line-code { background-color:#DDEEFF; }
68 table.filecontent tr:target td.line-code { background-color:#DDEEFF; }
69
69
70 img.filecontent.image { max-width: 100%; }
71
70 /* 12 different colors for the annonate view */
72 /* 12 different colors for the annonate view */
71 table.annotate tr.bloc-0 {background: #FFFFBF;}
73 table.annotate tr.bloc-0 {background: #FFFFBF;}
72 table.annotate tr.bloc-1 {background: #EABFFF;}
74 table.annotate tr.bloc-1 {background: #EABFFF;}
73 table.annotate tr.bloc-2 {background: #BFFFFF;}
75 table.annotate tr.bloc-2 {background: #BFFFFF;}
74 table.annotate tr.bloc-3 {background: #FFD9BF;}
76 table.annotate tr.bloc-3 {background: #FFD9BF;}
75 table.annotate tr.bloc-4 {background: #E6FFBF;}
77 table.annotate tr.bloc-4 {background: #E6FFBF;}
76 table.annotate tr.bloc-5 {background: #BFCFFF;}
78 table.annotate tr.bloc-5 {background: #BFCFFF;}
77 table.annotate tr.bloc-6 {background: #FFBFEF;}
79 table.annotate tr.bloc-6 {background: #FFBFEF;}
78 table.annotate tr.bloc-7 {background: #FFE6BF;}
80 table.annotate tr.bloc-7 {background: #FFE6BF;}
79 table.annotate tr.bloc-8 {background: #FFE680;}
81 table.annotate tr.bloc-8 {background: #FFE680;}
80 table.annotate tr.bloc-9 {background: #AA80FF;}
82 table.annotate tr.bloc-9 {background: #AA80FF;}
81 table.annotate tr.bloc-10 {background: #FFBFDC;}
83 table.annotate tr.bloc-10 {background: #FFBFDC;}
82 table.annotate tr.bloc-11 {background: #BFE4FF;}
84 table.annotate tr.bloc-11 {background: #BFE4FF;}
83
85
84 table.annotate td.revision {
86 table.annotate td.revision {
85 padding:0;
87 padding:0;
86 text-align: center;
88 text-align: center;
87 width: 2%;
89 width: 2%;
88 padding-left: 1em;
90 padding-left: 1em;
89 background: inherit;
91 background: inherit;
90 }
92 }
91
93
92 table.annotate td.author {
94 table.annotate td.author {
93 padding:0;
95 padding:0;
94 text-align: center;
96 text-align: center;
95 border-right: 1px solid #d7d7d7;
97 border-right: 1px solid #d7d7d7;
96 white-space: nowrap;
98 white-space: nowrap;
97 padding-left: 1em;
99 padding-left: 1em;
98 padding-right: 1em;
100 padding-right: 1em;
99 width: 3%;
101 width: 3%;
100 background: inherit;
102 background: inherit;
101 }
103 }
102
104
103 table.annotate td.line-code { background-color: #fafafa; }
105 table.annotate td.line-code { background-color: #fafafa; }
104
106
105 div.action_M { background: #fd8 }
107 div.action_M { background: #fd8 }
106 div.action_D { background: #f88 }
108 div.action_D { background: #f88 }
107 div.action_A { background: #bfb }
109 div.action_A { background: #bfb }
General Comments 0
You need to be logged in to leave comments. Login now