From 82f2a1aa0dfaad36b1723c8d3d1087c20a6f9618 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Tue, 9 Feb 2016 18:06:55 +0100 Subject: [PATCH] Initial work on GitLab Pages update --- Gemfile | 3 + Gemfile.lock | 4 + app/controllers/projects/pages_controller.rb | 94 +++++++++++++++++++ app/controllers/projects_controller.rb | 10 -- app/helpers/projects_helper.rb | 8 ++ app/models/ability.rb | 1 + app/models/project.rb | 51 ++++++++-- .../update_pages_configuration_service.rb | 53 +++++++++++ app/validators/certificate_key_validator.rb | 24 +++++ app/validators/certificate_validator.rb | 30 ++++++ .../layouts/nav/_project_settings.html.haml | 5 + app/views/projects/pages/_access.html.haml | 34 +++++++ app/views/projects/pages/_destroy.haml | 10 ++ app/views/projects/pages/_disabled.html.haml | 4 + app/views/projects/pages/_form.html.haml | 35 +++++++ .../pages/_remove_certificate.html.haml | 16 ++++ .../pages/_upload_certificate.html.haml | 32 +++++++ app/views/projects/pages/_use.html.haml | 18 ++++ app/views/projects/pages/show.html.haml | 18 ++++ app/workers/pages_worker.rb | 6 +- config/initializers/1_settings.rb | 1 + config/routes.rb | 4 +- ...808_add_pages_custom_domain_to_projects.rb | 10 ++ 23 files changed, 451 insertions(+), 20 deletions(-) create mode 100644 app/controllers/projects/pages_controller.rb create mode 100644 app/services/projects/update_pages_configuration_service.rb create mode 100644 app/validators/certificate_key_validator.rb create mode 100644 app/validators/certificate_validator.rb create mode 100644 app/views/projects/pages/_access.html.haml create mode 100644 app/views/projects/pages/_destroy.haml create mode 100644 app/views/projects/pages/_disabled.html.haml create mode 100644 app/views/projects/pages/_form.html.haml create mode 100644 app/views/projects/pages/_remove_certificate.html.haml create mode 100644 app/views/projects/pages/_upload_certificate.html.haml create mode 100644 app/views/projects/pages/_use.html.haml create mode 100644 app/views/projects/pages/show.html.haml create mode 100644 db/migrate/20160209125808_add_pages_custom_domain_to_projects.rb diff --git a/Gemfile b/Gemfile index e795da787ad8..9575e6d0cc2d 100644 --- a/Gemfile +++ b/Gemfile @@ -46,6 +46,9 @@ gem 'devise-two-factor', '~> 2.0.0' gem 'rqrcode-rails3', '~> 0.1.7' gem 'attr_encrypted', '~> 1.3.4' +# GitLab Pages +gem 'validates_hostname', '~> 1.0.0' + # Browser detection gem "browser", '~> 1.0.0' diff --git a/Gemfile.lock b/Gemfile.lock index d35964085a81..4c940c9367d7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -867,6 +867,9 @@ GEM uniform_notifier (1.9.0) uuid (2.3.8) macaddr (~> 1.0) + validates_hostname (1.0.5) + activerecord (>= 3.0) + activesupport (>= 3.0) version_sorter (2.0.0) virtus (1.0.5) axiom-types (~> 0.1) @@ -1066,6 +1069,7 @@ DEPENDENCIES unf (~> 0.1.4) unicorn (~> 4.8.2) unicorn-worker-killer (~> 0.4.2) + validates_hostname (~> 1.0.0) version_sorter (~> 2.0.0) virtus (~> 1.0.1) web-console (~> 2.0) diff --git a/app/controllers/projects/pages_controller.rb b/app/controllers/projects/pages_controller.rb new file mode 100644 index 000000000000..ef0ed5051423 --- /dev/null +++ b/app/controllers/projects/pages_controller.rb @@ -0,0 +1,94 @@ +class Projects::PagesController < Projects::ApplicationController + layout 'project_settings' + + before_action :authorize_update_pages!, except: [:show] + before_action :authorize_remove_pages!, only: :destroy + + helper_method :valid_certificate?, :valid_certificate_key? + helper_method :valid_key_for_certificiate?, :valid_certificate_intermediates? + helper_method :certificate, :certificate_key + + def show + end + + def update + if @project.update_attributes(pages_params) + redirect_to namespace_project_pages_path(@project.namespace, @project) + else + render 'show' + end + end + + def certificate + @project.remove_pages_certificate + end + + def destroy + @project.remove_pages + + respond_to do |format| + format.html { redirect_to project_path(@project) } + end + end + + private + + def pages_params + params.require(:project).permit( + :pages_custom_certificate, + :pages_custom_certificate_key, + :pages_custom_domain, + :pages_redirect_http, + ) + end + + def valid_certificate? + certificate.present? + end + + def valid_certificate_key? + certificate_key.present? + end + + def valid_key_for_certificiate? + return false unless certificate + return false unless certificate_key + + certificate.verify(certificate_key) + rescue OpenSSL::X509::CertificateError + false + end + + def valid_certificate_intermediates? + return false unless certificate + + store = OpenSSL::X509::Store.new + store.set_default_paths + + # This forces to load all intermediate certificates stored in `pages_custom_certificate` + Tempfile.open('project_certificate') do |f| + f.write(@project.pages_custom_certificate) + f.flush + store.add_file(f.path) + end + + store.verify(certificate) + rescue OpenSSL::X509::StoreError + false + end + + def certificate + return unless @project.pages_custom_certificate + + @certificate ||= OpenSSL::X509::Certificate.new(@project.pages_custom_certificate) + rescue OpenSSL::X509::CertificateError + nil + end + + def certificate_key + return unless @project.pages_custom_certificate_key + @certificate_key ||= OpenSSL::PKey::RSA.new(@project.pages_custom_certificate_key) + rescue OpenSSL::PKey::PKeyError + nil + end +end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 6393397000a0..95d851a69372 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -171,16 +171,6 @@ def unarchive end end - def remove_pages - return access_denied! unless can?(current_user, :remove_pages, @project) - - @project.remove_pages - - respond_to do |format| - format.html { redirect_to project_path(@project) } - end - end - def housekeeping ::Projects::HousekeepingService.new(@project).execute diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 93d530ef7694..8dd31a895ec4 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -90,6 +90,14 @@ def remove_fork_project_message(project) "You are going to remove the fork relationship to source project #{@project.forked_from_project.name_with_namespace}. Are you ABSOLUTELY sure?" end + def remove_pages_message(project) + "You are going to remove the pages for #{project.name_with_namespace}.\n Are you ABSOLUTELY sure?" + end + + def remove_pages_certificate_message(project) + "You are going to remove a certificates for #{project.name_with_namespace}.\n Are you ABSOLUTELY sure?" + end + def project_nav_tabs @nav_tabs ||= get_project_nav_tabs(@project, current_user) end diff --git a/app/models/ability.rb b/app/models/ability.rb index 29fcaf583fc9..38482c1de2cd 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -245,6 +245,7 @@ def project_admin_rules :change_visibility_level, :rename_project, :remove_project, + :update_pages, :remove_pages, :archive_project, :remove_fork_project diff --git a/app/models/project.rb b/app/models/project.rb index da08add18e6e..08faaa58414a 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -95,6 +95,8 @@ def update_forks_visibility_level attr_accessor :new_default_branch attr_accessor :old_path_with_namespace + attr_encrypted :pages_custom_certificate_key, mode: :per_attribute_iv_and_salt, key: Gitlab::Application.secrets.db_key_base + # Relations belongs_to :creator, foreign_key: 'creator_id', class_name: 'User' belongs_to :group, -> { where(type: Group) }, foreign_key: 'namespace_id' @@ -209,6 +211,11 @@ def update_forks_visibility_level validates :avatar, file_size: { maximum: 200.kilobytes.to_i } validates :approvals_before_merge, numericality: true, allow_blank: true + validates :pages_custom_domain, hostname: true, allow_blank: true, allow_nil: true + validates_uniqueness_of :pages_custom_domain, allow_nil: true, allow_blank: true + validates :pages_custom_certificate, certificate: { intermediate: true } + validates :pages_custom_certificate_key, certificate_key: true + add_authentication_token_field :runners_token before_save :ensure_runners_token @@ -1046,16 +1053,27 @@ def runners_token end def pages_url - if Dir.exist?(public_pages_path) - host = "#{namespace.path}.#{Settings.pages.host}" - url = Gitlab.config.pages.url.sub(/^https?:\/\//) do |prefix| - "#{prefix}#{namespace.path}." - end + return unless Dir.exist?(public_pages_path) + + host = "#{namespace.path}.#{Settings.pages.host}" + url = Gitlab.config.pages.url.sub(/^https?:\/\//) do |prefix| + "#{prefix}#{namespace.path}." + end + + # If the project path is the same as host, leave the short version + return url if host == path + + "#{url}/#{path}" + end - # If the project path is the same as host, leave the short version - return url if host == path + def pages_custom_url + return unless pages_custom_domain + return unless Dir.exist?(public_pages_path) - "#{url}/#{path}" + if Gitlab.config.pages.https + return "https://#{pages_custom_domain}" + else + return "http://#{pages_custom_domain}" end end @@ -1067,6 +1085,15 @@ def public_pages_path File.join(pages_path, 'public') end + def remove_pages_certificate + update( + pages_custom_certificate: nil, + pages_custom_certificate_key: nil + ) + + UpdatePagesConfigurationService.new(self).execute + end + def remove_pages # 1. We rename pages to temporary directory # 2. We wait 5 minutes, due to NFS caching @@ -1076,6 +1103,14 @@ def remove_pages if Gitlab::PagesTransfer.new.rename_project(path, temp_path, namespace.path) PagesWorker.perform_in(5.minutes, :remove, namespace.path, temp_path) end + + update( + pages_custom_certificate: nil, + pages_custom_certificate_key: nil, + pages_custom_domain: nil + ) + + UpdatePagesConfigurationService.new(self).execute end def merge_method diff --git a/app/services/projects/update_pages_configuration_service.rb b/app/services/projects/update_pages_configuration_service.rb new file mode 100644 index 000000000000..be4c2fbef8c3 --- /dev/null +++ b/app/services/projects/update_pages_configuration_service.rb @@ -0,0 +1,53 @@ +module Projects + class UpdatePagesConfigurationService < BaseService + attr_reader :project + + def initialize(project) + @project = project + end + + def execute + update_file(pages_cname_file, project.pages_custom_domain) + update_file(pages_certificate_file, project.pages_custom_certificate) + update_file(pages_certificate_file_key, project.pages_custom_certificate_key) + reload_daemon + success + rescue => e + error(e.message) + end + + private + + def reload_daemon + # GitLab Pages daemon constantly watches for modification time of `pages.path` + # It reloads configuration when `pages.path` is modified + File.touch(Settings.pages.path) + end + + def pages_path + @pages_path ||= project.pages_path + end + + def pages_cname_file + File.join(pages_path, 'CNAME') + end + + def pages_certificate_file + File.join(pages_path, 'domain.crt') + end + + def pages_certificate_key_file + File.join(pages_path, 'domain.key') + end + + def update_file(file, data) + if data + File.open(file, 'w') do |file| + file.write(data) + end + else + File.rm_r(file) + end + end + end +end diff --git a/app/validators/certificate_key_validator.rb b/app/validators/certificate_key_validator.rb new file mode 100644 index 000000000000..3b5bd30db1ae --- /dev/null +++ b/app/validators/certificate_key_validator.rb @@ -0,0 +1,24 @@ +# UrlValidator +# +# Custom validator for private keys. +# +# class Project < ActiveRecord::Base +# validates :certificate_key, certificate_key: true +# end +# +class CertificateKeyValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + unless valid_private_key_pem?(value) + record.errors.add(attribute, "must be a valid PEM private key") + end + end + + private + + def valid_private_key_pem?(value) + pkey = OpenSSL::PKey::RSA.new(value) + pkey.private? + rescue OpenSSL::PKey::PKeyError + false + end +end diff --git a/app/validators/certificate_validator.rb b/app/validators/certificate_validator.rb new file mode 100644 index 000000000000..2cba5a435b7d --- /dev/null +++ b/app/validators/certificate_validator.rb @@ -0,0 +1,30 @@ +# UrlValidator +# +# Custom validator for private keys. +# +# class Project < ActiveRecord::Base +# validates :certificate_key, certificate_key: true +# end +# +class CertificateValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + certificate = parse_certificate(value) + unless certificate + record.errors.add(attribute, "must be a valid PEM certificate") + end + + if options[:intermediates] + unless certificate + record.errors.add(attribute, "certificate verification failed: missing intermediate certificates") + end + end + end + + private + + def parse_certificate(value) + OpenSSL::X509::Certificate.new(value) + rescue OpenSSL::X509::CertificateError + nil + end +end diff --git a/app/views/layouts/nav/_project_settings.html.haml b/app/views/layouts/nav/_project_settings.html.haml index aea70b451e77..80a621edfbfe 100644 --- a/app/views/layouts/nav/_project_settings.html.haml +++ b/app/views/layouts/nav/_project_settings.html.haml @@ -49,6 +49,11 @@ = icon('clone fw') %span Mirror Repository + = nav_link(controller: :pages) do + = link_to namespace_project_pages_path(@project.namespace, @project), title: 'Pages', data: {placement: 'right'} do + = icon('clone fw') + %span + Pages = nav_link(controller: :audit_events) do = link_to namespace_project_audit_events_path(@project.namespace, @project) do = icon('file-text-o fw') diff --git a/app/views/projects/pages/_access.html.haml b/app/views/projects/pages/_access.html.haml new file mode 100644 index 000000000000..d64f99fd22b9 --- /dev/null +++ b/app/views/projects/pages/_access.html.haml @@ -0,0 +1,34 @@ +- if @project.pages_url + .panel.panel-default + .panel-heading + Access pages + .panel-body + %p + %strong + Congratulations! Your pages are served at: + %p= link_to @project.pages_url, @project.pages_url + + - if Settings.pages.custom_domain && @project.pages_custom_url + %p= link_to @project.pages_custom_url, @project.pages_custom_url + + - if @project.pages_custom_certificate + - unless valid_certificate? + #error_explanation + .alert.alert-warning + Your certificate is invalid. + + - unless valid_certificate_key? + #error_explanation + .alert.alert-warning + Your private key is invalid. + + - unless valid_key_for_certificiate? + #error_explanation + .alert.alert-warning + Your private key can't be used with your certificate. + + - unless valid_certificate_intermediates? + #error_explanation + .alert.alert-warning + Your certificate doesn't have intermediates. + Your page may not work properly. diff --git a/app/views/projects/pages/_destroy.haml b/app/views/projects/pages/_destroy.haml new file mode 100644 index 000000000000..61b995a59345 --- /dev/null +++ b/app/views/projects/pages/_destroy.haml @@ -0,0 +1,10 @@ +- if can?(current_user, :remove_pages, @project) && @project.pages_url + .panel.panel-default.panel.panel-danger + .panel-heading Remove pages + .errors-holder + .panel-body + = form_tag(namespace_project_pages_path(@project.namespace, @project), method: :delete, class: 'form-horizontal') do + %p + Removing the pages will prevent from exposing them to outside world. + .form-actions + = button_to 'Remove pages', '#', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => remove_pages_message(@project) } diff --git a/app/views/projects/pages/_disabled.html.haml b/app/views/projects/pages/_disabled.html.haml new file mode 100644 index 000000000000..cf9ef5b4d6f6 --- /dev/null +++ b/app/views/projects/pages/_disabled.html.haml @@ -0,0 +1,4 @@ +.panel.panel-default + .nothing-here-block + GitLab Pages is disabled. + Ask your system's administrator to enable it. diff --git a/app/views/projects/pages/_form.html.haml b/app/views/projects/pages/_form.html.haml new file mode 100644 index 000000000000..a7b03d552dbf --- /dev/null +++ b/app/views/projects/pages/_form.html.haml @@ -0,0 +1,35 @@ +- if can?(current_user, :update_pages, @project) + .panel.panel-default + .panel-heading + Settings + .panel-body + = form_for [@project], url: namespace_project_pages_path(@project.namespace, @project), html: { class: 'form-horizontal fieldset-form' } do |f| + - if @project.errors.any? + #error_explanation + .alert.alert-danger + - @project.errors.full_messages.each do |msg| + %p= msg + + .form-group + = f.label :pages_domain, class: 'control-label' do + Custom domain + .col-sm-10 + - if Settings.pages.custom_domain + = f.text_field :pages_custom_domain, required: false, autocomplete: 'off', class: 'form-control' + %span.help-inline Allows you to serve the pages under your domain + - else + .nothing-here-block + Support for custom domains and certificates is disabled. + Ask your system's administrator to enable it. + + - if Settings.pages.https + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :pages_redirect_http do + = f.check_box :pages_redirect_http + %span.descr Force HTTPS + .help-block Redirect the HTTP to HTTPS forcing to always use the secure connection + + .form-actions + = f.submit 'Save changes', class: "btn btn-save" diff --git a/app/views/projects/pages/_remove_certificate.html.haml b/app/views/projects/pages/_remove_certificate.html.haml new file mode 100644 index 000000000000..e8c0d03adfa8 --- /dev/null +++ b/app/views/projects/pages/_remove_certificate.html.haml @@ -0,0 +1,16 @@ +- if can?(current_user, :update_pages, @project) && @project.pages_custom_certificate + .panel.panel-default.panel.panel-danger + .panel-heading + Remove certificate + .errors-holder + .panel-body + = form_tag(certificates_namespace_project_pages_path(@project.namespace, @project), method: :delete, class: 'form-horizontal') do + %p + Removing the certificate will stop serving the page under HTTPS. + - if certificate + %p + %pre + = certificate.to_text + + .form-actions + = button_to 'Remove certificate', '#', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => remove_pages_certificate_message(@project) } diff --git a/app/views/projects/pages/_upload_certificate.html.haml b/app/views/projects/pages/_upload_certificate.html.haml new file mode 100644 index 000000000000..30873fcf3953 --- /dev/null +++ b/app/views/projects/pages/_upload_certificate.html.haml @@ -0,0 +1,32 @@ +- if can?(current_user, :update_pages, @project) && Settings.pages.https && Settings.pages.custom_domain + .panel.panel-default + .panel-heading + Certificate + .panel-body + %p + Allows you to upload your certificate which will be used to serve pages under your domain. + %br + + = form_for [@project], url: namespace_project_pages_path(@project.namespace, @project), html: { class: 'form-horizontal fieldset-form' } do |f| + - if @project.errors.any? + #error_explanation + .alert.alert-danger + - @project.errors.full_messages.each do |msg| + %p= msg + + .form-group + = f.label :pages_custom_certificate, class: 'control-label' do + Certificate (PEM) + .col-sm-10 + = f.text_area :pages_custom_certificate, required: true, rows: 5, class: 'form-control', value: '' + %span.help-inline Upload a certificate for your domain with all intermediates + + .form-group + = f.label :pages_custom_certificate_key, class: 'control-label' do + Key (PEM) + .col-sm-10 + = f.text_area :pages_custom_certificate_key, required: true, rows: 5, class: 'form-control', value: '' + %span.help-inline Upload a certificate for your domain with all intermediates + + .form-actions + = f.submit 'Update certificate', class: "btn btn-save" diff --git a/app/views/projects/pages/_use.html.haml b/app/views/projects/pages/_use.html.haml new file mode 100644 index 000000000000..5542bbe670ba --- /dev/null +++ b/app/views/projects/pages/_use.html.haml @@ -0,0 +1,18 @@ +- unless @project.pages_url + .panel.panel-info + .panel-heading + Configure pages + .panel-body + %p + Learn how to upload your static site and have it served by + GitLab by following the #{link_to "documentation on GitLab Pages", "http://doc.gitlab.com/ee/pages/README.html", target: :blank}. + %p + In the example below we define a special job named + %code pages + which is using Jekyll to build a static site. The generated + HTML will be stored in the + %code public/ + directory which will then be archived and uploaded to GitLab. + The name of the directory should not be different than + %code public/ + in order for the pages to work. diff --git a/app/views/projects/pages/show.html.haml b/app/views/projects/pages/show.html.haml new file mode 100644 index 000000000000..5f689800da84 --- /dev/null +++ b/app/views/projects/pages/show.html.haml @@ -0,0 +1,18 @@ +- page_title "Pages" +%h3.page_title Pages +%p.light + With GitLab Pages you can host for free your static websites on GitLab. + Combined with the power of GitLab CI and the help of GitLab Runner + you can deploy static pages for your individual projects, your user or your group. +%hr + +- if Settings.pages.enabled + = render 'access' + = render 'use' + - if @project.pages_url + = render 'form' + = render 'upload_certificate' + = render 'remove_certificate' + = render 'destroy' +- else + = render 'disabled' diff --git a/app/workers/pages_worker.rb b/app/workers/pages_worker.rb index 8c99e8dbe763..4eeb9666bb0d 100644 --- a/app/workers/pages_worker.rb +++ b/app/workers/pages_worker.rb @@ -9,7 +9,11 @@ def perform(action, *arg) def deploy(build_id) build = Ci::Build.find_by(id: build_id) - Projects::UpdatePagesService.new(build.project, build).execute + result = Projects::UpdatePagesService.new(build.project, build).execute + if result[:status] == :success + result = Projects::UpdatePagesConfigurationService.new(build.project).execute + end + result end def remove(namespace_path, project_path) diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index d33f1e736d35..eeddcce2a6c1 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -299,6 +299,7 @@ def host(url) Settings.pages['port'] ||= Settings.pages.https ? 443 : 80 Settings.pages['protocol'] ||= Settings.pages.https ? "https" : "http" Settings.pages['url'] ||= Settings.send(:build_pages_url) +Settings.pages['custom_domain'] ||= false if Settings.pages['custom_domain'].nil? # # Git LFS diff --git a/config/routes.rb b/config/routes.rb index 3ba62cf71556..b98c6c99fff5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -428,7 +428,6 @@ delete :remove_fork post :archive post :unarchive - post :remove_pages post :housekeeping post :toggle_star post :markdown_preview @@ -547,6 +546,9 @@ end end + resource :pages, only: [:show, :update, :destroy] do + delete :certificates + end resources :compare, only: [:index, :create] resources :network, only: [:show], constraints: { id: /(?:[^.]|\.(?!json$))+/, format: /json/ } diff --git a/db/migrate/20160209125808_add_pages_custom_domain_to_projects.rb b/db/migrate/20160209125808_add_pages_custom_domain_to_projects.rb new file mode 100644 index 000000000000..6472199fc4a9 --- /dev/null +++ b/db/migrate/20160209125808_add_pages_custom_domain_to_projects.rb @@ -0,0 +1,10 @@ +class AddPagesCustomDomainToProjects < ActiveRecord::Migration + def change + add_column :projects, :pages_custom_certificate, :text + add_column :projects, :pages_custom_certificate_key, :text + add_column :projects, :pages_custom_certificate_key_iv, :string + add_column :projects, :pages_custom_certificate_key_salt, :string + add_column :projects, :pages_custom_domain, :string, unique: true + add_column :projects, :pages_redirect_http, :boolean, default: false, null: false + end +end -- GitLab