diff options
author | Kamil Trzcinski <ayufan@ayufan.eu> | 2016-02-09 18:06:55 +0100 |
---|---|---|
committer | James Edwards-Jones <jedwardsjones@gitlab.com> | 2017-01-31 22:53:57 +0000 |
commit | 5f7257c27dace1dcb9d3eb4732caf68f061a8d68 (patch) | |
tree | a00cfa51f1aa44ae3592d49c3232795d21d3fae4 | |
parent | c4c8ca04052aaf7d37c2335066381b536df68427 (diff) | |
download | gitlab-ce-5f7257c27dace1dcb9d3eb4732caf68f061a8d68.tar.gz |
Initial work on GitLab Pages update
23 files changed, 451 insertions, 20 deletions
@@ -48,6 +48,9 @@ gem 'rqrcode-rails3', '~> 0.1.7' gem 'attr_encrypted', '~> 3.0.0' gem 'u2f', '~> 0.2.1' +# GitLab Pages +gem 'validates_hostname', '~> 1.0.0' + # Browser detection gem 'browser', '~> 2.2' diff --git a/Gemfile.lock b/Gemfile.lock index 3b207d19d1f..6263b02b041 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -799,6 +799,9 @@ GEM get_process_mem (~> 0) unicorn (>= 4, < 6) uniform_notifier (1.10.0) + validates_hostname (1.0.5) + activerecord (>= 3.0) + activesupport (>= 3.0) version_sorter (2.1.0) virtus (1.0.5) axiom-types (~> 0.1) @@ -1014,6 +1017,7 @@ DEPENDENCIES unf (~> 0.1.4) unicorn (~> 5.1.0) unicorn-worker-killer (~> 0.4.4) + validates_hostname (~> 1.0.0) version_sorter (~> 2.1.0) virtus (~> 1.0.1) vmstat (~> 2.3.0) diff --git a/app/controllers/projects/pages_controller.rb b/app/controllers/projects/pages_controller.rb new file mode 100644 index 00000000000..ef0ed505142 --- /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 123dc179e73..444ff837bb3 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -151,16 +151,6 @@ class ProjectsController < Projects::ApplicationController 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 eb98204285d..63aa182502d 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -81,6 +81,14 @@ module ProjectsHelper "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/project.rb b/app/models/project.rb index 8a8aca44945..34618817fb6 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -76,6 +76,8 @@ class Project < ActiveRecord::Base 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 + alias_attribute :title, :name # Relations @@ -205,6 +207,11 @@ class Project < ActiveRecord::Base presence: true, inclusion: { in: ->(_object) { Gitlab.config.repositories.storages.keys } } + 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 @@ -1164,16 +1171,27 @@ class Project < ActiveRecord::Base 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 @@ -1185,6 +1203,15 @@ class Project < ActiveRecord::Base 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 @@ -1194,6 +1221,14 @@ class Project < ActiveRecord::Base 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 wiki diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 63bc639688d..ca5b39a001f 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -110,6 +110,7 @@ class ProjectPolicy < BasePolicy can! :admin_pipeline can! :admin_environment can! :admin_deployment + can! :update_pages end def public_access! 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 00000000000..be4c2fbef8c --- /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 00000000000..3b5bd30db1a --- /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 00000000000..2cba5a435b7 --- /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 c6df66d2c3c..d6c158b6de3 100644 --- a/app/views/layouts/nav/_project_settings.html.haml +++ b/app/views/layouts/nav/_project_settings.html.haml @@ -34,3 +34,7 @@ = link_to namespace_project_pipelines_settings_path(@project.namespace, @project), title: 'CI/CD Pipelines' do %span CI/CD Pipelines + = nav_link(controller: :pages) do + = link_to namespace_project_pages_path(@project.namespace, @project), title: 'Pages', data: {placement: 'right'} do + %span + Pages diff --git a/app/views/projects/pages/_access.html.haml b/app/views/projects/pages/_access.html.haml new file mode 100644 index 00000000000..d64f99fd22b --- /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 00000000000..61b995a5934 --- /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 00000000000..cf9ef5b4d6f --- /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 00000000000..a7b03d552db --- /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 00000000000..e8c0d03adfa --- /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 00000000000..30873fcf395 --- /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 00000000000..5542bbe670b --- /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 00000000000..5f689800da8 --- /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 8c99e8dbe76..4eeb9666bb0 100644 --- a/app/workers/pages_worker.rb +++ b/app/workers/pages_worker.rb @@ -9,7 +9,11 @@ class PagesWorker 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 860cafad325..239aa662d9f 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -273,6 +273,7 @@ Settings.pages['https'] = false if Settings.pages['https'].nil? 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/project.rb b/config/routes/project.rb index cd56f6281f5..956a2d3186f 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -39,6 +39,10 @@ constraints(ProjectUrlConstrainer.new) do end end + resource :pages, only: [:show, :update, :destroy] do + delete :certificates + end + resources :compare, only: [:index, :create] do collection do get :diff_for_path @@ -329,7 +333,6 @@ constraints(ProjectUrlConstrainer.new) do post :archive post :unarchive post :housekeeping - post :remove_pages post :toggle_star post :preview_markdown post :export 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 00000000000..6472199fc4a --- /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 |