Mercurial > hg > soundsoftware-site
changeset 313:862e47cc1e09 live
Merge from branch "bug_97"
author | Chris Cannam |
---|---|
date | Mon, 28 Mar 2011 18:04:17 +0100 |
parents | 8019c250165b (diff) 76c548e4e6e4 (current diff) |
children | c80103b81194 |
files | config/locales/en-GB.yml config/locales/en.yml extra/soundsoftware/SoundSoftware-salted.pm |
diffstat | 21 files changed, 966 insertions(+), 55 deletions(-) [+] |
line wrap: on
line diff
--- a/app/controllers/application_controller.rb Thu Mar 24 13:58:03 2011 +0000 +++ b/app/controllers/application_controller.rb Mon Mar 28 18:04:17 2011 +0100 @@ -263,6 +263,12 @@ uri = URI.parse(back_url) # do not redirect user to another host or to the login or register page if (uri.relative? || (uri.host == request.host)) && !uri.path.match(%r{/(login|account/register)}) + # soundsoftware: if login page is https but back_url http, + # switch back_url to https to ensure cookie validity (#83) + if (uri.scheme == "http") && (URI.parse(request.url).scheme == "https") + uri.scheme = "https" + back_url = uri.to_s + end redirect_to(back_url) return end
--- a/app/controllers/attachments_controller.rb Thu Mar 24 13:58:03 2011 +0000 +++ b/app/controllers/attachments_controller.rb Mon Mar 28 18:04:17 2011 +0100 @@ -16,9 +16,11 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. class AttachmentsController < ApplicationController + before_filter :find_project before_filter :file_readable, :read_authorize, :except => :destroy before_filter :delete_authorize, :only => :destroy + before_filter :active_authorize, :only => :toggle_active verify :method => :post, :only => :destroy @@ -54,6 +56,12 @@ redirect_to :controller => 'projects', :action => 'show', :id => @project end + def toggle_active + @attachment.active = !@attachment.active? + @attachment.save! + render :layout => false + end + private def find_project @attachment = Attachment.find(params[:id]) @@ -77,6 +85,10 @@ @attachment.deletable? ? true : deny_access end + def active_authorize + true + end + def detect_content_type(attachment) content_type = attachment.content_type if content_type.blank?
--- a/app/controllers/files_controller.rb Thu Mar 24 13:58:03 2011 +0000 +++ b/app/controllers/files_controller.rb Mon Mar 28 18:04:17 2011 +0100 @@ -8,8 +8,9 @@ include SortHelper def index - sort_init 'filename', 'asc' + sort_init 'active', 'asc' sort_update 'filename' => "#{Attachment.table_name}.filename", + 'active' => "#{Attachment.table_name}.active", 'created_on' => "#{Attachment.table_name}.created_on", 'size' => "#{Attachment.table_name}.filesize", 'downloads' => "#{Attachment.table_name}.downloads" @@ -33,4 +34,5 @@ end redirect_to project_files_path(@project) end + end
--- a/app/controllers/sys_controller.rb Thu Mar 24 13:58:03 2011 +0000 +++ b/app/controllers/sys_controller.rb Mon Mar 28 18:04:17 2011 +0100 @@ -55,6 +55,20 @@ render :nothing => true, :status => 404 end + def get_external_repo_url + project = Project.find(params[:id]) + if project.repository + repo = project.repository + if repo.is_external? + render :text => repo.external_url, :status => 200 + else + render :nothing => true, :status => 200 + end + end + rescue ActiveRecord::RecordNotFound + render :nothing => true, :status => 404 + end + def set_embedded_active project = Project.find(params[:id]) mods = project.enabled_modules
--- a/app/helpers/repositories_helper.rb Thu Mar 24 13:58:03 2011 +0000 +++ b/app/helpers/repositories_helper.rb Mon Mar 28 18:04:17 2011 +0100 @@ -145,12 +145,6 @@ send(method, form, repository) if repository.is_a?(Repository) && respond_to?(method) && method != 'repository_field_tags' end - - def ssamr_scm_update(repository) - check_box_tag('repository_scm', value = "1", checked = false, onchange => remote_function(:url => { :controller => 'repositories', :action => 'ssamr_edit', :id => @project }, :method => :get, :with => "Form.serialize(this.form)")) - - end - def scm_select_tag(repository) scm_options = [["--- #{l(:actionview_instancetag_blank_option)} ---", '']] Redmine::Scm::Base.all.each do |scm|
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/app/views/attachments/toggle_active.rhtml Mon Mar 28 18:04:17 2011 +0100 @@ -0,0 +1,7 @@ +<%= +file = Attachment.find(params[:id]) +active_id = "active-" + file.id.to_s +link_to_remote image_tag(file.active? ? 'fav.png' : 'fav_off.png'), + :url => {:controller => 'attachments', :action => 'toggle_active', :project_id => @project.id, :id => file}, + :update => active_id +%>
--- a/app/views/files/index.html.erb Thu Mar 24 13:58:03 2011 +0000 +++ b/app/views/files/index.html.erb Mon Mar 28 18:04:17 2011 +0100 @@ -5,29 +5,51 @@ <h2><%=l(:label_attachment_plural)%></h2> <% delete_allowed = User.current.allowed_to?(:manage_files, @project) %> +<% active_change_allowed = delete_allowed %> <table class="list files"> <thead><tr> + <%= sort_header_tag('active', :caption => l(:field_active)) %> <%= sort_header_tag('filename', :caption => l(:field_filename)) %> <%= sort_header_tag('created_on', :caption => l(:label_date), :default_order => 'desc') %> <%= sort_header_tag('size', :caption => l(:field_filesize), :default_order => 'desc') %> - <%= sort_header_tag('downloads', :caption => l(:label_downloads_abbr), :default_order => 'desc') %> + <%= sort_header_tag('downloads', :caption => l(:field_downloads), :default_order => 'desc') %> <th>MD5</th> <th></th> </tr></thead> <tbody> +<% have_file = false %> <% @containers.each do |container| %> <% next if container.attachments.empty? -%> <% if container.is_a?(Version) -%> <tr> - <th colspan="6" align="left"> + <th colspan="7" align="left"> <%= link_to(h(container), {:controller => 'versions', :action => 'show', :id => container}, :class => "icon icon-package") %> </th> </tr> <% end -%> <% container.attachments.each do |file| %> - <tr class="file <%= cycle("odd", "even") %>"> - <td class="filename"><%= link_to_attachment file, :download => true, :title => file.description %></td> + <tr class="file <%= cycle("odd", "even") %> <%= "active" if file.active? %>"> + <td class="active"> + <% have_file = true %> + <% if active_change_allowed + active_id = "active-" + file.id.to_s -%> + <div id="<%= active_id %>"> + <%= link_to_remote image_tag(file.active? ? 'fav.png' : 'fav_off.png'), + :url => {:controller => 'attachments', :action => 'toggle_active', :project_id => @project.id, :id => file}, + :update => active_id + %> + </div> + <% else -%> + <%= image_tag('fav.png') if file.active? %> + <% end -%> + </td> + <% if file.active? %> + <td class="filename active"><%= link_to_attachment file, :download => true %><br><span class="description"><%= h(file.description) %></span></td> + <% else %> + <td class="filename"><%= link_to_attachment file, :download => true, :title => file.description %> + <% end %> + </td> <td class="created_on"><%= format_time(file.created_on) %></td> <td class="filesize"><%= number_to_human_size(file.filesize) %></td> <td class="downloads"><%= file.downloads %></td> @@ -43,4 +65,6 @@ </tbody> </table> +<%= l(:text_files_active_change) if active_change_allowed and have_file %> + <% html_title(l(:label_attachment_plural)) -%>
--- a/app/views/projects/settings/_members.rhtml Thu Mar 24 13:58:03 2011 +0000 +++ b/app/views/projects/settings/_members.rhtml Mon Mar 28 18:04:17 2011 +0100 @@ -75,11 +75,9 @@ </div> <p><%= l(:label_set_role_plural) %>:</p> - <p> <% roles.each do |role| %> - <label><%= check_box_tag 'member[role_ids][]', role.id %> <%=h role %> </label> - <i> <%=l( 'label_' + role.name.downcase + "_description").to_sym %></i> - <% end %></p> + <label><%= check_box_tag 'member[role_ids][]', role.id %> <%=h role %> </label><div style="margin-left: 2em; margin-bottom: 0.5em"><i><%=l( 'label_' + role.name.downcase + "_description").to_sym %></i></div> + <% end %> <p><%= submit_tag l(:button_add), :id => 'member-add-submit' %></p> </fieldset>
--- a/app/views/projects/settings/_repository.rhtml Thu Mar 24 13:58:03 2011 +0000 +++ b/app/views/projects/settings/_repository.rhtml Mon Mar 28 18:04:17 2011 +0100 @@ -10,17 +10,16 @@ <div class="box tabular"> +<p> +<% if @repository %> + <%= l(:text_settings_repo_explanation) %></ br> + <% if @repository.is_external %> + <p><%= l(:text_settings_repo_is_external) %></ br> + <% else %> + <p><%= l(:text_settings_repo_is_internal) %></ br> + <% end %> +</p> -<% if @repository %> - <% if @repository.is_external %> - <%= l(:text_settings_repo_is_external) %></ br> - <% else %> - <%= l(:text_settings_repo_is_internal) %></ br> -<% end %> - <% else %> - <%= l(:text_settings_repo_creation) %></ br> -<% end %> - @@ -28,16 +27,17 @@ <p> <%= label_tag('repository_is_external', l(:label_is_external_repository)) %> <%= check_box :repository, :is_external, :onclick => "toggle_ext_url()" %> - <%= l(:setting_external_repository) %> + <br/><em><%= l(:setting_external_repository) %></em> </p> <p> <%= label_tag('repository_external_url', l(:label_repository_external_url)) %> - <%= text_field :repository, :external_url, :disabled => true %> - <%= l(:setting_external_repository_url) %> + <%= text_field :repository, :external_url, :disabled => !(@repository and @repository.is_external) %> + <br/><em><%= l(:setting_external_repository_url) %></em> </p> +<p><%= l(:text_settings_repo_need_help) %></p> </div> @@ -48,4 +48,9 @@ </div> <%= submit_tag(l(:button_save), :onclick => remote_function(:url => { :controller => 'repositories', :action => 'edit', :id => @project }, :method => :get, :with => "Form.serialize(this.form)")) %> + +<% else %> + <%= l(:text_settings_repo_creation) %></ br> <% end %> + +<% end %>
--- a/config/locales/en-GB.yml Thu Mar 24 13:58:03 2011 +0000 +++ b/config/locales/en-GB.yml Mon Mar 28 18:04:17 2011 +0100 @@ -903,6 +903,7 @@ text_wiki_page_reassign_children: "Reassign child pages to this parent page" text_own_membership_delete_confirmation: "You are about to remove some or all of your permissions and may no longer be able to edit this project after that.\nAre you sure you want to continue?" text_settings_repo_creation: The repository for a project should be set up automatically within a few minutes of the project being created.<br>You should not have to adjust any settings here; please check again in ten minutes. + text_files_active_change: <br>Click the star to switch active status for a download on or off.<br>Active files will be shown more prominently in the download page. text_settings_repo_is_internal: The repository for this project is an internal Mercurial Repository, hosted by SoundSoftware.ac.uk. text_settings_repo_is_external: You are tracking an external repository, with a mirror Mercurial repository hosted by SoundSoftware.ac.uk.
--- a/config/locales/en.yml Thu Mar 24 13:58:03 2011 +0000 +++ b/config/locales/en.yml Mon Mar 28 18:04:17 2011 +0100 @@ -306,9 +306,9 @@ field_text: Text field field_visible: Visible - setting_external_repository: "In the case you wish to follow an external repository" - setting_external_repository_url: "The external repository URL" - label_repository_external_url: "External rep URL" + setting_external_repository: "Select this if the project's main repository is hosted somewhere else" + setting_external_repository_url: "The URL of the existing external repository. Must be publicly accessible without a password" + label_repository_external_url: "External repository URL" setting_tipoftheday_text: Tip of the Day setting_notifications_text: Notifications field_terms_and_conditions: 'Terms and Conditions:' @@ -634,7 +634,7 @@ label_not_contains: doesn't contain label_day_plural: days label_repository: Repository - label_is_external_repository: External? + label_is_external_repository: Track an external repository label_repository_plural: Repositories label_browse: Browse label_modification: "{{count}} change" @@ -863,6 +863,7 @@ version_status_closed: closed field_active: Active + field_current: Current text_select_mail_notifications: Select actions for which email notifications should be sent. text_regexp_info: eg. ^[A-Z0-9]+$ @@ -917,7 +918,7 @@ text_enumeration_destroy_question: "{{count}} objects are assigned to this value." text_enumeration_category_reassign_to: 'Reassign them to this value:' text_email_delivery_not_configured: "Email delivery is not configured, and notifications are disabled.\nConfigure your SMTP server in config/email.yml and restart the application to enable them." - text_repository_usernames_mapping: "Select or update the Redmine user mapped to each username found in the repository log.\nUsers with the same Redmine and repository username or email are automatically mapped." + text_repository_usernames_mapping: "Select the project member associated with each username found in the repository log.\nUsers whose name or email matches that in the repository are mapped automatically." text_diff_truncated: '... This diff was truncated because it exceeds the maximum size that can be displayed.' text_custom_field_possible_values_info: 'One line for each value' text_wiki_page_destroy_question: "This page has {{descendants}} child page(s) and descendant(s). What do you want to do?" @@ -927,12 +928,15 @@ text_own_membership_delete_confirmation: "You are about to remove some or all of your permissions and may no longer be able to edit this project after that.\nAre you sure you want to continue?" text_zoom_in: Zoom in text_zoom_out: Zoom out - text_settings_repo_creation: The repository for a project should be set up automatically within a few minutes of the project being created.<br>You should not have to adjust any settings here.<br>Please check again in ten minutes, and <a href="/projects/soundsoftware-site/wiki/Help">contact us</a> if there is any problem. - text_settings_repo_is_internal: The repository for this project is an internal Mercurial Repository, hosted by SoundSoftware.ac.uk. - text_settings_repo_is_external: You are tracking an external repository, with a mirror Mercurial repository hosted by SoundSoftware.ac.uk. + text_settings_repo_creation: <b>Creating repository...</b></p><p>The source code repository for a project will be set up automatically within a few minutes of the project being created.</p><p>Please check again in five minutes, and <a href="/projects/soundsoftware-site/wiki/Help">contact us</a> if there is any problem.</p><p>If you wish to use this project to track a repository that is already hosted somewhere else, please wait until the repository has been created here and then return to this settings page to configure it.</p><p>If you don't want a repository at all, go to the Modules tab and switch it off there. + text_files_active_change: <br>Click the star to switch active status for a download on or off.<br>Active files will be shown more prominently in the download page. + text_settings_repo_explanation: <b>External repositories</b><p>Normally your project's primary repository will be the Mercurial repository hosted at this site.<p>However, if you already have your project hosted somewhere else, you can specify your existing external repository's URL here – then this site will track that repository in a read-only “mirror” copy. External Mercurial, git and Subversion repositories can be tracked. Note that you cannot switch to an external repository if you have already made any commits to the repository hosted here. + text_settings_repo_is_internal: Currently the repository hosted at this site is the primary repository for this project. + text_settings_repo_is_external: Currently the repository hosted at this site is a read-only copy of an external repository. + text_settings_repo_need_help: Please <a href="/projects/soundsoftware-site/wiki/Help">contact us</a> if you need help deciding how best to set this up.<br>We can also import complete revision history from other systems into a new primary repository for you if you wish. + text_repository_external: "The primary repository for this project is hosted at <code>{{location}}</code><br>This repository is a read-only copy which is updated automatically." - - + default_role_manager: Manager default_role_developer: Developer default_role_reporter: Reporter @@ -965,6 +969,12 @@ label_reporter_description: Can submit bug reports; has read access for private projects label_set_role_plural: Choose roles for new member + + label_manager_description: All powers including adding and removing members and adjusting project settings + label_developer_description: Can commit to repository and carry out most project editing tasks + label_reporter_description: Can submit bug reports; has read access for private projects + + label_set_role_plural: Choose roles for new member notice_added_to_project: 'You have been added to the project "{{project_name}}".' notice_project_homepage: "You can visit the project using the following link: {{project_url}}" mail_subject_added_to_project: "You've been added to a project on {{value}}"
--- a/config/routes.rb Thu Mar 24 13:58:03 2011 +0000 +++ b/config/routes.rb Mon Mar 28 18:04:17 2011 +0100 @@ -236,6 +236,7 @@ map.with_options :controller => 'sys' do |sys| sys.connect 'sys/projects.:format', :action => 'projects', :conditions => {:method => :get} sys.connect 'sys/projects/:id/repository.:format', :action => 'create_project_repository', :conditions => {:method => :post} + sys.connect 'sys/projects/:id/external-repository.:format', :action => 'get_external_repo_url', :conditions => {:method => :get} sys.connect 'sys/projects/:id/embedded.:format', :action => 'set_embedded_active', :conditions => { :method => :post } end
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/db/migrate/20110303152903_add_active_column_to_attachments.rb Mon Mar 28 18:04:17 2011 +0100 @@ -0,0 +1,9 @@ +class AddActiveColumnToAttachments < ActiveRecord::Migration + def self.up + add_column :attachments, :active, :boolean + end + + def self.down + remove_column :attachments, :active + end +end
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/extra/soundsoftware/SoundSoftware-salted.pm Mon Mar 28 18:04:17 2011 +0100 @@ -0,0 +1,470 @@ +package Apache::Authn::SoundSoftware; + +=head1 Apache::Authn::SoundSoftware + +SoundSoftware - a mod_perl module for Apache authentication against a +Redmine database and optional LDAP implementing the access control +rules required for the SoundSoftware.ac.uk repository site. + +=head1 SYNOPSIS + +This module is closely based on the Redmine.pm authentication module +provided with Redmine. It is intended to be used for authentication +in front of a repository service such as hgwebdir. + +Requirements: + +1. Clone/pull from repo for public project: Any user, no +authentication required + +2. Clone/pull from repo for private project: Project members only + +3. Push to repo for public project: "Permitted" users only (this +probably means project members who are also identified in the hgrc web +section for the repository and so will be approved by hgwebdir?) + +4. Push to repo for private project: "Permitted" users only (as above) + +5. Push to any repo that is tracking an external repo: Refused always + +=head1 INSTALLATION + +Debian/ubuntu: + + apt-get install libapache-dbi-perl libapache2-mod-perl2 \ + libdbd-mysql-perl libauthen-simple-ldap-perl libio-socket-ssl-perl + +Note that LDAP support is hardcoded "on" in this script (it is +optional in the original Redmine.pm). + +=head1 CONFIGURATION + + ## This module has to be in your perl path + ## eg: /usr/local/lib/site_perl/Apache/Authn/SoundSoftware.pm + PerlLoadModule Apache::Authn::SoundSoftware + + # Example when using hgwebdir + ScriptAlias / "/var/hg/hgwebdir.cgi/" + + <Location /> + AuthName "Mercurial" + AuthType Basic + Require valid-user + PerlAccessHandler Apache::Authn::SoundSoftware::access_handler + PerlAuthenHandler Apache::Authn::SoundSoftware::authen_handler + SoundSoftwareDSN "DBI:mysql:database=redmine;host=localhost" + SoundSoftwareDbUser "redmine" + SoundSoftwareDbPass "password" + Options +ExecCGI + AddHandler cgi-script .cgi + ## Optional where clause (fulltext search would be slow and + ## database dependant). + # SoundSoftwareDbWhereClause "and members.role_id IN (1,2)" + ## Optional prefix for local repository URLs + # SoundSoftwareRepoPrefix "/var/hg/" + </Location> + +See the original Redmine.pm for further configuration notes. + +=cut + +use strict; +use warnings FATAL => 'all', NONFATAL => 'redefine'; + +use DBI; +use Digest::SHA1; +use Authen::Simple::LDAP; +use Apache2::Module; +use Apache2::Access; +use Apache2::ServerRec qw(); +use Apache2::RequestRec qw(); +use Apache2::RequestUtil qw(); +use Apache2::Const qw(:common :override :cmd_how); +use APR::Pool (); +use APR::Table (); + +my @directives = ( + { + name => 'SoundSoftwareDSN', + req_override => OR_AUTHCFG, + args_how => TAKE1, + errmsg => 'Dsn in format used by Perl DBI. eg: "DBI:Pg:dbname=databasename;host=my.db.server"', + }, + { + name => 'SoundSoftwareDbUser', + req_override => OR_AUTHCFG, + args_how => TAKE1, + }, + { + name => 'SoundSoftwareDbPass', + req_override => OR_AUTHCFG, + args_how => TAKE1, + }, + { + name => 'SoundSoftwareDbWhereClause', + req_override => OR_AUTHCFG, + args_how => TAKE1, + }, + { + name => 'SoundSoftwareRepoPrefix', + req_override => OR_AUTHCFG, + args_how => TAKE1, + }, +); + +sub SoundSoftwareDSN { + my ($self, $parms, $arg) = @_; + $self->{SoundSoftwareDSN} = $arg; + my $query = "SELECT + hashed_password, salt, auth_source_id, permissions + FROM members, projects, users, roles, member_roles + WHERE + projects.id=members.project_id + AND member_roles.member_id=members.id + AND users.id=members.user_id + AND roles.id=member_roles.role_id + AND users.status=1 + AND login=? + AND identifier=? "; + $self->{SoundSoftwareQuery} = trim($query); +} + +sub SoundSoftwareDbUser { set_val('SoundSoftwareDbUser', @_); } +sub SoundSoftwareDbPass { set_val('SoundSoftwareDbPass', @_); } +sub SoundSoftwareDbWhereClause { + my ($self, $parms, $arg) = @_; + $self->{SoundSoftwareQuery} = trim($self->{SoundSoftwareQuery}.($arg ? $arg : "")." "); +} + +sub SoundSoftwareRepoPrefix { + my ($self, $parms, $arg) = @_; + if ($arg) { + $self->{SoundSoftwareRepoPrefix} = $arg; + } +} + +sub trim { + my $string = shift; + $string =~ s/\s{2,}/ /g; + return $string; +} + +sub set_val { + my ($key, $self, $parms, $arg) = @_; + $self->{$key} = $arg; +} + +Apache2::Module::add(__PACKAGE__, \@directives); + + +my %read_only_methods = map { $_ => 1 } qw/GET PROPFIND REPORT OPTIONS/; + +sub access_handler { + my $r = shift; + + print STDERR "SoundSoftware.pm: In access handler at " . scalar localtime() . "\n"; + + unless ($r->some_auth_required) { + $r->log_reason("No authentication has been configured"); + return FORBIDDEN; + } + + my $method = $r->method; + + print STDERR "SoundSoftware.pm: Method: $method, uri " . $r->uri . ", location " . $r->location . "\n"; + print STDERR "SoundSoftware.pm: Accept: " . $r->headers_in->{Accept} . "\n"; + + my $dbh = connect_database($r); + unless ($dbh) { + print STDERR "SoundSoftware.pm: Database connection failed!: " . $DBI::errstr . "\n"; + return FORBIDDEN; + } + + print STDERR "Connected to db, dbh is " . $dbh . "\n"; + + my $project_id = get_project_identifier($dbh, $r); + + if (!defined $read_only_methods{$method}) { + print STDERR "SoundSoftware.pm: Method is not read-only\n"; + if (project_repo_is_readonly($dbh, $project_id, $r)) { + print STDERR "SoundSoftware.pm: Project repo is read-only, refusing access\n"; + return FORBIDDEN; + } else { + print STDERR "SoundSoftware.pm: Project repo is read-write, authentication handler required\n"; + return OK; + } + } + + my $status = get_project_status($dbh, $project_id, $r); + + $dbh->disconnect(); + undef $dbh; + + if ($status == 0) { # nonexistent + print STDERR "SoundSoftware.pm: Project does not exist, refusing access\n"; + return FORBIDDEN; + } elsif ($status == 1) { # public + print STDERR "SoundSoftware.pm: Project is public, no restriction here\n"; + $r->set_handlers(PerlAuthenHandler => [\&OK]) + } else { # private + print STDERR "SoundSoftware.pm: Project is private, authentication handler required\n"; + } + + return OK +} + +sub authen_handler { + my $r = shift; + + print STDERR "SoundSoftware.pm: In authentication handler at " . scalar localtime() . "\n"; + + my $dbh = connect_database($r); + unless ($dbh) { + print STDERR "SoundSoftware.pm: Database connection failed!: " . $DBI::errstr . "\n"; + return AUTH_REQUIRED; + } + + my $project_id = get_project_identifier($dbh, $r); + my $realm = get_realm($dbh, $project_id, $r); + $r->auth_name($realm); + + my ($res, $redmine_pass) = $r->get_basic_auth_pw(); + unless ($res == OK) { + $dbh->disconnect(); + undef $dbh; + return $res; + } + + print STDERR "SoundSoftware.pm: User is " . $r->user . ", got password\n"; + + my $permitted = is_permitted($dbh, $project_id, $r->user, $redmine_pass, $r); + + $dbh->disconnect(); + undef $dbh; + + if ($permitted) { + return OK; + } else { + print STDERR "SoundSoftware.pm: Not permitted\n"; + $r->note_auth_failure(); + return AUTH_REQUIRED; + } +} + +sub get_project_status { + my $dbh = shift; + my $project_id = shift; + my $r = shift; + + if (!defined $project_id or $project_id eq '') { + return 0; # nonexistent + } + + my $sth = $dbh->prepare( + "SELECT is_public FROM projects WHERE projects.identifier = ?;" + ); + + $sth->execute($project_id); + my $ret = 0; # nonexistent + if (my @row = $sth->fetchrow_array) { + if ($row[0] eq "1" || $row[0] eq "t") { + $ret = 1; # public + } else { + $ret = 2; # private + } + } + $sth->finish(); + undef $sth; + + $ret; +} + +sub project_repo_is_readonly { + my $dbh = shift; + my $project_id = shift; + my $r = shift; + + if (!defined $project_id or $project_id eq '') { + return 0; # nonexistent + } + + my $sth = $dbh->prepare( + "SELECT repositories.is_external FROM repositories, projects WHERE projects.identifier = ? AND repositories.project_id = projects.id;" + ); + + $sth->execute($project_id); + my $ret = 0; # nonexistent + if (my @row = $sth->fetchrow_array) { + if (defined($row[0]) && ($row[0] eq "1" || $row[0] eq "t")) { + $ret = 1; # read-only (i.e. external) + } else { + $ret = 0; # read-write + } + } + $sth->finish(); + undef $sth; + + $ret; +} + +sub is_permitted { + my $dbh = shift; + my $project_id = shift; + my $redmine_user = shift; + my $redmine_pass = shift; + my $r = shift; + + my $pass_digest = Digest::SHA1::sha1_hex($redmine_pass); + + my $cfg = Apache2::Module::get_config + (__PACKAGE__, $r->server, $r->per_dir_config); + + my $query = $cfg->{SoundSoftwareQuery}; + my $sth = $dbh->prepare($query); + $sth->execute($redmine_user, $project_id); + + my $ret; + while (my ($hashed_password, $salt, $auth_source_id, $permissions) = $sth->fetchrow_array) { + + # Test permissions for this user before we verify credentials + # -- if the user is not permitted this action anyway, there's + # not much point in e.g. contacting the LDAP + + my $method = $r->method; + + if ((defined $read_only_methods{$method} && $permissions =~ /:browse_repository/) + || $permissions =~ /:commit_access/) { + + # User would be permitted this action, if their + # credentials checked out -- test those now + + print STDERR "SoundSoftware.pm: User $redmine_user has required role, checking credentials\n"; + + unless ($auth_source_id) { + my $salted_password = Digest::SHA1::sha1_hex($salt.$pass_digest); + if ($hashed_password eq $salted_password) { + print STDERR "SoundSoftware.pm: User $redmine_user authenticated via password\n"; + $ret = 1; + last; + } + } else { + my $sthldap = $dbh->prepare( + "SELECT host,port,tls,account,account_password,base_dn,attr_login FROM auth_sources WHERE id = ?;" + ); + $sthldap->execute($auth_source_id); + while (my @rowldap = $sthldap->fetchrow_array) { + my $ldap = Authen::Simple::LDAP->new( + host => ($rowldap[2] eq "1" || $rowldap[2] eq "t") ? "ldaps://$rowldap[0]" : $rowldap[0], + port => $rowldap[1], + basedn => $rowldap[5], + binddn => $rowldap[3] ? $rowldap[3] : "", + bindpw => $rowldap[4] ? $rowldap[4] : "", + filter => "(".$rowldap[6]."=%s)" + ); + if ($ldap->authenticate($redmine_user, $redmine_pass)) { + print STDERR "SoundSoftware.pm: User $redmine_user authenticated via LDAP\n"; + $ret = 1; + } + } + $sthldap->finish(); + undef $sthldap; + } + } else { + print STDERR "SoundSoftware.pm: User $redmine_user lacks required role for this project\n"; + } + } + + $sth->finish(); + undef $sth; + + $ret; +} + +sub get_project_identifier { + my $dbh = shift; + my $r = shift; + + my $location = $r->location; + my ($repo) = $r->uri =~ m{$location/*([^/]+)}; + + return $repo if (!$repo); + + $repo =~ s/[^a-zA-Z0-9\._-]//g; + + # The original Redmine.pm returns the string just calculated as + # the project identifier. That won't do for us -- we may have + # (and in fact already do have, in our test instance) projects + # whose repository names differ from the project identifiers. + + # This is a rather fundamental change because it means that almost + # every request needs more than one database query -- which + # prompts us to start passing around $dbh instead of connecting + # locally within each function as is done in Redmine.pm. + + my $sth = $dbh->prepare( + "SELECT projects.identifier FROM projects, repositories WHERE repositories.project_id = projects.id AND repositories.url LIKE ?;" + ); + + my $cfg = Apache2::Module::get_config + (__PACKAGE__, $r->server, $r->per_dir_config); + + my $prefix = $cfg->{SoundSoftwareRepoPrefix}; + if (!defined $prefix) { $prefix = '%/'; } + + my $identifier = ''; + + $sth->execute($prefix . $repo); + my $ret = 0; + if (my @row = $sth->fetchrow_array) { + $identifier = $row[0]; + } + $sth->finish(); + undef $sth; + + print STDERR "SoundSoftware.pm: Repository '$repo' belongs to project '$identifier'\n"; + + $identifier; +} + +sub get_realm { + my $dbh = shift; + my $project_id = shift; + my $r = shift; + + my $sth = $dbh->prepare( + "SELECT projects.name FROM projects WHERE projects.identifier = ?;" + ); + + my $name = $project_id; + + $sth->execute($project_id); + my $ret = 0; + if (my @row = $sth->fetchrow_array) { + $name = $row[0]; + } + $sth->finish(); + undef $sth; + + # be timid about characters not permitted in auth realm and revert + # to project identifier if any are found + if ($name =~ m/[^\w\d\s\._-]/) { + $name = $project_id; + } + + my $realm = '"Mercurial repository for ' . "'$name'" . '"'; + + $realm; +} + +sub connect_database { + my $r = shift; + + my $cfg = Apache2::Module::get_config + (__PACKAGE__, $r->server, $r->per_dir_config); + + return DBI->connect($cfg->{SoundSoftwareDSN}, + $cfg->{SoundSoftwareDbUser}, + $cfg->{SoundSoftwareDbPass}); +} + +1;
--- a/extra/soundsoftware/SoundSoftware.pm Thu Mar 24 13:58:03 2011 +0000 +++ b/extra/soundsoftware/SoundSoftware.pm Mon Mar 28 18:04:17 2011 +0100 @@ -25,6 +25,8 @@ 4. Push to repo for private project: "Permitted" users only (as above) +5. Push to any repo that is tracking an external repo: Refused always + =head1 INSTALLATION Debian/ubuntu: @@ -172,21 +174,27 @@ print STDERR "SoundSoftware.pm: Method: $method, uri " . $r->uri . ", location " . $r->location . "\n"; print STDERR "SoundSoftware.pm: Accept: " . $r->headers_in->{Accept} . "\n"; - if (!defined $read_only_methods{$method}) { - print STDERR "SoundSoftware.pm: Method is not read-only, authentication handler required\n"; - return OK; - } - my $dbh = connect_database($r); unless ($dbh) { print STDERR "SoundSoftware.pm: Database connection failed!: " . $DBI::errstr . "\n"; return FORBIDDEN; } - -print STDERR "Connected to db, dbh is " . $dbh . "\n"; + print STDERR "Connected to db, dbh is " . $dbh . "\n"; my $project_id = get_project_identifier($dbh, $r); + + if (!defined $read_only_methods{$method}) { + print STDERR "SoundSoftware.pm: Method is not read-only\n"; + if (project_repo_is_readonly($dbh, $project_id, $r)) { + print STDERR "SoundSoftware.pm: Project repo is read-only, refusing access\n"; + return FORBIDDEN; + } else { + print STDERR "SoundSoftware.pm: Project repo is read-write, authentication handler required\n"; + return OK; + } + } + my $status = get_project_status($dbh, $project_id, $r); $dbh->disconnect(); @@ -271,6 +279,34 @@ $ret; } +sub project_repo_is_readonly { + my $dbh = shift; + my $project_id = shift; + my $r = shift; + + if (!defined $project_id or $project_id eq '') { + return 0; # nonexistent + } + + my $sth = $dbh->prepare( + "SELECT repositories.is_external FROM repositories, projects WHERE projects.identifier = ? AND repositories.project_id = projects.id;" + ); + + $sth->execute($project_id); + my $ret = 0; # nonexistent + if (my @row = $sth->fetchrow_array) { + if (defined($row[0]) && ($row[0] eq "1" || $row[0] eq "t")) { + $ret = 1; # read-only (i.e. external) + } else { + $ret = 0; # read-write + } + } + $sth->finish(); + undef $sth; + + $ret; +} + sub is_permitted { my $dbh = shift; my $project_id = shift;
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/extra/soundsoftware/convert-external-repos.rb Mon Mar 28 18:04:17 2011 +0100 @@ -0,0 +1,173 @@ +#!/usr/bin/env ruby + +# == Synopsis +# +# convert-external-repos: Update local Mercurial mirrors of external repos, +# by running an external command for each project requiring an update. +# +# == Usage +# +# convert-external-repos [OPTIONS...] -s [DIR] -r [HOST] +# +# == Arguments (mandatory) +# +# -s, --scm-dir=DIR use DIR as base directory for repositories +# -r, --redmine-host=HOST assume Redmine is hosted on HOST. Examples: +# -r redmine.example.net +# -r http://redmine.example.net +# -r https://example.net/redmine +# -k, --key=KEY use KEY as the Redmine API key +# -c, --command=COMMAND use this command to update each external +# repository: command is called with the name +# of the project, the path to its repo, and +# its external repo url as its three args +# +# == Options +# +# --http-user=USER User for HTTP Basic authentication with Redmine WS +# --http-pass=PASSWORD Password for Basic authentication with Redmine WS +# -t, --test only show what should be done +# -h, --help show help and exit +# -v, --verbose verbose +# -V, --version print version and exit +# -q, --quiet no log + + +require 'getoptlong' +require 'rdoc/usage' +require 'find' +require 'etc' + +Version = "1.0" + +opts = GetoptLong.new( + ['--scm-dir', '-s', GetoptLong::REQUIRED_ARGUMENT], + ['--redmine-host', '-r', GetoptLong::REQUIRED_ARGUMENT], + ['--key', '-k', GetoptLong::REQUIRED_ARGUMENT], + ['--http-user', GetoptLong::REQUIRED_ARGUMENT], + ['--http-pass', GetoptLong::REQUIRED_ARGUMENT], + ['--command' , '-c', GetoptLong::REQUIRED_ARGUMENT], + ['--test', '-t', GetoptLong::NO_ARGUMENT], + ['--verbose', '-v', GetoptLong::NO_ARGUMENT], + ['--version', '-V', GetoptLong::NO_ARGUMENT], + ['--help' , '-h', GetoptLong::NO_ARGUMENT], + ['--quiet' , '-q', GetoptLong::NO_ARGUMENT] + ) + +$verbose = 0 +$quiet = false +$redmine_host = '' +$repos_base = '' +$http_user = '' +$http_pass = '' +$test = false + +def log(text, options={}) + level = options[:level] || 0 + puts text unless $quiet or level > $verbose + exit 1 if options[:exit] +end + +def system_or_raise(command) + raise "\"#{command}\" failed" unless system command +end + +begin + opts.each do |opt, arg| + case opt + when '--scm-dir'; $repos_base = arg.dup + when '--redmine-host'; $redmine_host = arg.dup + when '--key'; $api_key = arg.dup + when '--http-user'; $http_user = arg.dup + when '--http-pass'; $http_pass = arg.dup + when '--command'; $command = arg.dup + when '--verbose'; $verbose += 1 + when '--test'; $test = true + when '--version'; puts Version; exit + when '--help'; RDoc::usage + when '--quiet'; $quiet = true + end + end +rescue + exit 1 +end + +if $test + log("running in test mode") +end + +if ($redmine_host.empty? or $repos_base.empty? or $command.empty?) + RDoc::usage +end + +unless File.directory?($repos_base) + log("directory '#{$repos_base}' doesn't exist", :exit => true) +end + +begin + require 'active_resource' +rescue LoadError + log("This script requires activeresource.\nRun 'gem install activeresource' to install it.", :exit => true) +end + +class Project < ActiveResource::Base + self.headers["User-agent"] = "SoundSoftware external repository converter/#{Version}" +end + +log("querying Redmine for projects...", :level => 1); + +$redmine_host.gsub!(/^/, "http://") unless $redmine_host.match("^https?://") +$redmine_host.gsub!(/\/$/, '') + +Project.site = "#{$redmine_host}/sys"; +Project.user = $http_user; +Project.password = $http_pass; + +begin + # Get all active projects that have the Repository module enabled + projects = Project.find(:all, :params => {:key => $api_key}) +rescue => e + log("Unable to connect to #{Project.site}: #{e}", :exit => true) +end + +if projects.nil? + log('no project found, perhaps you forgot to "Enable WS for repository management"', :exit => true) +end + +log("retrieved #{projects.size} projects", :level => 1) + +projects.each do |project| + log("treating project #{project.name}", :level => 1) + + if project.identifier.empty? + log("\tno identifier for project #{project.name}") + next + elsif not project.identifier.match(/^[a-z0-9\-]+$/) + log("\tinvalid identifier for project #{project.name} : #{project.identifier}"); + next + end + + if !project.respond_to?(:repository) or !project.repository.is_external? + log("\tproject #{project.identifier} does not use an external repository"); + next + end + + external_url = project.repository.external_url; + log("\tproject #{project.identifier} has external repository url #{external_url}"); + + if !external_url.match(/^[a-z][a-z+]{0,8}[a-z]:\/\//) + log("\tthis doesn't look like a plausible url to me, skipping") + next + end + + repos_path = File.join($repos_base, project.identifier).gsub(File::SEPARATOR, File::ALT_SEPARATOR || File::SEPARATOR) + + unless File.directory?(repos_path) + log("\tproject repo directory '#{repos_path}' doesn't exist") + next + end + + system($command, project.identifier, repos_path, external_url) + +end +
--- a/extra/soundsoftware/reposman-soundsoftware.rb Thu Mar 24 13:58:03 2011 +0000 +++ b/extra/soundsoftware/reposman-soundsoftware.rb Mon Mar 28 18:04:17 2011 +0100 @@ -9,12 +9,12 @@ # reposman [OPTIONS...] -s [DIR] -r [HOST] # # Examples: -# reposman --svn-dir=/var/svn --redmine-host=redmine.example.net --scm subversion +# reposman --scm-dir=/var/svn --redmine-host=redmine.example.net --scm subversion # reposman -s /var/git -r redmine.example.net -u http://svn.example.net --scm git # # == Arguments (mandatory) # -# -s, --svn-dir=DIR use DIR as base directory for svn repositories +# -s, --scm-dir=DIR use DIR as base directory for repositories # -r, --redmine-host=HOST assume Redmine is hosted on HOST. Examples: # -r redmine.example.net # -r http://redmine.example.net @@ -70,7 +70,7 @@ SUPPORTED_SCM = %w( Subversion Darcs Mercurial Bazaar Git Filesystem ) opts = GetoptLong.new( - ['--svn-dir', '-s', GetoptLong::REQUIRED_ARGUMENT], + ['--scm-dir', '-s', GetoptLong::REQUIRED_ARGUMENT], ['--redmine-host', '-r', GetoptLong::REQUIRED_ARGUMENT], ['--key', '-k', GetoptLong::REQUIRED_ARGUMENT], ['--owner', '-o', GetoptLong::REQUIRED_ARGUMENT], @@ -133,7 +133,7 @@ begin opts.each do |opt, arg| case opt - when '--svn-dir'; $repos_base = arg.dup + when '--scm-dir'; $repos_base = arg.dup when '--redmine-host'; $redmine_host = arg.dup when '--key'; $api_key = arg.dup when '--owner'; $svn_owner = arg.dup; $use_groupid = false; @@ -174,7 +174,7 @@ end unless File.directory?($repos_base) - log("directory '#{$repos_base}' doesn't exists", :exit => true) + log("directory '#{$repos_base}' doesn't exist", :exit => true) end begin @@ -184,7 +184,7 @@ end class Project < ActiveResource::Base - self.headers["User-agent"] = "Redmine repository manager/#{Version}" + self.headers["User-agent"] = "SoundSoftware repository manager/#{Version}" end log("querying Redmine for projects...", :level => 1); @@ -346,5 +346,14 @@ log("\trepository #{repos_path} created"); end + if project.respond_to?(:repository) and project.repository.is_external? + external_url = project.repository.external_url; + log("\tproject #{project.identifier} has external repository url #{external_url}"); + if !external_url.match(/^https?:/) + # wot about git, svn/svn+ssh, etc? should we just check for a scheme at all? + log("\tthis is not an http(s) url: ignoring"); + end + end + end
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/extra/soundsoftware/update-external-repo.sh Mon Mar 28 18:04:17 2011 +0100 @@ -0,0 +1,107 @@ +#!/bin/sh + +mirrordir="/var/mirror" +logfile="/var/www/test-cannam/log/update-external-repo.log" + +project="$1" +local_repo="$2" +remote_repo="$3" + +if [ -z "$project" ] || [ -z "$local_repo" ] || [ -z "$remote_repo" ]; then + echo "Usage: $0 <project> <local-repo-path> <remote-repo-url>" + exit 2 +fi + + # We need to handle different source repository types separately. + # + # The convert extension cannot convert directly from a remote git + # repo; we'd have to mirror to a local repo first. Incremental + # conversions do work though. The hg-git plugin will convert + # directly from remote repositories, but not via all schemes + # (e.g. https is not currently supported). It's probably easier to + # use git itself to clone locally and then convert or hg-git from + # there. + # + # We can of course convert directly from remote Subversion repos, + # but we need to keep track of that -- you can ask to convert into a + # repo that has already been used (for Mercurial) and it'll do so + # happily; we don't want that. + # + # Converting from a remote Hg repo should be fine! + # + # One other thing -- we can't actually tell the difference between + # the various SCM types based on URL alone. We have to try them + # (ideally in an order determined by a guess based on the URL) and + # see what happens. + +project_mirror="$mirrordir/$project" +mkdir -p "$project_mirror" +project_repo_mirror="$project_mirror/repo" + + # Some test URLs: + # + # http://aimc.googlecode.com/svn/trunk/ + # http://aimc.googlecode.com/svn/ + # http://vagar.org/git/flam + # https://github.com/wslihgt/IMMF0salience.git + # http://hg.breakfastquay.com/dssi-vst/ + # git://github.com/schacon/hg-git.git + # http://svn.drobilla.net/lad (externals!) + +# If we are importing from another distributed system, then our aim is +# to create either a Hg repo or a git repo at $project_mirror, which +# we can then pull from directly to the Hg repo at $local_repo (using +# hg-git, in the case of a git repo). + +# Importing from SVN, we should use hg convert directly to the target +# hg repo (or should we?) but keep a record of the last changeset ID +# we brought in, and test each time whether it matches the last +# changeset ID actually in the repo + +success="" + +if [ -d "$project_repo_mirror" ]; then + + # Repo mirror exists: update it + echo "$$: Mirror for project $project exists at $project_repo_mirror, updating" 1>&2 + + if [ -d "$project_repo_mirror/.hg" ]; then + hg --config extensions.convert= convert --datesort "$remote_repo" "$project_repo_mirror" && success=true + elif [ -d "$project_repo_mirror/.git" ]; then + ( cd "$project_repo_mirror" && git fetch "$remote_repo" ) && success=true + else + echo "$$: ERROR: Repo mirror dir $project_repo_mirror exists but is not an Hg or git repo" 1>&2 + fi + +else + + # Repo mirror does not exist yet + echo "$$: Mirror for project $project does not yet exist at $project_repo_mirror, trying to convert or clone" 1>&2 + + case "$remote_repo" in + *git*) + git clone "$remote_repo" "$project_repo_mirror" || + hg --config extensions.convert= convert --datesort "$remote_repo" "$project_repo_mirror" + ;; + *) + hg --config extensions.convert= convert --datesort "$remote_repo" "$project_repo_mirror" || + git clone "$remote_repo" "$project_repo_mirror" || + hg clone "$remote_repo" "$project_repo_mirror" + ;; + esac && success=true + +fi + +echo "Success=$success" + +if [ -n "$success" ]; then + echo "$$: Update successful, pulling into local repo at $local_repo" + if [ -d "$project_repo_mirror/.git" ]; then + if [ ! -d "$local_repo" ]; then + hg init "$local_repo" + fi + ( cd "$local_repo" && hg --config extensions.hgext.git= pull "$project_repo_mirror" ) + else + ( cd "$local_repo" && hg pull "$project_repo_mirror" ) + fi +fi
--- a/public/stylesheets/application.css Thu Mar 24 13:58:03 2011 +0000 +++ b/public/stylesheets/application.css Mon Mar 28 18:04:17 2011 +0100 @@ -157,7 +157,7 @@ tr.changeset td.committed_on { text-align: center; width: 15%; } table.files tr.file td { text-align: center; } -table.files tr.file td.filename { text-align: left; padding-left: 24px; } +table.files tr.file td.filename { text-align: left; } table.files tr.file td.digest { font-size: 80%; } table.members td.roles, table.memberships td.roles { width: 45%; }
--- a/public/themes/soundsoftware/stylesheets/application.css Thu Mar 24 13:58:03 2011 +0000 +++ b/public/themes/soundsoftware/stylesheets/application.css Mon Mar 28 18:04:17 2011 +0100 @@ -37,16 +37,41 @@ body,p,h2,h3,h4,li,table,.wiki h1 { font-family: DroidSans, 'Liberation Sans', tahoma, verdana, sans-serif; + line-height: 1.34; } h2,h3,h4,.wiki h1 { color: #3e442c; + font-weight: bold; +} + +.wiki h2,.wiki h3,.wiki h4 { + color: #000; } h2,.wiki h1 { - font-size: 1.8em; + font-size: 1.8em; } +.wiki h2 { + margin-top: 1em; +} + +.splitcontentleft p:first-child { + margin-top: 0; +} + +div.attachments { + margin-top: 2em; +} +#wiki_add_attachment { + margin-top: 1.5em; +} + +/* Hide these (the paragraph markers that show anchors) -- they confuse more than they help */ +a.wiki-anchor:hover { display: none; } +h1:hover a.wiki-anchor, h2:hover a.wiki-anchor, h3:hover a.wiki-anchor { display: none; } + .box { padding: 6px; margin-bottom: 10px; @@ -81,6 +106,10 @@ ul.projects .public, ul.projects .private { padding-left: 0.5em; color: #3e442c; font-size: 0.95em } +table.files tr.active td { padding-top: 0.5em; padding-bottom: 0.5em; } +table.files .file .active { font-weight: bold; } +table.files .file .description { font-weight: normal; color: #3e442c; } + #top-menu { position: absolute; top: 0; z-index: 1; left: 0px; width: 100%; font-size: 90%; /* height: 2em; */ margin: 0; padding: 0; padding-top: 0.5em; background-color: #3e442c; } #top-menu ul { margin-left: 10px; } #top-menu a { font-weight: bold; } @@ -98,7 +127,7 @@ #project-jump-box { float: right; margin-right: 6px; margin-top: 5px; color: #000; } #project-ancestors-title { margin-bottom: 0px; - margin-left: 10px; + margin-left: 12px; margin-top: 6px; font-family: GilliusADFNo2, 'Gill Sans', Tahoma, sans-serif; font-weight: normal;
--- a/vendor/plugins/redmine_checkout/app/views/redmine_checkout_hooks/_view_repositories_show_contextual.rhtml Thu Mar 24 13:58:03 2011 +0000 +++ b/vendor/plugins/redmine_checkout/app/views/redmine_checkout_hooks/_view_repositories_show_contextual.rhtml Mon Mar 28 18:04:17 2011 +0100 @@ -26,6 +26,10 @@ <% end %> </div> <% end%> + <% if repository.is_external? %> + <div class="bottomline"></div> + <p><%= l(:text_repository_external, :location => repository.external_url) %></p> + <% end %> </div> <div style="clear: left"></div> @@ -33,4 +37,4 @@ <%= stylesheet_link_tag 'checkout', :plugin => 'redmine_checkout' %> <%= javascript_include_tag 'checkout', :plugin => 'redmine_checkout' %> <%= (javascript_include_tag 'ZeroClipboard', :plugin => 'redmine_checkout') if Setting.checkout_use_zero_clipboard? %> -<% end %> \ No newline at end of file +<% end %>