diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 70643f2e1e0feb63a825eabc74016d95f5830eb8..e3784fd7039c56f2ef96a5fcbac2e7a14a38247b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -90,6 +90,7 @@ spec:other: spinach:project:half: stage: test script: + - RAILS_ENV=test bundle exec rake assets:precompile 2>/dev/null - RAILS_ENV=test SIMPLECOV=true bundle exec rake spinach:project:half tags: - ruby @@ -98,6 +99,7 @@ spinach:project:half: spinach:project:rest: stage: test script: + - RAILS_ENV=test bundle exec rake assets:precompile 2>/dev/null - RAILS_ENV=test SIMPLECOV=true bundle exec rake spinach:project:rest tags: - ruby @@ -106,6 +108,7 @@ spinach:project:rest: spinach:other: stage: test script: + - RAILS_ENV=test bundle exec rake assets:precompile 2>/dev/null - RAILS_ENV=test SIMPLECOV=true bundle exec rake spinach:other tags: - ruby @@ -276,6 +279,7 @@ spinach:project:half:ruby22: only: - master script: + - RAILS_ENV=test bundle exec rake assets:precompile 2>/dev/null - RAILS_ENV=test SIMPLECOV=true bundle exec rake spinach:project:half cache: key: "ruby22" @@ -291,6 +295,7 @@ spinach:project:rest:ruby22: only: - master script: + - RAILS_ENV=test bundle exec rake assets:precompile 2>/dev/null - RAILS_ENV=test SIMPLECOV=true bundle exec rake spinach:project:rest cache: key: "ruby22" @@ -306,6 +311,7 @@ spinach:other:ruby22: only: - master script: + - RAILS_ENV=test bundle exec rake assets:precompile 2>/dev/null - RAILS_ENV=test SIMPLECOV=true bundle exec rake spinach:other cache: key: "ruby22" @@ -319,7 +325,7 @@ spinach:other:ruby22: notify:slack: stage: notifications script: - - ./scripts/notify_slack.sh "#builds" "Build failed for master/tags!" + - ./scripts/notify_slack.sh "#builds" "Build on \`$CI_BUILD_REF_NAME\` failed! Commit \`$(git log -1 --oneline)\` See <https://gitlab.com/gitlab-org/$(basename "$PWD")/commit/"$CI_BUILD_REF"/builds>" when: on_failure only: - master@gitlab-org/gitlab-ce diff --git a/CHANGELOG b/CHANGELOG index c38af6c74be6897f61517ee5896e8ac935735931..d63385908d475869dbd94521d9740e61dd653159 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,7 +1,16 @@ Please view this file on the master branch, on stable branches it's out of date. v 8.6.0 (unreleased) + - Contributions to forked projects are included in calendar - Improve the formatting for the user page bio (Connor Shea) + - Fix issue when pushing to projects ending in .wiki + - Fix avatar stretching by providing a cropping feature (Johann Pardanaud) + - Strip leading and trailing spaces in URL validator (evuez) + - Update documentation to reflect Guest role not being enforced on internal projects + +v 8.5.2 + - Fix sidebar overlapping content when screen width was below 1200px + - Fix error 500 when commenting on a commit v 8.5.1 - Fix group projects styles @@ -21,9 +30,7 @@ v 8.5.1 - Re-add the newrelic_rpm gem which was removed without any deprecation or warning (Stan Hu) - Update sentry-raven gem to 0.15.6 - Add build coverage in project's builds page (Steffen Köhler) - -v 8.5.2 - - Fix error 500 when commenting on a commit + - Changed # to ! for merge requests in activity view v 8.5.1 - Fix group projects styles diff --git a/Gemfile b/Gemfile index b15f97e02e56f04baddb451641a925fbe5d55321..2ce00596748f92bc385b1a6754e4ba174c2227bf 100644 --- a/Gemfile +++ b/Gemfile @@ -1,6 +1,6 @@ source "https://rubygems.org" -gem 'rails', '4.2.5.1' +gem 'rails', '4.2.5.2' gem 'rails-deprecated_sanitizer', '~> 1.0.3' # Responders respond_to and respond_with @@ -82,6 +82,9 @@ gem "haml-rails", '~> 0.9.0' # Files attachments gem "carrierwave", '~> 0.9.0' +# Image editing +gem "mini_magick", '~> 4.4.0' + # Drag and Drop UI gem 'dropzonejs-rails', '~> 0.7.1' @@ -220,7 +223,6 @@ gem 'jquery-atwho-rails', '~> 1.3.2' gem 'jquery-rails', '~> 4.0.0' gem 'jquery-scrollto-rails', '~> 1.4.3' gem 'jquery-ui-rails', '~> 5.0.0' -gem 'nprogress-rails', '~> 0.1.6.7' gem 'raphael-rails', '~> 2.1.2' gem 'request_store', '~> 1.2.0' gem 'select2-rails', '~> 3.5.9' diff --git a/Gemfile.lock b/Gemfile.lock index 141e49972a7ad8e1d1c368d072765953f97de67e..0f197bdb402a39afaa5b5406e618edf8cabbce77 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -4,41 +4,41 @@ GEM CFPropertyList (2.3.2) RedCloth (4.2.9) ace-rails-ap (2.0.1) - actionmailer (4.2.5.1) - actionpack (= 4.2.5.1) - actionview (= 4.2.5.1) - activejob (= 4.2.5.1) + actionmailer (4.2.5.2) + actionpack (= 4.2.5.2) + actionview (= 4.2.5.2) + activejob (= 4.2.5.2) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 1.0, >= 1.0.5) - actionpack (4.2.5.1) - actionview (= 4.2.5.1) - activesupport (= 4.2.5.1) + actionpack (4.2.5.2) + actionview (= 4.2.5.2) + activesupport (= 4.2.5.2) rack (~> 1.6) rack-test (~> 0.6.2) rails-dom-testing (~> 1.0, >= 1.0.5) rails-html-sanitizer (~> 1.0, >= 1.0.2) - actionview (4.2.5.1) - activesupport (= 4.2.5.1) + actionview (4.2.5.2) + activesupport (= 4.2.5.2) builder (~> 3.1) erubis (~> 2.7.0) rails-dom-testing (~> 1.0, >= 1.0.5) rails-html-sanitizer (~> 1.0, >= 1.0.2) - activejob (4.2.5.1) - activesupport (= 4.2.5.1) + activejob (4.2.5.2) + activesupport (= 4.2.5.2) globalid (>= 0.3.0) - activemodel (4.2.5.1) - activesupport (= 4.2.5.1) + activemodel (4.2.5.2) + activesupport (= 4.2.5.2) builder (~> 3.1) - activerecord (4.2.5.1) - activemodel (= 4.2.5.1) - activesupport (= 4.2.5.1) + activerecord (4.2.5.2) + activemodel (= 4.2.5.2) + activesupport (= 4.2.5.2) arel (~> 6.0) activerecord-deprecated_finders (1.0.4) activerecord-session_store (0.1.2) actionpack (>= 4.0.0, < 5) activerecord (>= 4.0.0, < 5) railties (>= 4.0.0, < 5) - activesupport (4.2.5.1) + activesupport (4.2.5.2) i18n (~> 0.7) json (~> 1.7, >= 1.7.7) minitest (~> 5.1) @@ -492,6 +492,7 @@ GEM method_source (0.8.2) mime-types (1.25.1) mimemagic (0.3.0) + mini_magick (4.4.0) mini_portile2 (2.0.0) minitest (5.7.0) mousetrap-rails (1.4.6) @@ -506,7 +507,6 @@ GEM newrelic_rpm (3.14.1.311) nokogiri (1.6.7.2) mini_portile2 (~> 2.0.0.rc2) - nprogress-rails (0.1.6.7) oauth (0.4.7) oauth2 (1.0.0) faraday (>= 0.8, < 0.10) @@ -610,16 +610,16 @@ GEM rack rack-test (0.6.3) rack (>= 1.0) - rails (4.2.5.1) - actionmailer (= 4.2.5.1) - actionpack (= 4.2.5.1) - actionview (= 4.2.5.1) - activejob (= 4.2.5.1) - activemodel (= 4.2.5.1) - activerecord (= 4.2.5.1) - activesupport (= 4.2.5.1) + rails (4.2.5.2) + actionmailer (= 4.2.5.2) + actionpack (= 4.2.5.2) + actionview (= 4.2.5.2) + activejob (= 4.2.5.2) + activemodel (= 4.2.5.2) + activerecord (= 4.2.5.2) + activesupport (= 4.2.5.2) bundler (>= 1.3.0, < 2.0) - railties (= 4.2.5.1) + railties (= 4.2.5.2) sprockets-rails rails-deprecated_sanitizer (1.0.3) activesupport (>= 4.2.0.alpha) @@ -629,9 +629,9 @@ GEM rails-deprecated_sanitizer (>= 1.0.1) rails-html-sanitizer (1.0.3) loofah (~> 2.0) - railties (4.2.5.1) - actionpack (= 4.2.5.1) - activesupport (= 4.2.5.1) + railties (4.2.5.2) + actionpack (= 4.2.5.2) + activesupport (= 4.2.5.2) rake (>= 0.8.7) thor (>= 0.18.1, < 2.0) rainbow (2.0.0) @@ -987,6 +987,7 @@ DEPENDENCIES loofah (~> 2.0.3) mail_room (~> 0.6.1) method_source (~> 0.8) + mini_magick (~> 4.4.0) minitest (~> 5.7.0) mousetrap-rails (~> 1.4.6) mysql2 (~> 0.3.16) @@ -995,7 +996,6 @@ DEPENDENCIES net-ssh (~> 3.0.1) newrelic_rpm (~> 3.14) nokogiri (~> 1.6.7, >= 1.6.7.2) - nprogress-rails (~> 0.1.6.7) oauth2 (~> 1.0.0) octokit (~> 3.8.0) omniauth (~> 1.3.1) @@ -1020,7 +1020,7 @@ DEPENDENCIES rack-attack (~> 4.3.1) rack-cors (~> 0.4.0) rack-oauth2 (~> 1.2.1) - rails (= 4.2.5.1) + rails (= 4.2.5.2) rails-deprecated_sanitizer (~> 1.0.3) raphael-rails (~> 2.1.2) rblineprof diff --git a/app/assets/javascripts/application.js.coffee b/app/assets/javascripts/application.js.coffee index b0fd5af9e92f1fa036176b0a2732fed6edad3edc..2c2df95f5adca274651927e1bc30c3afc3506d07 100644 --- a/app/assets/javascripts/application.js.coffee +++ b/app/assets/javascripts/application.js.coffee @@ -32,8 +32,6 @@ #= require ace/ace #= require ace/ext-searchbox #= require underscore -#= require nprogress -#= require nprogress-turbolinks #= require dropzone #= require mousetrap #= require mousetrap/pause @@ -45,6 +43,7 @@ #= require jquery.nicescroll #= require_tree . #= require fuzzaldrin-plus +#= require cropper.js window.slugify = (text) -> text.replace(/[^-a-zA-Z0-9]+/g, '_').toLowerCase() diff --git a/app/assets/javascripts/autosave.js.coffee b/app/assets/javascripts/autosave.js.coffee index 5d3fe81da74aefe071aaf29f905d29f39a898859..28f8e103664110f8ba3bf8c4b6b9a663cb06c734 100644 --- a/app/assets/javascripts/autosave.js.coffee +++ b/app/assets/javascripts/autosave.js.coffee @@ -16,11 +16,11 @@ class @Autosave try text = window.localStorage.getItem @key - catch + catch e return @field.val text if text?.length > 0 - @field.trigger "input" + @field.trigger "input" save: -> return unless window.localStorage? @@ -35,5 +35,5 @@ class @Autosave reset: -> return unless window.localStorage? - try + try window.localStorage.removeItem @key diff --git a/app/assets/javascripts/logo.js.coffee b/app/assets/javascripts/logo.js.coffee index 35b2fbbba0736d135b3426cbfc60e6896894c514..d14b7139237217a1cb1d6c7a5ea94dd3129378ee 100644 --- a/app/assets/javascripts/logo.js.coffee +++ b/app/assets/javascripts/logo.js.coffee @@ -1,4 +1,4 @@ -NProgress.configure(showSpinner: false) +Turbolinks.enableProgressBar(); defaultClass = 'tanuki-shape' pieces = [ diff --git a/app/assets/javascripts/merge_request_tabs.js.coffee b/app/assets/javascripts/merge_request_tabs.js.coffee index 40cfa59a229b2287bda8f284ca333ba6d0fc43d2..23a218b4c7d0c44561a914b116ce0a32fe6b1f74 100644 --- a/app/assets/javascripts/merge_request_tabs.js.coffee +++ b/app/assets/javascripts/merge_request_tabs.js.coffee @@ -146,6 +146,7 @@ class @MergeRequestTabs url: "#{source}.json" + @_location.search success: (data) => document.querySelector("div#diffs").innerHTML = data.html + $('.js-timeago').timeago() $('div#diffs .js-syntax-highlight').syntaxHighlight() @expandViewContainer() if @diffViewType() is 'parallel' @diffsLoaded = true diff --git a/app/assets/javascripts/profile.js.coffee b/app/assets/javascripts/profile.js.coffee index bb0b66b86e1a91587f157ca9d9d0ef8f4d4cdac1..69d590a7533128dda290369837c05309721493d2 100644 --- a/app/assets/javascripts/profile.js.coffee +++ b/app/assets/javascripts/profile.js.coffee @@ -16,11 +16,50 @@ class @Profile $('.update-notifications').on 'ajax:complete', -> $(this).find('.btn-save').enable() - $('.js-choose-user-avatar-button').bind "click", -> - form = $(this).closest("form") - form.find(".js-user-avatar-input").click() + # Avatar management + + $avatarInput = $('.js-user-avatar-input') + $filename = $('.js-avatar-filename') + $modalCrop = $('.modal-profile-crop') + $modalCropImg = $('.modal-profile-crop-image') + + $('.js-choose-user-avatar-button').on "click", -> + $form = $(this).closest("form") + $form.find(".js-user-avatar-input").click() + + $modalCrop.on 'shown.bs.modal', -> + setTimeout ( -> # The cropper must be asynchronously initialized + $modalCropImg.cropper + aspectRatio: 1 + modal: false + scalable: false + rotatable: false + zoomable: false + + crop: (event) -> + ['x', 'y'].forEach (key) -> + $("#user_avatar_crop_#{key}").val(Math.floor(event[key])) + $("#user_avatar_crop_size").val(Math.floor(event.width)) + ), 0 + + $modalCrop.on 'hidden.bs.modal', -> + $modalCropImg.attr('src', '').cropper('destroy') + $avatarInput.val('') + $filename.text($filename.data('label')) - $('.js-user-avatar-input').bind "change", -> + $('.js-upload-user-avatar').on 'click', -> + $('.edit_user').submit() + + $avatarInput.on "change", -> form = $(this).closest("form") filename = $(this).val().replace(/^.*[\\\/]/, '') - form.find(".js-avatar-filename").text(filename) + $filename.data('label', $filename.text()).text(filename) + + reader = new FileReader + + reader.onload = (event) -> + $modalCrop.modal('show') + $modalCropImg.attr('src', event.target.result) + + fileData = reader.readAsDataURL(this.files[0]) + diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 0c0451fe4ddffcca924242dc13424067609af4a0..e2d590f4df4e8bd37ffeb1952adc6f7d2f4b0f0e 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -9,6 +9,7 @@ *= require_self *= require dropzone/basic *= require cal-heatmap + *= require cropper.css */ /* @@ -24,12 +25,6 @@ */ @import "framework"; -/* - * NProgress load bar css - */ -@import 'nprogress'; -@import 'nprogress-bootstrap'; - /* * Font icons */ diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index fa7641b1676d236fc9b1b45630b7c5653f26c027..e2a30f5ed34fd3c30ad18f814c277495b92b6a74 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -26,6 +26,7 @@ @import "framework/mobile.scss"; @import "framework/nav.scss"; @import "framework/pagination.scss"; +@import "framework/progress.scss"; @import "framework/panels.scss"; @import "framework/selects.scss"; @import "framework/sidebar.scss"; diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss index 354392d5ec3c4ffe3a8544b42c654f7d2ce80a03..b6a781f79de50b5bb4eab938fda59a749509e61f 100644 --- a/app/assets/stylesheets/framework/lists.scss +++ b/app/assets/stylesheets/framework/lists.scss @@ -110,7 +110,20 @@ ul.content-list { > li { border-color: $table-border-color; - color: $gl-gray; + color: $list-text-color; + font-size: $list-font-size; + + .title { + color: $list-title-color; + font-weight: 600; + } + + .description { + p { + @include str-truncated; + margin-bottom: 0; + } + } .avatar { margin-right: 15px; @@ -127,13 +140,6 @@ ul.content-list { } } -.panel > .content-list { - li { - margin: 0; - padding: $gl-padding; - } -} - ul.controls { padding-top: 1px; float: right; diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index 1d5000fe38881cdf5b388ab0ebf35caea083e2f8..368bbfe53551c81389dd378498f3e14d2ae5616c 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -41,6 +41,12 @@ transition: $transition; } +@mixin transform($transform) { + -webkit-transform: $transform; + -ms-transform: $transform; + transform: $transform; +} + /** * Prefilled mixins * Mixins with fixed values diff --git a/app/assets/stylesheets/framework/progress.scss b/app/assets/stylesheets/framework/progress.scss new file mode 100644 index 0000000000000000000000000000000000000000..e9800bd24b53d9e562b5470373170e921e298c64 --- /dev/null +++ b/app/assets/stylesheets/framework/progress.scss @@ -0,0 +1,5 @@ +html.turbolinks-progress-bar::before { + background-color: $progress-color!important; + height: 2px!important; + box-shadow: 0 0 10px $progress-color, 0 0 5px $progress-color; +} diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index e0ccd6f100fa3a8ff6fecf9b1f765a9c04bc0841..0596924a8f64d699d8c94d6c85b5e5d936d1795f 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -13,6 +13,19 @@ transition-duration: .3s; } + .home { + z-index: 1; + position: absolute; + left: 0px; + } + + #logo { + z-index: 2; + position: absolute; + width: 58px; + cursor: pointer; + } + &.right-sidebar-expanded { padding-right: $gutter_width; } @@ -74,7 +87,7 @@ width: 158px; float: left; margin: 0; - margin-left: 14px; + margin-left: 50px; font-size: 19px; line-height: 41px; font-weight: normal; diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 4d72c7e905e8c602dd3cef17fcb6dd4f8dbbedd7..7834cb0bfa541c9cd99300d161f76b074106b763 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -7,7 +7,7 @@ $gl-header-color: #323232; $gl-link-color: #333c48; $md-text-color: #444; $md-link-color: #3084bb; -$nprogress-color: #c0392b; +$progress-color: #c0392b; $gl-font-size: 15px; $list-font-size: 15px; $sidebar_collapsed_width: 62px; @@ -32,6 +32,8 @@ $gl-avatar-size: 40px; $secondary-text: #7f8fa4; $error-exclamation-point: #E62958; $border-radius-default: 3px; +$list-title-color: #333333; +$list-text-color: #555555; /* * Color schema diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index 8694bd654a745485ab8add2044b2e40448d49417..a2ca00234edc4b230dd1a21ae48b9a4603f401fe 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -4,13 +4,7 @@ position: relative; .issue-title { - margin-bottom: 5px; - font-size: $list-font-size; - font-weight: 600; - } - - .issue-info { - color: $gl-gray; + margin-bottom: 2px; } .issue-check { diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 9a2c4b83ffbb48d49cc258e23a646b5316012338..2772623f4bde7dc35d297abe5d00bf21fc8f4c23 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -148,15 +148,8 @@ position: relative; .merge-request-title { - margin-bottom: 5px; - font-size: $list-font-size; - font-weight: 600; - } - - .merge-request-info { - color: $gl-gray; + margin-bottom: 2px; } - } .merge-request-labels { diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 19ead07c06ae159039a21dfbfa897e989e25176a..d5f9852ebed3163afc9fcf70fba34ac13d7c7914 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -14,6 +14,18 @@ ul.notes { margin: 0px; padding: 0px; + .timeline-icon { + float: left; + } + + .timeline-content { + margin-left: 55px; + } + + .note_created_ago, .note-updated-at { + white-space: nowrap; + } + .system-note { font-size: 14px; padding-top: 10px; @@ -151,6 +163,7 @@ ul.notes { border-left: none; &.notes_line { + vertical-align: middle; text-align: center; padding: 10px 0; background: #FFF; diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index 4767c65d9a7858ccb9c30b77966cd6aaacc0642a..de4d9fd80fa25c7c8f1cdc3af1b0fc30e48f7542 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -78,3 +78,39 @@ max-width: 750px; margin: auto; } + +.modal-profile-crop { + .modal-dialog { + width: 500px; + } + + .modal-body { + p { + display: table; + margin: auto; + overflow: hidden; + } + + img { + display: block; + max-width: 400px; + max-height: 400px; + } + + .cropper-bg { + background: none; + } + + .cropper-crop-box { + box-sizing: content-box; + border: 999px solid transparentize(#ccc, 0.5); + @include transform(translate(-999px, -999px)); + } + } +} + +@media (max-width: 520px) { + .modal-profile-crop .modal-dialog { + width: auto; + } +} diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index b86d1a98723f8a57d346faa3e5507c8f61c00e24..247ac83c24a02fff34c799594e6b9a6c641aba71 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -397,15 +397,10 @@ pre.light-well { .project-full-name { @include str-truncated; - font-weight: 600; - color: #4c4e54; } - .project-controls { - float: right; - color: $gl-gray; + .controls { line-height: 40px; - color: #7f8fa4; a:hover { text-decoration: none; @@ -415,16 +410,6 @@ pre.light-well { margin-left: 10px; } } - - .project-description { - color: #7f8fa4; - - p { - @include str-truncated; - margin-bottom: 0; - color: #7f8fa4; - } - } } .bottom { diff --git a/app/assets/stylesheets/pages/snippets.scss b/app/assets/stylesheets/pages/snippets.scss index 1430d01859d9352d472c249a662ad4b9eed0e24c..0161642d87136a977ee1298daed388b4463f4d79 100644 --- a/app/assets/stylesheets/pages/snippets.scss +++ b/app/assets/stylesheets/pages/snippets.scss @@ -2,30 +2,6 @@ padding: 2px; } - -.snippet-row { - .snippet-title { - font-size: 15px; - font-weight: bold; - line-height: 20px; - margin-bottom: 2px; - - .monospace { - font-weight: normal; - } - } - - .snippet-info { - color: #888; - font-size: 13px; - line-height: 24px; - - a { - color: #888; - } - } -} - .snippet-holder { margin-bottom: -$gl-padding; diff --git a/app/assets/stylesheets/pages/todos.scss b/app/assets/stylesheets/pages/todos.scss index 2f57f21963d22ebe2de6e68c7f8d3df020505782..0dc5a905f995ab35591d69a4f57a9ff935acedb7 100644 --- a/app/assets/stylesheets/pages/todos.scss +++ b/app/assets/stylesheets/pages/todos.scss @@ -12,29 +12,10 @@ } } -.todos { - .panel { - border-top: none; - margin-bottom: 0; - } -} - .todo-item { font-size: $gl-font-size; - padding: $gl-padding-top 0 $gl-padding-top ($gl-avatar-size + $gl-padding-top); - border-bottom: 1px solid $table-border-color; - color: #7f8fa4; - - &.todo-inline { - .avatar { - position: relative; - top: -2px; - } - - .todo-title { - line-height: 40px; - } - } + padding-left: $gl-avatar-size + $gl-padding-top; + color: $secondary-text; a { color: #4c4e54; @@ -48,7 +29,7 @@ @include str-truncated(calc(100% - 174px)); font-weight: 600; - .author_name { + .author-name { color: #333; } } @@ -88,17 +69,7 @@ margin-bottom: 0; } } - - .todo-note-icon { - color: #777; - float: left; - font-size: $gl-font-size; - line-height: 16px; - margin-right: 5px; - } } - - &:last-child { border:none } } @media (max-width: $screen-xs-max) { diff --git a/app/assets/stylesheets/pages/ui_dev_kit.scss b/app/assets/stylesheets/pages/ui_dev_kit.scss index 185f3622e6474475d96c0c46ad706b224f86b62f..05fa9312efbb845e4324b114cd9a451a1d85006a 100644 --- a/app/assets/stylesheets/pages/ui_dev_kit.scss +++ b/app/assets/stylesheets/pages/ui_dev_kit.scss @@ -3,4 +3,15 @@ margin: 35px 0 20px; font-weight: bold; } + + .example { + &:before { + content: "Example"; + color: #BBB; + } + + padding: 15px; + border: 1px dashed #ddd; + margin-bottom: 15px; + } } diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb index f3bfede4354afc025e408e88b8000a96a56cc4a1..8f83fdd02bc71c256564ac40e6731415021e85ce 100644 --- a/app/controllers/profiles/two_factor_auths_controller.rb +++ b/app/controllers/profiles/two_factor_auths_controller.rb @@ -12,11 +12,13 @@ def new current_user.save! if current_user.changed? - if two_factor_grace_period_expired? - flash.now[:alert] = 'You must enable Two-factor Authentication for your account.' - else - grace_period_deadline = current_user.otp_grace_period_started_at + two_factor_grace_period.hours - flash.now[:alert] = "You must enable Two-factor Authentication for your account before #{l(grace_period_deadline)}." + if two_factor_authentication_required? + if two_factor_grace_period_expired? + flash.now[:alert] = 'You must enable Two-factor Authentication for your account.' + else + grace_period_deadline = current_user.otp_grace_period_started_at + two_factor_grace_period.hours + flash.now[:alert] = "You must enable Two-factor Authentication for your account before #{l(grace_period_deadline)}." + end end @qr_code = build_qr_code diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index 28803164fcfb29d079f76598b4b75b5069fb65b7..fa7a11489615f042b94a3badac85dcfdff588aaa 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -65,6 +65,9 @@ def authorize_change_username! def user_params params.require(:user).permit( + :avatar_crop_x, + :avatar_crop_y, + :avatar_crop_size, :avatar, :bio, :email, diff --git a/app/controllers/projects/avatars_controller.rb b/app/controllers/projects/avatars_controller.rb index f7e6bb34443e1daa1ae263cdb1a6c6d4bc9f07b3..b64dbbd89ce9eb0d15f80716961c6d3b4b53e7d6 100644 --- a/app/controllers/projects/avatars_controller.rb +++ b/app/controllers/projects/avatars_controller.rb @@ -1,4 +1,6 @@ class Projects::AvatarsController < Projects::ApplicationController + include BlobHelper + before_action :project def show @@ -7,7 +9,7 @@ def show headers['X-Content-Type-Options'] = 'nosniff' headers.store(*Gitlab::Workhorse.send_git_blob(@repository, @blob)) headers['Content-Disposition'] = 'inline' - headers['Content-Type'] = @blob.content_type + headers['Content-Type'] = safe_content_type(@blob) head :ok # 'render nothing: true' messes up the Content-Type else render_404 diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb index 0c551501ca4cd58c4808d90850a9fc66d859240a..a0835c9aad0e114e77d26333debbd95e9523c733 100644 --- a/app/controllers/projects/forks_controller.rb +++ b/app/controllers/projects/forks_controller.rb @@ -4,12 +4,22 @@ class Projects::ForksController < Projects::ApplicationController before_action :authorize_download_code! def index - @sort = params[:sort] || 'id_desc' - @all_forks = project.forks.includes(:creator).order_by(@sort) - - @public_forks, @protected_forks = @all_forks.partition do |project| - can?(current_user, :read_project, project) - end + base_query = project.forks.includes(:creator) + + @forks = if current_user + base_query.where('projects.visibility_level IN (?) OR projects.id IN (?)', + Project.public_and_internal_levels, + current_user.authorized_projects.pluck(:id)) + else + base_query.where('projects.visibility_level = ?', Project::PUBLIC) + end + + @total_forks_count = base_query.size + @private_forks_count = @total_forks_count - @forks.size + @public_forks_count = @total_forks_count - @private_forks_count + + @sort = params[:sort] || 'id_desc' + @forks = @forks.order_by(@sort).page(params[:page]).per(PER_PAGE) end def new diff --git a/app/controllers/projects/raw_controller.rb b/app/controllers/projects/raw_controller.rb index 87b4d08da0e75aa9421a703c892e51655e53633b..d9723acb1d96ee8982577bed7ac498533d625119 100644 --- a/app/controllers/projects/raw_controller.rb +++ b/app/controllers/projects/raw_controller.rb @@ -1,6 +1,7 @@ # Controller for viewing a file's raw class Projects::RawController < Projects::ApplicationController include ExtractsPath + include BlobHelper before_action :require_non_empty_project before_action :assign_ref_vars @@ -17,7 +18,7 @@ def show else headers.store(*Gitlab::Workhorse.send_git_blob(@repository, @blob)) headers['Content-Disposition'] = 'inline' - headers['Content-Type'] = get_blob_type + headers['Content-Type'] = safe_content_type(@blob) head :ok # 'render nothing: true' messes up the Content-Type end else @@ -27,16 +28,6 @@ def show private - def get_blob_type - if @blob.text? - 'text/plain; charset=utf-8' - elsif @blob.image? - @blob.content_type - else - 'application/octet-stream' - end - end - def send_lfs_object lfs_object = find_lfs_object diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 6055b60608622e8413d5e72874ec109da3f825a4..626213c67287a592a1fd30b221b4f7c4ca824e42 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -57,7 +57,7 @@ def contributed_projects def contributions_calendar @contributions_calendar ||= Gitlab::ContributionsCalendar. - new(contributed_projects.reject(&:forked?), @user) + new(contributed_projects, @user) end def load_events diff --git a/app/helpers/appearances_helper.rb b/app/helpers/appearances_helper.rb index d7e4d7c447ada6296a0b53ee3c025b2ecb079a4b..a46d4fe7ccffa9167335656390e2869e0c0d1dfe 100644 --- a/app/helpers/appearances_helper.rb +++ b/app/helpers/appearances_helper.rb @@ -3,7 +3,11 @@ def brand_title if brand_item && brand_item.title brand_item.title else +<<<<<<< HEAD 'GitLab Enterprise Edition' +======= + 'GitLab Community Edition' +>>>>>>> 204a9895333178cc90b40ef365ad566d750fa594 end end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 7143a7448699fd9fafa49507b25f211cb5aaf584..7f63a2e2cb4f7938910c54efe5d89c113fec3af9 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -134,4 +134,22 @@ def sanitize_svg(blob) blob.data = Loofah.scrub_fragment(blob.data, :strip).to_xml blob end + + # If we blindly set the 'real' content type when serving a Git blob we + # are enabling XSS attacks. An attacker could upload e.g. a Javascript + # file to a Git repository, trick the browser of a victim into + # downloading the blob, and then the 'application/javascript' content + # type would tell the browser to execute the attacker's Javascript. By + # overriding the content type and setting it to 'text/plain' (in the + # example of Javascript) we tell the browser of the victim not to + # execute untrusted data. + def safe_content_type(blob) + if blob.text? + 'text/plain; charset=utf-8' + elsif blob.image? + blob.content_type + else + 'application/octet-stream' + end + end end diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index 31bf45baeb708ec4cabf14b60f8e08a10740f68f..e5fcaab9551975f9381a24bb73035d7aedcc1a87 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -168,11 +168,11 @@ def event_note_title_html(event) link_to(namespace_project_snippet_path(event.project.namespace, event.project, event.note_target)) do - "#{event.note_target_type} ##{truncate event.note_target_id}" + "#{event.note_target_type} #{truncate event.note_target.to_reference}" end else link_to event_note_target_path(event) do - "#{event.note_target_type} ##{truncate event.note_target_iid}" + "#{event.note_target_type} #{truncate event.note_target.to_reference}" end end else diff --git a/app/models/appearance.rb b/app/models/appearance.rb index 40716c5ccfa9ec4412b7a7cd49ff38cddf65a2a3..5f4c5d8c9e09fd7948254a2c72b242d694b2c55d 100644 --- a/app/models/appearance.rb +++ b/app/models/appearance.rb @@ -1,3 +1,4 @@ +<<<<<<< HEAD # == Schema Information # # Table name: appearances @@ -12,6 +13,8 @@ # header_logo :string(255) # +======= +>>>>>>> 204a9895333178cc90b40ef365ad566d750fa594 class Appearance < ActiveRecord::Base validates :title, presence: true validates :description, presence: true diff --git a/app/models/milestone.rb b/app/models/milestone.rb index 5e65c376e7548af8518256d13258b86460e0becc..03e5217ef4bb9edbe2a3fcd7f67fc7f4c17afcbb 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -28,7 +28,7 @@ class Milestone < ActiveRecord::Base belongs_to :project has_many :issues - has_many :labels, through: :issues + has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues has_many :merge_requests has_many :participants, through: :issues, source: :assignee diff --git a/app/models/project.rb b/app/models/project.rb index fb347624b024cb22d5001a1f38f9dba18613518c..5d99637299f291eae9cb44467a5e820408b0f033 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -908,10 +908,7 @@ def unarchive! end def change_head(branch) - # Cached divergent commit counts are based on repository head - repository.expire_branch_cache - repository.expire_root_ref_cache - + repository.before_change_head gitlab_shell.update_repository_head(self.path_with_namespace, branch) reload_default_branch end diff --git a/app/models/repository.rb b/app/models/repository.rb index 4844e81feb71f9a47699c2a01efc993cf0c316e4..bebe873cc6573a0e3f9954088ac25cd2c83f1566 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -269,15 +269,6 @@ def expire_cache(branch_name = nil) expire_emptiness_caches if empty? end - # Expires _all_ caches, including those that would normally only be expired - # under specific conditions. - def expire_all_caches! - expire_cache - expire_root_ref_cache - expire_emptiness_caches - expire_has_visible_content_cache - end - def expire_branch_cache(branch_name = nil) # When we push to the root branch we have to flush the cache for all other # branches as their statistics are based on the commits relative to the @@ -331,6 +322,46 @@ def expire_branch_names cache.expire(:branch_names) end + # Runs code just before a repository is deleted. + def before_delete + expire_cache if exists? + + expire_root_ref_cache + expire_emptiness_caches + end + + # Runs code just before the HEAD of a repository is changed. + def before_change_head + # Cached divergent commit counts are based on repository head + expire_branch_cache + expire_root_ref_cache + end + + # Runs code before creating a new tag. + def before_create_tag + expire_cache + end + + # Runs code after a repository has been forked/imported. + def after_import + expire_emptiness_caches + end + + # Runs code after a new commit has been pushed. + def after_push_commit(branch_name) + expire_cache(branch_name) + end + + # Runs code after a new branch has been created. + def after_create_branch + expire_has_visible_content_cache + end + + # Runs code after an existing branch has been removed. + def after_remove_branch + expire_has_visible_content_cache + end + def method_missing(m, *args, &block) if m == :lookup && !block_given? lookup_cache[m] ||= {} diff --git a/app/models/user.rb b/app/models/user.rb index 2772952c295f620af1ba07f67330913f99775e20..07b36f73460bc566031ee4db93a027108bcd65f1 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -98,6 +98,9 @@ class User < ActiveRecord::Base # Virtual attribute for authenticating by either username or email attr_accessor :login + # Virtual attributes to define avatar cropping + attr_accessor :avatar_crop_x, :avatar_crop_y, :avatar_crop_size + # # Relations # @@ -165,6 +168,11 @@ class User < ActiveRecord::Base validate :owns_public_email, if: ->(user) { user.public_email_changed? } validates :avatar, file_size: { maximum: 200.kilobytes.to_i } + validates :avatar_crop_x, :avatar_crop_y, :avatar_crop_size, + numericality: { only_integer: true }, + presence: true, + if: ->(user) { user.avatar? } + before_validation :generate_password, on: :create before_validation :restricted_signup_domains, on: :create before_validation :sanitize_attrs @@ -383,11 +391,12 @@ def recently_sent_password_reset? def disable_two_factor! update_attributes( - two_factor_enabled: false, - encrypted_otp_secret: nil, - encrypted_otp_secret_iv: nil, - encrypted_otp_secret_salt: nil, - otp_backup_codes: nil + two_factor_enabled: false, + encrypted_otp_secret: nil, + encrypted_otp_secret_iv: nil, + encrypted_otp_secret_salt: nil, + otp_grace_period_started_at: nil, + otp_backup_codes: nil ) end diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb index 2544f82f91f06254f93f4cebf0da5d285af34021..de54712d9a6361b08803cfb21b69e4ca94206ee1 100644 --- a/app/services/git_push_service.rb +++ b/app/services/git_push_service.rb @@ -16,13 +16,13 @@ class GitPushService < BaseService # 5. Executes the project's services # def execute - @project.repository.expire_cache(branch_name) + @project.repository.after_push_commit(branch_name) if push_remove_branch? - @project.repository.expire_has_visible_content_cache + @project.repository.after_remove_branch @push_commits = [] elsif push_to_new_branch? - @project.repository.expire_has_visible_content_cache + @project.repository.after_create_branch # Re-find the pushed commits. if is_default_branch? diff --git a/app/services/git_tag_push_service.rb b/app/services/git_tag_push_service.rb index e06650fb47a290ead834e8e3529925594faa42eb..ca0dd8eeb696b1f80a50f27c88f29b60a27d5eae 100644 --- a/app/services/git_tag_push_service.rb +++ b/app/services/git_tag_push_service.rb @@ -2,7 +2,7 @@ class GitTagPushService attr_accessor :project, :user, :push_data def execute(project, user, oldrev, newrev, ref, mirror_update: false) - project.repository.expire_cache + project.repository.before_create_tag @project, @user = project, user @push_data = build_push_data(oldrev, newrev, ref) diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index f4dcb142850452cf51b39fa53c69bb1d9a4da5e8..df5054f08d7f35c2d21449ab593cb92a5abb47c0 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -76,11 +76,9 @@ def removal_path(path) end def flush_caches(project, wiki_path) - project.repository.expire_all_caches! if project.repository.exists? + project.repository.before_delete - wiki_repo = Repository.new(wiki_path, project) - - wiki_repo.expire_all_caches! if wiki_repo.exists? + Repository.new(wiki_path, project).before_delete end end end diff --git a/app/uploaders/avatar_uploader.rb b/app/uploaders/avatar_uploader.rb index 6135c3ad96f4e5c3916f426d08cc177880829a2a..2c72df44ff0ba10ffb9ab4daed36a9212fe950ce 100644 --- a/app/uploaders/avatar_uploader.rb +++ b/app/uploaders/avatar_uploader.rb @@ -2,11 +2,22 @@ class AvatarUploader < CarrierWave::Uploader::Base include UploaderHelper + include CarrierWave::MiniMagick storage :file after :store, :reset_events_cache + process :cropper + + def cropper + return unless model.respond_to?(:avatar_crop_size) && model.valid? + + manipulate! do |img| + img.crop "#{model.avatar_crop_size}x#{model.avatar_crop_size}+#{model.avatar_crop_x}+#{model.avatar_crop_y}" + end + end + def store_dir "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}" end diff --git a/app/validators/url_validator.rb b/app/validators/url_validator.rb index 2848b9cd33d1603fd5860315f99dbe961744ad21..a77beb2683dd88a94dfe89c04923ba37c8bb4d01 100644 --- a/app/validators/url_validator.rb +++ b/app/validators/url_validator.rb @@ -29,8 +29,11 @@ def default_options end def valid_url?(value) + return false if value.nil? + options = default_options.merge(self.options) + value.strip! value =~ /\A#{URI.regexp(options[:protocols])}\z/ end end diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml index 6975f6ed0db9ce2268fd09fd4253ecd1c041b728..f878d36e7394008d913e1d7e467bd38a36049d93 100644 --- a/app/views/dashboard/todos/_todo.html.haml +++ b/app/views/dashboard/todos/_todo.html.haml @@ -1,11 +1,11 @@ %li{class: "todo todo-#{todo.done? ? 'done' : 'pending'}", id: dom_id(todo) } - .todo-item{class: 'todo-block'} + .todo-item.todo-block = image_tag avatar_icon(todo.author_email, 40), class: 'avatar s40', alt:'' .todo-title - %span.author_name + %span.author-name = link_to_author todo - %span.todo_label + %span.todo-label = todo_action_name(todo) = todo_target_link(todo) diff --git a/app/views/events/event/_common.html.haml b/app/views/events/event/_common.html.haml index 4ecf1c33d2ad5e4c37e73bd21e2f57e75d64c31b..e9e16a7646f4abf54a28e37b954b661b182bc4d6 100644 --- a/app/views/events/event/_common.html.haml +++ b/app/views/events/event/_common.html.haml @@ -4,7 +4,7 @@ = event_action_name(event) - if event.target - %strong= link_to "##{event.target_iid}", [event.project.namespace.becomes(Namespace), event.project, event.target] + %strong= link_to event.target.to_reference, [event.project.namespace.becomes(Namespace), event.project, event.target] = event_preposition(event) diff --git a/app/views/help/ui.html.haml b/app/views/help/ui.html.haml index 746386cab5807683353eaa118548e7abd27db84b..a2c0a858930b8bfa979640efc701bc6cdcf95cfa 100644 --- a/app/views/help/ui.html.haml +++ b/app/views/help/ui.html.haml @@ -31,64 +31,91 @@ %h2#blocks Blocks - %h4 + .lead + Content block separated with botton border + %code .content-block + + .example + .content-block + %h4 Normal block inside content + = lorem + + .content-block + %h4 Second block + = lorem + + .lead + Gray content block with side padding using %code .gray-content-block - .gray-content-block.middle-block - %h4 Normal block inside content - = lorem + .example + .gray-content-block + %h4 Normal block inside content + = lorem - .gray-content-block.second-block - %h4 Second block - = lorem + .gray-content-block.second-block + %h4 Second block + = lorem - %h4 + .lead + Cover block for profile page with avatar, name and description %code .cover-block - %br - .cover-block - .avatar-holder - = image_tag avatar_icon('admin@example.com', 90), class: "avatar s90", alt: '' - .cover-title - John Smith - - .cover-desc - = lorem + .example + .cover-block + .avatar-holder + = image_tag avatar_icon('admin@example.com', 90), class: "avatar s90", alt: '' + .cover-title + John Smith + + .cover-desc + = lorem - .cover-controls - = link_to '#', class: 'btn btn-gray' do - = icon('pencil') - - = link_to '#', class: 'btn btn-gray' do - = icon('rss') + .cover-controls + = link_to '#', class: 'btn btn-gray' do + = icon('pencil') + + = link_to '#', class: 'btn btn-gray' do + = icon('rss') %h2#lists Lists - %h4 + .lead + Simple list using %code .content-list - %ul.content-list - %li - One item - %li - One item - %li - One item - %h4 - %code .well-list - %ul.well-list - %li - One item - %li - One item - %li - One item + .example + %ul.content-list + %li + One item + %li + One item + %li + One item - %h4 - %code .panel .well-list + .lead + List with avatar, title and description using + %code .content-list + + .example + %ul.content-list + %li + = image_tag 'no_avatar.png', class: 'avatar s40' + .title Title + .description Description + %li + = image_tag 'no_avatar.png', class: 'avatar s40' + .title Title + .description Description + %li + = image_tag 'no_avatar.png', class: 'avatar s40' + .title Title + .description Description - .panel.panel-default - .panel-heading Your list + .lead + List with hover effect + %code .well-list + .example %ul.well-list %li One item @@ -97,17 +124,18 @@ %li One item - %h4 - %code .bordered-list - %ul.bordered-list - %li - One item - %li - One item - %li - One item - - + .lead + List inside panel + .example + .panel.panel-default + .panel-heading Your list + %ul.well-list + %li + One item + %li + One item + %li + One item %h2#tables Tables @@ -138,9 +166,9 @@ %h2#navs Navigation - %h4 + .lead + Holder for top page navigation. Includes navigation, search field, sorting and button %code .top-area - %p Holder for top page navigation. Includes navigation, search field, sorting and button .example .top-area @@ -161,9 +189,9 @@ = link_to 'New issue', '#', class: 'btn btn-new' - %h4 + .lead + Only nav links without button and search %code .nav-links - %p Only nav links without button and search .example %ul.nav-links %li.active @@ -228,43 +256,47 @@ %h2#forms Forms - %h4 + .lead + Horizontal form when label rendered inline with input %code form.horizontal-form - %form.form-horizontal - .form-group - %label.col-sm-2.control-label{:for => "inputEmail3"} Email - .col-sm-10 - %input#inputEmail3.form-control{:placeholder => "Email", :type => "email"}/ - .form-group - %label.col-sm-2.control-label{:for => "inputPassword3"} Password - .col-sm-10 - %input#inputPassword3.form-control{:placeholder => "Password", :type => "password"}/ - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - %label - %input{:type => "checkbox"}/ - Remember me - .form-group - .col-sm-offset-2.col-sm-10 - %button.btn.btn-default{:type => "submit"} Sign in - - %h4 + .example + %form.form-horizontal + .form-group + %label.col-sm-2.control-label{:for => "inputEmail3"} Email + .col-sm-10 + %input#inputEmail3.form-control{:placeholder => "Email", :type => "email"}/ + .form-group + %label.col-sm-2.control-label{:for => "inputPassword3"} Password + .col-sm-10 + %input#inputPassword3.form-control{:placeholder => "Password", :type => "password"}/ + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + %label + %input{:type => "checkbox"}/ + Remember me + .form-group + .col-sm-offset-2.col-sm-10 + %button.btn.btn-default{:type => "submit"} Sign in + + .lead + Form when label rendered above input %code form - %form - .form-group - %label{:for => "exampleInputEmail1"} Email address - %input#exampleInputEmail1.form-control{:placeholder => "Enter email", :type => "email"}/ - .form-group - %label{:for => "exampleInputPassword1"} Password - %input#exampleInputPassword1.form-control{:placeholder => "Password", :type => "password"}/ - .checkbox - %label - %input{:type => "checkbox"}/ - Remember me - %button.btn.btn-default{:type => "submit"} Sign in + .example + %form + .form-group + %label{:for => "exampleInputEmail1"} Email address + %input#exampleInputEmail1.form-control{:placeholder => "Enter email", :type => "email"}/ + .form-group + %label{:for => "exampleInputPassword1"} Password + %input#exampleInputPassword1.form-control{:placeholder => "Password", :type => "password"}/ + .checkbox + %label + %input{:type => "checkbox"}/ + Remember me + %button.btn.btn-default{:type => "submit"} Sign in %h2#file File %h4 diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index 5051c6bf83b2013b5f49551e443f5f095c1c6604..64c4bdceff994dee56c661d6768ec68c55983e3f 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -90,6 +90,9 @@ %span.file_name.js-avatar-filename File name... = f.file_field :avatar, class: "js-user-avatar-input hidden" + = f.hidden_field :avatar_crop_x + = f.hidden_field :avatar_crop_y + = f.hidden_field :avatar_crop_size .light The maximum file size allowed is 200KB. - if @user.avatar? %hr @@ -99,3 +102,19 @@ .form-actions = f.submit 'Save changes', class: "btn btn-success" = link_to "Cancel", user_path(current_user), class: "btn btn-cancel" + +.modal.modal-profile-crop + .modal-dialog + .modal-content + .modal-header + %button.close{type: 'button', data: {dismiss: 'modal'}} + %span + × + %h4.modal-title + Crop your new profile picture + .modal-body + %p + %img.modal-profile-crop-image + .modal-footer + %button.btn.btn-primary.js-upload-user-avatar{:type => "button"} + Set new profile picture diff --git a/app/views/projects/diffs/_warning.html.haml b/app/views/projects/diffs/_warning.html.haml index f99bc9a85eb7aca109f57e670b68358021bac3b7..63ede71e6f1bdc5a17412abe711f8faf2f40537c 100644 --- a/app/views/projects/diffs/_warning.html.haml +++ b/app/views/projects/diffs/_warning.html.haml @@ -3,17 +3,16 @@ Too many changes to show. .pull-right - unless diff_hard_limit_enabled? - = link_to "Reload with full diff", url_for(params.merge(force_show_diff: true, format: nil)), class: "btn btn-sm btn-warning" + = link_to "Reload with full diff", url_for(params.merge(force_show_diff: true, format: nil)), class: "btn btn-sm" - if current_controller?(:commit) or current_controller?(:merge_requests) - if current_controller?(:commit) - = link_to "Plain diff", namespace_project_commit_path(@project.namespace, @project, @commit, format: :diff), class: "btn btn-warning btn-sm" - = link_to "Email patch", namespace_project_commit_path(@project.namespace, @project, @commit, format: :patch), class: "btn btn-warning btn-sm" + = link_to "Plain diff", namespace_project_commit_path(@project.namespace, @project, @commit, format: :diff), class: "btn btn-sm" + = link_to "Email patch", namespace_project_commit_path(@project.namespace, @project, @commit, format: :patch), class: "btn btn-sm" - elsif @merge_request && @merge_request.persisted? - = link_to "Plain diff", merge_request_path(@merge_request, format: :diff), class: "btn btn-warning btn-sm" - = link_to "Email patch", merge_request_path(@merge_request, format: :patch), class: "btn btn-warning btn-sm" + = link_to "Plain diff", merge_request_path(@merge_request, format: :diff), class: "btn btn-sm" + = link_to "Email patch", merge_request_path(@merge_request, format: :patch), class: "btn btn-sm" %p To preserve performance only %strong #{shown_files_count} of #{diffs.size} files are displayed. - diff --git a/app/views/projects/forks/index.html.haml b/app/views/projects/forks/index.html.haml index 42fa6fdb782709d43fef3eaf7cba0eeb4d601018..ace22625d1d748e3b8d521593900d74d4c5d7ed8 100644 --- a/app/views/projects/forks/index.html.haml +++ b/app/views/projects/forks/index.html.haml @@ -1,9 +1,7 @@ .top-area .nav-text - - public_count = @public_forks.size - - protected_count = @protected_forks.size - - full_count_title = "#{public_count} public and #{protected_count} private" - == #{pluralize(@all_forks.size, 'fork')}: #{full_count_title} + - full_count_title = "#{@public_forks_count} public and #{@private_forks_count} private" + == #{pluralize(@total_forks_count, 'fork')}: #{full_count_title} .nav-controls = search_field_tag :filter_projects, nil, placeholder: 'Search forks', class: 'projects-list-filter project-filter-form-field form-control input-short', @@ -41,17 +39,17 @@ .projects-list-holder - - if @public_forks.blank? + - if @forks.blank? %ul.content-list %li .nothing-here-block No forks to show - else - = render 'shared/projects/list', projects: @public_forks, use_creator_avatar: true, + = render 'shared/projects/list', projects: @forks, use_creator_avatar: true, forks: true, show_last_commit_as_description: true - - if protected_count > 0 + - if @private_forks_count > 0 %ul.projects-list.private-forks-notice %li.project-row = icon('lock fw', base: 'circle', class: 'fa-lg private-fork-icon') - %strong= pluralize(protected_count, 'private fork') + %strong= pluralize(@private_forks_count, 'private fork') %span you have no access to. diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml index 81a6b93c7d39fd71da17eccf8c2f2291f0e7b275..0daf492b652c0820b2d0d67df2e9cfc6be70ed28 100644 --- a/app/views/projects/issues/_issue.html.haml +++ b/app/views/projects/issues/_issue.html.haml @@ -5,7 +5,7 @@ .issue-title %span.issue-title-text - = link_to_gfm issue.title, issue_path(issue), class: "row_title" + = link_to_gfm issue.title, issue_path(issue), class: "title" %ul.controls.light - if issue.closed? %li diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml index b55f6a2d32ad3b5c88c522d37353835117be15cf..18cf3f14f0bf34e5c12b714561a049f1aa4f26d1 100644 --- a/app/views/projects/merge_requests/_merge_request.html.haml +++ b/app/views/projects/merge_requests/_merge_request.html.haml @@ -1,7 +1,7 @@ %li{ class: mr_css_classes(merge_request) } .merge-request-title %span.merge-request-title-text - = link_to_gfm merge_request.title, merge_request_path(merge_request), class: "row_title" + = link_to_gfm merge_request.title, merge_request_path(merge_request), class: "title" %ul.controls.light - if merge_request.merged? %li @@ -48,7 +48,7 @@ = note_count .merge-request-info - \##{merge_request.iid} · + #{merge_request.to_reference} · opened #{time_ago_with_tooltip(merge_request.created_at, placement: 'bottom')} by #{link_to_member(@project, merge_request.author, avatar: false)} - if merge_request.target_project.default_branch != merge_request.target_branch diff --git a/app/views/projects/merge_requests/_show.html.haml b/app/views/projects/merge_requests/_show.html.haml index 648512e5379200801189d0cf81cc5e4ff42b3283..d7bc26e24b9d15b08c84279c006fddf0e1d1e880 100644 --- a/app/views/projects/merge_requests/_show.html.haml +++ b/app/views/projects/merge_requests/_show.html.haml @@ -1,4 +1,4 @@ -- page_title "#{@merge_request.title} (##{@merge_request.iid})", "Merge Requests" +- page_title "#{@merge_request.title} (#{@merge_request.to_reference})", "Merge Requests" - page_description @merge_request.description - page_card_attributes @merge_request.card_attributes diff --git a/app/views/projects/merge_requests/show/_mr_title.html.haml b/app/views/projects/merge_requests/show/_mr_title.html.haml index 14ea7b17786f9e21766781f7580929b898ebb6f9..b634a4af8d2c65f17328ccf2e1954ad858909b1c 100644 --- a/app/views/projects/merge_requests/show/_mr_title.html.haml +++ b/app/views/projects/merge_requests/show/_mr_title.html.haml @@ -2,7 +2,7 @@ .status-box{ class: status_box_class(@merge_request) } = @merge_request.state_human_name %span.identifier - Merge Request ##{@merge_request.iid} + Merge Request #{@merge_request.to_reference} %span.creator · by #{link_to_member(@project, @merge_request.author, size: 24)} diff --git a/app/views/projects/notes/_note.html.haml b/app/views/projects/notes/_note.html.haml index e858c412836075137a5469299b6a577b70c2350e..52972576aff1f25ecac81da658998e297fda1195 100644 --- a/app/views/projects/notes/_note.html.haml +++ b/app/views/projects/notes/_note.html.haml @@ -28,7 +28,7 @@ %a{name: dom_id(note), href: "##{dom_id(note)}", title: 'Link here'} = time_ago_with_tooltip(note.created_at, placement: 'bottom', html_class: 'note_created_ago') - if note.updated_at != note.created_at - %span + %span.note-updated-at · = icon('edit', title: 'edited') = time_ago_with_tooltip(note.updated_at, placement: 'bottom', html_class: 'note_edited_ago') diff --git a/app/views/shared/_issues.html.haml b/app/views/shared/_issues.html.haml index 4b4c9e9eabe20248a893f855cbada00e0bb11327..8ff9d4c1c7f7e620e87fe826cf50c0cd6c69bbc1 100644 --- a/app/views/shared/_issues.html.haml +++ b/app/views/shared/_issues.html.haml @@ -8,7 +8,7 @@ .pull-right = link_to 'New issue', new_namespace_project_issue_path(project.namespace, project) - %ul.well-list.issues-list + %ul.content-list.issues-list - group[1].each do |issue| = render 'projects/issues/issue', issue: issue = paginate @issues, theme: "gitlab" diff --git a/app/views/shared/_merge_requests.html.haml b/app/views/shared/_merge_requests.html.haml index be17a511b26c7ec0fef762d433d8667115b99d16..e74fc36c797c0ba6c0078e5658e18e689f8898be 100644 --- a/app/views/shared/_merge_requests.html.haml +++ b/app/views/shared/_merge_requests.html.haml @@ -8,7 +8,7 @@ .pull-right = link_to 'New merge request', new_namespace_project_merge_request_path(project.namespace, project) - %ul.well-list.mr-list + %ul.content-list.mr-list - group[1].each do |merge_request| = render 'projects/merge_requests/merge_request', merge_request: merge_request = paginate @merge_requests, theme: "gitlab" diff --git a/app/views/shared/groups/_group.html.haml b/app/views/shared/groups/_group.html.haml index 289b0bfe1eb18e844d9800cdb7c75080439aef00..fb9a8db08893299c53db296e76f9e586fea3a009 100644 --- a/app/views/shared/groups/_group.html.haml +++ b/app/views/shared/groups/_group.html.haml @@ -22,13 +22,13 @@ = number_with_delimiter(group.users.count) = image_tag group_icon(group), class: "avatar s40 hidden-xs" - = link_to group, class: 'group-name' do - %span.item-title= group.name + = link_to group, class: 'group-name title' do + = group.name - if group_member as %span #{group_member.human_access} - if group.description.present? - .light + .description = markdown(group.description, pipeline: :description) diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index cf93f469aa2d1b9e4d3da5e898d4f11788b28c7d..b25c477525f69e10cf38b675337ef65ddd452636 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -1,14 +1,22 @@ %aside.right-sidebar{ class: sidebar_gutter_collapsed_class } .issuable-sidebar .block +<<<<<<< HEAD %span.issuable-count.pull-left.hide-collapsed +======= + %span.issuable-count.hide-collapsed.pull-left +>>>>>>> 204a9895333178cc90b40ef365ad566d750fa594 = issuable.iid of = issuables_count(issuable) %span.pull-right %a.gutter-toggle{href: '#'} = sidebar_gutter_toggle_icon +<<<<<<< HEAD .issuable-nav.pull-right.btn-group.hide-collapsed{role: 'group', "aria-label" => '...'} +======= + .issuable-nav.hide-collapsed.pull-right.btn-group{role: 'group', "aria-label" => '...'} +>>>>>>> 204a9895333178cc90b40ef365ad566d750fa594 - if prev_issuable = prev_issuable_for(issuable) = link_to 'Prev', [@project.namespace.becomes(Namespace), @project, prev_issuable], class: 'btn btn-default prev-btn' - else diff --git a/app/views/shared/projects/_list.html.haml b/app/views/shared/projects/_list.html.haml index 75684b972f1c627632166e6c5567760e521db6a5..e75af50a53765a9cdb60150bf5ab930f81af2931 100644 --- a/app/views/shared/projects/_list.html.haml +++ b/app/views/shared/projects/_list.html.haml @@ -7,7 +7,7 @@ - skip_namespace = false unless local_assigns[:skip_namespace] == true - show_last_commit_as_description = false unless local_assigns[:show_last_commit_as_description] == true -%ul.projects-list +%ul.projects-list.content-list - if projects.any? - projects.each_with_index do |project, i| - css_class = (i >= projects_limit) ? 'hide' : nil diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index 72061e272f166529c427561a327d08b56af13e76..99e48e86e389ae7ef5f8451aa0b4e7aa5ef2a2b5 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -19,7 +19,7 @@ = image_tag avatar_icon(project.creator.email, 40), class: "avatar s40", alt:'' - else = project_icon(project, alt: '', class: 'avatar project-avatar s40') - %span.project-full-name + %span.project-full-name.title %span.namespace-name - if project.namespace && !skip_namespace = project.namespace.human_name @@ -27,7 +27,7 @@ %span.project-name.filter-title = project.name - .project-controls + .controls - if ci_commit %span = render_ci_status(ci_commit) @@ -43,9 +43,9 @@ title: "#{visibility_level_label(project.visibility_level)} - #{project_visibility_level_description(project.visibility_level)}"} = visibility_level_icon(project.visibility_level, fw: false) - if show_last_commit_as_description - .project-description + .description = link_to_gfm project.commit.title, namespace_project_commit_path(project.namespace, project, project.commit), class: "commit-row-message" - elsif project.description.present? - .project-description + .description = markdown(project.description, pipeline: :description) diff --git a/app/views/shared/snippets/_snippet.html.haml b/app/views/shared/snippets/_snippet.html.haml index c6294caddc7f7d26d8d9fc2d002f5a3d3e9b38bd..a316a085107e2edc6f0f7b4ef7b075ce5b16b313 100644 --- a/app/views/shared/snippets/_snippet.html.haml +++ b/app/views/shared/snippets/_snippet.html.haml @@ -1,10 +1,12 @@ %li.snippet-row + = image_tag avatar_icon(snippet.author_email), class: "avatar s40 hidden-xs", alt: '' + .snippet-title - = link_to reliable_snippet_path(snippet) do + = link_to reliable_snippet_path(snippet), class: 'title' do = truncate(snippet.title, length: 60) - if snippet.private? %span.label.label-gray - %i.fa.fa-lock + = icon('lock') private %span.monospace.pull-right = snippet.file_name @@ -15,6 +17,5 @@ .snippet-info = link_to user_snippets_path(snippet.author) do - = image_tag avatar_icon(snippet.author_email), class: "avatar s24", alt: '' = snippet.author_name authored #{time_ago_with_tooltip(snippet.created_at)} diff --git a/app/views/snippets/_snippets.html.haml b/app/views/snippets/_snippets.html.haml index d9aa4dd1d2eb22db73e91a8f8e98cad288474197..80a3e731e1df9e3c38b09180d8665214d0856565 100644 --- a/app/views/snippets/_snippets.html.haml +++ b/app/views/snippets/_snippets.html.haml @@ -1,4 +1,4 @@ -%ul.bordered-list +%ul.content-list = render partial: 'shared/snippets/snippet', collection: @snippets - if @snippets.empty? %li diff --git a/app/workers/repository_fork_worker.rb b/app/workers/repository_fork_worker.rb index 2572b9d6d983a0d840990cb23d05fceee7b67fb0..21d311579e34af22f636a9c3f2d35e05cd4bd850 100644 --- a/app/workers/repository_fork_worker.rb +++ b/app/workers/repository_fork_worker.rb @@ -27,7 +27,7 @@ def perform(project_id, source_path, target_path) return end - project.repository.expire_emptiness_caches + project.repository.after_import project.import_finish end end diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb index da4ae186dc473526d8da44e5abafb1f96f04271d..c36ec31eef29b3a321fdf4e733c3232ac7d61886 100644 --- a/app/workers/repository_import_worker.rb +++ b/app/workers/repository_import_worker.rb @@ -18,7 +18,7 @@ def perform(project_id) return end - project.repository.expire_emptiness_caches + project.repository.after_import project.import_finish # Explicitly update mirror so that upstream remote is created and fetched diff --git a/doc/README.md b/doc/README.md index 2c0d745fb3fdfffe3782e795aa7876e2385325d6..d8f5a80236f09a37be10dc78ca9dd6d38b0936a6 100644 --- a/doc/README.md +++ b/doc/README.md @@ -19,40 +19,42 @@ - [GitLab Pages](pages/README.md) Using GitLab Pages. - [Custom templates for issues and merge requests](customization/issue_and_merge_request_template.md) Pre-fill the description of issues and merge requests to your liking. -## CI Documentation +## CI User documentation -- [Quick Start](ci/quick_start/README.md) -- [Enable or disable GitLab CI](ci/enable_or_disable_ci.md) -- [Configuring project (.gitlab-ci.yml)](ci/yaml/README.md) -- [Configuring runner](ci/runners/README.md) -- [Configuring deployment](ci/deployment/README.md) -- [Using Docker Images](ci/docker/using_docker_images.md) -- [Using Docker Build](ci/docker/using_docker_build.md) -- [Using Variables](ci/variables/README.md) -- [Using SSH keys](ci/ssh_keys/README.md) +- [Get started with GitLab CI](ci/quick_start/README.md) +- [Learn how to enable or disable GitLab CI](ci/enable_or_disable_ci.md) +- [Learn how `.gitlab-ci.yml` works](ci/yaml/README.md) +- [Configure a Runner, the application that runs your builds](ci/runners/README.md) +- [Use Docker images with GitLab Runner](ci/docker/using_docker_images.md) +- [Use CI to build Docker images](ci/docker/using_docker_build.md) +- [Use variables in your `.gitlab-ci.yml`](ci/variables/README.md) +- [Use SSH keys in your build environment](ci/ssh_keys/README.md) +- [Trigger builds through the API](ci/triggers/README.md) +- [Build artifacts](ci/build_artifacts/README.md) - [User permissions](ci/permissions/README.md) - [API](ci/api/README.md) -- [Triggering builds through the API](ci/triggers/README.md) -- [Build artifacts](ci/build_artifacts/README.md) -### CI Languages +### CI Examples -- [Testing PHP](ci/languages/php.md) +- [The .gitlab-ci.yml file for GitLab itself](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/.gitlab-ci.yml) +- [Test your PHP applications](ci/examples/php.md) +- [Test and deploy Ruby applications to Heroku](ci/examples/test-and-deploy-ruby-application-to-heroku.md) +- [Test and deploy Python applications to Heroku](ci/examples/test-and-deploy-python-application-to-heroku.md) +- [Test Clojure applications](ci/examples/test-clojure-application.md) +- [Using `dpl` as deployment tool](ci/deployment/README.md) +- Help your favorite programming language and GitLab by sending a merge request + with a guide for that language. ### CI Services +GitLab CI uses the `services` keyword to define what docker containers should +be linked with your base image. Below is a list of examples you may use: + - [Using MySQL](ci/services/mysql.md) - [Using PostgreSQL](ci/services/postgres.md) - [Using Redis](ci/services/redis.md) - [Using Other Services](ci/docker/using_docker_images.md#how-to-use-other-images-as-services) -### CI Examples - -- [Test and deploy Ruby applications to Heroku](ci/examples/test-and-deploy-ruby-application-to-heroku.md) -- [Test and deploy Python applications to Heroku](ci/examples/test-and-deploy-python-application-to-heroku.md) -- [Test Clojure applications](ci/examples/test-clojure-application.md) -- Help your favorite programming language and GitLab by sending a merge request with a guide for that language. - ## Administrator documentation - [Audit Events](administration/audit_events.md) Check how user access changed in projects and groups. diff --git a/doc/ci/README.md b/doc/ci/README.md index 5886829be51084594c0637561b464f89d2866e3c..2120b5b28509a53a089d4074f1fab363fdf0e909 100644 --- a/doc/ci/README.md +++ b/doc/ci/README.md @@ -1,39 +1,37 @@ ## GitLab CI Documentation -### User documentation - -* [Quick Start](quick_start/README.md) -* [Enable or disable GitLab CI](enable_or_disable_ci.md) -* [Configuring project (.gitlab-ci.yml)](yaml/README.md) -* [Configuring runner](runners/README.md) -* [Configuring deployment](deployment/README.md) -* [Using Docker Images](docker/using_docker_images.md) -* [Using Docker Build](docker/using_docker_build.md) -* [Using Variables](variables/README.md) -* [Using SSH keys](ssh_keys/README.md) -* [Triggering builds through the API](triggers/README.md) -* [Build artifacts](build_artifacts/README.md) - -### Languages - -* [Testing PHP](languages/php.md) - -### Services - -* [Using MySQL](services/mysql.md) -* [Using PostgreSQL](services/postgres.md) -* [Using Redis](services/redis.md) -* [Using Other Services](docker/using_docker_images.md#how-to-use-other-images-as-services) - -### Examples - -+ [The .gitlab-ci.yml file for GitLab itself](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/.gitlab-ci.yml) -+ [Test and deploy Ruby applications to Heroku](examples/test-and-deploy-ruby-application-to-heroku.md) -+ [Test and deploy Python applications to Heroku](examples/test-and-deploy-python-application-to-heroku.md) -+ [Test Clojure applications](examples/test-clojure-application.md) -+ Help your favorite programming language and GitLab by sending a merge request with a guide for that language. - -### Administrator documentation - -* [User permissions](permissions/README.md) -* [API](api/README.md) +### CI User documentation + +- [Get started with GitLab CI](quick_start/README.md) +- [Learn how to enable or disable GitLab CI](enable_or_disable_ci.md) +- [Learn how `.gitlab-ci.yml` works](yaml/README.md) +- [Configure a Runner, the application that runs your builds](runners/README.md) +- [Use Docker images with GitLab Runner](docker/using_docker_images.md) +- [Use CI to build Docker images](docker/using_docker_build.md) +- [Use variables in your `.gitlab-ci.yml`](variables/README.md) +- [Use SSH keys in your build environment](ssh_keys/README.md) +- [Trigger builds through the API](triggers/README.md) +- [Build artifacts](build_artifacts/README.md) +- [User permissions](permissions/README.md) +- [API](api/README.md) + +### CI Examples + +- [The .gitlab-ci.yml file for GitLab itself](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/.gitlab-ci.yml) +- [Test your PHP applications](examples/php.md) +- [Test and deploy Ruby applications to Heroku](examples/test-and-deploy-ruby-application-to-heroku.md) +- [Test and deploy Python applications to Heroku](examples/test-and-deploy-python-application-to-heroku.md) +- [Test Clojure applications](examples/test-clojure-application.md) +- [Using `dpl` as deployment tool](deployment/README.md) +- Help your favorite programming language and GitLab by sending a merge request + with a guide for that language. + +### CI Services + +GitLab CI uses the `services` keyword to define what docker containers should +be linked with your base image. Below is a list of examples you may use: + +- [Using MySQL](services/mysql.md) +- [Using PostgreSQL](services/postgres.md) +- [Using Redis](services/redis.md) +- [Using Other Services](docker/using_docker_images.md#how-to-use-other-images-as-services) diff --git a/doc/ci/examples/README.md b/doc/ci/examples/README.md index 1cf41aea391f32c4af004c5aed31f2456b486b7c..31f29f4a082e92ab05db675b040e4532a644c313 100644 --- a/doc/ci/examples/README.md +++ b/doc/ci/examples/README.md @@ -1,5 +1,13 @@ -# Build script examples +## Build script examples -+ [Test and deploy a Ruby application to Heroku](test-and-deploy-ruby-application-to-heroku.md) -+ [Test and deploy a Python application to Heroku](test-and-deploy-python-application-to-heroku.md) -+ [Test a Clojure application](test-clojure-application.md) +- [Test and deploy a Ruby application to Heroku](test-and-deploy-ruby-application-to-heroku.md) +- [Test and deploy a Python application to Heroku](test-and-deploy-python-application-to-heroku.md) +- [Test a Clojure application](test-clojure-application.md) + +## Languages + +This is a list of languages you can test with GitLab CI. Each section has +comprehensive documentation and comes with a test repository hosted on +GitLab.com. + +- [Testing PHP](php.md) diff --git a/doc/ci/languages/php.md b/doc/ci/examples/php.md similarity index 100% rename from doc/ci/languages/php.md rename to doc/ci/examples/php.md diff --git a/doc/ci/languages/README.md b/doc/ci/languages/README.md deleted file mode 100644 index 54b2343e08b4a91b6465451e7ad4511bf9ef2164..0000000000000000000000000000000000000000 --- a/doc/ci/languages/README.md +++ /dev/null @@ -1,7 +0,0 @@ -### Languages - -This is a list of languages you can test with GitLab CI. Each section has -comprehensive documentation and comes with a test repository hosted on -GitLab.com - -+ [Testing PHP](php.md) diff --git a/doc/ci/quick_start/README.md b/doc/ci/quick_start/README.md index 327c83bef720e44a034231dfc0045449aa6ea5ff..5af7be5581e72c28451b9ae248cef71e31bac596 100644 --- a/doc/ci/quick_start/README.md +++ b/doc/ci/quick_start/README.md @@ -201,6 +201,11 @@ You can access a builds badge image using following link: http://example.gitlab.com/namespace/project/badges/branch/build.svg ``` +## Examples + +Visit the [examples README][examples] to see a list of examples using GitLab +CI with various languages. + ## Next steps Awesome! You started using CI in GitLab! @@ -212,3 +217,4 @@ Visit our various languages examples at <https://gitlab.com/groups/gitlab-exampl [runner-install]: https://gitlab.com/gitlab-org/gitlab-ci-multi-runner/tree/master#installation [blog-ci]: https://about.gitlab.com/2015/05/06/why-were-replacing-gitlab-ci-jobs-with-gitlab-ci-dot-yml/ +[examples]: ../examples/README.md diff --git a/doc/ci/services/README.md b/doc/ci/services/README.md index 1ebb0a4a25085cef1fb65afb87a19d7a379daa27..4b79461d55c64d338fbdcbb66d5df0ad8fd03afd 100644 --- a/doc/ci/services/README.md +++ b/doc/ci/services/README.md @@ -1,9 +1,9 @@ ## GitLab CI Services -GitLab CI uses the `services` keyword to define what docker containers should be -linked with your base image. Below is a list of examples you may use. +GitLab CI uses the `services` keyword to define what docker containers should +be linked with your base image. Below is a list of examples you may use. -+ [Using MySQL](mysql.md) -+ [Using PostgreSQL](postgres.md) -+ [Using Redis](redis.md) -+ [Using Other Services](../docker/using_docker_images.md#how-to-use-other-images-as-services) +- [Using MySQL](mysql.md) +- [Using PostgreSQL](postgres.md) +- [Using Redis](redis.md) +- [Using Other Services](../docker/using_docker_images.md#how-to-use-other-images-as-services) diff --git a/doc/ci/variables/README.md b/doc/ci/variables/README.md index 9e89e6e395ecb33d933d45accd9aafb23382ac1f..b0e53cbc2615817cd405d8fd04401751fb088bf5 100644 --- a/doc/ci/variables/README.md +++ b/doc/ci/variables/README.md @@ -30,7 +30,7 @@ The API_TOKEN will take the Secure Variable value: `SECURE`. | **CI_BUILD_REF_NAME** | all | The branch or tag name for which project is built | | **CI_BUILD_ID** | all | The unique id of the current build that GitLab CI uses internally | | **CI_BUILD_REPO** | all | The URL to clone the Git repository | -| **CI_BUILD_TRIGGERED** | 0.5 | The flag to indicate that build was triggered | +| **CI_BUILD_TRIGGERED** | 0.5 | The flag to indicate that build was [triggered] | | **CI_PROJECT_ID** | all | The unique id of the current project that GitLab CI uses internally | | **CI_PROJECT_DIR** | all | The full path where the repository is cloned and where the build is ran | @@ -104,3 +104,5 @@ job_name: script: - export ``` + +[triggered]: ../triggers/README.md diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 0edb56dc20e5c78a61374908ef75466a4ee2d1c1..051eaa04152a478481b58ef11cae322e05ab1114 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -518,3 +518,10 @@ You can find the link under `/ci/lint` of your gitlab instance. If your commit message contains `[ci skip]`, the commit will be created but the builds will be skipped. + +## Examples + +Visit the [examples README][examples] to see a list of examples using GitLab +CI with various languages. + +[examples]: ../examples/README.md diff --git a/doc/customization/branded_login_page.md b/doc/customization/branded_login_page.md index ea38659059616a3b459fe2a773d082bc13a38e53..45fccc9ad878d0bb7dd35b85d5d2bbc128315b26 100644 --- a/doc/customization/branded_login_page.md +++ b/doc/customization/branded_login_page.md @@ -1,6 +1,10 @@ # Changing the appearance of the login page +<<<<<<< HEAD GitLab Enterprise Edition offers a way to put your company's identity on the login page of your GitLab server and make it a branded login page. +======= +GitLab Community Edition offers a way to put your company's identity on the login page of your GitLab server and make it a branded login page. +>>>>>>> 204a9895333178cc90b40ef365ad566d750fa594 By default, the page shows the GitLab logo and description. diff --git a/doc/development/README.md b/doc/development/README.md index d5bf166ad32065eb49d5f89c7089758773fd1703..b9a0d81e5ba9c3ae30d6d0f50183caefd8afae54 100644 --- a/doc/development/README.md +++ b/doc/development/README.md @@ -1,11 +1,12 @@ # Development - [Architecture](architecture.md) of GitLab -- [Shell commands](shell_commands.md) in the GitLab codebase -- [Rake tasks](rake_tasks.md) for development +- [Benchmarking](benchmarking.md) - [CI setup](ci_setup.md) for testing GitLab +- [Gotchas](gotchas.md) to avoid +- [How to dump production data to staging](db_dump.md) +- [Migration Style Guide](migration_style_guide.md) for creating safe migrations +- [Rake tasks](rake_tasks.md) for development +- [Shell commands](shell_commands.md) in the GitLab codebase - [Sidekiq debugging](sidekiq_debugging.md) - [UI guide](ui_guide.md) for building GitLab with existing css styles and elements -- [Migration Style Guide](migration_style_guide.md) for creating safe migrations -- [How to dump production data to staging](dump_db.md) -- [Benchmarking](benchmarking.md) diff --git a/doc/development/gotchas.md b/doc/development/gotchas.md new file mode 100644 index 0000000000000000000000000000000000000000..21078c8d6f9aba69873a757e449d5658a1bf327c --- /dev/null +++ b/doc/development/gotchas.md @@ -0,0 +1,103 @@ +# Gotchas + +The purpose of this guide is to document potential "gotchas" that contributors +might encounter or should avoid during development of GitLab CE and EE. + +## Don't `describe` symbols + +Consider the following model spec: + +```ruby +require 'rails_helper' + +describe User do + describe :to_param do + it 'converts the username to a param' do + user = described_class.new(username: 'John Smith') + + expect(user.to_param).to eq 'john-smith' + end + end +end +``` + +When run, this spec doesn't do what we might expect: + +```sh +spec/models/user_spec.rb|6 error| Failure/Error: u = described_class.new NoMethodError: undefined method `new' for :to_param:Symbol +``` + +### Solution + +Except for the top-level `describe` block, always provide a String argument to +`describe`. + +## Don't `rescue Exception` + +See ["Why is it bad style to `rescue Exception => e` in Ruby?"][Exception]. + +_**Note:** This rule is [enforced automatically by +Rubocop](https://gitlab.com/gitlab-org/gitlab-ce/blob/8-4-stable/.rubocop.yml#L911-914)._ + +[Exception]: http://stackoverflow.com/q/10048173/223897 + +## Don't use inline CoffeeScript in views + +Using the inline `:coffee` or `:coffeescript` Haml filters comes with a +performance overhead. + +_**Note:** We've [removed these two filters](https://gitlab.com/gitlab-org/gitlab-ce/blob/8-5-stable/config/initializers/haml.rb) +in an initializer._ + +### Further reading + +- Pull Request: [Replace CoffeeScript block into JavaScript in Views](https://git.io/vztMu) +- Stack Overflow: [Performance implications of using :coffescript filter inside HAML templates?](http://stackoverflow.com/a/17571242/223897) + +## ID-based CSS selectors need to be a bit more specific + +Normally, because HTML `id` attributes need to be unique to the page, it's +perfectly fine to write some JavaScript like the following: + +```javascript +$('#js-my-selector').hide(); +``` + +However, there's a feature of GitLab's Markdown processing that [automatically +adds anchors to header elements][ToC Processing], with the `id` attribute being +automatically generated based on the content of the header. + +Unfortunately, this feature makes it possible for user-generated content to +create a header element with the same `id` attribute we're using in our +selector, potentially breaking the JavaScript behavior. A user could break the +above example with the following Markdown: + +```markdown +## JS My Selector +``` + +Which gets converted to the following HTML: + +```html +<h2> + <a id="js-my-selector" class="anchor" href="#js-my-selector" aria-hidden="true"></a> + JS My Selector +</h2> +``` + +[ToC Processing]: https://gitlab.com/gitlab-org/gitlab-ce/blob/8-4-stable/lib/banzai/filter/table_of_contents_filter.rb#L31-37 + +### Solution + +The current recommended fix for this is to make our selectors slightly more +specific: + +```javascript +$('div#js-my-selector').hide(); +``` + +### Further reading + +- Issue: [Merge request ToC anchor conflicts with tabs](https://gitlab.com/gitlab-org/gitlab-ce/issues/3908) +- Merge Request: [Make tab target selectors less naive](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/2023) +- Merge Request: [Make cross-project reference's clipboard target less naive](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/2024) diff --git a/doc/permissions/permissions.md b/doc/permissions/permissions.md index 4cd5a39d8c4eb698f9bcc0c5b0f2d4a49d9415bc..398439c782d82b95166291836115f6f3163ca130 100644 --- a/doc/permissions/permissions.md +++ b/doc/permissions/permissions.md @@ -6,7 +6,7 @@ If a user is both in a project group and in the project itself, the highest perm If a user is a GitLab administrator they receive all permissions. -On public projects the Guest role is not enforced. +On public and internal projects the Guest role is not enforced. All users will be able to create issues, leave comments, and pull or download the project code. To add or import a user, you can follow the [project users and members diff --git a/features/project/milestone.feature b/features/project/milestone.feature index e0f4c0e9d7cb403cd4f69aa83cd59fe660109ea5..713f0f3b97965e8651ec63fefbc61a18998a1806 100644 --- a/features/project/milestone.feature +++ b/features/project/milestone.feature @@ -13,6 +13,7 @@ Feature: Project Milestone Given I visit project "Shop" milestones page And I click link "v2.2" Then I should see the labels "bug", "enhancement" and "feature" + And I should see the "bug" label listed only once @javascript Scenario: Listing labels from labels tab diff --git a/features/steps/profile/profile.rb b/features/steps/profile/profile.rb index 6b0c1049ece560d1ea9512af641a487609a989dc..7895f643d0c3dd5698ca66ef1781a330074a34b9 100644 --- a/features/steps/profile/profile.rb +++ b/features/steps/profile/profile.rb @@ -27,9 +27,7 @@ class Spinach::Features::Profile < Spinach::FeatureSteps end step 'I change my avatar' do - attach_file(:user_avatar, File.join(Rails.root, 'spec', 'fixtures', 'banana_sample.gif')) - click_button "Save changes" - @user.reload + attach_avatar end step 'I should see new avatar' do @@ -42,9 +40,7 @@ class Spinach::Features::Profile < Spinach::FeatureSteps end step 'I have an avatar' do - attach_file(:user_avatar, File.join(Rails.root, 'spec', 'fixtures', 'banana_sample.gif')) - click_button "Save changes" - @user.reload + attach_avatar end step 'I remove my avatar' do @@ -233,4 +229,16 @@ class Spinach::Features::Profile < Spinach::FeatureSteps step "I see that application is removed" do expect(page.find(".oauth-applications")).not_to have_content "test_changed" end + + def attach_avatar + attach_file :user_avatar, Rails.root.join(*%w(spec fixtures banana_sample.gif)) + + page.find('#user_avatar_crop_x', visible: false).set('0') + page.find('#user_avatar_crop_y', visible: false).set('0') + page.find('#user_avatar_crop_size', visible: false).set('256') + + click_button "Save changes" + + @user.reload + end end diff --git a/features/steps/project/project_milestone.rb b/features/steps/project/project_milestone.rb index ec881c0d8fc95b416783e03819f9de920c0c2b57..2508c09e36d8612c6ca6f4e0f680a5bca5c1847b 100644 --- a/features/steps/project/project_milestone.rb +++ b/features/steps/project/project_milestone.rb @@ -41,6 +41,12 @@ class Spinach::Features::ProjectMilestone < Spinach::FeatureSteps end end + step 'I should see the "bug" label listed only once' do + page.within('#tab-labels') do + expect(page).to have_content('bug', count: 1) + end + end + step 'I click link "v2.2"' do click_link "v2.2" end diff --git a/lib/api/internal.rb b/lib/api/internal.rb index e38736fc28b65e1712ad29d8b4176516853d443f..2200208b946528a9bec8bfde4dfd276a3b437fc9 100644 --- a/lib/api/internal.rb +++ b/lib/api/internal.rb @@ -14,6 +14,14 @@ class Internal < Grape::API # ref - branch name # forced_push - forced_push # + + helpers do + def wiki? + @wiki ||= params[:project].end_with?('.wiki') && + !Project.find_with_namespace(params[:project]) + end + end + post "/allowed" do status 200 @@ -30,13 +38,12 @@ class Internal < Grape::API # Strip out the .wiki from the pathname before finding the # project. This applies the correct project permissions to # the wiki repository as well. - wiki = project_path.end_with?('.wiki') - project_path.chomp!('.wiki') if wiki + project_path.chomp!('.wiki') if wiki? project = Project.find_with_namespace(project_path) access = - if wiki + if wiki? Gitlab::GitAccessWiki.new(actor, project) else Gitlab::GitAccess.new(actor, project) diff --git a/lib/banzai/filter/sanitization_filter.rb b/lib/banzai/filter/sanitization_filter.rb index 04ddfe53ed65729443d0ce3dc9110921d208ba86..abd79b329ae06e54a22ed5ecd5cdf6e8076da288 100644 --- a/lib/banzai/filter/sanitization_filter.rb +++ b/lib/banzai/filter/sanitization_filter.rb @@ -7,6 +7,8 @@ module Filter # # Extends HTML::Pipeline::SanitizationFilter with a custom whitelist. class SanitizationFilter < HTML::Pipeline::SanitizationFilter + UNSAFE_PROTOCOLS = %w(javascript :javascript data vbscript).freeze + def whitelist whitelist = super @@ -43,8 +45,8 @@ def customize_whitelist(whitelist) # Allow any protocol in `a` elements... whitelist[:protocols].delete('a') - # ...but then remove links with the `javascript` protocol - whitelist[:transformers].push(remove_javascript_links) + # ...but then remove links with unsafe protocols + whitelist[:transformers].push(remove_unsafe_links) # Remove `rel` attribute from `a` elements whitelist[:transformers].push(remove_rel) @@ -55,14 +57,14 @@ def customize_whitelist(whitelist) whitelist end - def remove_javascript_links + def remove_unsafe_links lambda do |env| node = env[:node] return unless node.name == 'a' return unless node.has_attribute?('href') - if node['href'].start_with?('javascript', ':javascript') + if node['href'].start_with?(*UNSAFE_PROTOCOLS) node.remove_attribute('href') end end diff --git a/lib/tasks/cache.rake b/lib/tasks/cache.rake index 9e2fb429d577f37fc78365f9a98b3ae42ba8c103..f221afcf73a21b5bb1c802653bd29afa5cf7625a 100644 --- a/lib/tasks/cache.rake +++ b/lib/tasks/cache.rake @@ -1,5 +1,5 @@ namespace :cache do - CLEAR_BATCH_SIZE = 1000 # The more the faster, but having too many can crash Ruby + CLEAR_BATCH_SIZE = 1000 # There seems to be no speedup when pushing beyond 1,000 REDIS_SCAN_START_STOP = '0' # Magic value, see http://redis.io/commands/scan desc "GitLab | Clear redis cache" diff --git a/spec/controllers/namespaces_controller_spec.rb b/spec/controllers/namespaces_controller_spec.rb index 774369587113bca181d9b6fdedbfb737731e465b..d4a380cc2ee6942715468616ca87f31a7605349c 100644 --- a/spec/controllers/namespaces_controller_spec.rb +++ b/spec/controllers/namespaces_controller_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe NamespacesController do - let!(:user) { create(:user, avatar: fixture_file_upload(Rails.root + "spec/fixtures/dk.png", "image/png")) } + let!(:user) { create(:user, :with_avatar) } describe "GET show" do context "when the namespace belongs to a user" do diff --git a/spec/controllers/profiles/avatars_controller_spec.rb b/spec/controllers/profiles/avatars_controller_spec.rb index ad5855df0a433eadd70ae0a74db6435a1cb0cc6d..85dff009bcf9289ed61a21dc5d1d6f5d3685302d 100644 --- a/spec/controllers/profiles/avatars_controller_spec.rb +++ b/spec/controllers/profiles/avatars_controller_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe Profiles::AvatarsController do - let(:user) { create(:user, avatar: fixture_file_upload(Rails.root + "spec/fixtures/dk.png")) } + let(:user) { create(:user, :with_avatar) } before do sign_in(user) diff --git a/spec/controllers/uploads_controller_spec.rb b/spec/controllers/uploads_controller_spec.rb index af5d043cf02276ead874f5b16e0a309edce94772..0d9f4b299bc595d9d40713f724a50735422b1079 100644 --- a/spec/controllers/uploads_controller_spec.rb +++ b/spec/controllers/uploads_controller_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe UploadsController do - let!(:user) { create(:user, avatar: fixture_file_upload(Rails.root + "spec/fixtures/dk.png", "image/png")) } + let!(:user) { create(:user, :with_avatar) } describe "GET show" do context "when viewing a user avatar" do diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb index 104a5f501432f7c25b520e16b91ef26ec3d4be12..7337ff58be121153a38d51f1cbc967742b46aa5a 100644 --- a/spec/controllers/users_controller_spec.rb +++ b/spec/controllers/users_controller_spec.rb @@ -41,6 +41,7 @@ end describe 'GET #calendar' do + it 'renders calendar' do sign_in(user) @@ -48,6 +49,23 @@ expect(response).to render_template('calendar') end + + context 'forked project' do + let!(:project) { create(:project) } + let!(:forked_project) { Projects::ForkService.new(project, user).execute } + + before do + sign_in(user) + project.team << [user, :developer] + EventCreateService.new.push(project, user, []) + EventCreateService.new.push(forked_project, user, []) + end + + it 'includes forked projects' do + get :calendar, username: user.username + expect(assigns(:contributions_calendar).projects.count).to eq(2) + end + end end describe 'GET #calendar_activities' do diff --git a/spec/factories.rb b/spec/factories.rb index 3ad8226dfb24b31e046916e321648cb3efb16ba5..8f55064db14eef8c71f5b4ca09fa52f8895a02d7 100644 --- a/spec/factories.rb +++ b/spec/factories.rb @@ -32,10 +32,18 @@ before(:create) do |user| user.two_factor_enabled = true user.otp_secret = User.generate_otp_secret(32) + user.otp_grace_period_started_at = Time.now user.generate_otp_backup_codes! end end + trait :with_avatar do + avatar { fixture_file_upload(Rails.root.join(*%w(spec fixtures dk.png)), 'image/png') } + avatar_crop_x 0 + avatar_crop_y 0 + avatar_crop_size 256 + end + factory :omniauth_user do ignore do extern_uid '123456' diff --git a/spec/factories/appearances.rb b/spec/factories/appearances.rb index b301fa93791506050db31ef6fcc927ee33072d81..16156c302531e8d1003252d33195091efc5e7fee 100644 --- a/spec/factories/appearances.rb +++ b/spec/factories/appearances.rb @@ -2,7 +2,12 @@ FactoryGirl.define do factory :appearance do +<<<<<<< HEAD title "GitLab Enterprise Edition" description "Open source software to collaborate on code" +======= + title "MepMep" + description "This is my Community Edition instance" +>>>>>>> 204a9895333178cc90b40ef365ad566d750fa594 end end diff --git a/spec/features/issues/filter_by_milestone_spec.rb b/spec/features/issues/filter_by_milestone_spec.rb index 38c8d343ce3bd79b1462905104e464fbebec152f..591866b40d46521c012c2a0e9f8b3c131a6126a4 100644 --- a/spec/features/issues/filter_by_milestone_spec.rb +++ b/spec/features/issues/filter_by_milestone_spec.rb @@ -13,7 +13,7 @@ visit_issues(project) filter_by_milestone(Milestone::None.title) - expect(page).to have_css('.title', count: 1) + expect(page).to have_css('.issue .title', count: 1) end scenario 'filters by a specific Milestone', js: true do @@ -23,7 +23,7 @@ visit_issues(project) filter_by_milestone(milestone.title) - expect(page).to have_css('.title', count: 1) + expect(page).to have_css('.issue .title', count: 1) end def visit_issues(project) diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index f6c1005d2655971e5585000f02f797e60a9e17cc..8013b31524f005721b0477e784c415c94b07e3e6 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -77,7 +77,7 @@ def stub_action_name(value) let(:avatar_file_path) { File.join(Rails.root, 'spec', 'fixtures', 'banana_sample.gif') } it 'should return an url for the avatar' do - user = create(:user, avatar: File.open(avatar_file_path)) + user = create(:user, :with_avatar, avatar: File.open(avatar_file_path)) expect(helper.avatar_icon(user.email).to_s). to match("/uploads/user/avatar/#{user.id}/banana_sample.gif") @@ -88,7 +88,7 @@ def stub_action_name(value) # Must be stubbed after the stub above, and separately stub_config_setting(url: Settings.send(:build_gitlab_url)) - user = create(:user, avatar: File.open(avatar_file_path)) + user = create(:user, :with_avatar, avatar: File.open(avatar_file_path)) expect(helper.avatar_icon(user.email).to_s). to match("/gitlab/uploads/user/avatar/#{user.id}/banana_sample.gif") @@ -102,7 +102,7 @@ def stub_action_name(value) describe 'using a User' do it 'should return an URL for the avatar' do - user = create(:user, avatar: File.open(avatar_file_path)) + user = create(:user, :with_avatar, avatar: File.open(avatar_file_path)) expect(helper.avatar_icon(user).to_s). to match("/uploads/user/avatar/#{user.id}/banana_sample.gif") diff --git a/spec/lib/banzai/filter/sanitization_filter_spec.rb b/spec/lib/banzai/filter/sanitization_filter_spec.rb index e14a6dbf9225ae2c54d28831410ead8ef1afae53..4a7b00c7660cdfc827e201a0234bd6c9ff643d3c 100644 --- a/spec/lib/banzai/filter/sanitization_filter_spec.rb +++ b/spec/lib/banzai/filter/sanitization_filter_spec.rb @@ -156,13 +156,27 @@ } protocols.each do |name, data| - it "handles #{name}" do + it "disallows #{name}" do doc = filter(data[:input]) expect(doc.to_html).to eq data[:output] end end + it 'disallows data links' do + input = '<a href="data:text/html;base64,PHNjcmlwdD5hbGVydCgnWFNTJyk8L3NjcmlwdD4K">XSS</a>' + output = filter(input) + + expect(output.to_html).to eq '<a>XSS</a>' + end + + it 'disallows vbscript links' do + input = '<a href="vbscript:alert(document.domain)">XSS</a>' + output = filter(input) + + expect(output.to_html).to eq '<a>XSS</a>' + end + it 'allows non-standard anchor schemes' do exp = %q{<a href="irc://irc.freenode.net/git">IRC</a>} act = filter(exp) diff --git a/spec/models/hooks/project_hook_spec.rb b/spec/models/hooks/project_hook_spec.rb index 645ee0b929a7ff6d0a09fd2f3af9aff175f3adea..983848392b7eb52048649b89e7464ffd19471540 100644 --- a/spec/models/hooks/project_hook_spec.rb +++ b/spec/models/hooks/project_hook_spec.rb @@ -19,6 +19,10 @@ require 'spec_helper' describe ProjectHook, models: true do + describe "Associations" do + it { is_expected.to belong_to :project } + end + describe '.push_hooks' do it 'should return hooks for push events only' do hook = create(:project_hook, push_events: true) diff --git a/spec/models/hooks/web_hook_spec.rb b/spec/models/hooks/web_hook_spec.rb index 7070aa4ac620fbbaf670be7c91b3d85736ad1669..6ea99952a8f4021d06308ce9bc10c14b922eabea 100644 --- a/spec/models/hooks/web_hook_spec.rb +++ b/spec/models/hooks/web_hook_spec.rb @@ -18,20 +18,14 @@ require 'spec_helper' -describe ProjectHook, models: true do - describe "Associations" do - it { is_expected.to belong_to :project } - end - - describe "Mass assignment" do - end - +describe WebHook, models: true do describe "Validations" do it { is_expected.to validate_presence_of(:url) } - context "url format" do + describe 'url' do it { is_expected.to allow_value("http://example.com").for(:url) } - it { is_expected.to allow_value("https://excample.com").for(:url) } + it { is_expected.to allow_value("https://example.com").for(:url) } + it { is_expected.to allow_value(" https://example.com ").for(:url) } it { is_expected.to allow_value("http://test.com/api").for(:url) } it { is_expected.to allow_value("http://test.com/api?key=abc").for(:url) } it { is_expected.to allow_value("http://test.com/api?key=abc&type=def").for(:url) } @@ -39,6 +33,12 @@ it { is_expected.not_to allow_value("example.com").for(:url) } it { is_expected.not_to allow_value("ftp://example.com").for(:url) } it { is_expected.not_to allow_value("herp-and-derp").for(:url) } + + it 'strips :url before saving it' do + hook = create(:project_hook, url: ' https://example.com ') + + expect(hook.url).to eq('https://example.com') + end end end diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index c05afc2c91aec81381e8d8c1d6ea82cd51242a69..a39ca420a68b6e2a06c99610e695579440a8b272 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -362,14 +362,14 @@ repository.expire_cache('master') end - it 'expires the emptiness cache for an empty repository' do + it 'expires the emptiness caches for an empty repository' do expect(repository).to receive(:empty?).and_return(true) expect(repository).to receive(:expire_emptiness_caches) repository.expire_cache end - it 'does not expire the emptiness cache for a non-empty repository' do + it 'does not expire the emptiness caches for a non-empty repository' do expect(repository).to receive(:empty?).and_return(false) expect(repository).to_not receive(:expire_emptiness_caches) @@ -465,6 +465,110 @@ end end + describe '#before_delete' do + describe 'when a repository does not exist' do + before do + allow(repository).to receive(:exists?).and_return(false) + end + + it 'does not flush caches that depend on repository data' do + expect(repository).to_not receive(:expire_cache) + + repository.before_delete + end + + it 'flushes the root ref cache' do + expect(repository).to receive(:expire_root_ref_cache) + + repository.before_delete + end + + it 'flushes the emptiness caches' do + expect(repository).to receive(:expire_emptiness_caches) + + repository.before_delete + end + end + + describe 'when a repository exists' do + before do + allow(repository).to receive(:exists?).and_return(true) + end + + it 'flushes the caches that depend on repository data' do + expect(repository).to receive(:expire_cache) + + repository.before_delete + end + + it 'flushes the root ref cache' do + expect(repository).to receive(:expire_root_ref_cache) + + repository.before_delete + end + + it 'flushes the emptiness caches' do + expect(repository).to receive(:expire_emptiness_caches) + + repository.before_delete + end + end + end + + describe '#before_change_head' do + it 'flushes the branch cache' do + expect(repository).to receive(:expire_branch_cache) + + repository.before_change_head + end + + it 'flushes the root ref cache' do + expect(repository).to receive(:expire_root_ref_cache) + + repository.before_change_head + end + end + + describe '#before_create_tag' do + it 'flushes the cache' do + expect(repository).to receive(:expire_cache) + + repository.before_create_tag + end + end + + describe '#after_import' do + it 'flushes the emptiness cachess' do + expect(repository).to receive(:expire_emptiness_caches) + + repository.after_import + end + end + + describe '#after_push_commit' do + it 'flushes the cache' do + expect(repository).to receive(:expire_cache).with('master') + + repository.after_push_commit('master') + end + end + + describe '#after_create_branch' do + it 'flushes the visible content cache' do + expect(repository).to receive(:expire_has_visible_content_cache) + + repository.after_create_branch + end + end + + describe '#after_remove_branch' do + it 'flushes the visible content cache' do + expect(repository).to receive(:expire_has_visible_content_cache) + + repository.after_remove_branch + end + end + describe "Elastic search", elastic: true do before do Repository.__elasticsearch__.create_index! diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 308b9f22ceb13e9a83a87cfbf0ab10379efe66df..4bf1c04975404b123a64bbbd1adbc651bc52f3bd 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -174,6 +174,18 @@ end end end + + describe 'avatar' do + it 'only validates when avatar is present' do + user = build(:user, :with_avatar) + + user.avatar_crop_x = nil + user.avatar_crop_y = nil + user.avatar_crop_size = nil + + expect(user).not_to be_valid + end + end end describe "non_ldap" do @@ -269,6 +281,7 @@ expect(user).to be_two_factor_enabled expect(user.encrypted_otp_secret).not_to be_nil expect(user.otp_backup_codes).not_to be_nil + expect(user.otp_grace_period_started_at).not_to be_nil user.disable_two_factor! @@ -277,6 +290,7 @@ expect(user.encrypted_otp_secret_iv).to be_nil expect(user.encrypted_otp_secret_salt).to be_nil expect(user.otp_backup_codes).to be_nil + expect(user.otp_grace_period_started_at).to be_nil end end diff --git a/spec/requests/api/internal_spec.rb b/spec/requests/api/internal_spec.rb index 8d0ae1475c2de6de947d39a155ba412a8554820c..22802dd0e05104ab36d0a9ad81a03099c5d7f5e6 100644 --- a/spec/requests/api/internal_spec.rb +++ b/spec/requests/api/internal_spec.rb @@ -54,6 +54,18 @@ project.team << [user, :developer] end + context "git push with project.wiki" do + it 'responds with success' do + project_wiki = create(:project, name: 'my.wiki', path: 'my.wiki') + project_wiki.team << [user, :developer] + + push(key, project_wiki) + + expect(response.status).to eq(200) + expect(json_response["status"]).to be_truthy + end + end + context "git pull" do it do pull(key, project) diff --git a/vendor/assets/javascripts/cropper.js b/vendor/assets/javascripts/cropper.js new file mode 100755 index 0000000000000000000000000000000000000000..84aa6119ec3a9cbeeae99a57bea24235fb26d0c8 --- /dev/null +++ b/vendor/assets/javascripts/cropper.js @@ -0,0 +1,2972 @@ +/*! + * Cropper v2.2.5 + * https://github.com/fengyuanchen/cropper + * + * Copyright (c) 2014-2016 Fengyuan Chen and contributors + * Released under the MIT license + * + * Date: 2016-01-18T05:42:50.800Z + */ + +(function (factory) { + if (typeof define === 'function' && define.amd) { + // AMD. Register as anonymous module. + define(['jquery'], factory); + } else if (typeof exports === 'object') { + // Node / CommonJS + factory(require('jquery')); + } else { + // Browser globals. + factory(jQuery); + } +})(function ($) { + + 'use strict'; + + // Globals + var $window = $(window); + var $document = $(document); + var location = window.location; + var ArrayBuffer = window.ArrayBuffer; + var Uint8Array = window.Uint8Array; + var DataView = window.DataView; + var btoa = window.btoa; + + // Constants + var NAMESPACE = 'cropper'; + + // Classes + var CLASS_MODAL = 'cropper-modal'; + var CLASS_HIDE = 'cropper-hide'; + var CLASS_HIDDEN = 'cropper-hidden'; + var CLASS_INVISIBLE = 'cropper-invisible'; + var CLASS_MOVE = 'cropper-move'; + var CLASS_CROP = 'cropper-crop'; + var CLASS_DISABLED = 'cropper-disabled'; + var CLASS_BG = 'cropper-bg'; + + // Events + var EVENT_MOUSE_DOWN = 'mousedown touchstart pointerdown MSPointerDown'; + var EVENT_MOUSE_MOVE = 'mousemove touchmove pointermove MSPointerMove'; + var EVENT_MOUSE_UP = 'mouseup touchend touchcancel pointerup pointercancel MSPointerUp MSPointerCancel'; + var EVENT_WHEEL = 'wheel mousewheel DOMMouseScroll'; + var EVENT_DBLCLICK = 'dblclick'; + var EVENT_LOAD = 'load.' + NAMESPACE; + var EVENT_ERROR = 'error.' + NAMESPACE; + var EVENT_RESIZE = 'resize.' + NAMESPACE; // Bind to window with namespace + var EVENT_BUILD = 'build.' + NAMESPACE; + var EVENT_BUILT = 'built.' + NAMESPACE; + var EVENT_CROP_START = 'cropstart.' + NAMESPACE; + var EVENT_CROP_MOVE = 'cropmove.' + NAMESPACE; + var EVENT_CROP_END = 'cropend.' + NAMESPACE; + var EVENT_CROP = 'crop.' + NAMESPACE; + var EVENT_ZOOM = 'zoom.' + NAMESPACE; + + // RegExps + var REGEXP_ACTIONS = /e|w|s|n|se|sw|ne|nw|all|crop|move|zoom/; + var REGEXP_DATA_URL = /^data\:/; + var REGEXP_DATA_URL_HEAD = /^data\:([^\;]+)\;base64,/; + var REGEXP_DATA_URL_JPEG = /^data\:image\/jpeg.*;base64,/; + + // Data keys + var DATA_PREVIEW = 'preview'; + var DATA_ACTION = 'action'; + + // Actions + var ACTION_EAST = 'e'; + var ACTION_WEST = 'w'; + var ACTION_SOUTH = 's'; + var ACTION_NORTH = 'n'; + var ACTION_SOUTH_EAST = 'se'; + var ACTION_SOUTH_WEST = 'sw'; + var ACTION_NORTH_EAST = 'ne'; + var ACTION_NORTH_WEST = 'nw'; + var ACTION_ALL = 'all'; + var ACTION_CROP = 'crop'; + var ACTION_MOVE = 'move'; + var ACTION_ZOOM = 'zoom'; + var ACTION_NONE = 'none'; + + // Supports + var SUPPORT_CANVAS = $.isFunction($('<canvas>')[0].getContext); + + // Maths + var num = Number; + var min = Math.min; + var max = Math.max; + var abs = Math.abs; + var sin = Math.sin; + var cos = Math.cos; + var sqrt = Math.sqrt; + var round = Math.round; + var floor = Math.floor; + + // Utilities + var fromCharCode = String.fromCharCode; + + function isNumber(n) { + return typeof n === 'number' && !isNaN(n); + } + + function isUndefined(n) { + return typeof n === 'undefined'; + } + + function toArray(obj, offset) { + var args = []; + + // This is necessary for IE8 + if (isNumber(offset)) { + args.push(offset); + } + + return args.slice.apply(obj, args); + } + + // Custom proxy to avoid jQuery's guid + function proxy(fn, context) { + var args = toArray(arguments, 2); + + return function () { + return fn.apply(context, args.concat(toArray(arguments))); + }; + } + + function isCrossOriginURL(url) { + var parts = url.match(/^(https?:)\/\/([^\:\/\?#]+):?(\d*)/i); + + return parts && ( + parts[1] !== location.protocol || + parts[2] !== location.hostname || + parts[3] !== location.port + ); + } + + function addTimestamp(url) { + var timestamp = 'timestamp=' + (new Date()).getTime(); + + return (url + (url.indexOf('?') === -1 ? '?' : '&') + timestamp); + } + + function getCrossOrigin(crossOrigin) { + return crossOrigin ? ' crossOrigin="' + crossOrigin + '"' : ''; + } + + function getImageSize(image, callback) { + var newImage; + + // Modern browsers + if (image.naturalWidth) { + return callback(image.naturalWidth, image.naturalHeight); + } + + // IE8: Don't use `new Image()` here (#319) + newImage = document.createElement('img'); + + newImage.onload = function () { + callback(this.width, this.height); + }; + + newImage.src = image.src; + } + + function getTransform(options) { + var transforms = []; + var rotate = options.rotate; + var scaleX = options.scaleX; + var scaleY = options.scaleY; + + if (isNumber(rotate)) { + transforms.push('rotate(' + rotate + 'deg)'); + } + + if (isNumber(scaleX) && isNumber(scaleY)) { + transforms.push('scale(' + scaleX + ',' + scaleY + ')'); + } + + return transforms.length ? transforms.join(' ') : 'none'; + } + + function getRotatedSizes(data, isReversed) { + var deg = abs(data.degree) % 180; + var arc = (deg > 90 ? (180 - deg) : deg) * Math.PI / 180; + var sinArc = sin(arc); + var cosArc = cos(arc); + var width = data.width; + var height = data.height; + var aspectRatio = data.aspectRatio; + var newWidth; + var newHeight; + + if (!isReversed) { + newWidth = width * cosArc + height * sinArc; + newHeight = width * sinArc + height * cosArc; + } else { + newWidth = width / (cosArc + sinArc / aspectRatio); + newHeight = newWidth / aspectRatio; + } + + return { + width: newWidth, + height: newHeight + }; + } + + function getSourceCanvas(image, data) { + var canvas = $('<canvas>')[0]; + var context = canvas.getContext('2d'); + var x = 0; + var y = 0; + var width = data.naturalWidth; + var height = data.naturalHeight; + var rotate = data.rotate; + var scaleX = data.scaleX; + var scaleY = data.scaleY; + var scalable = isNumber(scaleX) && isNumber(scaleY) && (scaleX !== 1 || scaleY !== 1); + var rotatable = isNumber(rotate) && rotate !== 0; + var advanced = rotatable || scalable; + var canvasWidth = width; + var canvasHeight = height; + var translateX; + var translateY; + var rotated; + + if (scalable) { + translateX = width / 2; + translateY = height / 2; + } + + if (rotatable) { + rotated = getRotatedSizes({ + width: width, + height: height, + degree: rotate + }); + + canvasWidth = rotated.width; + canvasHeight = rotated.height; + translateX = rotated.width / 2; + translateY = rotated.height / 2; + } + + canvas.width = canvasWidth; + canvas.height = canvasHeight; + + if (advanced) { + x = -width / 2; + y = -height / 2; + + context.save(); + context.translate(translateX, translateY); + } + + if (rotatable) { + context.rotate(rotate * Math.PI / 180); + } + + // Should call `scale` after rotated + if (scalable) { + context.scale(scaleX, scaleY); + } + + context.drawImage(image, floor(x), floor(y), floor(width), floor(height)); + + if (advanced) { + context.restore(); + } + + return canvas; + } + + function getTouchesCenter(touches) { + var length = touches.length; + var pageX = 0; + var pageY = 0; + + if (length) { + $.each(touches, function (i, touch) { + pageX += touch.pageX; + pageY += touch.pageY; + }); + + pageX /= length; + pageY /= length; + } + + return { + pageX: pageX, + pageY: pageY + }; + } + + function getStringFromCharCode(dataView, start, length) { + var str = ''; + var i; + + for (i = start, length += start; i < length; i++) { + str += fromCharCode(dataView.getUint8(i)); + } + + return str; + } + + function getOrientation(arrayBuffer) { + var dataView = new DataView(arrayBuffer); + var length = dataView.byteLength; + var orientation; + var exifIDCode; + var tiffOffset; + var firstIFDOffset; + var littleEndian; + var endianness; + var app1Start; + var ifdStart; + var offset; + var i; + + // Only handle JPEG image (start by 0xFFD8) + if (dataView.getUint8(0) === 0xFF && dataView.getUint8(1) === 0xD8) { + offset = 2; + + while (offset < length) { + if (dataView.getUint8(offset) === 0xFF && dataView.getUint8(offset + 1) === 0xE1) { + app1Start = offset; + break; + } + + offset++; + } + } + + if (app1Start) { + exifIDCode = app1Start + 4; + tiffOffset = app1Start + 10; + + if (getStringFromCharCode(dataView, exifIDCode, 4) === 'Exif') { + endianness = dataView.getUint16(tiffOffset); + littleEndian = endianness === 0x4949; + + if (littleEndian || endianness === 0x4D4D /* bigEndian */) { + if (dataView.getUint16(tiffOffset + 2, littleEndian) === 0x002A) { + firstIFDOffset = dataView.getUint32(tiffOffset + 4, littleEndian); + + if (firstIFDOffset >= 0x00000008) { + ifdStart = tiffOffset + firstIFDOffset; + } + } + } + } + } + + if (ifdStart) { + length = dataView.getUint16(ifdStart, littleEndian); + + for (i = 0; i < length; i++) { + offset = ifdStart + i * 12 + 2; + + if (dataView.getUint16(offset, littleEndian) === 0x0112 /* Orientation */) { + + // 8 is the offset of the current tag's value + offset += 8; + + // Get the original orientation value + orientation = dataView.getUint16(offset, littleEndian); + + // Override the orientation with the default value: 1 + dataView.setUint16(offset, 1, littleEndian); + break; + } + } + } + + return orientation; + } + + function dataURLToArrayBuffer(dataURL) { + var base64 = dataURL.replace(REGEXP_DATA_URL_HEAD, ''); + var binary = atob(base64); + var length = binary.length; + var arrayBuffer = new ArrayBuffer(length); + var dataView = new Uint8Array(arrayBuffer); + var i; + + for (i = 0; i < length; i++) { + dataView[i] = binary.charCodeAt(i); + } + + return arrayBuffer; + } + + // Only available for JPEG image + function arrayBufferToDataURL(arrayBuffer) { + var dataView = new Uint8Array(arrayBuffer); + var length = dataView.length; + var base64 = ''; + var i; + + for (i = 0; i < length; i++) { + base64 += fromCharCode(dataView[i]); + } + + return 'data:image/jpeg;base64,' + btoa(base64); + } + + function Cropper(element, options) { + this.$element = $(element); + this.options = $.extend({}, Cropper.DEFAULTS, $.isPlainObject(options) && options); + this.isLoaded = false; + this.isBuilt = false; + this.isCompleted = false; + this.isRotated = false; + this.isCropped = false; + this.isDisabled = false; + this.isReplaced = false; + this.isLimited = false; + this.wheeling = false; + this.isImg = false; + this.originalUrl = ''; + this.canvas = null; + this.cropBox = null; + this.init(); + } + + Cropper.prototype = { + constructor: Cropper, + + init: function () { + var $this = this.$element; + var url; + + if ($this.is('img')) { + this.isImg = true; + + // Should use `$.fn.attr` here. e.g.: "img/picture.jpg" + this.originalUrl = url = $this.attr('src'); + + // Stop when it's a blank image + if (!url) { + return; + } + + // Should use `$.fn.prop` here. e.g.: "http://example.com/img/picture.jpg" + url = $this.prop('src'); + } else if ($this.is('canvas') && SUPPORT_CANVAS) { + url = $this[0].toDataURL(); + } + + this.load(url); + }, + + // A shortcut for triggering custom events + trigger: function (type, data) { + var e = $.Event(type, data); + + this.$element.trigger(e); + + return e; + }, + + load: function (url) { + var options = this.options; + var $this = this.$element; + var read; + var xhr; + + if (!url) { + return; + } + + // Trigger build event first + $this.one(EVENT_BUILD, options.build); + + if (this.trigger(EVENT_BUILD).isDefaultPrevented()) { + return; + } + + this.url = url; + this.image = {}; + + if (!options.checkOrientation || !ArrayBuffer) { + return this.clone(); + } + + read = $.proxy(this.read, this); + + // XMLHttpRequest disallows to open a Data URL in some browsers like IE11 and Safari + if (REGEXP_DATA_URL.test(url)) { + return REGEXP_DATA_URL_JPEG.test(url) ? + read(dataURLToArrayBuffer(url)) : + this.clone(); + } + + xhr = new XMLHttpRequest(); + + xhr.onerror = xhr.onabort = $.proxy(function () { + this.clone(); + }, this); + + xhr.onload = function () { + read(this.response); + }; + + xhr.open('get', url); + xhr.responseType = 'arraybuffer'; + xhr.send(); + }, + + read: function (arrayBuffer) { + var options = this.options; + var orientation = getOrientation(arrayBuffer); + var image = this.image; + var rotate; + var scaleX; + var scaleY; + + if (orientation > 1) { + this.url = arrayBufferToDataURL(arrayBuffer); + + switch (orientation) { + + // flip horizontal + case 2: + scaleX = -1; + break; + + // rotate left 180° + case 3: + rotate = -180; + break; + + // flip vertical + case 4: + scaleY = -1; + break; + + // flip vertical + rotate right 90° + case 5: + rotate = 90; + scaleY = -1; + break; + + // rotate right 90° + case 6: + rotate = 90; + break; + + // flip horizontal + rotate right 90° + case 7: + rotate = 90; + scaleX = -1; + break; + + // rotate left 90° + case 8: + rotate = -90; + break; + } + } + + if (options.rotatable) { + image.rotate = rotate; + } + + if (options.scalable) { + image.scaleX = scaleX; + image.scaleY = scaleY; + } + + this.clone(); + }, + + clone: function () { + var options = this.options; + var $this = this.$element; + var url = this.url; + var crossOrigin = ''; + var crossOriginUrl; + var $clone; + + if (options.checkCrossOrigin && isCrossOriginURL(url)) { + crossOrigin = $this.prop('crossOrigin'); + + if (crossOrigin) { + crossOriginUrl = url; + } else { + crossOrigin = 'anonymous'; + + // Bust cache (#148) when there is not a "crossOrigin" property + crossOriginUrl = addTimestamp(url); + } + } + + this.crossOrigin = crossOrigin; + this.crossOriginUrl = crossOriginUrl; + this.$clone = $clone = $('<img' + getCrossOrigin(crossOrigin) + ' src="' + (crossOriginUrl || url) + '">'); + + if (this.isImg) { + if ($this[0].complete) { + this.start(); + } else { + $this.one(EVENT_LOAD, $.proxy(this.start, this)); + } + } else { + $clone. + one(EVENT_LOAD, $.proxy(this.start, this)). + one(EVENT_ERROR, $.proxy(this.stop, this)). + addClass(CLASS_HIDE). + insertAfter($this); + } + }, + + start: function () { + var $image = this.$element; + var $clone = this.$clone; + + if (!this.isImg) { + $clone.off(EVENT_ERROR, this.stop); + $image = $clone; + } + + getImageSize($image[0], $.proxy(function (naturalWidth, naturalHeight) { + $.extend(this.image, { + naturalWidth: naturalWidth, + naturalHeight: naturalHeight, + aspectRatio: naturalWidth / naturalHeight + }); + + this.isLoaded = true; + this.build(); + }, this)); + }, + + stop: function () { + this.$clone.remove(); + this.$clone = null; + }, + + build: function () { + var options = this.options; + var $this = this.$element; + var $clone = this.$clone; + var $cropper; + var $cropBox; + var $face; + + if (!this.isLoaded) { + return; + } + + // Unbuild first when replace + if (this.isBuilt) { + this.unbuild(); + } + + // Create cropper elements + this.$container = $this.parent(); + this.$cropper = $cropper = $(Cropper.TEMPLATE); + this.$canvas = $cropper.find('.cropper-canvas').append($clone); + this.$dragBox = $cropper.find('.cropper-drag-box'); + this.$cropBox = $cropBox = $cropper.find('.cropper-crop-box'); + this.$viewBox = $cropper.find('.cropper-view-box'); + this.$face = $face = $cropBox.find('.cropper-face'); + + // Hide the original image + $this.addClass(CLASS_HIDDEN).after($cropper); + + // Show the clone image if is hidden + if (!this.isImg) { + $clone.removeClass(CLASS_HIDE); + } + + this.initPreview(); + this.bind(); + + options.aspectRatio = max(0, options.aspectRatio) || NaN; + options.viewMode = max(0, min(3, round(options.viewMode))) || 0; + + if (options.autoCrop) { + this.isCropped = true; + + if (options.modal) { + this.$dragBox.addClass(CLASS_MODAL); + } + } else { + $cropBox.addClass(CLASS_HIDDEN); + } + + if (!options.guides) { + $cropBox.find('.cropper-dashed').addClass(CLASS_HIDDEN); + } + + if (!options.center) { + $cropBox.find('.cropper-center').addClass(CLASS_HIDDEN); + } + + if (options.cropBoxMovable) { + $face.addClass(CLASS_MOVE).data(DATA_ACTION, ACTION_ALL); + } + + if (!options.highlight) { + $face.addClass(CLASS_INVISIBLE); + } + + if (options.background) { + $cropper.addClass(CLASS_BG); + } + + if (!options.cropBoxResizable) { + $cropBox.find('.cropper-line, .cropper-point').addClass(CLASS_HIDDEN); + } + + this.setDragMode(options.dragMode); + this.render(); + this.isBuilt = true; + this.setData(options.data); + $this.one(EVENT_BUILT, options.built); + + // Trigger the built event asynchronously to keep `data('cropper')` is defined + setTimeout($.proxy(function () { + this.trigger(EVENT_BUILT); + this.isCompleted = true; + }, this), 0); + }, + + unbuild: function () { + if (!this.isBuilt) { + return; + } + + this.isBuilt = false; + this.isCompleted = false; + this.initialImage = null; + + // Clear `initialCanvas` is necessary when replace + this.initialCanvas = null; + this.initialCropBox = null; + this.container = null; + this.canvas = null; + + // Clear `cropBox` is necessary when replace + this.cropBox = null; + this.unbind(); + + this.resetPreview(); + this.$preview = null; + + this.$viewBox = null; + this.$cropBox = null; + this.$dragBox = null; + this.$canvas = null; + this.$container = null; + + this.$cropper.remove(); + this.$cropper = null; + }, + + render: function () { + this.initContainer(); + this.initCanvas(); + this.initCropBox(); + + this.renderCanvas(); + + if (this.isCropped) { + this.renderCropBox(); + } + }, + + initContainer: function () { + var options = this.options; + var $this = this.$element; + var $container = this.$container; + var $cropper = this.$cropper; + + $cropper.addClass(CLASS_HIDDEN); + $this.removeClass(CLASS_HIDDEN); + + $cropper.css((this.container = { + width: max($container.width(), num(options.minContainerWidth) || 200), + height: max($container.height(), num(options.minContainerHeight) || 100) + })); + + $this.addClass(CLASS_HIDDEN); + $cropper.removeClass(CLASS_HIDDEN); + }, + + // Canvas (image wrapper) + initCanvas: function () { + var viewMode = this.options.viewMode; + var container = this.container; + var containerWidth = container.width; + var containerHeight = container.height; + var image = this.image; + var imageNaturalWidth = image.naturalWidth; + var imageNaturalHeight = image.naturalHeight; + var is90Degree = abs(image.rotate) === 90; + var naturalWidth = is90Degree ? imageNaturalHeight : imageNaturalWidth; + var naturalHeight = is90Degree ? imageNaturalWidth : imageNaturalHeight; + var aspectRatio = naturalWidth / naturalHeight; + var canvasWidth = containerWidth; + var canvasHeight = containerHeight; + var canvas; + + if (containerHeight * aspectRatio > containerWidth) { + if (viewMode === 3) { + canvasWidth = containerHeight * aspectRatio; + } else { + canvasHeight = containerWidth / aspectRatio; + } + } else { + if (viewMode === 3) { + canvasHeight = containerWidth / aspectRatio; + } else { + canvasWidth = containerHeight * aspectRatio; + } + } + + canvas = { + naturalWidth: naturalWidth, + naturalHeight: naturalHeight, + aspectRatio: aspectRatio, + width: canvasWidth, + height: canvasHeight + }; + + canvas.oldLeft = canvas.left = (containerWidth - canvasWidth) / 2; + canvas.oldTop = canvas.top = (containerHeight - canvasHeight) / 2; + + this.canvas = canvas; + this.isLimited = (viewMode === 1 || viewMode === 2); + this.limitCanvas(true, true); + this.initialImage = $.extend({}, image); + this.initialCanvas = $.extend({}, canvas); + }, + + limitCanvas: function (isSizeLimited, isPositionLimited) { + var options = this.options; + var viewMode = options.viewMode; + var container = this.container; + var containerWidth = container.width; + var containerHeight = container.height; + var canvas = this.canvas; + var aspectRatio = canvas.aspectRatio; + var cropBox = this.cropBox; + var isCropped = this.isCropped && cropBox; + var minCanvasWidth; + var minCanvasHeight; + var newCanvasLeft; + var newCanvasTop; + + if (isSizeLimited) { + minCanvasWidth = num(options.minCanvasWidth) || 0; + minCanvasHeight = num(options.minCanvasHeight) || 0; + + if (viewMode) { + if (viewMode > 1) { + minCanvasWidth = max(minCanvasWidth, containerWidth); + minCanvasHeight = max(minCanvasHeight, containerHeight); + + if (viewMode === 3) { + if (minCanvasHeight * aspectRatio > minCanvasWidth) { + minCanvasWidth = minCanvasHeight * aspectRatio; + } else { + minCanvasHeight = minCanvasWidth / aspectRatio; + } + } + } else { + if (minCanvasWidth) { + minCanvasWidth = max(minCanvasWidth, isCropped ? cropBox.width : 0); + } else if (minCanvasHeight) { + minCanvasHeight = max(minCanvasHeight, isCropped ? cropBox.height : 0); + } else if (isCropped) { + minCanvasWidth = cropBox.width; + minCanvasHeight = cropBox.height; + + if (minCanvasHeight * aspectRatio > minCanvasWidth) { + minCanvasWidth = minCanvasHeight * aspectRatio; + } else { + minCanvasHeight = minCanvasWidth / aspectRatio; + } + } + } + } + + if (minCanvasWidth && minCanvasHeight) { + if (minCanvasHeight * aspectRatio > minCanvasWidth) { + minCanvasHeight = minCanvasWidth / aspectRatio; + } else { + minCanvasWidth = minCanvasHeight * aspectRatio; + } + } else if (minCanvasWidth) { + minCanvasHeight = minCanvasWidth / aspectRatio; + } else if (minCanvasHeight) { + minCanvasWidth = minCanvasHeight * aspectRatio; + } + + canvas.minWidth = minCanvasWidth; + canvas.minHeight = minCanvasHeight; + canvas.maxWidth = Infinity; + canvas.maxHeight = Infinity; + } + + if (isPositionLimited) { + if (viewMode) { + newCanvasLeft = containerWidth - canvas.width; + newCanvasTop = containerHeight - canvas.height; + + canvas.minLeft = min(0, newCanvasLeft); + canvas.minTop = min(0, newCanvasTop); + canvas.maxLeft = max(0, newCanvasLeft); + canvas.maxTop = max(0, newCanvasTop); + + if (isCropped && this.isLimited) { + canvas.minLeft = min( + cropBox.left, + cropBox.left + cropBox.width - canvas.width + ); + canvas.minTop = min( + cropBox.top, + cropBox.top + cropBox.height - canvas.height + ); + canvas.maxLeft = cropBox.left; + canvas.maxTop = cropBox.top; + + if (viewMode === 2) { + if (canvas.width >= containerWidth) { + canvas.minLeft = min(0, newCanvasLeft); + canvas.maxLeft = max(0, newCanvasLeft); + } + + if (canvas.height >= containerHeight) { + canvas.minTop = min(0, newCanvasTop); + canvas.maxTop = max(0, newCanvasTop); + } + } + } + } else { + canvas.minLeft = -canvas.width; + canvas.minTop = -canvas.height; + canvas.maxLeft = containerWidth; + canvas.maxTop = containerHeight; + } + } + }, + + renderCanvas: function (isChanged) { + var canvas = this.canvas; + var image = this.image; + var rotate = image.rotate; + var naturalWidth = image.naturalWidth; + var naturalHeight = image.naturalHeight; + var aspectRatio; + var rotated; + + if (this.isRotated) { + this.isRotated = false; + + // Computes rotated sizes with image sizes + rotated = getRotatedSizes({ + width: image.width, + height: image.height, + degree: rotate + }); + + aspectRatio = rotated.width / rotated.height; + + if (aspectRatio !== canvas.aspectRatio) { + canvas.left -= (rotated.width - canvas.width) / 2; + canvas.top -= (rotated.height - canvas.height) / 2; + canvas.width = rotated.width; + canvas.height = rotated.height; + canvas.aspectRatio = aspectRatio; + canvas.naturalWidth = naturalWidth; + canvas.naturalHeight = naturalHeight; + + // Computes rotated sizes with natural image sizes + if (rotate % 180) { + rotated = getRotatedSizes({ + width: naturalWidth, + height: naturalHeight, + degree: rotate + }); + + canvas.naturalWidth = rotated.width; + canvas.naturalHeight = rotated.height; + } + + this.limitCanvas(true, false); + } + } + + if (canvas.width > canvas.maxWidth || canvas.width < canvas.minWidth) { + canvas.left = canvas.oldLeft; + } + + if (canvas.height > canvas.maxHeight || canvas.height < canvas.minHeight) { + canvas.top = canvas.oldTop; + } + + canvas.width = min(max(canvas.width, canvas.minWidth), canvas.maxWidth); + canvas.height = min(max(canvas.height, canvas.minHeight), canvas.maxHeight); + + this.limitCanvas(false, true); + + canvas.oldLeft = canvas.left = min(max(canvas.left, canvas.minLeft), canvas.maxLeft); + canvas.oldTop = canvas.top = min(max(canvas.top, canvas.minTop), canvas.maxTop); + + this.$canvas.css({ + width: canvas.width, + height: canvas.height, + left: canvas.left, + top: canvas.top + }); + + this.renderImage(); + + if (this.isCropped && this.isLimited) { + this.limitCropBox(true, true); + } + + if (isChanged) { + this.output(); + } + }, + + renderImage: function (isChanged) { + var canvas = this.canvas; + var image = this.image; + var reversed; + + if (image.rotate) { + reversed = getRotatedSizes({ + width: canvas.width, + height: canvas.height, + degree: image.rotate, + aspectRatio: image.aspectRatio + }, true); + } + + $.extend(image, reversed ? { + width: reversed.width, + height: reversed.height, + left: (canvas.width - reversed.width) / 2, + top: (canvas.height - reversed.height) / 2 + } : { + width: canvas.width, + height: canvas.height, + left: 0, + top: 0 + }); + + this.$clone.css({ + width: image.width, + height: image.height, + marginLeft: image.left, + marginTop: image.top, + transform: getTransform(image) + }); + + if (isChanged) { + this.output(); + } + }, + + initCropBox: function () { + var options = this.options; + var canvas = this.canvas; + var aspectRatio = options.aspectRatio; + var autoCropArea = num(options.autoCropArea) || 0.8; + var cropBox = { + width: canvas.width, + height: canvas.height + }; + + if (aspectRatio) { + if (canvas.height * aspectRatio > canvas.width) { + cropBox.height = cropBox.width / aspectRatio; + } else { + cropBox.width = cropBox.height * aspectRatio; + } + } + + this.cropBox = cropBox; + this.limitCropBox(true, true); + + // Initialize auto crop area + cropBox.width = min(max(cropBox.width, cropBox.minWidth), cropBox.maxWidth); + cropBox.height = min(max(cropBox.height, cropBox.minHeight), cropBox.maxHeight); + + // The width of auto crop area must large than "minWidth", and the height too. (#164) + cropBox.width = max(cropBox.minWidth, cropBox.width * autoCropArea); + cropBox.height = max(cropBox.minHeight, cropBox.height * autoCropArea); + cropBox.oldLeft = cropBox.left = canvas.left + (canvas.width - cropBox.width) / 2; + cropBox.oldTop = cropBox.top = canvas.top + (canvas.height - cropBox.height) / 2; + + this.initialCropBox = $.extend({}, cropBox); + }, + + limitCropBox: function (isSizeLimited, isPositionLimited) { + var options = this.options; + var aspectRatio = options.aspectRatio; + var container = this.container; + var containerWidth = container.width; + var containerHeight = container.height; + var canvas = this.canvas; + var cropBox = this.cropBox; + var isLimited = this.isLimited; + var minCropBoxWidth; + var minCropBoxHeight; + var maxCropBoxWidth; + var maxCropBoxHeight; + + if (isSizeLimited) { + minCropBoxWidth = num(options.minCropBoxWidth) || 0; + minCropBoxHeight = num(options.minCropBoxHeight) || 0; + + // The min/maxCropBoxWidth/Height must be less than containerWidth/Height + minCropBoxWidth = min(minCropBoxWidth, containerWidth); + minCropBoxHeight = min(minCropBoxHeight, containerHeight); + maxCropBoxWidth = min(containerWidth, isLimited ? canvas.width : containerWidth); + maxCropBoxHeight = min(containerHeight, isLimited ? canvas.height : containerHeight); + + if (aspectRatio) { + if (minCropBoxWidth && minCropBoxHeight) { + if (minCropBoxHeight * aspectRatio > minCropBoxWidth) { + minCropBoxHeight = minCropBoxWidth / aspectRatio; + } else { + minCropBoxWidth = minCropBoxHeight * aspectRatio; + } + } else if (minCropBoxWidth) { + minCropBoxHeight = minCropBoxWidth / aspectRatio; + } else if (minCropBoxHeight) { + minCropBoxWidth = minCropBoxHeight * aspectRatio; + } + + if (maxCropBoxHeight * aspectRatio > maxCropBoxWidth) { + maxCropBoxHeight = maxCropBoxWidth / aspectRatio; + } else { + maxCropBoxWidth = maxCropBoxHeight * aspectRatio; + } + } + + // The minWidth/Height must be less than maxWidth/Height + cropBox.minWidth = min(minCropBoxWidth, maxCropBoxWidth); + cropBox.minHeight = min(minCropBoxHeight, maxCropBoxHeight); + cropBox.maxWidth = maxCropBoxWidth; + cropBox.maxHeight = maxCropBoxHeight; + } + + if (isPositionLimited) { + if (isLimited) { + cropBox.minLeft = max(0, canvas.left); + cropBox.minTop = max(0, canvas.top); + cropBox.maxLeft = min(containerWidth, canvas.left + canvas.width) - cropBox.width; + cropBox.maxTop = min(containerHeight, canvas.top + canvas.height) - cropBox.height; + } else { + cropBox.minLeft = 0; + cropBox.minTop = 0; + cropBox.maxLeft = containerWidth - cropBox.width; + cropBox.maxTop = containerHeight - cropBox.height; + } + } + }, + + renderCropBox: function () { + var options = this.options; + var container = this.container; + var containerWidth = container.width; + var containerHeight = container.height; + var cropBox = this.cropBox; + + if (cropBox.width > cropBox.maxWidth || cropBox.width < cropBox.minWidth) { + cropBox.left = cropBox.oldLeft; + } + + if (cropBox.height > cropBox.maxHeight || cropBox.height < cropBox.minHeight) { + cropBox.top = cropBox.oldTop; + } + + cropBox.width = min(max(cropBox.width, cropBox.minWidth), cropBox.maxWidth); + cropBox.height = min(max(cropBox.height, cropBox.minHeight), cropBox.maxHeight); + + this.limitCropBox(false, true); + + cropBox.oldLeft = cropBox.left = min(max(cropBox.left, cropBox.minLeft), cropBox.maxLeft); + cropBox.oldTop = cropBox.top = min(max(cropBox.top, cropBox.minTop), cropBox.maxTop); + + if (options.movable && options.cropBoxMovable) { + + // Turn to move the canvas when the crop box is equal to the container + this.$face.data(DATA_ACTION, (cropBox.width === containerWidth && cropBox.height === containerHeight) ? ACTION_MOVE : ACTION_ALL); + } + + this.$cropBox.css({ + width: cropBox.width, + height: cropBox.height, + left: cropBox.left, + top: cropBox.top + }); + + if (this.isCropped && this.isLimited) { + this.limitCanvas(true, true); + } + + if (!this.isDisabled) { + this.output(); + } + }, + + output: function () { + this.preview(); + + if (this.isCompleted) { + this.trigger(EVENT_CROP, this.getData()); + } else if (!this.isBuilt) { + + // Only trigger one crop event before complete + this.$element.one(EVENT_BUILT, $.proxy(function () { + this.trigger(EVENT_CROP, this.getData()); + }, this)); + } + }, + + initPreview: function () { + var crossOrigin = getCrossOrigin(this.crossOrigin); + var url = crossOrigin ? this.crossOriginUrl : this.url; + + this.$preview = $(this.options.preview); + this.$viewBox.html('<img' + crossOrigin + ' src="' + url + '">'); + this.$preview.each(function () { + var $this = $(this); + + // Save the original size for recover + $this.data(DATA_PREVIEW, { + width: $this.width(), + height: $this.height(), + html: $this.html() + }); + + /** + * Override img element styles + * Add `display:block` to avoid margin top issue + * (Occur only when margin-top <= -height) + */ + $this.html( + '<img' + crossOrigin + ' src="' + url + '" style="' + + 'display:block;width:100%;height:auto;' + + 'min-width:0!important;min-height:0!important;' + + 'max-width:none!important;max-height:none!important;' + + 'image-orientation:0deg!important;">' + ); + }); + }, + + resetPreview: function () { + this.$preview.each(function () { + var $this = $(this); + var data = $this.data(DATA_PREVIEW); + + $this.css({ + width: data.width, + height: data.height + }).html(data.html).removeData(DATA_PREVIEW); + }); + }, + + preview: function () { + var image = this.image; + var canvas = this.canvas; + var cropBox = this.cropBox; + var cropBoxWidth = cropBox.width; + var cropBoxHeight = cropBox.height; + var width = image.width; + var height = image.height; + var left = cropBox.left - canvas.left - image.left; + var top = cropBox.top - canvas.top - image.top; + + if (!this.isCropped || this.isDisabled) { + return; + } + + this.$viewBox.find('img').css({ + width: width, + height: height, + marginLeft: -left, + marginTop: -top, + transform: getTransform(image) + }); + + this.$preview.each(function () { + var $this = $(this); + var data = $this.data(DATA_PREVIEW); + var originalWidth = data.width; + var originalHeight = data.height; + var newWidth = originalWidth; + var newHeight = originalHeight; + var ratio = 1; + + if (cropBoxWidth) { + ratio = originalWidth / cropBoxWidth; + newHeight = cropBoxHeight * ratio; + } + + if (cropBoxHeight && newHeight > originalHeight) { + ratio = originalHeight / cropBoxHeight; + newWidth = cropBoxWidth * ratio; + newHeight = originalHeight; + } + + $this.css({ + width: newWidth, + height: newHeight + }).find('img').css({ + width: width * ratio, + height: height * ratio, + marginLeft: -left * ratio, + marginTop: -top * ratio, + transform: getTransform(image) + }); + }); + }, + + bind: function () { + var options = this.options; + var $this = this.$element; + var $cropper = this.$cropper; + + if ($.isFunction(options.cropstart)) { + $this.on(EVENT_CROP_START, options.cropstart); + } + + if ($.isFunction(options.cropmove)) { + $this.on(EVENT_CROP_MOVE, options.cropmove); + } + + if ($.isFunction(options.cropend)) { + $this.on(EVENT_CROP_END, options.cropend); + } + + if ($.isFunction(options.crop)) { + $this.on(EVENT_CROP, options.crop); + } + + if ($.isFunction(options.zoom)) { + $this.on(EVENT_ZOOM, options.zoom); + } + + $cropper.on(EVENT_MOUSE_DOWN, $.proxy(this.cropStart, this)); + + if (options.zoomable && options.zoomOnWheel) { + $cropper.on(EVENT_WHEEL, $.proxy(this.wheel, this)); + } + + if (options.toggleDragModeOnDblclick) { + $cropper.on(EVENT_DBLCLICK, $.proxy(this.dblclick, this)); + } + + $document. + on(EVENT_MOUSE_MOVE, (this._cropMove = proxy(this.cropMove, this))). + on(EVENT_MOUSE_UP, (this._cropEnd = proxy(this.cropEnd, this))); + + if (options.responsive) { + $window.on(EVENT_RESIZE, (this._resize = proxy(this.resize, this))); + } + }, + + unbind: function () { + var options = this.options; + var $this = this.$element; + var $cropper = this.$cropper; + + if ($.isFunction(options.cropstart)) { + $this.off(EVENT_CROP_START, options.cropstart); + } + + if ($.isFunction(options.cropmove)) { + $this.off(EVENT_CROP_MOVE, options.cropmove); + } + + if ($.isFunction(options.cropend)) { + $this.off(EVENT_CROP_END, options.cropend); + } + + if ($.isFunction(options.crop)) { + $this.off(EVENT_CROP, options.crop); + } + + if ($.isFunction(options.zoom)) { + $this.off(EVENT_ZOOM, options.zoom); + } + + $cropper.off(EVENT_MOUSE_DOWN, this.cropStart); + + if (options.zoomable && options.zoomOnWheel) { + $cropper.off(EVENT_WHEEL, this.wheel); + } + + if (options.toggleDragModeOnDblclick) { + $cropper.off(EVENT_DBLCLICK, this.dblclick); + } + + $document. + off(EVENT_MOUSE_MOVE, this._cropMove). + off(EVENT_MOUSE_UP, this._cropEnd); + + if (options.responsive) { + $window.off(EVENT_RESIZE, this._resize); + } + }, + + resize: function () { + var restore = this.options.restore; + var $container = this.$container; + var container = this.container; + var canvasData; + var cropBoxData; + var ratio; + + // Check `container` is necessary for IE8 + if (this.isDisabled || !container) { + return; + } + + ratio = $container.width() / container.width; + + // Resize when width changed or height changed + if (ratio !== 1 || $container.height() !== container.height) { + if (restore) { + canvasData = this.getCanvasData(); + cropBoxData = this.getCropBoxData(); + } + + this.render(); + + if (restore) { + this.setCanvasData($.each(canvasData, function (i, n) { + canvasData[i] = n * ratio; + })); + this.setCropBoxData($.each(cropBoxData, function (i, n) { + cropBoxData[i] = n * ratio; + })); + } + } + }, + + dblclick: function () { + if (this.isDisabled) { + return; + } + + if (this.$dragBox.hasClass(CLASS_CROP)) { + this.setDragMode(ACTION_MOVE); + } else { + this.setDragMode(ACTION_CROP); + } + }, + + wheel: function (event) { + var e = event.originalEvent || event; + var ratio = num(this.options.wheelZoomRatio) || 0.1; + var delta = 1; + + if (this.isDisabled) { + return; + } + + event.preventDefault(); + + // Limit wheel speed to prevent zoom too fast + if (this.wheeling) { + return; + } + + this.wheeling = true; + + setTimeout($.proxy(function () { + this.wheeling = false; + }, this), 50); + + if (e.deltaY) { + delta = e.deltaY > 0 ? 1 : -1; + } else if (e.wheelDelta) { + delta = -e.wheelDelta / 120; + } else if (e.detail) { + delta = e.detail > 0 ? 1 : -1; + } + + this.zoom(-delta * ratio, event); + }, + + cropStart: function (event) { + var options = this.options; + var originalEvent = event.originalEvent; + var touches = originalEvent && originalEvent.touches; + var e = event; + var touchesLength; + var action; + + if (this.isDisabled) { + return; + } + + if (touches) { + touchesLength = touches.length; + + if (touchesLength > 1) { + if (options.zoomable && options.zoomOnTouch && touchesLength === 2) { + e = touches[1]; + this.startX2 = e.pageX; + this.startY2 = e.pageY; + action = ACTION_ZOOM; + } else { + return; + } + } + + e = touches[0]; + } + + action = action || $(e.target).data(DATA_ACTION); + + if (REGEXP_ACTIONS.test(action)) { + if (this.trigger(EVENT_CROP_START, { + originalEvent: originalEvent, + action: action + }).isDefaultPrevented()) { + return; + } + + event.preventDefault(); + + this.action = action; + this.cropping = false; + + // IE8 has `event.pageX/Y`, but not `event.originalEvent.pageX/Y` + // IE10 has `event.originalEvent.pageX/Y`, but not `event.pageX/Y` + this.startX = e.pageX || originalEvent && originalEvent.pageX; + this.startY = e.pageY || originalEvent && originalEvent.pageY; + + if (action === ACTION_CROP) { + this.cropping = true; + this.$dragBox.addClass(CLASS_MODAL); + } + } + }, + + cropMove: function (event) { + var options = this.options; + var originalEvent = event.originalEvent; + var touches = originalEvent && originalEvent.touches; + var e = event; + var action = this.action; + var touchesLength; + + if (this.isDisabled) { + return; + } + + if (touches) { + touchesLength = touches.length; + + if (touchesLength > 1) { + if (options.zoomable && options.zoomOnTouch && touchesLength === 2) { + e = touches[1]; + this.endX2 = e.pageX; + this.endY2 = e.pageY; + } else { + return; + } + } + + e = touches[0]; + } + + if (action) { + if (this.trigger(EVENT_CROP_MOVE, { + originalEvent: originalEvent, + action: action + }).isDefaultPrevented()) { + return; + } + + event.preventDefault(); + + this.endX = e.pageX || originalEvent && originalEvent.pageX; + this.endY = e.pageY || originalEvent && originalEvent.pageY; + + this.change(e.shiftKey, action === ACTION_ZOOM ? event : null); + } + }, + + cropEnd: function (event) { + var originalEvent = event.originalEvent; + var action = this.action; + + if (this.isDisabled) { + return; + } + + if (action) { + event.preventDefault(); + + if (this.cropping) { + this.cropping = false; + this.$dragBox.toggleClass(CLASS_MODAL, this.isCropped && this.options.modal); + } + + this.action = ''; + + this.trigger(EVENT_CROP_END, { + originalEvent: originalEvent, + action: action + }); + } + }, + + change: function (shiftKey, event) { + var options = this.options; + var aspectRatio = options.aspectRatio; + var action = this.action; + var container = this.container; + var canvas = this.canvas; + var cropBox = this.cropBox; + var width = cropBox.width; + var height = cropBox.height; + var left = cropBox.left; + var top = cropBox.top; + var right = left + width; + var bottom = top + height; + var minLeft = 0; + var minTop = 0; + var maxWidth = container.width; + var maxHeight = container.height; + var renderable = true; + var offset; + var range; + + // Locking aspect ratio in "free mode" by holding shift key (#259) + if (!aspectRatio && shiftKey) { + aspectRatio = width && height ? width / height : 1; + } + + if (this.limited) { + minLeft = cropBox.minLeft; + minTop = cropBox.minTop; + maxWidth = minLeft + min(container.width, canvas.width); + maxHeight = minTop + min(container.height, canvas.height); + } + + range = { + x: this.endX - this.startX, + y: this.endY - this.startY + }; + + if (aspectRatio) { + range.X = range.y * aspectRatio; + range.Y = range.x / aspectRatio; + } + + switch (action) { + // Move crop box + case ACTION_ALL: + left += range.x; + top += range.y; + break; + + // Resize crop box + case ACTION_EAST: + if (range.x >= 0 && (right >= maxWidth || aspectRatio && + (top <= minTop || bottom >= maxHeight))) { + + renderable = false; + break; + } + + width += range.x; + + if (aspectRatio) { + height = width / aspectRatio; + top -= range.Y / 2; + } + + if (width < 0) { + action = ACTION_WEST; + width = 0; + } + + break; + + case ACTION_NORTH: + if (range.y <= 0 && (top <= minTop || aspectRatio && + (left <= minLeft || right >= maxWidth))) { + + renderable = false; + break; + } + + height -= range.y; + top += range.y; + + if (aspectRatio) { + width = height * aspectRatio; + left += range.X / 2; + } + + if (height < 0) { + action = ACTION_SOUTH; + height = 0; + } + + break; + + case ACTION_WEST: + if (range.x <= 0 && (left <= minLeft || aspectRatio && + (top <= minTop || bottom >= maxHeight))) { + + renderable = false; + break; + } + + width -= range.x; + left += range.x; + + if (aspectRatio) { + height = width / aspectRatio; + top += range.Y / 2; + } + + if (width < 0) { + action = ACTION_EAST; + width = 0; + } + + break; + + case ACTION_SOUTH: + if (range.y >= 0 && (bottom >= maxHeight || aspectRatio && + (left <= minLeft || right >= maxWidth))) { + + renderable = false; + break; + } + + height += range.y; + + if (aspectRatio) { + width = height * aspectRatio; + left -= range.X / 2; + } + + if (height < 0) { + action = ACTION_NORTH; + height = 0; + } + + break; + + case ACTION_NORTH_EAST: + if (aspectRatio) { + if (range.y <= 0 && (top <= minTop || right >= maxWidth)) { + renderable = false; + break; + } + + height -= range.y; + top += range.y; + width = height * aspectRatio; + } else { + if (range.x >= 0) { + if (right < maxWidth) { + width += range.x; + } else if (range.y <= 0 && top <= minTop) { + renderable = false; + } + } else { + width += range.x; + } + + if (range.y <= 0) { + if (top > minTop) { + height -= range.y; + top += range.y; + } + } else { + height -= range.y; + top += range.y; + } + } + + if (width < 0 && height < 0) { + action = ACTION_SOUTH_WEST; + height = 0; + width = 0; + } else if (width < 0) { + action = ACTION_NORTH_WEST; + width = 0; + } else if (height < 0) { + action = ACTION_SOUTH_EAST; + height = 0; + } + + break; + + case ACTION_NORTH_WEST: + if (aspectRatio) { + if (range.y <= 0 && (top <= minTop || left <= minLeft)) { + renderable = false; + break; + } + + height -= range.y; + top += range.y; + width = height * aspectRatio; + left += range.X; + } else { + if (range.x <= 0) { + if (left > minLeft) { + width -= range.x; + left += range.x; + } else if (range.y <= 0 && top <= minTop) { + renderable = false; + } + } else { + width -= range.x; + left += range.x; + } + + if (range.y <= 0) { + if (top > minTop) { + height -= range.y; + top += range.y; + } + } else { + height -= range.y; + top += range.y; + } + } + + if (width < 0 && height < 0) { + action = ACTION_SOUTH_EAST; + height = 0; + width = 0; + } else if (width < 0) { + action = ACTION_NORTH_EAST; + width = 0; + } else if (height < 0) { + action = ACTION_SOUTH_WEST; + height = 0; + } + + break; + + case ACTION_SOUTH_WEST: + if (aspectRatio) { + if (range.x <= 0 && (left <= minLeft || bottom >= maxHeight)) { + renderable = false; + break; + } + + width -= range.x; + left += range.x; + height = width / aspectRatio; + } else { + if (range.x <= 0) { + if (left > minLeft) { + width -= range.x; + left += range.x; + } else if (range.y >= 0 && bottom >= maxHeight) { + renderable = false; + } + } else { + width -= range.x; + left += range.x; + } + + if (range.y >= 0) { + if (bottom < maxHeight) { + height += range.y; + } + } else { + height += range.y; + } + } + + if (width < 0 && height < 0) { + action = ACTION_NORTH_EAST; + height = 0; + width = 0; + } else if (width < 0) { + action = ACTION_SOUTH_EAST; + width = 0; + } else if (height < 0) { + action = ACTION_NORTH_WEST; + height = 0; + } + + break; + + case ACTION_SOUTH_EAST: + if (aspectRatio) { + if (range.x >= 0 && (right >= maxWidth || bottom >= maxHeight)) { + renderable = false; + break; + } + + width += range.x; + height = width / aspectRatio; + } else { + if (range.x >= 0) { + if (right < maxWidth) { + width += range.x; + } else if (range.y >= 0 && bottom >= maxHeight) { + renderable = false; + } + } else { + width += range.x; + } + + if (range.y >= 0) { + if (bottom < maxHeight) { + height += range.y; + } + } else { + height += range.y; + } + } + + if (width < 0 && height < 0) { + action = ACTION_NORTH_WEST; + height = 0; + width = 0; + } else if (width < 0) { + action = ACTION_SOUTH_WEST; + width = 0; + } else if (height < 0) { + action = ACTION_NORTH_EAST; + height = 0; + } + + break; + + // Move canvas + case ACTION_MOVE: + this.move(range.x, range.y); + renderable = false; + break; + + // Zoom canvas + case ACTION_ZOOM: + this.zoom((function (x1, y1, x2, y2) { + var z1 = sqrt(x1 * x1 + y1 * y1); + var z2 = sqrt(x2 * x2 + y2 * y2); + + return (z2 - z1) / z1; + })( + abs(this.startX - this.startX2), + abs(this.startY - this.startY2), + abs(this.endX - this.endX2), + abs(this.endY - this.endY2) + ), event); + this.startX2 = this.endX2; + this.startY2 = this.endY2; + renderable = false; + break; + + // Create crop box + case ACTION_CROP: + if (!range.x || !range.y) { + renderable = false; + break; + } + + offset = this.$cropper.offset(); + left = this.startX - offset.left; + top = this.startY - offset.top; + width = cropBox.minWidth; + height = cropBox.minHeight; + + if (range.x > 0) { + action = range.y > 0 ? ACTION_SOUTH_EAST : ACTION_NORTH_EAST; + } else if (range.x < 0) { + left -= width; + action = range.y > 0 ? ACTION_SOUTH_WEST : ACTION_NORTH_WEST; + } + + if (range.y < 0) { + top -= height; + } + + // Show the crop box if is hidden + if (!this.isCropped) { + this.$cropBox.removeClass(CLASS_HIDDEN); + this.isCropped = true; + + if (this.limited) { + this.limitCropBox(true, true); + } + } + + break; + + // No default + } + + if (renderable) { + cropBox.width = width; + cropBox.height = height; + cropBox.left = left; + cropBox.top = top; + this.action = action; + + this.renderCropBox(); + } + + // Override + this.startX = this.endX; + this.startY = this.endY; + }, + + // Show the crop box manually + crop: function () { + if (!this.isBuilt || this.isDisabled) { + return; + } + + if (!this.isCropped) { + this.isCropped = true; + this.limitCropBox(true, true); + + if (this.options.modal) { + this.$dragBox.addClass(CLASS_MODAL); + } + + this.$cropBox.removeClass(CLASS_HIDDEN); + } + + this.setCropBoxData(this.initialCropBox); + }, + + // Reset the image and crop box to their initial states + reset: function () { + if (!this.isBuilt || this.isDisabled) { + return; + } + + this.image = $.extend({}, this.initialImage); + this.canvas = $.extend({}, this.initialCanvas); + this.cropBox = $.extend({}, this.initialCropBox); + + this.renderCanvas(); + + if (this.isCropped) { + this.renderCropBox(); + } + }, + + // Clear the crop box + clear: function () { + if (!this.isCropped || this.isDisabled) { + return; + } + + $.extend(this.cropBox, { + left: 0, + top: 0, + width: 0, + height: 0 + }); + + this.isCropped = false; + this.renderCropBox(); + + this.limitCanvas(true, true); + + // Render canvas after crop box rendered + this.renderCanvas(); + + this.$dragBox.removeClass(CLASS_MODAL); + this.$cropBox.addClass(CLASS_HIDDEN); + }, + + /** + * Replace the image's src and rebuild the cropper + * + * @param {String} url + */ + replace: function (url) { + if (!this.isDisabled && url) { + if (this.isImg) { + this.isReplaced = true; + this.$element.attr('src', url); + } + + // Clear previous data + this.options.data = null; + this.load(url); + } + }, + + // Enable (unfreeze) the cropper + enable: function () { + if (this.isBuilt) { + this.isDisabled = false; + this.$cropper.removeClass(CLASS_DISABLED); + } + }, + + // Disable (freeze) the cropper + disable: function () { + if (this.isBuilt) { + this.isDisabled = true; + this.$cropper.addClass(CLASS_DISABLED); + } + }, + + // Destroy the cropper and remove the instance from the image + destroy: function () { + var $this = this.$element; + + if (this.isLoaded) { + if (this.isImg && this.isReplaced) { + $this.attr('src', this.originalUrl); + } + + this.unbuild(); + $this.removeClass(CLASS_HIDDEN); + } else { + if (this.isImg) { + $this.off(EVENT_LOAD, this.start); + } else if (this.$clone) { + this.$clone.remove(); + } + } + + $this.removeData(NAMESPACE); + }, + + /** + * Move the canvas with relative offsets + * + * @param {Number} offsetX + * @param {Number} offsetY (optional) + */ + move: function (offsetX, offsetY) { + var canvas = this.canvas; + + this.moveTo( + isUndefined(offsetX) ? offsetX : canvas.left + num(offsetX), + isUndefined(offsetY) ? offsetY : canvas.top + num(offsetY) + ); + }, + + /** + * Move the canvas to an absolute point + * + * @param {Number} x + * @param {Number} y (optional) + */ + moveTo: function (x, y) { + var canvas = this.canvas; + var isChanged = false; + + // If "y" is not present, its default value is "x" + if (isUndefined(y)) { + y = x; + } + + x = num(x); + y = num(y); + + if (this.isBuilt && !this.isDisabled && this.options.movable) { + if (isNumber(x)) { + canvas.left = x; + isChanged = true; + } + + if (isNumber(y)) { + canvas.top = y; + isChanged = true; + } + + if (isChanged) { + this.renderCanvas(true); + } + } + }, + + /** + * Zoom the canvas with a relative ratio + * + * @param {Number} ratio + * @param {jQuery Event} _event (private) + */ + zoom: function (ratio, _event) { + var canvas = this.canvas; + + ratio = num(ratio); + + if (ratio < 0) { + ratio = 1 / (1 - ratio); + } else { + ratio = 1 + ratio; + } + + this.zoomTo(canvas.width * ratio / canvas.naturalWidth, _event); + }, + + /** + * Zoom the canvas to an absolute ratio + * + * @param {Number} ratio + * @param {jQuery Event} _event (private) + */ + zoomTo: function (ratio, _event) { + var options = this.options; + var canvas = this.canvas; + var width = canvas.width; + var height = canvas.height; + var naturalWidth = canvas.naturalWidth; + var naturalHeight = canvas.naturalHeight; + var originalEvent; + var newWidth; + var newHeight; + var offset; + var center; + + ratio = num(ratio); + + if (ratio >= 0 && this.isBuilt && !this.isDisabled && options.zoomable) { + newWidth = naturalWidth * ratio; + newHeight = naturalHeight * ratio; + + if (_event) { + originalEvent = _event.originalEvent; + } + + if (this.trigger(EVENT_ZOOM, { + originalEvent: originalEvent, + oldRatio: width / naturalWidth, + ratio: newWidth / naturalWidth + }).isDefaultPrevented()) { + return; + } + + if (originalEvent) { + offset = this.$cropper.offset(); + center = originalEvent.touches ? getTouchesCenter(originalEvent.touches) : { + pageX: _event.pageX || originalEvent.pageX || 0, + pageY: _event.pageY || originalEvent.pageY || 0 + }; + + // Zoom from the triggering point of the event + canvas.left -= (newWidth - width) * ( + ((center.pageX - offset.left) - canvas.left) / width + ); + canvas.top -= (newHeight - height) * ( + ((center.pageY - offset.top) - canvas.top) / height + ); + } else { + + // Zoom from the center of the canvas + canvas.left -= (newWidth - width) / 2; + canvas.top -= (newHeight - height) / 2; + } + + canvas.width = newWidth; + canvas.height = newHeight; + this.renderCanvas(true); + } + }, + + /** + * Rotate the canvas with a relative degree + * + * @param {Number} degree + */ + rotate: function (degree) { + this.rotateTo((this.image.rotate || 0) + num(degree)); + }, + + /** + * Rotate the canvas to an absolute degree + * https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function#rotate() + * + * @param {Number} degree + */ + rotateTo: function (degree) { + degree = num(degree); + + if (isNumber(degree) && this.isBuilt && !this.isDisabled && this.options.rotatable) { + this.image.rotate = degree % 360; + this.isRotated = true; + this.renderCanvas(true); + } + }, + + /** + * Scale the image + * https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function#scale() + * + * @param {Number} scaleX + * @param {Number} scaleY (optional) + */ + scale: function (scaleX, scaleY) { + var image = this.image; + var isChanged = false; + + // If "scaleY" is not present, its default value is "scaleX" + if (isUndefined(scaleY)) { + scaleY = scaleX; + } + + scaleX = num(scaleX); + scaleY = num(scaleY); + + if (this.isBuilt && !this.isDisabled && this.options.scalable) { + if (isNumber(scaleX)) { + image.scaleX = scaleX; + isChanged = true; + } + + if (isNumber(scaleY)) { + image.scaleY = scaleY; + isChanged = true; + } + + if (isChanged) { + this.renderImage(true); + } + } + }, + + /** + * Scale the abscissa of the image + * + * @param {Number} scaleX + */ + scaleX: function (scaleX) { + var scaleY = this.image.scaleY; + + this.scale(scaleX, isNumber(scaleY) ? scaleY : 1); + }, + + /** + * Scale the ordinate of the image + * + * @param {Number} scaleY + */ + scaleY: function (scaleY) { + var scaleX = this.image.scaleX; + + this.scale(isNumber(scaleX) ? scaleX : 1, scaleY); + }, + + /** + * Get the cropped area position and size data (base on the original image) + * + * @param {Boolean} isRounded (optional) + * @return {Object} data + */ + getData: function (isRounded) { + var options = this.options; + var image = this.image; + var canvas = this.canvas; + var cropBox = this.cropBox; + var ratio; + var data; + + if (this.isBuilt && this.isCropped) { + data = { + x: cropBox.left - canvas.left, + y: cropBox.top - canvas.top, + width: cropBox.width, + height: cropBox.height + }; + + ratio = image.width / image.naturalWidth; + + $.each(data, function (i, n) { + n = n / ratio; + data[i] = isRounded ? round(n) : n; + }); + + } else { + data = { + x: 0, + y: 0, + width: 0, + height: 0 + }; + } + + if (options.rotatable) { + data.rotate = image.rotate || 0; + } + + if (options.scalable) { + data.scaleX = image.scaleX || 1; + data.scaleY = image.scaleY || 1; + } + + return data; + }, + + /** + * Set the cropped area position and size with new data + * + * @param {Object} data + */ + setData: function (data) { + var options = this.options; + var image = this.image; + var canvas = this.canvas; + var cropBoxData = {}; + var isRotated; + var isScaled; + var ratio; + + if ($.isFunction(data)) { + data = data.call(this.element); + } + + if (this.isBuilt && !this.isDisabled && $.isPlainObject(data)) { + if (options.rotatable) { + if (isNumber(data.rotate) && data.rotate !== image.rotate) { + image.rotate = data.rotate; + this.isRotated = isRotated = true; + } + } + + if (options.scalable) { + if (isNumber(data.scaleX) && data.scaleX !== image.scaleX) { + image.scaleX = data.scaleX; + isScaled = true; + } + + if (isNumber(data.scaleY) && data.scaleY !== image.scaleY) { + image.scaleY = data.scaleY; + isScaled = true; + } + } + + if (isRotated) { + this.renderCanvas(); + } else if (isScaled) { + this.renderImage(); + } + + ratio = image.width / image.naturalWidth; + + if (isNumber(data.x)) { + cropBoxData.left = data.x * ratio + canvas.left; + } + + if (isNumber(data.y)) { + cropBoxData.top = data.y * ratio + canvas.top; + } + + if (isNumber(data.width)) { + cropBoxData.width = data.width * ratio; + } + + if (isNumber(data.height)) { + cropBoxData.height = data.height * ratio; + } + + this.setCropBoxData(cropBoxData); + } + }, + + /** + * Get the container size data + * + * @return {Object} data + */ + getContainerData: function () { + return this.isBuilt ? this.container : {}; + }, + + /** + * Get the image position and size data + * + * @return {Object} data + */ + getImageData: function () { + return this.isLoaded ? this.image : {}; + }, + + /** + * Get the canvas position and size data + * + * @return {Object} data + */ + getCanvasData: function () { + var canvas = this.canvas; + var data = {}; + + if (this.isBuilt) { + $.each([ + 'left', + 'top', + 'width', + 'height', + 'naturalWidth', + 'naturalHeight' + ], function (i, n) { + data[n] = canvas[n]; + }); + } + + return data; + }, + + /** + * Set the canvas position and size with new data + * + * @param {Object} data + */ + setCanvasData: function (data) { + var canvas = this.canvas; + var aspectRatio = canvas.aspectRatio; + + if ($.isFunction(data)) { + data = data.call(this.$element); + } + + if (this.isBuilt && !this.isDisabled && $.isPlainObject(data)) { + if (isNumber(data.left)) { + canvas.left = data.left; + } + + if (isNumber(data.top)) { + canvas.top = data.top; + } + + if (isNumber(data.width)) { + canvas.width = data.width; + canvas.height = data.width / aspectRatio; + } else if (isNumber(data.height)) { + canvas.height = data.height; + canvas.width = data.height * aspectRatio; + } + + this.renderCanvas(true); + } + }, + + /** + * Get the crop box position and size data + * + * @return {Object} data + */ + getCropBoxData: function () { + var cropBox = this.cropBox; + var data; + + if (this.isBuilt && this.isCropped) { + data = { + left: cropBox.left, + top: cropBox.top, + width: cropBox.width, + height: cropBox.height + }; + } + + return data || {}; + }, + + /** + * Set the crop box position and size with new data + * + * @param {Object} data + */ + setCropBoxData: function (data) { + var cropBox = this.cropBox; + var aspectRatio = this.options.aspectRatio; + var isWidthChanged; + var isHeightChanged; + + if ($.isFunction(data)) { + data = data.call(this.$element); + } + + if (this.isBuilt && this.isCropped && !this.isDisabled && $.isPlainObject(data)) { + + if (isNumber(data.left)) { + cropBox.left = data.left; + } + + if (isNumber(data.top)) { + cropBox.top = data.top; + } + + if (isNumber(data.width)) { + isWidthChanged = true; + cropBox.width = data.width; + } + + if (isNumber(data.height)) { + isHeightChanged = true; + cropBox.height = data.height; + } + + if (aspectRatio) { + if (isWidthChanged) { + cropBox.height = cropBox.width / aspectRatio; + } else if (isHeightChanged) { + cropBox.width = cropBox.height * aspectRatio; + } + } + + this.renderCropBox(); + } + }, + + /** + * Get a canvas drawn the cropped image + * + * @param {Object} options (optional) + * @return {HTMLCanvasElement} canvas + */ + getCroppedCanvas: function (options) { + var originalWidth; + var originalHeight; + var canvasWidth; + var canvasHeight; + var scaledWidth; + var scaledHeight; + var scaledRatio; + var aspectRatio; + var canvas; + var context; + var data; + + if (!this.isBuilt || !this.isCropped || !SUPPORT_CANVAS) { + return; + } + + if (!$.isPlainObject(options)) { + options = {}; + } + + data = this.getData(); + originalWidth = data.width; + originalHeight = data.height; + aspectRatio = originalWidth / originalHeight; + + if ($.isPlainObject(options)) { + scaledWidth = options.width; + scaledHeight = options.height; + + if (scaledWidth) { + scaledHeight = scaledWidth / aspectRatio; + scaledRatio = scaledWidth / originalWidth; + } else if (scaledHeight) { + scaledWidth = scaledHeight * aspectRatio; + scaledRatio = scaledHeight / originalHeight; + } + } + + // The canvas element will use `Math.floor` on a float number, so floor first + canvasWidth = floor(scaledWidth || originalWidth); + canvasHeight = floor(scaledHeight || originalHeight); + + canvas = $('<canvas>')[0]; + canvas.width = canvasWidth; + canvas.height = canvasHeight; + context = canvas.getContext('2d'); + + if (options.fillColor) { + context.fillStyle = options.fillColor; + context.fillRect(0, 0, canvasWidth, canvasHeight); + } + + // https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D.drawImage + context.drawImage.apply(context, (function () { + var source = getSourceCanvas(this.$clone[0], this.image); + var sourceWidth = source.width; + var sourceHeight = source.height; + var args = [source]; + + // Source canvas + var srcX = data.x; + var srcY = data.y; + var srcWidth; + var srcHeight; + + // Destination canvas + var dstX; + var dstY; + var dstWidth; + var dstHeight; + + if (srcX <= -originalWidth || srcX > sourceWidth) { + srcX = srcWidth = dstX = dstWidth = 0; + } else if (srcX <= 0) { + dstX = -srcX; + srcX = 0; + srcWidth = dstWidth = min(sourceWidth, originalWidth + srcX); + } else if (srcX <= sourceWidth) { + dstX = 0; + srcWidth = dstWidth = min(originalWidth, sourceWidth - srcX); + } + + if (srcWidth <= 0 || srcY <= -originalHeight || srcY > sourceHeight) { + srcY = srcHeight = dstY = dstHeight = 0; + } else if (srcY <= 0) { + dstY = -srcY; + srcY = 0; + srcHeight = dstHeight = min(sourceHeight, originalHeight + srcY); + } else if (srcY <= sourceHeight) { + dstY = 0; + srcHeight = dstHeight = min(originalHeight, sourceHeight - srcY); + } + + // All the numerical parameters should be integer for `drawImage` (#476) + args.push(floor(srcX), floor(srcY), floor(srcWidth), floor(srcHeight)); + + // Scale destination sizes + if (scaledRatio) { + dstX *= scaledRatio; + dstY *= scaledRatio; + dstWidth *= scaledRatio; + dstHeight *= scaledRatio; + } + + // Avoid "IndexSizeError" in IE and Firefox + if (dstWidth > 0 && dstHeight > 0) { + args.push(floor(dstX), floor(dstY), floor(dstWidth), floor(dstHeight)); + } + + return args; + }).call(this)); + + return canvas; + }, + + /** + * Change the aspect ratio of the crop box + * + * @param {Number} aspectRatio + */ + setAspectRatio: function (aspectRatio) { + var options = this.options; + + if (!this.isDisabled && !isUndefined(aspectRatio)) { + + // 0 -> NaN + options.aspectRatio = max(0, aspectRatio) || NaN; + + if (this.isBuilt) { + this.initCropBox(); + + if (this.isCropped) { + this.renderCropBox(); + } + } + } + }, + + /** + * Change the drag mode + * + * @param {String} mode (optional) + */ + setDragMode: function (mode) { + var options = this.options; + var croppable; + var movable; + + if (this.isLoaded && !this.isDisabled) { + croppable = mode === ACTION_CROP; + movable = options.movable && mode === ACTION_MOVE; + mode = (croppable || movable) ? mode : ACTION_NONE; + + this.$dragBox. + data(DATA_ACTION, mode). + toggleClass(CLASS_CROP, croppable). + toggleClass(CLASS_MOVE, movable); + + if (!options.cropBoxMovable) { + + // Sync drag mode to crop box when it is not movable(#300) + this.$face. + data(DATA_ACTION, mode). + toggleClass(CLASS_CROP, croppable). + toggleClass(CLASS_MOVE, movable); + } + } + } + }; + + Cropper.DEFAULTS = { + + // Define the view mode of the cropper + viewMode: 0, // 0, 1, 2, 3 + + // Define the dragging mode of the cropper + dragMode: 'crop', // 'crop', 'move' or 'none' + + // Define the aspect ratio of the crop box + aspectRatio: NaN, + + // An object with the previous cropping result data + data: null, + + // A jQuery selector for adding extra containers to preview + preview: '', + + // Re-render the cropper when resize the window + responsive: true, + + // Restore the cropped area after resize the window + restore: true, + + // Check if the current image is a cross-origin image + checkCrossOrigin: true, + + // Check the current image's Exif Orientation information + checkOrientation: true, + + // Show the black modal + modal: true, + + // Show the dashed lines for guiding + guides: true, + + // Show the center indicator for guiding + center: true, + + // Show the white modal to highlight the crop box + highlight: true, + + // Show the grid background + background: true, + + // Enable to crop the image automatically when initialize + autoCrop: true, + + // Define the percentage of automatic cropping area when initializes + autoCropArea: 0.8, + + // Enable to move the image + movable: true, + + // Enable to rotate the image + rotatable: true, + + // Enable to scale the image + scalable: true, + + // Enable to zoom the image + zoomable: true, + + // Enable to zoom the image by dragging touch + zoomOnTouch: true, + + // Enable to zoom the image by wheeling mouse + zoomOnWheel: true, + + // Define zoom ratio when zoom the image by wheeling mouse + wheelZoomRatio: 0.1, + + // Enable to move the crop box + cropBoxMovable: true, + + // Enable to resize the crop box + cropBoxResizable: true, + + // Toggle drag mode between "crop" and "move" when click twice on the cropper + toggleDragModeOnDblclick: true, + + // Size limitation + minCanvasWidth: 0, + minCanvasHeight: 0, + minCropBoxWidth: 0, + minCropBoxHeight: 0, + minContainerWidth: 200, + minContainerHeight: 100, + + // Shortcuts of events + build: null, + built: null, + cropstart: null, + cropmove: null, + cropend: null, + crop: null, + zoom: null + }; + + Cropper.setDefaults = function (options) { + $.extend(Cropper.DEFAULTS, options); + }; + + Cropper.TEMPLATE = ( + '<div class="cropper-container">' + + '<div class="cropper-wrap-box">' + + '<div class="cropper-canvas"></div>' + + '</div>' + + '<div class="cropper-drag-box"></div>' + + '<div class="cropper-crop-box">' + + '<span class="cropper-view-box"></span>' + + '<span class="cropper-dashed dashed-h"></span>' + + '<span class="cropper-dashed dashed-v"></span>' + + '<span class="cropper-center"></span>' + + '<span class="cropper-face"></span>' + + '<span class="cropper-line line-e" data-action="e"></span>' + + '<span class="cropper-line line-n" data-action="n"></span>' + + '<span class="cropper-line line-w" data-action="w"></span>' + + '<span class="cropper-line line-s" data-action="s"></span>' + + '<span class="cropper-point point-e" data-action="e"></span>' + + '<span class="cropper-point point-n" data-action="n"></span>' + + '<span class="cropper-point point-w" data-action="w"></span>' + + '<span class="cropper-point point-s" data-action="s"></span>' + + '<span class="cropper-point point-ne" data-action="ne"></span>' + + '<span class="cropper-point point-nw" data-action="nw"></span>' + + '<span class="cropper-point point-sw" data-action="sw"></span>' + + '<span class="cropper-point point-se" data-action="se"></span>' + + '</div>' + + '</div>' + ); + + // Save the other cropper + Cropper.other = $.fn.cropper; + + // Register as jQuery plugin + $.fn.cropper = function (option) { + var args = toArray(arguments, 1); + var result; + + this.each(function () { + var $this = $(this); + var data = $this.data(NAMESPACE); + var options; + var fn; + + if (!data) { + if (/destroy/.test(option)) { + return; + } + + options = $.extend({}, $this.data(), $.isPlainObject(option) && option); + $this.data(NAMESPACE, (data = new Cropper(this, options))); + } + + if (typeof option === 'string' && $.isFunction(fn = data[option])) { + result = fn.apply(data, args); + } + }); + + return isUndefined(result) ? this : result; + }; + + $.fn.cropper.Constructor = Cropper; + $.fn.cropper.setDefaults = Cropper.setDefaults; + + // No conflict + $.fn.cropper.noConflict = function () { + $.fn.cropper = Cropper.other; + return this; + }; + +}); diff --git a/vendor/assets/stylesheets/cropper.css b/vendor/assets/stylesheets/cropper.css new file mode 100755 index 0000000000000000000000000000000000000000..41ee4bd546c8dd405c26b47272efe4d90f467b67 --- /dev/null +++ b/vendor/assets/stylesheets/cropper.css @@ -0,0 +1,379 @@ +/*! + * Cropper v2.2.5 + * https://github.com/fengyuanchen/cropper + * + * Copyright (c) 2014-2016 Fengyuan Chen and contributors + * Released under the MIT license + * + * Date: 2016-01-18T05:42:29.639Z + */ +.cropper-container { + font-size: 0; + line-height: 0; + + position: relative; + + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + + direction: ltr !important; + -ms-touch-action: none; + touch-action: none; + -webkit-tap-highlight-color: transparent; + -webkit-touch-callout: none; +} + +.cropper-container img { + display: block; + + width: 100%; + min-width: 0 !important; + max-width: none !important; + height: 100%; + min-height: 0 !important; + max-height: none !important; + + image-orientation: 0deg !important; +} + +.cropper-wrap-box, +.cropper-canvas, +.cropper-drag-box, +.cropper-crop-box, +.cropper-modal { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; +} + +.cropper-wrap-box { + overflow: hidden; +} + +.cropper-drag-box { + opacity: 0; + background-color: #fff; + + filter: alpha(opacity=0); +} + +.cropper-modal { + opacity: .5; + background-color: #000; + + filter: alpha(opacity=50); +} + +.cropper-view-box { + display: block; + overflow: hidden; + + width: 100%; + height: 100%; + + outline: 1px solid #39f; + outline-color: rgba(51, 153, 255, .75); +} + +.cropper-dashed { + position: absolute; + + display: block; + + opacity: .5; + border: 0 dashed #eee; + + filter: alpha(opacity=50); +} + +.cropper-dashed.dashed-h { + top: 33.33333%; + left: 0; + + width: 100%; + height: 33.33333%; + + border-top-width: 1px; + border-bottom-width: 1px; +} + +.cropper-dashed.dashed-v { + top: 0; + left: 33.33333%; + + width: 33.33333%; + height: 100%; + + border-right-width: 1px; + border-left-width: 1px; +} + +.cropper-center { + position: absolute; + top: 50%; + left: 50%; + + display: block; + + width: 0; + height: 0; + + opacity: .75; + + filter: alpha(opacity=75); +} + +.cropper-center:before, +.cropper-center:after { + position: absolute; + + display: block; + + content: ' '; + + background-color: #eee; +} + +.cropper-center:before { + top: 0; + left: -3px; + + width: 7px; + height: 1px; +} + +.cropper-center:after { + top: -3px; + left: 0; + + width: 1px; + height: 7px; +} + +.cropper-face, +.cropper-line, +.cropper-point { + position: absolute; + + display: block; + + width: 100%; + height: 100%; + + opacity: .1; + + filter: alpha(opacity=10); +} + +.cropper-face { + top: 0; + left: 0; + + background-color: #fff; +} + +.cropper-line { + background-color: #39f; +} + +.cropper-line.line-e { + top: 0; + right: -3px; + + width: 5px; + + cursor: e-resize; +} + +.cropper-line.line-n { + top: -3px; + left: 0; + + height: 5px; + + cursor: n-resize; +} + +.cropper-line.line-w { + top: 0; + left: -3px; + + width: 5px; + + cursor: w-resize; +} + +.cropper-line.line-s { + bottom: -3px; + left: 0; + + height: 5px; + + cursor: s-resize; +} + +.cropper-point { + width: 5px; + height: 5px; + + opacity: .75; + background-color: #39f; + + filter: alpha(opacity=75); +} + +.cropper-point.point-e { + top: 50%; + right: -3px; + + margin-top: -3px; + + cursor: e-resize; +} + +.cropper-point.point-n { + top: -3px; + left: 50%; + + margin-left: -3px; + + cursor: n-resize; +} + +.cropper-point.point-w { + top: 50%; + left: -3px; + + margin-top: -3px; + + cursor: w-resize; +} + +.cropper-point.point-s { + bottom: -3px; + left: 50%; + + margin-left: -3px; + + cursor: s-resize; +} + +.cropper-point.point-ne { + top: -3px; + right: -3px; + + cursor: ne-resize; +} + +.cropper-point.point-nw { + top: -3px; + left: -3px; + + cursor: nw-resize; +} + +.cropper-point.point-sw { + bottom: -3px; + left: -3px; + + cursor: sw-resize; +} + +.cropper-point.point-se { + right: -3px; + bottom: -3px; + + width: 20px; + height: 20px; + + cursor: se-resize; + + opacity: 1; + + filter: alpha(opacity=100); +} + +.cropper-point.point-se:before { + position: absolute; + right: -50%; + bottom: -50%; + + display: block; + + width: 200%; + height: 200%; + + content: ' '; + + opacity: 0; + background-color: #39f; + + filter: alpha(opacity=0); +} + +@media (min-width: 768px) { + .cropper-point.point-se { + width: 15px; + height: 15px; + } +} + +@media (min-width: 992px) { + .cropper-point.point-se { + width: 10px; + height: 10px; + } +} + +@media (min-width: 1200px) { + .cropper-point.point-se { + width: 5px; + height: 5px; + + opacity: .75; + + filter: alpha(opacity=75); + } +} + +.cropper-invisible { + opacity: 0; + + filter: alpha(opacity=0); +} + +.cropper-bg { + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC'); +} + +.cropper-hide { + position: absolute; + + display: block; + + width: 0; + height: 0; +} + +.cropper-hidden { + display: none !important; +} + +.cropper-move { + cursor: move; +} + +.cropper-crop { + cursor: crosshair; +} + +.cropper-disabled .cropper-drag-box, +.cropper-disabled .cropper-face, +.cropper-disabled .cropper-line, +.cropper-disabled .cropper-point { + cursor: not-allowed; +}