diff --git a/Jenkinsfile b/Jenkinsfile index c0ca5f33d0..0f586bf346 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -15,7 +15,7 @@ node('docker') { disableConcurrentBuilds() ]) - timeout(activity: true, time: 20, unit: 'MINUTES') { + timeout(activity: true, time: 30, unit: 'MINUTES') { catchError { @@ -26,7 +26,7 @@ node('docker') { } stage('Build') { - mvn 'clean install -DskipTests' + mvn 'clean install -Pdoc -DskipTests' } stage('Unit Test') { @@ -50,6 +50,17 @@ node('docker') { def dockerImageTag = "2.0.0-dev-${commitHash.substring(0,7)}-${BUILD_NUMBER}" if (isMainBranch()) { + +// stage('Lifecycle') { +// nexusPolicyEvaluation iqApplication: selectedApplication('scm'), iqScanPatterns: [[scanPattern: 'scm-server/target/scm-server-app.zip']], iqStage: 'build' +// } + + stage('Archive') { + archiveArtifacts 'scm-webapp/target/scm-webapp.war' + archiveArtifacts 'scm-server/target/scm-server-app.*' + archiveArtifacts 'scm-webapp/target/scm-webapp-restdocs.zip' + } + stage('Docker') { def image = docker.build('cloudogu/scm-manager') docker.withRegistry('', 'hub.docker.com-cesmarvin') { @@ -107,7 +118,8 @@ void analyzeWith(Maven mvn) { "-Dsonar.pullrequest.key=${env.CHANGE_ID} " + "-Dsonar.pullrequest.provider=bitbucketcloud " + "-Dsonar.pullrequest.bitbucketcloud.owner=sdorra " + - "-Dsonar.pullrequest.bitbucketcloud.repository=scm-manager " + "-Dsonar.pullrequest.bitbucketcloud.repository=scm-manager " + + "-Dsonar.cpd.exclusions=**/*StoreFactory.java,**/*UserPassword.js " } else { mvnArgs += " -Dsonar.branch.name=${env.BRANCH_NAME} " if (!isMainBranch()) { diff --git a/deployments/helm/templates/configmap.yaml b/deployments/helm/templates/configmap.yaml index dd52b6fa8c..1ccb773355 100644 --- a/deployments/helm/templates/configmap.yaml +++ b/deployments/helm/templates/configmap.yaml @@ -108,7 +108,7 @@ data: - <-- + diff --git a/deployments/helm/templates/deployment.yaml b/deployments/helm/templates/deployment.yaml index 928daa5f06..7e19f61e57 100644 --- a/deployments/helm/templates/deployment.yaml +++ b/deployments/helm/templates/deployment.yaml @@ -29,6 +29,17 @@ spec: volumeMounts: - name: data mountPath: /data + {{- if .Values.plugins }} + - name: install-plugins + image: alpine:3.8 + imagePullPolicy: IfNotPresent + command: ['sh', '/scripts/install-plugins.sh'] + volumeMounts: + - name: data + mountPath: /data + - name: scripts + mountPath: /scripts + {{- end }} containers: - name: {{ .Chart.Name }} image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" @@ -63,6 +74,11 @@ spec: - name: config configMap: name: {{ include "scm-manager.fullname" . }} + {{- if .Values.plugins }} + - name: scripts + configMap: + name: {{ include "scm-manager.fullname" . }}-scripts + {{- end }} {{- with .Values.nodeSelector }} nodeSelector: {{ toYaml . | indent 8 }} diff --git a/deployments/helm/templates/scripts.yaml b/deployments/helm/templates/scripts.yaml new file mode 100644 index 0000000000..43a442a8e2 --- /dev/null +++ b/deployments/helm/templates/scripts.yaml @@ -0,0 +1,21 @@ +{{- if .Values.plugins }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "scm-manager.fullname" . }}-scripts + labels: + app: {{ include "scm-manager.name" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +data: + install-plugins.sh: | + #!/bin/sh + mkdir -p /data/plugins + chown 1000:1000 /data/plugins + {{ range $i, $plugin := .Values.plugins }} + # install plugin {{ $plugin.name }} + wget -O /data/plugins/{{ $plugin.name }}.smp {{ $plugin.url }} + chown 1000:1000 /data/plugins/{{ $plugin.name }}.smp + {{ end }} +{{- end }} diff --git a/deployments/helm/values.yaml b/deployments/helm/values.yaml index d54088aa8b..0b107a8168 100644 --- a/deployments/helm/values.yaml +++ b/deployments/helm/values.yaml @@ -10,6 +10,10 @@ image: tag: latest pullPolicy: Always +# plugins: +# - name: scm-review-plugin +# url: https://oss.cloudogu.com/jenkins/job/scm-manager/job/scm-manager-bitbucket/job/scm-review-plugin/job/develop/lastSuccessfulBuild/artifact/target/scm-review-plugin-2.0.0-SNAPSHOT.smp + nameOverride: "" fullnameOverride: "" diff --git a/docs/mercurial/clone-empty.md b/docs/mercurial/clone-empty.md new file mode 100644 index 0000000000..44a81de20c --- /dev/null +++ b/docs/mercurial/clone-empty.md @@ -0,0 +1,76 @@ +# Clone empty repository + +```http +GET /scm/hg/hgtest?cmd=capabilities HTTP/1.1. +Accept-Encoding: identity. +accept: application/mercurial-0.1. +authorization: Basic c2NtYWRtaW46c2NtYWRtaW4=. +host: localhost:8080. +user-agent: mercurial/proto-1.0 (Mercurial 4.3.1). + +HTTP/1.1 200 OK. +Set-Cookie: JSESSIONID=1efk0qxy1dj5v133hev91zwsf4;Path=/scm. +Expires: Thu, 01 Jan 1970 00:00:00 GMT. +Set-Cookie: rememberMe=deleteMe; Path=/scm; Max-Age=0; Expires=Wed, 28-Mar-2018 05:57:18 GMT. +Content-Type: application/mercurial-0.1. +Content-Length: 130. +Server: Jetty(7.6.21.v20160908). +. +lookup changegroupsubset branchmap pushkey known getbundle unbundlehash batch stream unbundle=HG10GZ,HG10BZ,HG10UN httpheader=1024 + +GET /scm/hg/hgtest?cmd=listkeys HTTP/1.1. +Accept-Encoding: identity. +vary: X-HgArg-1. +x-hgarg-1: namespace=bookmarks. +accept: application/mercurial-0.1. +authorization: Basic c2NtYWRtaW46c2NtYWRtaW4=. +host: localhost:8080. +user-agent: mercurial/proto-1.0 (Mercurial 4.3.1). + +HTTP/1.1 200 OK. +Set-Cookie: JSESSIONID=1rsxj8u1rq9wizawhyyxok2p5;Path=/scm. +Expires: Thu, 01 Jan 1970 00:00:00 GMT. +Set-Cookie: rememberMe=deleteMe; Path=/scm; Max-Age=0; Expires=Wed, 28-Mar-2018 05:57:18 GMT. +Content-Type: application/mercurial-0.1. +Content-Length: 0. +Server: Jetty(7.6.21.v20160908). + +GET /scm/hg/hgtest?cmd=batch HTTP/1.1. +Accept-Encoding: identity. +vary: X-HgArg-1. +x-hgarg-1: cmds=heads+%3Bknown+nodes%3D. +accept: application/mercurial-0.1. +authorization: Basic c2NtYWRtaW46c2NtYWRtaW4=. +host: localhost:8080. +user-agent: mercurial/proto-1.0 (Mercurial 4.3.1). + +HTTP/1.1 200 OK. +Set-Cookie: JSESSIONID=ewyx4m53d8dajjsob6gxobne;Path=/scm. +Expires: Thu, 01 Jan 1970 00:00:00 GMT. +Set-Cookie: rememberMe=deleteMe; Path=/scm; Max-Age=0; Expires=Wed, 28-Mar-2018 05:57:18 GMT. +Content-Type: application/mercurial-0.1. +Content-Length: 42. +Server: Jetty(7.6.21.v20160908). + +0000000000000000000000000000000000000000 +; + +GET /scm/hg/hgtest?cmd=listkeys HTTP/1.1. +Accept-Encoding: identity. +vary: X-HgArg-1. +x-hgarg-1: namespace=phases. +accept: application/mercurial-0.1. +authorization: Basic c2NtYWRtaW46c2NtYWRtaW4=. +host: localhost:8080. +user-agent: mercurial/proto-1.0 (Mercurial 4.3.1). + +HTTP/1.1 200 OK. +Set-Cookie: JSESSIONID=1o0hou15jtiywsywutf30qwm8;Path=/scm. +Expires: Thu, 01 Jan 1970 00:00:00 GMT. +Set-Cookie: rememberMe=deleteMe; Path=/scm; Max-Age=0; Expires=Wed, 28-Mar-2018 05:57:18 GMT. +Content-Type: application/mercurial-0.1. +Content-Length: 15. +Server: Jetty(7.6.21.v20160908). +. +publishing.True +``` diff --git a/docs/mercurial/push-bookmark.md b/docs/mercurial/push-bookmark.md new file mode 100644 index 0000000000..9ed591f9f4 --- /dev/null +++ b/docs/mercurial/push-bookmark.md @@ -0,0 +1,117 @@ +# Push bookmark + +```http +GET /scm/hg/hgtest?cmd=capabilities HTTP/1.1. +Accept-Encoding: identity. +accept: application/mercurial-0.1. +authorization: Basic c2NtYWRtaW46c2NtYWRtaW4=. +host: localhost:8080. +user-agent: mercurial/proto-1.0 (Mercurial 4.3.1). + +HTTP/1.1 200 OK. +Set-Cookie: JSESSIONID=7rq9vpp9svfm1sicq7h9vetmv;Path=/scm. +Expires: Thu, 01 Jan 1970 00:00:00 GMT. +Set-Cookie: rememberMe=deleteMe; Path=/scm; Max-Age=0; Expires=Wed, 28-Mar-2018 08:08:35 GMT. +Content-Type: application/mercurial-0.1. +Content-Length: 130. +Server: Jetty(7.6.21.v20160908). + +lookup changegroupsubset branchmap pushkey known getbundle unbundlehash batch stream unbundle=HG10GZ,HG10BZ,HG10UN httpheader=1024 + +GET /scm/hg/hgtest?cmd=batch HTTP/1.1. +Accept-Encoding: identity. +vary: X-HgArg-1. +x-hgarg-1: cmds=heads+%3Bknown+nodes%3Def5993bb4abb32a0565c347844c6d939fc4f4b98. +accept: application/mercurial-0.1. +authorization: Basic c2NtYWRtaW46c2NtYWRtaW4=. +host: localhost:8080. +user-agent: mercurial/proto-1.0 (Mercurial 4.3.1). + +T 172.17.0.2:8080 -> 172.17.0.1:36576 [AP] +HTTP/1.1 200 OK. +Set-Cookie: JSESSIONID=1553csz4sf7scyvw8mqnqfirn;Path=/scm. +Expires: Thu, 01 Jan 1970 00:00:00 GMT. +Set-Cookie: rememberMe=deleteMe; Path=/scm; Max-Age=0; Expires=Wed, 28-Mar-2018 08:08:35 GMT. +Content-Type: application/mercurial-0.1. +Content-Length: 43. +Server: Jetty(7.6.21.v20160908). + +ef5993bb4abb32a0565c347844c6d939fc4f4b98 +;1 + +GET /scm/hg/hgtest?cmd=listkeys HTTP/1.1. +Accept-Encoding: identity. +vary: X-HgArg-1. +x-hgarg-1: namespace=phases. +accept: application/mercurial-0.1. +authorization: Basic c2NtYWRtaW46c2NtYWRtaW4=. +host: localhost:8080. +user-agent: mercurial/proto-1.0 (Mercurial 4.3.1). + +HTTP/1.1 200 OK. +Set-Cookie: JSESSIONID=11xa5u3nrmx8k1nar3sazg6jzh;Path=/scm. +Expires: Thu, 01 Jan 1970 00:00:00 GMT. +Set-Cookie: rememberMe=deleteMe; Path=/scm; Max-Age=0; Expires=Wed, 28-Mar-2018 08:08:35 GMT. +Content-Type: application/mercurial-0.1. +Content-Length: 15. +Server: Jetty(7.6.21.v20160908). + +publishing.True + +GET /scm/hg/hgtest?cmd=listkeys HTTP/1.1. +Accept-Encoding: identity. +vary: X-HgArg-1. +x-hgarg-1: namespace=bookmarks. +accept: application/mercurial-0.1. +authorization: Basic c2NtYWRtaW46c2NtYWRtaW4=. +host: localhost:8080. +user-agent: mercurial/proto-1.0 (Mercurial 4.3.1). + +HTTP/1.1 200 OK. +Set-Cookie: JSESSIONID=1p1uzcvfe1pvzh2buzo658rxw;Path=/scm. +Expires: Thu, 01 Jan 1970 00:00:00 GMT. +Set-Cookie: rememberMe=deleteMe; Path=/scm; Max-Age=0; Expires=Wed, 28-Mar-2018 08:08:35 GMT. +Content-Type: application/mercurial-0.1. +Content-Length: 0. +Server: Jetty(7.6.21.v20160908). + +GET /scm/hg/hgtest?cmd=listkeys HTTP/1.1. +Accept-Encoding: identity. +vary: X-HgArg-1. +x-hgarg-1: namespace=phases. +accept: application/mercurial-0.1. +authorization: Basic c2NtYWRtaW46c2NtYWRtaW4=. +host: localhost:8080. +user-agent: mercurial/proto-1.0 (Mercurial 4.3.1). + +HTTP/1.1 200 OK. +Set-Cookie: JSESSIONID=1mhlj3ucfzdp6ifmzoua4zwit;Path=/scm. +Expires: Thu, 01 Jan 1970 00:00:00 GMT. +Set-Cookie: rememberMe=deleteMe; Path=/scm; Max-Age=0; Expires=Wed, 28-Mar-2018 08:08:35 GMT. +Content-Type: application/mercurial-0.1. +Content-Length: 15. +Server: Jetty(7.6.21.v20160908). + +publishing.True + +POST /scm/hg/hgtest?cmd=pushkey HTTP/1.1. +Accept-Encoding: identity. +content-type: application/mercurial-0.1. +vary: X-HgArg-1. +x-hgarg-1: key=markone&namespace=bookmarks&new=ef5993bb4abb32a0565c347844c6d939fc4f4b98&old=. +accept: application/mercurial-0.1. +authorization: Basic c2NtYWRtaW46c2NtYWRtaW4=. +content-length: 0. +host: localhost:8080. +user-agent: mercurial/proto-1.0 (Mercurial 4.3.1). + +HTTP/1.1 200 OK. +Set-Cookie: JSESSIONID=s4vtagb303dv1xg809wnp7e8z;Path=/scm. +Expires: Thu, 01 Jan 1970 00:00:00 GMT. +Set-Cookie: rememberMe=deleteMe; Path=/scm; Max-Age=0; Expires=Wed, 28-Mar-2018 08:08:35 GMT. +Content-Type: application/mercurial-0.1. +Content-Length: 2. +Server: Jetty(7.6.21.v20160908). +. +1 +``` diff --git a/docs/mercurial/push-multiple-branches-to-new.md b/docs/mercurial/push-multiple-branches-to-new.md new file mode 100644 index 0000000000..734c479fef --- /dev/null +++ b/docs/mercurial/push-multiple-branches-to-new.md @@ -0,0 +1,167 @@ +# Push multiple branches to new repository + +```http +GET /scm/hg/hgtest?cmd=capabilities HTTP/1.1. +Accept-Encoding: identity. +accept: application/mercurial-0.1. +authorization: Basic c2NtYWRtaW46c2NtYWRtaW4=. +host: localhost:8080. +user-agent: mercurial/proto-1.0 (Mercurial 4.3.1). + +HTTP/1.1 200 OK. +Set-Cookie: JSESSIONID=1wu06ykfd4bcv1uv731y4hss2m;Path=/scm. +Expires: Thu, 01 Jan 1970 00:00:00 GMT. +Set-Cookie: rememberMe=deleteMe; Path=/scm; Max-Age=0; Expires=Wed, 28-Mar-2018 07:55:14 GMT. +Content-Type: application/mercurial-0.1. +Content-Length: 130. +Server: Jetty(7.6.21.v20160908). + +lookup changegroupsubset branchmap pushkey known getbundle unbundlehash batch stream unbundle=HG10GZ,HG10BZ,HG10UN httpheader=1024 + +GET /scm/hg/hgtest?cmd=batch HTTP/1.1. +Accept-Encoding: identity. +vary: X-HgArg-1. +x-hgarg-1: cmds=heads+%3Bknown+nodes%3Def5993bb4abb32a0565c347844c6d939fc4f4b98. +accept: application/mercurial-0.1. +authorization: Basic c2NtYWRtaW46c2NtYWRtaW4=. +host: localhost:8080. +user-agent: mercurial/proto-1.0 (Mercurial 4.3.1). + +HTTP/1.1 200 OK. +Set-Cookie: JSESSIONID=1rajglvqx222g5nppcq3jdfk0;Path=/scm. +Expires: Thu, 01 Jan 1970 00:00:00 GMT. +Set-Cookie: rememberMe=deleteMe; Path=/scm; Max-Age=0; Expires=Wed, 28-Mar-2018 07:55:14 GMT. +Content-Type: application/mercurial-0.1. +Content-Length: 43. +Server: Jetty(7.6.21.v20160908). + +0000000000000000000000000000000000000000 +;0 + +GET /scm/hg/hgtest?cmd=known HTTP/1.1. +Accept-Encoding: identity. +vary: X-HgArg-1. +x-hgarg-1: nodes=c0ceccb3b2f0f5c977ff32b9337519e5f37942c2+187ddf37e237c370514487a0bb1a226f11a780b3+b5914611f84eae14543684b2721eec88b0edac12+8b63a323606f10c86b30465570c2574eb7a3a989. +accept: application/mercurial-0.1. +authorization: Basic c2NtYWRtaW46c2NtYWRtaW4=. +host: localhost:8080. +user-agent: mercurial/proto-1.0 (Mercurial 4.3.1). + +HTTP/1.1 200 OK. +Set-Cookie: JSESSIONID=a5vykp1f0ga2186l8v3gu6lid;Path=/scm. +Expires: Thu, 01 Jan 1970 00:00:00 GMT. +Set-Cookie: rememberMe=deleteMe; Path=/scm; Max-Age=0; Expires=Wed, 28-Mar-2018 07:55:14 GMT. +Content-Type: application/mercurial-0.1. +Content-Length: 4. +Server: Jetty(7.6.21.v20160908). + +0000 + +GET /scm/hg/hgtest?cmd=listkeys HTTP/1.1. +Accept-Encoding: identity. +vary: X-HgArg-1. +x-hgarg-1: namespace=phases. +accept: application/mercurial-0.1. +authorization: Basic c2NtYWRtaW46c2NtYWRtaW4=. +host: localhost:8080. +user-agent: mercurial/proto-1.0 (Mercurial 4.3.1). + +HTTP/1.1 200 OK. +Set-Cookie: JSESSIONID=s8lpwqm4c2nqs9kwcg2ca6vm;Path=/scm. +Expires: Thu, 01 Jan 1970 00:00:00 GMT. +Set-Cookie: rememberMe=deleteMe; Path=/scm; Max-Age=0; Expires=Wed, 28-Mar-2018 07:55:14 GMT. +Content-Type: application/mercurial-0.1. +Content-Length: 15. +Server: Jetty(7.6.21.v20160908). + +publishing.True + +GET /scm/hg/hgtest?cmd=listkeys HTTP/1.1. +Accept-Encoding: identity. +vary: X-HgArg-1. +x-hgarg-1: namespace=bookmarks. +accept: application/mercurial-0.1. +authorization: Basic c2NtYWRtaW46c2NtYWRtaW4=. +host: localhost:8080. +user-agent: mercurial/proto-1.0 (Mercurial 4.3.1). + +HTTP/1.1 200 OK. +Set-Cookie: JSESSIONID=1d2qj3kynxlhvk31oli4kk7vf;Path=/scm. +Expires: Thu, 01 Jan 1970 00:00:00 GMT. +Set-Cookie: rememberMe=deleteMe; Path=/scm; Max-Age=0; Expires=Wed, 28-Mar-2018 07:55:14 GMT. +Content-Type: application/mercurial-0.1. +Content-Length: 0. +Server: Jetty(7.6.21.v20160908). + +POST /scm/hg/hgtest?cmd=unbundle HTTP/1.1. +Accept-Encoding: identity. +content-type: application/mercurial-0.1. +vary: X-HgArg-1. +x-hgarg-1: heads=686173686564+6768033e216468247bd031a0a2d9876d79818f8f. +accept: application/mercurial-0.1. +authorization: Basic c2NtYWRtaW46c2NtYWRtaW4=. +content-length: 913. +host: localhost:8080. +user-agent: mercurial/proto-1.0 (Mercurial 4.3.1). + +HG10GZx...oh.U......E.1.....2q.<...s.1.YK*e#..b..{....{..%A..... +,\.....Y.XV....Q/J......`Q/.z.{...<.7....r.s.~?.?..5.~`..?..........O.j.0.....Ih.....!@.P... ..a +;!y..cT...]q.8Zg=...<..,.tq.*.........l........';..w^...w...-......Co..Fs.HYg... +9.F#.P......1..;......D.H.9$@.^....r:E..18...H....3..h...-.=.6l......=q .)."Yg..p\...s@.#.H.*....c8&96..2.GjJ.`.J....r...=Q1..@R.3.o{q...|.......yq.k..,cY..:[... ...S.2...VYp..c5..&.SFR.............V.d..o..........,.. A..M....k...0_.LO1..1"4.;...B....5.9.".U.m.e......]\../p..;?C..W9.........n.~o..gW...Q;..$....S..X.CN.5I].H..!.@...U..J...L.lY.../.-...6.:.Q.'...>.e'..<#3........OL}.52ra[..g*Y:Y....w...=..Z\...S.......tz..;..mf...W......&yUN.r.......4...........`..F...nT..U9................_.~..?...BwzUN.r....B. + +HTTP/1.1 200 OK. +Set-Cookie: JSESSIONID=163487i0ayf9s1k2ng9e1azadj;Path=/scm. +Expires: Thu, 01 Jan 1970 00:00:00 GMT. +Set-Cookie: rememberMe=deleteMe; Path=/scm; Max-Age=0; Expires=Wed, 28-Mar-2018 07:55:14 GMT. +Content-Type: application/mercurial-0.1. +Content-Length: 102. +Server: Jetty(7.6.21.v20160908). + +1 +adding changesets +adding manifests +adding file changes +added 5 changesets with 3 changes to 3 files + +GET /scm/hg/hgtest?cmd=listkeys HTTP/1.1. +Accept-Encoding: identity. +vary: X-HgArg-1. +x-hgarg-1: namespace=phases. +accept: application/mercurial-0.1. +authorization: Basic c2NtYWRtaW46c2NtYWRtaW4=. +host: localhost:8080. +user-agent: mercurial/proto-1.0 (Mercurial 4.3.1). + +HTTP/1.1 200 OK. +Set-Cookie: JSESSIONID=a3i712yjss6t1xsxltnssq0tl;Path=/scm. +Expires: Thu, 01 Jan 1970 00:00:00 GMT. +Set-Cookie: rememberMe=deleteMe; Path=/scm; Max-Age=0; Expires=Wed, 28-Mar-2018 07:55:14 GMT. +Content-Type: application/mercurial-0.1. +Content-Length: 58. +Server: Jetty(7.6.21.v20160908). + +c0ceccb3b2f0f5c977ff32b9337519e5f37942c2.1 +publishing.True + +POST /scm/hg/hgtest?cmd=pushkey HTTP/1.1. +Accept-Encoding: identity. +content-type: application/mercurial-0.1. +vary: X-HgArg-1. +x-hgarg-1: key=ef5993bb4abb32a0565c347844c6d939fc4f4b98&namespace=phases&new=0&old=1. +accept: application/mercurial-0.1. +authorization: Basic c2NtYWRtaW46c2NtYWRtaW4=. +content-length: 0. +host: localhost:8080. +user-agent: mercurial/proto-1.0 (Mercurial 4.3.1). + +HTTP/1.1 200 OK. +Set-Cookie: JSESSIONID=g8cavdze42d83knmuasrlg10;Path=/scm. +Expires: Thu, 01 Jan 1970 00:00:00 GMT. +Set-Cookie: rememberMe=deleteMe; Path=/scm; Max-Age=0; Expires=Wed, 28-Mar-2018 07:55:14 GMT. +Content-Type: application/mercurial-0.1. +Content-Length: 2. +Server: Jetty(7.6.21.v20160908). +. +1 +``` diff --git a/docs/mercurial/push-multiple-branches.md b/docs/mercurial/push-multiple-branches.md new file mode 100644 index 0000000000..5827cb0ceb --- /dev/null +++ b/docs/mercurial/push-multiple-branches.md @@ -0,0 +1,183 @@ +# Push multiple branches + +```http +GET /scm/hg/hgtest?cmd=capabilities HTTP/1.1. +Accept-Encoding: identity. +accept: application/mercurial-0.1. +authorization: Basic c2NtYWRtaW46c2NtYWRtaW4=. +host: localhost:8080. +user-agent: mercurial/proto-1.0 (Mercurial 4.3.1). + +HTTP/1.1 200 OK. +Set-Cookie: JSESSIONID=1mvm1rxg8333iib7754ksusxc;Path=/scm. +Expires: Thu, 01 Jan 1970 00:00:00 GMT. +Set-Cookie: rememberMe=deleteMe; Path=/scm; Max-Age=0; Expires=Wed, 28-Mar-2018 06:16:50 GMT. +Content-Type: application/mercurial-0.1. +Content-Length: 130. +Server: Jetty(7.6.21.v20160908). + +lookup changegroupsubset branchmap pushkey known getbundle unbundlehash batch stream unbundle=HG10GZ,HG10BZ,HG10UN httpheader=1024 + +GET /scm/hg/hgtest?cmd=batch HTTP/1.1. +Accept-Encoding: identity. +vary: X-HgArg-1. +x-hgarg-1: cmds=heads+%3Bknown+nodes%3Def5993bb4abb32a0565c347844c6d939fc4f4b98. +accept: application/mercurial-0.1. +authorization: Basic c2NtYWRtaW46c2NtYWRtaW4=. +host: localhost:8080. +user-agent: mercurial/proto-1.0 (Mercurial 4.3.1). + +HTTP/1.1 200 OK. +Set-Cookie: JSESSIONID=58p9y9vcnz5cjs22dtw8mpwk;Path=/scm. +Expires: Thu, 01 Jan 1970 00:00:00 GMT. +Set-Cookie: rememberMe=deleteMe; Path=/scm; Max-Age=0; Expires=Wed, 28-Mar-2018 06:16:50 GMT. +Content-Type: application/mercurial-0.1. +Content-Length: 43. +Server: Jetty(7.6.21.v20160908). + +c0ceccb3b2f0f5c977ff32b9337519e5f37942c2 +;0 + +GET /scm/hg/hgtest?cmd=listkeys HTTP/1.1. +Accept-Encoding: identity. +vary: X-HgArg-1. +x-hgarg-1: namespace=phases. +accept: application/mercurial-0.1. +authorization: Basic c2NtYWRtaW46c2NtYWRtaW4=. +host: localhost:8080. +user-agent: mercurial/proto-1.0 (Mercurial 4.3.1). + +HTTP/1.1 200 OK. +Set-Cookie: JSESSIONID=v5wfwj8k4t261dp6808cdouoa;Path=/scm. +Expires: Thu, 01 Jan 1970 00:00:00 GMT. +Set-Cookie: rememberMe=deleteMe; Path=/scm; Max-Age=0; Expires=Wed, 28-Mar-2018 06:16:50 GMT. +Content-Type: application/mercurial-0.1. +Content-Length: 15. +Server: Jetty(7.6.21.v20160908). + +publishing.True + +GET /scm/hg/hgtest?cmd=listkeys HTTP/1.1. +Accept-Encoding: identity. +vary: X-HgArg-1. +x-hgarg-1: namespace=bookmarks. +accept: application/mercurial-0.1. +authorization: Basic c2NtYWRtaW46c2NtYWRtaW4=. +host: localhost:8080. +user-agent: mercurial/proto-1.0 (Mercurial 4.3.1). + +HTTP/1.1 200 OK. +Set-Cookie: JSESSIONID=3pgqytfhm4za1dco9p41j9yz5;Path=/scm. +Expires: Thu, 01 Jan 1970 00:00:00 GMT. +Set-Cookie: rememberMe=deleteMe; Path=/scm; Max-Age=0; Expires=Wed, 28-Mar-2018 06:16:50 GMT. +Content-Type: application/mercurial-0.1. +Content-Length: 0. +Server: Jetty(7.6.21.v20160908). + +GET /scm/hg/hgtest?cmd=branchmap HTTP/1.1. +Accept-Encoding: identity. +accept: application/mercurial-0.1. +authorization: Basic c2NtYWRtaW46c2NtYWRtaW4=. +host: localhost:8080. +user-agent: mercurial/proto-1.0 (Mercurial 4.3.1). +. + +HTTP/1.1 200 OK. +Set-Cookie: JSESSIONID=1tiz6zf7ui54e1j3d4vouxig5m;Path=/scm. +Expires: Thu, 01 Jan 1970 00:00:00 GMT. +Set-Cookie: rememberMe=deleteMe; Path=/scm; Max-Age=0; Expires=Wed, 28-Mar-2018 06:16:50 GMT. +Content-Type: application/mercurial-0.1. +Content-Length: 48. +Server: Jetty(7.6.21.v20160908). + +default c0ceccb3b2f0f5c977ff32b9337519e5f37942c2 + +GET /scm/hg/hgtest?cmd=listkeys HTTP/1.1. +Accept-Encoding: identity. +vary: X-HgArg-1. +x-hgarg-1: namespace=bookmarks. +accept: application/mercurial-0.1. +authorization: Basic c2NtYWRtaW46c2NtYWRtaW4=. +host: localhost:8080. +user-agent: mercurial/proto-1.0 (Mercurial 4.3.1). + +HTTP/1.1 200 OK. +Set-Cookie: JSESSIONID=1augu4tc71xax1dit20dtxzkez;Path=/scm. +Expires: Thu, 01 Jan 1970 00:00:00 GMT. +Set-Cookie: rememberMe=deleteMe; Path=/scm; Max-Age=0; Expires=Wed, 28-Mar-2018 06:16:50 GMT. +Content-Type: application/mercurial-0.1. +Content-Length: 0. +Server: Jetty(7.6.21.v20160908). + +POST /scm/hg/hgtest?cmd=unbundle HTTP/1.1. +Accept-Encoding: identity. +content-type: application/mercurial-0.1. +vary: X-HgArg-1. +x-hgarg-1: heads=686173686564+95373ca7cd5371cb6c49bb755ee451d9ec585845. +accept: application/mercurial-0.1. +authorization: Basic c2NtYWRtaW46c2NtYWRtaW4=. +content-length: 746. +host: localhost:8080. +user-agent: mercurial/proto-1.0 (Mercurial 4.3.1). + +HG10GZx...]H.Q...z..r.,.Y..Bw.~..c.Z&...hf.:......e.XK.X,... +,2.E1.B+...(.B"."*..z1.*......M...........93..k|..I..<...h..J_.L.9>.h..@.....op..^.....#....;.*..W....T@....!..dY....jT..A0O6.}..S.2..JPU.O6...aa...rY.VOf9.....7Ukj.&..<...z...j......%}..Jc.8c....k.."9.&".I.P.\..$.At......0..1..g.2.)<..$.. E..dn#....#.Y$3...n...5....J.e.......SNHN.q.MD..4..."I..`PF..?GH1..F..uES..Rl$47.....a........D.1...87.k.t..D..O_.3..6'cN.w.M..|@E.).X!.h*....U.B.X.....h..$.`4... +-..O.:./..oWN.....3...x.L......_[..../..k.R$.x.2..kkv.\2R....4...@.2...1Q..T +..(..m....s.Uo.......{.d.....Y....TYO...S.Pl`a5. ."N$.@...b...qJ.l.).n...1..F.Zy.....&>v;.q.....Jy..X.?.;....>U..|.....d.Y.*.q...NR.3...h.T..x..,.]...p{.^S.S...~..`..q.\j{.oCI.............K.....l9n.s...... + +HTTP/1.1 200 OK. +Set-Cookie: JSESSIONID=1e4fnqpncil9z1f7a2pya26nt7;Path=/scm. +Expires: Thu, 01 Jan 1970 00:00:00 GMT. +Set-Cookie: rememberMe=deleteMe; Path=/scm; Max-Age=0; Expires=Wed, 28-Mar-2018 06:16:50 GMT. +Content-Type: application/mercurial-0.1. +Content-Length: 102. +Server: Jetty(7.6.21.v20160908). + +1 +adding changesets +adding manifests +adding file changes +added 4 changesets with 2 changes to 2 files + +GET /scm/hg/hgtest?cmd=listkeys HTTP/1.1. +Accept-Encoding: identity. +vary: X-HgArg-1. +x-hgarg-1: namespace=phases. +accept: application/mercurial-0.1. +authorization: Basic c2NtYWRtaW46c2NtYWRtaW4=. +host: localhost:8080. +user-agent: mercurial/proto-1.0 (Mercurial 4.3.1). + +HTTP/1.1 200 OK. +Set-Cookie: JSESSIONID=f9hvrjssniym1qe33q0u8r2m8;Path=/scm. +Expires: Thu, 01 Jan 1970 00:00:00 GMT. +Set-Cookie: rememberMe=deleteMe; Path=/scm; Max-Age=0; Expires=Wed, 28-Mar-2018 06:16:50 GMT. +Content-Type: application/mercurial-0.1. +Content-Length: 101. +Server: Jetty(7.6.21.v20160908). + +b5914611f84eae14543684b2721eec88b0edac12.1 +187ddf37e237c370514487a0bb1a226f11a780b3.1 +publishing.True + +POST /scm/hg/hgtest?cmd=pushkey HTTP/1.1. +Accept-Encoding: identity. +content-type: application/mercurial-0.1. +vary: X-HgArg-1. +x-hgarg-1: key=ef5993bb4abb32a0565c347844c6d939fc4f4b98&namespace=phases&new=0&old=1. +accept: application/mercurial-0.1. +authorization: Basic c2NtYWRtaW46c2NtYWRtaW4=. +content-length: 0. +host: localhost:8080. +user-agent: mercurial/proto-1.0 (Mercurial 4.3.1). + +HTTP/1.1 200 OK. +Set-Cookie: JSESSIONID=z5lrut6940a650sw6x9bls8a;Path=/scm. +Expires: Thu, 01 Jan 1970 00:00:00 GMT. +Set-Cookie: rememberMe=deleteMe; Path=/scm; Max-Age=0; Expires=Wed, 28-Mar-2018 06:16:50 GMT. +Content-Type: application/mercurial-0.1. +Content-Length: 2. +Server: Jetty(7.6.21.v20160908). + +1 +``` diff --git a/docs/mercurial/push-single-changeset.md b/docs/mercurial/push-single-changeset.md new file mode 100644 index 0000000000..499b4c21c3 --- /dev/null +++ b/docs/mercurial/push-single-changeset.md @@ -0,0 +1,147 @@ +# Push single changeset + +```http +GET /scm/hg/hgtest?cmd=capabilities HTTP/1.1. +Accept-Encoding: identity. +accept: application/mercurial-0.1. +authorization: Basic c2NtYWRtaW46c2NtYWRtaW4=. +host: localhost:8080. +user-agent: mercurial/proto-1.0 (Mercurial 4.3.1). + +HTTP/1.1 200 OK. +Set-Cookie: JSESSIONID=18r2i2jsba46d14ncsmcjdhaem;Path=/scm. +Expires: Thu, 01 Jan 1970 00:00:00 GMT. +Set-Cookie: rememberMe=deleteMe; Path=/scm; Max-Age=0; Expires=Wed, 28-Mar-2018 06:03:35 GMT. +Content-Type: application/mercurial-0.1. +Content-Length: 130. +Server: Jetty(7.6.21.v20160908). + +lookup changegroupsubset branchmap pushkey known getbundle unbundlehash batch stream unbundle=HG10GZ,HG10BZ,HG10UN httpheader=1024 + +GET /scm/hg/hgtest?cmd=batch HTTP/1.1. +Accept-Encoding: identity. +vary: X-HgArg-1. +x-hgarg-1: cmds=heads+%3Bknown+nodes%3Dc0ceccb3b2f0f5c977ff32b9337519e5f37942c2. +accept: application/mercurial-0.1. +authorization: Basic c2NtYWRtaW46c2NtYWRtaW4=. +host: localhost:8080. +user-agent: mercurial/proto-1.0 (Mercurial 4.3.1). + +HTTP/1.1 200 OK. +Set-Cookie: JSESSIONID=1fw0i0c5zpy281gfgha0f26git;Path=/scm. +Expires: Thu, 01 Jan 1970 00:00:00 GMT. +Set-Cookie: rememberMe=deleteMe; Path=/scm; Max-Age=0; Expires=Wed, 28-Mar-2018 06:03:35 GMT. +Content-Type: application/mercurial-0.1. +Content-Length: 43. +Server: Jetty(7.6.21.v20160908). + +0000000000000000000000000000000000000000 +;0 + +GET /scm/hg/hgtest?cmd=listkeys HTTP/1.1. +Accept-Encoding: identity. +vary: X-HgArg-1. +x-hgarg-1: namespace=phases. +accept: application/mercurial-0.1. +authorization: Basic c2NtYWRtaW46c2NtYWRtaW4=. +host: localhost:8080. +user-agent: mercurial/proto-1.0 (Mercurial 4.3.1). + +HTTP/1.1 200 OK. +Set-Cookie: JSESSIONID=dfa46uaqgf39w3jhk857oymu;Path=/scm. +Expires: Thu, 01 Jan 1970 00:00:00 GMT. +Set-Cookie: rememberMe=deleteMe; Path=/scm; Max-Age=0; Expires=Wed, 28-Mar-2018 06:03:35 GMT. +Content-Type: application/mercurial-0.1. +Content-Length: 15. +Server: Jetty(7.6.21.v20160908). + +publishing.True + +GET /scm/hg/hgtest?cmd=listkeys HTTP/1.1. +Accept-Encoding: identity. +vary: X-HgArg-1. +x-hgarg-1: namespace=bookmarks. +accept: application/mercurial-0.1. +authorization: Basic c2NtYWRtaW46c2NtYWRtaW4=. +host: localhost:8080. +user-agent: mercurial/proto-1.0 (Mercurial 4.3.1). + +HTTP/1.1 200 OK. +Set-Cookie: JSESSIONID=2sk1llvrsagg33xgmwyirfpi;Path=/scm. +Expires: Thu, 01 Jan 1970 00:00:00 GMT. +Set-Cookie: rememberMe=deleteMe; Path=/scm; Max-Age=0; Expires=Wed, 28-Mar-2018 06:03:35 GMT. +Content-Type: application/mercurial-0.1. +Content-Length: 0. +Server: Jetty(7.6.21.v20160908). + +POST /scm/hg/hgtest?cmd=unbundle HTTP/1.1. +Accept-Encoding: identity. +content-type: application/mercurial-0.1. +vary: X-HgArg-1. +x-hgarg-1: heads=686173686564+6768033e216468247bd031a0a2d9876d79818f8f. +accept: application/mercurial-0.1. +authorization: Basic c2NtYWRtaW46c2NtYWRtaW4=. +content-length: 261. +host: localhost:8080. +user-agent: mercurial/proto-1.0 (Mercurial 4.3.1). + +HG10GZx.c``8w.....>|=Y..h.q.....N.......%......Z....&&&.&...YZ.&.&[$.........$.%q..&%..d&.).....%*.....Y.....9z...v\..FF...... +..F..\.z%.%\\.)).) +.P[....D..[un..L).nc..q.m*.H.l#C...eZJ..YJ.Q.qR...e.aJ.EjjJ.AZ..A.Q..E.1.T.'D..C....7s.}..4G........3.S.mL.0.....zk + +HTTP/1.1 200 OK. +Set-Cookie: JSESSIONID=hlucs5utn1ifnpehqmjpt593;Path=/scm. +Expires: Thu, 01 Jan 1970 00:00:00 GMT. +Set-Cookie: rememberMe=deleteMe; Path=/scm; Max-Age=0; Expires=Wed, 28-Mar-2018 06:03:35 GMT. +Content-Type: application/mercurial-0.1. +Content-Length: 102. +Server: Jetty(7.6.21.v20160908). + +1 +adding changesets +adding manifests +adding file changes +added 1 changesets with 1 changes to 1 files + +T 172.17.0.1:33206 -> 172.17.0.2:8080 [AP] +GET /scm/hg/hgtest?cmd=listkeys HTTP/1.1. +Accept-Encoding: identity. +vary: X-HgArg-1. +x-hgarg-1: namespace=phases. +accept: application/mercurial-0.1. +authorization: Basic c2NtYWRtaW46c2NtYWRtaW4=. +host: localhost:8080. +user-agent: mercurial/proto-1.0 (Mercurial 4.3.1). + +HTTP/1.1 200 OK. +Set-Cookie: JSESSIONID=15xomlrxl8qja1cj47rjpqda0y;Path=/scm. +Expires: Thu, 01 Jan 1970 00:00:00 GMT. +Set-Cookie: rememberMe=deleteMe; Path=/scm; Max-Age=0; Expires=Wed, 28-Mar-2018 06:03:35 GMT. +Content-Type: application/mercurial-0.1. +Content-Length: 58. +Server: Jetty(7.6.21.v20160908). + +c0ceccb3b2f0f5c977ff32b9337519e5f37942c2.1 +publishing.True + +POST /scm/hg/hgtest?cmd=pushkey HTTP/1.1. +Accept-Encoding: identity. +content-type: application/mercurial-0.1. +vary: X-HgArg-1. +x-hgarg-1: key=c0ceccb3b2f0f5c977ff32b9337519e5f37942c2&namespace=phases&new=0&old=1. +accept: application/mercurial-0.1. +authorization: Basic c2NtYWRtaW46c2NtYWRtaW4=. +content-length: 0. +host: localhost:8080. +user-agent: mercurial/proto-1.0 (Mercurial 4.3.1). + +HTTP/1.1 200 OK. +Set-Cookie: JSESSIONID=5zrop5v8e661ipk12tvru525;Path=/scm. +Expires: Thu, 01 Jan 1970 00:00:00 GMT. +Set-Cookie: rememberMe=deleteMe; Path=/scm; Max-Age=0; Expires=Wed, 28-Mar-2018 06:03:35 GMT. +Content-Type: application/mercurial-0.1. +Content-Length: 2. +Server: Jetty(7.6.21.v20160908). + +1 +``` diff --git a/pom.xml b/pom.xml index e480d1527b..d8437f2934 100644 --- a/pom.xml +++ b/pom.xml @@ -86,20 +86,6 @@ http://maven.scm-manager.org/nexus/content/groups/public - - ossrh - https://oss.sonatype.org/content/repositories/snapshots - - true - daily - - - - - jitpack - https://jitpack.io - - @@ -142,6 +128,11 @@ junit-vintage-engine + + org.junit-pioneer + junit-pioneer + + org.hamcrest hamcrest-core @@ -160,20 +151,13 @@ - org.assertj - assertj-core + org.mockito + mockito-junit-jupiter - - com.github.cloudogu - ces-build-lib - - 9aadeeb - - true - - provided + org.assertj + assertj-core @@ -325,6 +309,13 @@ test + + org.junit-pioneer + junit-pioneer + 0.3.0 + test + + org.hamcrest hamcrest-core @@ -346,12 +337,68 @@ test + + org.mockito + mockito-junit-jupiter + ${mockito.version} + test + + org.assertj assertj-core 3.10.0 test + + + + + org.apache.httpcomponents + httpclient + 4.5.5 + + + + + + slf4j-api + org.slf4j + ${slf4j.version} + + + + ch.qos.logback + logback-classic + ${logback.version} + + + + + + javax.xml.bind + jaxb-api + ${jaxb.version} + + + + com.sun.xml.bind + jaxb-impl + ${jaxb.version} + + + + org.glassfish.jaxb + jaxb-runtime + ${jaxb.version} + + + + javax.activation + activation + 1.1.1 + + @@ -362,7 +409,7 @@ com.github.sdorra buildfrontend-maven-plugin - 2.1.1 + 2.2.0 org.apache.maven.plugins @@ -394,10 +441,11 @@ maven-surefire-plugin 2.22.0 + org.apache.maven.plugins maven-enforcer-plugin - 1.4.1 + 3.0.0-M1 enforce-java @@ -419,11 +467,31 @@ [3.1,) + + + 1.8 + + + module-info + + true + + + org.codehaus.mojo + extra-enforcer-rules + 1.0-beta-7 + + @@ -612,53 +680,53 @@ org.apache.maven.plugins maven-site-plugin - 3.2 - - - - - org.apache.maven.plugins - maven-project-info-reports-plugin - 2.4 - - - - org.apache.maven.plugins - maven-jxr-plugin - 2.3 - - - - org.codehaus.mojo - findbugs-maven-plugin - 2.4.0 - - - - org.apache.maven.plugins - maven-surefire-report-plugin - 2.12 - - - - org.apache.maven.plugins - maven-pmd-plugin - 2.7.1 - - true - ${project.build.sourceEncoding} - ${project.build.javaLevel} - - - - - + 3.7 + + + + + org.apache.maven.plugins + maven-project-info-reports-plugin + 2.4 + + + + org.apache.maven.plugins + maven-jxr-plugin + 2.3 + + + + org.codehaus.mojo + findbugs-maven-plugin + 2.4.0 + + + + org.apache.maven.plugins + maven-surefire-report-plugin + 2.12 + + + + org.apache.maven.plugins + maven-pmd-plugin + 2.7.1 + + ${project.build.sourceEncoding} + ${project.build.javaLevel} + + + + + + @@ -702,17 +770,9 @@ org.apache.maven.plugins maven-javadoc-plugin + 3.0.0 - org.jboss.apiviz.APIviz - - org.jboss.apiviz - apiviz - 1.3.2.GA - - - -sourceclasspath ${project.build.outputDirectory} - -nopackagediagram - + false @@ -746,31 +806,32 @@ 5.2.0 - 1.7.22 - 1.1.10 + 1.7.25 + 1.2.3 3.0.1 - 2.0.1 - 3.1.3.Final + 2.1.1 + 3.6.2.Final 1.19.4 - 2.9.1 - 2.8.6 + 2.11.1 + 2.9.8 4.0 + 2.3.0 1.4.2 - 9.2.10.v20150310 - 9.2.10.v20150310 + 9.4.14.v20181114 + 9.4.14.v20181114 - 1.1.0 + 1.2.0 1.4.0 - - v4.5.2.201704071617-r-scm1 - 1.8.15-scm1 + + v4.5.3.201708160445-r-scm1 + 1.9.0-scm3 26.0-jre @@ -782,10 +843,22 @@ 1.8 + 1.8 UTF-8 SCM-BSD 1.2.0.Final + + + + + + **/*StoreFactory.java,**/*UserPassword.js + + 8.11.4 + ./scm-ui/target/frontend/buildfrontend-node/node-v${node.version}-linux-x64/bin/node + + diff --git a/scm-annotations/src/main/java/sonia/scm/api/v2/resources/Enrich.java b/scm-annotations/src/main/java/sonia/scm/api/v2/resources/Enrich.java new file mode 100644 index 0000000000..a1269dfc00 --- /dev/null +++ b/scm-annotations/src/main/java/sonia/scm/api/v2/resources/Enrich.java @@ -0,0 +1,26 @@ +package sonia.scm.api.v2.resources; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to specify the source of an enricher. + * + * @author Sebastian Sdorra + * @since 2.0.0 + */ +@Documented +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface Enrich { + + /** + * Source mapping class. + * + * @return source mapping class + */ + Class value(); +} diff --git a/scm-clients/scm-cli-client/src/main/java/sonia/scm/cli/config/AesCipherStreamHandler.java b/scm-clients/scm-cli-client/src/main/java/sonia/scm/cli/config/AesCipherStreamHandler.java new file mode 100644 index 0000000000..680dd25563 --- /dev/null +++ b/scm-clients/scm-cli-client/src/main/java/sonia/scm/cli/config/AesCipherStreamHandler.java @@ -0,0 +1,114 @@ +/** + * Copyright (c) 2010, Sebastian Sdorra + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * 3. Neither the name of SCM-Manager; nor the names of its + * contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * http://bitbucket.org/sdorra/scm-manager + * + */ + + +package sonia.scm.cli.config; + +import com.google.common.base.Charsets; + +import javax.crypto.Cipher; +import javax.crypto.CipherInputStream; +import javax.crypto.CipherOutputStream; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.security.SecureRandom; + +/** + * Implementation of {@link CipherStreamHandler} which uses AES. This version is used since version 1.60 for the + * cli client encryption. + * + * @author Sebastian Sdorra + * @since 1.60 + */ +public class AesCipherStreamHandler implements CipherStreamHandler { + + private static final String CIPHER_ALGORITHM = "AES/CBC/PKCS5PADDING"; + private static final String SECRET_KEY_ALGORITHM = "AES"; + private static final int IV_LENGTH = 16; + + private final SecureRandom random = new SecureRandom(); + + private final byte[] secretKey; + + AesCipherStreamHandler(String secretKey) { + this.secretKey = secretKey.getBytes(Charsets.UTF_8); + } + + @Override + public OutputStream encrypt(OutputStream outputStream) throws IOException { + Cipher cipher = createCipherForEncryption(); + outputStream.write(cipher.getIV()); + return new CipherOutputStream(outputStream, cipher); + } + + @Override + public InputStream decrypt(InputStream inputStream) throws IOException { + Cipher cipher = createCipherForDecryption(inputStream); + return new CipherInputStream(inputStream, cipher); + } + + private Cipher createCipherForDecryption(InputStream inputStream) throws IOException { + byte[] iv = createEmptyIvArray(); + inputStream.read(iv); + return createCipher(Cipher.DECRYPT_MODE, iv); + } + + private byte[] createEmptyIvArray() { + return new byte[IV_LENGTH]; + } + + private Cipher createCipherForEncryption() { + byte[] iv = generateIV(); + return createCipher(Cipher.ENCRYPT_MODE, iv); + } + + private byte[] generateIV() { + // use 12 byte as described at nist + // https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-38d.pdf + byte[] iv = createEmptyIvArray(); + random.nextBytes(iv); + return iv; + } + + private Cipher createCipher(int mode, byte[] iv) { + try { + Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM); + IvParameterSpec ivParameterSpec = new IvParameterSpec(iv); + cipher.init(mode, new SecretKeySpec(secretKey, SECRET_KEY_ALGORITHM), ivParameterSpec); + return cipher; + } catch (Exception ex) { + throw new ScmConfigException("failed to create cipher", ex); + } + } +} diff --git a/scm-clients/scm-cli-client/src/main/java/sonia/scm/cli/config/CipherStreamHandler.java b/scm-clients/scm-cli-client/src/main/java/sonia/scm/cli/config/CipherStreamHandler.java new file mode 100644 index 0000000000..2321b3d9d9 --- /dev/null +++ b/scm-clients/scm-cli-client/src/main/java/sonia/scm/cli/config/CipherStreamHandler.java @@ -0,0 +1,64 @@ +/** + * Copyright (c) 2010, Sebastian Sdorra + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * 3. Neither the name of SCM-Manager; nor the names of its + * contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * http://bitbucket.org/sdorra/scm-manager + * + */ + + +package sonia.scm.cli.config; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +/** + * The CipherStreamHandler is able to encrypt and decrypt streams. + * + * @author Sebastian Sdorra + * @since 1.60 + */ +public interface CipherStreamHandler { + + /** + * Decrypts the given input stream. + * + * @param inputStream encrypted input stream + * + * @return raw input stream + */ + InputStream decrypt(InputStream inputStream) throws IOException; + + /** + * Encrypts the given output stream. + * + * @param outputStream raw output stream + * + * @return encrypting output stream + */ + OutputStream encrypt(OutputStream outputStream) throws IOException; +} diff --git a/scm-clients/scm-cli-client/src/main/java/sonia/scm/cli/config/ConfigFiles.java b/scm-clients/scm-cli-client/src/main/java/sonia/scm/cli/config/ConfigFiles.java new file mode 100644 index 0000000000..0f88ef662a --- /dev/null +++ b/scm-clients/scm-cli-client/src/main/java/sonia/scm/cli/config/ConfigFiles.java @@ -0,0 +1,151 @@ +/** + * Copyright (c) 2010, Sebastian Sdorra + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * 3. Neither the name of SCM-Manager; nor the names of its + * contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * http://bitbucket.org/sdorra/scm-manager + * + */ + +package sonia.scm.cli.config; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Charsets; +import com.google.common.base.Preconditions; +import com.google.common.base.Strings; +import sonia.scm.security.KeyGenerator; + +import javax.xml.bind.JAXB; +import java.io.*; +import java.util.Arrays; + +/** + * Util methods for configuration files. + * + * @author Sebastian Sdorra + * @since 1.60 + */ +final class ConfigFiles { + + private static final KeyGenerator keyGenerator = new SecureRandomKeyGenerator(); + + // SCM Config Version 2 + @VisibleForTesting + static final byte[] VERSION_IDENTIFIER = "SCV2".getBytes(Charsets.US_ASCII); + + private ConfigFiles() { + } + + /** + * Returns {@code true} if the file is encrypted with the v2 format. + * + * @param file configuration file + * + * @return {@code true} for format v2 + * + * @throws IOException + */ + static boolean isFormatV2(File file) throws IOException { + try (InputStream input = new FileInputStream(file)) { + byte[] bytes = new byte[VERSION_IDENTIFIER.length]; + input.read(bytes); + return Arrays.equals(VERSION_IDENTIFIER, bytes); + } + } + + /** + * Decrypt and parse v1 configuration file. + * + * @param secretKeyStore key store + * @param file configuration file + * + * @return client configuration + * + * @throws IOException + */ + static ScmClientConfig parseV1(SecretKeyStore secretKeyStore, File file) throws IOException { + String secretKey = secretKey(secretKeyStore); + CipherStreamHandler cipherStreamHandler = new WeakCipherStreamHandler(secretKey); + return decrypt(cipherStreamHandler, new FileInputStream(file)); + } + + /** + * Decrypt and parse v12configuration file. + * + * @param secretKeyStore key store + * @param file configuration file + * + * @return client configuration + * + * @throws IOException + */ + static ScmClientConfig parseV2(SecretKeyStore secretKeyStore, File file) throws IOException { + String secretKey = secretKey(secretKeyStore); + CipherStreamHandler cipherStreamHandler = new AesCipherStreamHandler(secretKey); + try (InputStream input = new FileInputStream(file)) { + input.skip(VERSION_IDENTIFIER.length); + return decrypt(cipherStreamHandler, input); + } + } + + /** + * Store encrypt and write the configuration to the given file. + * Note the method uses always the latest available format. + * + * @param secretKeyStore key store + * @param config configuration + * @param file configuration file + * + * @throws IOException + */ + static void store(SecretKeyStore secretKeyStore, ScmClientConfig config, File file) throws IOException { + String secretKey = keyGenerator.createKey(); + CipherStreamHandler cipherStreamHandler = new AesCipherStreamHandler(secretKey); + try (OutputStream output = new FileOutputStream(file)) { + output.write(VERSION_IDENTIFIER); + encrypt(cipherStreamHandler, output, config); + } + secretKeyStore.set(secretKey); + } + + private static String secretKey(SecretKeyStore secretKeyStore) { + String secretKey = secretKeyStore.get(); + Preconditions.checkState(!Strings.isNullOrEmpty(secretKey), "no stored secret key found"); + return secretKey; + } + + private static ScmClientConfig decrypt(CipherStreamHandler cipherStreamHandler, InputStream input) throws IOException { + try ( InputStream decryptedInputStream = cipherStreamHandler.decrypt(input) ) { + return JAXB.unmarshal(decryptedInputStream, ScmClientConfig.class); + } + } + + private static void encrypt(CipherStreamHandler cipherStreamHandler, OutputStream output, ScmClientConfig clientConfig) throws IOException { + try ( OutputStream encryptedOutputStream = cipherStreamHandler.encrypt(output) ) { + JAXB.marshal(clientConfig, encryptedOutputStream); + } + } + +} diff --git a/scm-clients/scm-cli-client/src/main/java/sonia/scm/cli/config/EncryptionSecretKeyStoreWrapper.java b/scm-clients/scm-cli-client/src/main/java/sonia/scm/cli/config/EncryptionSecretKeyStoreWrapper.java new file mode 100644 index 0000000000..123655a7e3 --- /dev/null +++ b/scm-clients/scm-cli-client/src/main/java/sonia/scm/cli/config/EncryptionSecretKeyStoreWrapper.java @@ -0,0 +1,138 @@ +/** + * Copyright (c) 2010, Sebastian Sdorra + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * 3. Neither the name of SCM-Manager; nor the names of its + * contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * http://bitbucket.org/sdorra/scm-manager + * + */ + + +package sonia.scm.cli.config; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Charsets; +import com.google.common.base.Strings; +import com.google.common.io.BaseEncoding; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.SecretKeySpec; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; + +/** + * The EncryptionSecretKeyStoreWrapper is a wrapper around the {@link SecretKeyStore} interface. The wrapper will + * encrypt the passed secret keys, before they are written to the underlying {@link SecretKeyStore} implementation. The + * wrapper will also honor old unencrypted keys. + * + * @author Sebastian Sdorra + * @since 1.60 + */ +public class EncryptionSecretKeyStoreWrapper implements SecretKeyStore { + + private static final String ALGORITHM = "AES"; + + private static final SecureRandom random = new SecureRandom(); + + // i know storing the key directly in the class is far away from a best practice, but this is a chicken egg type + // of problem. We need a key to encrypt the stored keys, however encrypting the keys with a static defined key + // is better as storing them as plain text. + private static final byte[] SECRET_KEY = new byte[]{ 0x50, 0x61, 0x41, 0x67, 0x55, 0x43, 0x48, 0x7a, 0x48, 0x59, + 0x7a, 0x57, 0x6b, 0x34, 0x54, 0x62 + }; + + @VisibleForTesting + static final String ENCRYPTED_PREFIX = "SKV2:"; + + private SecretKeyStore wrappedSecretKeyStore; + + EncryptionSecretKeyStoreWrapper(SecretKeyStore wrappedSecretKeyStore) { + this.wrappedSecretKeyStore = wrappedSecretKeyStore; + } + + @Override + public void set(String secretKey) { + String encrypted = encrypt(secretKey); + wrappedSecretKeyStore.set(ENCRYPTED_PREFIX.concat(encrypted)); + } + + private String encrypt(String value) { + try { + Cipher cipher = createCipher(Cipher.ENCRYPT_MODE); + byte[] raw = cipher.doFinal(value.getBytes(Charsets.UTF_8)); + return encode(raw); + } catch (IllegalBlockSizeException | BadPaddingException ex) { + throw new ScmConfigException("failed to encrypt key", ex); + } + } + + private String encode(byte[] raw) { + return BaseEncoding.base64().encode(raw); + } + + @Override + public String get() { + String value = wrappedSecretKeyStore.get(); + if (Strings.nullToEmpty(value).startsWith(ENCRYPTED_PREFIX)) { + String encrypted = value.substring(ENCRYPTED_PREFIX.length()); + return decrypt(encrypted); + } + return value; + } + + private String decrypt(String encoded) { + try { + Cipher cipher = createCipher(Cipher.DECRYPT_MODE); + byte[] raw = decode(encoded); + return new String(cipher.doFinal(raw), Charsets.UTF_8); + } catch (IllegalBlockSizeException | BadPaddingException ex) { + throw new ScmConfigException("failed to decrypt key", ex); + } + } + + private byte[] decode(String encoded) { + return BaseEncoding.base64().decode(encoded); + } + + private Cipher createCipher(int mode) { + try { + Cipher cipher = Cipher.getInstance(ALGORITHM); + SecretKeySpec secretKeySpec = new SecretKeySpec(SECRET_KEY, "AES"); + cipher.init(mode, secretKeySpec, random); + return cipher; + } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException ex) { + throw new ScmConfigException("failed to create key", ex); + } + } + + @Override + public void remove() { + wrappedSecretKeyStore.remove(); + } +} diff --git a/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryMapAdapter.java b/scm-clients/scm-cli-client/src/main/java/sonia/scm/cli/config/PrefsSecretKeyStore.java similarity index 65% rename from scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryMapAdapter.java rename to scm-clients/scm-cli-client/src/main/java/sonia/scm/cli/config/PrefsSecretKeyStore.java index 4d6686dfb2..d3bd6847a8 100644 --- a/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryMapAdapter.java +++ b/scm-clients/scm-cli-client/src/main/java/sonia/scm/cli/config/PrefsSecretKeyStore.java @@ -29,40 +29,39 @@ * */ +package sonia.scm.cli.config; - -package sonia.scm.repository.xml; - -//~--- non-JDK imports -------------------------------------------------------- - -import sonia.scm.repository.Repository; - -import javax.xml.bind.annotation.adapters.XmlAdapter; -import java.util.LinkedHashMap; -import java.util.Map; - -//~--- JDK imports ------------------------------------------------------------ +import java.util.prefs.Preferences; /** + * SecretKeyStore implementation with uses {@link Preferences}. * * @author Sebastian Sdorra + * @since 1.60 */ -public class XmlRepositoryMapAdapter extends XmlAdapter> { +public class PrefsSecretKeyStore implements SecretKeyStore { - @Override - public XmlRepositoryList marshal(Map repositoryMap) { - return new XmlRepositoryList(repositoryMap); + private static final String PREF_SECRET_KEY = "scm.client.key"; + + private final Preferences preferences; + + PrefsSecretKeyStore() { + // we use ScmClientConfigFileHandler as base for backward compatibility + preferences = Preferences.userNodeForPackage(ScmClientConfigFileHandler.class); } @Override - public Map unmarshal(XmlRepositoryList repositories) { - Map repositoryMap = new LinkedHashMap<>(); + public void set(String secretKey) { + preferences.put(PREF_SECRET_KEY, secretKey); + } - for (Repository repository : repositories) { - repositoryMap.put(XmlRepositoryDatabase.createKey(repository), - repository); - } + @Override + public String get() { + return preferences.get(PREF_SECRET_KEY, null); + } - return repositoryMap; + @Override + public void remove() { + preferences.remove(PREF_SECRET_KEY); } } diff --git a/scm-clients/scm-cli-client/src/main/java/sonia/scm/cli/config/SecretKeyStore.java b/scm-clients/scm-cli-client/src/main/java/sonia/scm/cli/config/SecretKeyStore.java new file mode 100644 index 0000000000..be600125b7 --- /dev/null +++ b/scm-clients/scm-cli-client/src/main/java/sonia/scm/cli/config/SecretKeyStore.java @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2010, Sebastian Sdorra + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * 3. Neither the name of SCM-Manager; nor the names of its + * contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * http://bitbucket.org/sdorra/scm-manager + * + */ + +package sonia.scm.cli.config; + +/** + * SecretKeyStore is able to read and write secret keys. + * + * @author Sebastian Sdorra + * @since 1.60 + */ +public interface SecretKeyStore { + + /** + * Writes the given secret key to the store. + * + * @param secretKey secret key to write + */ + void set(String secretKey); + + /** + * Reads the secret key from the store. The method returns {@code null} if no secret key was stored. + * + * @return secret key or {@code null} + */ + String get(); + + /** + * Removes the secret key from store. + */ + void remove(); +} diff --git a/scm-clients/scm-cli-client/src/main/java/sonia/scm/cli/config/SecureRandomKeyGenerator.java b/scm-clients/scm-cli-client/src/main/java/sonia/scm/cli/config/SecureRandomKeyGenerator.java new file mode 100644 index 0000000000..19ae135461 --- /dev/null +++ b/scm-clients/scm-cli-client/src/main/java/sonia/scm/cli/config/SecureRandomKeyGenerator.java @@ -0,0 +1,69 @@ +/** + * Copyright (c) 2010, Sebastian Sdorra + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * 3. Neither the name of SCM-Manager; nor the names of its + * contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * http://bitbucket.org/sdorra/scm-manager + * + */ + + +package sonia.scm.cli.config; + +import com.google.common.annotations.VisibleForTesting; +import sonia.scm.security.KeyGenerator; + +import java.security.SecureRandom; +import java.util.Locale; + +/** + * Create keys by using {@link SecureRandom}. The SecureRandomKeyGenerator produces aes compatible keys. + * Warning the class is not thread safe. + * + * @author Sebastian Sdorra + * @since 1.60 + */ +public class SecureRandomKeyGenerator implements KeyGenerator { + + private SecureRandom random = new SecureRandom(); + + // key length 16 for aes128 + @VisibleForTesting + static final int KEY_LENGTH = 16; + + private static final String UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + private static final String LOWER = UPPER.toLowerCase(Locale.ENGLISH); + private static final String DIGITS = "0123456789"; + private static final char[] ALL = (UPPER + LOWER + DIGITS).toCharArray(); + + @Override + public String createKey() { + char[] key = new char[KEY_LENGTH]; + for (int idx = 0; idx < KEY_LENGTH; ++idx) { + key[idx] = ALL[random.nextInt(ALL.length)]; + } + return new String(key); + } +} diff --git a/scm-clients/scm-cli-client/src/main/java/sonia/scm/cli/config/WeakCipherStreamHandler.java b/scm-clients/scm-cli-client/src/main/java/sonia/scm/cli/config/WeakCipherStreamHandler.java new file mode 100644 index 0000000000..175d1986c6 --- /dev/null +++ b/scm-clients/scm-cli-client/src/main/java/sonia/scm/cli/config/WeakCipherStreamHandler.java @@ -0,0 +1,113 @@ +/** + * Copyright (c) 2010, Sebastian Sdorra + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * 3. Neither the name of SCM-Manager; nor the names of its + * contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * http://bitbucket.org/sdorra/scm-manager + * + */ + +package sonia.scm.cli.config; + +import javax.crypto.*; +import javax.crypto.spec.PBEKeySpec; +import javax.crypto.spec.PBEParameterSpec; +import java.io.InputStream; +import java.io.OutputStream; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.spec.InvalidKeySpecException; + +/** + * Weak implementation of {@link CipherStreamHandler}. This is the old implementation, which was used in versions prior + * 1.60. + * + * @author Sebastian Sdorra + * @since 1.60 + * + * @see Issue 978 + * @see Issue 979 + */ +public class WeakCipherStreamHandler implements CipherStreamHandler { + + private static final String SALT = "AE16347F"; + private static final int SPEC_ITERATION = 12; + private static final String CIPHER_NAME = "PBEWithMD5AndDES"; + + private final char[] secretKey; + + /** + * Creates a new handler with the given secret key. + * + * @param secretKey secret key + */ + WeakCipherStreamHandler(String secretKey) { + this.secretKey = secretKey.toCharArray(); + } + + @Override + public InputStream decrypt(InputStream inputStream) { + try { + Cipher c = createCipher(Cipher.DECRYPT_MODE); + return new CipherInputStream(inputStream, c); + } catch (Exception ex) { + throw new ScmConfigException("could not encrypt output stream", ex); + } + } + + @Override + public OutputStream encrypt(OutputStream outputStream) { + try { + Cipher c = createCipher(Cipher.ENCRYPT_MODE); + return new CipherOutputStream(outputStream, c); + } catch (Exception ex) { + throw new ScmConfigException("could not encrypt output stream", ex); + } + } + + private Cipher createCipher(int mode) + throws NoSuchAlgorithmException, NoSuchPaddingException, + InvalidKeySpecException, InvalidKeyException, + InvalidAlgorithmParameterException + { + SecretKey sk = createSecretKey(); + Cipher cipher = Cipher.getInstance(CIPHER_NAME); + PBEParameterSpec spec = new PBEParameterSpec(SALT.getBytes(), SPEC_ITERATION); + + cipher.init(mode, sk, spec); + + return cipher; + } + + private SecretKey createSecretKey() + throws NoSuchAlgorithmException, InvalidKeySpecException + { + PBEKeySpec keySpec = new PBEKeySpec(secretKey); + SecretKeyFactory factory = SecretKeyFactory.getInstance(CIPHER_NAME); + + return factory.generateSecret(keySpec); + } +} diff --git a/scm-core/src/main/java/sonia/scm/repository/PermissionType.java b/scm-clients/scm-cli-client/src/test/java/sonia/scm/cli/config/AesCipherStreamHandlerTest.java similarity index 57% rename from scm-core/src/main/java/sonia/scm/repository/PermissionType.java rename to scm-clients/scm-cli-client/src/test/java/sonia/scm/cli/config/AesCipherStreamHandlerTest.java index bba0d44f3d..14000faa33 100644 --- a/scm-core/src/main/java/sonia/scm/repository/PermissionType.java +++ b/scm-clients/scm-cli-client/src/test/java/sonia/scm/cli/config/AesCipherStreamHandlerTest.java @@ -30,70 +30,39 @@ */ +package sonia.scm.cli.config; -package sonia.scm.repository; +import com.google.common.base.Charsets; +import com.google.common.io.ByteStreams; +import org.junit.Test; +import sonia.scm.security.KeyGenerator; -/** - * Type of permissionPrefix for a {@link Repository}. - * - * @author Sebastian Sdorra - */ -public enum PermissionType -{ +import java.io.*; - /** read permision */ - READ(0, "repository:read,pull:"), +import static org.junit.Assert.assertEquals; - /** read and write permissionPrefix */ - WRITE(10, "repository:read,pull,push:"), +public class AesCipherStreamHandlerTest { - /** - * read, write and - * also the ability to manage the properties and permissions - */ - OWNER(100, "repository:*:"); + private final KeyGenerator keyGenerator = new SecureRandomKeyGenerator(); - /** - * Constructs a new permissionPrefix type - * - * - * @param value - */ - private PermissionType(int value, String permissionPrefix) - { - this.value = value; - this.permissionPrefix = permissionPrefix; + @Test + public void testEncryptAndDecrypt() throws IOException { + AesCipherStreamHandler cipherStreamHandler = new AesCipherStreamHandler(keyGenerator.createKey()); + + // douglas adams + String content = "If you try and take a cat apart to see how it works, the first thing you have on your hands is a nonworking cat."; + + // encrypt + ByteArrayOutputStream output = new ByteArrayOutputStream(); + OutputStream encryptedOutput = cipherStreamHandler.encrypt(output); + encryptedOutput.write(content.getBytes(Charsets.UTF_8)); + encryptedOutput.close(); + + InputStream input = new ByteArrayInputStream(output.toByteArray()); + input = cipherStreamHandler.decrypt(input); + byte[] decrypted = ByteStreams.toByteArray(input); + + assertEquals(content, new String(decrypted, Charsets.UTF_8)); } - //~--- get methods ---------------------------------------------------------- - - /** - * - * @return - * - * @since 2.0.0 - */ - public String getPermissionPrefix() - { - return permissionPrefix; - } - - /** - * Returns the integer representation of the {@link PermissionType} - * - * - * @return integer representation - */ - public int getValue() - { - return value; - } - - //~--- fields --------------------------------------------------------------- - - /** Field description */ - private final String permissionPrefix; - - /** Field description */ - private final int value; } diff --git a/scm-clients/scm-cli-client/src/test/java/sonia/scm/cli/config/ClientConfigurationTests.java b/scm-clients/scm-cli-client/src/test/java/sonia/scm/cli/config/ClientConfigurationTests.java new file mode 100644 index 0000000000..20422df7ed --- /dev/null +++ b/scm-clients/scm-cli-client/src/test/java/sonia/scm/cli/config/ClientConfigurationTests.java @@ -0,0 +1,96 @@ +/** + * Copyright (c) 2010, Sebastian Sdorra + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * 3. Neither the name of SCM-Manager; nor the names of its + * contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * http://bitbucket.org/sdorra/scm-manager + * + */ + + +package sonia.scm.cli.config; + +import com.google.common.base.Charsets; +import com.google.common.io.ByteStreams; + +import javax.xml.bind.JAXB; +import java.io.*; + +import static org.junit.Assert.assertEquals; + +final class ClientConfigurationTests { + + private ClientConfigurationTests() { + } + + static void testCipherStream(CipherStreamHandler cipherStreamHandler, String content) throws IOException { + byte[] encrypted = encrypt(cipherStreamHandler, content); + String decrypted = decrypt(cipherStreamHandler, encrypted); + assertEquals(content, decrypted); + } + + + static byte[] encrypt(CipherStreamHandler cipherStreamHandler, String content) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + OutputStream encryptedOutput = cipherStreamHandler.encrypt(output); + encryptedOutput.write(content.getBytes(Charsets.UTF_8)); + encryptedOutput.close(); + return output.toByteArray(); + } + + static String decrypt(CipherStreamHandler cipherStreamHandler, byte[] encrypted) throws IOException { + InputStream input = new ByteArrayInputStream(encrypted); + input = cipherStreamHandler.decrypt(input); + byte[] decrypted = ByteStreams.toByteArray(input); + input.close(); + + return new String(decrypted, Charsets.UTF_8); + } + + static void assertSampleConfig(ScmClientConfig config) { + ServerConfig defaultConfig; + defaultConfig = config.getDefaultConfig(); + + assertEquals("http://localhost:8080/scm", defaultConfig.getServerUrl()); + assertEquals("admin", defaultConfig.getUsername()); + assertEquals("admin123", defaultConfig.getPassword()); + } + + static ScmClientConfig createSampleConfig() { + ScmClientConfig config = new ScmClientConfig(); + ServerConfig defaultConfig = config.getDefaultConfig(); + defaultConfig.setServerUrl("http://localhost:8080/scm"); + defaultConfig.setUsername("admin"); + defaultConfig.setPassword("admin123"); + return config; + } + + static void encrypt(CipherStreamHandler cipherStreamHandler, ScmClientConfig config, File file) throws IOException { + try (OutputStream output = cipherStreamHandler.encrypt(new FileOutputStream(file))) { + JAXB.marshal(config, output); + } + } + +} diff --git a/scm-clients/scm-cli-client/src/test/java/sonia/scm/cli/config/ConfigFilesTest.java b/scm-clients/scm-cli-client/src/test/java/sonia/scm/cli/config/ConfigFilesTest.java new file mode 100644 index 0000000000..d9edc64884 --- /dev/null +++ b/scm-clients/scm-cli-client/src/test/java/sonia/scm/cli/config/ConfigFilesTest.java @@ -0,0 +1,105 @@ +/** + * Copyright (c) 2010, Sebastian Sdorra + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * 3. Neither the name of SCM-Manager; nor the names of its + * contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * http://bitbucket.org/sdorra/scm-manager + * + */ + +package sonia.scm.cli.config; + +import com.google.common.base.Charsets; +import com.google.common.io.Files; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; + +import static org.junit.Assert.*; + +public class ConfigFilesTest { + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void testIsFormatV2() throws IOException { + byte[] content = "The door was the way to... to... The Door was The Way".getBytes(Charsets.UTF_8); + + File fileV1 = temporaryFolder.newFile(); + Files.write(content, fileV1); + + assertFalse(ConfigFiles.isFormatV2(fileV1)); + + File fileV2 = temporaryFolder.newFile(); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + baos.write(ConfigFiles.VERSION_IDENTIFIER); + baos.write(content); + Files.write(baos.toByteArray(), fileV2); + + assertTrue(ConfigFiles.isFormatV2(fileV2)); + } + + @Test + public void testParseV1() throws IOException { + InMemorySecretKeyStore keyStore = createKeyStore(); + WeakCipherStreamHandler handler = new WeakCipherStreamHandler(keyStore.get()); + + ScmClientConfig config = ClientConfigurationTests.createSampleConfig(); + File file = temporaryFolder.newFile(); + ClientConfigurationTests.encrypt(handler, config, file); + + config = ConfigFiles.parseV1(keyStore, file); + ClientConfigurationTests.assertSampleConfig(config); + } + + @Test + public void storeAndParseV2() throws IOException { + InMemorySecretKeyStore keyStore = new InMemorySecretKeyStore(); + ScmClientConfig config = ClientConfigurationTests.createSampleConfig(); + File file = temporaryFolder.newFile(); + + ConfigFiles.store(keyStore, config, file); + + String key = keyStore.get(); + assertNotNull(key); + + config = ConfigFiles.parseV2(keyStore, file); + ClientConfigurationTests.assertSampleConfig(config); + } + + private InMemorySecretKeyStore createKeyStore() { + String secretKey = new SecureRandomKeyGenerator().createKey(); + InMemorySecretKeyStore keyStore = new InMemorySecretKeyStore(); + keyStore.set(secretKey); + return keyStore; + } + +} diff --git a/scm-clients/scm-cli-client/src/test/java/sonia/scm/cli/config/EncryptionSecretKeyStoreWrapperTest.java b/scm-clients/scm-cli-client/src/test/java/sonia/scm/cli/config/EncryptionSecretKeyStoreWrapperTest.java new file mode 100644 index 0000000000..c9190c3357 --- /dev/null +++ b/scm-clients/scm-cli-client/src/test/java/sonia/scm/cli/config/EncryptionSecretKeyStoreWrapperTest.java @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2010, Sebastian Sdorra + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * 3. Neither the name of SCM-Manager; nor the names of its + * contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * http://bitbucket.org/sdorra/scm-manager + * + */ + + + +package sonia.scm.cli.config; + +import org.junit.Test; + +import static org.junit.Assert.*; + +public class EncryptionSecretKeyStoreWrapperTest { + + private SecretKeyStore secretKeyStore = new InMemorySecretKeyStore(); + + @Test + public void testEncryptionKeyStoreWrapper() { + EncryptionSecretKeyStoreWrapper wrapper = new EncryptionSecretKeyStoreWrapper(secretKeyStore); + wrapper.set("mysecretkey"); + + assertEquals("mysecretkey", wrapper.get()); + assertTrue(secretKeyStore.get().startsWith(EncryptionSecretKeyStoreWrapper.ENCRYPTED_PREFIX)); + } + + @Test + public void testEncryptionKeyStoreWrapperWithOldUnencryptedKey() { + secretKeyStore.set("mysecretkey"); + EncryptionSecretKeyStoreWrapper wrapper = new EncryptionSecretKeyStoreWrapper(secretKeyStore); + assertEquals("mysecretkey", wrapper.get()); + } + +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitConstants.java b/scm-clients/scm-cli-client/src/test/java/sonia/scm/cli/config/InMemorySecretKeyStore.java similarity index 80% rename from scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitConstants.java rename to scm-clients/scm-cli-client/src/test/java/sonia/scm/cli/config/InMemorySecretKeyStore.java index 6d833577e1..4510a7a072 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitConstants.java +++ b/scm-clients/scm-cli-client/src/test/java/sonia/scm/cli/config/InMemorySecretKeyStore.java @@ -1,5 +1,5 @@ /** - * Copyright (c) 2014, Sebastian Sdorra + * Copyright (c) 2010, Sebastian Sdorra * All rights reserved. * * Redistribution and use in source and binary forms, with or without @@ -28,22 +28,26 @@ * http://bitbucket.org/sdorra/scm-manager * */ -package sonia.scm.repository; -/** - * Constants for Git. - * - * @author Sebastian Sdorra - * @since 1.50 - */ -public final class GitConstants { - /** - * Default branch repository property. - */ - public static final String PROPERTY_DEFAULT_BRANCH = "git.default-branch"; - - private GitConstants() { +package sonia.scm.cli.config; + +public class InMemorySecretKeyStore implements SecretKeyStore { + + private String secretKey; + + @Override + public void set(String secretKey) { + this.secretKey = secretKey; + } + + @Override + public String get() { + return secretKey; + } + + @Override + public void remove() { + this.secretKey = null; } - } diff --git a/scm-clients/scm-cli-client/src/test/java/sonia/scm/cli/config/ScmClientConfigFileHandlerTest.java b/scm-clients/scm-cli-client/src/test/java/sonia/scm/cli/config/ScmClientConfigFileHandlerTest.java new file mode 100644 index 0000000000..9f65d34655 --- /dev/null +++ b/scm-clients/scm-cli-client/src/test/java/sonia/scm/cli/config/ScmClientConfigFileHandlerTest.java @@ -0,0 +1,138 @@ +/** + * Copyright (c) 2010, Sebastian Sdorra + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * 3. Neither the name of SCM-Manager; nor the names of its + * contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * http://bitbucket.org/sdorra/scm-manager + * + */ + +package sonia.scm.cli.config; + +import com.google.common.io.Files; +import com.google.common.io.Resources; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import sonia.scm.security.UUIDKeyGenerator; + +import java.io.File; +import java.io.IOException; +import java.net.URL; + +import static org.junit.Assert.*; + +public class ScmClientConfigFileHandlerTest { + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void testClientConfigFileHandler() throws IOException { + File configFile = temporaryFolder.newFile(); + + ScmClientConfigFileHandler handler = new ScmClientConfigFileHandler( + new EncryptionSecretKeyStoreWrapper(new InMemorySecretKeyStore()), configFile + ); + + ScmClientConfig config = new ScmClientConfig(); + ServerConfig defaultConfig = config.getDefaultConfig(); + defaultConfig.setServerUrl("http://localhost:8080/scm"); + defaultConfig.setUsername("scmadmin"); + defaultConfig.setPassword("admin123"); + handler.write(config); + + assertTrue(configFile.exists()); + + config = handler.read(); + defaultConfig = config.getDefaultConfig(); + assertEquals("http://localhost:8080/scm", defaultConfig.getServerUrl()); + assertEquals("scmadmin", defaultConfig.getUsername()); + assertEquals("admin123", defaultConfig.getPassword()); + + handler.delete(); + + assertFalse(configFile.exists()); + } + + @Test + public void testClientConfigFileHandlerWithOldConfiguration() throws IOException { + File configFile = temporaryFolder.newFile(); + + // old implementation has used uuids as keys + String key = new UUIDKeyGenerator().createKey(); + + WeakCipherStreamHandler weakCipherStreamHandler = new WeakCipherStreamHandler(key); + ScmClientConfig clientConfig = ClientConfigurationTests.createSampleConfig(); + ClientConfigurationTests.encrypt(weakCipherStreamHandler, clientConfig, configFile); + + assertFalse(ConfigFiles.isFormatV2(configFile)); + + SecretKeyStore secretKeyStore = new EncryptionSecretKeyStoreWrapper(new InMemorySecretKeyStore()); + secretKeyStore.set(key); + + ScmClientConfigFileHandler handler = new ScmClientConfigFileHandler( + secretKeyStore, configFile + ); + + ScmClientConfig config = handler.read(); + ClientConfigurationTests.assertSampleConfig(config); + + // ensure key has changed + assertNotEquals(key, secretKeyStore.get()); + + // ensure config rewritten with v2 + assertTrue(ConfigFiles.isFormatV2(configFile)); + } + + @Test + public void testClientConfigFileHandlerWithRealMigration() throws IOException { + URL resource = Resources.getResource("sonia/scm/cli/config/scm-cli-config.enc.xml"); + byte[] bytes = Resources.toByteArray(resource); + + File configFile = temporaryFolder.newFile(); + Files.write(bytes, configFile); + + String key = "358e018a-0c3c-4339-8266-3874e597305f"; + SecretKeyStore secretKeyStore = new EncryptionSecretKeyStoreWrapper(new InMemorySecretKeyStore()); + secretKeyStore.set(key); + + ScmClientConfigFileHandler handler = new ScmClientConfigFileHandler( + secretKeyStore, configFile + ); + + ScmClientConfig config = handler.read(); + ServerConfig defaultConfig = config.getDefaultConfig(); + assertEquals("http://hitchhicker.com/scm", defaultConfig.getServerUrl()); + assertEquals("tricia", defaultConfig.getUsername()); + assertEquals("trillian123", defaultConfig.getPassword()); + + // ensure key has changed + assertNotEquals(key, secretKeyStore.get()); + + // ensure config rewritten with v2 + assertTrue(ConfigFiles.isFormatV2(configFile)); + } +} diff --git a/scm-clients/scm-cli-client/src/test/java/sonia/scm/cli/config/SecureRandomKeyGeneratorTest.java b/scm-clients/scm-cli-client/src/test/java/sonia/scm/cli/config/SecureRandomKeyGeneratorTest.java new file mode 100644 index 0000000000..e847e89cb1 --- /dev/null +++ b/scm-clients/scm-cli-client/src/test/java/sonia/scm/cli/config/SecureRandomKeyGeneratorTest.java @@ -0,0 +1,47 @@ +/** + * Copyright (c) 2010, Sebastian Sdorra + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * 3. Neither the name of SCM-Manager; nor the names of its + * contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * http://bitbucket.org/sdorra/scm-manager + * + */ + +package sonia.scm.cli.config; + +import org.junit.Test; + +import static org.junit.Assert.*; + +public class SecureRandomKeyGeneratorTest { + + @Test + public void testCreateKey() { + SecureRandomKeyGenerator keyGenerator = new SecureRandomKeyGenerator(); + assertNotNull(keyGenerator.createKey()); + assertEquals(SecureRandomKeyGenerator.KEY_LENGTH, keyGenerator.createKey().length()); + } + +} diff --git a/scm-clients/scm-cli-client/src/test/resources/sonia/scm/cli/config/scm-cli-config.enc.xml b/scm-clients/scm-cli-client/src/test/resources/sonia/scm/cli/config/scm-cli-config.enc.xml new file mode 100644 index 0000000000..94132772a4 Binary files /dev/null and b/scm-clients/scm-cli-client/src/test/resources/sonia/scm/cli/config/scm-cli-config.enc.xml differ diff --git a/scm-core/pom.xml b/scm-core/pom.xml index 3c90fec779..f19d50064d 100644 --- a/scm-core/pom.xml +++ b/scm-core/pom.xml @@ -24,9 +24,9 @@ ${servlet.version} provided - + - + sonia.scm scm-annotations @@ -39,7 +39,7 @@ provided - + @@ -47,6 +47,7 @@ org.slf4j ${slf4j.version} + jcl-over-slf4j org.slf4j @@ -80,7 +81,7 @@ guice-servlet ${guice.version} - + com.google.inject.extensions guice-throwingproviders @@ -92,6 +93,7 @@ javax.ws.rs javax.ws.rs-api + provided @@ -136,29 +138,51 @@ - + com.github.legman core ${legman.version} - + + + + + javax.xml.bind + jaxb-api + + + + com.sun.xml.bind + jaxb-impl + + + + org.glassfish.jaxb + jaxb-runtime + + + + javax.activation + activation + + - + com.google.guava guava ${guava.version} - + commons-lang commons-lang 2.6 - + - + sonia.scm scm-annotation-processor @@ -186,13 +210,14 @@ - + - + org.apache.maven.plugins maven-javadoc-plugin + 3.0.0 true ${project.build.sourceEncoding} @@ -211,15 +236,28 @@ http://download.oracle.com/javase/6/docs/api/ http://download.oracle.com/docs/cd/E17802_01/products/products/servlet/2.5/docs/servlet-2_5-mr2/ - http://jersey.java.net/nonav/apidocs/${jersey.version}/jersey/ https://google.github.io/guice/api-docs/${guice.version}/javadoc http://www.slf4j.org/api/ http://shiro.apache.org/static/${shiro.version}/apidocs/ + org.jboss.apiviz.APIviz + + org.jboss.apiviz + apiviz + 1.3.2.GA + + + + -sourceclasspath ${project.build.outputDirectory} + + + -nopackagediagram + + - + - + diff --git a/scm-core/src/main/java/sonia/scm/BadRequestException.java b/scm-core/src/main/java/sonia/scm/BadRequestException.java new file mode 100644 index 0000000000..544ed75a0b --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/BadRequestException.java @@ -0,0 +1,9 @@ +package sonia.scm; + +import java.util.List; + +public abstract class BadRequestException extends ExceptionWithContext { + public BadRequestException(List context, String message) { + super(context, message); + } +} diff --git a/scm-core/src/main/java/sonia/scm/BasicContextProvider.java b/scm-core/src/main/java/sonia/scm/BasicContextProvider.java index f6507fc453..6954c03832 100644 --- a/scm-core/src/main/java/sonia/scm/BasicContextProvider.java +++ b/scm-core/src/main/java/sonia/scm/BasicContextProvider.java @@ -35,6 +35,7 @@ package sonia.scm; //~--- non-JDK imports -------------------------------------------------------- +import com.google.common.annotations.VisibleForTesting; import sonia.scm.util.Util; //~--- JDK imports ------------------------------------------------------------ @@ -43,6 +44,7 @@ import java.io.File; import java.io.IOException; import java.io.InputStream; +import java.nio.file.Path; import java.util.Locale; import java.util.Properties; @@ -105,8 +107,26 @@ public class BasicContextProvider implements SCMContextProvider } } + @VisibleForTesting + BasicContextProvider(File baseDirectory, String version, Stage stage) { + this.baseDirectory = baseDirectory; + this.version = version; + this.stage = stage; + } + //~--- methods -------------------------------------------------------------- + + @Override + public Path resolve(Path path) { + if (path.isAbsolute()) { + return path; + } + + return baseDirectory.toPath().resolve(path); + } + + /** * {@inheritDoc} */ diff --git a/scm-core/src/main/java/sonia/scm/ExceptionWithContext.java b/scm-core/src/main/java/sonia/scm/ExceptionWithContext.java index dd87b77210..8702567880 100644 --- a/scm-core/src/main/java/sonia/scm/ExceptionWithContext.java +++ b/scm-core/src/main/java/sonia/scm/ExceptionWithContext.java @@ -6,6 +6,8 @@ import static java.util.Collections.unmodifiableList; public abstract class ExceptionWithContext extends RuntimeException { + private static final long serialVersionUID = 4327413456580409224L; + private final List context; public ExceptionWithContext(List context, String message) { diff --git a/scm-core/src/main/java/sonia/scm/NotSupportedFeatureException.java b/scm-core/src/main/java/sonia/scm/FeatureNotSupportedException.java similarity index 88% rename from scm-core/src/main/java/sonia/scm/NotSupportedFeatureException.java rename to scm-core/src/main/java/sonia/scm/FeatureNotSupportedException.java index daf996ee6c..2d64af4318 100644 --- a/scm-core/src/main/java/sonia/scm/NotSupportedFeatureException.java +++ b/scm-core/src/main/java/sonia/scm/FeatureNotSupportedException.java @@ -40,13 +40,14 @@ import java.util.Collections; * @author Sebastian Sdorra * @version 1.6 */ -public class NotSupportedFeatureException extends ExceptionWithContext { +@SuppressWarnings("squid:MaximumInheritanceDepth") // exceptions have a deep inheritance depth themselves; therefore we accept this here +public class FeatureNotSupportedException extends BadRequestException { private static final long serialVersionUID = 256498734456613496L; private static final String CODE = "9SR8G0kmU1"; - public NotSupportedFeatureException(String feature) + public FeatureNotSupportedException(String feature) { super(Collections.emptyList(),createMessage(feature)); } diff --git a/scm-core/src/main/java/sonia/scm/HandlerBase.java b/scm-core/src/main/java/sonia/scm/HandlerBase.java index a621f4f697..d960cc6107 100644 --- a/scm-core/src/main/java/sonia/scm/HandlerBase.java +++ b/scm-core/src/main/java/sonia/scm/HandlerBase.java @@ -36,7 +36,6 @@ package sonia.scm; //~--- JDK imports ------------------------------------------------------------ import java.io.Closeable; -import java.io.IOException; /** * The base class of all handlers. diff --git a/scm-core/src/main/java/sonia/scm/NotFoundException.java b/scm-core/src/main/java/sonia/scm/NotFoundException.java index 69b9617e93..9c478da855 100644 --- a/scm-core/src/main/java/sonia/scm/NotFoundException.java +++ b/scm-core/src/main/java/sonia/scm/NotFoundException.java @@ -7,6 +7,8 @@ import static java.util.stream.Collectors.joining; public class NotFoundException extends ExceptionWithContext { + private static final long serialVersionUID = 1710455380886499111L; + private static final String CODE = "AGR7UzkhA1"; public NotFoundException(Class type, String id) { diff --git a/scm-core/src/main/java/sonia/scm/SCMContextProvider.java b/scm-core/src/main/java/sonia/scm/SCMContextProvider.java index 18328403fe..93918770c8 100644 --- a/scm-core/src/main/java/sonia/scm/SCMContextProvider.java +++ b/scm-core/src/main/java/sonia/scm/SCMContextProvider.java @@ -37,6 +37,7 @@ package sonia.scm; import java.io.Closeable; import java.io.File; +import java.nio.file.Path; /** * The main class for retrieving the home and the version of the SCM-Manager. @@ -65,6 +66,17 @@ public interface SCMContextProvider extends Closeable */ public File getBaseDirectory(); + /** + * Resolves the given path against the base directory. + * + * @param path path to resolve + * + * @return absolute resolved path + * + * @since 2.0.0 + */ + Path resolve(Path path); + /** * Returns the current stage of SCM-Manager. * diff --git a/scm-core/src/main/java/sonia/scm/ScmConstraintViolationException.java b/scm-core/src/main/java/sonia/scm/ScmConstraintViolationException.java new file mode 100644 index 0000000000..a28812eb8a --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/ScmConstraintViolationException.java @@ -0,0 +1,136 @@ +package sonia.scm; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collection; + +import static java.util.Collections.unmodifiableCollection; + +/** + * Use this exception to handle invalid input values that cannot be handled using + * JEE bean validation. + * Use the {@link Builder} to conditionally create a new exception: + *
+ * Builder
+ *   .doThrow()
+ *   .violation("name or alias must not be empty if not anonymous", "myParameter", "name")
+ *   .violation("name or alias must not be empty if not anonymous", "myParameter", "alias")
+ *   .when(myParameter.getName() == null && myParameter.getAlias() == null && !myParameter.isAnonymous())
+ *   .andThrow()
+ *   .violation("name must be empty if anonymous", "myParameter", "name")
+ *   .when(myParameter.getName() != null && myParameter.isAnonymous());
+ * 
+ * Mind that using this way you do not have to use if-else constructs. + */ +public class ScmConstraintViolationException extends RuntimeException implements Serializable { + + private static final long serialVersionUID = 6904534307450229887L; + + private final Collection violations; + + private final String furtherInformation; + + private ScmConstraintViolationException(Collection violations, String furtherInformation) { + this.violations = violations; + this.furtherInformation = furtherInformation; + } + + /** + * The violations that caused this exception. + */ + public Collection getViolations() { + return unmodifiableCollection(violations); + } + + /** + * An optional URL for more informations about this constraint violation. + */ + public String getUrl() { + return furtherInformation; + } + + /** + * Builder to conditionally create constraint violations. + */ + public static class Builder { + private final Collection violations = new ArrayList<>(); + private String furtherInformation; + + /** + * Use this to create a new builder instance. + */ + public static Builder doThrow() { + return new Builder(); + } + + /** + * Resets this builder to check for further violations. + * @return this builder instance. + */ + public Builder andThrow() { + this.violations.clear(); + this.furtherInformation = null; + return this; + } + + /** + * Describes the violation with a custom message and the affected property. When more than one property is affected, + * you can call this method multiple times. + * @param message The message describing the violation. + * @param pathElements The affected property denoted by the path to reach this property, + * eg. "someParameter", "complexProperty", "attribute" + * @return this builder instance. + */ + public Builder violation(String message, String... pathElements) { + this.violations.add(new ScmConstraintViolation(message, pathElements)); + return this; + } + + /** + * Use this to specify a URL with further information about this violation and hints how to solve this. + * This is optional. + * @return this builder instance. + */ + public Builder withFurtherInformation(String furtherInformation) { + this.furtherInformation = furtherInformation; + return this; + } + + /** + * When the given condition is true, a exception will be thrown. Otherwise this simply resets this + * builder and does nothing else. + * @param condition The condition that indicates a violation of this constraint. + * @return this builder instance. + */ + public Builder when(boolean condition) { + if (condition && !this.violations.isEmpty()) { + throw new ScmConstraintViolationException(violations, furtherInformation); + } + return andThrow(); + } + } + + /** + * A single constraint violation. + */ + public static class ScmConstraintViolation implements Serializable { + + private static final long serialVersionUID = -6900317468157084538L; + + private final String message; + private final String path; + + private ScmConstraintViolation(String message, String... pathElements) { + this.message = message; + this.path = String.join(".", pathElements); + } + + public String getMessage() { + return message; + } + + public String getPropertyPath() { + return path; + } + } +} diff --git a/scm-core/src/main/java/sonia/scm/ScmState.java b/scm-core/src/main/java/sonia/scm/ScmState.java index cd36aa707c..09def95d54 100644 --- a/scm-core/src/main/java/sonia/scm/ScmState.java +++ b/scm-core/src/main/java/sonia/scm/ScmState.java @@ -77,15 +77,13 @@ public final class ScmState * @param repositoryTypes available repository types * @param defaultUserType default user type * @param clientConfig client configuration - * @param assignedPermission assigned permissions * @param availablePermissions list of available permissions * * @since 2.0.0 */ public ScmState(String version, User user, Collection groups, String token, Collection repositoryTypes, String defaultUserType, - ScmClientConfig clientConfig, List assignedPermission, - List availablePermissions) + ScmClientConfig clientConfig, Collection availablePermissions) { this.version = version; this.user = user; @@ -94,24 +92,11 @@ public final class ScmState this.repositoryTypes = repositoryTypes; this.clientConfig = clientConfig; this.defaultUserType = defaultUserType; - this.assignedPermissions = assignedPermission; this.availablePermissions = availablePermissions; } //~--- get methods ---------------------------------------------------------- - /** - * Return a list of assigned permissions. - * - * - * @return list of assigned permissions - * @since 1.31 - */ - public List getAssignedPermissions() - { - return assignedPermissions; - } - /** * Returns a list of available global permissions. * @@ -119,7 +104,7 @@ public final class ScmState * @return available global permissions * @since 1.31 */ - public List getAvailablePermissions() + public Collection getAvailablePermissions() { return availablePermissions; } @@ -225,14 +210,11 @@ public final class ScmState /** authentication token */ private String token; - /** Field description */ - private List assignedPermissions; - /** * Avaliable global permission * @since 1.31 */ - private List availablePermissions; + private Collection availablePermissions; /** Field description */ private ScmClientConfig clientConfig; diff --git a/scm-core/src/main/java/sonia/scm/ScmStateFactory.java b/scm-core/src/main/java/sonia/scm/ScmStateFactory.java index 41502a6850..ed8bfba5dc 100644 --- a/scm-core/src/main/java/sonia/scm/ScmStateFactory.java +++ b/scm-core/src/main/java/sonia/scm/ScmStateFactory.java @@ -74,20 +74,17 @@ public final class ScmStateFactory * @param repositoryManger repository manager * @param userManager user manager * @param securitySystem security system - * @param authorizationCollector authorization collector */ @Inject public ScmStateFactory(SCMContextProvider contextProvider, ScmConfiguration configuration, RepositoryManager repositoryManger, - UserManager userManager, SecuritySystem securitySystem, - AuthorizationCollector authorizationCollector) + UserManager userManager, SecuritySystem securitySystem) { this.contextProvider = contextProvider; this.configuration = configuration; this.repositoryManger = repositoryManger; this.userManager = userManager; this.securitySystem = securitySystem; - this.authorizationCollector = authorizationCollector; } //~--- methods -------------------------------------------------------------- @@ -101,8 +98,7 @@ public final class ScmStateFactory @SuppressWarnings("unchecked") public ScmState createAnonymousState() { - return createState(SCMContext.ANONYMOUS, Collections.EMPTY_LIST, null, - Collections.EMPTY_LIST, Collections.EMPTY_LIST); + return createState(SCMContext.ANONYMOUS, Collections.EMPTY_LIST, null, Collections.EMPTY_LIST); } /** @@ -134,23 +130,19 @@ public final class ScmStateFactory User user = collection.oneByType(User.class); GroupNames groups = collection.oneByType(GroupNames.class); - List ap = Collections.EMPTY_LIST; + Collection ap = Collections.EMPTY_LIST; if (subject.hasRole(Role.ADMIN)) { ap = securitySystem.getAvailablePermissions(); } - List permissions = - ImmutableList.copyOf( - authorizationCollector.collect().getStringPermissions()); - - return createState(user, groups.getCollection(), token, permissions, ap); + return createState(user, groups.getCollection(), token, ap); } private ScmState createState(User user, Collection groups, - String token, List assignedPermissions, - List availablePermissions) + String token, + Collection availablePermissions) { User u = user.clone(); @@ -159,15 +151,11 @@ public final class ScmStateFactory return new ScmState(contextProvider.getVersion(), u, groups, token, repositoryManger.getConfiguredTypes(), userManager.getDefaultType(), - new ScmClientConfig(configuration), assignedPermissions, - availablePermissions); + new ScmClientConfig(configuration), availablePermissions); } //~--- fields --------------------------------------------------------------- - /** authorization collector */ - private final AuthorizationCollector authorizationCollector; - /** configuration */ private final ScmConfiguration configuration; diff --git a/scm-core/src/main/java/sonia/scm/api/v2/resources/BaseMapper.java b/scm-core/src/main/java/sonia/scm/api/v2/resources/BaseMapper.java index d7f299d989..eba8173de1 100644 --- a/scm-core/src/main/java/sonia/scm/api/v2/resources/BaseMapper.java +++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/BaseMapper.java @@ -3,7 +3,7 @@ package sonia.scm.api.v2.resources; import de.otto.edison.hal.HalRepresentation; import org.mapstruct.Mapping; -public abstract class BaseMapper implements InstantAttributeMapper { +public abstract class BaseMapper extends HalAppenderMapper implements InstantAttributeMapper { @Mapping(target = "attributes", ignore = true) // We do not map HAL attributes public abstract D map(T modelObject); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ChangesetDto.java b/scm-core/src/main/java/sonia/scm/api/v2/resources/ChangesetDto.java similarity index 57% rename from scm-webapp/src/main/java/sonia/scm/api/v2/resources/ChangesetDto.java rename to scm-core/src/main/java/sonia/scm/api/v2/resources/ChangesetDto.java index 162d5d6699..6759f5cb8c 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ChangesetDto.java +++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/ChangesetDto.java @@ -1,5 +1,6 @@ package sonia.scm.api.v2.resources; +import de.otto.edison.hal.Embedded; import de.otto.edison.hal.HalRepresentation; import de.otto.edison.hal.Links; import lombok.Getter; @@ -7,7 +8,6 @@ import lombok.NoArgsConstructor; import lombok.Setter; import java.time.Instant; -import java.util.List; @Getter @Setter @@ -34,16 +34,7 @@ public class ChangesetDto extends HalRepresentation { */ private String description; - @Override - @SuppressWarnings("squid:S1185") // We want to have this method available in this package - protected HalRepresentation add(Links links) { - return super.add(links); + public ChangesetDto(Links links, Embedded embedded) { + super(links, embedded); } - - @SuppressWarnings("squid:S1185") // We want to have this method available in this package - protected HalRepresentation withEmbedded(String rel, List halRepresentations) { - return super.withEmbedded(rel, halRepresentations); - } - - } diff --git a/scm-core/src/main/java/sonia/scm/api/v2/resources/ChangesetToChangesetDtoMapper.java b/scm-core/src/main/java/sonia/scm/api/v2/resources/ChangesetToChangesetDtoMapper.java new file mode 100644 index 0000000000..cd7d7ecebe --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/ChangesetToChangesetDtoMapper.java @@ -0,0 +1,14 @@ +package sonia.scm.api.v2.resources; + +import org.mapstruct.Context; +import org.mapstruct.Mapping; +import sonia.scm.repository.Changeset; +import sonia.scm.repository.Repository; + +public interface ChangesetToChangesetDtoMapper { + + @Mapping(target = "attributes", ignore = true) // We do not map HAL attributes + ChangesetDto map(Changeset changeset, @Context Repository repository); + + +} diff --git a/scm-core/src/main/java/sonia/scm/api/v2/resources/HalAppender.java b/scm-core/src/main/java/sonia/scm/api/v2/resources/HalAppender.java new file mode 100644 index 0000000000..6afb542646 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/HalAppender.java @@ -0,0 +1,56 @@ +package sonia.scm.api.v2.resources; + +import de.otto.edison.hal.HalRepresentation; + +/** + * The {@link HalAppender} can be used within an {@link HalEnricher} to append hateoas links to a json response. + * + * @author Sebastian Sdorra + * @since 2.0.0 + */ +public interface HalAppender { + + /** + * Appends one link to the json response. + * + * @param rel name of relation + * @param href link uri + */ + void appendLink(String rel, String href); + + /** + * Returns a builder which is able to append an array of links to the resource. + * + * @param rel name of link relation + * @return multi link builder + */ + LinkArrayBuilder linkArrayBuilder(String rel); + + /** + * Appends one embedded object to the json response. + * + * @param rel name of relation + * @param embeddedItem embedded object + */ + void appendEmbedded(String rel, HalRepresentation embeddedItem); + + /** + * Builder for link arrays. + */ + interface LinkArrayBuilder { + + /** + * Append a link to the array. + * + * @param name name of link + * @param href link target + * @return {@code this} + */ + LinkArrayBuilder append(String name, String href); + + /** + * Builds the array and appends the it to the json response. + */ + void build(); + } +} diff --git a/scm-core/src/main/java/sonia/scm/api/v2/resources/HalAppenderMapper.java b/scm-core/src/main/java/sonia/scm/api/v2/resources/HalAppenderMapper.java new file mode 100644 index 0000000000..dd49b765bc --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/HalAppenderMapper.java @@ -0,0 +1,36 @@ +package sonia.scm.api.v2.resources; + +import com.google.common.annotations.VisibleForTesting; + +import javax.inject.Inject; + +public class HalAppenderMapper { + + @Inject + private HalEnricherRegistry registry; + + @VisibleForTesting + void setRegistry(HalEnricherRegistry registry) { + this.registry = registry; + } + + protected void applyEnrichers(HalAppender appender, Object source, Object... contextEntries) { + // null check is only their to not break existing tests + if (registry != null) { + + Object[] ctx = new Object[contextEntries.length + 1]; + ctx[0] = source; + for (int i = 0; i < contextEntries.length; i++) { + ctx[i + 1] = contextEntries[i]; + } + + HalEnricherContext context = HalEnricherContext.of(ctx); + + Iterable enrichers = registry.allByType(source.getClass()); + for (HalEnricher enricher : enrichers) { + enricher.enrich(context, appender); + } + } + } + +} diff --git a/scm-core/src/main/java/sonia/scm/api/v2/resources/HalEnricher.java b/scm-core/src/main/java/sonia/scm/api/v2/resources/HalEnricher.java new file mode 100644 index 0000000000..647a1cf74e --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/HalEnricher.java @@ -0,0 +1,26 @@ +package sonia.scm.api.v2.resources; + +import sonia.scm.plugin.ExtensionPoint; + +/** + * A {@link HalEnricher} can be used to append hal specific attributes, such as links, to the json response. + * To register an enricher use the {@link Enrich} annotation or the {@link HalEnricherRegistry} which is available + * via injection. + * + * Warning: enrichers are always registered as singletons. + * + * @author Sebastian Sdorra + * @since 2.0.0 + */ +@ExtensionPoint +@FunctionalInterface +public interface HalEnricher { + + /** + * Enriches the response with hal specific attributes. + * + * @param context contains the source for the json mapping and related objects + * @param appender can be used to append links or embedded objects to the json response + */ + void enrich(HalEnricherContext context, HalAppender appender); +} diff --git a/scm-core/src/main/java/sonia/scm/api/v2/resources/HalEnricherContext.java b/scm-core/src/main/java/sonia/scm/api/v2/resources/HalEnricherContext.java new file mode 100644 index 0000000000..36128087b8 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/HalEnricherContext.java @@ -0,0 +1,72 @@ +package sonia.scm.api.v2.resources; + +import com.google.common.collect.ImmutableMap; + +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Optional; + +/** + * Context object for the {@link HalEnricher}. The context holds the source object for the json and all related + * objects, which can be useful for the enrichment. + * + * @author Sebastian Sdorra + * @since 2.0.0 + */ +public final class HalEnricherContext { + + private final Map instanceMap; + + private HalEnricherContext(Map instanceMap) { + this.instanceMap = instanceMap; + } + + /** + * Creates a context with the given entries + * + * @param instances entries of the context + * + * @return context of given entries + */ + public static HalEnricherContext of(Object... instances) { + ImmutableMap.Builder builder = ImmutableMap.builder(); + for (Object instance : instances) { + builder.put(instance.getClass(), instance); + } + return new HalEnricherContext(builder.build()); + } + + /** + * Returns the registered object from the context. The method will return an empty optional, if no object with the + * given type was registered. + * + * @param type type of instance + * @param type of instance + * @return optional instance + */ + public Optional oneByType(Class type) { + Object instance = instanceMap.get(type); + if (instance != null) { + return Optional.of(type.cast(instance)); + } + return Optional.empty(); + } + + /** + * Returns the registered object from the context, but throws an {@link NoSuchElementException} if the type was not + * registered. + * + * @param type type of instance + * @param type of instance + * @return instance + */ + public T oneRequireByType(Class type) { + Optional instance = oneByType(type); + if (instance.isPresent()) { + return instance.get(); + } else { + throw new NoSuchElementException("No instance for given type present"); + } + } + +} diff --git a/scm-core/src/main/java/sonia/scm/api/v2/resources/HalEnricherRegistry.java b/scm-core/src/main/java/sonia/scm/api/v2/resources/HalEnricherRegistry.java new file mode 100644 index 0000000000..3fadbfa388 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/HalEnricherRegistry.java @@ -0,0 +1,40 @@ +package sonia.scm.api.v2.resources; + +import com.google.common.collect.HashMultimap; +import com.google.common.collect.Multimap; +import sonia.scm.plugin.Extension; + +import javax.inject.Singleton; + +/** + * The {@link HalEnricherRegistry} is responsible for binding {@link HalEnricher} instances to their source types. + * + * @author Sebastian Sdorra + * @since 2.0.0 + */ +@Extension +@Singleton +public final class HalEnricherRegistry { + + private final Multimap enrichers = HashMultimap.create(); + + /** + * Registers a new {@link HalEnricher} for the given source type. + * + * @param sourceType type of json mapping source + * @param enricher link enricher instance + */ + public void register(Class sourceType, HalEnricher enricher) { + enrichers.put(sourceType, enricher); + } + + /** + * Returns all registered {@link HalEnricher} for the given type. + * + * @param sourceType type of json mapping source + * @return all registered enrichers + */ + public Iterable allByType(Class sourceType) { + return enrichers.get(sourceType); + } +} diff --git a/scm-core/src/main/java/sonia/scm/api/v2/resources/Index.java b/scm-core/src/main/java/sonia/scm/api/v2/resources/Index.java new file mode 100644 index 0000000000..346ce83816 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/Index.java @@ -0,0 +1,10 @@ +package sonia.scm.api.v2.resources; + +/** + * The {@link Index} object can be used to register a {@link HalEnricher} for the index resource. + * + * @author Sebastian Sdorra + * @since 2.0.0 + */ +public final class Index { +} diff --git a/scm-core/src/main/java/sonia/scm/api/v2/resources/Me.java b/scm-core/src/main/java/sonia/scm/api/v2/resources/Me.java new file mode 100644 index 0000000000..a027a78d79 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/Me.java @@ -0,0 +1,10 @@ +package sonia.scm.api.v2.resources; + +/** + * The {@link Me} object can be used to register a {@link HalEnricher} for the me resource. + * + * @author Sebastian Sdorra + * @since 2.0.0 + */ +public final class Me { +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PersonDto.java b/scm-core/src/main/java/sonia/scm/api/v2/resources/PersonDto.java similarity index 100% rename from scm-webapp/src/main/java/sonia/scm/api/v2/resources/PersonDto.java rename to scm-core/src/main/java/sonia/scm/api/v2/resources/PersonDto.java diff --git a/scm-core/src/main/java/sonia/scm/config/Configuration.java b/scm-core/src/main/java/sonia/scm/config/Configuration.java index 823c50b155..4019925c27 100644 --- a/scm-core/src/main/java/sonia/scm/config/Configuration.java +++ b/scm-core/src/main/java/sonia/scm/config/Configuration.java @@ -22,7 +22,8 @@ import com.github.sdorra.ssp.StaticPermissions; @StaticPermissions( value = "configuration", permissions = {"read", "write"}, - globalPermissions = {"list"} + globalPermissions = {"list"}, + custom = true, customGlobal = true ) public interface Configuration extends PermissionObject { } diff --git a/scm-core/src/main/java/sonia/scm/filter/GZipResponseFilter.java b/scm-core/src/main/java/sonia/scm/filter/GZipResponseFilter.java index 1fa525d4fd..ef0ec8a5ef 100644 --- a/scm-core/src/main/java/sonia/scm/filter/GZipResponseFilter.java +++ b/scm-core/src/main/java/sonia/scm/filter/GZipResponseFilter.java @@ -1,24 +1,59 @@ package sonia.scm.filter; -import lombok.extern.slf4j.Slf4j; -import sonia.scm.util.WebUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; -import javax.ws.rs.container.ContainerRequestContext; -import javax.ws.rs.container.ContainerResponseContext; -import javax.ws.rs.container.ContainerResponseFilter; -import javax.ws.rs.ext.Provider; +import javax.inject.Inject; +import javax.inject.Provider; +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.WebApplicationException; +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.ext.WriterInterceptor; +import javax.ws.rs.ext.WriterInterceptorContext; import java.io.IOException; +import java.io.OutputStream; +import java.util.Locale; import java.util.zip.GZIPOutputStream; -@Provider -@Slf4j -public class GZipResponseFilter implements ContainerResponseFilter { - public void filter(ContainerRequestContext requestContext, ContainerResponseContext responseContext) throws IOException { - if (WebUtil.isGzipSupported(requestContext::getHeaderString)) { - log.trace("compress output with gzip"); - GZIPOutputStream wrappedResponse = new GZIPOutputStream(responseContext.getEntityStream()); - responseContext.getHeaders().add("Content-Encoding", "gzip"); - responseContext.setEntityStream(wrappedResponse); +@javax.ws.rs.ext.Provider +public class GZipResponseFilter implements WriterInterceptor { + + private static final Logger LOG = LoggerFactory.getLogger(GZipResponseFilter.class); + + private final Provider requestProvider; + + @Inject + public GZipResponseFilter(Provider requestProvider) { + this.requestProvider = requestProvider; + } + + @Override + public void aroundWriteTo(WriterInterceptorContext context) throws IOException, WebApplicationException { + if (isGZipSupported()) { + LOG.trace("compress output with gzip"); + encodeWithGZip(context); + } else { + context.proceed(); } } + + private void encodeWithGZip(WriterInterceptorContext context) throws IOException { + context.getHeaders().remove(HttpHeaders.CONTENT_LENGTH); + context.getHeaders().add(HttpHeaders.CONTENT_ENCODING, "gzip"); + + OutputStream outputStream = context.getOutputStream(); + GZIPOutputStream compressedOutputStream = new GZIPOutputStream(outputStream); + context.setOutputStream(compressedOutputStream); + try { + context.proceed(); + } finally { + compressedOutputStream.finish(); + context.setOutputStream(outputStream); + } + } + + private boolean isGZipSupported() { + Object encoding = requestProvider.get().getHeader(HttpHeaders.ACCEPT_ENCODING); + return encoding != null && encoding.toString().toLowerCase(Locale.ENGLISH).contains("gzip"); + } } diff --git a/scm-core/src/main/java/sonia/scm/group/Group.java b/scm-core/src/main/java/sonia/scm/group/Group.java index c0b3c2ee8b..8860545c93 100644 --- a/scm-core/src/main/java/sonia/scm/group/Group.java +++ b/scm-core/src/main/java/sonia/scm/group/Group.java @@ -61,7 +61,11 @@ import java.util.List; * * @author Sebastian Sdorra */ -@StaticPermissions(value = "group", globalPermissions = {"create", "list", "autocomplete"}) +@StaticPermissions( + value = "group", + globalPermissions = {"create", "list", "autocomplete"}, + custom = true, customGlobal = true +) @XmlRootElement(name = "groups") @XmlAccessorType(XmlAccessType.FIELD) public class Group extends BasicPropertiesAware diff --git a/scm-core/src/main/java/sonia/scm/group/GroupNames.java b/scm-core/src/main/java/sonia/scm/group/GroupNames.java index 24d32972b6..faeec7218b 100644 --- a/scm-core/src/main/java/sonia/scm/group/GroupNames.java +++ b/scm-core/src/main/java/sonia/scm/group/GroupNames.java @@ -70,21 +70,9 @@ public final class GroupNames implements Serializable, Iterable * Constructs ... * */ - @SuppressWarnings("unchecked") public GroupNames() { - this.collection = Collections.EMPTY_LIST; - } - - /** - * Constructs ... - * - * - * @param collection - */ - public GroupNames(Collection collection) - { - this.collection = Collections.unmodifiableCollection(collection); + this(Collections.emptyList()); } /** @@ -96,7 +84,30 @@ public final class GroupNames implements Serializable, Iterable */ public GroupNames(String groupName, String... groupNames) { - this.collection = Lists.asList(groupName, groupNames); + this(Lists.asList(groupName, groupNames)); + } + + /** + * Constructs ... + * + * + * @param collection + */ + public GroupNames(Collection collection) + { + this(collection, false); + } + + /** + * Constructs ... + * + * + * @param collection + */ + public GroupNames(Collection collection, boolean external) + { + this.collection = Collections.unmodifiableCollection(collection); + this.external = external; } //~--- methods -------------------------------------------------------------- @@ -165,7 +176,7 @@ public final class GroupNames implements Serializable, Iterable @Override public String toString() { - return Joiner.on(", ").join(collection); + return Joiner.on(", ").join(collection) + "(" + (external? "external": "internal") + ")"; } //~--- get methods ---------------------------------------------------------- @@ -181,8 +192,13 @@ public final class GroupNames implements Serializable, Iterable return collection; } - //~--- fields --------------------------------------------------------------- + public boolean isExternal() { + return external; + } + //~--- fields --------------------------------------------------------------- /** Field description */ private final Collection collection; + + private final boolean external; } diff --git a/scm-core/src/main/java/sonia/scm/net/ahc/BaseHttpRequest.java b/scm-core/src/main/java/sonia/scm/net/ahc/BaseHttpRequest.java index c80ae9b1c4..0723f44b6c 100644 --- a/scm-core/src/main/java/sonia/scm/net/ahc/BaseHttpRequest.java +++ b/scm-core/src/main/java/sonia/scm/net/ahc/BaseHttpRequest.java @@ -35,7 +35,6 @@ package sonia.scm.net.ahc; import com.google.common.base.Charsets; import com.google.common.base.Strings; -import com.google.common.collect.HashMultimap; import com.google.common.collect.LinkedHashMultimap; import com.google.common.collect.Multimap; diff --git a/scm-core/src/main/java/sonia/scm/plugin/PluginInformation.java b/scm-core/src/main/java/sonia/scm/plugin/PluginInformation.java index 3ae359ceb7..6de52c3cca 100644 --- a/scm-core/src/main/java/sonia/scm/plugin/PluginInformation.java +++ b/scm-core/src/main/java/sonia/scm/plugin/PluginInformation.java @@ -61,7 +61,8 @@ import java.util.List; value = "plugin", generatedClass = "PluginPermissions", permissions = {}, - globalPermissions = { "read", "manage" } + globalPermissions = { "read", "manage" }, + custom = true, customGlobal = true ) @XmlAccessorType(XmlAccessType.FIELD) @XmlRootElement(name = "plugin-information") diff --git a/scm-core/src/main/java/sonia/scm/plugin/PluginLoader.java b/scm-core/src/main/java/sonia/scm/plugin/PluginLoader.java index 0bf37054a8..2d65d1cc98 100644 --- a/scm-core/src/main/java/sonia/scm/plugin/PluginLoader.java +++ b/scm-core/src/main/java/sonia/scm/plugin/PluginLoader.java @@ -35,8 +35,6 @@ package sonia.scm.plugin; //~--- non-JDK imports -------------------------------------------------------- -import com.google.inject.Module; - //~--- JDK imports ------------------------------------------------------------ import java.util.Collection; diff --git a/scm-core/src/main/java/sonia/scm/repository/AbstactImportHandler.java b/scm-core/src/main/java/sonia/scm/repository/AbstactImportHandler.java index f4438807c6..1f1e7cfefb 100644 --- a/scm-core/src/main/java/sonia/scm/repository/AbstactImportHandler.java +++ b/scm-core/src/main/java/sonia/scm/repository/AbstactImportHandler.java @@ -229,49 +229,10 @@ public abstract class AbstactImportHandler implements AdvancedImportHandler // } } - /** - * Method description - * - * - * @param manager - * @param repositoryName - * - * - * @return - * @throws IOException - */ - private void importRepository(RepositoryManager manager, - String repositoryName) - throws IOException { - Repository repository = - createRepository(getRepositoryDirectory(repositoryName), repositoryName); - if (logger.isInfoEnabled()) - { - logger.info("import repository {} of type {}", repositoryName, - getTypeName()); - } - - manager.importRepository(repository); - } //~--- get methods ---------------------------------------------------------- - /** - * Method description - * - * - * @param repositoryName - * - * @return - */ - private File getRepositoryDirectory(String repositoryName) - { - return new File( - getRepositoryHandler().getConfig().getRepositoryDirectory(), - repositoryName); - } - /** * Method description * diff --git a/scm-core/src/main/java/sonia/scm/repository/AbstractRepositoryHandler.java b/scm-core/src/main/java/sonia/scm/repository/AbstractRepositoryHandler.java index 42c8f22a0f..1e9cc3d374 100644 --- a/scm-core/src/main/java/sonia/scm/repository/AbstractRepositoryHandler.java +++ b/scm-core/src/main/java/sonia/scm/repository/AbstractRepositoryHandler.java @@ -38,7 +38,7 @@ package sonia.scm.repository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import sonia.scm.NotSupportedFeatureException; +import sonia.scm.FeatureNotSupportedException; import sonia.scm.SCMContextProvider; import sonia.scm.event.ScmEventBus; @@ -72,9 +72,11 @@ public abstract class AbstractRepositoryHandler * * @param storeFactory */ - protected AbstractRepositoryHandler(ConfigurationStoreFactory storeFactory) - { - this.store = storeFactory.getStore(getConfigClass(), getType().getName()); + protected AbstractRepositoryHandler(ConfigurationStoreFactory storeFactory) { + this.store = storeFactory + .withType(getConfigClass()) + .withName(getType().getName()) + .build(); } //~--- get methods ---------------------------------------------------------- @@ -165,12 +167,12 @@ public abstract class AbstractRepositoryHandler * * @return * - * @throws NotSupportedFeatureException + * @throws FeatureNotSupportedException */ @Override - public ImportHandler getImportHandler() throws NotSupportedFeatureException + public ImportHandler getImportHandler() { - throw new NotSupportedFeatureException("import"); + throw new FeatureNotSupportedException("import"); } /** diff --git a/scm-core/src/main/java/sonia/scm/repository/AbstractSimpleRepositoryHandler.java b/scm-core/src/main/java/sonia/scm/repository/AbstractSimpleRepositoryHandler.java index d190935fae..663d053ca5 100644 --- a/scm-core/src/main/java/sonia/scm/repository/AbstractSimpleRepositoryHandler.java +++ b/scm-core/src/main/java/sonia/scm/repository/AbstractSimpleRepositoryHandler.java @@ -34,18 +34,14 @@ package sonia.scm.repository; //~--- non-JDK imports -------------------------------------------------------- import com.google.common.base.Charsets; -import com.google.common.base.Throwables; import com.google.common.io.Resources; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import sonia.scm.AlreadyExistsException; import sonia.scm.ConfigurationException; -import sonia.scm.ContextEntry; import sonia.scm.io.CommandResult; import sonia.scm.io.ExtendedCommand; -import sonia.scm.io.FileSystem; +import sonia.scm.plugin.PluginLoader; import sonia.scm.store.ConfigurationStoreFactory; -import sonia.scm.util.IOUtil; import java.io.File; import java.io.IOException; @@ -62,9 +58,8 @@ public abstract class AbstractSimpleRepositoryHandler + * WARNING: The Locations provided with this class may not be used from the plugins to store any plugin specific files. + *

+ * Please use the {@link sonia.scm.store.DataStoreFactory } and the {@link sonia.scm.store.DataStore} classes to store data
+ * Please use the {@link sonia.scm.store.BlobStoreFactory } and the {@link sonia.scm.store.BlobStore} classes to store binary files
+ * Please use the {@link sonia.scm.store.ConfigurationStoreFactory} and the {@link sonia.scm.store.ConfigurationStore} classes to store configurations + * + * @author Mohamed Karray + * @since 2.0.0 + */ +public class InitialRepositoryLocationResolver { + + private static final String DEFAULT_REPOSITORY_PATH = "repositories"; + + private static final CharMatcher ID_MATCHER = CharMatcher.anyOf("/\\."); + + /** + * Returns the initial path to repository. + * + * @param repositoryId id of the repository + * + * @return initial path of repository + */ + @SuppressWarnings("squid:S2083") // path traversal is prevented with ID_MATCHER + public Path getPath(String repositoryId) { + // avoid path traversal attacks + checkArgument(ID_MATCHER.matchesNoneOf(repositoryId), "repository id contains invalid characters"); + return Paths.get(DEFAULT_REPOSITORY_PATH, repositoryId); + } + +} diff --git a/scm-core/src/main/java/sonia/scm/repository/PathBasedRepositoryDAO.java b/scm-core/src/main/java/sonia/scm/repository/PathBasedRepositoryDAO.java new file mode 100644 index 0000000000..35a47af233 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/PathBasedRepositoryDAO.java @@ -0,0 +1,18 @@ +package sonia.scm.repository; + +import java.nio.file.Path; + +/** + * A DAO used for Repositories accessible by a path + * + * @author Mohamed Karray + * @since 2.0.0 + */ +public interface PathBasedRepositoryDAO extends RepositoryDAO { + + /** + * Get the current path of the repository for the given id. + * This works for existing repositories only, not for repositories that should be created. + */ + Path getPath(String repositoryId) ; +} diff --git a/scm-core/src/main/java/sonia/scm/repository/Repository.java b/scm-core/src/main/java/sonia/scm/repository/Repository.java index fd8f07df8d..18613c1a12 100644 --- a/scm-core/src/main/java/sonia/scm/repository/Repository.java +++ b/scm-core/src/main/java/sonia/scm/repository/Repository.java @@ -62,13 +62,13 @@ import java.util.Set; */ @StaticPermissions( value = "repository", - permissions = {"read", "modify", "delete", "healthCheck", "pull", "push", "permissionRead", "permissionWrite"} + permissions = {"read", "modify", "delete", "healthCheck", "pull", "push", "permissionRead", "permissionWrite"}, + custom = true, customGlobal = true ) @XmlAccessorType(XmlAccessType.FIELD) @XmlRootElement(name = "repositories") public class Repository extends BasicPropertiesAware implements ModelObject, PermissionObject{ - private static final long serialVersionUID = 3486560714961909711L; private String contact; @@ -81,7 +81,8 @@ public class Repository extends BasicPropertiesAware implements ModelObject, Per private Long lastModified; private String namespace; private String name; - private final Set permissions = new HashSet<>(); + @XmlElement(name = "permission") + private final Set permissions = new HashSet<>(); @XmlElement(name = "public") private boolean publicReadable = false; private boolean archived = false; @@ -122,7 +123,7 @@ public class Repository extends BasicPropertiesAware implements ModelObject, Per * @param permissions permissions for specific users and groups. */ public Repository(String id, String type, String namespace, String name, String contact, - String description, Permission... permissions) { + String description, RepositoryPermission... permissions) { this.id = id; this.type = type; this.namespace = namespace; @@ -201,7 +202,7 @@ public class Repository extends BasicPropertiesAware implements ModelObject, Per return new NamespaceAndName(getNamespace(), getName()); } - public Collection getPermissions() { + public Collection getPermissions() { return Collections.unmodifiableCollection(permissions); } @@ -297,16 +298,16 @@ public class Repository extends BasicPropertiesAware implements ModelObject, Per this.name = name; } - public void setPermissions(Collection permissions) { + public void setPermissions(Collection permissions) { this.permissions.clear(); this.permissions.addAll(permissions); } - public void addPermission(Permission newPermission) { + public void addPermission(RepositoryPermission newPermission) { this.permissions.add(newPermission); } - public void removePermission(Permission permission) { + public void removePermission(RepositoryPermission permission) { this.permissions.remove(permission); } diff --git a/scm-core/src/main/java/sonia/scm/repository/RepositoryConfig.java b/scm-core/src/main/java/sonia/scm/repository/RepositoryConfig.java index 3db13e5618..909d95dce2 100644 --- a/scm-core/src/main/java/sonia/scm/repository/RepositoryConfig.java +++ b/scm-core/src/main/java/sonia/scm/repository/RepositoryConfig.java @@ -1,19 +1,19 @@ /** * Copyright (c) 2010, Sebastian Sdorra * All rights reserved. - * + *

* Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: - * + *

* 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. + * this list of conditions and the following disclaimer. * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. * 3. Neither the name of SCM-Manager; nor the names of its - * contributors may be used to endorse or promote products derived from this - * software without specific prior written permission. - * + * contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + *

* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE @@ -24,13 +24,11 @@ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - * + *

* http://bitbucket.org/sdorra/scm-manager - * */ - package sonia.scm.repository; import sonia.scm.Validateable; @@ -38,7 +36,6 @@ import sonia.scm.config.Configuration; import javax.xml.bind.annotation.XmlRootElement; import javax.xml.bind.annotation.XmlTransient; -import java.io.File; /** * Basic {@link Repository} configuration class. @@ -46,20 +43,10 @@ import java.io.File; * @author Sebastian Sdorra */ @XmlRootElement -public abstract class RepositoryConfig implements Validateable, Configuration -{ - - /** - * Returns the directory for the repositories. - * - * - * @return directory for the repositories - */ - public File getRepositoryDirectory() - { - return repositoryDirectory; - } +public abstract class RepositoryConfig implements Validateable, Configuration { + /** true if the plugin is disabled */ + private boolean disabled = false; /** * Returns true if the plugin is disabled. * @@ -67,8 +54,7 @@ public abstract class RepositoryConfig implements Validateable, Configuration * @return true if the plugin is disabled * @since 1.13 */ - public boolean isDisabled() - { + public boolean isDisabled() { return disabled; } @@ -79,9 +65,8 @@ public abstract class RepositoryConfig implements Validateable, Configuration * @return true if the configuration object is valid */ @Override - public boolean isValid() - { - return repositoryDirectory != null; + public boolean isValid() { + return true; } //~--- set methods ---------------------------------------------------------- @@ -93,29 +78,11 @@ public abstract class RepositoryConfig implements Validateable, Configuration * @param disabled * @since 1.13 */ - public void setDisabled(boolean disabled) - { + public void setDisabled(boolean disabled) { this.disabled = disabled; } - /** - * Sets the directory for the repositories - * - * - * @param repositoryDirectory directory for repositories - */ - public void setRepositoryDirectory(File repositoryDirectory) - { - this.repositoryDirectory = repositoryDirectory; - } - //~--- fields --------------------------------------------------------------- - - /** true if the plugin is disabled */ - private boolean disabled = false; - - /** directory for repositories */ - private File repositoryDirectory; /** * Specifies the identifier of the concrete {@link RepositoryConfig} when checking permissions of an object. diff --git a/scm-core/src/main/java/sonia/scm/repository/RepositoryDirectoryHandler.java b/scm-core/src/main/java/sonia/scm/repository/RepositoryDirectoryHandler.java index 2f766fb1f6..fd8e8a25f3 100644 --- a/scm-core/src/main/java/sonia/scm/repository/RepositoryDirectoryHandler.java +++ b/scm-core/src/main/java/sonia/scm/repository/RepositoryDirectoryHandler.java @@ -40,8 +40,11 @@ import java.io.File; * @author Sebastian Sdorra * @since 1.36 */ -public interface RepositoryDirectoryHandler extends RepositoryHandler -{ +public interface RepositoryDirectoryHandler extends RepositoryHandler { - public File getDirectory(Repository repository); + /** + * Get the current directory of the repository for the given id. + * @return the current directory of the given repository + */ + File getDirectory(String repositoryId); } diff --git a/scm-core/src/main/java/sonia/scm/repository/RepositoryHandler.java b/scm-core/src/main/java/sonia/scm/repository/RepositoryHandler.java index 79c06d03f9..aaa090827a 100644 --- a/scm-core/src/main/java/sonia/scm/repository/RepositoryHandler.java +++ b/scm-core/src/main/java/sonia/scm/repository/RepositoryHandler.java @@ -36,7 +36,7 @@ package sonia.scm.repository; //~--- non-JDK imports -------------------------------------------------------- import sonia.scm.Handler; -import sonia.scm.NotSupportedFeatureException; +import sonia.scm.FeatureNotSupportedException; import sonia.scm.plugin.ExtensionPoint; /** @@ -50,17 +50,6 @@ public interface RepositoryHandler extends Handler { - /** - * Returns the resource path for the given {@link Repository}. - * The resource path is part of the {@link Repository} url. - * - * - * - * @param repository given {@link Repository} - * @return resource path of the {@link Repository} - */ - public String createResourcePath(Repository repository); - //~--- get methods ---------------------------------------------------------- /** @@ -70,9 +59,9 @@ public interface RepositoryHandler * @return {@link ImportHandler} for the repository type of this handler * @since 1.12 * - * @throws NotSupportedFeatureException + * @throws FeatureNotSupportedException */ - public ImportHandler getImportHandler() throws NotSupportedFeatureException; + public ImportHandler getImportHandler() throws FeatureNotSupportedException; /** * Returns informations about the version of the RepositoryHandler. diff --git a/scm-core/src/main/java/sonia/scm/repository/RepositoryLocationResolver.java b/scm-core/src/main/java/sonia/scm/repository/RepositoryLocationResolver.java new file mode 100644 index 0000000000..737374025d --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/RepositoryLocationResolver.java @@ -0,0 +1,51 @@ +package sonia.scm.repository; + +import sonia.scm.SCMContextProvider; + +import javax.inject.Inject; +import java.nio.file.Path; + +/** + * A Location Resolver for File based Repository Storage. + *

+ * WARNING: The Locations provided with this class may not be used from the plugins to store any plugin specific files. + *

+ * Please use the {@link sonia.scm.store.DataStoreFactory } and the {@link sonia.scm.store.DataStore} classes to store data
+ * Please use the {@link sonia.scm.store.BlobStoreFactory } and the {@link sonia.scm.store.BlobStore} classes to store binary files
+ * Please use the {@link sonia.scm.store.ConfigurationStoreFactory} and the {@link sonia.scm.store.ConfigurationStore} classes to store configurations + * + * @author Mohamed Karray + * @since 2.0.0 + */ +public class RepositoryLocationResolver { + + private final SCMContextProvider contextProvider; + private final RepositoryDAO repositoryDAO; + private final InitialRepositoryLocationResolver initialRepositoryLocationResolver; + + @Inject + public RepositoryLocationResolver(SCMContextProvider contextProvider, RepositoryDAO repositoryDAO, InitialRepositoryLocationResolver initialRepositoryLocationResolver) { + this.contextProvider = contextProvider; + this.repositoryDAO = repositoryDAO; + this.initialRepositoryLocationResolver = initialRepositoryLocationResolver; + } + + /** + * Returns the path to the repository. + * + * @param repositoryId repository id + * + * @return path of repository + */ + public Path getPath(String repositoryId) { + Path path; + + if (repositoryDAO instanceof PathBasedRepositoryDAO) { + path = ((PathBasedRepositoryDAO) repositoryDAO).getPath(repositoryId); + } else { + path = initialRepositoryLocationResolver.getPath(repositoryId); + } + + return contextProvider.resolve(path); + } +} diff --git a/scm-core/src/main/java/sonia/scm/repository/RepositoryPathNotFoundException.java b/scm-core/src/main/java/sonia/scm/repository/RepositoryPathNotFoundException.java new file mode 100644 index 0000000000..96f76346b3 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/RepositoryPathNotFoundException.java @@ -0,0 +1,12 @@ + +package sonia.scm.repository; + +public class RepositoryPathNotFoundException extends Exception { + + public static final String REPOSITORY_PATH_NOT_FOUND = "Repository path not found"; + + public RepositoryPathNotFoundException() { + super(REPOSITORY_PATH_NOT_FOUND); + } + +} diff --git a/scm-core/src/main/java/sonia/scm/repository/Permission.java b/scm-core/src/main/java/sonia/scm/repository/RepositoryPermission.java similarity index 69% rename from scm-core/src/main/java/sonia/scm/repository/Permission.java rename to scm-core/src/main/java/sonia/scm/repository/RepositoryPermission.java index 20cdc83cef..54163e0393 100644 --- a/scm-core/src/main/java/sonia/scm/repository/Permission.java +++ b/scm-core/src/main/java/sonia/scm/repository/RepositoryPermission.java @@ -41,8 +41,15 @@ import sonia.scm.security.PermissionObject; import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlElement; import javax.xml.bind.annotation.XmlRootElement; import java.io.Serializable; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.Set; + +import static java.util.Collections.emptyList; +import static java.util.Collections.unmodifiableSet; //~--- JDK imports ------------------------------------------------------------ @@ -53,73 +60,38 @@ import java.io.Serializable; */ @XmlRootElement(name = "permissions") @XmlAccessorType(XmlAccessType.FIELD) -public class Permission implements PermissionObject, Serializable +public class RepositoryPermission implements PermissionObject, Serializable { private static final long serialVersionUID = -2915175031430884040L; private boolean groupPermission = false; private String name; - private PermissionType type = PermissionType.READ; + @XmlElement(name = "verb") + private Set verbs; /** - * Constructs a new {@link Permission}. - * This constructor is used by JAXB. - * + * Constructs a new {@link RepositoryPermission}. + * This constructor is used by JAXB and mapstruct. */ - public Permission() {} + public RepositoryPermission() {} - /** - * Constructs a new {@link Permission} with type = {@link PermissionType#READ} - * for the specified user. - * - * - * @param name name of the user - */ - public Permission(String name) + public RepositoryPermission(String name, Collection verbs, boolean groupPermission) { - this(); this.name = name; - } - - /** - * Constructs a new {@link Permission} with the specified type for - * the given user. - * - * - * @param name name of the user - * @param type type of the permission - */ - public Permission(String name, PermissionType type) - { - this(name); - this.type = type; - } - - /** - * Constructs a new {@link Permission} with the specified type for - * the given user or group. - * - * - * @param name name of the user or group - * @param type type of the permission - * @param groupPermission true if the permission is a permission for a group - */ - public Permission(String name, PermissionType type, boolean groupPermission) - { - this(name, type); + this.verbs = unmodifiableSet(new LinkedHashSet<>(verbs)); this.groupPermission = groupPermission; } //~--- methods -------------------------------------------------------------- /** - * Returns true if the {@link Permission} is the same as the obj argument. + * Returns true if the {@link RepositoryPermission} is the same as the obj argument. * * * @param obj the reference object with which to compare * - * @return true if the {@link Permission} is the same as the obj argument + * @return true if the {@link RepositoryPermission} is the same as the obj argument */ @Override public boolean equals(Object obj) @@ -134,23 +106,26 @@ public class Permission implements PermissionObject, Serializable return false; } - final Permission other = (Permission) obj; + final RepositoryPermission other = (RepositoryPermission) obj; return Objects.equal(name, other.name) - && Objects.equal(type, other.type) + && verbs.containsAll(other.verbs) + && verbs.size() == other.verbs.size() && Objects.equal(groupPermission, other.groupPermission); } /** - * Returns the hash code value for the {@link Permission}. + * Returns the hash code value for the {@link RepositoryPermission}. * * - * @return the hash code value for the {@link Permission} + * @return the hash code value for the {@link RepositoryPermission} */ @Override public int hashCode() { - return Objects.hashCode(name, type, groupPermission); + // Normally we do not have a log of repository permissions having the same size of verbs, but different content. + // Therefore we do not use the verbs themselves for the hash code but only the number of verbs. + return Objects.hashCode(name, verbs.size(), groupPermission); } @@ -160,7 +135,7 @@ public class Permission implements PermissionObject, Serializable //J- return MoreObjects.toStringHelper(this) .add("name", name) - .add("type", type) + .add("verbs", verbs) .add("groupPermission", groupPermission) .toString(); //J+ @@ -181,14 +156,14 @@ public class Permission implements PermissionObject, Serializable } /** - * Returns the {@link PermissionType} of the permission. + * Returns the verb of the permission. * * - * @return {@link PermissionType} of the permission + * @return verb of the permission */ - public PermissionType getType() + public Collection getVerbs() { - return type; + return verbs == null? emptyList(): verbs; } /** @@ -228,13 +203,13 @@ public class Permission implements PermissionObject, Serializable } /** - * Sets the type of the permission. + * Sets the verb of the permission. * * - * @param type type of the permission + * @param verbs verbs of the permission */ - public void setType(PermissionType type) + public void setVerbs(Collection verbs) { - this.type = type; + this.verbs = unmodifiableSet(new LinkedHashSet<>(verbs)); } } diff --git a/scm-core/src/main/java/sonia/scm/repository/RepositoryUtil.java b/scm-core/src/main/java/sonia/scm/repository/RepositoryUtil.java deleted file mode 100644 index a90eb4cc34..0000000000 --- a/scm-core/src/main/java/sonia/scm/repository/RepositoryUtil.java +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Copyright (c) 2010, Sebastian Sdorra - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * 3. Neither the name of SCM-Manager; nor the names of its - * contributors may be used to endorse or promote products derived from this - * software without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY - * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON - * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - * - * http://bitbucket.org/sdorra/scm-manager - * - */ - - - -package sonia.scm.repository; - -//~--- non-JDK imports -------------------------------------------------------- - -import com.google.common.base.Preconditions; -import sonia.scm.io.DirectoryFileFilter; -import sonia.scm.util.IOUtil; - -import java.io.File; -import java.io.IOException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -//~--- JDK imports ------------------------------------------------------------ - - -/** - * - * @author Sebastian Sdorra - * @since 1.11 - */ -public final class RepositoryUtil { - - private RepositoryUtil() {} - - public static List searchRepositoryDirectories(File directory, String... names) { - List repositories = new ArrayList<>(); - - searchRepositoryDirectories(repositories, directory, Arrays.asList(names)); - - return repositories; - } - - @SuppressWarnings("squid:S2083") // ignore, because the path is validated at {@link #getRepositoryId(File, File)} - public static String getRepositoryId(AbstractRepositoryHandler handler, String directoryPath) throws IOException { - return getRepositoryId(handler.getConfig().getRepositoryDirectory(), new File(directoryPath)); - } - - public static String getRepositoryId(AbstractRepositoryHandler handler, File directory) throws IOException { - return getRepositoryId(handler.getConfig(), directory); - } - - public static String getRepositoryId(RepositoryConfig config, File directory) throws IOException { - return getRepositoryId(config.getRepositoryDirectory(), directory); - } - - public static String getRepositoryId(File baseDirectory, File directory) throws IOException { - String path = directory.getCanonicalPath(); - String basePath = baseDirectory.getCanonicalPath(); - - Preconditions.checkArgument( - path.startsWith(basePath), - "repository path %s is not in the main repository path %s", path, basePath - ); - - String id = IOUtil.trimSeperatorChars(path.substring(basePath.length())); - - Preconditions.checkArgument( - !id.contains("\\") && !id.contains("/"), - "got illegal repository directory with separators in id: %s", path - ); - - return id; - } - - private static void searchRepositoryDirectories(List repositories, File directory, List names) { - boolean found = false; - - for (String name : names) { - if (new File(directory, name).exists()) { - found = true; - - break; - } - } - - if (found) { - repositories.add(directory); - } else { - File[] directories = directory.listFiles(DirectoryFileFilter.instance); - - if (directories != null) { - for (File d : directories) { - searchRepositoryDirectories(repositories, d, names); - } - } - } - } -} diff --git a/scm-core/src/main/java/sonia/scm/repository/api/DiffCommandBuilder.java b/scm-core/src/main/java/sonia/scm/repository/api/DiffCommandBuilder.java index 7217d0e97a..9e7094d5bf 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/DiffCommandBuilder.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/DiffCommandBuilder.java @@ -38,6 +38,8 @@ package sonia.scm.repository.api; import com.google.common.base.Preconditions; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import sonia.scm.FeatureNotSupportedException; +import sonia.scm.repository.Feature; import sonia.scm.repository.spi.DiffCommand; import sonia.scm.repository.spi.DiffCommandRequest; import sonia.scm.util.IOUtil; @@ -45,6 +47,7 @@ import sonia.scm.util.IOUtil; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; +import java.util.Set; //~--- JDK imports ------------------------------------------------------------ @@ -85,10 +88,12 @@ public final class DiffCommandBuilder * only be called from the {@link RepositoryService}. * * @param diffCommand implementation of {@link DiffCommand} + * @param supportedFeatures The supported features of the provider */ - DiffCommandBuilder(DiffCommand diffCommand) + DiffCommandBuilder(DiffCommand diffCommand, Set supportedFeatures) { this.diffCommand = diffCommand; + this.supportedFeatures = supportedFeatures; } //~--- methods -------------------------------------------------------------- @@ -174,7 +179,8 @@ public final class DiffCommandBuilder } /** - * Show the difference only for the given revision. + * Show the difference only for the given revision or (using {@link #setAncestorChangeset(String)}) between this + * and another revision. * * * @param revision revision for difference @@ -187,6 +193,22 @@ public final class DiffCommandBuilder return this; } + /** + * Compute the incoming changes of the branch set with {@link #setRevision(String)} in respect to the changeset given + * here. In other words: What changes would be new to the ancestor changeset given here when the branch would + * be merged into it. Requires feature {@link sonia.scm.repository.Feature#INCOMING_REVISION}! + * + * @return {@code this} + */ + public DiffCommandBuilder setAncestorChangeset(String revision) + { + if (!supportedFeatures.contains(Feature.INCOMING_REVISION)) { + throw new FeatureNotSupportedException(Feature.INCOMING_REVISION.name()); + } + request.setAncestorChangeset(revision); + + return this; + } //~--- get methods ---------------------------------------------------------- @@ -215,6 +237,7 @@ public final class DiffCommandBuilder /** implementation of the diff command */ private final DiffCommand diffCommand; + private Set supportedFeatures; /** request for the diff command implementation */ private final DiffCommandRequest request = new DiffCommandRequest(); diff --git a/scm-core/src/main/java/sonia/scm/repository/api/IncomingCommandBuilder.java b/scm-core/src/main/java/sonia/scm/repository/api/IncomingCommandBuilder.java index 6c7c620fa4..6098bdf92b 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/IncomingCommandBuilder.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/IncomingCommandBuilder.java @@ -39,12 +39,11 @@ import org.apache.shiro.subject.Subject; import sonia.scm.cache.CacheManager; import sonia.scm.repository.Changeset; import sonia.scm.repository.ChangesetPagingResult; -import sonia.scm.repository.PermissionType; import sonia.scm.repository.PreProcessorUtil; import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryPermissions; import sonia.scm.repository.spi.IncomingCommand; import sonia.scm.repository.spi.IncomingCommandRequest; -import sonia.scm.security.RepositoryPermission; import java.io.IOException; @@ -94,8 +93,7 @@ public final class IncomingCommandBuilder { Subject subject = SecurityUtils.getSubject(); - subject.checkPermission(new RepositoryPermission(remoteRepository, - PermissionType.READ)); + subject.isPermitted(RepositoryPermissions.pull(remoteRepository).asShiroString()); request.setRemoteRepository(remoteRepository); diff --git a/scm-core/src/main/java/sonia/scm/repository/api/LogCommandBuilder.java b/scm-core/src/main/java/sonia/scm/repository/api/LogCommandBuilder.java index 73062a0244..917b81391f 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/LogCommandBuilder.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/LogCommandBuilder.java @@ -39,10 +39,12 @@ import com.google.common.base.Objects; import com.google.common.collect.ImmutableList; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import sonia.scm.FeatureNotSupportedException; import sonia.scm.cache.Cache; import sonia.scm.cache.CacheManager; import sonia.scm.repository.Changeset; import sonia.scm.repository.ChangesetPagingResult; +import sonia.scm.repository.Feature; import sonia.scm.repository.PreProcessorUtil; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryCacheKey; @@ -51,6 +53,7 @@ import sonia.scm.repository.spi.LogCommandRequest; import java.io.IOException; import java.io.Serializable; +import java.util.Set; //~--- JDK imports ------------------------------------------------------------ @@ -104,19 +107,20 @@ public final class LogCommandBuilder /** * Constructs a new {@link LogCommandBuilder}, this constructor should * only be called from the {@link RepositoryService}. - * - * @param cacheManager cache manager + * @param cacheManager cache manager * @param logCommand implementation of the {@link LogCommand} * @param repository repository to query * @param preProcessorUtil + * @param supportedFeatures The supported features of the provider */ LogCommandBuilder(CacheManager cacheManager, LogCommand logCommand, - Repository repository, PreProcessorUtil preProcessorUtil) + Repository repository, PreProcessorUtil preProcessorUtil, Set supportedFeatures) { this.cache = cacheManager.getCache(CACHE_NAME); this.logCommand = logCommand; this.repository = repository; this.preProcessorUtil = preProcessorUtil; + this.supportedFeatures = supportedFeatures; } //~--- methods -------------------------------------------------------------- @@ -397,7 +401,17 @@ public final class LogCommandBuilder return this; } + /** + * Compute the incoming changes of the branch set with {@link #setBranch(String)} in respect to the changeset given + * here. In other words: What changesets would be new to the ancestor changeset given here when the branch would + * be merged into it. Requires feature {@link sonia.scm.repository.Feature#INCOMING_REVISION}! + * + * @return {@code this} + */ public LogCommandBuilder setAncestorChangeset(String ancestorChangeset) { + if (!supportedFeatures.contains(Feature.INCOMING_REVISION)) { + throw new FeatureNotSupportedException(Feature.INCOMING_REVISION.name()); + } request.setAncestorChangeset(ancestorChangeset); return this; } @@ -527,6 +541,7 @@ public final class LogCommandBuilder /** Field description */ private final PreProcessorUtil preProcessorUtil; + private Set supportedFeatures; /** repository to query */ private final Repository repository; diff --git a/scm-core/src/main/java/sonia/scm/repository/api/MergeCommandBuilder.java b/scm-core/src/main/java/sonia/scm/repository/api/MergeCommandBuilder.java index 881a374864..8fcfc937e5 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/MergeCommandBuilder.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/MergeCommandBuilder.java @@ -15,7 +15,7 @@ import sonia.scm.repository.spi.MergeCommandRequest; * * To actually merge feature_branch into integration_branch do this: *


- *     repositoryService.gerMergeCommand()
+ *     repositoryService.getMergeCommand()
  *       .setBranchToMerge("feature_branch")
  *       .setTargetBranch("integration_branch")
  *       .executeMerge();
@@ -33,7 +33,7 @@ import sonia.scm.repository.spi.MergeCommandRequest;
  *
  * To check whether they can be merged without conflicts beforehand do this:
  * 

- *     repositoryService.gerMergeCommand()
+ *     repositoryService.getMergeCommand()
  *       .setBranchToMerge("feature_branch")
  *       .setTargetBranch("integration_branch")
  *       .dryRun()
diff --git a/scm-core/src/main/java/sonia/scm/repository/api/OutgoingCommandBuilder.java b/scm-core/src/main/java/sonia/scm/repository/api/OutgoingCommandBuilder.java
index 2753128eac..d39c95e0e2 100644
--- a/scm-core/src/main/java/sonia/scm/repository/api/OutgoingCommandBuilder.java
+++ b/scm-core/src/main/java/sonia/scm/repository/api/OutgoingCommandBuilder.java
@@ -34,12 +34,11 @@ import org.apache.shiro.SecurityUtils;
 import org.apache.shiro.subject.Subject;
 import sonia.scm.cache.CacheManager;
 import sonia.scm.repository.ChangesetPagingResult;
-import sonia.scm.repository.PermissionType;
 import sonia.scm.repository.PreProcessorUtil;
 import sonia.scm.repository.Repository;
+import sonia.scm.repository.RepositoryPermissions;
 import sonia.scm.repository.spi.OutgoingCommand;
 import sonia.scm.repository.spi.OutgoingCommandRequest;
-import sonia.scm.security.RepositoryPermission;
 
 import java.io.IOException;
 
@@ -84,8 +83,7 @@ public final class OutgoingCommandBuilder
   {
     Subject subject = SecurityUtils.getSubject();
 
-    subject.checkPermission(new RepositoryPermission(remoteRepository,
-      PermissionType.READ));
+    subject.isPermitted(RepositoryPermissions.pull(remoteRepository).asShiroString());
 
     request.setRemoteRepository(remoteRepository);
 
diff --git a/scm-core/src/main/java/sonia/scm/repository/api/PullCommandBuilder.java b/scm-core/src/main/java/sonia/scm/repository/api/PullCommandBuilder.java
index a0f5ff4115..969ec6ef11 100644
--- a/scm-core/src/main/java/sonia/scm/repository/api/PullCommandBuilder.java
+++ b/scm-core/src/main/java/sonia/scm/repository/api/PullCommandBuilder.java
@@ -38,11 +38,10 @@ import org.apache.shiro.SecurityUtils;
 import org.apache.shiro.subject.Subject;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
-import sonia.scm.repository.PermissionType;
 import sonia.scm.repository.Repository;
+import sonia.scm.repository.RepositoryPermissions;
 import sonia.scm.repository.spi.PullCommand;
 import sonia.scm.repository.spi.PullCommandRequest;
-import sonia.scm.security.RepositoryPermission;
 
 import java.io.IOException;
 import java.net.URL;
@@ -96,9 +95,7 @@ public final class PullCommandBuilder
   public PullResponse pull(String url) throws IOException {
     Subject subject = SecurityUtils.getSubject();
     //J-
-    subject.checkPermission(
-      new RepositoryPermission(localRepository, PermissionType.WRITE)
-    );
+    subject.isPermitted(RepositoryPermissions.push(localRepository).asShiroString());
     //J+
     
     URL remoteUrl = new URL(url);
@@ -124,12 +121,8 @@ public final class PullCommandBuilder
     Subject subject = SecurityUtils.getSubject();
 
     //J-
-    subject.checkPermission(
-      new RepositoryPermission(localRepository, PermissionType.WRITE)
-    );
-    subject.checkPermission(
-      new RepositoryPermission(remoteRepository, PermissionType.READ)
-    );
+    subject.isPermitted(RepositoryPermissions.push(localRepository).asShiroString());
+    subject.isPermitted(RepositoryPermissions.push(remoteRepository).asShiroString());
     //J+
 
     request.reset();
diff --git a/scm-core/src/main/java/sonia/scm/repository/api/PushCommandBuilder.java b/scm-core/src/main/java/sonia/scm/repository/api/PushCommandBuilder.java
index 7b318e49ec..a734225281 100644
--- a/scm-core/src/main/java/sonia/scm/repository/api/PushCommandBuilder.java
+++ b/scm-core/src/main/java/sonia/scm/repository/api/PushCommandBuilder.java
@@ -39,11 +39,10 @@ import org.apache.shiro.SecurityUtils;
 import org.apache.shiro.subject.Subject;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
-import sonia.scm.repository.PermissionType;
 import sonia.scm.repository.Repository;
+import sonia.scm.repository.RepositoryPermissions;
 import sonia.scm.repository.spi.PushCommand;
 import sonia.scm.repository.spi.PushCommandRequest;
-import sonia.scm.security.RepositoryPermission;
 
 import java.io.IOException;
 import java.net.URL;
@@ -92,9 +91,7 @@ public final class PushCommandBuilder
     Subject subject = SecurityUtils.getSubject();
 
     //J-
-    subject.checkPermission(
-      new RepositoryPermission(remoteRepository, PermissionType.WRITE)
-    );
+    subject.isPermitted(RepositoryPermissions.push(remoteRepository).asShiroString());
     //J+
 
     logger.info("push changes to repository {}", remoteRepository.getId());
diff --git a/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java b/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java
index fe0529e6b5..ad53c3a8f7 100644
--- a/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java
+++ b/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java
@@ -221,7 +221,7 @@ public final class RepositoryService implements Closeable {
     logger.debug("create diff command for repository {}",
       repository.getNamespaceAndName());
 
-    return new DiffCommandBuilder(provider.getDiffCommand());
+    return new DiffCommandBuilder(provider.getDiffCommand(), provider.getSupportedFeatures());
   }
 
   /**
@@ -253,7 +253,7 @@ public final class RepositoryService implements Closeable {
       repository.getNamespaceAndName());
 
     return new LogCommandBuilder(cacheManager, provider.getLogCommand(),
-      repository, preProcessorUtil);
+      repository, preProcessorUtil, provider.getSupportedFeatures());
   }
 
   /**
@@ -363,8 +363,8 @@ public final class RepositoryService implements Closeable {
    *                                      by the implementation of the repository service provider.
    * @since 2.0.0
    */
-  public MergeCommandBuilder gerMergeCommand() {
-    logger.debug("create unbundle command for repository {}",
+  public MergeCommandBuilder getMergeCommand() {
+    logger.debug("create merge command for repository {}",
       repository.getNamespaceAndName());
 
     return new MergeCommandBuilder(provider.getMergeCommand());
diff --git a/scm-core/src/main/java/sonia/scm/repository/api/ScmProtocolProvider.java b/scm-core/src/main/java/sonia/scm/repository/api/ScmProtocolProvider.java
index 597826676d..591b8167e5 100644
--- a/scm-core/src/main/java/sonia/scm/repository/api/ScmProtocolProvider.java
+++ b/scm-core/src/main/java/sonia/scm/repository/api/ScmProtocolProvider.java
@@ -3,10 +3,29 @@ package sonia.scm.repository.api;
 import sonia.scm.plugin.ExtensionPoint;
 import sonia.scm.repository.Repository;
 
+/**
+ * Provider for scm native protocols.
+ *
+ * @param  type of protocol
+ *
+ * @since 2.0.0
+ */
 @ExtensionPoint(multi = true)
 public interface ScmProtocolProvider {
 
+  /**
+   * Returns type of repository (e.g.: git, svn, hg, etc.)
+   *
+   * @return name of type
+   */
   String getType();
 
+  /**
+   * Returns protocol for the given repository.
+   *
+   * @param repository repository
+   *
+   * @return protocol for repository
+   */
   T get(Repository repository);
 }
diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/HookEventFacade.java b/scm-core/src/main/java/sonia/scm/repository/spi/HookEventFacade.java
index 4bbe61ea41..27cafd1a16 100644
--- a/scm-core/src/main/java/sonia/scm/repository/spi/HookEventFacade.java
+++ b/scm-core/src/main/java/sonia/scm/repository/spi/HookEventFacade.java
@@ -74,19 +74,24 @@ public final class HookEventFacade
   //~--- methods --------------------------------------------------------------
 
   public HookEventHandler handle(String id) {
-    return handle(repositoryManagerProvider.get().get(id));
+    Repository repository = repositoryManagerProvider.get().get(id);
+    if (repository == null)
+    {
+      throw notFound(entity("repository", id));
+    }
+    return handle(repository);
   }
 
   public HookEventHandler handle(NamespaceAndName namespaceAndName) {
-    return handle(repositoryManagerProvider.get().get(namespaceAndName));
+    Repository repository = repositoryManagerProvider.get().get(namespaceAndName);
+    if (repository == null)
+    {
+      throw notFound(entity(namespaceAndName));
+    }
+    return handle(repository);
   }
 
   public HookEventHandler handle(Repository repository) {
-    if (repository == null)
-    {
-      throw notFound(entity(repository));
-    }
-
     return new HookEventHandler(repositoryManagerProvider.get(),
       hookContextFactory, repository);
   }
diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/MergeCommandRequest.java b/scm-core/src/main/java/sonia/scm/repository/spi/MergeCommandRequest.java
index baf03a0aef..45dfc0b2b7 100644
--- a/scm-core/src/main/java/sonia/scm/repository/spi/MergeCommandRequest.java
+++ b/scm-core/src/main/java/sonia/scm/repository/spi/MergeCommandRequest.java
@@ -5,7 +5,6 @@ import com.google.common.base.Objects;
 import com.google.common.base.Strings;
 import sonia.scm.Validateable;
 import sonia.scm.repository.Person;
-import sonia.scm.util.Util;
 
 import java.io.Serializable;
 
diff --git a/scm-core/src/main/java/sonia/scm/security/AccessToken.java b/scm-core/src/main/java/sonia/scm/security/AccessToken.java
index 714b09eff8..3341500199 100644
--- a/scm-core/src/main/java/sonia/scm/security/AccessToken.java
+++ b/scm-core/src/main/java/sonia/scm/security/AccessToken.java
@@ -31,77 +31,105 @@
 package sonia.scm.security;
 
 import java.util.Date;
+import java.util.Map;
 import java.util.Optional;
+import java.util.Set;
 
 /**
  * An access token can be used to access scm-manager without providing username and password. An {@link AccessToken} can
  * be issued from a restful webservice endpoint by providing credentials. After the token was issued, the token must be
  * send along with every request. The token should be send in its compact representation as bearer authorization header
  * or as cookie.
- * 
+ *
  * @author Sebastian Sdorra
  * @since 2.0.0
  */
 public interface AccessToken {
-  
+
   /**
    * Returns unique id of the access token.
-   * 
+   *
    * @return unique id
    */
   String getId();
-  
+
   /**
    * Returns name of subject which identifies the principal.
-   * 
+   *
    * @return name of subject
    */
   String getSubject();
-  
+
   /**
    * Returns optional issuer. The issuer identifies the principal that issued the token.
-   * 
+   *
    * @return optional issuer
    */
   Optional getIssuer();
-  
+
   /**
    * Returns time at which the token was issued.
-   * 
+   *
    * @return time at which the token was issued
    */
   Date getIssuedAt();
-  
+
   /**
    * Returns the expiration time of token.
-   * 
+   *
    * @return expiration time
    */
   Date getExpiration();
-  
+
   /**
-   * Returns the scope of the token. The scope is able to reduce the permissions of the subject in the context of this 
+   * Returns refresh expiration of token.
+   *
+   * @return refresh expiration
+   */
+  Optional getRefreshExpiration();
+
+  /**
+   * Returns id of the parent key.
+   *
+   * @return parent key id
+   */
+  Optional getParentKey();
+
+  /**
+   * Returns the scope of the token. The scope is able to reduce the permissions of the subject in the context of this
    * token. For example we could issue a token which can only be used to read a single repository. for more informations
    * please have a look at {@link Scope}.
-   * 
+   *
    * @return scope of token.
    */
   Scope getScope();
-  
+
+  /**
+   * Returns name of groups, in which the user should be a member.
+   *
+   * @return name of groups
+   */
+  Set getGroups();
+
   /**
    * Returns an optional value of a custom token field.
-   * 
+   *
    * @param  type of field
    * @param key key of token field
-   * 
+   *
    * @return optional value of custom field
    */
    Optional getCustom(String key);
-  
+
   /**
    * Returns compact representation of token.
-   * 
+   *
    * @return compact representation
    */
   String compact();
+
+  /**
+   * Returns read only map of all claim keys with their values.
+   */
+  Map getClaims();
 }
diff --git a/scm-core/src/main/java/sonia/scm/security/AccessTokenBuilder.java b/scm-core/src/main/java/sonia/scm/security/AccessTokenBuilder.java
index dd7986c22a..0924716bd8 100644
--- a/scm-core/src/main/java/sonia/scm/security/AccessTokenBuilder.java
+++ b/scm-core/src/main/java/sonia/scm/security/AccessTokenBuilder.java
@@ -74,21 +74,40 @@ public interface AccessTokenBuilder {
    * Sets the expiration for the token.
    * 
    * @param count expiration count
-   * @param unit expirtation unit
+   * @param unit expiration unit
    * 
    * @return {@code this}
    */
   AccessTokenBuilder expiresIn(long count, TimeUnit unit);
-  
+
+  /**
+   * Sets the time how long this token may be refreshed. Set this to 0 (zero) to disable automatic refresh.
+   *
+   * @param count Time unit count. If set to 0, automatic refresh is disabled.
+   * @param unit time unit
+   *
+   * @return {@code this}
+   */
+  AccessTokenBuilder refreshableFor(long count, TimeUnit unit);
+
   /**
    * Reduces the permissions of the token by providing a scope.
-   * 
+   *
    * @param scope scope of token
-   * 
+   *
    * @return {@code this}
    */
   AccessTokenBuilder scope(Scope scope);
-  
+
+  /**
+   * Define the logged in user as member of the given groups.
+   *
+   * @param groups group names
+   *
+   * @return {@code this}
+   */
+  AccessTokenBuilder groups(String... groups);
+
   /**
    * Creates a new {@link AccessToken} with the provided settings.
    * 
diff --git a/scm-core/src/main/java/sonia/scm/security/AccessTokenCookieIssuer.java b/scm-core/src/main/java/sonia/scm/security/AccessTokenCookieIssuer.java
new file mode 100644
index 0000000000..999c693b8f
--- /dev/null
+++ b/scm-core/src/main/java/sonia/scm/security/AccessTokenCookieIssuer.java
@@ -0,0 +1,30 @@
+package sonia.scm.security;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * Generates cookies and invalidates access token cookies.
+ *
+ * @author Sebastian Sdorra
+ * @since 2.0.0
+ */
+public interface AccessTokenCookieIssuer {
+
+  /**
+   * Creates a cookie for token authentication and attaches it to the response.
+   *
+   * @param request http servlet request
+   * @param response http servlet response
+   * @param accessToken access token
+   */
+  void authenticate(HttpServletRequest request, HttpServletResponse response, AccessToken accessToken);
+  /**
+   * Invalidates the authentication cookie.
+   *
+   * @param request http servlet request
+   * @param response http servlet response
+   */
+  void invalidate(HttpServletRequest request, HttpServletResponse response);
+
+}
diff --git a/scm-core/src/main/java/sonia/scm/security/TokenClaimsValidator.java b/scm-core/src/main/java/sonia/scm/security/AccessTokenValidator.java
similarity index 82%
rename from scm-core/src/main/java/sonia/scm/security/TokenClaimsValidator.java
rename to scm-core/src/main/java/sonia/scm/security/AccessTokenValidator.java
index 4389e7bfb7..24a92929f9 100644
--- a/scm-core/src/main/java/sonia/scm/security/TokenClaimsValidator.java
+++ b/scm-core/src/main/java/sonia/scm/security/AccessTokenValidator.java
@@ -30,26 +30,25 @@
  */
 package sonia.scm.security;
 
-import java.util.Map;
 import sonia.scm.plugin.ExtensionPoint;
 
 /**
- * Validates the claims of a jwt token. The validator is called durring authentication
- * with a jwt token.
+ * Validates an {@link AccessToken}. The validator is called during authentication
+ * with an {@link AccessToken}.
  * 
  * @author Sebastian Sdorra
  * @since 2.0.0
  */
 @ExtensionPoint
-public interface TokenClaimsValidator {
+public interface AccessTokenValidator {
   
   /**
-   * Returns {@code true} if the claims is valid. If the token is not valid and the
+   * Returns {@code true} if the {@link AccessToken} is valid. If the token is not valid and the
    * method returns {@code false}, the authentication is treated as failed.
    * 
-   * @param claims token claims
+   * @param token the access token to verify
    * 
-   * @return {@code true} if the claims is valid
+   * @return {@code true} if the token is valid
    */
-  boolean validate(Map claims); 
+  boolean validate(AccessToken token);
 }
diff --git a/scm-core/src/main/java/sonia/scm/security/AssignedPermission.java b/scm-core/src/main/java/sonia/scm/security/AssignedPermission.java
index 56b8d04a41..c98d81f8ba 100644
--- a/scm-core/src/main/java/sonia/scm/security/AssignedPermission.java
+++ b/scm-core/src/main/java/sonia/scm/security/AssignedPermission.java
@@ -89,8 +89,12 @@ public class AssignedPermission implements PermissionObject, Serializable
    */
   public AssignedPermission(String name, String permission)
   {
-    this.name = name;
-    this.permission = permission;
+    this(name, new PermissionDescriptor(permission));
+  }
+
+  public AssignedPermission(String name, PermissionDescriptor permission)
+  {
+    this(name, false, permission);
   }
 
   /**
@@ -103,6 +107,12 @@ public class AssignedPermission implements PermissionObject, Serializable
    */
   public AssignedPermission(String name, boolean groupPermission,
     String permission)
+  {
+    this(name, groupPermission, new PermissionDescriptor(permission));
+  }
+
+  public AssignedPermission(String name, boolean groupPermission,
+    PermissionDescriptor permission)
   {
     this.name = name;
     this.groupPermission = groupPermission;
@@ -173,12 +183,9 @@ public class AssignedPermission implements PermissionObject, Serializable
   }
 
   /**
-   * Returns the string representation of the permission.
-   *
-   *
-   * @return string representation of the permission
+   * Returns the description of the permission.
    */
-  public String getPermission()
+  public PermissionDescriptor getPermission()
   {
     return permission;
   }
@@ -205,5 +212,5 @@ public class AssignedPermission implements PermissionObject, Serializable
   private String name;
 
   /** string representation of the permission */
-  private String permission;
+  private PermissionDescriptor permission;
 }
diff --git a/scm-core/src/main/java/sonia/scm/security/StoredAssignedPermissionEvent.java b/scm-core/src/main/java/sonia/scm/security/AssignedPermissionEvent.java
similarity index 90%
rename from scm-core/src/main/java/sonia/scm/security/StoredAssignedPermissionEvent.java
rename to scm-core/src/main/java/sonia/scm/security/AssignedPermissionEvent.java
index ad93bf25a9..9ebea488a5 100644
--- a/scm-core/src/main/java/sonia/scm/security/StoredAssignedPermissionEvent.java
+++ b/scm-core/src/main/java/sonia/scm/security/AssignedPermissionEvent.java
@@ -51,7 +51,7 @@ import java.io.Serializable;
  * @since 1.31
  */
 @Event
-public final class StoredAssignedPermissionEvent implements Serializable
+public final class AssignedPermissionEvent implements Serializable
 {
 
   /** serial version uid */
@@ -60,14 +60,14 @@ public final class StoredAssignedPermissionEvent implements Serializable
   //~--- constructors ---------------------------------------------------------
 
   /**
-   * Constructs a new StoredAssignedPermissionEvent.
+   * Constructs a new AssignedPermissionEvent.
    *
    *
    * @param type type of the event
    * @param permission permission object which has changed
    */
-  public StoredAssignedPermissionEvent(HandlerEventType type,
-    StoredAssignedPermission permission)
+  public AssignedPermissionEvent(HandlerEventType type,
+                                 AssignedPermission permission)
   {
     this.type = type;
     this.permission = permission;
@@ -91,8 +91,8 @@ public final class StoredAssignedPermissionEvent implements Serializable
       return false;
     }
 
-    final StoredAssignedPermissionEvent other =
-      (StoredAssignedPermissionEvent) obj;
+    final AssignedPermissionEvent other =
+      (AssignedPermissionEvent) obj;
 
     return Objects.equal(type, other.type)
       && Objects.equal(permission, other.permission);
@@ -140,7 +140,7 @@ public final class StoredAssignedPermissionEvent implements Serializable
    *
    * @return changed permission
    */
-  public StoredAssignedPermission getPermission()
+  public AssignedPermission getPermission()
   {
     return permission;
   }
@@ -148,7 +148,7 @@ public final class StoredAssignedPermissionEvent implements Serializable
   //~--- fields ---------------------------------------------------------------
 
   /** changed permission */
-  private StoredAssignedPermission permission;
+  private AssignedPermission permission;
 
   /** type of the event */
   private HandlerEventType type;
diff --git a/scm-core/src/main/java/sonia/scm/security/AuthorizationCollector.java b/scm-core/src/main/java/sonia/scm/security/AuthorizationCollector.java
index d1151c3b35..b12d8b6978 100644
--- a/scm-core/src/main/java/sonia/scm/security/AuthorizationCollector.java
+++ b/scm-core/src/main/java/sonia/scm/security/AuthorizationCollector.java
@@ -34,6 +34,7 @@ package sonia.scm.security;
 //~--- non-JDK imports --------------------------------------------------------
 
 import org.apache.shiro.authz.AuthorizationInfo;
+import org.apache.shiro.subject.PrincipalCollection;
 import sonia.scm.plugin.ExtensionPoint;
 
 /**
@@ -42,15 +43,16 @@ import sonia.scm.plugin.ExtensionPoint;
  * @author Sebastian Sdorra
  * @since 2.0.0
  */
-@ExtensionPoint(multi = false)
+@ExtensionPoint
 public interface AuthorizationCollector
 {
 
   /**
    * Returns {@link AuthorizationInfo} for the authenticated user.
    *
+   * @param principalCollection collected principals
    *
    * @return {@link AuthorizationInfo} for authenticated user
    */
-  public AuthorizationInfo collect();
+  AuthorizationInfo collect(PrincipalCollection principalCollection);
 }
diff --git a/scm-core/src/main/java/sonia/scm/security/DAORealmHelper.java b/scm-core/src/main/java/sonia/scm/security/DAORealmHelper.java
index 3fcab8762c..115bb082c9 100644
--- a/scm-core/src/main/java/sonia/scm/security/DAORealmHelper.java
+++ b/scm-core/src/main/java/sonia/scm/security/DAORealmHelper.java
@@ -37,7 +37,6 @@ import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSet.Builder;
-import org.apache.shiro.authc.AuthenticationException;
 import org.apache.shiro.authc.AuthenticationInfo;
 import org.apache.shiro.authc.AuthenticationToken;
 import org.apache.shiro.authc.DisabledAccountException;
@@ -54,6 +53,8 @@ import sonia.scm.group.GroupNames;
 import sonia.scm.user.User;
 import sonia.scm.user.UserDAO;
 
+import java.util.Collections;
+
 import static com.google.common.base.Preconditions.checkArgument;
 
 /**
@@ -63,8 +64,7 @@ import static com.google.common.base.Preconditions.checkArgument;
  * @author Sebastian Sdorra
  * @since 2.0.0
  */
-public final class DAORealmHelper
-{
+public final class DAORealmHelper {
 
   /**
    * the logger for DAORealmHelper
@@ -109,37 +109,37 @@ public final class DAORealmHelper
   public CredentialsMatcher wrapCredentialsMatcher(CredentialsMatcher credentialsMatcher) {
     return new RetryLimitPasswordMatcher(loginAttemptHandler, credentialsMatcher);
   }
-  
+
   /**
-   * Method description
+   * Creates {@link AuthenticationInfo} from a {@link UsernamePasswordToken}. The method accepts
+   * {@link AuthenticationInfo} as argument, so that the caller does not need to cast.
    *
+   * @param token authentication token, it must be {@link UsernamePasswordToken}
    *
-   * @param token
-   *
-   * @return
-   *
-   * @throws AuthenticationException
+   * @return authentication info
    */
-  public AuthenticationInfo getAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
+  public AuthenticationInfo getAuthenticationInfo(AuthenticationToken token) {
     checkArgument(token instanceof UsernamePasswordToken, "%s is required", UsernamePasswordToken.class);
 
     UsernamePasswordToken upt = (UsernamePasswordToken) token;
     String principal = upt.getUsername();
 
-    return getAuthenticationInfo(principal, null, null);
+    return getAuthenticationInfo(principal, null, null, Collections.emptySet());
   }
 
   /**
-   * Method description
+   * Returns a builder for {@link AuthenticationInfo}.
    *
+   * @param principal name of principal (username)
    *
-   * @param principal
-   * @param credentials
-   * @param scope
-   *
-   * @return
+   * @return authentication info builder
    */
-  public AuthenticationInfo getAuthenticationInfo(String principal, String credentials, Scope scope) {
+  public AuthenticationInfoBuilder authenticationInfoBuilder(String principal) {
+    return new AuthenticationInfoBuilder(principal);
+  }
+
+
+  private AuthenticationInfo getAuthenticationInfo(String principal, String credentials, Scope scope, Iterable groups) {
     checkArgument(!Strings.isNullOrEmpty(principal), "username is required");
 
     LOG.debug("try to authenticate {}", principal);
@@ -157,7 +157,7 @@ public final class DAORealmHelper
 
     collection.add(principal, realm);
     collection.add(user, realm);
-    collection.add(collectGroups(principal), realm);
+    collection.add(collectGroups(principal, groups), realm);
     collection.add(MoreObjects.firstNonNull(scope, Scope.empty()), realm);
 
     String creds = credentials;
@@ -171,11 +171,15 @@ public final class DAORealmHelper
 
   //~--- methods --------------------------------------------------------------
 
-  private GroupNames collectGroups(String principal) {
+  private GroupNames collectGroups(String principal, Iterable groupNames) {
     Builder builder = ImmutableSet.builder();
 
     builder.add(GroupNames.AUTHENTICATED);
 
+    for (String group : groupNames) {
+      builder.add(group);
+    }
+
     for (Group group : groupDAO.getAll()) {
       if (group.isMember(principal)) {
         builder.add(group.getName());
@@ -187,6 +191,69 @@ public final class DAORealmHelper
     return groups;
   }
 
+  /**
+   * Builder class for {@link AuthenticationInfo}.
+   */
+  public class AuthenticationInfoBuilder {
+
+    private final String principal;
+
+    private String credentials;
+    private Scope scope;
+    private Iterable groups = Collections.emptySet();
+
+    private AuthenticationInfoBuilder(String principal) {
+      this.principal = principal;
+    }
+
+    /**
+     * With credentials uses the given credentials for the {@link AuthenticationInfo}, this is particularly important
+     * for caching purposes.
+     *
+     * @param credentials credentials such as password
+     *
+     * @return {@code this}
+     */
+    public AuthenticationInfoBuilder withCredentials(String credentials) {
+      this.credentials = credentials;
+      return this;
+    }
+
+    /**
+     * With the scope object it is possible to limit the access permissions to scm-manager.
+     *
+     * @param scope scope object
+     *
+     * @return {@code this}
+     */
+    public AuthenticationInfoBuilder withScope(Scope scope) {
+      this.scope = scope;
+      return this;
+    }
+
+    /**
+     * With groups adds extra groups, besides those which come from the {@link GroupDAO}, to the authentication info.
+     *
+     * @param groups extra groups
+     *
+     * @return {@code this}
+     */
+    public AuthenticationInfoBuilder withGroups(Iterable groups) {
+      this.groups = groups;
+      return this;
+    }
+
+    /**
+     * Build creates the authentication info from the given information.
+     *
+     * @return authentication info
+     */
+    public AuthenticationInfo build() {
+      return getAuthenticationInfo(principal, credentials, scope, groups);
+    }
+
+  }
+
   private static class RetryLimitPasswordMatcher implements CredentialsMatcher {
 
     private final LoginAttemptHandler loginAttemptHandler;
diff --git a/scm-core/src/main/java/sonia/scm/security/DefaultCipherHandler.java b/scm-core/src/main/java/sonia/scm/security/DefaultCipherHandler.java
index b4f0d81cd3..9c1fa590cc 100644
--- a/scm-core/src/main/java/sonia/scm/security/DefaultCipherHandler.java
+++ b/scm-core/src/main/java/sonia/scm/security/DefaultCipherHandler.java
@@ -164,7 +164,7 @@ public class DefaultCipherHandler implements CipherHandler {
     String result = null;
 
     try {
-      byte[] encodedInput = Base64.getDecoder().decode(value);
+      byte[] encodedInput = Base64.getUrlDecoder().decode(value);
       byte[] salt = new byte[SALT_LENGTH];
       byte[] encoded = new byte[encodedInput.length - SALT_LENGTH];
 
@@ -221,7 +221,7 @@ public class DefaultCipherHandler implements CipherHandler {
       System.arraycopy(salt, 0, result, 0, SALT_LENGTH);
       System.arraycopy(encodedInput, 0, result, SALT_LENGTH,
         result.length - SALT_LENGTH);
-      res = new String(Base64.getEncoder().encode(result), ENCODING);
+      res = new String(Base64.getUrlEncoder().encode(result), ENCODING);
     } catch (IOException | GeneralSecurityException ex) {
       throw new CipherException("could not encode string", ex);
     }
diff --git a/scm-core/src/main/java/sonia/scm/security/Permission.java b/scm-core/src/main/java/sonia/scm/security/Permission.java
new file mode 100644
index 0000000000..a7aa2798e7
--- /dev/null
+++ b/scm-core/src/main/java/sonia/scm/security/Permission.java
@@ -0,0 +1,13 @@
+package sonia.scm.security;
+
+import com.github.sdorra.ssp.PermissionObject;
+import com.github.sdorra.ssp.StaticPermissions;
+
+@StaticPermissions(
+  value = "permission",
+  permissions = {},
+  globalPermissions = {"list", "read", "assign"},
+  custom = true, customGlobal = true
+)
+public interface Permission extends PermissionObject {
+}
diff --git a/scm-core/src/main/java/sonia/scm/security/PermissionDescriptor.java b/scm-core/src/main/java/sonia/scm/security/PermissionDescriptor.java
index 20d95958a1..8d95131ee6 100644
--- a/scm-core/src/main/java/sonia/scm/security/PermissionDescriptor.java
+++ b/scm-core/src/main/java/sonia/scm/security/PermissionDescriptor.java
@@ -39,7 +39,6 @@ import com.google.common.base.Objects;
 
 import javax.xml.bind.annotation.XmlAccessType;
 import javax.xml.bind.annotation.XmlAccessorType;
-import javax.xml.bind.annotation.XmlElement;
 import javax.xml.bind.annotation.XmlRootElement;
 import java.io.Serializable;
 
@@ -67,19 +66,8 @@ public class PermissionDescriptor implements Serializable
    */
   public PermissionDescriptor() {}
 
-  /**
-   * Constructs ...
-   *
-   *
-   * @param displayName
-   * @param description
-   * @param value
-   */
-  public PermissionDescriptor(String displayName, String description,
-    String value)
+  public PermissionDescriptor(String value)
   {
-    this.displayName = displayName;
-    this.description = description;
     this.value = value;
   }
 
@@ -103,9 +91,7 @@ public class PermissionDescriptor implements Serializable
 
     final PermissionDescriptor other = (PermissionDescriptor) obj;
 
-    return Objects.equal(displayName, other.displayName)
-      && Objects.equal(description, other.description)
-      && Objects.equal(value, other.value);
+    return Objects.equal(value, other.value);
   }
 
   /**
@@ -114,7 +100,7 @@ public class PermissionDescriptor implements Serializable
   @Override
   public int hashCode()
   {
-    return Objects.hashCode(displayName, description, value);
+    return value.hashCode();
   }
 
   /**
@@ -126,8 +112,6 @@ public class PermissionDescriptor implements Serializable
 
     //J-
     return MoreObjects.toStringHelper(this)
-                  .add("displayName", displayName)
-                  .add("description", description)
                   .add("value", value)
                   .toString();
 
@@ -136,28 +120,6 @@ public class PermissionDescriptor implements Serializable
 
   //~--- get methods ----------------------------------------------------------
 
-  /**
-   * Returns the description of the permission.
-   *
-   *
-   * @return description
-   */
-  public String getDescription()
-  {
-    return description;
-  }
-
-  /**
-   * Returns the display name of the permission.
-   *
-   *
-   * @return display name
-   */
-  public String getDisplayName()
-  {
-    return displayName;
-  }
-
   /**
    * Returns the string representation of the permission.
    *
@@ -171,13 +133,6 @@ public class PermissionDescriptor implements Serializable
 
   //~--- fields ---------------------------------------------------------------
 
-  /** description */
-  private String description;
-
-  /** display name */
-  @XmlElement(name = "display-name")
-  private String displayName;
-
   /** value */
   private String value;
 }
diff --git a/scm-core/src/main/java/sonia/scm/security/RepositoryPermission.java b/scm-core/src/main/java/sonia/scm/security/RepositoryPermission.java
deleted file mode 100644
index 1b0229d6f5..0000000000
--- a/scm-core/src/main/java/sonia/scm/security/RepositoryPermission.java
+++ /dev/null
@@ -1,230 +0,0 @@
-/**
- * Copyright (c) 2010, Sebastian Sdorra
- * All rights reserved.
- *
- * Redistribution and use in source and binary forms, with or without
- * modification, are permitted provided that the following conditions are met:
- *
- * 1. Redistributions of source code must retain the above copyright notice,
- *    this list of conditions and the following disclaimer.
- * 2. Redistributions in binary form must reproduce the above copyright notice,
- *    this list of conditions and the following disclaimer in the documentation
- *    and/or other materials provided with the distribution.
- * 3. Neither the name of SCM-Manager; nor the names of its
- *    contributors may be used to endorse or promote products derived from this
- *    software without specific prior written permission.
- *
- * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
- * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
- * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
- * DISCLAIMED.  IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY
- * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
- * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
- * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
- * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
- * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
- * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
- *
- * http://bitbucket.org/sdorra/scm-manager
- *
- */
-
-
-
-package sonia.scm.security;
-
-//~--- non-JDK imports --------------------------------------------------------
-
-import com.google.common.base.MoreObjects;
-import com.google.common.base.Objects;
-import org.apache.shiro.authz.Permission;
-import sonia.scm.repository.PermissionType;
-import sonia.scm.repository.Repository;
-
-import java.io.Serializable;
-
-//~--- JDK imports ------------------------------------------------------------
-
-/**
- * This class represents the permission to a repository of a user.
- *
- * @author Sebastian Sdorra
- * @since 1.21
- */
-public final class RepositoryPermission
-  implements StringablePermission, Serializable
-{
-
-  /**
-   * Type string of the permission
-   * @since 1.31
-   */
-  public static final String TYPE = "repository";
-
-  /** Field description */
-  public static final String WILDCARD = "*";
-
-  /** Field description */
-  private static final long serialVersionUID = 3832804235417228043L;
-
-  //~--- constructors ---------------------------------------------------------
-
-  /**
-   * Constructs ...
-   *
-   *
-   * @param repository
-   * @param permissionType
-   */
-  public RepositoryPermission(Repository repository,
-    PermissionType permissionType)
-  {
-    this(repository.getId(), permissionType);
-  }
-
-  /**
-   * Constructs ...
-   *
-   *
-   * @param repositoryId
-   * @param permissionType
-   */
-  public RepositoryPermission(String repositoryId,
-    PermissionType permissionType)
-  {
-    this.repositoryId = repositoryId;
-    this.permissionType = permissionType;
-  }
-
-  //~--- methods --------------------------------------------------------------
-
-  /**
-   * Method description
-   *
-   *
-   * @param obj
-   *
-   * @return
-   */
-  @Override
-  public boolean equals(Object obj)
-  {
-    if (obj == null)
-    {
-      return false;
-    }
-
-    if (getClass() != obj.getClass())
-    {
-      return false;
-    }
-
-    final RepositoryPermission other = (RepositoryPermission) obj;
-
-    return Objects.equal(repositoryId, other.repositoryId)
-      && Objects.equal(permissionType, other.permissionType);
-  }
-
-  /**
-   * Method description
-   *
-   *
-   * @return
-   */
-  @Override
-  public int hashCode()
-  {
-    return Objects.hashCode(repositoryId, permissionType);
-  }
-
-  /**
-   * Method description
-   *
-   *
-   * @param p
-   *
-   * @return
-   */
-  @Override
-  public boolean implies(Permission p)
-  {
-    boolean result = false;
-
-    if (p instanceof RepositoryPermission)
-    {
-      RepositoryPermission rp = (RepositoryPermission) p;
-
-      //J-
-      result = (repositoryId.equals(WILDCARD) || repositoryId.equals(rp.repositoryId)) 
-                && (permissionType.getValue() >= rp.permissionType.getValue());
-      //J+
-    }
-
-    return result;
-  }
-
-  /**
-   * Method description
-   *
-   *
-   * @return
-   */
-  @Override
-  public String toString()
-  {
-    //J-
-    return MoreObjects.toStringHelper(this)
-                  .add("repositoryId", repositoryId)
-                  .add("permissionType", permissionType)
-                  .toString();
-    //J+
-  }
-
-  //~--- get methods ----------------------------------------------------------
-
-  /**
-   * Method description
-   *
-   *
-   * @return
-   */
-  @Override
-  public String getAsString()
-  {
-    StringBuilder buffer = new StringBuilder(TYPE);
-
-    buffer.append(":").append(repositoryId).append(":").append(permissionType);
-
-    return buffer.toString();
-  }
-
-  /**
-   * Method description
-   *
-   *
-   * @return
-   */
-  public PermissionType getPermissionType()
-  {
-    return permissionType;
-  }
-
-  /**
-   * Method description
-   *
-   *
-   * @return
-   */
-  public String getRepositoryId()
-  {
-    return repositoryId;
-  }
-
-  //~--- fields ---------------------------------------------------------------
-
-  /** Field description */
-  private PermissionType permissionType;
-
-  /** Field description */
-  private String repositoryId;
-}
diff --git a/scm-core/src/main/java/sonia/scm/security/SecuritySystem.java b/scm-core/src/main/java/sonia/scm/security/SecuritySystem.java
index f43afcb7f2..174b64f5e6 100644
--- a/scm-core/src/main/java/sonia/scm/security/SecuritySystem.java
+++ b/scm-core/src/main/java/sonia/scm/security/SecuritySystem.java
@@ -32,13 +32,8 @@
 
 package sonia.scm.security;
 
-//~--- non-JDK imports --------------------------------------------------------
-
-import com.google.common.base.Predicate;
-
-//~--- JDK imports ------------------------------------------------------------
-
-import java.util.List;
+import java.util.Collection;
+import java.util.function.Predicate;
 
 /**
  * The SecuritySystem manages global permissions.
@@ -57,7 +52,7 @@ public interface SecuritySystem
    *
    * @return stored permission
    */
-  public StoredAssignedPermission addPermission(AssignedPermission permission);
+  void addPermission(AssignedPermission permission);
 
   /**
    * Delete stored permission.
@@ -65,51 +60,17 @@ public interface SecuritySystem
    *
    * @param permission permission to be deleted
    */
-  public void deletePermission(StoredAssignedPermission permission);
-
-  /**
-   * Delete stored permission.
-   *
-   *
-   * @param id id  of the permission
-   */
-  public void deletePermission(String id);
-
-  /**
-   * Modify stored permission.
-   *
-   *
-   * @param permission stored permisison
-   */
-  public void modifyPermission(StoredAssignedPermission permission);
+  void deletePermission(AssignedPermission permission);
 
   //~--- get methods ----------------------------------------------------------
 
-  /**
-   * Return all stored permissions.
-   *
-   *
-   * @return stored permission
-   */
-  public List getAllPermissions();
-
   /**
    * Return all available permissions.
    *
    *
    * @return available permissions
    */
-  public List getAvailablePermissions();
-
-  /**
-   * Return the stored permission which is stored with the given id.
-   *
-   *
-   * @param id id of the stored permission
-   *
-   * @return stored permission
-   */
-  public StoredAssignedPermission getPermission(String id);
+  Collection getAvailablePermissions();
 
   /**
    * Returns all stored permissions which are matched by the given
@@ -120,6 +81,5 @@ public interface SecuritySystem
    *
    * @return filtered permissions
    */
-  public List getPermissions(
-    Predicate predicate);
+  Collection getPermissions(Predicate predicate);
 }
diff --git a/scm-core/src/main/java/sonia/scm/security/SyncingRealmHelper.java b/scm-core/src/main/java/sonia/scm/security/SyncingRealmHelper.java
index 0e3c06e32d..da11400140 100644
--- a/scm-core/src/main/java/sonia/scm/security/SyncingRealmHelper.java
+++ b/scm-core/src/main/java/sonia/scm/security/SyncingRealmHelper.java
@@ -45,7 +45,12 @@ import sonia.scm.user.User;
 import sonia.scm.user.UserManager;
 import sonia.scm.web.security.AdministrationContext;
 
+import java.util.Arrays;
 import java.util.Collection;
+import java.util.Collections;
+
+import static java.util.Arrays.asList;
+import static java.util.Collections.emptyList;
 
 /**
  * Helper class for syncing realms. The class should simplify the creation of realms, which are syncing authenticated
@@ -80,22 +85,107 @@ public final class SyncingRealmHelper {
     this.groupManager = groupManager;
   }
 
-  //~--- methods --------------------------------------------------------------
   /**
    * Create {@link AuthenticationInfo} from user and groups.
-   *
-   *
-   * @param realm name of the realm
-   * @param user authenticated user
-   * @param groups groups of the authenticated user
-   *
-   * @return authentication info
    */
-  public AuthenticationInfo createAuthenticationInfo(String realm, User user,
-    String... groups) {
-    return createAuthenticationInfo(realm, user, ImmutableList.copyOf(groups));
+  public AuthenticationInfoBuilder.ForRealm authenticationInfo() {
+    return new AuthenticationInfoBuilder().new ForRealm();
   }
 
+  public class AuthenticationInfoBuilder {
+    private String realm;
+    private User user;
+    private Collection groups;
+    private boolean external;
+
+    private AuthenticationInfo build() {
+      return SyncingRealmHelper.this.createAuthenticationInfo(realm, user, groups, external);
+    }
+
+    public class ForRealm {
+      private ForRealm() {
+      }
+
+      /**
+       * Sets the realm.
+       * @param realm name of the realm
+       */
+      public ForUser forRealm(String realm) {
+        AuthenticationInfoBuilder.this.realm = realm;
+        return AuthenticationInfoBuilder.this.new ForUser();
+      }
+    }
+
+    public class ForUser {
+      private ForUser() {
+      }
+
+      /**
+       * Sets the user.
+       * @param user authenticated user
+       */
+      public AuthenticationInfoBuilder.WithGroups andUser(User user) {
+        AuthenticationInfoBuilder.this.user = user;
+        return AuthenticationInfoBuilder.this.new WithGroups();
+      }
+    }
+
+    public class WithGroups {
+      private WithGroups() {
+      }
+
+      /**
+       * Build the authentication info without groups.
+       * @return The complete {@link AuthenticationInfo}
+       */
+      public AuthenticationInfo withoutGroups() {
+        return withGroups(emptyList());
+      }
+
+      /**
+       * Set the internal groups for the user.
+       * @param groups groups of the authenticated user
+       * @return The complete {@link AuthenticationInfo}
+       */
+      public AuthenticationInfo withGroups(String... groups) {
+        return withGroups(asList(groups));
+      }
+
+      /**
+       * Set the internal groups for the user.
+       * @param groups groups of the authenticated user
+       * @return The complete {@link AuthenticationInfo}
+       */
+      public AuthenticationInfo withGroups(Collection groups) {
+        AuthenticationInfoBuilder.this.groups = groups;
+        AuthenticationInfoBuilder.this.external = false;
+        return build();
+      }
+
+      /**
+       * Set the external groups for the user.
+       * @param groups external groups of the authenticated user
+       * @return The complete {@link AuthenticationInfo}
+       */
+      public AuthenticationInfo withExternalGroups(String... groups) {
+        return withExternalGroups(asList(groups));
+      }
+
+      /**
+       * Set the external groups for the user.
+       * @param groups external groups of the authenticated user
+       * @return The complete {@link AuthenticationInfo}
+       */
+      public AuthenticationInfo withExternalGroups(Collection groups) {
+        AuthenticationInfoBuilder.this.groups = groups;
+        AuthenticationInfoBuilder.this.external = true;
+        return build();
+      }
+    }
+  }
+
+  //~--- methods --------------------------------------------------------------
+
   /**
    * Create {@link AuthenticationInfo} from user and groups.
    *
@@ -106,13 +196,13 @@ public final class SyncingRealmHelper {
    *
    * @return authentication info
    */
-  public AuthenticationInfo createAuthenticationInfo(String realm, User user,
-    Collection groups) {
+  private AuthenticationInfo createAuthenticationInfo(String realm, User user,
+    Collection groups, boolean externalGroups) {
     SimplePrincipalCollection collection = new SimplePrincipalCollection();
 
     collection.add(user.getId(), realm);
     collection.add(user, realm);
-    collection.add(new GroupNames(groups), realm);
+    collection.add(new GroupNames(groups, externalGroups), realm);
 
     return new SimpleAuthenticationInfo(collection, user.getPassword());
   }
@@ -161,6 +251,6 @@ public final class SyncingRealmHelper {
 
         }
       }
-      });
-    }
+    });
   }
+}
diff --git a/scm-core/src/main/java/sonia/scm/store/BlobStoreFactory.java b/scm-core/src/main/java/sonia/scm/store/BlobStoreFactory.java
index 941b3923d1..cf58fc43c7 100644
--- a/scm-core/src/main/java/sonia/scm/store/BlobStoreFactory.java
+++ b/scm-core/src/main/java/sonia/scm/store/BlobStoreFactory.java
@@ -32,9 +32,25 @@
 
 package sonia.scm.store;
 
+import sonia.scm.repository.Repository;
+
 /**
- * The BlobStoreFactory can be used to create new or get existing
- * {@link BlobStore}s.
+ * The BlobStoreFactory can be used to create a new or get an existing {@link BlobStore}s.
+ * 
+ * You can either create a global {@link BlobStore} or a {@link BlobStore} for a specific repository. To create a global + * {@link BlobStore} call: + *
+ *     blobStoreFactory
+ *       .withName("name")
+ *       .build();
+ * 
+ * To create a {@link BlobStore} for a specific repository call: + *
+ *     blobStoreFactory
+ *       .withName("name")
+ *       .forRepository(repository)
+ *       .build();
+ * 
* * @author Sebastian Sdorra * @since 1.23 @@ -45,13 +61,68 @@ package sonia.scm.store; public interface BlobStoreFactory { /** - * Returns a {@link BlobStore} with the given name, if the {@link BlobStore} - * with the given name does not exists the factory will create a new one. + * Creates a new or gets an existing {@link BlobStore}. Instead of calling this method you should use the floating API + * from {@link #withName(String)}. * - * - * @param name name of the {@link BlobStore} - * - * @return {@link BlobStore} with the given name + * @param storeParameters The parameters for the blob store. + * @return A new or an existing {@link BlobStore} for the given parameters. */ - public BlobStore getBlobStore(String name); + BlobStore getStore(final StoreParameters storeParameters); + + /** + * Use this to create a new or get an existing {@link BlobStore} with a floating API. + * @param name The name for the {@link BlobStore}. + * @return Floating API to either specify a repository or directly build a global {@link BlobStore}. + */ + default FloatingStoreParameters.Builder withName(String name) { + return new FloatingStoreParameters(this).new Builder(name); + } +} + +final class FloatingStoreParameters implements StoreParameters { + + private String name; + private Repository repository; + + private final BlobStoreFactory factory; + + FloatingStoreParameters(BlobStoreFactory factory) { + this.factory = factory; + } + + @Override + public String getName() { + return name; + } + + @Override + public Repository getRepository() { + return repository; + } + + public class Builder { + + Builder(String name) { + FloatingStoreParameters.this.name = name; + } + + /** + * Use this to create or get a {@link BlobStore} for a specific repository. This step is optional. If you want to + * have a global {@link BlobStore}, omit this. + * @param repository The optional repository for the {@link BlobStore}. + * @return Floating API to finish the call. + */ + public FloatingStoreParameters.Builder forRepository(Repository repository) { + FloatingStoreParameters.this.repository = repository; + return this; + } + + /** + * Creates or gets the {@link BlobStore} with the given name and (if specified) the given repository. If no + * repository is given, the {@link BlobStore} will be global. + */ + public BlobStore build(){ + return factory.getStore(FloatingStoreParameters.this); + } + } } diff --git a/scm-core/src/main/java/sonia/scm/store/ConfigurationEntryStoreFactory.java b/scm-core/src/main/java/sonia/scm/store/ConfigurationEntryStoreFactory.java index 7cfebd69c1..80f9cb3df9 100644 --- a/scm-core/src/main/java/sonia/scm/store/ConfigurationEntryStoreFactory.java +++ b/scm-core/src/main/java/sonia/scm/store/ConfigurationEntryStoreFactory.java @@ -32,31 +32,104 @@ package sonia.scm.store; +import sonia.scm.repository.Repository; + /** - * The ConfigurationEntryStoreFactory can be used to create new or get existing - * {@link ConfigurationEntryStore}s. Note: the default implementation - * uses the same location as the {@link StoreFactory}, so be sure that the - * store names are unique for all {@link ConfigurationEntryStore}s and - * {@link Store}s. - * + * The ConfigurationEntryStoreFactory can be used to create new or get existing {@link ConfigurationEntryStore}s. + *
+ * Note: the default implementation uses the same location as the {@link ConfigurationStoreFactory}, so be sure + * that the store names are unique for all {@link ConfigurationEntryStore}s and {@link ConfigurationEntryStore}s. + *
+ * You can either create a global {@link ConfigurationEntryStore} or a {@link ConfigurationEntryStore} for a specific + * repository. To create a global {@link ConfigurationEntryStore} call: + *
+ *     configurationEntryStoreFactory
+ *       .withType(PersistedType.class)
+ *       .withName("name")
+ *       .build();
+ * 
+ * To create a {@link ConfigurationEntryStore} for a specific repository call: + *
+ *     configurationEntryStoreFactory
+ *       .withType(PersistedType.class)
+ *       .withName("name")
+ *       .forRepository(repository)
+ *       .build();
+ * 
+ * * @author Sebastian Sdorra * @since 1.31 * * @apiviz.landmark * @apiviz.uses sonia.scm.store.ConfigurationEntryStore */ -public interface ConfigurationEntryStoreFactory -{ +public interface ConfigurationEntryStoreFactory { /** - * Get an existing {@link ConfigurationEntryStore} or create a new one. + * Creates a new or gets an existing {@link ConfigurationEntryStore}. Instead of calling this method you should use + * the floating API from {@link #withType(Class)}. * - * - * @param type type of the store objects - * @param name name of the store - * @param type of the store objects - * - * @return {@link ConfigurationEntryStore} with given name and type + * @param storeParameters The parameters for the {@link ConfigurationEntryStore}. + * @return A new or an existing {@link ConfigurationEntryStore} for the given parameters. */ - public ConfigurationEntryStore getStore(Class type, String name); + ConfigurationEntryStore getStore(final TypedStoreParameters storeParameters); + + /** + * Use this to create a new or get an existing {@link ConfigurationEntryStore} with a floating API. + * @param type The type for the {@link ConfigurationEntryStore}. + * @return Floating API to set the name and either specify a repository or directly build a global + * {@link ConfigurationEntryStore}. + */ + default TypedFloatingConfigurationEntryStoreParameters.Builder withType(Class type) { + return new TypedFloatingConfigurationEntryStoreParameters(this).new Builder(type); + } +} + +final class TypedFloatingConfigurationEntryStoreParameters { + + private final TypedStoreParametersImpl parameters = new TypedStoreParametersImpl<>(); + private final ConfigurationEntryStoreFactory factory; + + TypedFloatingConfigurationEntryStoreParameters(ConfigurationEntryStoreFactory factory) { + this.factory = factory; + } + + public class Builder { + + Builder(Class type) { + parameters.setType(type); + } + + /** + * Use this to set the name for the {@link ConfigurationEntryStore}. + * @param name The name for the {@link ConfigurationEntryStore}. + * @return Floating API to either specify a repository or directly build a global {@link ConfigurationEntryStore}. + */ + public OptionalRepositoryBuilder withName(String name) { + parameters.setName(name); + return new OptionalRepositoryBuilder(); + } + } + + public class OptionalRepositoryBuilder { + + /** + * Use this to create or get a {@link ConfigurationEntryStore} for a specific repository. This step is optional. If + * you want to have a global {@link ConfigurationEntryStore}, omit this. + * @param repository The optional repository for the {@link ConfigurationEntryStore}. + * @return Floating API to finish the call. + */ + public OptionalRepositoryBuilder forRepository(Repository repository) { + parameters.setRepository(repository); + return this; + } + + /** + * Creates or gets the {@link ConfigurationEntryStore} with the given name and (if specified) the given repository. + * If no repository is given, the {@link ConfigurationEntryStore} will be global. + */ + public ConfigurationEntryStore build(){ + return factory.getStore(parameters); + } + } } diff --git a/scm-core/src/main/java/sonia/scm/store/ConfigurationStore.java b/scm-core/src/main/java/sonia/scm/store/ConfigurationStore.java index a7f21dd304..b1c38f1a00 100644 --- a/scm-core/src/main/java/sonia/scm/store/ConfigurationStore.java +++ b/scm-core/src/main/java/sonia/scm/store/ConfigurationStore.java @@ -33,6 +33,10 @@ package sonia.scm.store; +import java.util.Optional; + +import static java.util.Optional.ofNullable; + /** * ConfigurationStore for configuration objects. Note: the default * implementation use JAXB to marshall the configuration objects. @@ -50,7 +54,17 @@ public interface ConfigurationStore * * @return configuration object from store */ - public T get(); + T get(); + + /** + * Returns the configuration object from store. + * + * + * @return configuration object from store + */ + default Optional getOptional() { + return ofNullable(get()); + } //~--- set methods ---------------------------------------------------------- @@ -58,7 +72,7 @@ public interface ConfigurationStore * Stores the given configuration object to the store. * * - * @param obejct configuration object to store + * @param object configuration object to store */ - public void set(T obejct); + void set(T object); } diff --git a/scm-core/src/main/java/sonia/scm/store/ConfigurationStoreFactory.java b/scm-core/src/main/java/sonia/scm/store/ConfigurationStoreFactory.java index d9a97de98d..6624f307e7 100644 --- a/scm-core/src/main/java/sonia/scm/store/ConfigurationStoreFactory.java +++ b/scm-core/src/main/java/sonia/scm/store/ConfigurationStoreFactory.java @@ -33,27 +33,103 @@ package sonia.scm.store; +import sonia.scm.repository.Repository; + /** - * The ConfigurationStoreFactory can be used to create new or get existing - * {@link ConfigurationStore} objects. + * The ConfigurationStoreFactory can be used to create new or get existing {@link ConfigurationStore} objects. + *
+ * Note: the default implementation uses the same location as the {@link ConfigurationEntryStoreFactory}, so be + * sure that the store names are unique for all {@link ConfigurationEntryStore}s and {@link ConfigurationStore}s. + *
+ * You can either create a global {@link ConfigurationStore} or a {@link ConfigurationStore} for a specific repository. + * To create a global {@link ConfigurationStore} call: + *
+ *     configurationStoreFactory
+ *       .withType(PersistedType.class)
+ *       .withName("name")
+ *       .build();
+ * 
+ * To create a {@link ConfigurationStore} for a specific repository call: + *
+ *     configurationStoreFactory
+ *       .withType(PersistedType.class)
+ *       .withName("name")
+ *       .forRepository(repository)
+ *       .build();
+ * 
* * @author Sebastian Sdorra * * @apiviz.landmark * @apiviz.uses sonia.scm.store.ConfigurationStore */ -public interface ConfigurationStoreFactory -{ +public interface ConfigurationStoreFactory { /** - * Get an existing {@link ConfigurationStore} or create a new one. + * Creates a new or gets an existing {@link ConfigurationStore}. Instead of calling this method you should use the + * floating API from {@link #withType(Class)}. * - * - * @param type type of the store objects - * @param name name of the store - * @param type of the store objects - * - * @return {@link ConfigurationStore} of the given type and name + * @param storeParameters The parameters for the {@link ConfigurationStore}. + * @return A new or an existing {@link ConfigurationStore} for the given parameters. */ - public ConfigurationStore getStore(Class type, String name); + ConfigurationStore getStore(final TypedStoreParameters storeParameters); + + /** + * Use this to create a new or get an existing {@link ConfigurationStore} with a floating API. + * @param type The type for the {@link ConfigurationStore}. + * @return Floating API to set the name and either specify a repository or directly build a global + * {@link ConfigurationStore}. + */ + default TypedFloatingConfigurationStoreParameters.Builder withType(Class type) { + return new TypedFloatingConfigurationStoreParameters(this).new Builder(type); + } +} + +final class TypedFloatingConfigurationStoreParameters { + + private final TypedStoreParametersImpl parameters = new TypedStoreParametersImpl<>(); + private final ConfigurationStoreFactory factory; + + TypedFloatingConfigurationStoreParameters(ConfigurationStoreFactory factory) { + this.factory = factory; + } + + public class Builder { + + Builder(Class type) { + parameters.setType(type); + } + + /** + * Use this to set the name for the {@link ConfigurationStore}. + * @param name The name for the {@link ConfigurationStore}. + * @return Floating API to either specify a repository or directly build a global {@link ConfigurationStore}. + */ + public OptionalRepositoryBuilder withName(String name) { + parameters.setName(name); + return new OptionalRepositoryBuilder(); + } + } + + public class OptionalRepositoryBuilder { + + /** + * Use this to create or get a {@link ConfigurationStore} for a specific repository. This step is optional. If you + * want to have a global {@link ConfigurationStore}, omit this. + * @param repository The optional repository for the {@link ConfigurationStore}. + * @return Floating API to finish the call. + */ + public OptionalRepositoryBuilder forRepository(Repository repository) { + parameters.setRepository(repository); + return this; + } + + /** + * Creates or gets the {@link ConfigurationStore} with the given name and (if specified) the given repository. If no + * repository is given, the {@link ConfigurationStore} will be global. + */ + public ConfigurationStore build(){ + return factory.getStore(parameters); + } + } } diff --git a/scm-core/src/main/java/sonia/scm/store/DataStoreFactory.java b/scm-core/src/main/java/sonia/scm/store/DataStoreFactory.java index caed974ee4..564c339d3d 100644 --- a/scm-core/src/main/java/sonia/scm/store/DataStoreFactory.java +++ b/scm-core/src/main/java/sonia/scm/store/DataStoreFactory.java @@ -32,9 +32,27 @@ package sonia.scm.store; +import sonia.scm.repository.Repository; + /** - * The DataStoreFactory can be used to create new or get existing - * {@link DataStore}s. + * The DataStoreFactory can be used to create new or get existing {@link DataStore}s. + *
+ * You can either create a global {@link DataStore} or a {@link DataStore} for a specific repository. + * To create a global {@link DataStore} call: + *
+ *     dataStoreFactory
+ *       .withType(PersistedType.class)
+ *       .withName("name")
+ *       .build();
+ * 
+ * To create a {@link DataStore} for a specific repository call: + *
+ *     dataStoreFactory
+ *       .withType(PersistedType.class)
+ *       .withName("name")
+ *       .forRepository(repository)
+ *       .build();
+ * 
* * @author Sebastian Sdorra * @since 1.23 @@ -45,14 +63,70 @@ package sonia.scm.store; public interface DataStoreFactory { /** - * Get an existing {@link DataStore} or create a new one. + * Creates a new or gets an existing {@link DataStore}. Instead of calling this method you should use the + * floating API from {@link #withType(Class)}. * - * - * @param type type of the store objects - * @param name name of the store - * @param type of the store objects - * - * @return {@link DataStore} with given name and type + * @param storeParameters The parameters for the {@link DataStore}. + * @return A new or an existing {@link DataStore} for the given parameters. */ - public DataStore getStore(Class type, String name); + DataStore getStore(final TypedStoreParameters storeParameters); + + /** + * Use this to create a new or get an existing {@link DataStore} with a floating API. + * @param type The type for the {@link DataStore}. + * @return Floating API to set the name and either specify a repository or directly build a global + * {@link DataStore}. + */ + default TypedFloatingDataStoreParameters.Builder withType(Class type) { + return new TypedFloatingDataStoreParameters(this).new Builder(type); + } +} + +final class TypedFloatingDataStoreParameters { + + private final TypedStoreParametersImpl parameters = new TypedStoreParametersImpl<>(); + private final DataStoreFactory factory; + + TypedFloatingDataStoreParameters(DataStoreFactory factory) { + this.factory = factory; + } + + public class Builder { + + Builder(Class type) { + parameters.setType(type); + } + + /** + * Use this to set the name for the {@link DataStore}. + * @param name The name for the {@link DataStore}. + * @return Floating API to either specify a repository or directly build a global {@link DataStore}. + */ + public OptionalRepositoryBuilder withName(String name) { + parameters.setName(name); + return new OptionalRepositoryBuilder(); + } + } + + public class OptionalRepositoryBuilder { + + /** + * Use this to create or get a {@link DataStore} for a specific repository. This step is optional. If you + * want to have a global {@link DataStore}, omit this. + * @param repository The optional repository for the {@link DataStore}. + * @return Floating API to finish the call. + */ + public OptionalRepositoryBuilder forRepository(Repository repository) { + parameters.setRepository(repository); + return this; + } + + /** + * Creates or gets the {@link DataStore} with the given name and (if specified) the given repository. If no + * repository is given, the {@link DataStore} will be global. + */ + public DataStore build(){ + return factory.getStore(parameters); + } + } } diff --git a/scm-core/src/main/java/sonia/scm/store/MultiEntryStore.java b/scm-core/src/main/java/sonia/scm/store/MultiEntryStore.java index 9a35cee0e0..c1a8863758 100644 --- a/scm-core/src/main/java/sonia/scm/store/MultiEntryStore.java +++ b/scm-core/src/main/java/sonia/scm/store/MultiEntryStore.java @@ -32,6 +32,10 @@ package sonia.scm.store; +import java.util.Optional; + +import static java.util.Optional.ofNullable; + /** * Base class for {@link BlobStore} and {@link DataStore}. * @@ -67,4 +71,16 @@ public interface MultiEntryStore { * @return item with the given id */ public T get(String id); + + /** + * Returns the item with the given id from the store. + * + * + * @param id id of the item to return + * + * @return item with the given id + */ + default Optional getOptional(String id) { + return ofNullable(get(id)); + } } diff --git a/scm-core/src/main/java/sonia/scm/store/StoreParameters.java b/scm-core/src/main/java/sonia/scm/store/StoreParameters.java new file mode 100644 index 0000000000..da8ee4c916 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/store/StoreParameters.java @@ -0,0 +1,16 @@ +package sonia.scm.store; + +import sonia.scm.repository.Repository; + +/** + * The fields of the {@link StoreParameters} are used from the {@link BlobStoreFactory} to create a store. + * + * @author Mohamed Karray + * @since 2.0.0 + */ +public interface StoreParameters { + + String getName(); + + Repository getRepository(); +} diff --git a/scm-core/src/main/java/sonia/scm/store/TypedStoreParameters.java b/scm-core/src/main/java/sonia/scm/store/TypedStoreParameters.java new file mode 100644 index 0000000000..116bccac41 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/store/TypedStoreParameters.java @@ -0,0 +1,19 @@ +package sonia.scm.store; + +import sonia.scm.repository.Repository; + +/** + * The fields of the {@link TypedStoreParameters} are used from the {@link ConfigurationStoreFactory}, + * {@link ConfigurationEntryStoreFactory} and {@link DataStoreFactory} to create a type safe store. + * + * @author Mohamed Karray + * @since 2.0.0 + */ +public interface TypedStoreParameters { + + Class getType(); + + String getName(); + + Repository getRepository(); +} diff --git a/scm-core/src/main/java/sonia/scm/store/TypedStoreParametersImpl.java b/scm-core/src/main/java/sonia/scm/store/TypedStoreParametersImpl.java new file mode 100644 index 0000000000..50ce6a496b --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/store/TypedStoreParametersImpl.java @@ -0,0 +1,36 @@ +package sonia.scm.store; + +import sonia.scm.repository.Repository; + +class TypedStoreParametersImpl implements TypedStoreParameters { + private Class type; + private String name; + private Repository repository; + + @Override + public Class getType() { + return type; + } + + void setType(Class type) { + this.type = type; + } + + @Override + public String getName() { + return name; + } + + void setName(String name) { + this.name = name; + } + + @Override + public Repository getRepository() { + return repository; + } + + void setRepository(Repository repository) { + this.repository = repository; + } +} diff --git a/scm-core/src/main/java/sonia/scm/user/ChangePasswordNotAllowedException.java b/scm-core/src/main/java/sonia/scm/user/ChangePasswordNotAllowedException.java index caa35e0b88..b0f8117e82 100644 --- a/scm-core/src/main/java/sonia/scm/user/ChangePasswordNotAllowedException.java +++ b/scm-core/src/main/java/sonia/scm/user/ChangePasswordNotAllowedException.java @@ -1,12 +1,13 @@ package sonia.scm.user; +import sonia.scm.BadRequestException; import sonia.scm.ContextEntry; -import sonia.scm.ExceptionWithContext; -public class ChangePasswordNotAllowedException extends ExceptionWithContext { +@SuppressWarnings("squid:MaximumInheritanceDepth") // exceptions have a deep inheritance depth themselves; therefore we accept this here +public class ChangePasswordNotAllowedException extends BadRequestException { private static final String CODE = "9BR7qpDAe1"; - public static final String WRONG_USER_TYPE = "User of type %s are not allowed to change password"; + public static final String WRONG_USER_TYPE = "Users of type %s are not allowed to change password"; public ChangePasswordNotAllowedException(ContextEntry.ContextBuilder context, String type) { super(context.build(), String.format(WRONG_USER_TYPE, type)); diff --git a/scm-core/src/main/java/sonia/scm/user/InvalidPasswordException.java b/scm-core/src/main/java/sonia/scm/user/InvalidPasswordException.java index 93a6a7c1d1..6f1bfd9954 100644 --- a/scm-core/src/main/java/sonia/scm/user/InvalidPasswordException.java +++ b/scm-core/src/main/java/sonia/scm/user/InvalidPasswordException.java @@ -1,9 +1,10 @@ package sonia.scm.user; +import sonia.scm.BadRequestException; import sonia.scm.ContextEntry; -import sonia.scm.ExceptionWithContext; -public class InvalidPasswordException extends ExceptionWithContext { +@SuppressWarnings("squid:MaximumInheritanceDepth") // exceptions have a deep inheritance depth themselves; therefore we accept this here +public class InvalidPasswordException extends BadRequestException { private static final String CODE = "8YR7aawFW1"; diff --git a/scm-core/src/main/java/sonia/scm/user/User.java b/scm-core/src/main/java/sonia/scm/user/User.java index cae383a402..3c185ae3b8 100644 --- a/scm-core/src/main/java/sonia/scm/user/User.java +++ b/scm-core/src/main/java/sonia/scm/user/User.java @@ -59,7 +59,9 @@ import java.security.Principal; @StaticPermissions( value = "user", globalPermissions = {"create", "list", "autocomplete"}, - permissions = {"read", "modify", "delete", "changePassword"}) + permissions = {"read", "modify", "delete", "changePassword"}, + custom = true, customGlobal = true +) @XmlRootElement(name = "users") @XmlAccessorType(XmlAccessType.FIELD) public class User extends BasicPropertiesAware implements Principal, ModelObject, PermissionObject, ReducedModelObject diff --git a/scm-core/src/main/java/sonia/scm/util/Comparables.java b/scm-core/src/main/java/sonia/scm/util/Comparables.java new file mode 100644 index 0000000000..1fb0c5e358 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/util/Comparables.java @@ -0,0 +1,88 @@ +package sonia.scm.util; + +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; + +import java.beans.BeanInfo; +import java.beans.IntrospectionException; +import java.beans.Introspector; +import java.beans.PropertyDescriptor; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Comparator; +import java.util.Optional; + +import static com.google.common.base.Preconditions.checkArgument; + +public final class Comparables { + + private static final CacheLoader beanInfoCacheLoader = new CacheLoader() { + @Override + public BeanInfo load(Class type) throws IntrospectionException { + return Introspector.getBeanInfo(type); + } + }; + + private static final LoadingCache beanInfoCache = CacheBuilder.newBuilder() + .maximumSize(50) // limit the cache to avoid consuming to much memory on miss usage + .build(beanInfoCacheLoader); + + private Comparables() { + } + + public static Comparator comparator(Class type, String sortBy) { + BeanInfo info = createBeanInfo(type); + PropertyDescriptor propertyDescriptor = findPropertyDescriptor(sortBy, info); + + Method readMethod = propertyDescriptor.getReadMethod(); + checkIfPropertyIsComparable(readMethod, sortBy); + + return new MethodComparator<>(readMethod); + } + + private static void checkIfPropertyIsComparable(Method readMethod, String sortBy) { + checkArgument(isReturnTypeComparable(readMethod), "property %s is not comparable", sortBy); + } + + private static boolean isReturnTypeComparable(Method readMethod) { + return Comparable.class.isAssignableFrom(readMethod.getReturnType()); + } + + private static PropertyDescriptor findPropertyDescriptor(String sortBy, BeanInfo info) { + PropertyDescriptor[] propertyDescriptors = info.getPropertyDescriptors(); + + Optional sortByPropertyDescriptor = Arrays.stream(propertyDescriptors) + .filter(p -> p.getName().equals(sortBy)) + .findFirst(); + + return sortByPropertyDescriptor.orElseThrow(() -> new IllegalArgumentException("could not find property " + sortBy)); + } + + private static BeanInfo createBeanInfo(Class type) { + return beanInfoCache.getUnchecked(type); + } + + private static class MethodComparator implements Comparator { + + private final Method readMethod; + + private MethodComparator(Method readMethod) { + this.readMethod = readMethod; + } + + @Override + @SuppressWarnings("unchecked") + public int compare(T left, T right) { + try { + Comparable leftResult = (Comparable) readMethod.invoke(left); + Comparable rightResult = (Comparable) readMethod.invoke(right); + return leftResult.compareTo(rightResult); + } catch (IllegalAccessException | InvocationTargetException ex) { + throw new IllegalArgumentException("failed to invoke read method", ex); + } + } + } + +} diff --git a/scm-core/src/main/java/sonia/scm/web/AbstractRepositoryJsonEnricher.java b/scm-core/src/main/java/sonia/scm/web/AbstractRepositoryJsonEnricher.java new file mode 100644 index 0000000000..2cb4674d24 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/web/AbstractRepositoryJsonEnricher.java @@ -0,0 +1,40 @@ +package sonia.scm.web; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import static java.util.Collections.singletonMap; +import static sonia.scm.web.VndMediaType.REPOSITORY; +import static sonia.scm.web.VndMediaType.REPOSITORY_COLLECTION; + +public abstract class AbstractRepositoryJsonEnricher extends JsonEnricherBase { + + public AbstractRepositoryJsonEnricher(ObjectMapper objectMapper) { + super(objectMapper); + } + + @Override + public void enrich(JsonEnricherContext context) { + if (resultHasMediaType(REPOSITORY, context)) { + JsonNode repositoryNode = context.getResponseEntity(); + enrichRepositoryNode(repositoryNode); + } else if (resultHasMediaType(REPOSITORY_COLLECTION, context)) { + JsonNode repositoryCollectionNode = context.getResponseEntity().get("_embedded").withArray("repositories"); + repositoryCollectionNode.elements().forEachRemaining(this::enrichRepositoryNode); + } + } + + private void enrichRepositoryNode(JsonNode repositoryNode) { + String namespace = repositoryNode.get("namespace").asText(); + String name = repositoryNode.get("name").asText(); + + enrichRepositoryNode(repositoryNode, namespace, name); + } + + protected abstract void enrichRepositoryNode(JsonNode repositoryNode, String namespace, String name); + + protected void addLink(JsonNode repositoryNode, String linkName, String link) { + JsonNode hrefNode = createObject(singletonMap("href", value(link))); + addPropertyNode(repositoryNode.get("_links"), linkName, hrefNode); + } +} diff --git a/scm-core/src/main/java/sonia/scm/web/JsonEnricherBase.java b/scm-core/src/main/java/sonia/scm/web/JsonEnricherBase.java index 1baecb62af..6bdd321c86 100644 --- a/scm-core/src/main/java/sonia/scm/web/JsonEnricherBase.java +++ b/scm-core/src/main/java/sonia/scm/web/JsonEnricherBase.java @@ -15,7 +15,7 @@ public abstract class JsonEnricherBase implements JsonEnricher { } protected boolean resultHasMediaType(String mediaType, JsonEnricherContext context) { - return mediaType.equals(context.getResponseMediaType().toString()); + return mediaType.equalsIgnoreCase(context.getResponseMediaType().toString()); } protected JsonNode value(Object object) { diff --git a/scm-core/src/main/java/sonia/scm/web/VndMediaType.java b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java index e2a2218d34..19859b876b 100644 --- a/scm-core/src/main/java/sonia/scm/web/VndMediaType.java +++ b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java @@ -20,7 +20,7 @@ public class VndMediaType { public static final String GROUP = PREFIX + "group" + SUFFIX; public static final String AUTOCOMPLETE = PREFIX + "autocomplete" + SUFFIX; public static final String REPOSITORY = PREFIX + "repository" + SUFFIX; - public static final String PERMISSION = PREFIX + "permission" + SUFFIX; + public static final String REPOSITORY_PERMISSION = PREFIX + "repositoryPermission" + SUFFIX; public static final String CHANGESET = PREFIX + "changeset" + SUFFIX; public static final String CHANGESET_COLLECTION = PREFIX + "changesetCollection" + SUFFIX; public static final String MODIFICATIONS = PREFIX + "modifications" + SUFFIX; @@ -33,6 +33,7 @@ public class VndMediaType { public static final String REPOSITORY_COLLECTION = PREFIX + "repositoryCollection" + SUFFIX; public static final String BRANCH_COLLECTION = PREFIX + "branchCollection" + SUFFIX; public static final String CONFIG = PREFIX + "config" + SUFFIX; + public static final String REPOSITORY_PERMISSION_COLLECTION = PREFIX + "repositoryPermissionCollection" + SUFFIX; public static final String REPOSITORY_TYPE_COLLECTION = PREFIX + "repositoryTypeCollection" + SUFFIX; public static final String REPOSITORY_TYPE = PREFIX + "repositoryType" + SUFFIX; public static final String UI_PLUGIN = PREFIX + "uiPlugin" + SUFFIX; @@ -41,6 +42,9 @@ public class VndMediaType { public static final String PASSWORD_CHANGE = PREFIX + "passwordChange" + SUFFIX; @SuppressWarnings("squid:S2068") public static final String PASSWORD_OVERWRITE = PREFIX + "passwordOverwrite" + SUFFIX; + public static final String PERMISSION_COLLECTION = PREFIX + "permissionCollection" + SUFFIX; + public static final String MERGE_RESULT = PREFIX + "mergeResult" + SUFFIX; + public static final String MERGE_COMMAND = PREFIX + "mergeCommand" + SUFFIX; public static final String ME = PREFIX + "me" + SUFFIX; public static final String SOURCE = PREFIX + "source" + SUFFIX; diff --git a/scm-core/src/main/java/sonia/scm/web/filter/AuthenticationFilter.java b/scm-core/src/main/java/sonia/scm/web/filter/AuthenticationFilter.java index ffe6ecc787..c6a8463998 100644 --- a/scm-core/src/main/java/sonia/scm/web/filter/AuthenticationFilter.java +++ b/scm-core/src/main/java/sonia/scm/web/filter/AuthenticationFilter.java @@ -128,7 +128,7 @@ public class AuthenticationFilter extends HttpFilter } else if (subject.isAuthenticated()) { - logger.trace("user is allready authenticated"); + logger.trace("user is already authenticated"); processChain(request, response, chain, subject); } else if (isAnonymousAccessEnabled()) diff --git a/scm-core/src/main/java/sonia/scm/web/filter/PermissionFilter.java b/scm-core/src/main/java/sonia/scm/web/filter/PermissionFilter.java index 328494a626..a062fdb360 100644 --- a/scm-core/src/main/java/sonia/scm/web/filter/PermissionFilter.java +++ b/scm-core/src/main/java/sonia/scm/web/filter/PermissionFilter.java @@ -252,7 +252,7 @@ public abstract class PermissionFilter extends ScmProviderHttpServletDecorator } else { - permitted = RepositoryPermissions.read(repository).isPermitted(); + permitted = RepositoryPermissions.pull(repository).isPermitted(); } return permitted; diff --git a/scm-core/src/main/java/sonia/scm/xml/IndentXMLStreamWriter.java b/scm-core/src/main/java/sonia/scm/xml/IndentXMLStreamWriter.java index 81e973f379..f900ceb234 100644 --- a/scm-core/src/main/java/sonia/scm/xml/IndentXMLStreamWriter.java +++ b/scm-core/src/main/java/sonia/scm/xml/IndentXMLStreamWriter.java @@ -45,7 +45,7 @@ import javax.xml.stream.XMLStreamWriter; * @author Sebastian Sdorra * @since 1.31 */ -public final class IndentXMLStreamWriter implements XMLStreamWriter +public final class IndentXMLStreamWriter implements XMLStreamWriter, AutoCloseable { /** line separator */ @@ -475,7 +475,7 @@ public final class IndentXMLStreamWriter implements XMLStreamWriter //~--- fields --------------------------------------------------------------- /** indent string */ - private String indent = " "; + private String indent = " "; /** current level */ private int level = 0; diff --git a/scm-core/src/main/java/sonia/scm/xml/XmlInstantAdapter.java b/scm-core/src/main/java/sonia/scm/xml/XmlInstantAdapter.java new file mode 100644 index 0000000000..9b8d718851 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/xml/XmlInstantAdapter.java @@ -0,0 +1,25 @@ +package sonia.scm.xml; + +import javax.xml.bind.annotation.adapters.XmlAdapter; +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.time.temporal.TemporalAccessor; + +/** + * JAXB adapter for {@link Instant} objects. + * + * @since 2.0.0 + */ +public class XmlInstantAdapter extends XmlAdapter { + + @Override + public String marshal(Instant instant) { + return DateTimeFormatter.ISO_INSTANT.format(instant); + } + + @Override + public Instant unmarshal(String text) { + TemporalAccessor parsed = DateTimeFormatter.ISO_INSTANT.parse(text); + return Instant.from(parsed); + } +} diff --git a/scm-core/src/test/java/sonia/scm/BasicContextProviderTest.java b/scm-core/src/test/java/sonia/scm/BasicContextProviderTest.java new file mode 100644 index 0000000000..4fb9dfd4fa --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/BasicContextProviderTest.java @@ -0,0 +1,44 @@ +package sonia.scm; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junitpioneer.jupiter.TempDirectory; + +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(TempDirectory.class) +class BasicContextProviderTest { + + private Path baseDirectory; + + private BasicContextProvider context; + + @BeforeEach + void setUpContext(@TempDirectory.TempDir Path baseDirectory) { + this.baseDirectory = baseDirectory; + context = new BasicContextProvider(baseDirectory.toFile(), "x.y.z", Stage.PRODUCTION); + } + + @Test + void shouldReturnAbsolutePathAsIs(@TempDirectory.TempDir Path path) { + Path absolutePath = path.toAbsolutePath(); + Path resolved = context.resolve(absolutePath); + + assertThat(resolved).isSameAs(absolutePath); + } + + @Test + void shouldResolveRelatePath() { + Path path = Paths.get("repos", "42"); + Path resolved = context.resolve(path); + + assertThat(resolved).isAbsolute(); + assertThat(resolved).startsWithRaw(baseDirectory); + assertThat(resolved).endsWithRaw(path); + } + +} diff --git a/scm-core/src/test/java/sonia/scm/api/v2/resources/HalAppenderMapperTest.java b/scm-core/src/test/java/sonia/scm/api/v2/resources/HalAppenderMapperTest.java new file mode 100644 index 0000000000..ff658cc26a --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/api/v2/resources/HalAppenderMapperTest.java @@ -0,0 +1,74 @@ +package sonia.scm.api.v2.resources; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class HalAppenderMapperTest { + + @Mock + private HalAppender appender; + + private HalEnricherRegistry registry; + private HalAppenderMapper mapper; + + @BeforeEach + void beforeEach() { + registry = new HalEnricherRegistry(); + mapper = new HalAppenderMapper(); + mapper.setRegistry(registry); + } + + @Test + void shouldAppendSimpleLink() { + registry.register(String.class, (ctx, appender) -> appender.appendLink("42", "https://hitchhiker.com")); + + mapper.applyEnrichers(appender, "hello"); + + verify(appender).appendLink("42", "https://hitchhiker.com"); + } + + @Test + void shouldCallMultipleEnrichers() { + registry.register(String.class, (ctx, appender) -> appender.appendLink("42", "https://hitchhiker.com")); + registry.register(String.class, (ctx, appender) -> appender.appendLink("21", "https://scm.hitchhiker.com")); + + mapper.applyEnrichers(appender, "hello"); + + verify(appender).appendLink("42", "https://hitchhiker.com"); + verify(appender).appendLink("21", "https://scm.hitchhiker.com"); + } + + @Test + void shouldAppendLinkByUsingSourceFromContext() { + registry.register(String.class, (ctx, appender) -> { + Optional rel = ctx.oneByType(String.class); + appender.appendLink(rel.get(), "https://hitchhiker.com"); + }); + + mapper.applyEnrichers(appender, "42"); + + verify(appender).appendLink("42", "https://hitchhiker.com"); + } + + @Test + void shouldAppendLinkByUsingMultipleContextEntries() { + registry.register(Integer.class, (ctx, appender) -> { + Optional rel = ctx.oneByType(Integer.class); + Optional href = ctx.oneByType(String.class); + appender.appendLink(String.valueOf(rel.get()), href.get()); + }); + + mapper.applyEnrichers(appender, Integer.valueOf(42), "https://hitchhiker.com"); + + verify(appender).appendLink("42", "https://hitchhiker.com"); + } + +} diff --git a/scm-core/src/test/java/sonia/scm/api/v2/resources/HalEnricherContextTest.java b/scm-core/src/test/java/sonia/scm/api/v2/resources/HalEnricherContextTest.java new file mode 100644 index 0000000000..1aecb5ad46 --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/api/v2/resources/HalEnricherContextTest.java @@ -0,0 +1,44 @@ +package sonia.scm.api.v2.resources; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +import java.util.NoSuchElementException; + +class HalEnricherContextTest { + + @Test + void shouldCreateContextFromSingleObject() { + HalEnricherContext context = HalEnricherContext.of("hello"); + assertThat(context.oneByType(String.class)).contains("hello"); + } + + @Test + void shouldCreateContextFromMultipleObjects() { + HalEnricherContext context = HalEnricherContext.of("hello", Integer.valueOf(42), Long.valueOf(21L)); + assertThat(context.oneByType(String.class)).contains("hello"); + assertThat(context.oneByType(Integer.class)).contains(42); + assertThat(context.oneByType(Long.class)).contains(21L); + } + + @Test + void shouldReturnEmptyOptionalForUnknownTypes() { + HalEnricherContext context = HalEnricherContext.of(); + assertThat(context.oneByType(String.class)).isNotPresent(); + } + + @Test + void shouldReturnRequiredObject() { + HalEnricherContext context = HalEnricherContext.of("hello"); + assertThat(context.oneRequireByType(String.class)).isEqualTo("hello"); + } + + @Test + void shouldThrowAnNoSuchElementExceptionForUnknownTypes() { + HalEnricherContext context = HalEnricherContext.of(); + assertThrows(NoSuchElementException.class, () -> context.oneRequireByType(String.class)); + } + +} diff --git a/scm-core/src/test/java/sonia/scm/api/v2/resources/HalEnricherRegistryTest.java b/scm-core/src/test/java/sonia/scm/api/v2/resources/HalEnricherRegistryTest.java new file mode 100644 index 0000000000..6a863d2f04 --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/api/v2/resources/HalEnricherRegistryTest.java @@ -0,0 +1,60 @@ +package sonia.scm.api.v2.resources; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class HalEnricherRegistryTest { + + private HalEnricherRegistry registry; + + @BeforeEach + void setUpObjectUnderTest() { + registry = new HalEnricherRegistry(); + } + + @Test + void shouldRegisterTheEnricher() { + SampleHalEnricher enricher = new SampleHalEnricher(); + registry.register(String.class, enricher); + + Iterable enrichers = registry.allByType(String.class); + assertThat(enrichers).containsOnly(enricher); + } + + @Test + void shouldRegisterMultipleEnrichers() { + SampleHalEnricher one = new SampleHalEnricher(); + registry.register(String.class, one); + + SampleHalEnricher two = new SampleHalEnricher(); + registry.register(String.class, two); + + Iterable enrichers = registry.allByType(String.class); + assertThat(enrichers).containsOnly(one, two); + } + + @Test + void shouldRegisterEnrichersForDifferentTypes() { + SampleHalEnricher one = new SampleHalEnricher(); + registry.register(String.class, one); + + SampleHalEnricher two = new SampleHalEnricher(); + registry.register(Integer.class, two); + + Iterable enrichers = registry.allByType(String.class); + assertThat(enrichers).containsOnly(one); + + enrichers = registry.allByType(Integer.class); + assertThat(enrichers).containsOnly(two); + } + + private static class SampleHalEnricher implements HalEnricher { + @Override + public void enrich(HalEnricherContext context, HalAppender appender) { + + } + } + +} diff --git a/scm-core/src/test/java/sonia/scm/filter/GZipResponseFilterTest.java b/scm-core/src/test/java/sonia/scm/filter/GZipResponseFilterTest.java new file mode 100644 index 0000000000..c3648fd4b8 --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/filter/GZipResponseFilterTest.java @@ -0,0 +1,87 @@ +package sonia.scm.filter; + +import com.google.inject.util.Providers; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.MultivaluedMap; +import javax.ws.rs.ext.WriterInterceptorContext; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.zip.GZIPOutputStream; + +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class GZipResponseFilterTest { + + @Mock + private HttpServletRequest request; + + @Mock + private WriterInterceptorContext context; + + @Mock + private MultivaluedMap headers; + + private GZipResponseFilter filter; + + @BeforeEach + void setupObjectUnderTest() { + filter = new GZipResponseFilter(Providers.of(request)); + } + + @Test + void shouldSkipGZipCompression() throws IOException { + when(request.getHeader(HttpHeaders.ACCEPT_ENCODING)).thenReturn("deflate, br"); + + filter.aroundWriteTo(context); + + verifySkipped(); + } + + @Test + void shouldSkipGZipCompressionWithoutAcceptEncodingHeader() throws IOException { + filter.aroundWriteTo(context); + + verifySkipped(); + } + + private void verifySkipped() throws IOException { + verify(context, never()).getOutputStream(); + verify(context).proceed(); + } + + + @Nested + class AcceptGZipEncoding { + + @BeforeEach + void setUpContext() { + when(request.getHeader(HttpHeaders.ACCEPT_ENCODING)).thenReturn("gzip, deflate, br"); + when(context.getHeaders()).thenReturn(headers); + when(context.getOutputStream()).thenReturn(new ByteArrayOutputStream()); + } + + @Test + void shouldEncode() throws IOException { + filter.aroundWriteTo(context); + + verify(headers).remove(HttpHeaders.CONTENT_LENGTH); + verify(headers).add(HttpHeaders.CONTENT_ENCODING, "gzip"); + + verify(context).setOutputStream(any(GZIPOutputStream.class)); + verify(context, times(2)).setOutputStream(any(OutputStream.class)); + } + + } + + +} diff --git a/scm-core/src/test/java/sonia/scm/net/ahc/AdvancedHttpRequestWithBodyTest.java b/scm-core/src/test/java/sonia/scm/net/ahc/AdvancedHttpRequestWithBodyTest.java index b7fa9ae84a..92ca488ddf 100644 --- a/scm-core/src/test/java/sonia/scm/net/ahc/AdvancedHttpRequestWithBodyTest.java +++ b/scm-core/src/test/java/sonia/scm/net/ahc/AdvancedHttpRequestWithBodyTest.java @@ -36,7 +36,7 @@ import com.google.common.io.ByteSource; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; -import java.io.UnsupportedEncodingException; + import org.junit.Test; import static org.junit.Assert.*; import static org.hamcrest.Matchers.*; diff --git a/scm-core/src/test/java/sonia/scm/repository/InitialRepositoryLocationResolverTest.java b/scm-core/src/test/java/sonia/scm/repository/InitialRepositoryLocationResolverTest.java new file mode 100644 index 0000000000..e4cd22b060 --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/repository/InitialRepositoryLocationResolverTest.java @@ -0,0 +1,45 @@ +package sonia.scm.repository; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.File; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith({MockitoExtension.class}) +class InitialRepositoryLocationResolverTest { + + private InitialRepositoryLocationResolver resolver = new InitialRepositoryLocationResolver(); + + @Test + void shouldComputeInitialPath() { + Path path = resolver.getPath("42"); + + assertThat(path).isRelative(); + assertThat(path.toString()).isEqualTo("repositories" + File.separator + "42"); + } + + @Test + void shouldThrowIllegalArgumentExceptionIfIdHasASlash() { + Assertions.assertThrows(IllegalArgumentException.class, () -> resolver.getPath("../../../passwd")); + } + + @Test + void shouldThrowIllegalArgumentExceptionIfIdHasABackSlash() { + Assertions.assertThrows(IllegalArgumentException.class, () -> resolver.getPath("..\\..\\..\\users.ntlm")); + } + + @Test + void shouldThrowIllegalArgumentExceptionIfIdIsDotDot() { + Assertions.assertThrows(IllegalArgumentException.class, () -> resolver.getPath("..")); + } + + @Test + void shouldThrowIllegalArgumentExceptionIfIdIsDot() { + Assertions.assertThrows(IllegalArgumentException.class, () -> resolver.getPath(".")); + } +} diff --git a/scm-core/src/test/java/sonia/scm/repository/RepositoryLocationResolverTest.java b/scm-core/src/test/java/sonia/scm/repository/RepositoryLocationResolverTest.java new file mode 100644 index 0000000000..05f9af3773 --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/repository/RepositoryLocationResolverTest.java @@ -0,0 +1,65 @@ +package sonia.scm.repository; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.stubbing.Answer; +import sonia.scm.SCMContextProvider; + +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith({MockitoExtension.class}) +class RepositoryLocationResolverTest { + + @Mock + private SCMContextProvider contextProvider; + + @Mock + private PathBasedRepositoryDAO pathBasedRepositoryDAO; + + @Mock + private RepositoryDAO repositoryDAO; + + @Mock + private InitialRepositoryLocationResolver initialRepositoryLocationResolver; + + + @BeforeEach + void beforeEach() { + when(contextProvider.resolve(any(Path.class))).then((Answer) invocationOnMock -> invocationOnMock.getArgument(0)); + } + + private RepositoryLocationResolver createResolver(RepositoryDAO pathBasedRepositoryDAO) { + return new RepositoryLocationResolver(contextProvider, pathBasedRepositoryDAO, initialRepositoryLocationResolver); + } + + @Test + void shouldReturnPathFromDao() { + Path repositoryPath = Paths.get("repos", "42"); + when(pathBasedRepositoryDAO.getPath("42")).thenReturn(repositoryPath); + + RepositoryLocationResolver resolver = createResolver(pathBasedRepositoryDAO); + Path path = resolver.getPath("42"); + + assertThat(path).isSameAs(repositoryPath); + } + + @Test + void shouldReturnInitialPathIfDaoIsNotPathBased() { + Path repositoryPath = Paths.get("r", "42"); + when(initialRepositoryLocationResolver.getPath("42")).thenReturn(repositoryPath); + + RepositoryLocationResolver resolver = createResolver(repositoryDAO); + Path path = resolver.getPath("42"); + + assertThat(path).isSameAs(repositoryPath); + } + +} diff --git a/scm-core/src/test/java/sonia/scm/repository/RepositoryPermissionTest.java b/scm-core/src/test/java/sonia/scm/repository/RepositoryPermissionTest.java new file mode 100644 index 0000000000..d65358a66e --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/repository/RepositoryPermissionTest.java @@ -0,0 +1,58 @@ +package sonia.scm.repository; + +import org.junit.jupiter.api.Test; + +import static java.util.Arrays.asList; +import static org.assertj.core.api.Assertions.assertThat; + +class RepositoryPermissionTest { + + @Test + void shouldBeEqualWithSameVerbs() { + RepositoryPermission permission1 = new RepositoryPermission("name", asList("one", "two"), false); + RepositoryPermission permission2 = new RepositoryPermission("name", asList("two", "one"), false); + + assertThat(permission1).isEqualTo(permission2); + } + + @Test + void shouldHaveSameHashCodeWithSameVerbs() { + long hash1 = new RepositoryPermission("name", asList("one", "two"), false).hashCode(); + long hash2 = new RepositoryPermission("name", asList("two", "one"), false).hashCode(); + + assertThat(hash1).isEqualTo(hash2); + } + + @Test + void shouldNotBeEqualWithSameVerbs() { + RepositoryPermission permission1 = new RepositoryPermission("name", asList("one", "two"), false); + RepositoryPermission permission2 = new RepositoryPermission("name", asList("three", "one"), false); + + assertThat(permission1).isNotEqualTo(permission2); + } + + @Test + void shouldNotBeEqualWithDifferentType() { + RepositoryPermission permission1 = new RepositoryPermission("name", asList("one"), false); + RepositoryPermission permission2 = new RepositoryPermission("name", asList("one"), true); + + assertThat(permission1).isNotEqualTo(permission2); + } + + @Test + void shouldNotBeEqualWithDifferentName() { + RepositoryPermission permission1 = new RepositoryPermission("name1", asList("one"), false); + RepositoryPermission permission2 = new RepositoryPermission("name2", asList("one"), false); + + assertThat(permission1).isNotEqualTo(permission2); + } + + @Test + void shouldBeEqualWithRedundantVerbs() { + RepositoryPermission permission1 = new RepositoryPermission("name1", asList("one", "two"), false); + RepositoryPermission permission2 = new RepositoryPermission("name1", asList("one", "two"), false); + permission2.setVerbs(asList("one", "two", "two")); + + assertThat(permission1).isEqualTo(permission2); + } +} diff --git a/scm-core/src/test/java/sonia/scm/repository/RepositoryUtilTest.java b/scm-core/src/test/java/sonia/scm/repository/RepositoryUtilTest.java deleted file mode 100644 index fabc55b9c9..0000000000 --- a/scm-core/src/test/java/sonia/scm/repository/RepositoryUtilTest.java +++ /dev/null @@ -1,78 +0,0 @@ -package sonia.scm.repository; - -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.TemporaryFolder; -import org.junit.runner.RunWith; -import org.mockito.Mock; -import org.mockito.junit.MockitoJUnitRunner; - -import java.io.File; -import java.io.IOException; - -import static org.junit.Assert.*; -import static org.mockito.Mockito.*; - -@RunWith(MockitoJUnitRunner.class) -public class RepositoryUtilTest { - - @Rule - public TemporaryFolder temporaryFolder = new TemporaryFolder(); - - @Mock - private AbstractRepositoryHandler repositoryHandler; - - private RepositoryConfig repositoryConfig = new RepositoryConfig() { - @Override - public String getId() { - return "repository"; - } - }; - - @Before - public void setUpMocks() { - when(repositoryHandler.getConfig()).thenReturn(repositoryConfig); - } - - @Test - public void testGetRepositoryId() throws IOException { - File repositoryTypeRoot = temporaryFolder.newFolder(); - repositoryConfig.setRepositoryDirectory(repositoryTypeRoot); - - File repository = new File(repositoryTypeRoot, "abc"); - String id = RepositoryUtil.getRepositoryId(repositoryHandler, repository.getPath()); - assertEquals("abc", id); - } - - @Test(expected = IllegalArgumentException.class) - public void testGetRepositoryIdWithInvalidPath() throws IOException { - File repositoryTypeRoot = temporaryFolder.newFolder(); - repositoryConfig.setRepositoryDirectory(repositoryTypeRoot); - - File repository = new File("/etc/abc"); - String id = RepositoryUtil.getRepositoryId(repositoryHandler, repository.getPath()); - assertEquals("abc", id); - } - - @Test(expected = IllegalArgumentException.class) - public void testGetRepositoryIdWithInvalidPathButSameLength() throws IOException { - File repositoryTypeRoot = temporaryFolder.newFolder(); - repositoryConfig.setRepositoryDirectory(repositoryTypeRoot); - - File repository = new File(temporaryFolder.newFolder(), "abc"); - - String id = RepositoryUtil.getRepositoryId(repositoryHandler, repository.getPath()); - assertEquals("abc", id); - } - - @Test(expected = IllegalArgumentException.class) - public void testGetRepositoryIdWithInvalidId() throws IOException { - File repositoryTypeRoot = temporaryFolder.newFolder(); - repositoryConfig.setRepositoryDirectory(repositoryTypeRoot); - - File repository = new File(repositoryTypeRoot, "abc/123"); - RepositoryUtil.getRepositoryId(repositoryHandler, repository.getPath()); - } - -} diff --git a/scm-core/src/test/java/sonia/scm/security/DAORealmHelperTest.java b/scm-core/src/test/java/sonia/scm/security/DAORealmHelperTest.java new file mode 100644 index 0000000000..7ddeabd8ac --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/security/DAORealmHelperTest.java @@ -0,0 +1,154 @@ +package sonia.scm.security; + +import com.google.common.collect.ImmutableList; +import org.apache.shiro.authc.AuthenticationInfo; +import org.apache.shiro.authc.DisabledAccountException; +import org.apache.shiro.authc.UnknownAccountException; +import org.apache.shiro.authc.UsernamePasswordToken; +import org.apache.shiro.subject.PrincipalCollection; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.group.Group; +import sonia.scm.group.GroupDAO; +import sonia.scm.group.GroupNames; +import sonia.scm.user.User; +import sonia.scm.user.UserDAO; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DAORealmHelperTest { + + @Mock + private LoginAttemptHandler loginAttemptHandler; + + @Mock + private UserDAO userDAO; + + @Mock + private GroupDAO groupDAO; + + private DAORealmHelper helper; + + @BeforeEach + void setUpObjectUnderTest() { + helper = new DAORealmHelper(loginAttemptHandler, userDAO, groupDAO, "hitchhiker"); + } + + @Test + void shouldThrowExceptionWithoutUsername() { + assertThrows(IllegalArgumentException.class, () -> helper.authenticationInfoBuilder(null).build()); + } + + @Test + void shouldThrowExceptionWithEmptyUsername() { + assertThrows(IllegalArgumentException.class, () -> helper.authenticationInfoBuilder("").build()); + } + + @Test + void shouldThrowExceptionWithUnknownUser() { + assertThrows(UnknownAccountException.class, () -> helper.authenticationInfoBuilder("trillian").build()); + } + + @Test + void shouldThrowExceptionOnDisabledAccount() { + User user = new User("trillian"); + user.setActive(false); + when(userDAO.get("trillian")).thenReturn(user); + + assertThrows(DisabledAccountException.class, () -> helper.authenticationInfoBuilder("trillian").build()); + } + + @Test + void shouldReturnAuthenticationInfo() { + User user = new User("trillian"); + when(userDAO.get("trillian")).thenReturn(user); + + AuthenticationInfo authenticationInfo = helper.authenticationInfoBuilder("trillian").build(); + PrincipalCollection principals = authenticationInfo.getPrincipals(); + assertThat(principals.oneByType(User.class)).isSameAs(user); + assertThat(principals.oneByType(GroupNames.class)).containsOnly("_authenticated"); + assertThat(principals.oneByType(Scope.class)).isEmpty(); + } + + @Test + void shouldReturnAuthenticationInfoWithGroups() { + User user = new User("trillian"); + when(userDAO.get("trillian")).thenReturn(user); + + Group one = new Group("xml", "one", "trillian"); + Group two = new Group("xml", "two", "trillian"); + Group six = new Group("xml", "six", "dent"); + when(groupDAO.getAll()).thenReturn(ImmutableList.of(one, two, six)); + + AuthenticationInfo authenticationInfo = helper.authenticationInfoBuilder("trillian") + .withGroups(ImmutableList.of("three")) + .build(); + + PrincipalCollection principals = authenticationInfo.getPrincipals(); + assertThat(principals.oneByType(GroupNames.class)).containsOnly("_authenticated", "one", "two", "three"); + } + + @Test + void shouldReturnAuthenticationInfoWithScope() { + User user = new User("trillian"); + when(userDAO.get("trillian")).thenReturn(user); + + Scope scope = Scope.valueOf("user:*", "group:*"); + + AuthenticationInfo authenticationInfo = helper.authenticationInfoBuilder("trillian") + .withScope(scope) + .build(); + + PrincipalCollection principals = authenticationInfo.getPrincipals(); + assertThat(principals.oneByType(Scope.class)).isSameAs(scope); + } + + @Test + void shouldReturnAuthenticationInfoWithCredentials() { + User user = new User("trillian"); + when(userDAO.get("trillian")).thenReturn(user); + + AuthenticationInfo authenticationInfo = helper.authenticationInfoBuilder("trillian") + .withCredentials("secret") + .build(); + + assertThat(authenticationInfo.getCredentials()).isEqualTo("secret"); + } + + @Test + void shouldReturnAuthenticationInfoWithCredentialsFromUser() { + User user = new User("trillian"); + user.setPassword("secret"); + when(userDAO.get("trillian")).thenReturn(user); + + AuthenticationInfo authenticationInfo = helper.authenticationInfoBuilder("trillian").build(); + + assertThat(authenticationInfo.getCredentials()).isEqualTo("secret"); + } + + @Test + void shouldThrowExceptionWithWrongTypeOfToken() { + assertThrows(IllegalArgumentException.class, () -> helper.getAuthenticationInfo(BearerToken.valueOf("__bearer__"))); + } + + @Test + void shouldGetAuthenticationInfo() { + User user = new User("trillian"); + when(userDAO.get("trillian")).thenReturn(user); + + AuthenticationInfo authenticationInfo = helper.getAuthenticationInfo(new UsernamePasswordToken("trillian", "secret")); + + PrincipalCollection principals = authenticationInfo.getPrincipals(); + assertThat(principals.oneByType(User.class)).isSameAs(user); + assertThat(principals.oneByType(GroupNames.class)).containsOnly("_authenticated"); + assertThat(principals.oneByType(Scope.class)).isEmpty(); + + assertThat(authenticationInfo.getCredentials()).isNull(); + } +} diff --git a/scm-core/src/test/java/sonia/scm/security/RepositoryPermissionTest.java b/scm-core/src/test/java/sonia/scm/security/RepositoryPermissionTest.java deleted file mode 100644 index e8180ca24a..0000000000 --- a/scm-core/src/test/java/sonia/scm/security/RepositoryPermissionTest.java +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Copyright (c) 2010, Sebastian Sdorra - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * 3. Neither the name of SCM-Manager; nor the names of its - * contributors may be used to endorse or promote products derived from this - * software without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY - * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON - * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - * - * http://bitbucket.org/sdorra/scm-manager - * - */ - - -package sonia.scm.security; - -//~--- non-JDK imports -------------------------------------------------------- - -import org.junit.Test; - -import sonia.scm.repository.PermissionType; - -import static org.junit.Assert.*; - -/** - * - * @author Sebastian Sdorra - */ -public class RepositoryPermissionTest -{ - - /** - * Method description - * - */ - @Test - public void testImplies() - { - RepositoryPermission p = new RepositoryPermission("asd", - PermissionType.READ); - - assertTrue(p.implies(new RepositoryPermission("asd", PermissionType.READ))); - assertFalse(p.implies(new RepositoryPermission("asd", - PermissionType.OWNER))); - assertFalse(p.implies(new RepositoryPermission("asd", - PermissionType.WRITE))); - p = new RepositoryPermission("asd", PermissionType.OWNER); - assertTrue(p.implies(new RepositoryPermission("asd", PermissionType.READ))); - assertFalse(p.implies(new RepositoryPermission("bdb", - PermissionType.READ))); - } - - /** - * Method description - * - */ - @Test - public void testImpliesWithWildcard() - { - RepositoryPermission p = new RepositoryPermission("*", - PermissionType.OWNER); - - assertTrue(p.implies(new RepositoryPermission("asd", PermissionType.READ))); - assertTrue(p.implies(new RepositoryPermission("bdb", - PermissionType.OWNER))); - assertTrue(p.implies(new RepositoryPermission("cgd", - PermissionType.WRITE))); - } -} diff --git a/scm-core/src/test/java/sonia/scm/security/SyncingRealmHelperTest.java b/scm-core/src/test/java/sonia/scm/security/SyncingRealmHelperTest.java index 50bde53ae1..b8d6ae909e 100644 --- a/scm-core/src/test/java/sonia/scm/security/SyncingRealmHelperTest.java +++ b/scm-core/src/test/java/sonia/scm/security/SyncingRealmHelperTest.java @@ -37,6 +37,7 @@ package sonia.scm.security; import com.google.common.base.Throwables; import org.apache.shiro.authc.AuthenticationInfo; +import org.assertj.core.api.Assertions; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -53,10 +54,14 @@ import sonia.scm.web.security.PrivilegedAction; import java.io.IOException; +import static java.util.Collections.singletonList; +import static org.assertj.core.util.Arrays.asList; import static org.hamcrest.Matchers.hasItem; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -106,25 +111,6 @@ public class SyncingRealmHelperTest { helper = new SyncingRealmHelper(ctx, userManager, groupManager); } - /** - * Tests {@link SyncingRealmHelper#createAuthenticationInfo(String, User, String...)}. - */ - @Test - public void testCreateAuthenticationInfo() { - User user = new User("tricia"); - AuthenticationInfo authInfo = helper.createAuthenticationInfo("unit-test", - user, "heartOfGold"); - - assertNotNull(authInfo); - assertEquals("tricia", authInfo.getPrincipals().getPrimaryPrincipal()); - assertThat(authInfo.getPrincipals().getRealmNames(), hasItem("unit-test")); - assertEquals(user, authInfo.getPrincipals().oneByType(User.class)); - - GroupNames groups = authInfo.getPrincipals().oneByType(GroupNames.class); - - assertThat(groups, hasItem("heartOfGold")); - } - /** * Tests {@link SyncingRealmHelper#store(Group)}. * @@ -198,4 +184,45 @@ public class SyncingRealmHelperTest { helper.store(user); verify(userManager, times(1)).modify(user); } + + @Test + public void builderShouldSetInternalGroups() { + AuthenticationInfo authenticationInfo = helper + .authenticationInfo() + .forRealm("unit-test") + .andUser(new User("ziltoid")) + .withGroups("internal"); + + GroupNames groupNames = authenticationInfo.getPrincipals().oneByType(GroupNames.class); + Assertions.assertThat(groupNames.getCollection()).containsOnly("internal"); + Assertions.assertThat(groupNames.isExternal()).isFalse(); + } + + @Test + public void builderShouldSetExternalGroups() { + AuthenticationInfo authenticationInfo = helper + .authenticationInfo() + .forRealm("unit-test") + .andUser(new User("ziltoid")) + .withExternalGroups("external"); + + GroupNames groupNames = authenticationInfo.getPrincipals().oneByType(GroupNames.class); + Assertions.assertThat(groupNames.getCollection()).containsOnly("external"); + Assertions.assertThat(groupNames.isExternal()).isTrue(); + } + + @Test + public void builderShouldSetValues() { + User user = new User("ziltoid"); + AuthenticationInfo authInfo = helper + .authenticationInfo() + .forRealm("unit-test") + .andUser(user) + .withoutGroups(); + + assertNotNull(authInfo); + assertEquals("ziltoid", authInfo.getPrincipals().getPrimaryPrincipal()); + assertThat(authInfo.getPrincipals().getRealmNames(), hasItem("unit-test")); + assertEquals(user, authInfo.getPrincipals().oneByType(User.class)); + } } diff --git a/scm-core/src/test/java/sonia/scm/util/ComparablesTest.java b/scm-core/src/test/java/sonia/scm/util/ComparablesTest.java new file mode 100644 index 0000000000..50ae2254e6 --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/util/ComparablesTest.java @@ -0,0 +1,57 @@ +package sonia.scm.util; + +import org.junit.jupiter.api.Test; + +import java.util.Comparator; + +import static org.assertj.core.api.Java6Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ComparablesTest { + + @Test + void shouldCompare() { + One a = new One("a"); + One b = new One("b"); + + Comparator comparable = Comparables.comparator(One.class, "value"); + assertThat(comparable.compare(a, b)).isEqualTo(-1); + } + + @Test + void shouldThrowAnExceptionForNonExistingField() { + assertThrows(IllegalArgumentException.class, () -> Comparables.comparator(One.class, "awesome")); + } + + @Test + void shouldThrowAnExceptionForNonComparableField() { + assertThrows(IllegalArgumentException.class, () -> Comparables.comparator(One.class, "nonComparable")); + } + + @Test + void shouldThrowAnExceptionIfTheFieldHasNoGetter() { + assertThrows(IllegalArgumentException.class, () -> Comparables.comparator(One.class, "incredible")); + } + + private static class One { + + private String value; + private String incredible; + private NonComparable nonComparable; + + One(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + + public NonComparable getNonComparable() { + return nonComparable; + } + } + + private static class NonComparable {} + +} diff --git a/scm-core/src/test/java/sonia/scm/web/AbstractRepositoryJsonEnricherTest.java b/scm-core/src/test/java/sonia/scm/web/AbstractRepositoryJsonEnricherTest.java new file mode 100644 index 0000000000..2c8ef76464 --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/web/AbstractRepositoryJsonEnricherTest.java @@ -0,0 +1,107 @@ +package sonia.scm.web; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.io.Resources; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.api.v2.resources.ScmPathInfoStore; + +import javax.ws.rs.core.MediaType; +import java.io.IOException; +import java.net.URI; +import java.net.URL; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +class AbstractRepositoryJsonEnricherTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + private AbstractRepositoryJsonEnricher linkEnricher; + private JsonNode rootNode; + + @BeforeEach + void globalSetUp() { + ScmPathInfoStore pathInfoStore = new ScmPathInfoStore(); + pathInfoStore.set(() -> URI.create("/")); + + linkEnricher = new AbstractRepositoryJsonEnricher(objectMapper) { + @Override + protected void enrichRepositoryNode(JsonNode repositoryNode, String namespace, String name) { + addLink(repositoryNode, "new-link", "/somewhere"); + } + }; + } + + @Test + void shouldEnrichRepositories() throws IOException { + URL resource = Resources.getResource("sonia/scm/repository/repository-001.json"); + rootNode = objectMapper.readTree(resource); + + JsonEnricherContext context = new JsonEnricherContext( + URI.create("/"), + MediaType.valueOf(VndMediaType.REPOSITORY), + rootNode + ); + + linkEnricher.enrich(context); + + String configLink = context.getResponseEntity() + .get("_links") + .get("new-link") + .get("href") + .asText(); + + assertThat(configLink).isEqualTo("/somewhere"); + } + + @Test + void shouldEnrichAllRepositories() throws IOException { + URL resource = Resources.getResource("sonia/scm/repository/repository-collection-001.json"); + rootNode = objectMapper.readTree(resource); + + JsonEnricherContext context = new JsonEnricherContext( + URI.create("/"), + MediaType.valueOf(VndMediaType.REPOSITORY_COLLECTION), + rootNode + ); + + linkEnricher.enrich(context); + + context.getResponseEntity() + .get("_embedded") + .withArray("repositories") + .elements() + .forEachRemaining(node -> { + String configLink = node + .get("_links") + .get("new-link") + .get("href") + .asText(); + + assertThat(configLink).isEqualTo("/somewhere"); + }); + } + + @Test + void shouldNotModifyObjectsWithUnsupportedMediaType() throws IOException { + URL resource = Resources.getResource("sonia/scm/repository/repository-001.json"); + rootNode = objectMapper.readTree(resource); + JsonEnricherContext context = new JsonEnricherContext( + URI.create("/"), + MediaType.valueOf(VndMediaType.USER), + rootNode + ); + + linkEnricher.enrich(context); + + boolean hasNewPullRequestLink = context.getResponseEntity() + .get("_links") + .has("new-link"); + + assertThat(hasNewPullRequestLink).isFalse(); + } +} diff --git a/scm-core/src/test/java/sonia/scm/web/JsonEnricherBaseTest.java b/scm-core/src/test/java/sonia/scm/web/JsonEnricherBaseTest.java index 43ed4940fa..2f53ae8102 100644 --- a/scm-core/src/test/java/sonia/scm/web/JsonEnricherBaseTest.java +++ b/scm-core/src/test/java/sonia/scm/web/JsonEnricherBaseTest.java @@ -23,6 +23,14 @@ public class JsonEnricherBaseTest { assertThat(enricher.resultHasMediaType(MediaType.APPLICATION_XML, context)).isFalse(); } + @Test + public void testResultHasMediaTypeWithCamelCaseMediaType() { + String mediaType = "application/hitchhikersGuideToTheGalaxy"; + JsonEnricherContext context = new JsonEnricherContext(null, MediaType.valueOf(mediaType), null); + + assertThat(enricher.resultHasMediaType(mediaType, context)).isTrue(); + } + @Test public void testAppendLink() { ObjectNode root = objectMapper.createObjectNode(); diff --git a/scm-core/src/test/java/sonia/scm/xml/IndentXMLStreamWriterTest.java b/scm-core/src/test/java/sonia/scm/xml/IndentXMLStreamWriterTest.java index ecfdd06a0f..16c4278793 100644 --- a/scm-core/src/test/java/sonia/scm/xml/IndentXMLStreamWriterTest.java +++ b/scm-core/src/test/java/sonia/scm/xml/IndentXMLStreamWriterTest.java @@ -89,7 +89,7 @@ public class IndentXMLStreamWriterTest StringBuilder buffer = new StringBuilder(""); buffer.append(IndentXMLStreamWriter.LINE_SEPARATOR); buffer.append("").append(IndentXMLStreamWriter.LINE_SEPARATOR); - buffer.append(" Hello"); + buffer.append(" Hello"); buffer.append(IndentXMLStreamWriter.LINE_SEPARATOR); buffer.append("").append(IndentXMLStreamWriter.LINE_SEPARATOR); diff --git a/scm-core/src/test/java/sonia/scm/xml/XmlInstantAdapterTest.java b/scm-core/src/test/java/sonia/scm/xml/XmlInstantAdapterTest.java new file mode 100644 index 0000000000..eb1ea86aee --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/xml/XmlInstantAdapterTest.java @@ -0,0 +1,47 @@ +package sonia.scm.xml; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junitpioneer.jupiter.TempDirectory; + +import javax.xml.bind.JAXB; +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlRootElement; +import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; +import java.nio.file.Path; +import java.time.Instant; + +import static org.junit.jupiter.api.Assertions.*; + +@ExtendWith(TempDirectory.class) +class XmlInstantAdapterTest { + + @Test + void shouldMarshalAndUnmarshalInstant(@TempDirectory.TempDir Path tempDirectory) { + Path path = tempDirectory.resolve("instant.xml"); + + Instant instant = Instant.now(); + InstantObject object = new InstantObject(instant); + JAXB.marshal(object, path.toFile()); + + InstantObject unmarshaled = JAXB.unmarshal(path.toFile(), InstantObject.class); + assertEquals(instant, unmarshaled.instant); + } + + @XmlRootElement(name = "instant-object") + @XmlAccessorType(XmlAccessType.FIELD) + public static class InstantObject { + + @XmlJavaTypeAdapter(XmlInstantAdapter.class) + private Instant instant; + + public InstantObject() { + } + + InstantObject(Instant instant) { + this.instant = instant; + } + } + +} diff --git a/scm-core/src/test/resources/sonia/scm/repository/repository-001.json b/scm-core/src/test/resources/sonia/scm/repository/repository-001.json new file mode 100644 index 0000000000..43ea136942 --- /dev/null +++ b/scm-core/src/test/resources/sonia/scm/repository/repository-001.json @@ -0,0 +1,42 @@ +{ + "creationDate": "2018-11-09T09:48:32.732Z", + "description": "Handling static webresources made easy", + "healthCheckFailures": [], + "lastModified": "2018-11-09T09:49:20.973Z", + "namespace": "scmadmin", + "name": "web-resources", + "archived": false, + "type": "git", + "_links": { + "self": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources" + }, + "delete": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources" + }, + "update": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources" + }, + "permissions": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/permissions/" + }, + "protocol": [ + { + "href": "http://localhost:8081/scm/repo/scmadmin/web-resources", + "name": "http" + } + ], + "tags": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/tags/" + }, + "branches": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/branches/" + }, + "changesets": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/changesets/" + }, + "sources": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/sources/" + } + } +} diff --git a/scm-core/src/test/resources/sonia/scm/repository/repository-collection-001.json b/scm-core/src/test/resources/sonia/scm/repository/repository-collection-001.json new file mode 100644 index 0000000000..f4eeb24bbc --- /dev/null +++ b/scm-core/src/test/resources/sonia/scm/repository/repository-collection-001.json @@ -0,0 +1,106 @@ +{ + "page": 0, + "pageTotal": 1, + "_links": { + "self": { + "href": "http://localhost:8081/scm/api/v2/repositories/?page=0&pageSize=10" + }, + "first": { + "href": "http://localhost:8081/scm/api/v2/repositories/?page=0&pageSize=10" + }, + "last": { + "href": "http://localhost:8081/scm/api/v2/repositories/?page=0&pageSize=10" + }, + "create": { + "href": "http://localhost:8081/scm/api/v2/repositories/" + } + }, + "_embedded": { + "repositories": [ + { + "creationDate": "2018-11-09T09:48:32.732Z", + "description": "Handling static webresources made easy", + "healthCheckFailures": [], + "lastModified": "2018-11-09T09:49:20.973Z", + "namespace": "scmadmin", + "name": "web-resources", + "archived": false, + "type": "git", + "_links": { + "self": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources" + }, + "delete": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources" + }, + "update": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources" + }, + "permissions": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/permissions/" + }, + "protocol": [ + { + "href": "http://localhost:8081/scm/repo/scmadmin/web-resources", + "name": "http" + } + ], + "tags": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/tags/" + }, + "branches": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/branches/" + }, + "changesets": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/changesets/" + }, + "sources": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/sources/" + } + } + }, + { + "creationDate": "2018-11-09T09:48:32.732Z", + "description": "Handling static webresources made easy", + "healthCheckFailures": [], + "lastModified": "2018-11-09T09:49:20.973Z", + "namespace": "scmadmin", + "name": "web-resources", + "archived": false, + "type": "git", + "_links": { + "self": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources" + }, + "delete": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources" + }, + "update": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources" + }, + "permissions": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/permissions/" + }, + "protocol": [ + { + "href": "http://localhost:8081/scm/repo/scmadmin/web-resources", + "name": "http" + } + ], + "tags": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/tags/" + }, + "branches": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/branches/" + }, + "changesets": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/changesets/" + }, + "sources": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/sources/" + } + } + } + ] + } +} diff --git a/scm-core/src/test/resources/sonia/scm/shiro.ini b/scm-core/src/test/resources/sonia/scm/shiro.ini index fbdd35ba50..fda268ec83 100644 --- a/scm-core/src/test/resources/sonia/scm/shiro.ini +++ b/scm-core/src/test/resources/sonia/scm/shiro.ini @@ -8,5 +8,5 @@ unpriv = secret [roles] admin = * user = something:* -repo_read = "repository:read:1" -repo_write = "repository:push:1" +repo_read = "repository:read,pull:1" +repo_write = "repository:read,write,pull,push:1" diff --git a/scm-dao-xml/src/main/java/sonia/scm/group/xml/XmlGroupDAO.java b/scm-dao-xml/src/main/java/sonia/scm/group/xml/XmlGroupDAO.java index 577d732317..d6b65b41bd 100644 --- a/scm-dao-xml/src/main/java/sonia/scm/group/xml/XmlGroupDAO.java +++ b/scm-dao-xml/src/main/java/sonia/scm/group/xml/XmlGroupDAO.java @@ -64,9 +64,11 @@ public class XmlGroupDAO extends AbstractXmlDAO * @param storeFactory */ @Inject - public XmlGroupDAO(ConfigurationStoreFactory storeFactory) - { - super(storeFactory.getStore(XmlGroupDatabase.class, STORE_NAME)); + public XmlGroupDAO(ConfigurationStoreFactory storeFactory) { + super(storeFactory + .withType(XmlGroupDatabase.class) + .withName(STORE_NAME) + .build()); } //~--- methods -------------------------------------------------------------- diff --git a/scm-dao-xml/src/main/java/sonia/scm/repository/xml/MetadataStore.java b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/MetadataStore.java new file mode 100644 index 0000000000..1f5f0e81b6 --- /dev/null +++ b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/MetadataStore.java @@ -0,0 +1,50 @@ +package sonia.scm.repository.xml; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.ContextEntry; +import sonia.scm.repository.InternalRepositoryException; +import sonia.scm.repository.Repository; + +import javax.xml.bind.JAXBContext; +import javax.xml.bind.JAXBException; +import javax.xml.bind.Marshaller; +import java.nio.file.Path; + +class MetadataStore { + + private static final Logger LOG = LoggerFactory.getLogger(MetadataStore.class); + + private final JAXBContext jaxbContext; + + MetadataStore() { + try { + jaxbContext = JAXBContext.newInstance(Repository.class); + } catch (JAXBException ex) { + throw new IllegalStateException("failed to create jaxb context for repository", ex); + } + } + + Repository read(Path path) { + LOG.trace("read repository metadata from {}", path); + try { + return (Repository) jaxbContext.createUnmarshaller().unmarshal(path.toFile()); + } catch (JAXBException ex) { + throw new InternalRepositoryException( + ContextEntry.ContextBuilder.entity(Path.class, path.toString()).build(), "failed read repository metadata", ex + ); + } + } + + void write(Path path, Repository repository) { + LOG.trace("write repository metadata of {} to {}", repository.getNamespaceAndName(), path); + try { + Marshaller marshaller = jaxbContext.createMarshaller(); + marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, Boolean.TRUE); + marshaller.marshal(repository, path.toFile()); + } catch (JAXBException ex) { + throw new InternalRepositoryException(repository, "failed write repository metadata", ex); + } + } + +} diff --git a/scm-dao-xml/src/main/java/sonia/scm/repository/xml/PathDatabase.java b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/PathDatabase.java new file mode 100644 index 0000000000..70698aed59 --- /dev/null +++ b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/PathDatabase.java @@ -0,0 +1,146 @@ +package sonia.scm.repository.xml; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.ContextEntry; +import sonia.scm.repository.InternalRepositoryException; +import sonia.scm.xml.IndentXMLStreamWriter; +import sonia.scm.xml.XmlStreams; + +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.XMLStreamReader; +import javax.xml.stream.XMLStreamWriter; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Map; + +class PathDatabase { + + private static final Logger LOG = LoggerFactory.getLogger(PathDatabase.class); + + private static final String ENCODING = "UTF-8"; + private static final String VERSION = "1.0"; + + private static final String ELEMENT_REPOSITORIES = "repositories"; + private static final String ATTRIBUTE_CREATION_TIME = "creation-time"; + private static final String ATTRIBUTE_LAST_MODIFIED = "last-modified"; + + private static final String ELEMENT_REPOSITORY = "repository"; + private static final String ATTRIBUTE_ID = "id"; + + private final Path storePath; + + PathDatabase(Path storePath){ + this.storePath = storePath; + } + + void write(Long creationTime, Long lastModified, Map pathDatabase) { + ensureParentDirectoryExists(); + LOG.trace("write repository path database to {}", storePath); + + try (IndentXMLStreamWriter writer = XmlStreams.createWriter(storePath)) { + writer.writeStartDocument(ENCODING, VERSION); + + writeRepositoriesStart(writer, creationTime, lastModified); + for (Map.Entry e : pathDatabase.entrySet()) { + writeRepository(writer, e.getKey(), e.getValue()); + } + writer.writeEndElement(); + + writer.writeEndDocument(); + } catch (XMLStreamException | IOException ex) { + throw new InternalRepositoryException( + ContextEntry.ContextBuilder.entity(Path.class, storePath.toString()).build(), + "failed to write repository path database", + ex + ); + } + } + + private void ensureParentDirectoryExists() { + Path parent = storePath.getParent(); + // Files.exists is slow on java 8 + if (!parent.toFile().exists()) { + try { + Files.createDirectories(parent); + } catch (IOException ex) { + throw new InternalRepositoryException( + ContextEntry.ContextBuilder.entity(Path.class, parent.toString()).build(), + "failed to create parent directory", + ex + ); + } + } + } + + private void writeRepositoriesStart(XMLStreamWriter writer, Long creationTime, Long lastModified) throws XMLStreamException { + writer.writeStartElement(ELEMENT_REPOSITORIES); + writer.writeAttribute(ATTRIBUTE_CREATION_TIME, String.valueOf(creationTime)); + writer.writeAttribute(ATTRIBUTE_LAST_MODIFIED, String.valueOf(lastModified)); + } + + private void writeRepository(XMLStreamWriter writer, String id, Path value) throws XMLStreamException { + writer.writeStartElement(ELEMENT_REPOSITORY); + writer.writeAttribute(ATTRIBUTE_ID, id); + writer.writeCharacters(value.toString()); + writer.writeEndElement(); + } + + void read(OnRepositories onRepositories, OnRepository onRepository) { + LOG.trace("read repository path database from {}", storePath); + XMLStreamReader reader = null; + try { + reader = XmlStreams.createReader(storePath); + + while (reader.hasNext()) { + int eventType = reader.next(); + + if (eventType == XMLStreamReader.START_ELEMENT) { + String element = reader.getLocalName(); + if (ELEMENT_REPOSITORIES.equals(element)) { + readRepositories(reader, onRepositories); + } else if (ELEMENT_REPOSITORY.equals(element)) { + readRepository(reader, onRepository); + } + } + } + } catch (XMLStreamException | IOException ex) { + throw new InternalRepositoryException( + ContextEntry.ContextBuilder.entity(Path.class, storePath.toString()).build(), + "failed to read repository path database", + ex + ); + } finally { + XmlStreams.close(reader); + } + } + + private void readRepository(XMLStreamReader reader, OnRepository onRepository) throws XMLStreamException { + String id = reader.getAttributeValue(null, ATTRIBUTE_ID); + Path path = Paths.get(reader.getElementText()); + onRepository.handle(id, path); + } + + private void readRepositories(XMLStreamReader reader, OnRepositories onRepositories) { + String creationTime = reader.getAttributeValue(null, ATTRIBUTE_CREATION_TIME); + String lastModified = reader.getAttributeValue(null, ATTRIBUTE_LAST_MODIFIED); + onRepositories.handle(Long.parseLong(creationTime), Long.parseLong(lastModified)); + } + + @FunctionalInterface + interface OnRepositories { + + void handle(Long creationTime, Long lastModified); + + } + + @FunctionalInterface + interface OnRepository { + + void handle(String id, Path path); + + } + +} diff --git a/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryDAO.java b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryDAO.java index 4510706721..4987b269da 100644 --- a/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryDAO.java +++ b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryDAO.java @@ -1,19 +1,19 @@ /** * Copyright (c) 2010, Sebastian Sdorra * All rights reserved. - * + *

* Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: - * + *

* 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. + * this list of conditions and the following disclaimer. * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. * 3. Neither the name of SCM-Manager; nor the names of its - * contributors may be used to endorse or promote products derived from this - * software without specific prior written permission. - * + * contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + *

* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE @@ -24,9 +24,8 @@ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - * + *

* http://bitbucket.org/sdorra/scm-manager - * */ @@ -34,82 +33,231 @@ package sonia.scm.repository.xml; //~--- non-JDK imports -------------------------------------------------------- -import com.google.inject.Inject; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableList; import com.google.inject.Singleton; +import sonia.scm.SCMContextProvider; +import sonia.scm.io.FileSystem; +import sonia.scm.repository.InitialRepositoryLocationResolver; +import sonia.scm.repository.InternalRepositoryException; import sonia.scm.repository.NamespaceAndName; +import sonia.scm.repository.PathBasedRepositoryDAO; import sonia.scm.repository.Repository; -import sonia.scm.repository.RepositoryDAO; -import sonia.scm.store.ConfigurationStoreFactory; -import sonia.scm.xml.AbstractXmlDAO; +import sonia.scm.store.StoreConstants; + +import javax.inject.Inject; +import java.io.IOException; +import java.nio.file.Path; +import java.time.Clock; +import java.util.Collection; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; /** - * * @author Sebastian Sdorra */ @Singleton -public class XmlRepositoryDAO - extends AbstractXmlDAO - implements RepositoryDAO -{ +public class XmlRepositoryDAO implements PathBasedRepositoryDAO { - /** Field description */ - public static final String STORE_NAME = "repositories"; + private static final String STORE_NAME = "repositories"; - //~--- constructors --------------------------------------------------------- + private final PathDatabase pathDatabase; + private final MetadataStore metadataStore = new MetadataStore(); + + private final SCMContextProvider context; + private final InitialRepositoryLocationResolver locationResolver; + private final FileSystem fileSystem; + + private final Map pathById; + private final Map byId; + private final Map byNamespaceAndName; + + private final Clock clock; + + private Long creationTime; + private Long lastModified; - /** - * Constructs ... - * - * - * @param storeFactory - */ @Inject - public XmlRepositoryDAO(ConfigurationStoreFactory storeFactory) - { - super(storeFactory.getStore(XmlRepositoryDatabase.class, STORE_NAME)); + public XmlRepositoryDAO(SCMContextProvider context, InitialRepositoryLocationResolver locationResolver, FileSystem fileSystem) { + this(context, locationResolver, fileSystem, Clock.systemUTC()); } - //~--- methods -------------------------------------------------------------- + XmlRepositoryDAO(SCMContextProvider context, InitialRepositoryLocationResolver locationResolver, FileSystem fileSystem, Clock clock) { + this.context = context; + this.locationResolver = locationResolver; + this.fileSystem = fileSystem; - @Override - public boolean contains(NamespaceAndName namespaceAndName) - { - return db.contains(namespaceAndName); + this.clock = clock; + this.creationTime = clock.millis(); + + this.pathById = new ConcurrentHashMap<>(); + this.byId = new ConcurrentHashMap<>(); + this.byNamespaceAndName = new ConcurrentHashMap<>(); + + pathDatabase = new PathDatabase(resolveStorePath()); + read(); } - //~--- get methods ---------------------------------------------------------- + private void read() { + Path storePath = resolveStorePath(); - @Override - public Repository get(NamespaceAndName namespaceAndName) - { - return db.get(namespaceAndName); + // Files.exists is slow on java 8 + if (storePath.toFile().exists()) { + pathDatabase.read(this::onLoadDates, this::onLoadRepository); + } } - //~--- methods -------------------------------------------------------------- - - /** - * Method description - * - * - * @param repository - * - * @return - */ - @Override - protected Repository clone(Repository repository) - { - return repository.clone(); + private void onLoadDates(Long creationTime, Long lastModified) { + this.creationTime = creationTime; + this.lastModified = lastModified; + } + + private void onLoadRepository(String id, Path repositoryPath) { + Path metadataPath = resolveMetadataPath(context.resolve(repositoryPath)); + + Repository repository = metadataStore.read(metadataPath); + + byId.put(id, repository); + byNamespaceAndName.put(repository.getNamespaceAndName(), repository); + pathById.put(id, repositoryPath); + } + + @VisibleForTesting + Path resolveStorePath() { + return context.getBaseDirectory() + .toPath() + .resolve(StoreConstants.CONFIG_DIRECTORY_NAME) + .resolve(STORE_NAME.concat(StoreConstants.FILE_EXTENSION)); + } + + + @VisibleForTesting + Path resolveMetadataPath(Path repositoryPath) { + return repositoryPath.resolve(StoreConstants.REPOSITORY_METADATA.concat(StoreConstants.FILE_EXTENSION)); } - /** - * Method description - * - * - * @return - */ @Override - protected XmlRepositoryDatabase createNewDatabase() - { - return new XmlRepositoryDatabase(); + public String getType() { + return "xml"; + } + + @Override + public Long getCreationTime() { + return creationTime; + } + + @Override + public Long getLastModified() { + return lastModified; + } + + @Override + public void add(Repository repository) { + Repository clone = repository.clone(); + + Path repositoryPath = locationResolver.getPath(repository.getId()); + Path resolvedPath = context.resolve(repositoryPath); + + try { + fileSystem.create(resolvedPath.toFile()); + + Path metadataPath = resolveMetadataPath(resolvedPath); + metadataStore.write(metadataPath, repository); + + synchronized (this) { + pathById.put(repository.getId(), repositoryPath); + + byId.put(repository.getId(), clone); + byNamespaceAndName.put(repository.getNamespaceAndName(), clone); + + writePathDatabase(); + } + + } catch (IOException e) { + throw new InternalRepositoryException(repository, "failed to create filesystem", e); + } + } + + private void writePathDatabase() { + lastModified = clock.millis(); + pathDatabase.write(creationTime, lastModified, pathById); + } + + @Override + public boolean contains(Repository repository) { + return byId.containsKey(repository.getId()); + } + + @Override + public boolean contains(NamespaceAndName namespaceAndName) { + return byNamespaceAndName.containsKey(namespaceAndName); + } + + @Override + public boolean contains(String id) { + return byId.containsKey(id); + } + + @Override + public Repository get(NamespaceAndName namespaceAndName) { + return byNamespaceAndName.get(namespaceAndName); + } + + @Override + public Repository get(String id) { + return byId.get(id); + } + + @Override + public Collection getAll() { + return ImmutableList.copyOf(byNamespaceAndName.values()); + } + + @Override + public void modify(Repository repository) { + Repository clone = repository.clone(); + + synchronized (this) { + // remove old namespaceAndName from map, in case of rename + Repository prev = byId.put(clone.getId(), clone); + if (prev != null) { + byNamespaceAndName.remove(prev.getNamespaceAndName()); + } + byNamespaceAndName.put(clone.getNamespaceAndName(), clone); + + writePathDatabase(); + } + + Path repositoryPath = context.resolve(getPath(repository.getId())); + Path metadataPath = resolveMetadataPath(repositoryPath); + metadataStore.write(metadataPath, clone); + } + + @Override + public void delete(Repository repository) { + Path path; + synchronized (this) { + Repository prev = byId.remove(repository.getId()); + if (prev != null) { + byNamespaceAndName.remove(prev.getNamespaceAndName()); + } + + path = pathById.remove(repository.getId()); + + writePathDatabase(); + } + + path = context.resolve(path); + + try { + fileSystem.destroy(path.toFile()); + } catch (IOException e) { + throw new InternalRepositoryException(repository, "failed to destroy filesystem", e); + } + } + + @Override + public Path getPath(String repositoryId) { + return pathById.get(repositoryId); } } diff --git a/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryDatabase.java b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryDatabase.java deleted file mode 100644 index 93be611213..0000000000 --- a/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryDatabase.java +++ /dev/null @@ -1,214 +0,0 @@ -/** - * Copyright (c) 2010, Sebastian Sdorra - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * 3. Neither the name of SCM-Manager; nor the names of its - * contributors may be used to endorse or promote products derived from this - * software without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY - * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON - * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - * - * http://bitbucket.org/sdorra/scm-manager - * - */ - - - -package sonia.scm.repository.xml; - -//~--- non-JDK imports -------------------------------------------------------- - -import sonia.scm.repository.NamespaceAndName; -import sonia.scm.repository.Repository; -import sonia.scm.xml.XmlDatabase; - -import javax.xml.bind.annotation.XmlAccessType; -import javax.xml.bind.annotation.XmlAccessorType; -import javax.xml.bind.annotation.XmlElement; -import javax.xml.bind.annotation.XmlRootElement; -import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; -import java.util.Collection; -import java.util.LinkedHashMap; -import java.util.Map; - -//~--- JDK imports ------------------------------------------------------------ - -@XmlRootElement(name = "repository-db") -@XmlAccessorType(XmlAccessType.FIELD) -public class XmlRepositoryDatabase implements XmlDatabase -{ - - public XmlRepositoryDatabase() - { - long c = System.currentTimeMillis(); - - creationTime = c; - lastModified = c; - } - - static String createKey(NamespaceAndName namespaceAndName) - { - return namespaceAndName.getNamespace() + ":" + namespaceAndName.getName(); - } - - static String createKey(Repository repository) - { - return createKey(repository.getNamespaceAndName()); - } - - @Override - public void add(Repository repository) - { - repositoryMap.put(createKey(repository), repository); - } - - public boolean contains(NamespaceAndName namespaceAndName) - { - return repositoryMap.containsKey(createKey(namespaceAndName)); - } - - @Override - public boolean contains(String id) - { - return get(id) != null; - } - - public boolean contains(Repository repository) - { - return repositoryMap.containsKey(createKey(repository)); - } - - public void remove(Repository repository) - { - repositoryMap.remove(createKey(repository)); - } - - @Override - public Repository remove(String id) - { - Repository r = get(id); - - remove(r); - - return r; - } - - @Override - public Collection values() - { - return repositoryMap.values(); - } - - //~--- get methods ---------------------------------------------------------- - - public Repository get(NamespaceAndName namespaceAndName) - { - return repositoryMap.get(createKey(namespaceAndName)); - } - - /** - * Method description - * - * - * @param id - * - * @return - */ - @Override - public Repository get(String id) - { - Repository repository = null; - - for (Repository r : values()) - { - if (r.getId().equals(id)) - { - repository = r; - - break; - } - } - - return repository; - } - - /** - * Method description - * - * - * @return - */ - @Override - public long getCreationTime() - { - return creationTime; - } - - /** - * Method description - * - * - * @return - */ - @Override - public long getLastModified() - { - return lastModified; - } - - //~--- set methods ---------------------------------------------------------- - - /** - * Method description - * - * - * @param creationTime - */ - @Override - public void setCreationTime(long creationTime) - { - this.creationTime = creationTime; - } - - /** - * Method description - * - * - * @param lastModified - */ - @Override - public void setLastModified(long lastModified) - { - this.lastModified = lastModified; - } - - //~--- fields --------------------------------------------------------------- - - /** Field description */ - private Long creationTime; - - /** Field description */ - private Long lastModified; - - /** Field description */ - @XmlJavaTypeAdapter(XmlRepositoryMapAdapter.class) - @XmlElement(name = "repositories") - private Map repositoryMap = new LinkedHashMap<>(); -} diff --git a/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryList.java b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryList.java deleted file mode 100644 index d9807e9188..0000000000 --- a/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryList.java +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Copyright (c) 2010, Sebastian Sdorra - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * 3. Neither the name of SCM-Manager; nor the names of its - * contributors may be used to endorse or promote products derived from this - * software without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY - * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON - * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - * - * http://bitbucket.org/sdorra/scm-manager - * - */ - - - -package sonia.scm.repository.xml; - -//~--- non-JDK imports -------------------------------------------------------- - -import sonia.scm.repository.Repository; - -//~--- JDK imports ------------------------------------------------------------ - -import java.util.Iterator; -import java.util.LinkedList; -import java.util.Map; - -import javax.xml.bind.annotation.XmlAccessType; -import javax.xml.bind.annotation.XmlAccessorType; -import javax.xml.bind.annotation.XmlElement; -import javax.xml.bind.annotation.XmlRootElement; - -/** - * - * @author Sebastian Sdorra - */ -@XmlRootElement(name = "repositories") -@XmlAccessorType(XmlAccessType.FIELD) -public class XmlRepositoryList implements Iterable -{ - - /** - * Constructs ... - * - */ - public XmlRepositoryList() {} - - /** - * Constructs ... - * - * - * - * @param repositoryMap - */ - public XmlRepositoryList(Map repositoryMap) - { - this.repositories = new LinkedList(repositoryMap.values()); - } - - //~--- methods -------------------------------------------------------------- - - /** - * Method description - * - * - * @return - */ - @Override - public Iterator iterator() - { - return repositories.iterator(); - } - - //~--- get methods ---------------------------------------------------------- - - /** - * Method description - * - * - * @return - */ - public LinkedList getRepositories() - { - return repositories; - } - - //~--- set methods ---------------------------------------------------------- - - /** - * Method description - * - * - * @param repositories - */ - public void setRepositories(LinkedList repositories) - { - this.repositories = repositories; - } - - //~--- fields --------------------------------------------------------------- - - /** Field description */ - @XmlElement(name = "repository") - private LinkedList repositories; -} diff --git a/scm-dao-xml/src/main/java/sonia/scm/store/FileBasedStoreFactory.java b/scm-dao-xml/src/main/java/sonia/scm/store/FileBasedStoreFactory.java index 5bfc4f34b9..d37a150723 100644 --- a/scm-dao-xml/src/main/java/sonia/scm/store/FileBasedStoreFactory.java +++ b/scm-dao-xml/src/main/java/sonia/scm/store/FileBasedStoreFactory.java @@ -31,18 +31,21 @@ package sonia.scm.store; //~--- non-JDK imports -------------------------------------------------------- + import org.slf4j.Logger; import org.slf4j.LoggerFactory; - import sonia.scm.SCMContextProvider; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryLocationResolver; import sonia.scm.util.IOUtil; -//~--- JDK imports ------------------------------------------------------------ import java.io.File; +//~--- JDK imports ------------------------------------------------------------ + /** * Abstract store factory for file based stores. - * + * * @author Sebastian Sdorra */ public abstract class FileBasedStoreFactory { @@ -51,39 +54,54 @@ public abstract class FileBasedStoreFactory { * the logger for FileBasedStoreFactory */ private static final Logger LOG = LoggerFactory.getLogger(FileBasedStoreFactory.class); + private SCMContextProvider contextProvider; + private RepositoryLocationResolver repositoryLocationResolver; + private Store store; - private static final String BASE_DIRECTORY = "var"; - - private final SCMContextProvider context; - - private final String dataDirectoryName; - - private File dataDirectory; - - protected FileBasedStoreFactory(SCMContextProvider context, - String dataDirectoryName) { - this.context = context; - this.dataDirectoryName = dataDirectoryName; + protected FileBasedStoreFactory(SCMContextProvider contextProvider , RepositoryLocationResolver repositoryLocationResolver, Store store) { + this.contextProvider = contextProvider; + this.repositoryLocationResolver = repositoryLocationResolver; + this.store = store; } - //~--- get methods ---------------------------------------------------------- - /** - * Returns data directory for given name. - * - * @param name name of data directory - * - * @return data directory - */ - protected File getDirectory(String name) { - if (dataDirectory == null) { - dataDirectory = new File(context.getBaseDirectory(), - BASE_DIRECTORY.concat(File.separator).concat(dataDirectoryName)); - LOG.debug("create data directory {}", dataDirectory); - } + protected File getStoreLocation(StoreParameters storeParameters) { + return getStoreLocation(storeParameters.getName(), null, storeParameters.getRepository()); + } - File storeDirectory = new File(dataDirectory, name); + protected File getStoreLocation(TypedStoreParameters storeParameters) { + return getStoreLocation(storeParameters.getName(), storeParameters.getType(), storeParameters.getRepository()); + } + + protected File getStoreLocation(String name, Class type, Repository repository) { + File storeDirectory; + if (repository != null) { + LOG.debug("create store with type: {}, name: {} and repository: {}", type, name, repository.getNamespaceAndName()); + storeDirectory = this.getStoreDirectory(store, repository); + } else { + LOG.debug("create store with type: {} and name: {} ", type, name); + storeDirectory = this.getStoreDirectory(store); + } IOUtil.mkdirs(storeDirectory); - return storeDirectory; + return new File(storeDirectory, name); + } + + /** + * Get the store directory of a specific repository + * @param store the type of the store + * @param repository the repo + * @return the store directory of a specific repository + */ + private File getStoreDirectory(Store store, Repository repository) { + return new File(repositoryLocationResolver.getPath(repository.getId()).toFile(), store.getRepositoryStoreDirectory()); + } + + /** + * Get the global store directory + * @param store the type of the store + * @return the global store directory + */ + private File getStoreDirectory(Store store) { + return new File(contextProvider.getBaseDirectory(), store.getGlobalStoreDirectory()); } } diff --git a/scm-dao-xml/src/main/java/sonia/scm/store/FileBlobStoreFactory.java b/scm-dao-xml/src/main/java/sonia/scm/store/FileBlobStoreFactory.java index 8cc5b34ac2..7e2e5a9e29 100644 --- a/scm-dao-xml/src/main/java/sonia/scm/store/FileBlobStoreFactory.java +++ b/scm-dao-xml/src/main/java/sonia/scm/store/FileBlobStoreFactory.java @@ -31,14 +31,17 @@ package sonia.scm.store; //~--- non-JDK imports -------------------------------------------------------- + import com.google.inject.Inject; import com.google.inject.Singleton; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; - import sonia.scm.SCMContextProvider; +import sonia.scm.repository.RepositoryLocationResolver; import sonia.scm.security.KeyGenerator; +import sonia.scm.util.IOUtil; + +import java.io.File; /** * File based store factory. @@ -48,8 +51,6 @@ import sonia.scm.security.KeyGenerator; @Singleton public class FileBlobStoreFactory extends FileBasedStoreFactory implements BlobStoreFactory { - private static final String DIRECTORY_NAME = "blob"; - /** * the logger for FileBlobStoreFactory */ @@ -60,21 +61,22 @@ public class FileBlobStoreFactory extends FileBasedStoreFactory implements BlobS /** * Constructs a new instance. * - * @param context scm context + * @param repositoryLocationResolver location resolver * @param keyGenerator key generator */ @Inject - public FileBlobStoreFactory(SCMContextProvider context, - KeyGenerator keyGenerator) { - super(context, DIRECTORY_NAME); + public FileBlobStoreFactory(SCMContextProvider contextProvider ,RepositoryLocationResolver repositoryLocationResolver, KeyGenerator keyGenerator) { + super(contextProvider, repositoryLocationResolver, Store.BLOB); this.keyGenerator = keyGenerator; } @Override - public BlobStore getBlobStore(String name) { - LOG.debug("create new blob with name {}", name); - - return new FileBlobStore(keyGenerator, getDirectory(name)); + @SuppressWarnings("unchecked") + public BlobStore getStore(StoreParameters storeParameters) { + File storeLocation = getStoreLocation(storeParameters); + IOUtil.mkdirs(storeLocation); + return new FileBlobStore(keyGenerator, storeLocation); } + } diff --git a/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationEntryStore.java b/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationEntryStore.java index 6a9098b545..40cf03c8a8 100644 --- a/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationEntryStore.java +++ b/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationEntryStore.java @@ -35,32 +35,14 @@ package sonia.scm.store; //~--- non-JDK imports -------------------------------------------------------- -import com.google.common.base.Charsets; import com.google.common.base.Predicate; import com.google.common.collect.Collections2; import com.google.common.collect.Maps; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; - import sonia.scm.security.KeyGenerator; import sonia.scm.xml.IndentXMLStreamWriter; - -//~--- JDK imports ------------------------------------------------------------ - -import java.io.File; -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.FileOutputStream; -import java.io.InputStreamReader; -import java.io.OutputStreamWriter; -import java.io.Reader; -import java.io.Writer; - -import java.util.Collection; -import java.util.Collections; -import java.util.Map; -import java.util.Map.Entry; +import sonia.scm.xml.XmlStreams; import javax.xml.bind.JAXBContext; import javax.xml.bind.JAXBElement; @@ -68,11 +50,14 @@ import javax.xml.bind.JAXBException; import javax.xml.bind.Marshaller; import javax.xml.bind.Unmarshaller; import javax.xml.namespace.QName; -import javax.xml.stream.XMLInputFactory; -import javax.xml.stream.XMLOutputFactory; -import javax.xml.stream.XMLStreamException; import javax.xml.stream.XMLStreamReader; -import javax.xml.stream.XMLStreamWriter; +import java.io.File; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; +import java.util.Map.Entry; + +//~--- JDK imports ------------------------------------------------------------ /** * @@ -255,74 +240,6 @@ public class JAXBConfigurationEntryStore implements ConfigurationEntryStore implements ConfigurationEntryStore implements ConfigurationEntryStore implements ConfigurationEntryStore implements ConfigurationEntryStore * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: - * + *

* 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. + * this list of conditions and the following disclaimer. * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. * 3. Neither the name of SCM-Manager; nor the names of its - * contributors may be used to endorse or promote products derived from this - * software without specific prior written permission. - * + * contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + *

* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE @@ -24,97 +24,42 @@ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - * + *

* http://bitbucket.org/sdorra/scm-manager - * */ - package sonia.scm.store; //~--- non-JDK imports -------------------------------------------------------- import com.google.inject.Inject; import com.google.inject.Singleton; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import sonia.scm.SCMContextProvider; +import sonia.scm.repository.RepositoryLocationResolver; import sonia.scm.security.KeyGenerator; -import sonia.scm.util.IOUtil; //~--- JDK imports ------------------------------------------------------------ -import java.io.File; - /** * * @author Sebastian Sdorra */ @Singleton -public class JAXBConfigurationEntryStoreFactory - implements ConfigurationEntryStoreFactory -{ +public class JAXBConfigurationEntryStoreFactory extends FileBasedStoreFactory + implements ConfigurationEntryStoreFactory { - /** - * the logger for JAXBConfigurationEntryStoreFactory - */ - private static final Logger logger = - LoggerFactory.getLogger(JAXBConfigurationEntryStoreFactory.class); - - //~--- constructors --------------------------------------------------------- - - /** - * Constructs ... - * - * - * @param keyGenerator - * @param context - */ - @Inject - public JAXBConfigurationEntryStoreFactory(KeyGenerator keyGenerator, - SCMContextProvider context) - { - this.keyGenerator = keyGenerator; - directory = new File(context.getBaseDirectory(), - StoreConstants.CONFIGDIRECTORY_NAME); - IOUtil.mkdirs(directory); - } - - //~--- get methods ---------------------------------------------------------- - - /** - * Method description - * - * - * @param type - * @param name - * @param - * - * @return - */ - @Override - public ConfigurationEntryStore getStore(Class type, String name) - { - logger.debug("create new configuration store for type {} with name {}", - type, name); - - //J- - return new JAXBConfigurationEntryStore( - new File(directory,name.concat(StoreConstants.FILE_EXTENSION)), - keyGenerator, - type - ); - //J+ - } - - //~--- fields --------------------------------------------------------------- - - /** Field description */ - private File directory; - - /** Field description */ private KeyGenerator keyGenerator; + + @Inject + public JAXBConfigurationEntryStoreFactory(SCMContextProvider contextProvider , RepositoryLocationResolver repositoryLocationResolver, KeyGenerator keyGenerator) { + super(contextProvider, repositoryLocationResolver, Store.CONFIG); + this.keyGenerator = keyGenerator; + } + + @Override + public ConfigurationEntryStore getStore(TypedStoreParameters storeParameters) { + return new JAXBConfigurationEntryStore<>(getStoreLocation(storeParameters.getName().concat(StoreConstants.FILE_EXTENSION), storeParameters.getType(), storeParameters.getRepository()), keyGenerator, storeParameters.getType()); + } + } diff --git a/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationStore.java b/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationStore.java index 4a87ea57f6..ac1477d7ea 100644 --- a/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationStore.java +++ b/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationStore.java @@ -61,7 +61,7 @@ public class JAXBConfigurationStore extends AbstractStore { private JAXBContext context; - JAXBConfigurationStore(Class type, File configFile) { + public JAXBConfigurationStore(Class type, File configFile) { this.type = type; try { diff --git a/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationStoreFactory.java b/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationStoreFactory.java index 70fd962254..bb68ab93dc 100644 --- a/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationStoreFactory.java +++ b/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationStoreFactory.java @@ -32,55 +32,29 @@ package sonia.scm.store; import com.google.inject.Inject; import com.google.inject.Singleton; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import sonia.scm.SCMContextProvider; -import sonia.scm.util.IOUtil; - -import java.io.File; +import sonia.scm.repository.RepositoryLocationResolver; /** - * JAXB implementation of {@link JAXBConfigurationStoreFactory}. + * JAXB implementation of {@link ConfigurationStoreFactory}. * * @author Sebastian Sdorra */ @Singleton -public class JAXBConfigurationStoreFactory implements ConfigurationStoreFactory { - - /** - * the logger for JAXBConfigurationStoreFactory - */ - private static final Logger LOG = LoggerFactory.getLogger(JAXBConfigurationStoreFactory.class); - - private final File configDirectory; +public class JAXBConfigurationStoreFactory extends FileBasedStoreFactory implements ConfigurationStoreFactory { /** * Constructs a new instance. * - * @param context scm context + * @param repositoryLocationResolver Resolver to get the repository Directory */ @Inject - public JAXBConfigurationStoreFactory(SCMContextProvider context) { - configDirectory = new File(context.getBaseDirectory(), StoreConstants.CONFIGDIRECTORY_NAME); - IOUtil.mkdirs(configDirectory); + public JAXBConfigurationStoreFactory(SCMContextProvider contextProvider, RepositoryLocationResolver repositoryLocationResolver) { + super(contextProvider, repositoryLocationResolver, Store.CONFIG); } @Override - public JAXBConfigurationStore getStore(Class type, String name) { - if (configDirectory == null) { - throw new IllegalStateException("store factory is not initialized"); - } - - File configFile = new File(configDirectory, name.concat(StoreConstants.FILE_EXTENSION)); - - if (LOG.isDebugEnabled()) { - LOG.debug("create store for {} at {}", type.getName(), - configFile.getPath()); - } - - return new JAXBConfigurationStore<>(type, configFile); + public JAXBConfigurationStore getStore(TypedStoreParameters storeParameters) { + return new JAXBConfigurationStore<>(storeParameters.getType(), getStoreLocation(storeParameters.getName().concat(StoreConstants.FILE_EXTENSION), storeParameters.getType(), storeParameters.getRepository())); } - } diff --git a/scm-dao-xml/src/main/java/sonia/scm/store/JAXBDataStoreFactory.java b/scm-dao-xml/src/main/java/sonia/scm/store/JAXBDataStoreFactory.java index 732b8c675b..579ef75b71 100644 --- a/scm-dao-xml/src/main/java/sonia/scm/store/JAXBDataStoreFactory.java +++ b/scm-dao-xml/src/main/java/sonia/scm/store/JAXBDataStoreFactory.java @@ -37,11 +37,12 @@ package sonia.scm.store; import com.google.inject.Inject; import com.google.inject.Singleton; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import sonia.scm.SCMContextProvider; +import sonia.scm.repository.RepositoryLocationResolver; import sonia.scm.security.KeyGenerator; +import sonia.scm.util.IOUtil; + +import java.io.File; /** * @@ -49,57 +50,20 @@ import sonia.scm.security.KeyGenerator; */ @Singleton public class JAXBDataStoreFactory extends FileBasedStoreFactory - implements DataStoreFactory -{ + implements DataStoreFactory { - /** Field description */ - private static final String DIRECTORY_NAME = "data"; + private KeyGenerator keyGenerator; - /** - * the logger for JAXBDataStoreFactory - */ - private static final Logger logger = - LoggerFactory.getLogger(JAXBDataStoreFactory.class); - - //~--- constructors --------------------------------------------------------- - - /** - * Constructs ... - * - * - * @param context - * @param keyGenerator - */ @Inject - public JAXBDataStoreFactory(SCMContextProvider context, - KeyGenerator keyGenerator) - { - super(context, DIRECTORY_NAME); + public JAXBDataStoreFactory(SCMContextProvider contextProvider , RepositoryLocationResolver repositoryLocationResolver, KeyGenerator keyGenerator) { + super(contextProvider, repositoryLocationResolver, Store.DATA); this.keyGenerator = keyGenerator; } - //~--- get methods ---------------------------------------------------------- - - /** - * Method description - * - * - * @param type - * @param name - * @param - * - * @return - */ @Override - public DataStore getStore(Class type, String name) - { - logger.debug("create new store for type {} with name {}", type, name); - - return new JAXBDataStore<>(keyGenerator, type, getDirectory(name)); + public DataStore getStore(TypedStoreParameters storeParameters) { + File storeLocation = getStoreLocation(storeParameters); + IOUtil.mkdirs(storeLocation); + return new JAXBDataStore<>(keyGenerator, storeParameters.getType(), storeLocation); } - - //~--- fields --------------------------------------------------------------- - - /** Field description */ - private KeyGenerator keyGenerator; } diff --git a/scm-dao-xml/src/main/java/sonia/scm/store/Store.java b/scm-dao-xml/src/main/java/sonia/scm/store/Store.java new file mode 100644 index 0000000000..511ef8323e --- /dev/null +++ b/scm-dao-xml/src/main/java/sonia/scm/store/Store.java @@ -0,0 +1,50 @@ +package sonia.scm.store; + +import java.io.File; + +public enum Store { + CONFIG("config"), + DATA("data"), + BLOB("blob"); + + private static final String GLOBAL_STORE_BASE_DIRECTORY = "var"; + private static final String STORE_DIRECTORY = "store"; + + private String directory; + + Store(String directory) { + + this.directory = directory; + } + + /** + * Get the relative store directory path to be stored in the repository root + *

+ * The repository store directories are: + * repo_base_dir/store/config/ + * repo_base_dir/store/blob/ + * repo_base_dir/store/data/ + * + * @return the relative store directory path to be stored in the repository root + */ + public String getRepositoryStoreDirectory() { + return STORE_DIRECTORY + File.separator + directory; + } + + /** + * Get the relative store directory path to be stored in the global root + *

+ * The global store directories are: + * base_dir/config/ + * base_dir/var/blob/ + * base_dir/var/data/ + * + * @return the relative store directory path to be stored in the global root + */ + public String getGlobalStoreDirectory() { + if (this.equals(CONFIG)) { + return directory; + } + return GLOBAL_STORE_BASE_DIRECTORY + File.separator + directory; + } +} diff --git a/scm-dao-xml/src/main/java/sonia/scm/store/StoreConstants.java b/scm-dao-xml/src/main/java/sonia/scm/store/StoreConstants.java index 0bd9864cd7..cf24d125c2 100644 --- a/scm-dao-xml/src/main/java/sonia/scm/store/StoreConstants.java +++ b/scm-dao-xml/src/main/java/sonia/scm/store/StoreConstants.java @@ -37,12 +37,14 @@ package sonia.scm.store; * * @author Sebastian Sdorra */ -public interface StoreConstants +public class StoreConstants { - /** Field description */ - public static final String CONFIGDIRECTORY_NAME = "config"; + private StoreConstants() { } + + public static final String CONFIG_DIRECTORY_NAME = "config"; + + public static final String REPOSITORY_METADATA = "metadata"; - /** Field description */ public static final String FILE_EXTENSION = ".xml"; } diff --git a/scm-dao-xml/src/main/java/sonia/scm/user/xml/XmlUserDAO.java b/scm-dao-xml/src/main/java/sonia/scm/user/xml/XmlUserDAO.java index 1bfd877f44..ea7f18fbba 100644 --- a/scm-dao-xml/src/main/java/sonia/scm/user/xml/XmlUserDAO.java +++ b/scm-dao-xml/src/main/java/sonia/scm/user/xml/XmlUserDAO.java @@ -36,11 +36,10 @@ package sonia.scm.user.xml; import com.google.inject.Inject; import com.google.inject.Singleton; - +import sonia.scm.store.ConfigurationStoreFactory; import sonia.scm.user.User; import sonia.scm.user.UserDAO; import sonia.scm.xml.AbstractXmlDAO; -import sonia.scm.store.ConfigurationStoreFactory; /** * @@ -65,7 +64,10 @@ public class XmlUserDAO extends AbstractXmlDAO @Inject public XmlUserDAO(ConfigurationStoreFactory storeFactory) { - super(storeFactory.getStore(XmlUserDatabase.class, STORE_NAME)); + super(storeFactory + .withType(XmlUserDatabase.class) + .withName(STORE_NAME) + .build()); } //~--- methods -------------------------------------------------------------- diff --git a/scm-dao-xml/src/main/java/sonia/scm/xml/AbstractXmlDAO.java b/scm-dao-xml/src/main/java/sonia/scm/xml/AbstractXmlDAO.java index b8d1e6f42e..5b24096eb5 100644 --- a/scm-dao-xml/src/main/java/sonia/scm/xml/AbstractXmlDAO.java +++ b/scm-dao-xml/src/main/java/sonia/scm/xml/AbstractXmlDAO.java @@ -1,19 +1,19 @@ /** * Copyright (c) 2010, Sebastian Sdorra * All rights reserved. - * + *

* Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: - * + *

* 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. + * this list of conditions and the following disclaimer. * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. * 3. Neither the name of SCM-Manager; nor the names of its - * contributors may be used to endorse or promote products derived from this - * software without specific prior written permission. - * + * contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + *

* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE @@ -24,9 +24,8 @@ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - * + *

* http://bitbucket.org/sdorra/scm-manager - * */ @@ -35,18 +34,13 @@ package sonia.scm.xml; //~--- non-JDK imports -------------------------------------------------------- import com.google.common.collect.ImmutableList; -import org.apache.commons.lang.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; - import sonia.scm.GenericDAO; import sonia.scm.ModelObject; -import sonia.scm.group.xml.XmlGroupDAO; import sonia.scm.store.ConfigurationStore; -import sonia.scm.util.AssertUtil; import java.util.Collection; -import java.util.stream.Collectors; //~--- JDK imports ------------------------------------------------------------ @@ -58,7 +52,7 @@ import java.util.stream.Collectors; * @param */ public abstract class AbstractXmlDAO> implements GenericDAO + T extends XmlDatabase> implements GenericDAO { /** Field description */ @@ -68,7 +62,7 @@ public abstract class AbstractXmlDAO store; + protected final ConfigurationStore store; /** Field description */ protected T db; diff --git a/scm-dao-xml/src/main/java/sonia/scm/xml/XmlStreams.java b/scm-dao-xml/src/main/java/sonia/scm/xml/XmlStreams.java new file mode 100644 index 0000000000..4b3d9b0f28 --- /dev/null +++ b/scm-dao-xml/src/main/java/sonia/scm/xml/XmlStreams.java @@ -0,0 +1,71 @@ +package sonia.scm.xml; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.xml.stream.XMLInputFactory; +import javax.xml.stream.XMLOutputFactory; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.XMLStreamReader; +import javax.xml.stream.XMLStreamWriter; +import java.io.File; +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +public final class XmlStreams { + + private static final Logger LOG = LoggerFactory.getLogger(XmlStreams.class); + + private XmlStreams() { + } + + public static void close(XMLStreamWriter writer) { + if (writer != null) { + try { + writer.close(); + } catch (XMLStreamException ex) { + LOG.error("could not close writer", ex); + } + } + } + + public static void close(XMLStreamReader reader) { + if (reader != null) { + try { + reader.close(); + } catch (XMLStreamException ex) { + LOG.error("could not close reader", ex); + } + } + } + + public static XMLStreamReader createReader(Path path) throws IOException, XMLStreamException { + return createReader(Files.newBufferedReader(path, StandardCharsets.UTF_8)); + } + + public static XMLStreamReader createReader(File file) throws IOException, XMLStreamException { + return createReader(file.toPath()); + } + + private static XMLStreamReader createReader(Reader reader) throws XMLStreamException { + return XMLInputFactory.newInstance().createXMLStreamReader(reader); + } + + + public static IndentXMLStreamWriter createWriter(Path path) throws IOException, XMLStreamException { + return createWriter(Files.newBufferedWriter(path, StandardCharsets.UTF_8)); + } + + public static IndentXMLStreamWriter createWriter(File file) throws IOException, XMLStreamException { + return createWriter(file.toPath()); + } + + private static IndentXMLStreamWriter createWriter(Writer writer) throws XMLStreamException { + return new IndentXMLStreamWriter(XMLOutputFactory.newFactory().createXMLStreamWriter(writer)); + } + +} diff --git a/scm-dao-xml/src/test/java/sonia/scm/repository/xml/XmlRepositoryDAOTest.java b/scm-dao-xml/src/test/java/sonia/scm/repository/xml/XmlRepositoryDAOTest.java new file mode 100644 index 0000000000..aebdf010e2 --- /dev/null +++ b/scm-dao-xml/src/test/java/sonia/scm/repository/xml/XmlRepositoryDAOTest.java @@ -0,0 +1,377 @@ +package sonia.scm.repository.xml; + + +import com.google.common.base.Charsets; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junitpioneer.jupiter.TempDirectory; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import sonia.scm.SCMContextProvider; +import sonia.scm.io.DefaultFileSystem; +import sonia.scm.io.FileSystem; +import sonia.scm.repository.InitialRepositoryLocationResolver; +import sonia.scm.repository.NamespaceAndName; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryPermission; +import sonia.scm.repository.RepositoryTestData; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Clock; +import java.util.Collection; +import java.util.Collections; +import java.util.concurrent.atomic.AtomicLong; + +import static java.util.Arrays.asList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith({MockitoExtension.class, TempDirectory.class}) +@MockitoSettings(strictness = Strictness.LENIENT) +class XmlRepositoryDAOTest { + + @Mock + private SCMContextProvider context; + + @Mock + private InitialRepositoryLocationResolver locationResolver; + + private FileSystem fileSystem = new DefaultFileSystem(); + + private XmlRepositoryDAO dao; + + private Path baseDirectory; + + private AtomicLong atomicClock; + + @BeforeEach + void createDAO(@TempDirectory.TempDir Path baseDirectory) { + this.baseDirectory = baseDirectory; + this.atomicClock = new AtomicLong(); + + when(locationResolver.getPath("42")).thenReturn(Paths.get("repos", "42")); + when(locationResolver.getPath("42+1")).thenReturn(Paths.get("repos", "puzzle")); + + when(context.getBaseDirectory()).thenReturn(baseDirectory.toFile()); + when(context.resolve(any(Path.class))).then(ic -> { + Path path = ic.getArgument(0); + return baseDirectory.resolve(path); + }); + + dao = createDAO(); + } + + private XmlRepositoryDAO createDAO() { + Clock clock = mock(Clock.class); + when(clock.millis()).then(ic -> atomicClock.incrementAndGet()); + + return new XmlRepositoryDAO(context, locationResolver, fileSystem, clock); + } + + @Test + void shouldReturnXmlType() { + assertThat(dao.getType()).isEqualTo("xml"); + } + + @Test + void shouldReturnCreationTimeAfterCreation() { + long now = atomicClock.get(); + assertThat(dao.getCreationTime()).isEqualTo(now); + } + + @Test + void shouldNotReturnLastModifiedAfterCreation() { + assertThat(dao.getLastModified()).isNull(); + } + + @Test + void shouldReturnTrueForEachContainsMethod() { + Repository heartOfGold = createHeartOfGold(); + dao.add(heartOfGold); + + assertThat(dao.contains(heartOfGold)).isTrue(); + assertThat(dao.contains(heartOfGold.getId())).isTrue(); + assertThat(dao.contains(heartOfGold.getNamespaceAndName())).isTrue(); + } + + private Repository createHeartOfGold() { + Repository heartOfGold = RepositoryTestData.createHeartOfGold(); + heartOfGold.setId("42"); + return heartOfGold; + } + + @Test + void shouldReturnFalseForEachContainsMethod() { + Repository heartOfGold = createHeartOfGold(); + + assertThat(dao.contains(heartOfGold)).isFalse(); + assertThat(dao.contains(heartOfGold.getId())).isFalse(); + assertThat(dao.contains(heartOfGold.getNamespaceAndName())).isFalse(); + } + + @Test + void shouldReturnNullForEachGetMethod() { + assertThat(dao.get("42")).isNull(); + assertThat(dao.get(new NamespaceAndName("hitchhiker","HeartOfGold"))).isNull(); + } + + @Test + void shouldReturnRepository() { + Repository heartOfGold = createHeartOfGold(); + dao.add(heartOfGold); + + assertThat(dao.get("42")).isEqualTo(heartOfGold); + assertThat(dao.get(new NamespaceAndName("hitchhiker","HeartOfGold"))).isEqualTo(heartOfGold); + } + + @Test + void shouldNotReturnTheSameInstance() { + Repository heartOfGold = createHeartOfGold(); + dao.add(heartOfGold); + + Repository repository = dao.get("42"); + assertThat(repository).isNotSameAs(heartOfGold); + } + + @Test + void shouldReturnAllRepositories() { + Repository heartOfGold = createHeartOfGold(); + dao.add(heartOfGold); + + Repository puzzle = createPuzzle(); + dao.add(puzzle); + + Collection repositories = dao.getAll(); + assertThat(repositories).containsExactlyInAnyOrder(heartOfGold, puzzle); + } + + private Repository createPuzzle() { + Repository puzzle = RepositoryTestData.create42Puzzle(); + puzzle.setId("42+1"); + return puzzle; + } + + @Test + void shouldModifyRepository() { + Repository heartOfGold = createHeartOfGold(); + heartOfGold.setDescription("HeartOfGold"); + dao.add(heartOfGold); + assertThat(dao.get("42").getDescription()).isEqualTo("HeartOfGold"); + + heartOfGold = createHeartOfGold(); + heartOfGold.setDescription("Heart of Gold"); + dao.modify(heartOfGold); + + assertThat(dao.get("42").getDescription()).isEqualTo("Heart of Gold"); + } + + @Test + void shouldRemoveRepository() { + Repository heartOfGold = createHeartOfGold(); + dao.add(heartOfGold); + assertThat(dao.contains("42")).isTrue(); + + dao.delete(heartOfGold); + assertThat(dao.contains("42")).isFalse(); + assertThat(dao.contains(new NamespaceAndName("hitchhiker", "HeartOfGold"))).isFalse(); + } + + @Test + void shouldUpdateLastModifiedAfterEachWriteOperation() { + Repository heartOfGold = createHeartOfGold(); + dao.add(heartOfGold); + + Long firstLastModified = dao.getLastModified(); + assertThat(firstLastModified).isNotNull(); + + Repository puzzle = createPuzzle(); + dao.add(puzzle); + + Long lastModifiedAdded = dao.getLastModified(); + assertThat(lastModifiedAdded).isGreaterThan(firstLastModified); + + heartOfGold.setDescription("Heart of Gold"); + dao.modify(heartOfGold); + + Long lastModifiedModified = dao.getLastModified(); + assertThat(lastModifiedModified).isGreaterThan(lastModifiedAdded); + + dao.delete(puzzle); + + Long lastModifiedRemoved = dao.getLastModified(); + assertThat(lastModifiedRemoved).isGreaterThan(lastModifiedModified); + } + + @Test + void shouldRenameTheRepository() { + Repository heartOfGold = createHeartOfGold(); + dao.add(heartOfGold); + + heartOfGold.setNamespace("hg2tg"); + heartOfGold.setName("hog"); + + dao.modify(heartOfGold); + + Repository repository = dao.get("42"); + assertThat(repository.getNamespace()).isEqualTo("hg2tg"); + assertThat(repository.getName()).isEqualTo("hog"); + + assertThat(dao.contains(new NamespaceAndName("hg2tg", "hog"))).isTrue(); + assertThat(dao.contains(new NamespaceAndName("hitchhiker", "HeartOfGold"))).isFalse(); + } + + @Test + void shouldDeleteRepositoryEvenWithChangedNamespace() { + Repository heartOfGold = createHeartOfGold(); + dao.add(heartOfGold); + + heartOfGold.setNamespace("hg2tg"); + heartOfGold.setName("hog"); + + dao.delete(heartOfGold); + + assertThat(dao.contains(new NamespaceAndName("hitchhiker", "HeartOfGold"))).isFalse(); + } + + @Test + void shouldReturnThePathForTheRepository() { + Path repositoryPath = Paths.get("r", "42"); + when(locationResolver.getPath("42")).thenReturn(repositoryPath); + + Repository heartOfGold = createHeartOfGold(); + dao.add(heartOfGold); + + Path path = dao.getPath("42"); + assertThat(path).isEqualTo(repositoryPath); + } + + @Test + void shouldCreateTheDirectoryForTheRepository() { + Path repositoryPath = Paths.get("r", "42"); + when(locationResolver.getPath("42")).thenReturn(repositoryPath); + + Repository heartOfGold = createHeartOfGold(); + dao.add(heartOfGold); + + Path path = getAbsolutePathFromDao("42"); + assertThat(path).isDirectory(); + } + + @Test + void shouldRemoveRepositoryDirectoryAfterDeletion() { + Repository heartOfGold = createHeartOfGold(); + dao.add(heartOfGold); + + Path path = getAbsolutePathFromDao(heartOfGold.getId()); + assertThat(path).isDirectory(); + + dao.delete(heartOfGold); + assertThat(path).doesNotExist(); + } + + private Path getAbsolutePathFromDao(String id) { + return context.resolve(dao.getPath(id)); + } + + @Test + void shouldCreateRepositoryPathDatabase() throws IOException { + Repository heartOfGold = createHeartOfGold(); + dao.add(heartOfGold); + + Path storePath = dao.resolveStorePath(); + assertThat(storePath).isRegularFile(); + + String content = content(storePath); + + assertThat(content).contains(heartOfGold.getId()); + assertThat(content).contains(dao.getPath(heartOfGold.getId()).toString()); + } + + private String content(Path storePath) throws IOException { + return new String(Files.readAllBytes(storePath), Charsets.UTF_8); + } + + @Test + void shouldStoreRepositoryMetadataAfterAdd() throws IOException { + Repository heartOfGold = createHeartOfGold(); + dao.add(heartOfGold); + + Path repositoryDirectory = getAbsolutePathFromDao(heartOfGold.getId()); + Path metadataPath = dao.resolveMetadataPath(repositoryDirectory); + + assertThat(metadataPath).isRegularFile(); + + String content = content(metadataPath); + assertThat(content).contains(heartOfGold.getName()); + assertThat(content).contains(heartOfGold.getNamespace()); + assertThat(content).contains(heartOfGold.getDescription()); + } + + @Test + void shouldUpdateRepositoryMetadataAfterModify() throws IOException { + Repository heartOfGold = createHeartOfGold(); + dao.add(heartOfGold); + + heartOfGold.setDescription("Awesome Spaceship"); + dao.modify(heartOfGold); + + Path repositoryDirectory = getAbsolutePathFromDao(heartOfGold.getId()); + Path metadataPath = dao.resolveMetadataPath(repositoryDirectory); + + String content = content(metadataPath); + assertThat(content).contains("Awesome Spaceship"); + } + + @Test + void shouldPersistPermissions() throws IOException { + Repository heartOfGold = createHeartOfGold(); + heartOfGold.setPermissions(asList(new RepositoryPermission("trillian", asList("read", "write"), false), new RepositoryPermission("vogons", Collections.singletonList("delete"), true))); + dao.add(heartOfGold); + + Path repositoryDirectory = getAbsolutePathFromDao(heartOfGold.getId()); + Path metadataPath = dao.resolveMetadataPath(repositoryDirectory); + + String content = content(metadataPath); + System.out.println(content); + assertThat(content).containsSubsequence("trillian", "read", "write"); + assertThat(content).containsSubsequence("vogons", "delete"); + } + + @Test + void shouldReadPathDatabaseAndMetadataOfRepositories() { + Repository heartOfGold = createHeartOfGold(); + dao.add(heartOfGold); + + // reload data + dao = createDAO(); + + heartOfGold = dao.get("42"); + assertThat(heartOfGold.getName()).isEqualTo("HeartOfGold"); + + Path path = getAbsolutePathFromDao(heartOfGold.getId()); + assertThat(path).isDirectory(); + } + + @Test + void shouldReadCreationTimeAndLastModifedDateFromDatabase() { + Repository heartOfGold = createHeartOfGold(); + dao.add(heartOfGold); + + Long creationTime = dao.getCreationTime(); + Long lastModified = dao.getLastModified(); + + // reload data + dao = createDAO(); + + assertThat(dao.getCreationTime()).isEqualTo(creationTime); + assertThat(dao.getLastModified()).isEqualTo(lastModified); + } +} diff --git a/scm-dao-xml/src/test/java/sonia/scm/store/FileBlobStoreTest.java b/scm-dao-xml/src/test/java/sonia/scm/store/FileBlobStoreTest.java index cae872538d..3ec16baa57 100644 --- a/scm-dao-xml/src/test/java/sonia/scm/store/FileBlobStoreTest.java +++ b/scm-dao-xml/src/test/java/sonia/scm/store/FileBlobStoreTest.java @@ -34,8 +34,15 @@ package sonia.scm.store; //~--- non-JDK imports -------------------------------------------------------- +import org.junit.Test; +import sonia.scm.repository.Repository; import sonia.scm.security.UUIDKeyGenerator; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertNotNull; + /** * * @author Sebastian Sdorra @@ -52,6 +59,24 @@ public class FileBlobStoreTest extends BlobStoreTestBase @Override protected BlobStoreFactory createBlobStoreFactory() { - return new FileBlobStoreFactory(contextProvider, new UUIDKeyGenerator()); + return new FileBlobStoreFactory(contextProvider, repositoryLocationResolver, new UUIDKeyGenerator()); + } + + @Test + @SuppressWarnings("unchecked") + public void shouldStoreAndLoadInRepository() { + BlobStore store = createBlobStoreFactory() + .withName("test") + .forRepository(new Repository("id", "git", "ns", "n")) + .build(); + + Blob createdBlob = store.create("abc"); + List storedBlobs = store.getAll(); + + assertNotNull(createdBlob); + assertThat(storedBlobs) + .isNotNull() + .hasSize(1) + .usingElementComparatorOnFields("id").containsExactly(createdBlob); } } diff --git a/scm-dao-xml/src/test/java/sonia/scm/store/JAXBConfigurationEntryStoreTest.java b/scm-dao-xml/src/test/java/sonia/scm/store/JAXBConfigurationEntryStoreTest.java index d0f17fc313..3d9fa3f283 100644 --- a/scm-dao-xml/src/test/java/sonia/scm/store/JAXBConfigurationEntryStoreTest.java +++ b/scm-dao-xml/src/test/java/sonia/scm/store/JAXBConfigurationEntryStoreTest.java @@ -37,25 +37,22 @@ package sonia.scm.store; import com.google.common.io.Closeables; import com.google.common.io.Resources; - import org.junit.Test; - import sonia.scm.security.AssignedPermission; import sonia.scm.security.UUIDKeyGenerator; -import static org.junit.Assert.*; - -//~--- JDK imports ------------------------------------------------------------ - import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStream; - import java.net.URL; - import java.util.UUID; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +//~--- JDK imports ------------------------------------------------------------ + /** * * @author Sebastian Sdorra @@ -132,19 +129,29 @@ public class JAXBConfigurationEntryStoreTest public void testStoreAndLoad() throws IOException { String name = UUID.randomUUID().toString(); - ConfigurationEntryStore store = - createPermissionStore(RESOURCE_FIXED, name); + ConfigurationEntryStore store = createPermissionStore(RESOURCE_FIXED, name); store.put("a45", new AssignedPermission("tuser4", "repository:create")); - store = - createConfigurationStoreFactory().getStore(AssignedPermission.class, - name); + store = createConfigurationStoreFactory() + .withType(AssignedPermission.class) + .withName(name) + .build(); AssignedPermission ap = store.get("a45"); assertNotNull(ap); assertEquals("tuser4", ap.getName()); - assertEquals("repository:create", ap.getPermission()); + assertEquals("repository:create", ap.getPermission().getValue()); + } + + @Test + public void shouldStoreAndLoadInRepository() throws IOException + { + repoStore.put("abc", new StoreObject("abc_value")); + StoreObject storeObject = repoStore.get("abc"); + + assertNotNull(storeObject); + assertEquals("abc_value", storeObject.getValue()); } /** @@ -154,10 +161,9 @@ public class JAXBConfigurationEntryStoreTest * @return */ @Override - protected ConfigurationEntryStoreFactory createConfigurationStoreFactory() + protected ConfigurationEntryStoreFactory createConfigurationStoreFactory() { - return new JAXBConfigurationEntryStoreFactory(new UUIDKeyGenerator(), - contextProvider); + return new JAXBConfigurationEntryStoreFactory(contextProvider, repositoryLocationResolver, new UUIDKeyGenerator()); } /** @@ -225,8 +231,9 @@ public class JAXBConfigurationEntryStoreTest } copy(resource, name); - - return createConfigurationStoreFactory().getStore(AssignedPermission.class, - name); + return createConfigurationStoreFactory() + .withType(AssignedPermission.class) + .withName(name) + .build(); } } diff --git a/scm-dao-xml/src/test/java/sonia/scm/store/JAXBConfigurationStoreTest.java b/scm-dao-xml/src/test/java/sonia/scm/store/JAXBConfigurationStoreTest.java index 4151a6ca20..802f193340 100644 --- a/scm-dao-xml/src/test/java/sonia/scm/store/JAXBConfigurationStoreTest.java +++ b/scm-dao-xml/src/test/java/sonia/scm/store/JAXBConfigurationStoreTest.java @@ -32,9 +32,15 @@ package sonia.scm.store; +import org.junit.Test; +import sonia.scm.repository.Repository; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + /** * Unit tests for {@link JAXBConfigurationStore}. - * + * * @author Sebastian Sdorra */ public class JAXBConfigurationStoreTest extends StoreTestBase { @@ -42,6 +48,24 @@ public class JAXBConfigurationStoreTest extends StoreTestBase { @Override protected ConfigurationStoreFactory createStoreFactory() { - return new JAXBConfigurationStoreFactory(contextProvider); + return new JAXBConfigurationStoreFactory(contextProvider, repositoryLocationResolver); + } + + + @Test + @SuppressWarnings("unchecked") + public void shouldStoreAndLoadInRepository() + { + ConfigurationStore store = createStoreFactory() + .withType(StoreObject.class) + .withName("test") + .forRepository(new Repository("id", "git", "ns", "n")) + .build(); + + store.set(new StoreObject("value")); + StoreObject storeObject = store.get(); + + assertNotNull(storeObject); + assertEquals("value", storeObject.getValue()); } } diff --git a/scm-dao-xml/src/test/java/sonia/scm/store/JAXBDataStoreTest.java b/scm-dao-xml/src/test/java/sonia/scm/store/JAXBDataStoreTest.java index 9834a48916..04d86aa625 100644 --- a/scm-dao-xml/src/test/java/sonia/scm/store/JAXBDataStoreTest.java +++ b/scm-dao-xml/src/test/java/sonia/scm/store/JAXBDataStoreTest.java @@ -34,14 +34,18 @@ package sonia.scm.store; //~--- non-JDK imports -------------------------------------------------------- +import org.junit.Test; +import sonia.scm.repository.Repository; import sonia.scm.security.UUIDKeyGenerator; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + /** * * @author Sebastian Sdorra */ -public class JAXBDataStoreTest extends DataStoreTestBase -{ +public class JAXBDataStoreTest extends DataStoreTestBase { /** * Method description @@ -52,6 +56,33 @@ public class JAXBDataStoreTest extends DataStoreTestBase @Override protected DataStoreFactory createDataStoreFactory() { - return new JAXBDataStoreFactory(contextProvider, new UUIDKeyGenerator()); + return new JAXBDataStoreFactory(contextProvider, repositoryLocationResolver, new UUIDKeyGenerator()); + } + + @Override + protected DataStore getDataStore(Class type, Repository repository) { + return createDataStoreFactory() + .withType(type) + .withName("test") + .forRepository(repository) + .build(); + } + + @Override + protected DataStore getDataStore(Class type) { + return createDataStoreFactory() + .withType(type) + .withName("test") + .build(); + } + + @Test + public void shouldStoreAndLoadInRepository() + { + repoStore.put("abc", new StoreObject("abc_value")); + StoreObject storeObject = repoStore.get("abc"); + + assertNotNull(storeObject); + assertEquals("abc_value", storeObject.getValue()); } } diff --git a/scm-it/pom.xml b/scm-it/pom.xml index 3f518dd9fe..c38c73ce3e 100644 --- a/scm-it/pom.xml +++ b/scm-it/pom.xml @@ -11,7 +11,8 @@ sonia.scm scm-it - jar + + war 2.0.0-SNAPSHOT scm-it @@ -91,6 +92,14 @@ + + org.apache.maven.plugins + maven-war-plugin + + false + + + com.mycila.maven-license-plugin maven-license-plugin diff --git a/scm-it/src/test/java/sonia/scm/it/GitNonFastForwardITCase.java b/scm-it/src/test/java/sonia/scm/it/GitNonFastForwardITCase.java new file mode 100644 index 0000000000..1490a917df --- /dev/null +++ b/scm-it/src/test/java/sonia/scm/it/GitNonFastForwardITCase.java @@ -0,0 +1,209 @@ +/** + * Copyright (c) 2010, Sebastian Sdorra + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * 3. Neither the name of SCM-Manager; nor the names of its + * contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * http://bitbucket.org/sdorra/scm-manager + * + */ + + +package sonia.scm.it; + +import com.google.common.base.Charsets; +import com.google.common.io.Files; +import org.eclipse.jgit.api.CommitCommand; +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.transport.CredentialsProvider; +import org.eclipse.jgit.transport.PushResult; +import org.eclipse.jgit.transport.RemoteRefUpdate; +import org.eclipse.jgit.transport.RemoteRefUpdate.Status; +import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import sonia.scm.it.utils.RestUtil; +import sonia.scm.it.utils.TestData; +import sonia.scm.web.VndMediaType; + +import javax.servlet.http.HttpServletResponse; +import java.io.File; +import java.io.IOException; + +import static org.junit.Assert.assertEquals; +import static sonia.scm.it.utils.RestUtil.given; + +/** + * Integration Tests for Git with non fast-forward pushes. + */ +public class GitNonFastForwardITCase { + + private File workingCopy; + private Git git; + + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + + @Before + public void createAndCloneTestRepository() throws IOException, GitAPIException { + TestData.createDefault(); + this.workingCopy = tempFolder.newFolder(); + + this.git = clone(RestUtil.BASE_URL.toASCIIString() + "repo/scmadmin/HeartOfGold-git"); + } + + @After + public void cleanup() { + TestData.cleanup(); + } + + /** + * Ensures that the normal behaviour (non fast-forward is allowed), is restored after the tests are executed. + */ + @AfterClass + public static void allowNonFastForward() { + setNonFastForwardDisallowed(false); + } + + @Test + public void testGitPushAmendWithoutForce() throws IOException, GitAPIException { + setNonFastForwardDisallowed(false); + + addTestFileToWorkingCopyAndCommit("a"); + pushAndAssert(false, Status.OK); + + addTestFileToWorkingCopyAndCommitAmend("c"); + pushAndAssert(false, Status.REJECTED_NONFASTFORWARD); + } + + @Test + public void testGitPushAmendWithForce() throws IOException, GitAPIException { + setNonFastForwardDisallowed(false); + + addTestFileToWorkingCopyAndCommit("a"); + pushAndAssert(false, Status.OK); + + addTestFileToWorkingCopyAndCommitAmend("c"); + pushAndAssert(true, Status.OK); + } + + @Test + public void testGitPushAmendForceWithDisallowNonFastForward() throws GitAPIException, IOException { + setNonFastForwardDisallowed(true); + + addTestFileToWorkingCopyAndCommit("a"); + pushAndAssert(false, Status.OK); + + addTestFileToWorkingCopyAndCommitAmend("c"); + pushAndAssert(true, Status.REJECTED_OTHER_REASON); + + setNonFastForwardDisallowed(false); + } + + private CredentialsProvider createCredentialProvider() { + return new UsernamePasswordCredentialsProvider( + RestUtil.ADMIN_USERNAME, RestUtil.ADMIN_PASSWORD + ); + } + + private Git clone(String url) throws GitAPIException { + return Git.cloneRepository() + .setDirectory(workingCopy) + .setURI(url) + .setCredentialsProvider(createCredentialProvider()) + .call(); + } + + private void addTestFileToWorkingCopyAndCommit(String name) throws IOException, GitAPIException { + addTestFile(name); + prepareCommit() + .setMessage("added ".concat(name)) + .call(); + } + + private void addTestFile(String name) throws IOException, GitAPIException { + String filename = name.concat(".txt"); + Files.write(name, new File(workingCopy, filename), Charsets.UTF_8); + git.add().addFilepattern(filename).call(); + } + + private CommitCommand prepareCommit() { + return git.commit() + .setAuthor("Trillian McMillian", "trillian@hitchhiker.com"); + } + + private void pushAndAssert(boolean force, Status expectedStatus) throws GitAPIException { + Iterable results = push(force); + assertStatus(results, expectedStatus); + } + + private Iterable push(boolean force) throws GitAPIException { + return git.push() + .setRemote("origin") + .add("master") + .setForce(force) + .setCredentialsProvider(createCredentialProvider()) + .call(); + } + + private void assertStatus(Iterable results, Status expectedStatus) { + for ( PushResult pushResult : results ) { + assertStatus(pushResult, expectedStatus); + } + } + + private void assertStatus(PushResult pushResult, Status expectedStatus) { + for ( RemoteRefUpdate remoteRefUpdate : pushResult.getRemoteUpdates() ) { + assertEquals(expectedStatus, remoteRefUpdate.getStatus()); + } + } + + private void addTestFileToWorkingCopyAndCommitAmend(String name) throws IOException, GitAPIException { + addTestFile(name); + prepareCommit() + .setMessage("amend commit, because of missing ".concat(name)) + .setAmend(true) + .call(); + } + + private static void setNonFastForwardDisallowed(boolean nonFastForwardDisallowed) { + String config = String.format("{'disabled': false, 'gcExpression': null, 'nonFastForwardDisallowed': %s}", nonFastForwardDisallowed) + .replace('\'', '"'); + + given(VndMediaType.PREFIX + "gitConfig" + VndMediaType.SUFFIX) + .body(config) + + .when() + .put(RestUtil.REST_BASE_URL.toASCIIString() + "config/git" ) + + .then() + .statusCode(HttpServletResponse.SC_NO_CONTENT); + } + +} diff --git a/scm-it/src/test/java/sonia/scm/it/MeITCase.java b/scm-it/src/test/java/sonia/scm/it/MeITCase.java index ce6593ef11..89c6eeb7b8 100644 --- a/scm-it/src/test/java/sonia/scm/it/MeITCase.java +++ b/scm-it/src/test/java/sonia/scm/it/MeITCase.java @@ -1,13 +1,10 @@ package sonia.scm.it; -import org.junit.Assert; import org.junit.Before; import org.junit.Test; import sonia.scm.it.utils.ScmRequests; import sonia.scm.it.utils.TestData; -import static org.assertj.core.api.Assertions.assertThat; - public class MeITCase { @Before @@ -23,9 +20,6 @@ public class MeITCase { .requestIndexResource(TestData.USER_SCM_ADMIN, TestData.USER_SCM_ADMIN) .requestMe() .assertStatusCode(200) - .assertAdmin(aBoolean -> assertThat(aBoolean).isEqualTo(Boolean.TRUE)) - .assertPassword(Assert::assertNull) - .assertType(s -> assertThat(s).isEqualTo("xml")) .requestChangePassword(TestData.USER_SCM_ADMIN, newPassword) .assertStatusCode(204); // assert password is changed -> login with the new Password than undo changes @@ -33,7 +27,6 @@ public class MeITCase { .requestIndexResource(TestData.USER_SCM_ADMIN, newPassword) .requestMe() .assertStatusCode(200) - .assertAdmin(aBoolean -> assertThat(aBoolean).isEqualTo(Boolean.TRUE))// still admin .requestChangePassword(newPassword, TestData.USER_SCM_ADMIN) .assertStatusCode(204); } @@ -49,9 +42,6 @@ public class MeITCase { .requestIndexResource(username, password) .requestMe() .assertStatusCode(200) - .assertAdmin(aBoolean -> assertThat(aBoolean).isEqualTo(Boolean.FALSE)) - .assertPassword(Assert::assertNull) - .assertType(s -> assertThat(s).isEqualTo("xml")) .requestChangePassword(password, newPassword) .assertStatusCode(204); // assert password is changed -> login with the new Password than undo changes @@ -72,9 +62,6 @@ public class MeITCase { .requestIndexResource(newUser, password) .requestMe() .assertStatusCode(200) - .assertAdmin(aBoolean -> assertThat(aBoolean).isEqualTo(Boolean.TRUE)) - .assertPassword(Assert::assertNull) - .assertType(s -> assertThat(s).isEqualTo(type)) .assertPasswordLinkDoesNotExists(); } } diff --git a/scm-it/src/test/java/sonia/scm/it/PermissionsITCase.java b/scm-it/src/test/java/sonia/scm/it/PermissionsITCase.java index aa91e67022..926be5459f 100644 --- a/scm-it/src/test/java/sonia/scm/it/PermissionsITCase.java +++ b/scm-it/src/test/java/sonia/scm/it/PermissionsITCase.java @@ -42,7 +42,6 @@ import org.junit.runners.Parameterized; import org.junit.runners.Parameterized.Parameters; import sonia.scm.it.utils.RepositoryUtil; import sonia.scm.it.utils.TestData; -import sonia.scm.repository.PermissionType; import sonia.scm.repository.client.api.RepositoryClient; import sonia.scm.repository.client.api.RepositoryClientException; import sonia.scm.web.VndMediaType; @@ -59,7 +58,10 @@ import static org.junit.Assert.assertNull; import static sonia.scm.it.utils.RepositoryUtil.addAndCommitRandomFile; import static sonia.scm.it.utils.RestUtil.given; import static sonia.scm.it.utils.ScmTypes.availableScmTypes; +import static sonia.scm.it.utils.TestData.OWNER; +import static sonia.scm.it.utils.TestData.READ; import static sonia.scm.it.utils.TestData.USER_SCM_ADMIN; +import static sonia.scm.it.utils.TestData.WRITE; import static sonia.scm.it.utils.TestData.callRepository; @RunWith(Parameterized.class) @@ -91,11 +93,11 @@ public class PermissionsITCase { public void prepareEnvironment() { TestData.createDefault(); TestData.createNotAdminUser(USER_READ, USER_PASS); - TestData.createUserPermission(USER_READ, PermissionType.READ, repositoryType); + TestData.createUserPermission(USER_READ, READ, repositoryType); TestData.createNotAdminUser(USER_WRITE, USER_PASS); - TestData.createUserPermission(USER_WRITE, PermissionType.WRITE, repositoryType); + TestData.createUserPermission(USER_WRITE, WRITE, repositoryType); TestData.createNotAdminUser(USER_OWNER, USER_PASS); - TestData.createUserPermission(USER_OWNER, PermissionType.OWNER, repositoryType); + TestData.createUserPermission(USER_OWNER, OWNER, repositoryType); TestData.createNotAdminUser(USER_OTHER, USER_PASS); createdPermissions = asList(USER_READ, USER_WRITE, USER_OWNER); } @@ -109,7 +111,7 @@ public class PermissionsITCase { @Test public void readUserShouldNotSeeBruteForcePermissions() { - given(VndMediaType.PERMISSION, USER_READ, USER_PASS) + given(VndMediaType.REPOSITORY_PERMISSION, USER_READ, USER_PASS) .when() .get(TestData.getDefaultPermissionUrl(USER_SCM_ADMIN, USER_SCM_ADMIN, repositoryType)) .then() @@ -125,7 +127,7 @@ public class PermissionsITCase { @Test public void writeUserShouldNotSeeBruteForcePermissions() { - given(VndMediaType.PERMISSION, USER_WRITE, USER_PASS) + given(VndMediaType.REPOSITORY_PERMISSION, USER_WRITE, USER_PASS) .when() .get(TestData.getDefaultPermissionUrl(USER_SCM_ADMIN, USER_SCM_ADMIN, repositoryType)) .then() @@ -145,7 +147,7 @@ public class PermissionsITCase { @Test public void otherUserShouldNotSeeBruteForcePermissions() { - given(VndMediaType.PERMISSION, USER_OTHER, USER_PASS) + given(VndMediaType.REPOSITORY_PERMISSION, USER_OTHER, USER_PASS) .when() .get(TestData.getDefaultPermissionUrl(USER_SCM_ADMIN, USER_SCM_ADMIN, repositoryType)) .then() diff --git a/scm-it/src/test/java/sonia/scm/it/RepositoriesITCase.java b/scm-it/src/test/java/sonia/scm/it/RepositoriesITCase.java index 3c67ca3dc3..c49a65bea2 100644 --- a/scm-it/src/test/java/sonia/scm/it/RepositoriesITCase.java +++ b/scm-it/src/test/java/sonia/scm/it/RepositoriesITCase.java @@ -36,7 +36,6 @@ package sonia.scm.it; import org.apache.http.HttpStatus; import org.assertj.core.api.Assertions; import org.junit.Before; -import org.junit.BeforeClass; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; diff --git a/scm-it/src/test/java/sonia/scm/it/utils/ScmRequests.java b/scm-it/src/test/java/sonia/scm/it/utils/ScmRequests.java index bde3892773..0a5693ad2e 100644 --- a/scm-it/src/test/java/sonia/scm/it/utils/ScmRequests.java +++ b/scm-it/src/test/java/sonia/scm/it/utils/ScmRequests.java @@ -5,10 +5,8 @@ import io.restassured.response.Response; import org.junit.Assert; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import sonia.scm.user.User; import sonia.scm.web.VndMediaType; -import java.net.ConnectException; import java.util.List; import java.util.Map; import java.util.function.Consumer; @@ -48,7 +46,7 @@ public class ScmRequests { return new IndexResponse(applyGETRequest(RestUtil.REST_BASE_URL.toString())); } - public , T extends ModelResponse> UserResponse requestUser(String username, String password, String pathParam) { + public UserResponse requestUser(String username, String password, String pathParam) { setUsername(username); setPassword(password); return new UserResponse<>(applyGETRequest(RestUtil.REST_BASE_URL.resolve("users/"+pathParam).toString()), null); @@ -195,7 +193,7 @@ public class ScmRequests { return new MeResponse<>(applyGETRequestFromLink(response, LINK_ME), this); } - public UserResponse requestUser(String username) { + public UserResponse requestUser(String username) { return new UserResponse<>(applyGETRequestFromLinkWithParams(response, LINK_USERS, username), this); } @@ -307,19 +305,24 @@ public class ScmRequests { } - public class MeResponse extends UserResponse, PREV> { + public class MeResponse extends ModelResponse, PREV> { + public static final String LINKS_PASSWORD_HREF = "_links.password.href"; public MeResponse(Response response, PREV previousResponse) { super(response, previousResponse); } - public ChangePasswordResponse requestChangePassword(String oldPassword, String newPassword) { + public MeResponse assertPasswordLinkDoesNotExists() { + return assertPropertyPathDoesNotExists(LINKS_PASSWORD_HREF); + } + + public ChangePasswordResponse requestChangePassword(String oldPassword, String newPassword) { return new ChangePasswordResponse<>(applyPUTRequestFromLink(super.response, LINKS_PASSWORD_HREF, VndMediaType.PASSWORD_CHANGE, createPasswordChangeJson(oldPassword, newPassword)), this); } } - public class UserResponse, PREV extends ModelResponse> extends ModelResponse { + public class UserResponse extends ModelResponse, PREV> { public static final String LINKS_PASSWORD_HREF = "_links.password.href"; @@ -327,23 +330,23 @@ public class ScmRequests { super(response, previousResponse); } - public SELF assertPassword(Consumer assertPassword) { + public UserResponse assertPassword(Consumer assertPassword) { return super.assertSingleProperty(assertPassword, "password"); } - public SELF assertType(Consumer assertType) { + public UserResponse assertType(Consumer assertType) { return assertSingleProperty(assertType, "type"); } - public SELF assertAdmin(Consumer assertAdmin) { + public UserResponse assertAdmin(Consumer assertAdmin) { return assertSingleProperty(assertAdmin, "admin"); } - public SELF assertPasswordLinkDoesNotExists() { + public UserResponse assertPasswordLinkDoesNotExists() { return assertPropertyPathDoesNotExists(LINKS_PASSWORD_HREF); } - public SELF assertPasswordLinkExists() { + public UserResponse assertPasswordLinkExists() { return assertPropertyPathExists(LINKS_PASSWORD_HREF); } diff --git a/scm-it/src/test/java/sonia/scm/it/utils/TestData.java b/scm-it/src/test/java/sonia/scm/it/utils/TestData.java index 48605437c6..584737221f 100644 --- a/scm-it/src/test/java/sonia/scm/it/utils/TestData.java +++ b/scm-it/src/test/java/sonia/scm/it/utils/TestData.java @@ -4,15 +4,16 @@ import io.restassured.response.ValidatableResponse; import org.apache.http.HttpStatus; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import sonia.scm.repository.PermissionType; import sonia.scm.web.VndMediaType; import javax.json.Json; import javax.json.JsonObjectBuilder; import java.net.URI; +import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import static java.util.Arrays.asList; import static sonia.scm.it.utils.RestUtil.createResourceUrl; @@ -25,6 +26,11 @@ public class TestData { public static final String USER_SCM_ADMIN = "scmadmin"; public static final String USER_ANONYMOUS = "anonymous"; + + public static final Collection READ = asList("read", "pull"); + public static final Collection WRITE = asList("read", "write", "pull", "push"); + public static final Collection OWNER = asList("*"); + private static final List PROTECTED_USERS = asList(USER_SCM_ADMIN, USER_ANONYMOUS); private static Map DEFAULT_REPOSITORIES = new HashMap<>(); @@ -82,13 +88,13 @@ public class TestData { ; } - public static void createUserPermission(String name, PermissionType permissionType, String repositoryType) { + public static void createUserPermission(String name, Collection permissionType, String repositoryType) { String defaultPermissionUrl = TestData.getDefaultPermissionUrl(USER_SCM_ADMIN, USER_SCM_ADMIN, repositoryType); LOG.info("create permission with name {} and type: {} using the endpoint: {}", name, permissionType, defaultPermissionUrl); - given(VndMediaType.PERMISSION) + given(VndMediaType.REPOSITORY_PERMISSION) .when() .content("{\n" + - "\t\"type\": \"" + permissionType.name() + "\",\n" + + "\t\"verbs\": " + permissionType.stream().collect(Collectors.joining("\",\"", "[\"", "\"]")) + ",\n" + "\t\"name\": \"" + name + "\",\n" + "\t\"groupPermission\": false\n" + "\t\n" + @@ -106,7 +112,7 @@ public class TestData { } public static ValidatableResponse callUserPermissions(String username, String password, String repositoryType, int expectedStatusCode) { - return given(VndMediaType.PERMISSION, username, password) + return given(VndMediaType.REPOSITORY_PERMISSION, username, password) .when() .get(TestData.getDefaultPermissionUrl(username, password, repositoryType)) .then() diff --git a/scm-plugins/pom.xml b/scm-plugins/pom.xml index db0173d1df..02085bb7c5 100644 --- a/scm-plugins/pom.xml +++ b/scm-plugins/pom.xml @@ -82,7 +82,6 @@ test - @@ -137,7 +136,7 @@ - doc + plugin-doc diff --git a/scm-plugins/scm-git-plugin/package.json b/scm-plugins/scm-git-plugin/package.json index 6377574498..1805f0665b 100644 --- a/scm-plugins/scm-git-plugin/package.json +++ b/scm-plugins/scm-git-plugin/package.json @@ -9,9 +9,9 @@ "flow": "flow check" }, "dependencies": { - "@scm-manager/ui-extensions": "^0.1.1" + "@scm-manager/ui-extensions": "^0.1.2" }, "devDependencies": { - "@scm-manager/ui-bundler": "^0.0.21" + "@scm-manager/ui-bundler": "^0.0.24" } } diff --git a/scm-plugins/scm-git-plugin/src/main/java/org/eclipse/jgit/transport/ScmTransportProtocol.java b/scm-plugins/scm-git-plugin/src/main/java/org/eclipse/jgit/transport/ScmTransportProtocol.java index 88c988537d..8e1a6e5ef3 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/org/eclipse/jgit/transport/ScmTransportProtocol.java +++ b/scm-plugins/scm-git-plugin/src/main/java/org/eclipse/jgit/transport/ScmTransportProtocol.java @@ -48,6 +48,7 @@ import org.eclipse.jgit.lib.RepositoryCache; import sonia.scm.repository.GitRepositoryHandler; import sonia.scm.repository.spi.HookEventFacade; +import sonia.scm.web.CollectingPackParserListener; import sonia.scm.web.GitReceiveHook; //~--- JDK imports ------------------------------------------------------------ @@ -64,10 +65,10 @@ public class ScmTransportProtocol extends TransportProtocol { /** Field description */ - private static final String NAME = "scm"; + public static final String NAME = "scm"; /** Field description */ - private static final Set SCHEMES = ImmutableSet.of("scm"); + private static final Set SCHEMES = ImmutableSet.of(NAME); //~--- constructors --------------------------------------------------------- @@ -136,7 +137,7 @@ public class ScmTransportProtocol extends TransportProtocol */ @Override public Transport open(URIish uri, Repository local, String remoteName) - throws NotSupportedException, TransportException + throws TransportException { File localDirectory = local.getDirectory(); File path = local.getFS().resolve(localDirectory, uri.getPath()); @@ -150,7 +151,7 @@ public class ScmTransportProtocol extends TransportProtocol //J- return new TransportLocalWithHooks( hookEventFacadeProvider.get(), - repositoryHandlerProvider.get(), + repositoryHandlerProvider.get(), local, uri, gitDir ); //J+ @@ -234,6 +235,8 @@ public class ScmTransportProtocol extends TransportProtocol pack.setPreReceiveHook(hook); pack.setPostReceiveHook(hook); + + CollectingPackParserListener.set(pack); } return pack; diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitConfigDto.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitConfigDto.java index 19a014dcdf..08602e2af1 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitConfigDto.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitConfigDto.java @@ -6,18 +6,17 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; -import java.io.File; - @NoArgsConstructor @Getter @Setter public class GitConfigDto extends HalRepresentation { - private File repositoryDirectory; private boolean disabled = false; private String gcExpression; + private boolean nonFastForwardDisallowed; + @Override @SuppressWarnings("squid:S1185") // We want to have this method available in this package protected HalRepresentation add(Links links) { diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitConfigInIndexResource.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitConfigInIndexResource.java index a1120adda4..e6d7546ff4 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitConfigInIndexResource.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitConfigInIndexResource.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import sonia.scm.config.ConfigurationPermissions; import sonia.scm.plugin.Extension; +import sonia.scm.repository.GitConfig; import sonia.scm.web.JsonEnricherBase; import sonia.scm.web.JsonEnricherContext; @@ -26,7 +27,7 @@ public class GitConfigInIndexResource extends JsonEnricherBase { @Override public void enrich(JsonEnricherContext context) { - if (resultHasMediaType(INDEX, context) && ConfigurationPermissions.list().isPermitted()) { + if (resultHasMediaType(INDEX, context) && ConfigurationPermissions.read(GitConfig.PERMISSION).isPermitted()) { String gitConfigUrl = new LinkBuilder(scmPathInfoStore.get().get(), GitConfigResource.class) .method("get") .parameters() diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitConfigResource.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitConfigResource.java index 1384d73d9c..7cda4bc9d3 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitConfigResource.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitConfigResource.java @@ -9,10 +9,12 @@ import sonia.scm.repository.GitRepositoryHandler; import sonia.scm.web.GitVndMediaType; import javax.inject.Inject; +import javax.inject.Provider; import javax.ws.rs.Consumes; import javax.ws.rs.GET; import javax.ws.rs.PUT; import javax.ws.rs.Path; +import javax.ws.rs.PathParam; import javax.ws.rs.Produces; import javax.ws.rs.core.Response; @@ -26,13 +28,15 @@ public class GitConfigResource { private final GitConfigDtoToGitConfigMapper dtoToConfigMapper; private final GitConfigToGitConfigDtoMapper configToDtoMapper; private final GitRepositoryHandler repositoryHandler; + private final Provider gitRepositoryConfigResource; @Inject public GitConfigResource(GitConfigDtoToGitConfigMapper dtoToConfigMapper, GitConfigToGitConfigDtoMapper configToDtoMapper, - GitRepositoryHandler repositoryHandler) { + GitRepositoryHandler repositoryHandler, Provider gitRepositoryConfigResource) { this.dtoToConfigMapper = dtoToConfigMapper; this.configToDtoMapper = configToDtoMapper; this.repositoryHandler = repositoryHandler; + this.gitRepositoryConfigResource = gitRepositoryConfigResource; } /** @@ -88,4 +92,9 @@ public class GitConfigResource { return Response.noContent().build(); } + + @Path("{namespace}/{name}") + public GitRepositoryConfigResource getRepositoryConfig(@PathParam("namespace") String namespace, @PathParam("name") String name) { + return gitRepositoryConfigResource.get(); + } } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitRepositoryConfigChangeClearRepositoryCacheListener.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitRepositoryConfigChangeClearRepositoryCacheListener.java new file mode 100644 index 0000000000..df93fa4886 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitRepositoryConfigChangeClearRepositoryCacheListener.java @@ -0,0 +1,19 @@ +package sonia.scm.api.v2.resources; + +import com.github.legman.Subscribe; +import sonia.scm.EagerSingleton; +import sonia.scm.event.ScmEventBus; +import sonia.scm.plugin.Extension; +import sonia.scm.repository.ClearRepositoryCacheEvent; + +import java.util.Objects; + +@EagerSingleton @Extension +public class GitRepositoryConfigChangeClearRepositoryCacheListener { + @Subscribe + public void sendClearRepositoryCacheEvent(GitRepositoryConfigChangedEvent event) { + if (!Objects.equals(event.getOldConfig().getDefaultBranch(), event.getNewConfig().getDefaultBranch())) { + ScmEventBus.getInstance().post(new ClearRepositoryCacheEvent(event.getRepository())); + } + } +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitRepositoryConfigChangedEvent.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitRepositoryConfigChangedEvent.java new file mode 100644 index 0000000000..eaf575a610 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitRepositoryConfigChangedEvent.java @@ -0,0 +1,30 @@ +package sonia.scm.api.v2.resources; + +import sonia.scm.event.Event; +import sonia.scm.repository.GitRepositoryConfig; +import sonia.scm.repository.Repository; + +@Event +public class GitRepositoryConfigChangedEvent { + private final Repository repository; + private final GitRepositoryConfig oldConfig; + private final GitRepositoryConfig newConfig; + + public GitRepositoryConfigChangedEvent(Repository repository, GitRepositoryConfig oldConfig, GitRepositoryConfig newConfig) { + this.repository = repository; + this.oldConfig = oldConfig; + this.newConfig = newConfig; + } + + public Repository getRepository() { + return repository; + } + + public GitRepositoryConfig getOldConfig() { + return oldConfig; + } + + public GitRepositoryConfig getNewConfig() { + return newConfig; + } +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitRepositoryConfigDto.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitRepositoryConfigDto.java new file mode 100644 index 0000000000..d22d6c194e --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitRepositoryConfigDto.java @@ -0,0 +1,24 @@ +package sonia.scm.api.v2.resources; + +import de.otto.edison.hal.HalRepresentation; +import de.otto.edison.hal.Links; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@SuppressWarnings("squid:S2160") // there is no proper semantic for equals on this dto +public class GitRepositoryConfigDto extends HalRepresentation { + + private String defaultBranch; + + @Override + @SuppressWarnings("squid:S1185") // We want to have this method available in this package + protected HalRepresentation add(Links links) { + return super.add(links); + } +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitRepositoryConfigEnricher.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitRepositoryConfigEnricher.java new file mode 100644 index 0000000000..2566fd82f7 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitRepositoryConfigEnricher.java @@ -0,0 +1,37 @@ +package sonia.scm.api.v2.resources; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import sonia.scm.plugin.Extension; +import sonia.scm.repository.GitRepositoryHandler; +import sonia.scm.repository.NamespaceAndName; +import sonia.scm.repository.RepositoryManager; +import sonia.scm.web.AbstractRepositoryJsonEnricher; + +import javax.inject.Inject; +import javax.inject.Provider; + +@Extension +public class GitRepositoryConfigEnricher extends AbstractRepositoryJsonEnricher { + + private final Provider scmPathInfoStore; + private final RepositoryManager manager; + + @Inject + public GitRepositoryConfigEnricher(Provider scmPathInfoStore, ObjectMapper objectMapper, RepositoryManager manager) { + super(objectMapper); + this.scmPathInfoStore = scmPathInfoStore; + this.manager = manager; + } + + @Override + protected void enrichRepositoryNode(JsonNode repositoryNode, String namespace, String name) { + if (GitRepositoryHandler.TYPE_NAME.equals(manager.get(new NamespaceAndName(namespace, name)).getType())) { + String repositoryConfigLink = new LinkBuilder(scmPathInfoStore.get().get(), GitConfigResource.class) + .method("getRepositoryConfig") + .parameters(namespace, name) + .href(); + addLink(repositoryNode, "configuration", repositoryConfigLink); + } + } +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitRepositoryConfigMapper.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitRepositoryConfigMapper.java new file mode 100644 index 0000000000..6480e526b1 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitRepositoryConfigMapper.java @@ -0,0 +1,46 @@ +package sonia.scm.api.v2.resources; + +import de.otto.edison.hal.Links; +import org.mapstruct.AfterMapping; +import org.mapstruct.Context; +import org.mapstruct.Mapper; +import org.mapstruct.MappingTarget; +import sonia.scm.repository.GitRepositoryConfig; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryPermissions; + +import javax.inject.Inject; + +import static de.otto.edison.hal.Link.link; +import static de.otto.edison.hal.Links.linkingTo; + +// Mapstruct does not support parameterized (i.e. non-default) constructors. Thus, we need to use field injection. +@SuppressWarnings("squid:S3306") +@Mapper +public abstract class GitRepositoryConfigMapper { + + @Inject + private ScmPathInfoStore scmPathInfoStore; + + public abstract GitRepositoryConfigDto map(GitRepositoryConfig config, @Context Repository repository); + public abstract GitRepositoryConfig map(GitRepositoryConfigDto dto); + + @AfterMapping + void appendLinks(@MappingTarget GitRepositoryConfigDto target, @Context Repository repository) { + Links.Builder linksBuilder = linkingTo().self(self()); + if (RepositoryPermissions.modify(repository).isPermitted()) { + linksBuilder.single(link("update", update())); + } + target.add(linksBuilder.build()); + } + + private String self() { + LinkBuilder linkBuilder = new LinkBuilder(scmPathInfoStore.get(), GitConfigResource.class); + return linkBuilder.method("get").parameters().href(); + } + + private String update() { + LinkBuilder linkBuilder = new LinkBuilder(scmPathInfoStore.get(), GitConfigResource.class); + return linkBuilder.method("update").parameters().href(); + } +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitRepositoryConfigResource.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitRepositoryConfigResource.java new file mode 100644 index 0000000000..7b226186e5 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitRepositoryConfigResource.java @@ -0,0 +1,90 @@ +package sonia.scm.api.v2.resources; + +import com.webcohesion.enunciate.metadata.rs.ResponseCode; +import com.webcohesion.enunciate.metadata.rs.StatusCodes; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.repository.GitRepositoryConfig; +import sonia.scm.repository.NamespaceAndName; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryManager; +import sonia.scm.store.ConfigurationStore; +import sonia.scm.web.GitVndMediaType; + +import javax.inject.Inject; +import javax.ws.rs.Consumes; +import javax.ws.rs.GET; +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Response; + +import static sonia.scm.ContextEntry.ContextBuilder.entity; +import static sonia.scm.NotFoundException.notFound; + +public class GitRepositoryConfigResource { + + private static final Logger LOG = LoggerFactory.getLogger(GitRepositoryConfigResource.class); + + private final GitRepositoryConfigMapper repositoryConfigMapper; + private final RepositoryManager repositoryManager; + private final GitRepositoryConfigStoreProvider gitRepositoryConfigStoreProvider; + + @Inject + public GitRepositoryConfigResource(GitRepositoryConfigMapper repositoryConfigMapper, RepositoryManager repositoryManager, GitRepositoryConfigStoreProvider gitRepositoryConfigStoreProvider) { + this.repositoryConfigMapper = repositoryConfigMapper; + this.repositoryManager = repositoryManager; + this.gitRepositoryConfigStoreProvider = gitRepositoryConfigStoreProvider; + } + + @GET + @Path("/") + @Produces(GitVndMediaType.GIT_REPOSITORY_CONFIG) + @StatusCodes({ + @ResponseCode(code = 200, condition = "success"), + @ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), + @ResponseCode(code = 403, condition = "not authorized, the current user has no privileges to read the repository config"), + @ResponseCode(code = 404, condition = "not found, no repository with the specified namespace and name available"), + @ResponseCode(code = 500, condition = "internal server error") + }) + public Response getRepositoryConfig(@PathParam("namespace") String namespace, @PathParam("name") String name) { + Repository repository = getRepository(namespace, name); + ConfigurationStore repositoryConfigStore = getStore(repository); + GitRepositoryConfig config = repositoryConfigStore.get(); + GitRepositoryConfigDto dto = repositoryConfigMapper.map(config, repository); + return Response.ok(dto).build(); + } + + @PUT + @Path("/") + @Consumes(GitVndMediaType.GIT_REPOSITORY_CONFIG) + @StatusCodes({ + @ResponseCode(code = 204, condition = "update success"), + @ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), + @ResponseCode(code = 403, condition = "not authorized, the current user does not have the privilege to change this repositories config"), + @ResponseCode(code = 404, condition = "not found, no repository with the specified namespace and name available/name available"), + @ResponseCode(code = 500, condition = "internal server error") + }) + public Response setRepositoryConfig(@PathParam("namespace") String namespace, @PathParam("name") String name, GitRepositoryConfigDto dto) { + Repository repository = getRepository(namespace, name); + ConfigurationStore repositoryConfigStore = getStore(repository); + GitRepositoryConfig config = repositoryConfigMapper.map(dto); + repositoryConfigStore.set(config); + LOG.info("git default branch of repository {} has changed, sending clear cache event", repository.getNamespaceAndName()); + return Response.noContent().build(); + } + + private Repository getRepository(@PathParam("namespace") String namespace, @PathParam("name") String name) { + NamespaceAndName namespaceAndName = new NamespaceAndName(namespace, name); + Repository repository = repositoryManager.get(namespaceAndName); + if (repository == null) { + throw notFound(entity(namespaceAndName)); + } + return repository; + } + + private ConfigurationStore getStore(Repository repository) { + return gitRepositoryConfigStoreProvider.get(repository); + } +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitRepositoryConfigStoreProvider.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitRepositoryConfigStoreProvider.java new file mode 100644 index 0000000000..ce37fb65f4 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitRepositoryConfigStoreProvider.java @@ -0,0 +1,50 @@ +package sonia.scm.api.v2.resources; + +import sonia.scm.event.ScmEventBus; +import sonia.scm.repository.GitRepositoryConfig; +import sonia.scm.repository.Repository; +import sonia.scm.store.ConfigurationStore; +import sonia.scm.store.ConfigurationStoreFactory; + +import javax.inject.Inject; + +public class GitRepositoryConfigStoreProvider { + + private final ConfigurationStoreFactory configurationStoreFactory; + + @Inject + public GitRepositoryConfigStoreProvider(ConfigurationStoreFactory configurationStoreFactory) { + this.configurationStoreFactory = configurationStoreFactory; + } + + public ConfigurationStore get(Repository repository) { + return new StoreWrapper(configurationStoreFactory.withType(GitRepositoryConfig.class).withName("gitConfig").forRepository(repository).build(), repository); + } + + private static class StoreWrapper implements ConfigurationStore { + + private final ConfigurationStore delegate; + private final Repository repository; + + private StoreWrapper(ConfigurationStore delegate, Repository repository) { + this.delegate = delegate; + this.repository = repository; + } + + @Override + public GitRepositoryConfig get() { + GitRepositoryConfig config = delegate.get(); + if (config == null) { + return new GitRepositoryConfig(); + } + return config; + } + + @Override + public void set(GitRepositoryConfig newConfig) { + GitRepositoryConfig oldConfig = get(); + delegate.set(newConfig); + ScmEventBus.getInstance().post(new GitRepositoryConfigChangedEvent(repository, oldConfig, newConfig)); + } + } +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitConfig.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitConfig.java index 03f38b0086..8f4de9aa30 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitConfig.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitConfig.java @@ -55,8 +55,10 @@ public class GitConfig extends RepositoryConfig { @XmlElement(name = "gc-expression") private String gcExpression; - public String getGcExpression() - { + @XmlElement(name = "disallow-non-fast-forward") + private boolean nonFastForwardDisallowed; + + public String getGcExpression() { return gcExpression; } @@ -64,6 +66,14 @@ public class GitConfig extends RepositoryConfig { this.gcExpression = gcExpression; } + public boolean isNonFastForwardDisallowed() { + return nonFastForwardDisallowed; + } + + public void setNonFastForwardDisallowed(boolean nonFastForwardDisallowed) { + this.nonFastForwardDisallowed = nonFastForwardDisallowed; + } + @Override @XmlTransient // Only for permission checks, don't serialize to XML public String getId() { diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitGcTask.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitGcTask.java index 248eae92d1..9a30cc9f3e 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitGcTask.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitGcTask.java @@ -121,7 +121,7 @@ public class GitGcTask implements Runnable { } private void gc(Repository repository){ - File file = repositoryHandler.getDirectory(repository); + File file = repositoryHandler.getDirectory(repository.getId()); Git git = null; try { git = open(file); diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitHeadModifier.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitHeadModifier.java new file mode 100644 index 0000000000..b82e8bdd3c --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitHeadModifier.java @@ -0,0 +1,101 @@ +/** + * Copyright (c) 2014, Sebastian Sdorra + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * 3. Neither the name of SCM-Manager; nor the names of its + * contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * http://bitbucket.org/sdorra/scm-manager + * + */ +package sonia.scm.repository; + +import org.eclipse.jgit.lib.Constants; +import org.eclipse.jgit.lib.Ref; +import org.eclipse.jgit.lib.RefUpdate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.inject.Inject; +import java.io.File; +import java.io.IOException; +import java.util.Objects; + +/** + * The GitHeadModifier is able to modify the head of a git repository. + * + * @author Sebastian Sdorra + * @since 1.61 + */ +public class GitHeadModifier { + + private static final Logger LOG = LoggerFactory.getLogger(GitHeadModifier.class); + + private final GitRepositoryHandler repositoryHandler; + + @Inject + public GitHeadModifier(GitRepositoryHandler repositoryHandler) { + this.repositoryHandler = repositoryHandler; + } + + /** + * Ensures that the repositories head points to the given branch. The method will return {@code false} if the + * repositories head points already to the given branch. + * + * @param repository repository to modify + * @param newHead branch which should be the new head of the repository + * + * @return {@code true} if the head has changed + */ + public boolean ensure(Repository repository, String newHead) { + try (org.eclipse.jgit.lib.Repository gitRepository = open(repository)) { + String currentHead = resolve(gitRepository); + if (!Objects.equals(currentHead, newHead)) { + return modify(gitRepository, newHead); + } + } catch (IOException ex) { + LOG.warn("failed to change head of repository", ex); + } + return false; + } + + private String resolve(org.eclipse.jgit.lib.Repository gitRepository) throws IOException { + Ref ref = gitRepository.getRefDatabase().getRef(Constants.HEAD); + if ( ref.isSymbolic() ) { + ref = ref.getTarget(); + } + return GitUtil.getBranch(ref); + } + + private boolean modify(org.eclipse.jgit.lib.Repository gitRepository, String newHead) throws IOException { + RefUpdate refUpdate = gitRepository.getRefDatabase().newUpdate(Constants.HEAD, true); + refUpdate.setForceUpdate(true); + RefUpdate.Result result = refUpdate.link(Constants.R_HEADS + newHead); + return result == RefUpdate.Result.FORCED; + } + + private org.eclipse.jgit.lib.Repository open(Repository repository) throws IOException { + File directory = repositoryHandler.getDirectory(repository.getId()); + return GitUtil.open(directory); + } +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitRepositoryConfig.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitRepositoryConfig.java new file mode 100644 index 0000000000..a0136c8ea6 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitRepositoryConfig.java @@ -0,0 +1,27 @@ +package sonia.scm.repository; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlRootElement; + +@XmlRootElement(name = "config") +@XmlAccessorType(XmlAccessType.FIELD) +public class GitRepositoryConfig { + + public GitRepositoryConfig() { + } + + public GitRepositoryConfig(String defaultBranch) { + this.defaultBranch = defaultBranch; + } + + private String defaultBranch; + + public String getDefaultBranch() { + return defaultBranch; + } + + public void setDefaultBranch(String defaultBranch) { + this.defaultBranch = defaultBranch; + } +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitRepositoryHandler.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitRepositoryHandler.java index 9da44d245f..63800e8a02 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitRepositoryHandler.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitRepositoryHandler.java @@ -38,12 +38,13 @@ package sonia.scm.repository; import com.google.common.base.Strings; import com.google.inject.Inject; import com.google.inject.Singleton; +import org.eclipse.jgit.lib.StoredConfig; import org.eclipse.jgit.storage.file.FileRepositoryBuilder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.SCMContextProvider; -import sonia.scm.io.FileSystem; import sonia.scm.plugin.Extension; +import sonia.scm.plugin.PluginLoader; import sonia.scm.repository.spi.GitRepositoryServiceProvider; import sonia.scm.schedule.Scheduler; import sonia.scm.schedule.Task; @@ -88,27 +89,25 @@ public class GitRepositoryHandler GitRepositoryServiceProvider.COMMANDS); private static final Object LOCK = new Object(); - + private static final String CONFIG_SECTION_SCMM = "scmm"; + private static final String CONFIG_KEY_REPOSITORY_ID = "repositoryid"; + private final Scheduler scheduler; private final GitWorkdirFactory workdirFactory; - + private Task task; - + //~--- constructors --------------------------------------------------------- - /** - * Constructs ... - * - * - * @param storeFactory - * @param fileSystem - * @param scheduler - */ @Inject - public GitRepositoryHandler(ConfigurationStoreFactory storeFactory, FileSystem fileSystem, Scheduler scheduler, GitWorkdirFactory workdirFactory) + public GitRepositoryHandler(ConfigurationStoreFactory storeFactory, + Scheduler scheduler, + RepositoryLocationResolver repositoryLocationResolver, + GitWorkdirFactory workdirFactory, + PluginLoader pluginLoader) { - super(storeFactory, fileSystem); + super(storeFactory, repositoryLocationResolver, pluginLoader); this.scheduler = scheduler; this.workdirFactory = workdirFactory; } @@ -128,7 +127,7 @@ public class GitRepositoryHandler scheduleGc(config.getGcExpression()); super.setConfig(config); } - + private void scheduleGc(String expression) { synchronized (LOCK){ @@ -144,7 +143,7 @@ public class GitRepositoryHandler } } } - + /** * Method description * @@ -181,15 +180,26 @@ public class GitRepositoryHandler return getStringFromResource(RESOURCE_VERSION, DEFAULT_VERSION_INFORMATION); } + public GitWorkdirFactory getWorkdirFactory() { + return workdirFactory; + } + + public String getRepositoryId(StoredConfig gitConfig) { + return gitConfig.getString(GitRepositoryHandler.CONFIG_SECTION_SCMM, null, GitRepositoryHandler.CONFIG_KEY_REPOSITORY_ID); + } + //~--- methods -------------------------------------------------------------- @Override protected void create(Repository repository, File directory) throws IOException { try (org.eclipse.jgit.lib.Repository gitRepository = build(directory)) { gitRepository.create(true); + StoredConfig config = gitRepository.getConfig(); + config.setString(CONFIG_SECTION_SCMM, null, CONFIG_KEY_REPOSITORY_ID, repository.getId()); + config.save(); } } - + private org.eclipse.jgit.lib.Repository build(File directory) throws IOException { return new FileRepositoryBuilder() .setGitDir(directory) @@ -223,22 +233,4 @@ public class GitRepositoryHandler { return GitConfig.class; } - - /** - * Method description - * - * - * @param directory - * - * @return - */ - @Override - protected boolean isRepository(File directory) - { - return new File(directory, DIRECTORY_REFS).exists(); - } - - public GitWorkdirFactory getWorkdirFactory() { - return workdirFactory; - } } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitRepositoryModifyListener.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitRepositoryModifyListener.java index 1cbcdc35bf..a16b34f6be 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitRepositoryModifyListener.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitRepositoryModifyListener.java @@ -31,67 +31,45 @@ package sonia.scm.repository; import com.github.legman.Subscribe; -import com.google.common.annotations.VisibleForTesting; -import com.google.common.base.Objects; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import sonia.scm.EagerSingleton; -import sonia.scm.HandlerEventType; -import sonia.scm.event.ScmEventBus; +import sonia.scm.api.v2.resources.GitRepositoryConfigChangedEvent; +import sonia.scm.api.v2.resources.GitRepositoryConfigStoreProvider; import sonia.scm.plugin.Extension; +import javax.inject.Inject; + /** * Repository listener which handles git related repository events. - * + * * @author Sebastian Sdorra * @since 1.50 */ @Extension @EagerSingleton public class GitRepositoryModifyListener { - - /** - * the logger for GitRepositoryModifyListener - */ - private static final Logger logger = LoggerFactory.getLogger(GitRepositoryModifyListener.class); - + + private final GitHeadModifier headModifier; + private final GitRepositoryConfigStoreProvider storeProvider; + + @Inject + public GitRepositoryModifyListener(GitHeadModifier headModifier, GitRepositoryConfigStoreProvider storeProvider) { + this.headModifier = headModifier; + this.storeProvider = storeProvider; + } + /** * Receives {@link RepositoryModificationEvent} and fires a {@link ClearRepositoryCacheEvent} if * the default branch of a git repository was modified. - * + * * @param event repository modification event */ @Subscribe - public void handleEvent(RepositoryModificationEvent event){ - Repository repository = event.getItem(); - - if ( isModifyEvent(event) && - isGitRepository(event.getItem()) && - hasDefaultBranchChanged(event.getItemBeforeModification(), repository)) - { - logger.info("git default branch of repository {} has changed, sending clear cache event", repository.getId()); - sendClearRepositoryCacheEvent(repository); + public void handleEvent(GitRepositoryConfigChangedEvent event){ + Repository repository = event.getRepository(); + + String defaultBranch = storeProvider.get(repository).get().getDefaultBranch(); + if (defaultBranch != null) { + headModifier.ensure(repository, defaultBranch); } } - - @VisibleForTesting - protected void sendClearRepositoryCacheEvent(Repository repository) { - ScmEventBus.getInstance().post(new ClearRepositoryCacheEvent(repository)); - } - - private boolean isModifyEvent(RepositoryEvent event) { - return event.getEventType() == HandlerEventType.MODIFY; - } - - private boolean isGitRepository(Repository repository) { - return GitRepositoryHandler.TYPE_NAME.equals(repository.getType()); - } - - private boolean hasDefaultBranchChanged(Repository old, Repository current) { - return !Objects.equal( - old.getProperty(GitConstants.PROPERTY_DEFAULT_BRANCH), - current.getProperty(GitConstants.PROPERTY_DEFAULT_BRANCH) - ); - } - } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/api/GitHookTagProvider.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/api/GitHookTagProvider.java index e7a75a0ff4..bcc2dc8a18 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/api/GitHookTagProvider.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/api/GitHookTagProvider.java @@ -68,17 +68,40 @@ public class GitHookTagProvider implements HookTagProvider { if (Strings.isNullOrEmpty(tag)){ logger.debug("received ref name {} is not a tag", refName); - } else if (rc.getType() == ReceiveCommand.Type.CREATE) { - createdTagBuilder.add(new Tag(tag, GitUtil.getId(rc.getNewId()))); - } else if (rc.getType() == ReceiveCommand.Type.DELETE){ - deletedTagBuilder.add(new Tag(tag, GitUtil.getId(rc.getOldId()))); + } else if (isCreate(rc)) { + createdTagBuilder.add(createTagFromNewId(rc, tag)); + } else if (isDelete(rc)){ + deletedTagBuilder.add(createTagFromOldId(rc, tag)); + } else if (isUpdate(rc)) { + createdTagBuilder.add(createTagFromNewId(rc, tag)); + deletedTagBuilder.add(createTagFromOldId(rc, tag)); } } createdTags = createdTagBuilder.build(); deletedTags = deletedTagBuilder.build(); } - + + private Tag createTagFromNewId(ReceiveCommand rc, String tag) { + return new Tag(tag, GitUtil.getId(rc.getNewId())); + } + + private Tag createTagFromOldId(ReceiveCommand rc, String tag) { + return new Tag(tag, GitUtil.getId(rc.getOldId())); + } + + private boolean isUpdate(ReceiveCommand rc) { + return rc.getType() == ReceiveCommand.Type.UPDATE || rc.getType() == ReceiveCommand.Type.UPDATE_NONFASTFORWARD; + } + + private boolean isDelete(ReceiveCommand rc) { + return rc.getType() == ReceiveCommand.Type.DELETE; + } + + private boolean isCreate(ReceiveCommand rc) { + return rc.getType() == ReceiveCommand.Type.CREATE; + } + @Override public List getCreatedTags() { return createdTags; diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AbstractGitCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AbstractGitCommand.java index 2970bbd627..f94ccd59f6 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AbstractGitCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AbstractGitCommand.java @@ -40,7 +40,6 @@ import org.eclipse.jgit.lib.Ref; import org.eclipse.jgit.lib.Repository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import sonia.scm.repository.GitConstants; import sonia.scm.repository.GitUtil; import java.io.IOException; @@ -110,7 +109,7 @@ public class AbstractGitCommand protected Ref getBranchOrDefault(Repository gitRepository, String requestedBranch) throws IOException { if ( Strings.isNullOrEmpty(requestedBranch) ) { - String defaultBranchName = repository.getProperty(GitConstants.PROPERTY_DEFAULT_BRANCH); + String defaultBranchName = context.getConfig().getDefaultBranch(); if (!Strings.isNullOrEmpty(defaultBranchName)) { return GitUtil.getBranchId(gitRepository, defaultBranchName); } else { diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AbstractGitIncomingOutgoingCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AbstractGitIncomingOutgoingCommand.java index 3cf72166ea..348203af92 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AbstractGitIncomingOutgoingCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AbstractGitIncomingOutgoingCommand.java @@ -119,7 +119,7 @@ public abstract class AbstractGitIncomingOutgoingCommand Git git = Git.wrap(open()); - GitUtil.fetch(git, handler.getDirectory(remoteRepository), remoteRepository); + GitUtil.fetch(git, handler.getDirectory(remoteRepository.getId()), remoteRepository); ObjectId localId = getDefaultBranch(git.getRepository()); ObjectId remoteId = null; diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AbstractGitPushOrPullCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AbstractGitPushOrPullCommand.java index e4e37d6fed..a9b9e25aca 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AbstractGitPushOrPullCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AbstractGitPushOrPullCommand.java @@ -196,7 +196,7 @@ public abstract class AbstractGitPushOrPullCommand extends AbstractGitCommand */ protected String getRemoteUrl(sonia.scm.repository.Repository repository) { - return getRemoteUrl(handler.getDirectory(repository)); + return getRemoteUrl(handler.getDirectory(repository.getId())); } //~--- methods -------------------------------------------------------------- diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitContext.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitContext.java index b0dd8f1fd6..0b93f9cf2b 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitContext.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitContext.java @@ -37,6 +37,8 @@ package sonia.scm.repository.spi; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import sonia.scm.api.v2.resources.GitRepositoryConfigStoreProvider; +import sonia.scm.repository.GitRepositoryConfig; import sonia.scm.repository.GitUtil; import sonia.scm.repository.Repository; @@ -68,10 +70,11 @@ public class GitContext implements Closeable * @param directory * @param repository */ - public GitContext(File directory, Repository repository) + public GitContext(File directory, Repository repository, GitRepositoryConfigStoreProvider storeProvider) { this.directory = directory; this.repository = repository; + this.storeProvider = storeProvider; } //~--- methods -------------------------------------------------------------- @@ -117,11 +120,25 @@ public class GitContext implements Closeable return directory; } + GitRepositoryConfig getConfig() { + GitRepositoryConfig config = storeProvider.get(repository).get(); + if (config == null) { + return new GitRepositoryConfig(); + } else { + return config; + } + } + + void setConfig(GitRepositoryConfig newConfig) { + storeProvider.get(repository).set(newConfig); + } + //~--- fields --------------------------------------------------------------- /** Field description */ private final File directory; private final Repository repository; + private final GitRepositoryConfigStoreProvider storeProvider; /** Field description */ private org.eclipse.jgit.lib.Repository gitRepository; diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitLogCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitLogCommand.java index 2ea25126cf..a6f74d24eb 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitLogCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitLogCommand.java @@ -205,7 +205,7 @@ public class GitLogCommand extends AbstractGitCommand implements LogCommand ObjectId ancestorId = null; if (!Strings.isNullOrEmpty(request.getAncestorChangeset())) { - ancestorId = computeCommonAncestor(request, repository, startId, branch); + ancestorId = repository.resolve(request.getAncestorChangeset()); } revWalk = new RevWalk(repository); @@ -225,16 +225,15 @@ public class GitLogCommand extends AbstractGitCommand implements LogCommand revWalk.markStart(revWalk.lookupCommit(branch.getObjectId())); } + if (ancestorId != null) { + revWalk.markUninteresting(revWalk.lookupCommit(ancestorId)); + } Iterator iterator = revWalk.iterator(); while (iterator.hasNext()) { RevCommit commit = iterator.next(); - if (commit.getId().equals(ancestorId)) { - break; - } - if ((counter >= start) && ((limit < 0) || (counter < start + limit))) { changesetList.add(converter.createChangeset(commit)); diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeCommand.java index 5e9eac5230..be91d06361 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeCommand.java @@ -4,8 +4,10 @@ import com.google.common.base.Strings; import org.apache.shiro.SecurityUtils; import org.apache.shiro.subject.Subject; import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.MergeCommand.FastForwardMode; import org.eclipse.jgit.api.MergeResult; import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.api.errors.RefNotFoundException; import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.merge.MergeStrategy; @@ -15,6 +17,7 @@ import org.slf4j.LoggerFactory; import sonia.scm.repository.GitWorkdirFactory; import sonia.scm.repository.InternalRepositoryException; import sonia.scm.repository.Person; +import sonia.scm.repository.RepositoryPermissions; import sonia.scm.repository.api.MergeCommandResult; import sonia.scm.repository.api.MergeDryRunCommandResult; import sonia.scm.user.User; @@ -22,6 +25,9 @@ import sonia.scm.user.User; import java.io.IOException; import java.text.MessageFormat; +import static sonia.scm.ContextEntry.ContextBuilder.entity; +import static sonia.scm.NotFoundException.notFound; + public class GitMergeCommand extends AbstractGitCommand implements MergeCommand { private static final Logger logger = LoggerFactory.getLogger(GitMergeCommand.class); @@ -40,6 +46,8 @@ public class GitMergeCommand extends AbstractGitCommand implements MergeCommand @Override public MergeCommandResult merge(MergeCommandRequest request) { + RepositoryPermissions.push(context.getRepository().getId()).check(); + try (WorkingCopy workingCopy = workdirFactory.createWorkingCopy(context)) { Repository repository = workingCopy.get(); logger.debug("cloned repository to folder {}", repository.getWorkTree()); @@ -88,20 +96,43 @@ public class GitMergeCommand extends AbstractGitCommand implements MergeCommand } } - private void checkOutTargetBranch() { + private void checkOutTargetBranch() throws IOException { try { clone.checkout().setName(target).call(); + } catch (RefNotFoundException e) { + logger.trace("could not checkout target branch {} for merge directly; trying to create local branch", target, e); + checkOutTargetAsNewLocalBranch(); } catch (GitAPIException e) { throw new InternalRepositoryException(context.getRepository(), "could not checkout target branch for merge: " + target, e); } } + private void checkOutTargetAsNewLocalBranch() throws IOException { + try { + ObjectId targetRevision = resolveRevision(target); + if (targetRevision == null) { + throw notFound(entity("revision", target).in(context.getRepository())); + } + clone.checkout().setStartPoint(targetRevision.getName()).setName(target).setCreateBranch(true).call(); + } catch (RefNotFoundException e) { + logger.debug("could not checkout target branch {} for merge as local branch", target, e); + throw notFound(entity("revision", target).in(context.getRepository())); + } catch (GitAPIException e) { + throw new InternalRepositoryException(context.getRepository(), "could not checkout target branch for merge as local branch: " + target, e); + } + } + private MergeResult doMergeInClone() throws IOException { MergeResult result; try { + ObjectId sourceRevision = resolveRevision(toMerge); + if (sourceRevision == null) { + throw notFound(entity("revision", toMerge).in(context.getRepository())); + } result = clone.merge() + .setFastForward(FastForwardMode.NO_FF) .setCommit(false) // we want to set the author manually - .include(toMerge, resolveRevision(toMerge)) + .include(toMerge, sourceRevision) .call(); } catch (GitAPIException e) { throw new InternalRepositoryException(context.getRepository(), "could not merge branch " + toMerge + " into " + target, e); @@ -113,10 +144,12 @@ public class GitMergeCommand extends AbstractGitCommand implements MergeCommand logger.debug("merged branch {} into {}", toMerge, target); Person authorToUse = determineAuthor(); try { - clone.commit() - .setAuthor(authorToUse.getName(), authorToUse.getMail()) - .setMessage(MessageFormat.format(determineMessageTemplate(), toMerge, target)) - .call(); + if (!clone.status().call().isClean()) { + clone.commit() + .setAuthor(authorToUse.getName(), authorToUse.getMail()) + .setMessage(MessageFormat.format(determineMessageTemplate(), toMerge, target)) + .call(); + } } catch (GitAPIException e) { throw new InternalRepositoryException(context.getRepository(), "could not commit merge between branch " + toMerge + " and " + target, e); } @@ -147,7 +180,7 @@ public class GitMergeCommand extends AbstractGitCommand implements MergeCommand try { clone.push().call(); } catch (GitAPIException e) { - throw new InternalRepositoryException(context.getRepository(), "could not push merged branch " + toMerge + " to origin", e); + throw new InternalRepositoryException(context.getRepository(), "could not push merged branch " + target + " to origin", e); } logger.debug("pushed merged branch {}", target); } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitPullCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitPullCommand.java index 7a829355bf..8810c15c58 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitPullCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitPullCommand.java @@ -196,12 +196,12 @@ public class GitPullCommand extends AbstractGitPushOrPullCommand private PullResponse pullFromScmRepository(Repository sourceRepository) throws IOException { - File sourceDirectory = handler.getDirectory(sourceRepository); + File sourceDirectory = handler.getDirectory(sourceRepository.getId()); Preconditions.checkArgument(sourceDirectory.exists(), "source repository directory does not exists"); - File targetDirectory = handler.getDirectory(repository); + File targetDirectory = handler.getDirectory(repository.getId()); Preconditions.checkArgument(sourceDirectory.exists(), "target repository directory does not exists"); diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java index bda0d87b21..ef3d96d5cb 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java @@ -34,11 +34,14 @@ package sonia.scm.repository.spi; import com.google.common.collect.ImmutableSet; +import sonia.scm.api.v2.resources.GitRepositoryConfigStoreProvider; +import sonia.scm.repository.Feature; import sonia.scm.repository.GitRepositoryHandler; import sonia.scm.repository.Repository; import sonia.scm.repository.api.Command; import java.io.IOException; +import java.util.EnumSet; import java.util.Set; //~--- JDK imports ------------------------------------------------------------ @@ -66,14 +69,15 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider Command.PULL, Command.MERGE ); + protected static final Set FEATURES = EnumSet.of(Feature.INCOMING_REVISION); //J+ //~--- constructors --------------------------------------------------------- - public GitRepositoryServiceProvider(GitRepositoryHandler handler, Repository repository) { + public GitRepositoryServiceProvider(GitRepositoryHandler handler, Repository repository, GitRepositoryConfigStoreProvider storeProvider) { this.handler = handler; this.repository = repository; - this.context = new GitContext(handler.getDirectory(repository), repository); + this.context = new GitContext(handler.getDirectory(repository.getId()), repository, storeProvider); } //~--- methods -------------------------------------------------------------- @@ -246,6 +250,10 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider return new GitMergeCommand(context, repository, handler.getWorkdirFactory()); } + @Override + public Set getSupportedFeatures() { + return FEATURES; + } //~--- fields --------------------------------------------------------------- /** Field description */ diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceResolver.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceResolver.java index deca141556..0730ffc9cf 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceResolver.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceResolver.java @@ -35,6 +35,7 @@ package sonia.scm.repository.spi; //~--- non-JDK imports -------------------------------------------------------- import com.google.inject.Inject; +import sonia.scm.api.v2.resources.GitRepositoryConfigStoreProvider; import sonia.scm.plugin.Extension; import sonia.scm.repository.GitRepositoryHandler; import sonia.scm.repository.Repository; @@ -47,10 +48,12 @@ import sonia.scm.repository.Repository; public class GitRepositoryServiceResolver implements RepositoryServiceResolver { private final GitRepositoryHandler handler; + private final GitRepositoryConfigStoreProvider storeProvider; @Inject - public GitRepositoryServiceResolver(GitRepositoryHandler handler) { + public GitRepositoryServiceResolver(GitRepositoryHandler handler, GitRepositoryConfigStoreProvider storeProvider) { this.handler = handler; + this.storeProvider = storeProvider; } @Override @@ -58,7 +61,7 @@ public class GitRepositoryServiceResolver implements RepositoryServiceResolver { GitRepositoryServiceProvider provider = null; if (GitRepositoryHandler.TYPE_NAME.equalsIgnoreCase(repository.getType())) { - provider = new GitRepositoryServiceProvider(handler, repository); + provider = new GitRepositoryServiceProvider(handler, repository, storeProvider); } return provider; diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/SimpleGitWorkdirFactory.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/SimpleGitWorkdirFactory.java index 22fce5f330..f12818aa80 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/SimpleGitWorkdirFactory.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/SimpleGitWorkdirFactory.java @@ -3,6 +3,7 @@ package sonia.scm.repository.spi; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.transport.ScmTransportProtocol; import org.eclipse.jgit.util.FileUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -45,12 +46,16 @@ public class SimpleGitWorkdirFactory implements GitWorkdirFactory { protected Repository cloneRepository(File bareRepository, File target) throws GitAPIException { return Git.cloneRepository() - .setURI(bareRepository.getAbsolutePath()) + .setURI(createScmTransportProtocolUri(bareRepository)) .setDirectory(target) .call() .getRepository(); } + private String createScmTransportProtocolUri(File bareRepository) { + return ScmTransportProtocol.NAME + "://" + bareRepository.getAbsolutePath(); + } + private void close(Repository repository) { repository.close(); try { diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitReceiveHook.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitReceiveHook.java index eeda60ed02..74a5039516 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitReceiveHook.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitReceiveHook.java @@ -36,6 +36,7 @@ package sonia.scm.web; //~--- non-JDK imports -------------------------------------------------------- import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.lib.StoredConfig; import org.eclipse.jgit.transport.PostReceiveHook; import org.eclipse.jgit.transport.PreReceiveHook; import org.eclipse.jgit.transport.ReceiveCommand; @@ -44,11 +45,9 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.repository.GitRepositoryHandler; import sonia.scm.repository.RepositoryHookType; -import sonia.scm.repository.RepositoryUtil; import sonia.scm.repository.spi.GitHookContextProvider; import sonia.scm.repository.spi.HookEventFacade; -import java.io.File; import java.io.IOException; import java.util.Collection; import java.util.List; @@ -128,14 +127,14 @@ public class GitReceiveHook implements PreReceiveHook, PostReceiveHook try { Repository repository = rpack.getRepository(); - String id = resolveRepositoryId(repository); + String repositoryId = resolveRepositoryId(repository); - logger.trace("resolved repository to id {}", id); + logger.trace("resolved repository to {}", repositoryId); GitHookContextProvider context = new GitHookContextProvider(rpack, receiveCommands); - hookEventFacade.handle(id).fireHookEvent(type, context); + hookEventFacade.handle(repositoryId).fireHookEvent(type, context); } catch (Exception ex) @@ -187,20 +186,10 @@ public class GitReceiveHook implements PreReceiveHook, PostReceiveHook * * @throws IOException */ - private String resolveRepositoryId(Repository repository) throws IOException + private String resolveRepositoryId(Repository repository) { - File directory; - - if (repository.isBare()) - { - directory = repository.getDirectory(); - } - else - { - directory = repository.getWorkTree(); - } - - return RepositoryUtil.getRepositoryId(handler, directory); + StoredConfig gitConfig = repository.getConfig(); + return handler.getRepositoryId(gitConfig); } //~--- fields --------------------------------------------------------------- diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitReceivePackFactory.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitReceivePackFactory.java index 25bbe04cfc..5cb8007986 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitReceivePackFactory.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitReceivePackFactory.java @@ -35,79 +35,63 @@ package sonia.scm.web; //~--- non-JDK imports -------------------------------------------------------- +import com.google.common.annotations.VisibleForTesting; import com.google.inject.Inject; - import org.eclipse.jgit.http.server.resolver.DefaultReceivePackFactory; import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.transport.ReceivePack; import org.eclipse.jgit.transport.resolver.ReceivePackFactory; import org.eclipse.jgit.transport.resolver.ServiceNotAuthorizedException; import org.eclipse.jgit.transport.resolver.ServiceNotEnabledException; - import sonia.scm.repository.GitRepositoryHandler; import sonia.scm.repository.spi.HookEventFacade; -//~--- JDK imports ------------------------------------------------------------ - import javax.servlet.http.HttpServletRequest; +//~--- JDK imports ------------------------------------------------------------ + /** + * GitReceivePackFactory creates {@link ReceivePack} objects and assigns the required + * Hook components. * * @author Sebastian Sdorra */ -public class GitReceivePackFactory - implements ReceivePackFactory +public class GitReceivePackFactory implements ReceivePackFactory { - /** - * Constructs ... - * - * - * - * @param hookEventFacade - * @param handler - */ + private final GitRepositoryHandler handler; + + private ReceivePackFactory wrapped; + + private final GitReceiveHook hook; + @Inject - public GitReceivePackFactory(HookEventFacade hookEventFacade, - GitRepositoryHandler handler) - { - hook = new GitReceiveHook(hookEventFacade, handler); + public GitReceivePackFactory(GitRepositoryHandler handler, HookEventFacade hookEventFacade) { + this.handler = handler; + this.hook = new GitReceiveHook(hookEventFacade, handler); + this.wrapped = new DefaultReceivePackFactory(); } - //~--- methods -------------------------------------------------------------- - - /** - * Method description - * - * - * @param request - * @param repository - * - * @return - * - * @throws ServiceNotAuthorizedException - * @throws ServiceNotEnabledException - */ @Override public ReceivePack create(HttpServletRequest request, Repository repository) - throws ServiceNotEnabledException, ServiceNotAuthorizedException - { - ReceivePack rpack = defaultFactory.create(request, repository); + throws ServiceNotEnabledException, ServiceNotAuthorizedException { + ReceivePack receivePack = wrapped.create(request, repository); + receivePack.setAllowNonFastForwards(isNonFastForwardAllowed()); - rpack.setPreReceiveHook(hook); - rpack.setPostReceiveHook(hook); + receivePack.setPreReceiveHook(hook); + receivePack.setPostReceiveHook(hook); // apply collecting listener, to be able to check which commits are new - CollectingPackParserListener.set(rpack); + CollectingPackParserListener.set(receivePack); - return rpack; + return receivePack; } - //~--- fields --------------------------------------------------------------- + private boolean isNonFastForwardAllowed() { + return ! handler.getConfig().isNonFastForwardDisallowed(); + } - /** Field description */ - private DefaultReceivePackFactory defaultFactory = - new DefaultReceivePackFactory(); - - /** Field description */ - private GitReceiveHook hook; + @VisibleForTesting + void setWrapped(ReceivePackFactory wrapped) { + this.wrapped = wrapped; + } } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitRepositoryResolver.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitRepositoryResolver.java index 7f04bb3a54..6db7d694d5 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitRepositoryResolver.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitRepositoryResolver.java @@ -35,7 +35,6 @@ package sonia.scm.web; //~--- non-JDK imports -------------------------------------------------------- -import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Preconditions; import com.google.inject.Inject; import org.eclipse.jgit.errors.RepositoryNotFoundException; @@ -106,7 +105,7 @@ public class GitRepositoryResolver implements RepositoryResolver string +}; + +class CloneInformation extends React.Component { + + render() { + const { url, repository, t } = this.props; + + return ( +

+

{t("scm-git-plugin.information.clone")}

+
+          git clone {url}
+        
+

{t("scm-git-plugin.information.create")}

+
+          
+            git init {repository.name}
+            
+ echo "# {repository.name}" > README.md +
+ git add README.md +
+ git commit -m "added readme" +
+ git remote add origin {url} +
+ git push -u origin master +
+
+
+

{t("scm-git-plugin.information.replace")}

+
+          
+            git remote add origin {url}
+            
+ git push -u origin master +
+
+
+
+ ); + } + +} + +export default translate("plugins")(CloneInformation); diff --git a/scm-plugins/scm-git-plugin/src/main/js/GitConfigurationForm.js b/scm-plugins/scm-git-plugin/src/main/js/GitConfigurationForm.js index 630984ad87..363779c92b 100644 --- a/scm-plugins/scm-git-plugin/src/main/js/GitConfigurationForm.js +++ b/scm-plugins/scm-git-plugin/src/main/js/GitConfigurationForm.js @@ -8,6 +8,7 @@ import { InputField, Checkbox } from "@scm-manager/ui-components"; type Configuration = { repositoryDirectory?: string, gcExpression?: string, + nonFastForwardDisallowed: boolean, disabled: boolean, _links: Links } @@ -33,29 +34,19 @@ class GitConfigurationForm extends React.Component { this.state = { ...props.initialConfiguration }; } - isValid = () => { - return !!this.state.repositoryDirectory; - }; handleChange = (value: any, name: string) => { this.setState({ [name]: value - }, () => this.props.onConfigurationChange(this.state, this.isValid())); + }, () => this.props.onConfigurationChange(this.state, true)); }; render() { - const { repositoryDirectory, gcExpression, disabled } = this.state; + const { gcExpression, nonFastForwardDisallowed, disabled } = this.state; const { readOnly, t } = this.props; return ( <> - { onChange={this.handleChange} disabled={readOnly} /> + { return (
- <GlobalConfiguration link={link} render={props => <GitConfigurationForm {...props} />}/> + <Configuration link={link} render={props => <GitConfigurationForm {...props} />}/> </div> ); } diff --git a/scm-plugins/scm-git-plugin/src/main/js/GitMergeInformation.js b/scm-plugins/scm-git-plugin/src/main/js/GitMergeInformation.js new file mode 100644 index 0000000000..0e6a9d6af6 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/js/GitMergeInformation.js @@ -0,0 +1,59 @@ +//@flow +import React from "react"; +import type { Repository } from "@scm-manager/ui-types"; +import { translate } from "react-i18next"; + +type Props = { + repository: Repository, + target: string, + source: string, + t: string => string +}; + +class GitMergeInformation extends React.Component<Props> { + render() { + const { source, target, t } = this.props; + + return ( + <div> + <h4>{t("scm-git-plugin.information.merge.heading")}</h4> + {t("scm-git-plugin.information.merge.checkout")} + <pre> + <code>git checkout {target}</code> + </pre> + {t("scm-git-plugin.information.merge.update")} + <pre> + <code> + git pull + </code> + </pre> + {t("scm-git-plugin.information.merge.merge")} + <pre> + <code> + git merge {source} + </code> + </pre> + {t("scm-git-plugin.information.merge.resolve")} + <pre> + <code> + git add <conflict file> + </code> + </pre> + {t("scm-git-plugin.information.merge.commit")} + <pre> + <code> + git commit -m "Merge {source} into {target}" + </code> + </pre> + {t("scm-git-plugin.information.merge.push")} + <pre> + <code> + git push + </code> + </pre> + </div> + ); + } +} + +export default translate("plugins")(GitMergeInformation); diff --git a/scm-plugins/scm-git-plugin/src/main/js/ProtocolInformation.js b/scm-plugins/scm-git-plugin/src/main/js/ProtocolInformation.js index c6aed483e7..e4a1c451d3 100644 --- a/scm-plugins/scm-git-plugin/src/main/js/ProtocolInformation.js +++ b/scm-plugins/scm-git-plugin/src/main/js/ProtocolInformation.js @@ -1,59 +1,108 @@ //@flow import React from "react"; -import { repositories } from "@scm-manager/ui-components"; +import { ButtonGroup, Button } from "@scm-manager/ui-components"; import type { Repository } from "@scm-manager/ui-types"; -import { translate } from "react-i18next"; +import CloneInformation from "./CloneInformation"; +import type { Link } from "@scm-manager/ui-types"; +import injectSheets from "react-jss"; + +const styles = { + protocols: { + position: "relative" + }, + switcher: { + position: "absolute", + top: 0, + right: 0 + } +}; type Props = { repository: Repository, - t: string => string + + // context props + classes: Object } -class ProtocolInformation extends React.Component<Props> { +type State = { + selected?: Link +}; - render() { - const { repository, t } = this.props; - const href = repositories.getProtocolLinkByType(repository, "http"); - if (!href) { - return null; +function selectHttpOrFirst(repository: Repository) { + const protocols = repository._links["protocol"] || []; + + for (let protocol of protocols) { + if (protocol.name === "http") { + return protocol; + } + } + + if (protocols.length > 0) { + return protocols[0]; + } + return undefined; +} + +class ProtocolInformation extends React.Component<Props, State> { + + constructor(props: Props) { + super(props); + this.state = { + selected: selectHttpOrFirst(props.repository) + }; + } + + selectProtocol = (protocol: Link) => { + this.setState({ + selected: protocol + }); + }; + + renderProtocolButton = (protocol: Link) => { + const name = protocol.name || "unknown"; + + let color = null; + + const { selected } = this.state; + if ( selected && protocol.name === selected.name ) { + color = "link is-selected"; } return ( - <div> - <h4>{t("scm-git-plugin.information.clone")}</h4> - <pre> - <code>git clone {href}</code> - </pre> - <h4>{t("scm-git-plugin.information.create")}</h4> - <pre> - <code> - git init {repository.name} - <br /> - echo "# {repository.name}" > README.md - <br /> - git add README.md - <br /> - git commit -m "added readme" - <br /> - git remote add origin {href} - <br /> - git push -u origin master - <br /> - </code> - </pre> - <h4>{t("scm-git-plugin.information.replace")}</h4> - <pre> - <code> - git remote add origin {href} - <br /> - git push -u origin master - <br /> - </code> - </pre> - </div> + <Button color={ color } action={() => this.selectProtocol(protocol)}> + {name.toUpperCase()} + </Button> + ); + }; + + render() { + const { repository, classes } = this.props; + + const protocols = repository._links["protocol"]; + if (!protocols || protocols.length === 0) { + return null; + } + + if (protocols.length === 1) { + return <CloneInformation url={protocols[0].href} repository={repository} />; + } + + const { selected } = this.state; + let cloneInformation = null; + if (selected) { + cloneInformation = <CloneInformation repository={repository} url={selected.href} />; + } + + return ( + <div className={classes.protocols}> + <ButtonGroup className={classes.switcher}> + {protocols.map(this.renderProtocolButton)} + </ButtonGroup> + { cloneInformation } + </div> ); } } -export default translate("plugins")(ProtocolInformation); +export default injectSheets(styles)(ProtocolInformation); diff --git a/scm-plugins/scm-git-plugin/src/main/js/RepositoryConfig.js b/scm-plugins/scm-git-plugin/src/main/js/RepositoryConfig.js new file mode 100644 index 0000000000..aadb58eed6 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/js/RepositoryConfig.js @@ -0,0 +1,157 @@ +// @flow + +import React from "react"; + +import {apiClient, BranchSelector, ErrorPage, Loading, Subtitle, SubmitButton} from "@scm-manager/ui-components"; +import type {Branch, Repository} from "@scm-manager/ui-types"; +import {translate} from "react-i18next"; + +type Props = { + repository: Repository, + t: string => string +}; + +type State = { + loadingBranches: boolean, + loadingDefaultBranch: boolean, + submitPending: boolean, + error?: Error, + branches: Branch[], + selectedBranchName?: string, + defaultBranchChanged: boolean +}; + +const GIT_CONFIG_CONTENT_TYPE = "application/vnd.scmm-gitConfig+json"; + +class RepositoryConfig extends React.Component<Props, State> { + + constructor(props: Props) { + super(props); + + this.state = { + loadingBranches: true, + loadingDefaultBranch: true, + submitPending: false, + branches: [], + defaultBranchChanged: false + }; + } + + componentDidMount() { + const { repository } = this.props; + this.setState({ ...this.state, loadingBranches: true }); + apiClient + .get(repository._links.branches.href) + .then(response => response.json()) + .then(payload => payload._embedded.branches) + .then(branches => + this.setState({ ...this.state, branches, loadingBranches: false }) + ) + .catch(error => this.setState({ ...this.state, error })); + + this.setState({ ...this.state, loadingDefaultBranch: true }); + apiClient + .get(repository._links.configuration.href) + .then(response => response.json()) + .then(payload => payload.defaultBranch) + .then(selectedBranchName => + this.setState({ + ...this.state, + selectedBranchName, + loadingDefaultBranch: false + }) + ) + .catch(error => this.setState({ ...this.state, error })); + } + + branchSelected = (branch: Branch) => { + if (!branch) { + this.setState({ ...this.state, selectedBranchName: undefined, defaultBranchChanged: false}); + return; + } + this.setState({ ...this.state, selectedBranchName: branch.name, defaultBranchChanged: false }); + }; + + submit = (event: Event) => { + event.preventDefault(); + + const { repository } = this.props; + const newConfig = { + defaultBranch: this.state.selectedBranchName + }; + this.setState({ ...this.state, submitPending: true }); + apiClient + .put( + repository._links.configuration.href, + newConfig, + GIT_CONFIG_CONTENT_TYPE + ) + .then(() => + this.setState({ + ...this.state, + submitPending: false, + defaultBranchChanged: true + }) + ) + .catch(error => this.setState({ ...this.state, error })); + }; + + render() { + const { t } = this.props; + const { loadingBranches, loadingDefaultBranch, submitPending, error } = this.state; + + if (error) { + return ( + <ErrorPage + title={t("scm-git-plugin.repo-config.error.title")} + subtitle={t("scm-git-plugin.repo-config.error.subtitle")} + error={error} + /> + ); + } + + if (!(loadingBranches || loadingDefaultBranch)) { + return ( + <> + <Subtitle subtitle={t("scm-git-plugin.repo-config.title")}/> + {this.renderBranchChangedNotification()} + <form onSubmit={this.submit}> + <BranchSelector + label={t("scm-git-plugin.repo-config.default-branch")} + branches={this.state.branches} + selected={this.branchSelected} + selectedBranch={this.state.selectedBranchName} + /> + <SubmitButton + label={t("scm-git-plugin.repo-config.submit")} + loading={submitPending} + disabled={!this.state.selectedBranchName} + /> + </form> + <hr /> + </> + ); + } else { + return <Loading />; + } + } + + renderBranchChangedNotification = () => { + if (this.state.defaultBranchChanged) { + return ( + <div className="notification is-primary"> + <button + className="delete" + onClick={() => + this.setState({ ...this.state, defaultBranchChanged: false }) + } + /> + {this.props.t("scm-git-plugin.repo-config.success")} + </div> + ); + } + return null; + }; +} + +export default translate("plugins")(RepositoryConfig); diff --git a/scm-plugins/scm-git-plugin/src/main/js/index.js b/scm-plugins/scm-git-plugin/src/main/js/index.js index 3f91405509..43e3950beb 100644 --- a/scm-plugins/scm-git-plugin/src/main/js/index.js +++ b/scm-plugins/scm-git-plugin/src/main/js/index.js @@ -1,10 +1,13 @@ //@flow -import { binder } from "@scm-manager/ui-extensions"; +import React from "react"; +import {binder} from "@scm-manager/ui-extensions"; import ProtocolInformation from "./ProtocolInformation"; import GitAvatar from "./GitAvatar"; -import { ConfigurationBinder as cfgBinder } from "@scm-manager/ui-components"; +import {ConfigurationBinder as cfgBinder} from "@scm-manager/ui-components"; import GitGlobalConfiguration from "./GitGlobalConfiguration"; +import GitMergeInformation from "./GitMergeInformation"; +import RepositoryConfig from "./RepositoryConfig"; // repository @@ -12,9 +15,24 @@ const gitPredicate = (props: Object) => { return props.repository && props.repository.type === "git"; }; -binder.bind("repos.repository-details.information", ProtocolInformation, gitPredicate); +binder.bind( + "repos.repository-details.information", + ProtocolInformation, + gitPredicate +); +binder.bind( + "repos.repository-merge.information", + GitMergeInformation, + gitPredicate +); binder.bind("repos.repository-avatar", GitAvatar, gitPredicate); -// global config +binder.bind("repo-config.route", RepositoryConfig, gitPredicate); -cfgBinder.bindGlobal("/git", "scm-git-plugin.config.link", "gitConfig", GitGlobalConfiguration); +// global config +cfgBinder.bindGlobal( + "/git", + "scm-git-plugin.config.link", + "gitConfig", + GitGlobalConfiguration +); diff --git a/scm-plugins/scm-git-plugin/src/main/resources/META-INF/scm/permissions.xml b/scm-plugins/scm-git-plugin/src/main/resources/META-INF/scm/permissions.xml new file mode 100644 index 0000000000..4823cb5f4f --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/resources/META-INF/scm/permissions.xml @@ -0,0 +1,43 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + + Copyright (c) 2010, Sebastian Sdorra + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + 3. Neither the name of SCM-Manager; nor the names of its + contributors may be used to endorse or promote products derived from this + software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY + DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.dd7s + + http://bitbucket.org/sdorra/scm-manager + + +--> +<permissions> + + <permission> + <value>configuration:read,write:git</value> + </permission> + <permission> + <value>repository:git:*</value> + </permission> + +</permissions> diff --git a/scm-plugins/scm-git-plugin/src/main/resources/META-INF/scm/repository-permissions.xml b/scm-plugins/scm-git-plugin/src/main/resources/META-INF/scm/repository-permissions.xml new file mode 100644 index 0000000000..6c93929625 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/resources/META-INF/scm/repository-permissions.xml @@ -0,0 +1,7 @@ +<repository-permissions> + <verbs> + <verb>git</verb> + </verbs> + <roles> + </roles> +</repository-permissions> diff --git a/scm-plugins/scm-git-plugin/src/main/resources/locales/de/plugins.json b/scm-plugins/scm-git-plugin/src/main/resources/locales/de/plugins.json index 1dc0e254c2..578d859c8e 100644 --- a/scm-plugins/scm-git-plugin/src/main/resources/locales/de/plugins.json +++ b/scm-plugins/scm-git-plugin/src/main/resources/locales/de/plugins.json @@ -1,9 +1,66 @@ { "scm-git-plugin": { "information": { - "clone" : "Repository Klonen", - "create" : "Neue Repository erstellen", - "replace" : "Eine existierende Repository aktualisieren" + "clone" : "Repository klonen", + "create" : "Neues Repository erstellen", + "replace" : "Ein bestehendes Repository aktualisieren", + "merge": { + "heading": "Merge des Source Branch in den Target Branch", + "checkout": "1. Sicherstellen, dass der Workspace aufgeräumt ist und der Target Branch ausgecheckt wurde.", + "update": "2. Update Workspace", + "merge": "3. Merge Source Branch", + "resolve": "4. Merge Konflikte auflösen und korrigierte Dateien dem Index hinzufügen.", + "commit": "5. Commit", + "push": "6. Push des Merge" + } + }, + "config": { + "link": "Git", + "title": "Git Konfiguration", + "gcExpression": "GC Cron Ausdruck", + "gcExpressionHelpText": "Benutze Quartz Cron Ausdrücke (SECOND MINUTE HOUR DAYOFMONTH MONTH DAYOFWEEK), um git GC regelmäßig auszuführen.", + "nonFastForwardDisallowed": "Deaktiviere \"Non Fast-Forward\"", + "nonFastForwardDisallowedHelpText": "Git Pushes ablehnen, die nicht \"fast-forward\" sind, wie \"--force\".", + "disabled": "Deaktiviert", + "disabledHelpText": "Aktiviere oder deaktiviere das Git Plugin", + "submit": "Speichern" + }, + "repo-config": { + "link": "Konfiguration", + "title": "Git Einstellungen", + "default-branch": "Standard Branch", + "submit": "Speichern", + "error": { + "title": "Fehler", + "subtitle": "Ein Fehler ist aufgetreten." + }, + "success": "Der standard Branch wurde geändert!" + } + }, + "permissions" : { + "configuration": { + "read,write": { + "git": { + "displayName": "Git Konfiguration ändern", + "description": "Darf die git Konfiguration verändern." + } + } + }, + "repository": { + "git": { + "*": { + "displayName": "Repository-spezifische Git Konfiguration ändern", + "description": "Darf die git Konfiguration für alle Repositories verändern." + } + } + } + }, + "verbs": { + "repository": { + "git": { + "displayName": "Git konfigurieren", + "description": "Darf die git Konfiguration für dieses Repository verändern." + } } } } diff --git a/scm-plugins/scm-git-plugin/src/main/resources/locales/en/plugins.json b/scm-plugins/scm-git-plugin/src/main/resources/locales/en/plugins.json index 8cb801ac2c..bea0a08dc9 100644 --- a/scm-plugins/scm-git-plugin/src/main/resources/locales/en/plugins.json +++ b/scm-plugins/scm-git-plugin/src/main/resources/locales/en/plugins.json @@ -1,20 +1,66 @@ { "scm-git-plugin": { "information": { - "clone" : "Clone the repository", - "create" : "Create a new repository", - "replace" : "Push an existing repository" + "clone": "Clone the repository", + "create": "Create a new repository", + "replace": "Push an existing repository", + "merge": { + "heading": "How to merge source branch into target branch", + "checkout": "1. Make sure your workspace is clean and checkout target branch", + "update": "2. Update workspace", + "merge": "3. Merge source branch", + "resolve": "4. Resolve merge conflicts and add corrected files to index", + "commit": "5. Commit", + "push": "6. Push your merge" + } }, "config": { "link": "Git", "title": "Git Configuration", - "directory": "Repository Directory", - "directoryHelpText": "Location of the Git repositories.", "gcExpression": "GC Cron Expression", "gcExpressionHelpText": "Use Quartz Cron Expressions (SECOND MINUTE HOUR DAYOFMONTH MONTH DAYOFWEEK) to run git gc in intervals.", + "nonFastForwardDisallowed": "Disallow Non Fast-Forward", + "nonFastForwardDisallowedHelpText": "Reject git pushes which are non fast-forward such as --force.", "disabled": "Disabled", "disabledHelpText": "Enable or disable the Git plugin", "submit": "Submit" + }, + "repo-config": { + "link": "Configuration", + "title": "Git Settings", + "default-branch": "Default branch", + "submit": "Submit", + "error": { + "title": "Error", + "subtitle": "Something went wrong" + }, + "success": "Default branch changed!" + } + }, + "permissions" : { + "configuration": { + "read,write": { + "git": { + "displayName": "Modify git configuration", + "description": "May change the git configuration" + } + } + }, + "repository": { + "git": { + "*": { + "displayName": "Modify repository specific git configuration", + "description": "May change the git configuration for repositories" + } + } + } + }, + "verbs": { + "repository": { + "git": { + "displayName": "configure Git", + "description": "May change the git configuration for this repository" + } } } } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/api/v2/resources/GitConfigDtoToGitConfigMapperTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/api/v2/resources/GitConfigDtoToGitConfigMapperTest.java index 968b6b7f6f..6a05875aa9 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/api/v2/resources/GitConfigDtoToGitConfigMapperTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/api/v2/resources/GitConfigDtoToGitConfigMapperTest.java @@ -3,13 +3,10 @@ package sonia.scm.api.v2.resources; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.InjectMocks; -import org.mockito.runners.MockitoJUnitRunner; +import org.mockito.junit.MockitoJUnitRunner; import sonia.scm.repository.GitConfig; -import java.io.File; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; +import static org.junit.Assert.*; @RunWith(MockitoJUnitRunner.class) public class GitConfigDtoToGitConfigMapperTest { @@ -22,15 +19,15 @@ public class GitConfigDtoToGitConfigMapperTest { GitConfigDto dto = createDefaultDto(); GitConfig config = mapper.map(dto); assertEquals("express", config.getGcExpression()); - assertEquals("repository/directory", config.getRepositoryDirectory().getPath()); assertFalse(config.isDisabled()); + assertTrue(config.isNonFastForwardDisallowed()); } private GitConfigDto createDefaultDto() { GitConfigDto gitConfigDto = new GitConfigDto(); gitConfigDto.setGcExpression("express"); gitConfigDto.setDisabled(false); - gitConfigDto.setRepositoryDirectory(new File("repository/directory")); + gitConfigDto.setNonFastForwardDisallowed(true); return gitConfigDto; } } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/api/v2/resources/GitConfigInIndexResourceTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/api/v2/resources/GitConfigInIndexResourceTest.java index 665be19788..d6519a0013 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/api/v2/resources/GitConfigInIndexResourceTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/api/v2/resources/GitConfigInIndexResourceTest.java @@ -15,6 +15,7 @@ import java.net.URI; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; @SubjectAware(configuration = "classpath:sonia/scm/configuration/shiro.ini") public class GitConfigInIndexResourceTest { @@ -50,7 +51,7 @@ public class GitConfigInIndexResourceTest { gitConfigInIndexResource.enrich(context); - assertFalse(root.get("_links").iterator().hasNext()); + assertTrue(root.get("_links").iterator().hasNext()); } @Test diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/api/v2/resources/GitConfigResourceTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/api/v2/resources/GitConfigResourceTest.java index 1ebe7fc98b..8e657b1050 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/api/v2/resources/GitConfigResourceTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/api/v2/resources/GitConfigResourceTest.java @@ -1,7 +1,5 @@ package sonia.scm.api.v2.resources; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; import com.github.sdorra.shiro.ShiroRule; import com.github.sdorra.shiro.SubjectAware; import org.jboss.resteasy.core.Dispatcher; @@ -14,22 +12,34 @@ import org.junit.Test; import org.junit.rules.ExpectedException; import org.junit.runner.RunWith; import org.mockito.Answers; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.runners.MockitoJUnitRunner; +import org.mockito.Spy; +import org.mockito.junit.MockitoJUnitRunner; import sonia.scm.repository.GitConfig; +import sonia.scm.repository.GitRepositoryConfig; import sonia.scm.repository.GitRepositoryHandler; +import sonia.scm.repository.NamespaceAndName; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryManager; +import sonia.scm.store.ConfigurationStore; +import sonia.scm.store.ConfigurationStoreFactory; import sonia.scm.web.GitVndMediaType; import javax.servlet.http.HttpServletResponse; -import java.io.File; -import java.io.IOException; +import java.io.UnsupportedEncodingException; import java.net.URI; import java.net.URISyntaxException; +import static com.google.inject.util.Providers.of; import static junit.framework.TestCase.assertTrue; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.when; @SubjectAware( @@ -55,33 +65,50 @@ public class GitConfigResourceTest { @Mock(answer = Answers.RETURNS_DEEP_STUBS) private ScmPathInfoStore scmPathInfoStore; + @Mock + private RepositoryManager repositoryManager; + @InjectMocks private GitConfigToGitConfigDtoMapperImpl configToDtoMapper; + @InjectMocks + private GitRepositoryConfigMapperImpl repositoryConfigMapper; @Mock private GitRepositoryHandler repositoryHandler; + @Mock(answer = Answers.CALLS_REAL_METHODS) + private ConfigurationStoreFactory configurationStoreFactory; + @Spy + private ConfigurationStore<Object> configurationStore; + @Captor + private ArgumentCaptor<Object> configurationStoreCaptor; + @Before public void prepareEnvironment() { GitConfig gitConfig = createConfiguration(); when(repositoryHandler.getConfig()).thenReturn(gitConfig); - GitConfigResource gitConfigResource = new GitConfigResource(dtoToConfigMapper, configToDtoMapper, repositoryHandler); + GitRepositoryConfigResource gitRepositoryConfigResource = new GitRepositoryConfigResource(repositoryConfigMapper, repositoryManager, new GitRepositoryConfigStoreProvider(configurationStoreFactory)); + GitConfigResource gitConfigResource = new GitConfigResource(dtoToConfigMapper, configToDtoMapper, repositoryHandler, of(gitRepositoryConfigResource)); dispatcher.getRegistry().addSingletonResource(gitConfigResource); when(scmPathInfoStore.get().getApiRestUri()).thenReturn(baseUri); } + @Before + public void initConfigStore() { + when(configurationStoreFactory.getStore(any())).thenReturn(configurationStore); + doNothing().when(configurationStore).set(configurationStoreCaptor.capture()); + } + @Test @SubjectAware(username = "readWrite") - public void shouldGetGitConfig() throws URISyntaxException, IOException { + public void shouldGetGitConfig() throws URISyntaxException, UnsupportedEncodingException { MockHttpResponse response = get(); assertEquals(HttpServletResponse.SC_OK, response.getStatus()); String responseString = response.getContentAsString(); - ObjectNode responseJson = new ObjectMapper().readValue(responseString, ObjectNode.class); assertTrue(responseString.contains("\"disabled\":false")); - assertTrue(responseJson.get("repositoryDirectory").asText().endsWith("repository/directory")); assertTrue(responseString.contains("\"gcExpression\":\"valid Git GC Cron Expression\"")); assertTrue(responseString.contains("\"self\":{\"href\":\"/v2/config/git")); assertTrue(responseString.contains("\"update\":{\"href\":\"/v2/config/git")); @@ -89,7 +116,7 @@ public class GitConfigResourceTest { @Test @SubjectAware(username = "readWrite") - public void shouldGetGitConfigEvenWhenItsEmpty() throws URISyntaxException, IOException { + public void shouldGetGitConfigEvenWhenItsEmpty() throws URISyntaxException, UnsupportedEncodingException { when(repositoryHandler.getConfig()).thenReturn(null); MockHttpResponse response = get(); @@ -100,7 +127,7 @@ public class GitConfigResourceTest { @Test @SubjectAware(username = "readOnly") - public void shouldGetGitConfigWithoutUpdateLink() throws URISyntaxException { + public void shouldGetGitConfigWithoutUpdateLink() throws URISyntaxException, UnsupportedEncodingException { MockHttpResponse response = get(); assertEquals(HttpServletResponse.SC_OK, response.getStatus()); @@ -125,12 +152,84 @@ public class GitConfigResourceTest { @Test @SubjectAware(username = "readOnly") - public void shouldNotUpdateConfigWhenNotAuthorized() throws URISyntaxException, IOException { + public void shouldNotUpdateConfigWhenNotAuthorized() throws URISyntaxException { thrown.expectMessage("Subject does not have permission [configuration:write:git]"); put(); } + @Test + @SubjectAware(username = "writeOnly") + public void shouldReadDefaultRepositoryConfig() throws URISyntaxException, UnsupportedEncodingException { + when(repositoryManager.get(new NamespaceAndName("space", "X"))).thenReturn(new Repository("id", "git", "space", "X")); + + MockHttpRequest request = MockHttpRequest.get("/" + GitConfigResource.GIT_CONFIG_PATH_V2 + "/space/X"); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(HttpServletResponse.SC_OK, response.getStatus()); + assertThat(response.getContentAsString()) + .contains("\"defaultBranch\":null") + .contains("self") + .contains("update"); + } + + @Test + @SubjectAware(username = "readOnly") + public void shouldNotHaveUpdateLinkForReadOnlyUser() throws URISyntaxException, UnsupportedEncodingException { + when(repositoryManager.get(new NamespaceAndName("space", "X"))).thenReturn(new Repository("id", "git", "space", "X")); + + MockHttpRequest request = MockHttpRequest.get("/" + GitConfigResource.GIT_CONFIG_PATH_V2 + "/space/X"); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(HttpServletResponse.SC_OK, response.getStatus()); + assertThat(response.getContentAsString()) + .contains("\"defaultBranch\":null") + .contains("self") + .doesNotContain("update"); + } + + @Test + @SubjectAware(username = "writeOnly") + public void shouldReadStoredRepositoryConfig() throws URISyntaxException, UnsupportedEncodingException { + when(repositoryManager.get(new NamespaceAndName("space", "X"))).thenReturn(new Repository("id", "git", "space", "X")); + GitRepositoryConfig gitRepositoryConfig = new GitRepositoryConfig(); + gitRepositoryConfig.setDefaultBranch("test"); + when(configurationStore.get()).thenReturn(gitRepositoryConfig); + + MockHttpRequest request = MockHttpRequest.get("/" + GitConfigResource.GIT_CONFIG_PATH_V2 + "/space/X"); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(HttpServletResponse.SC_OK, response.getStatus()); + assertThat(response.getContentAsString()) + .contains("\"defaultBranch\":\"test\""); + } + + @Test + @SubjectAware(username = "writeOnly") + public void shouldStoreChangedRepositoryConfig() throws URISyntaxException { + when(repositoryManager.get(new NamespaceAndName("space", "X"))).thenReturn(new Repository("id", "git", "space", "X")); + + MockHttpRequest request = MockHttpRequest + .put("/" + GitConfigResource.GIT_CONFIG_PATH_V2 + "/space/X") + .contentType(GitVndMediaType.GIT_REPOSITORY_CONFIG) + .content("{\"defaultBranch\": \"new\"}".getBytes()); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(HttpServletResponse.SC_NO_CONTENT, response.getStatus()); + assertThat(configurationStoreCaptor.getValue()) + .isInstanceOfSatisfying(GitRepositoryConfig.class, x -> { }) + .extracting("defaultBranch") + .containsExactly("new"); + } + private MockHttpResponse get() throws URISyntaxException { MockHttpRequest request = MockHttpRequest.get("/" + GitConfigResource.GIT_CONFIG_PATH_V2); MockHttpResponse response = new MockHttpResponse(); @@ -152,9 +251,6 @@ public class GitConfigResourceTest { GitConfig config = new GitConfig(); config.setGcExpression("valid Git GC Cron Expression"); config.setDisabled(false); - config.setRepositoryDirectory(new File("repository/directory")); return config; } - } - diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/api/v2/resources/GitConfigToGitConfigDtoMapperTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/api/v2/resources/GitConfigToGitConfigDtoMapperTest.java index 82c85029a3..62fa8d33b4 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/api/v2/resources/GitConfigToGitConfigDtoMapperTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/api/v2/resources/GitConfigToGitConfigDtoMapperTest.java @@ -11,10 +11,9 @@ import org.junit.runner.RunWith; import org.mockito.Answers; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.runners.MockitoJUnitRunner; +import org.mockito.junit.MockitoJUnitRunner; import sonia.scm.repository.GitConfig; -import java.io.File; import java.net.URI; import static org.junit.Assert.assertEquals; @@ -60,7 +59,6 @@ public class GitConfigToGitConfigDtoMapperTest { assertEquals("express", dto.getGcExpression()); assertFalse(dto.isDisabled()); - assertEquals("repository/directory", dto.getRepositoryDirectory().getPath()); assertEquals(expectedBaseUri.toString(), dto.getLinks().getLinkBy("self").get().getHref()); assertEquals(expectedBaseUri.toString(), dto.getLinks().getLinkBy("update").get().getHref()); } @@ -79,7 +77,6 @@ public class GitConfigToGitConfigDtoMapperTest { private GitConfig createConfiguration() { GitConfig config = new GitConfig(); config.setDisabled(false); - config.setRepositoryDirectory(new File("repository/directory")); config.setGcExpression("express"); return config; } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/api/v2/resources/GitRepositoryConfigEnricherTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/api/v2/resources/GitRepositoryConfigEnricherTest.java new file mode 100644 index 0000000000..a1e349dd57 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/api/v2/resources/GitRepositoryConfigEnricherTest.java @@ -0,0 +1,149 @@ +package sonia.scm.api.v2.resources; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.io.Resources; +import com.google.inject.Provider; +import com.google.inject.util.Providers; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.repository.NamespaceAndName; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryManager; +import sonia.scm.web.JsonEnricherContext; +import sonia.scm.web.VndMediaType; + +import javax.ws.rs.core.MediaType; +import java.io.IOException; +import java.net.URI; +import java.net.URL; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class GitRepositoryConfigEnricherTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + private GitRepositoryConfigEnricher linkEnricher; + private JsonNode rootNode; + @Mock + private RepositoryManager manager; + + @BeforeEach + void globalSetUp() { + ScmPathInfoStore pathInfoStore = new ScmPathInfoStore(); + pathInfoStore.set(() -> URI.create("/")); + Provider<ScmPathInfoStore> pathInfoStoreProvider = Providers.of(pathInfoStore); + + linkEnricher = new GitRepositoryConfigEnricher(pathInfoStoreProvider, objectMapper, manager); + } + + @Nested + class ForSingleRepository { + @BeforeEach + void setUp() throws IOException { + URL resource = Resources.getResource("sonia/scm/repository/repository-001.json"); + rootNode = objectMapper.readTree(resource); + + when(manager.get(new NamespaceAndName("scmadmin", "web-resources"))).thenReturn(new Repository("id", "git", "scmadmin", "web-resources")); + } + + @Test + void shouldEnrichGitRepositories() { + JsonEnricherContext context = new JsonEnricherContext( + URI.create("/"), + MediaType.valueOf(VndMediaType.REPOSITORY), + rootNode + ); + + linkEnricher.enrich(context); + + String configLink = context.getResponseEntity() + .get("_links") + .get("configuration") + .get("href") + .asText(); + + assertThat(configLink).isEqualTo("/v2/config/git/scmadmin/web-resources"); + } + + @Test + void shouldNotEnrichOtherRepositories() { + when(manager.get(new NamespaceAndName("scmadmin", "web-resources"))).thenReturn(new Repository("id", "hg", "scmadmin", "web-resources")); + + JsonEnricherContext context = new JsonEnricherContext( + URI.create("/"), + MediaType.valueOf(VndMediaType.REPOSITORY), + rootNode + ); + + linkEnricher.enrich(context); + + JsonNode configLink = context.getResponseEntity() + .get("_links") + .get("configuration"); + + assertThat(configLink).isNull(); + } + } + + @Nested + class ForRepositoryCollection { + @BeforeEach + void setUp() throws IOException { + URL resource = Resources.getResource("sonia/scm/repository/repository-collection-001.json"); + rootNode = objectMapper.readTree(resource); + + when(manager.get(new NamespaceAndName("scmadmin", "web-resources"))).thenReturn(new Repository("id", "git", "scmadmin", "web-resources")); + } + + @Test + void shouldEnrichAllRepositories() { + JsonEnricherContext context = new JsonEnricherContext( + URI.create("/"), + MediaType.valueOf(VndMediaType.REPOSITORY_COLLECTION), + rootNode + ); + + linkEnricher.enrich(context); + + context.getResponseEntity() + .get("_embedded") + .withArray("repositories") + .elements() + .forEachRemaining(node -> { + String configLink = node + .get("_links") + .get("configuration") + .get("href") + .asText(); + + assertThat(configLink).isEqualTo("/v2/config/git/scmadmin/web-resources"); + }); + } + } + + @Test + void shouldNotModifyObjectsWithUnsupportedMediaType() throws IOException { + URL resource = Resources.getResource("sonia/scm/repository/repository-001.json"); + rootNode = objectMapper.readTree(resource); + JsonEnricherContext context = new JsonEnricherContext( + URI.create("/"), + MediaType.valueOf(VndMediaType.USER), + rootNode + ); + + linkEnricher.enrich(context); + + boolean hasNewPullRequestLink = context.getResponseEntity() + .get("_links") + .has("configuration"); + + assertThat(hasNewPullRequestLink).isFalse(); + } +} diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/GitRepositoryResolverTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitHeadModifierTest.java similarity index 54% rename from scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/GitRepositoryResolverTest.java rename to scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitHeadModifierTest.java index 6eabd4f5b3..3362c8a22b 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/GitRepositoryResolverTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitHeadModifierTest.java @@ -1,5 +1,5 @@ /** - * Copyright (c) 2010, Sebastian Sdorra + * Copyright (c) 2014, Sebastian Sdorra * All rights reserved. * * Redistribution and use in source and binary forms, with or without @@ -28,9 +28,12 @@ * http://bitbucket.org/sdorra/scm-manager * */ -package sonia.scm.web; +package sonia.scm.repository; -import org.junit.Before; +import com.google.common.base.Charsets; +import com.google.common.io.Files; +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.errors.GitAPIException; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; @@ -38,73 +41,60 @@ import org.junit.runner.RunWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; -import sonia.scm.repository.GitConfig; -import sonia.scm.repository.GitRepositoryHandler; import java.io.File; import java.io.IOException; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; +import static org.junit.Assert.*; +import static org.mockito.Mockito.when; -/** - * Unit tests for {@link GitRepositoryResolver}. - * - * @author Sebastian Sdorra - */ @RunWith(MockitoJUnitRunner.class) -public class GitRepositoryResolverTest { - +public class GitHeadModifierTest { + @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); - - private File parentDirectory; - + @Mock - private GitRepositoryHandler handler; - + private GitRepositoryHandler repositoryHandler; + @InjectMocks - private GitRepositoryResolver resolver; - - @Before - public void setUp() throws IOException { - parentDirectory = temporaryFolder.newFolder(); - - GitConfig config = new GitConfig(); - config.setRepositoryDirectory(parentDirectory); - } - + private GitHeadModifier modifier; + @Test - public void testFindRepositoryWithoutDotGit() { - createRepositories("a", "ab"); - - File directory = resolver.findRepository(parentDirectory, "a"); - assertNotNull(directory); - assertEquals("a", directory.getName()); - - directory = resolver.findRepository(parentDirectory, "ab"); - assertNotNull(directory); - assertEquals("ab", directory.getName()); + public void testEnsure() throws IOException, GitAPIException { + Repository repository = RepositoryTestData.createHeartOfGold("git"); + File headFile = create(repository, "master"); + + boolean result = modifier.ensure(repository, "develop"); + + assertEquals("ref: refs/heads/develop", Files.readFirstLine(headFile, Charsets.UTF_8)); + assertTrue(result); } - + @Test - public void testFindRepositoryWithDotGit() { - createRepositories("a", "ab"); - - File directory = resolver.findRepository(parentDirectory, "a.git"); - assertNotNull(directory); - assertEquals("a", directory.getName()); - - directory = resolver.findRepository(parentDirectory, "ab.git"); - assertNotNull(directory); - assertEquals("ab", directory.getName()); + public void testEnsureWithSameBranch() throws IOException, GitAPIException { + Repository repository = RepositoryTestData.createHeartOfGold("git"); + create(repository, "develop"); + + boolean result = modifier.ensure(repository, "develop"); + + assertFalse(result); } - - private void createRepositories(String... names) { - for (String name : names) { - assertTrue(new File(parentDirectory, name).mkdirs()); - } + + private File create(Repository repository, String head) throws IOException, GitAPIException { + File directory = temporaryFolder.newFolder(); + + Git.init() + .setBare(true) + .setDirectory(directory) + .call(); + + File headFile = new File(directory, "HEAD"); + Files.write(String.format("ref: refs/heads/%s\n", head), headFile, Charsets.UTF_8); + + when(repositoryHandler.getDirectory(repository.getId())).thenReturn(directory); + + return headFile; } - + } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitRepositoryHandlerTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitRepositoryHandlerTest.java index ad731280c5..66ec320067 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitRepositoryHandlerTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitRepositoryHandlerTest.java @@ -33,11 +33,11 @@ package sonia.scm.repository; //~--- non-JDK imports -------------------------------------------------------- +import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; -import sonia.scm.io.DefaultFileSystem; import sonia.scm.schedule.Scheduler; import sonia.scm.store.ConfigurationStoreFactory; @@ -45,14 +45,15 @@ import java.io.File; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; //~--- JDK imports ------------------------------------------------------------ /** - * * @author Sebastian Sdorra */ -@RunWith(MockitoJUnitRunner.class) +@RunWith(MockitoJUnitRunner.Silent.class) public class GitRepositoryHandlerTest extends SimpleRepositoryHandlerTestBase { @Mock @@ -64,6 +65,7 @@ public class GitRepositoryHandlerTest extends SimpleRepositoryHandlerTestBase { @Mock private GitWorkdirFactory gitWorkdirFactory; + @Override protected void checkDirectory(File directory) { File head = new File(directory, "HEAD"); @@ -82,18 +84,21 @@ public class GitRepositoryHandlerTest extends SimpleRepositoryHandlerTestBase { assertTrue(refs.isDirectory()); } + @Before + public void initFactory() { + when(factory.withType(any())).thenCallRealMethod(); + } @Override protected RepositoryHandler createRepositoryHandler(ConfigurationStoreFactory factory, + RepositoryLocationResolver locationResolver, File directory) { GitRepositoryHandler repositoryHandler = new GitRepositoryHandler(factory, - new DefaultFileSystem(), scheduler, gitWorkdirFactory); - + scheduler, locationResolver, gitWorkdirFactory, null); repositoryHandler.init(contextProvider); GitConfig config = new GitConfig(); - config.setRepositoryDirectory(directory); // TODO fix event bus exception repositoryHandler.setConfig(config); @@ -103,15 +108,15 @@ public class GitRepositoryHandlerTest extends SimpleRepositoryHandlerTestBase { @Test public void getDirectory() { GitRepositoryHandler repositoryHandler = new GitRepositoryHandler(factory, - new DefaultFileSystem(), scheduler, gitWorkdirFactory); + scheduler, locationResolver, gitWorkdirFactory, null); + GitConfig config = new GitConfig(); + config.setDisabled(false); + config.setGcExpression("gc exp"); - GitConfig gitConfig = new GitConfig(); - gitConfig.setRepositoryDirectory(new File("/path")); - repositoryHandler.setConfig(gitConfig); + repositoryHandler.setConfig(config); - Repository repository = new Repository("id", "git", "Space", "Name"); - - File path = repositoryHandler.getDirectory(repository); - assertEquals("/path/id", path.getAbsolutePath()); + initRepository(); + File path = repositoryHandler.getDirectory(repository.getId()); + assertEquals(repoPath.toString() + File.separator + AbstractSimpleRepositoryHandler.REPOSITORIES_NATIVE_DIRECTORY, path.getAbsolutePath()); } } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitRepositoryModifyListenerTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitRepositoryModifyListenerTest.java deleted file mode 100644 index a542674484..0000000000 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitRepositoryModifyListenerTest.java +++ /dev/null @@ -1,163 +0,0 @@ -/** - * Copyright (c) 2014, Sebastian Sdorra - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * 3. Neither the name of SCM-Manager; nor the names of its - * contributors may be used to endorse or promote products derived from this - * software without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY - * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON - * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - * - * http://bitbucket.org/sdorra/scm-manager - * - */ - -package sonia.scm.repository; - -import org.junit.Test; -import static org.junit.Assert.*; -import org.junit.Before; -import sonia.scm.HandlerEventType; - -/** - * Unit tests for {@link GitRepositoryModifyListener}. - * - * @author Sebastian Sdorra - */ -public class GitRepositoryModifyListenerTest { - - private GitRepositoryModifyTestListener repositoryModifyListener; - - /** - * Set up test object. - */ - @Before - public void setUpObjectUnderTest(){ - repositoryModifyListener = new GitRepositoryModifyTestListener(); - } - - /** - * Tests happy path. - */ - @Test - public void testHandleEvent() { - Repository old = RepositoryTestData.createHeartOfGold("git"); - old.setProperty(GitConstants.PROPERTY_DEFAULT_BRANCH, "master"); - Repository current = RepositoryTestData.createHeartOfGold("git"); - current.setProperty(GitConstants.PROPERTY_DEFAULT_BRANCH, "develop"); - - RepositoryModificationEvent event = new RepositoryModificationEvent(HandlerEventType.MODIFY, current, old); - repositoryModifyListener.handleEvent(event); - - assertNotNull(repositoryModifyListener.repository); - assertSame(current, repositoryModifyListener.repository); - } - - /** - * Tests with new default branch. - */ - @Test - public void testWithNewDefaultBranch() { - Repository old = RepositoryTestData.createHeartOfGold("git"); - Repository current = RepositoryTestData.createHeartOfGold("git"); - current.setProperty(GitConstants.PROPERTY_DEFAULT_BRANCH, "develop"); - - RepositoryModificationEvent event = new RepositoryModificationEvent(HandlerEventType.MODIFY, current, old); - repositoryModifyListener.handleEvent(event); - - assertNotNull(repositoryModifyListener.repository); - assertSame(current, repositoryModifyListener.repository); - } - - /** - * Tests with non git repositories. - */ - @Test - public void testNonGitRepository(){ - Repository old = RepositoryTestData.createHeartOfGold("hg"); - old.setProperty(GitConstants.PROPERTY_DEFAULT_BRANCH, "master"); - Repository current = RepositoryTestData.createHeartOfGold("hg"); - current.setProperty(GitConstants.PROPERTY_DEFAULT_BRANCH, "develop"); - - RepositoryModificationEvent event = new RepositoryModificationEvent(HandlerEventType.MODIFY, current, old); - repositoryModifyListener.handleEvent(event); - - assertNull(repositoryModifyListener.repository); - } - - /** - * Tests without default branch. - */ - @Test - public void testWithoutDefaultBranch(){ - Repository old = RepositoryTestData.createHeartOfGold("git"); - Repository current = RepositoryTestData.createHeartOfGold("git"); - - RepositoryModificationEvent event = new RepositoryModificationEvent(HandlerEventType.MODIFY, current, old); - repositoryModifyListener.handleEvent(event); - - assertNull(repositoryModifyListener.repository); - } - - /** - * Tests with non modify event. - */ - @Test - public void testNonModifyEvent(){ - Repository old = RepositoryTestData.createHeartOfGold("git"); - old.setProperty(GitConstants.PROPERTY_DEFAULT_BRANCH, "master"); - Repository current = RepositoryTestData.createHeartOfGold("git"); - current.setProperty(GitConstants.PROPERTY_DEFAULT_BRANCH, "develop"); - - RepositoryModificationEvent event = new RepositoryModificationEvent(HandlerEventType.CREATE, current, old); - repositoryModifyListener.handleEvent(event); - - assertNull(repositoryModifyListener.repository); - } - - /** - * Tests with non git repositories. - */ - @Test - public void testNoModification(){ - Repository old = RepositoryTestData.createHeartOfGold("git"); - old.setProperty(GitConstants.PROPERTY_DEFAULT_BRANCH, "master"); - Repository current = RepositoryTestData.createHeartOfGold("git"); - current.setProperty(GitConstants.PROPERTY_DEFAULT_BRANCH, "master"); - - RepositoryModificationEvent event = new RepositoryModificationEvent(HandlerEventType.MODIFY, current, old); - repositoryModifyListener.handleEvent(event); - - assertNull(repositoryModifyListener.repository); - } - - private static class GitRepositoryModifyTestListener extends GitRepositoryModifyListener { - - private Repository repository; - - @Override - protected void sendClearRepositoryCacheEvent(Repository repository) { - this.repository = repository; - } - - } - - -} \ No newline at end of file diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/api/GitHookTagProviderTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/api/GitHookTagProviderTest.java index 56939b29a7..46f28692f5 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/api/GitHookTagProviderTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/api/GitHookTagProviderTest.java @@ -32,20 +32,23 @@ package sonia.scm.repository.api; import com.google.common.collect.Lists; -import java.util.List; import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.transport.ReceiveCommand; -import org.junit.Test; -import static org.junit.Assert.*; -import static org.mockito.Mockito.*; -import static org.hamcrest.Matchers.*; import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; -import org.mockito.stubbing.OngoingStubbing; import sonia.scm.repository.Tag; +import java.util.List; + +import static org.hamcrest.Matchers.empty; +import static org.junit.Assert.*; +import static org.mockito.Mockito.when; + /** * Unit tests for {@link GitHookTagProvider}. * @@ -54,6 +57,11 @@ import sonia.scm.repository.Tag; @RunWith(MockitoJUnitRunner.class) public class GitHookTagProviderTest { + private static final String ZERO = ObjectId.zeroId().getName(); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + @Mock private ReceiveCommand command; @@ -73,7 +81,7 @@ public class GitHookTagProviderTest { @Test public void testGetCreatedTags() { String revision = "b2002b64013e54b78eac251df0672bd5d6a83aa7"; - GitHookTagProvider provider = createProvider(ReceiveCommand.Type.CREATE, "refs/tags/1.0.0", revision); + GitHookTagProvider provider = createProvider(ReceiveCommand.Type.CREATE, "refs/tags/1.0.0", revision, ZERO); assertTag("1.0.0", revision, provider.getCreatedTags()); assertThat(provider.getDeletedTags(), empty()); @@ -85,7 +93,7 @@ public class GitHookTagProviderTest { @Test public void testGetDeletedTags() { String revision = "b2002b64013e54b78eac251df0672bd5d6a83aa7"; - GitHookTagProvider provider = createProvider(ReceiveCommand.Type.DELETE, "refs/tags/1.0.0", revision); + GitHookTagProvider provider = createProvider(ReceiveCommand.Type.DELETE, "refs/tags/1.0.0", ZERO, revision); assertThat(provider.getCreatedTags(), empty()); assertTag("1.0.0", revision, provider.getDeletedTags()); @@ -97,12 +105,25 @@ public class GitHookTagProviderTest { @Test public void testWithBranch(){ String revision = "b2002b64013e54b78eac251df0672bd5d6a83aa7"; - GitHookTagProvider provider = createProvider(ReceiveCommand.Type.CREATE, "refs/heads/1.0.0", revision); + GitHookTagProvider provider = createProvider(ReceiveCommand.Type.CREATE, "refs/heads/1.0.0", revision, revision); assertThat(provider.getCreatedTags(), empty()); assertThat(provider.getDeletedTags(), empty()); } - + + /** + * Tests {@link GitHookTagProvider} with update command. + */ + @Test + public void testUpdateTags() { + String newId = "b2002b64013e54b78eac251df0672bd5d6a83aa7"; + String oldId = "e0f2be968b147ff7043684a7715d2fe852553db4"; + + GitHookTagProvider provider = createProvider(ReceiveCommand.Type.UPDATE, "refs/tags/1.0.0", newId, oldId); + assertTag("1.0.0", newId, provider.getCreatedTags()); + assertTag("1.0.0", oldId, provider.getDeletedTags()); + } + private void assertTag(String name, String revision, List<Tag> tags){ assertNotNull(tags); assertFalse(tags.isEmpty()); @@ -112,18 +133,11 @@ public class GitHookTagProviderTest { assertEquals(revision, tag.getRevision()); } - private GitHookTagProvider createProvider(ReceiveCommand.Type type, String ref, String id){ - OngoingStubbing<ObjectId> ongoing; - if (type == ReceiveCommand.Type.CREATE){ - ongoing = when(command.getNewId()); - } else { - ongoing = when(command.getOldId()); - } - ongoing.thenReturn(ObjectId.fromString(id)); - + private GitHookTagProvider createProvider(ReceiveCommand.Type type, String ref, String newId, String oldId){ + when(command.getNewId()).thenReturn(ObjectId.fromString(newId)); + when(command.getOldId()).thenReturn(ObjectId.fromString(oldId)); when(command.getType()).thenReturn(type); when(command.getRefName()).thenReturn(ref); - return new GitHookTagProvider(commands); } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/AbstractGitCommandTestBase.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/AbstractGitCommandTestBase.java index 0b3c1d6e9d..f2a4ed4954 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/AbstractGitCommandTestBase.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/AbstractGitCommandTestBase.java @@ -35,6 +35,9 @@ package sonia.scm.repository.spi; //~--- non-JDK imports -------------------------------------------------------- import org.junit.After; +import sonia.scm.api.v2.resources.GitRepositoryConfigStoreProvider; +import sonia.scm.repository.GitRepositoryConfig; +import sonia.scm.store.InMemoryConfigurationStoreFactory; /** * @@ -51,6 +54,7 @@ public class AbstractGitCommandTestBase extends ZippedRepositoryTestBase public void close() { if (context != null) { + context.setConfig(new GitRepositoryConfig()); context.close(); } } @@ -65,7 +69,7 @@ public class AbstractGitCommandTestBase extends ZippedRepositoryTestBase { if (context == null) { - context = new GitContext(repositoryDirectory, repository); + context = new GitContext(repositoryDirectory, repository, new GitRepositoryConfigStoreProvider(InMemoryConfigurationStoreFactory.create())); } return context; diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/AbstractRemoteCommandTestBase.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/AbstractRemoteCommandTestBase.java index 97e09c0708..1e366c919b 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/AbstractRemoteCommandTestBase.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/AbstractRemoteCommandTestBase.java @@ -92,9 +92,9 @@ public class AbstractRemoteCommandTestBase outgoing = Git.init().setDirectory(outgoingDirectory).setBare(false).call(); handler = mock(GitRepositoryHandler.class); - when(handler.getDirectory(incomingRepository)).thenReturn( + when(handler.getDirectory(incomingRepository.getId())).thenReturn( incomingDirectory); - when(handler.getDirectory(outgoingRepository)).thenReturn( + when(handler.getDirectory(outgoingRepository.getId())).thenReturn( outgoingDirectory); } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBlameCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBlameCommandTest.java index 5757cd5d5e..c8d260d503 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBlameCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBlameCommandTest.java @@ -37,7 +37,7 @@ package sonia.scm.repository.spi; import org.junit.Test; import sonia.scm.repository.BlameLine; import sonia.scm.repository.BlameResult; -import sonia.scm.repository.GitConstants; +import sonia.scm.repository.GitRepositoryConfig; import java.io.IOException; @@ -73,7 +73,7 @@ public class GitBlameCommandTest extends AbstractGitCommandTestBase assertEquals("fcd0ef1831e4002ac43ea539f4094334c79ea9ec", result.getLine(1).getRevision()); // set default branch and test again - repository.setProperty(GitConstants.PROPERTY_DEFAULT_BRANCH, "test-branch"); + createContext().setConfig(new GitRepositoryConfig("test-branch")); result = createCommand().getBlameResult(request); assertNotNull(result); assertEquals(1, result.getTotal()); diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBrowseCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBrowseCommandTest.java index 5e63adfb70..1feceba652 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBrowseCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBrowseCommandTest.java @@ -34,7 +34,7 @@ package sonia.scm.repository.spi; import org.junit.Test; import sonia.scm.repository.BrowserResult; import sonia.scm.repository.FileObject; -import sonia.scm.repository.GitConstants; +import sonia.scm.repository.GitRepositoryConfig; import java.io.IOException; import java.util.Collection; @@ -78,7 +78,7 @@ public class GitBrowseCommandTest extends AbstractGitCommandTestBase { @Test public void testExplicitDefaultBranch() throws IOException { - repository.setProperty(GitConstants.PROPERTY_DEFAULT_BRANCH, "test-branch"); + createContext().setConfig(new GitRepositoryConfig("test-branch")); FileObject root = createCommand().getBrowserResult(new BrowseCommandRequest()).getFile(); assertNotNull(root); diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitCatCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitCatCommandTest.java index 079fcac1da..0418bc3e61 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitCatCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitCatCommandTest.java @@ -38,7 +38,7 @@ import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; import sonia.scm.NotFoundException; -import sonia.scm.repository.GitConstants; +import sonia.scm.repository.GitRepositoryConfig; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -67,7 +67,7 @@ public class GitCatCommandTest extends AbstractGitCommandTestBase { assertEquals("a\nline for blame", execute(request)); // set default branch for repository and check again - repository.setProperty(GitConstants.PROPERTY_DEFAULT_BRANCH, "test-branch"); + createContext().setConfig(new GitRepositoryConfig("test-branch")); assertEquals("a and b", execute(request)); } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitIncomingCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitIncomingCommandTest.java index acf0b0f820..376d7cdf7a 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitIncomingCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitIncomingCommandTest.java @@ -38,7 +38,9 @@ import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.revwalk.RevCommit; import org.junit.Ignore; import org.junit.Test; +import sonia.scm.api.v2.resources.GitRepositoryConfigStoreProvider; import sonia.scm.repository.ChangesetPagingResult; +import sonia.scm.store.InMemoryConfigurationStoreFactory; import java.io.IOException; @@ -103,7 +105,7 @@ public class GitIncomingCommandTest commit(outgoing, "added a"); - GitPullCommand pull = new GitPullCommand(handler, new GitContext(incomingDirectory, null), incomingRepository); + GitPullCommand pull = new GitPullCommand(handler, new GitContext(incomingDirectory, null, new GitRepositoryConfigStoreProvider(new InMemoryConfigurationStoreFactory())), incomingRepository); PullCommandRequest req = new PullCommandRequest(); req.setRemoteRepository(outgoingRepository); pull.pull(req); @@ -187,7 +189,7 @@ public class GitIncomingCommandTest */ private GitIncomingCommand createCommand() { - return new GitIncomingCommand(handler, new GitContext(incomingDirectory, null), + return new GitIncomingCommand(handler, new GitContext(incomingDirectory, null, new GitRepositoryConfigStoreProvider(new InMemoryConfigurationStoreFactory())), incomingRepository); } } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitLogCommandAncestorTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitLogCommandAncestorTest.java new file mode 100644 index 0000000000..d36922f941 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitLogCommandAncestorTest.java @@ -0,0 +1,102 @@ + +/** + * Copyright (c) 2010, Sebastian Sdorra + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * 3. Neither the name of SCM-Manager; nor the names of its + * contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * http://bitbucket.org/sdorra/scm-manager + * + */ + + + +package sonia.scm.repository.spi; + +import org.junit.Test; +import sonia.scm.repository.ChangesetPagingResult; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +/** + * Unit tests for {@link GitLogCommand} with an ancestor commit. This test uses the following git repository: + * + * <pre> + * * 86e9ca0 (HEAD -> b) b5 + * * d69edb3 Merge branch 'master' into b + * |\ + * | * 946a8db (master) f + * | * b19b9cc e + * * | 3d6109c b4 + * * | 6330653 b3 + * * | a49a28e Merge branch 'master' into b + * |\ \ + * | |/ + * | * 0235584 d + * | * 20251c5 c + * * | 5023b85 b2 + * * | 201ecc1 b1 + * |/ + * * 36b19e4 b + * * c2190a9 a + * </pre> + * @author Sebastian Sdorra + */ +public class GitLogCommandAncestorTest extends AbstractGitCommandTestBase +{ + @Override + protected String getZippedRepositoryResource() + { + return "sonia/scm/repository/spi/scm-git-ancestor-test.zip"; + } + + @Test + public void testGetAncestor() + { + LogCommandRequest request = new LogCommandRequest(); + + request.setBranch("b"); + request.setAncestorChangeset("master"); + + ChangesetPagingResult result = createCommand().getChangesets(request); + + assertNotNull(result); + assertEquals(7, result.getTotal()); + assertEquals(7, result.getChangesets().size()); + + assertEquals("86e9ca012202b36865373a63c12ef4f4353506cd", result.getChangesets().get(0).getId()); + assertEquals("d69edb314d07ab20ad626e3101597702d3510b5d", result.getChangesets().get(1).getId()); + assertEquals("3d6109c4c830e91eaf12ac6a331a5fccd670fe3c", result.getChangesets().get(2).getId()); + assertEquals("63306538d06924d6b254f86541c638021c001141", result.getChangesets().get(3).getId()); + assertEquals("a49a28e0beb0ab55f985598d05b8628c2231c9b6", result.getChangesets().get(4).getId()); + assertEquals("5023b850c2077db857593a3c0269329c254a370d", result.getChangesets().get(5).getId()); + assertEquals("201ecc1131e6b99fb0a0fe9dcbc8c044383e1a07", result.getChangesets().get(6).getId()); + } + + private GitLogCommand createCommand() + { + return new GitLogCommand(createContext(), repository); + } +} diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitLogCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitLogCommandTest.java index 4afaf09c67..06e9b17fe7 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitLogCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitLogCommandTest.java @@ -38,7 +38,7 @@ import com.google.common.io.Files; import org.junit.Test; import sonia.scm.repository.Changeset; import sonia.scm.repository.ChangesetPagingResult; -import sonia.scm.repository.GitConstants; +import sonia.scm.repository.GitRepositoryConfig; import sonia.scm.repository.Modifications; import java.io.File; @@ -78,7 +78,7 @@ public class GitLogCommandTest extends AbstractGitCommandTestBase assertTrue(result.getChangesets().stream().allMatch(r -> r.getBranches().isEmpty())); // set default branch and fetch again - repository.setProperty(GitConstants.PROPERTY_DEFAULT_BRANCH, "test-branch"); + createContext().setConfig(new GitRepositoryConfig("test-branch")); result = createCommand().getChangesets(new LogCommandRequest()); diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMergeCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMergeCommandTest.java index 1fca7814ed..7e50b48b9a 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMergeCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMergeCommandTest.java @@ -6,20 +6,33 @@ import org.apache.shiro.subject.SimplePrincipalCollection; import org.apache.shiro.subject.Subject; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.PersonIdent; import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.transport.ScmTransportProtocol; +import org.eclipse.jgit.transport.Transport; +import org.junit.After; +import org.junit.Before; import org.junit.Rule; import org.junit.Test; +import sonia.scm.repository.GitRepositoryHandler; import sonia.scm.repository.Person; +import sonia.scm.repository.PreProcessorUtil; +import sonia.scm.repository.RepositoryManager; +import sonia.scm.repository.api.HookContextFactory; import sonia.scm.repository.api.MergeCommandResult; import sonia.scm.user.User; import java.io.IOException; +import static com.google.inject.util.Providers.of; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; -@SubjectAware(configuration = "classpath:sonia/scm/configuration/shiro.ini") +@SubjectAware(configuration = "classpath:sonia/scm/configuration/shiro.ini", username = "admin", password = "secret") public class GitMergeCommandTest extends AbstractGitCommandTestBase { private static final String REALM = "AdminRealm"; @@ -27,6 +40,27 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase { @Rule public ShiroRule shiro = new ShiroRule(); + private ScmTransportProtocol scmTransportProtocol; + + @Before + public void bindScmProtocol() { + HookContextFactory hookContextFactory = new HookContextFactory(mock(PreProcessorUtil.class)); + RepositoryManager repositoryManager = mock(RepositoryManager.class); + HookEventFacade hookEventFacade = new HookEventFacade(of(repositoryManager), hookContextFactory); + GitRepositoryHandler gitRepositoryHandler = mock(GitRepositoryHandler.class); + scmTransportProtocol = new ScmTransportProtocol(of(hookEventFacade), of(gitRepositoryHandler)); + + Transport.register(scmTransportProtocol); + + when(gitRepositoryHandler.getRepositoryId(any())).thenReturn("1"); + when(repositoryManager.get("1")).thenReturn(new sonia.scm.repository.Repository()); + } + + @After + public void unregisterScmProtocol() { + Transport.unregister(scmTransportProtocol); + } + @Test public void shouldDetectMergeableBranches() { GitMergeCommand command = createCommand(); @@ -77,6 +111,30 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase { assertThat(new String(contentOfFileB)).isEqualTo("b\ncontent from branch\n"); } + @Test + public void shouldNotMergeTwice() throws IOException, GitAPIException { + GitMergeCommand command = createCommand(); + MergeCommandRequest request = new MergeCommandRequest(); + request.setTargetBranch("master"); + request.setBranchToMerge("mergeable"); + request.setAuthor(new Person("Dirk Gently", "dirk@holistic.det")); + + MergeCommandResult mergeCommandResult = command.merge(request); + + assertThat(mergeCommandResult.isSuccess()).isTrue(); + + Repository repository = createContext().open(); + ObjectId firstMergeCommit = new Git(repository).log().add(repository.resolve("master")).setMaxCount(1).call().iterator().next().getId(); + + MergeCommandResult secondMergeCommandResult = command.merge(request); + + assertThat(secondMergeCommandResult.isSuccess()).isTrue(); + + ObjectId secondMergeCommit = new Git(repository).log().add(repository.resolve("master")).setMaxCount(1).call().iterator().next().getId(); + + assertThat(secondMergeCommit).isEqualTo(firstMergeCommit); + } + @Test public void shouldUseConfiguredCommitMessageTemplate() throws IOException, GitAPIException { GitMergeCommand command = createCommand(); @@ -111,11 +169,14 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase { } @Test - @SubjectAware(username = "admin", password = "secret") public void shouldTakeAuthorFromSubjectIfNotSet() throws IOException, GitAPIException { + SimplePrincipalCollection principals = new SimplePrincipalCollection(); + principals.add("admin", REALM); + principals.add( new User("dirk", "Dirk Gently", "dirk@holistic.det"), REALM); shiro.setSubject( new Subject.Builder() - .principals(new SimplePrincipalCollection(new User("dirk", "Dirk Gently", "dirk@holistic.det"), REALM)) + .principals(principals) + .authenticated(true) .buildSubject()); GitMergeCommand command = createCommand(); MergeCommandRequest request = new MergeCommandRequest(); @@ -133,6 +194,32 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase { assertThat(mergeAuthor.getEmailAddress()).isEqualTo("dirk@holistic.det"); } + @Test + public void shouldMergeIntoNotDefaultBranch() throws IOException, GitAPIException { + GitMergeCommand command = createCommand(); + MergeCommandRequest request = new MergeCommandRequest(); + request.setAuthor(new Person("Dirk Gently", "dirk@holistic.det")); + request.setTargetBranch("mergeable"); + request.setBranchToMerge("master"); + + MergeCommandResult mergeCommandResult = command.merge(request); + + Repository repository = createContext().open(); + assertThat(mergeCommandResult.isSuccess()).isTrue(); + + Iterable<RevCommit> commits = new Git(repository).log().add(repository.resolve("mergeable")).setMaxCount(1).call(); + RevCommit mergeCommit = commits.iterator().next(); + PersonIdent mergeAuthor = mergeCommit.getAuthorIdent(); + String message = mergeCommit.getFullMessage(); + assertThat(mergeAuthor.getName()).isEqualTo("Dirk Gently"); + assertThat(mergeAuthor.getEmailAddress()).isEqualTo("dirk@holistic.det"); + assertThat(message).contains("master", "mergeable"); + // We expect the merge result of file b.txt here by looking up the sha hash of its content. + // If the file is missing (aka not merged correctly) this will throw a MissingObjectException: + byte[] contentOfFileB = repository.open(repository.resolve("9513e9c76e73f3e562fd8e4c909d0607113c77c6")).getBytes(); + assertThat(new String(contentOfFileB)).isEqualTo("b\ncontent from branch\n"); + } + private GitMergeCommand createCommand() { return new GitMergeCommand(createContext(), repository, new SimpleGitWorkdirFactory()); } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModificationsCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModificationsCommandTest.java index 41a516a124..dbb510fb7e 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModificationsCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModificationsCommandTest.java @@ -18,8 +18,8 @@ public class GitModificationsCommandTest extends AbstractRemoteCommandTestBase { @Before public void init() { - incomingModificationsCommand = new GitModificationsCommand(new GitContext(incomingDirectory, null), incomingRepository); - outgoingModificationsCommand = new GitModificationsCommand(new GitContext(outgoingDirectory, null), outgoingRepository); + incomingModificationsCommand = new GitModificationsCommand(new GitContext(incomingDirectory, null, null), incomingRepository); + outgoingModificationsCommand = new GitModificationsCommand(new GitContext(outgoingDirectory, null, null), outgoingRepository); } @Test @@ -63,12 +63,12 @@ public class GitModificationsCommandTest extends AbstractRemoteCommandTestBase { } void pushOutgoingAndPullIncoming() throws IOException { - GitPushCommand cmd = new GitPushCommand(handler, new GitContext(outgoingDirectory, null), + GitPushCommand cmd = new GitPushCommand(handler, new GitContext(outgoingDirectory, null, null), outgoingRepository); PushCommandRequest request = new PushCommandRequest(); request.setRemoteRepository(incomingRepository); cmd.push(request); - GitPullCommand pullCommand = new GitPullCommand(handler, new GitContext(incomingDirectory, null), + GitPullCommand pullCommand = new GitPullCommand(handler, new GitContext(incomingDirectory, null, null), incomingRepository); PullCommandRequest pullRequest = new PullCommandRequest(); pullRequest.setRemoteRepository(incomingRepository); diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitOutgoingCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitOutgoingCommandTest.java index 65592cf7e4..2525a6fa38 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitOutgoingCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitOutgoingCommandTest.java @@ -38,7 +38,9 @@ package sonia.scm.repository.spi; import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.revwalk.RevCommit; import org.junit.Test; +import sonia.scm.api.v2.resources.GitRepositoryConfigStoreProvider; import sonia.scm.repository.ChangesetPagingResult; +import sonia.scm.store.InMemoryConfigurationStoreFactory; import java.io.IOException; @@ -104,7 +106,7 @@ public class GitOutgoingCommandTest extends AbstractRemoteCommandTestBase commit(outgoing, "added a"); GitPushCommand push = new GitPushCommand(handler, - new GitContext(outgoingDirectory, null), + new GitContext(outgoingDirectory, null, null), outgoingRepository); PushCommandRequest req = new PushCommandRequest(); @@ -158,7 +160,7 @@ public class GitOutgoingCommandTest extends AbstractRemoteCommandTestBase */ private GitOutgoingCommand createCommand() { - return new GitOutgoingCommand(handler, new GitContext(outgoingDirectory, null), + return new GitOutgoingCommand(handler, new GitContext(outgoingDirectory, null, new GitRepositoryConfigStoreProvider(new InMemoryConfigurationStoreFactory())), outgoingRepository); } } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitPushCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitPushCommandTest.java index 70212ba233..6aa831ec60 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitPushCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitPushCommandTest.java @@ -98,7 +98,7 @@ public class GitPushCommandTest extends AbstractRemoteCommandTestBase */ private GitPushCommand createCommand() { - return new GitPushCommand(handler, new GitContext(outgoingDirectory, null), + return new GitPushCommand(handler, new GitContext(outgoingDirectory, null, null), outgoingRepository); } } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/SimpleGitWorkdirFactoryTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/SimpleGitWorkdirFactoryTest.java index 0c39a1deb0..da26ebaf20 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/SimpleGitWorkdirFactoryTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/SimpleGitWorkdirFactoryTest.java @@ -2,14 +2,23 @@ package sonia.scm.repository.spi; import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.transport.ScmTransportProtocol; +import org.eclipse.jgit.transport.Transport; +import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; +import sonia.scm.repository.GitRepositoryHandler; +import sonia.scm.repository.PreProcessorUtil; +import sonia.scm.repository.RepositoryManager; +import sonia.scm.repository.api.HookContextFactory; import java.io.File; import java.io.IOException; +import static com.google.inject.util.Providers.of; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; @@ -18,6 +27,14 @@ public class SimpleGitWorkdirFactoryTest extends AbstractGitCommandTestBase { @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); + @Before + public void bindScmProtocol() { + HookContextFactory hookContextFactory = new HookContextFactory(mock(PreProcessorUtil.class)); + HookEventFacade hookEventFacade = new HookEventFacade(of(mock(RepositoryManager.class)), hookContextFactory); + GitRepositoryHandler gitRepositoryHandler = mock(GitRepositoryHandler.class); + Transport.register(new ScmTransportProtocol(of(hookEventFacade), of(gitRepositoryHandler))); + } + @Test public void emptyPoolShouldCreateNewWorkdir() throws IOException { SimpleGitWorkdirFactory factory = new SimpleGitWorkdirFactory(temporaryFolder.newFolder()); diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/GitReceivePackFactoryTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/GitReceivePackFactoryTest.java new file mode 100644 index 0000000000..4ed9d5a46a --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/GitReceivePackFactoryTest.java @@ -0,0 +1,117 @@ +/** + * Copyright (c) 2010, Sebastian Sdorra + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * 3. Neither the name of SCM-Manager; nor the names of its + * contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * http://bitbucket.org/sdorra/scm-manager + * + */ + +package sonia.scm.web; + +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.transport.ReceivePack; +import org.eclipse.jgit.transport.resolver.ReceivePackFactory; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import sonia.scm.repository.GitConfig; +import sonia.scm.repository.GitRepositoryHandler; + +import javax.servlet.http.HttpServletRequest; +import java.io.File; +import java.io.IOException; + +import static org.hamcrest.Matchers.instanceOf; +import static org.junit.Assert.*; +import static org.mockito.Mockito.when; + + +/** + * Unit tests for {@link GitReceivePackFactory}. + */ +@RunWith(MockitoJUnitRunner.class) +public class GitReceivePackFactoryTest { + + @Mock + private GitRepositoryHandler handler; + + private GitConfig config; + + @Mock + private ReceivePackFactory wrappedReceivePackFactory; + + private GitReceivePackFactory factory; + + @Mock + private HttpServletRequest request; + + private Repository repository; + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Before + public void setUpObjectUnderTest() throws Exception { + this.repository = createRepositoryForTesting(); + + config = new GitConfig(); + when(handler.getConfig()).thenReturn(config); + + ReceivePack receivePack = new ReceivePack(repository); + when(wrappedReceivePackFactory.create(request, repository)).thenReturn(receivePack); + + factory = new GitReceivePackFactory(handler, null); + factory.setWrapped(wrappedReceivePackFactory); + } + + private Repository createRepositoryForTesting() throws GitAPIException, IOException { + File directory = temporaryFolder.newFolder(); + return Git.init().setDirectory(directory).call().getRepository(); + } + + @Test + public void testCreate() throws Exception { + ReceivePack receivePack = factory.create(request, repository); + assertThat(receivePack.getPackParserListener(), instanceOf(CollectingPackParserListener.class)); + assertThat(receivePack.getPreReceiveHook(), instanceOf(GitReceiveHook.class)); + assertThat(receivePack.getPostReceiveHook(), instanceOf(GitReceiveHook.class)); + assertTrue(receivePack.isAllowNonFastForwards()); + } + + @Test + public void testCreateWithDisabledNonFastForward() throws Exception { + config.setNonFastForwardDisallowed(true); + ReceivePack receivePack = factory.create(request, repository); + assertFalse(receivePack.isAllowNonFastForwards()); + } + +} diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/lfs/LfsBlobStoreFactoryTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/lfs/LfsBlobStoreFactoryTest.java index 991e2655f7..93eadf8935 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/lfs/LfsBlobStoreFactoryTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/lfs/LfsBlobStoreFactoryTest.java @@ -40,9 +40,12 @@ import org.mockito.junit.MockitoJUnitRunner; import sonia.scm.repository.Repository; import sonia.scm.store.BlobStoreFactory; -import static org.mockito.Matchers.matches; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; /** * Unit tests for {@link LfsBlobStoreFactory}. @@ -59,15 +62,21 @@ public class LfsBlobStoreFactoryTest { private LfsBlobStoreFactory lfsBlobStoreFactory; @Test - public void getBlobStore() throws Exception { - lfsBlobStoreFactory.getLfsBlobStore(new Repository("the-id", "GIT", "space", "the-name")); + public void getBlobStore() { + when(blobStoreFactory.withName(any())).thenCallRealMethod(); + Repository repository = new Repository("the-id", "GIT", "space", "the-name"); + lfsBlobStoreFactory.getLfsBlobStore(repository); // just make sure the right parameter is passed, as properly validating the return value is nearly impossible with // the return value (and should not be part of this test) - verify(blobStoreFactory).getBlobStore(matches("the-id-git-lfs")); + verify(blobStoreFactory).getStore(argThat(blobStoreParameters -> { + assertThat(blobStoreParameters.getName()).isEqualTo("the-id-git-lfs"); + assertThat(blobStoreParameters.getRepository()).isEqualTo(repository); + return true; + })); // make sure there have been no further usages of the factory - verifyNoMoreInteractions(blobStoreFactory); + verify(blobStoreFactory, times(1)).getStore(any()); } } diff --git a/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/configuration/shiro.ini b/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/configuration/shiro.ini index 5d30a000f2..8a8ff98c2f 100644 --- a/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/configuration/shiro.ini +++ b/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/configuration/shiro.ini @@ -1,6 +1,6 @@ [users] -readOnly = secret, reader -writeOnly = secret, writer +readOnly = secret, reader, repoRead +writeOnly = secret, writer, repoWrite readWrite = secret, readerWriter admin = secret, admin @@ -9,3 +9,5 @@ reader = configuration:read:git writer = configuration:write:git readerWriter = configuration:*:git admin = * +repoRead = repository:read:* +repoWrite = repository:modify:* diff --git a/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/repository-001.json b/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/repository-001.json new file mode 100644 index 0000000000..43ea136942 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/repository-001.json @@ -0,0 +1,42 @@ +{ + "creationDate": "2018-11-09T09:48:32.732Z", + "description": "Handling static webresources made easy", + "healthCheckFailures": [], + "lastModified": "2018-11-09T09:49:20.973Z", + "namespace": "scmadmin", + "name": "web-resources", + "archived": false, + "type": "git", + "_links": { + "self": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources" + }, + "delete": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources" + }, + "update": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources" + }, + "permissions": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/permissions/" + }, + "protocol": [ + { + "href": "http://localhost:8081/scm/repo/scmadmin/web-resources", + "name": "http" + } + ], + "tags": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/tags/" + }, + "branches": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/branches/" + }, + "changesets": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/changesets/" + }, + "sources": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/sources/" + } + } +} diff --git a/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/repository-collection-001.json b/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/repository-collection-001.json new file mode 100644 index 0000000000..f4eeb24bbc --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/repository-collection-001.json @@ -0,0 +1,106 @@ +{ + "page": 0, + "pageTotal": 1, + "_links": { + "self": { + "href": "http://localhost:8081/scm/api/v2/repositories/?page=0&pageSize=10" + }, + "first": { + "href": "http://localhost:8081/scm/api/v2/repositories/?page=0&pageSize=10" + }, + "last": { + "href": "http://localhost:8081/scm/api/v2/repositories/?page=0&pageSize=10" + }, + "create": { + "href": "http://localhost:8081/scm/api/v2/repositories/" + } + }, + "_embedded": { + "repositories": [ + { + "creationDate": "2018-11-09T09:48:32.732Z", + "description": "Handling static webresources made easy", + "healthCheckFailures": [], + "lastModified": "2018-11-09T09:49:20.973Z", + "namespace": "scmadmin", + "name": "web-resources", + "archived": false, + "type": "git", + "_links": { + "self": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources" + }, + "delete": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources" + }, + "update": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources" + }, + "permissions": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/permissions/" + }, + "protocol": [ + { + "href": "http://localhost:8081/scm/repo/scmadmin/web-resources", + "name": "http" + } + ], + "tags": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/tags/" + }, + "branches": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/branches/" + }, + "changesets": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/changesets/" + }, + "sources": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/sources/" + } + } + }, + { + "creationDate": "2018-11-09T09:48:32.732Z", + "description": "Handling static webresources made easy", + "healthCheckFailures": [], + "lastModified": "2018-11-09T09:49:20.973Z", + "namespace": "scmadmin", + "name": "web-resources", + "archived": false, + "type": "git", + "_links": { + "self": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources" + }, + "delete": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources" + }, + "update": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources" + }, + "permissions": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/permissions/" + }, + "protocol": [ + { + "href": "http://localhost:8081/scm/repo/scmadmin/web-resources", + "name": "http" + } + ], + "tags": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/tags/" + }, + "branches": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/branches/" + }, + "changesets": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/changesets/" + }, + "sources": { + "href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/sources/" + } + } + } + ] + } +} diff --git a/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-ancestor-test.zip b/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-ancestor-test.zip new file mode 100644 index 0000000000..d740de7674 Binary files /dev/null and b/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-ancestor-test.zip differ diff --git a/scm-plugins/scm-git-plugin/yarn.lock b/scm-plugins/scm-git-plugin/yarn.lock index 3514ed3f2c..64c47a247d 100644 --- a/scm-plugins/scm-git-plugin/yarn.lock +++ b/scm-plugins/scm-git-plugin/yarn.lock @@ -707,9 +707,9 @@ version "0.0.2" resolved "https://registry.yarnpkg.com/@scm-manager/eslint-config/-/eslint-config-0.0.2.tgz#94cc8c3fb4f51f870b235893dc134fc6c423ae85" -"@scm-manager/ui-bundler@^0.0.21": - version "0.0.21" - resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.21.tgz#f8b5fa355415cc67b8aaf8744e1701a299dff647" +"@scm-manager/ui-bundler@^0.0.24": + version "0.0.24" + resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.24.tgz#034d5500c79b438c48d8f7ee985be07c4ea46d1e" dependencies: "@babel/core" "^7.0.0" "@babel/plugin-proposal-class-properties" "^7.0.0" @@ -747,9 +747,10 @@ vinyl-source-stream "^2.0.0" watchify "^3.11.0" -"@scm-manager/ui-extensions@^0.1.1": - version "0.1.1" - resolved "https://registry.yarnpkg.com/@scm-manager/ui-extensions/-/ui-extensions-0.1.1.tgz#966e62d89981e92a14adf7e674e646e76de96d45" +"@scm-manager/ui-extensions@^0.1.2": + version "0.1.2" + resolved "https://registry.yarnpkg.com/@scm-manager/ui-extensions/-/ui-extensions-0.1.2.tgz#0689427ca45c8e4e045b5b9dbc89036f1d2c45fc" + integrity sha512-oIkXcc/VWssnK/yjWKC/Wnq5DZ01rArsz76n4X/0DT0hkGNIKmwk/Fdp7OoXiUEb7+aaPjUX1VvDqlTwCNKPmA== dependencies: react "^16.4.2" react-dom "^16.4.2" diff --git a/scm-plugins/scm-hg-plugin/package.json b/scm-plugins/scm-hg-plugin/package.json index dbca702070..849d8a92cb 100644 --- a/scm-plugins/scm-hg-plugin/package.json +++ b/scm-plugins/scm-hg-plugin/package.json @@ -6,9 +6,9 @@ "build": "ui-bundler plugin" }, "dependencies": { - "@scm-manager/ui-extensions": "^0.1.1" + "@scm-manager/ui-extensions": "^0.1.2" }, "devDependencies": { - "@scm-manager/ui-bundler": "^0.0.21" + "@scm-manager/ui-bundler": "^0.0.24" } } diff --git a/scm-plugins/scm-hg-plugin/pom.xml b/scm-plugins/scm-hg-plugin/pom.xml index 6b7664c140..b07ce5bb3c 100644 --- a/scm-plugins/scm-hg-plugin/pom.xml +++ b/scm-plugins/scm-hg-plugin/pom.xml @@ -20,7 +20,7 @@ <dependency> <groupId>com.aragost.javahg</groupId> <artifactId>javahg</artifactId> - <version>0.8-scm1</version> + <version>0.13-java7</version> <exclusions> <exclusion> <groupId>com.google.guava</groupId> @@ -81,7 +81,6 @@ </executions> </plugin> - </plugins> </build> @@ -93,19 +92,6 @@ <url>http://maven.scm-manager.org/nexus/content/groups/public</url> </repository> - <repository> - <releases> - <enabled>false</enabled> - </releases> - <snapshots> - <enabled>true</enabled> - </snapshots> - <id>sonatype-ossrh</id> - <name>Sonatype Open Source Software Repository Hosting</name> - <layout>default</layout> - <url>https://oss.sonatype.org/content/groups/public/</url> - </repository> - </repositories> </project> diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/api/v2/resources/HgConfigDto.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/api/v2/resources/HgConfigDto.java index 9fefc05ca4..641b8650fc 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/api/v2/resources/HgConfigDto.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/api/v2/resources/HgConfigDto.java @@ -6,15 +6,12 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; -import java.io.File; - @NoArgsConstructor @Getter @Setter public class HgConfigDto extends HalRepresentation { private boolean disabled; - private File repositoryDirectory; private String encoding; private String hgBinary; @@ -22,6 +19,8 @@ public class HgConfigDto extends HalRepresentation { private String pythonPath; private boolean useOptimizedBytecode; private boolean showRevisionInId; + private boolean enableHttpPostArgs; + private boolean disableHookSSLValidation; @Override @SuppressWarnings("squid:S1185") // We want to have this method available in this package diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/api/v2/resources/HgConfigInIndexResource.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/api/v2/resources/HgConfigInIndexResource.java index 3de79b2f81..73c6e2e52f 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/api/v2/resources/HgConfigInIndexResource.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/api/v2/resources/HgConfigInIndexResource.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import sonia.scm.config.ConfigurationPermissions; import sonia.scm.plugin.Extension; +import sonia.scm.repository.HgConfig; import sonia.scm.web.JsonEnricherBase; import sonia.scm.web.JsonEnricherContext; @@ -26,7 +27,7 @@ public class HgConfigInIndexResource extends JsonEnricherBase { @Override public void enrich(JsonEnricherContext context) { - if (resultHasMediaType(INDEX, context) && ConfigurationPermissions.list().isPermitted()) { + if (resultHasMediaType(INDEX, context) && ConfigurationPermissions.read(HgConfig.PERMISSION).isPermitted()) { String hgConfigUrl = new LinkBuilder(scmPathInfoStore.get().get(), HgConfigResource.class) .method("get") .parameters() diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/installer/AbstractHgInstaller.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/installer/AbstractHgInstaller.java index bd5dfd7095..27fdc7a296 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/installer/AbstractHgInstaller.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/installer/AbstractHgInstaller.java @@ -35,14 +35,12 @@ package sonia.scm.installer; //~--- non-JDK imports -------------------------------------------------------- -import sonia.scm.repository.HgConfig; import sonia.scm.repository.HgRepositoryHandler; -import sonia.scm.util.IOUtil; //~--- JDK imports ------------------------------------------------------------ import java.io.File; -import java.io.IOException; + import sonia.scm.net.ahc.AdvancedHttpClient; /** @@ -52,32 +50,6 @@ import sonia.scm.net.ahc.AdvancedHttpClient; public abstract class AbstractHgInstaller implements HgInstaller { - /** Field description */ - public static final String DIRECTORY_REPOSITORY = "repositories"; - - //~--- methods -------------------------------------------------------------- - - /** - * Method description - * - * - * - * @param baseDirectory - * @param config - * - * @throws IOException - */ - @Override - public void install(File baseDirectory, HgConfig config) throws IOException - { - File repoDirectory = new File( - baseDirectory, - DIRECTORY_REPOSITORY.concat(File.separator).concat( - HgRepositoryHandler.TYPE_NAME)); - - IOUtil.mkdirs(repoDirectory); - config.setRepositoryDirectory(repoDirectory); - } /** * Method description diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/installer/UnixHgInstaller.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/installer/UnixHgInstaller.java index ed81b27bdb..74ca7dea34 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/installer/UnixHgInstaller.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/installer/UnixHgInstaller.java @@ -74,8 +74,6 @@ public class UnixHgInstaller extends AbstractHgInstaller @Override public void install(File baseDirectory, HgConfig config) throws IOException { - super.install(baseDirectory, config); - // search mercurial (hg) if (Util.isEmpty(config.getHgBinary())) { diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/installer/WindowsHgInstaller.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/installer/WindowsHgInstaller.java index a764997c73..0b0ecddfa6 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/installer/WindowsHgInstaller.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/installer/WindowsHgInstaller.java @@ -116,8 +116,6 @@ public class WindowsHgInstaller extends AbstractHgInstaller @Override public void install(File baseDirectory, HgConfig config) throws IOException { - super.install(baseDirectory, config); - if (Util.isEmpty(config.getPythonBinary())) { String pythonBinary = getPythonBinary(); diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/AbstractHgHandler.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/AbstractHgHandler.java index 4115a514a2..832a7203f8 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/AbstractHgHandler.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/AbstractHgHandler.java @@ -124,7 +124,7 @@ public class AbstractHgHandler protected AbstractHgHandler(HgRepositoryHandler handler, HgContext context, Repository repository) { - this(handler, context, repository, handler.getDirectory(repository)); + this(handler, context, repository, handler.getDirectory(repository.getId())); } /** diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/HgConfig.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/HgConfig.java index 41b0f8d205..bb3d2b1cfb 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/HgConfig.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/HgConfig.java @@ -58,6 +58,14 @@ public class HgConfig extends RepositoryConfig //~--- get methods ---------------------------------------------------------- + + @Override + @XmlTransient // Only for permission checks, don't serialize to XML + public String getId() { + // Don't change this without migrating SCM permission configuration! + return PERMISSION; + } + /** * Method description * @@ -124,6 +132,14 @@ public class HgConfig extends RepositoryConfig return useOptimizedBytecode; } + public boolean isDisableHookSSLValidation() { + return disableHookSSLValidation; + } + + public boolean isEnableHttpPostArgs() { + return enableHttpPostArgs; + } + /** * Method description * @@ -194,6 +210,10 @@ public class HgConfig extends RepositoryConfig this.showRevisionInId = showRevisionInId; } + public void setEnableHttpPostArgs(boolean enableHttpPostArgs) { + this.enableHttpPostArgs = enableHttpPostArgs; + } + /** * Method description * @@ -205,6 +225,10 @@ public class HgConfig extends RepositoryConfig this.useOptimizedBytecode = useOptimizedBytecode; } + public void setDisableHookSSLValidation(boolean disableHookSSLValidation) { + this.disableHookSSLValidation = disableHookSSLValidation; + } + //~--- fields --------------------------------------------------------------- /** Field description */ @@ -225,10 +249,11 @@ public class HgConfig extends RepositoryConfig /** Field description */ private boolean showRevisionInId = false; - @Override - @XmlTransient // Only for permission checks, don't serialize to XML - public String getId() { - // Don't change this without migrating SCM permission configuration! - return PERMISSION; - } + private boolean enableHttpPostArgs = false; + + /** + * disable validation of ssl certificates for mercurial hook + * @see <a href="https://goo.gl/zH5eY8">Issue 959</a> + */ + private boolean disableHookSSLValidation = false; } diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/HgContext.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/HgContext.java index cbf7804444..21e27af328 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/HgContext.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/HgContext.java @@ -35,13 +35,10 @@ package sonia.scm.repository; //~--- non-JDK imports -------------------------------------------------------- -import com.google.inject.servlet.RequestScoped; - /** * * @author Sebastian Sdorra */ -@RequestScoped public class HgContext { diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/HgContextProvider.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/HgContextProvider.java index c86588fb27..33477e04b8 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/HgContextProvider.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/HgContextProvider.java @@ -35,13 +35,20 @@ package sonia.scm.repository; //~--- non-JDK imports -------------------------------------------------------- -import com.google.inject.Inject; -import com.google.inject.Provider; +import com.google.common.annotations.VisibleForTesting; +import com.google.inject.OutOfScopeException; +import com.google.inject.Provider; +import com.google.inject.ProvisionException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import javax.inject.Inject; + /** + * Injection provider for {@link HgContext}. + * This provider returns an instance {@link HgContext} from request scope, if no {@link HgContext} could be found in + * request scope (mostly because the scope is not available) a new {@link HgContext} gets returned. * * @author Sebastian Sdorra */ @@ -49,36 +56,50 @@ public class HgContextProvider implements Provider<HgContext> { /** - * the logger for HgContextProvider + * the LOG for HgContextProvider */ - private static final Logger logger = + private static final Logger LOG = LoggerFactory.getLogger(HgContextProvider.class); //~--- get methods ---------------------------------------------------------- - /** - * Method description - * - * - * @return - */ - @Override - public HgContext get() - { - HgContext ctx = context; + private Provider<HgContextRequestStore> requestStoreProvider; - if (ctx == null) - { - ctx = new HgContext(); - logger.trace("context is null, we are probably out of request scope"); - } - - return ctx; + @Inject + public HgContextProvider(Provider<HgContextRequestStore> requestStoreProvider) { + this.requestStoreProvider = requestStoreProvider; } - //~--- fields --------------------------------------------------------------- + @VisibleForTesting + public HgContextProvider() { + } - /** Field description */ - @Inject(optional = true) - private HgContext context; + @Override + public HgContext get() { + HgContext context = fetchContextFromRequest(); + if (context != null) { + LOG.trace("return HgContext from request store"); + return context; + } + LOG.trace("could not find context in request scope, returning new instance"); + return new HgContext(); + } + + private HgContext fetchContextFromRequest() { + try { + if (requestStoreProvider != null) { + return requestStoreProvider.get().get(); + } else { + LOG.trace("no request store provider defined, could not return context from request"); + return null; + } + } catch (ProvisionException ex) { + if (ex.getCause() instanceof OutOfScopeException) { + LOG.trace("we are currently out of request scope, failed to retrieve context"); + return null; + } else { + throw ex; + } + } + } } diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/HgContextRequestStore.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/HgContextRequestStore.java new file mode 100644 index 0000000000..ff08c2fcd2 --- /dev/null +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/HgContextRequestStore.java @@ -0,0 +1,24 @@ +package sonia.scm.repository; + +import com.google.inject.servlet.RequestScoped; + +/** + * Holds an instance of {@link HgContext} in the request scope. + * + * <p>The problem seems to be that guice had multiple options for injecting HgContext. {@link HgContextProvider} + * bound via Module and {@link HgContext} bound void {@link RequestScoped} annotation. It looks like that Guice 4 + * injects randomly the one or the other, in SCMv1 (Guice 3) everything works as expected.</p> + * + * <p>To fix the problem we have created this class annotated with {@link RequestScoped}, which holds an instance + * of {@link HgContext}. This way only the {@link HgContextProvider} is used for injection.</p> + */ +@RequestScoped +public class HgContextRequestStore { + + private final HgContext context = new HgContext(); + + public HgContext get() { + return context; + } + +} diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/HgImportHandler.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/HgImportHandler.java index ad61926eef..b1d431c742 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/HgImportHandler.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/HgImportHandler.java @@ -39,7 +39,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.io.INIConfiguration; import sonia.scm.io.INIConfigurationReader; -import sonia.scm.io.INIConfigurationWriter; import sonia.scm.io.INISection; import sonia.scm.util.ValidationUtil; @@ -94,12 +93,7 @@ public class HgImportHandler extends AbstactImportHandler INIConfiguration c = reader.read(hgrc); INISection web = c.getSection("web"); - if (web == null) - { - handler.appendWebSection(c); - } - else - { + if (web != null) { repository.setDescription(web.getParameter("description")); String contact = web.getParameter("contact"); @@ -112,16 +106,7 @@ public class HgImportHandler extends AbstactImportHandler { logger.warn("contact {} is not a valid mail address", contact); } - - handler.setWebParameter(web); } - - // issue-97 - handler.registerMissingHook(c, repositoryName); - - INIConfigurationWriter writer = new INIConfigurationWriter(); - - writer.write(c, hgrc); } else { diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/HgRepositoryHandler.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/HgRepositoryHandler.java index 39100b8cfa..7db9a8becb 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/HgRepositoryHandler.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/HgRepositoryHandler.java @@ -41,22 +41,21 @@ import com.google.inject.Singleton; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.ConfigurationException; +import sonia.scm.ContextEntry; import sonia.scm.SCMContextProvider; import sonia.scm.installer.HgInstaller; import sonia.scm.installer.HgInstallerFactory; -import sonia.scm.io.DirectoryFileFilter; import sonia.scm.io.ExtendedCommand; -import sonia.scm.io.FileSystem; import sonia.scm.io.INIConfiguration; import sonia.scm.io.INIConfigurationReader; import sonia.scm.io.INIConfigurationWriter; import sonia.scm.io.INISection; import sonia.scm.plugin.Extension; +import sonia.scm.plugin.PluginLoader; import sonia.scm.repository.spi.HgRepositoryServiceProvider; import sonia.scm.store.ConfigurationStoreFactory; import sonia.scm.util.IOUtil; import sonia.scm.util.SystemUtil; -import sonia.scm.util.Util; import javax.xml.bind.JAXBContext; import javax.xml.bind.JAXBException; @@ -105,22 +104,18 @@ public class HgRepositoryHandler /** Field description */ public static final String PATH_HGRC = ".hg".concat(File.separator).concat("hgrc"); + private static final String CONFIG_SECTION_SCMM = "scmm"; + private static final String CONFIG_KEY_REPOSITORY_ID = "repositoryid"; //~--- constructors --------------------------------------------------------- - /** - * Constructs ... - * - * - * @param storeFactory - * @param fileSystem - * @param hgContextProvider - */ @Inject - public HgRepositoryHandler(ConfigurationStoreFactory storeFactory, FileSystem fileSystem, - Provider<HgContext> hgContextProvider) + public HgRepositoryHandler(ConfigurationStoreFactory storeFactory, + Provider<HgContext> hgContextProvider, + RepositoryLocationResolver repositoryLocationResolver, + PluginLoader pluginLoader) { - super(storeFactory, fileSystem); + super(storeFactory, repositoryLocationResolver, pluginLoader); this.hgContextProvider = hgContextProvider; try @@ -179,7 +174,6 @@ public class HgRepositoryHandler public void init(SCMContextProvider context) { super.init(context); - registerMissingHooks(); writePythonScripts(context); // fix wrong hg.bat from package installation @@ -299,100 +293,6 @@ public class HgRepositoryHandler return version; } - //~--- methods -------------------------------------------------------------- - - /** - * Method description - * - * - * @param hgrc - */ - void appendHookSection(INIConfiguration hgrc) - { - INISection hooksSection = new INISection("hooks"); - - setHookParameter(hooksSection); - hgrc.addSection(hooksSection); - } - - /** - * Method description - * - * - * @param hgrc - */ - void appendWebSection(INIConfiguration hgrc) - { - INISection webSection = new INISection("web"); - - setWebParameter(webSection); - hgrc.addSection(webSection); - } - - /** - * Method description - * - * - * @param c - * @param repositoryName - * - * @return - */ - boolean registerMissingHook(INIConfiguration c, String repositoryName) - { - INISection hooks = c.getSection("hooks"); - - if (hooks == null) - { - hooks = new INISection("hooks"); - c.addSection(hooks); - } - - boolean write = false; - - if (appendHook(repositoryName, hooks, "changegroup.scm")) - { - write = true; - } - - if (appendHook(repositoryName, hooks, "pretxnchangegroup.scm")) - { - write = true; - } - - return write; - } - - //~--- set methods ---------------------------------------------------------- - - /** - * Method description - * - * - * @param hooksSection - */ - void setHookParameter(INISection hooksSection) - { - hooksSection.setParameter("changegroup.scm", "python:scmhooks.callback"); - hooksSection.setParameter("pretxnchangegroup.scm", - "python:scmhooks.callback"); - } - - /** - * Method description - * - * - * @param webSection - */ - void setWebParameter(INISection webSection) - { - webSection.setParameter("push_ssl", "false"); - webSection.setParameter("allow_read", "*"); - webSection.setParameter("allow_push", "*"); - } - - //~--- methods -------------------------------------------------------------- - /** * Method description * @@ -434,16 +334,25 @@ public class HgRepositoryHandler File hgrcFile = new File(directory, PATH_HGRC); INIConfiguration hgrc = new INIConfiguration(); - appendWebSection(hgrc); - - // register hooks - appendHookSection(hgrc); + INISection iniSection = new INISection(CONFIG_SECTION_SCMM); + iniSection.setParameter(CONFIG_KEY_REPOSITORY_ID, repository.getId()); + INIConfiguration iniConfiguration = new INIConfiguration(); + iniConfiguration.addSection(iniSection); + hgrc.addSection(iniSection); INIConfigurationWriter writer = new INIConfigurationWriter(); writer.write(hgrc, hgrcFile); } + public String getRepositoryId(File directory) { + try { + return new INIConfigurationReader().read(new File(directory, PATH_HGRC)).getSection(CONFIG_SECTION_SCMM).getParameter(CONFIG_KEY_REPOSITORY_ID); + } catch (IOException e) { + throw new InternalRepositoryException(ContextEntry.ContextBuilder.entity("directory", directory.toString()), "could not read scm configuration file", e); + } + } + //~--- get methods ---------------------------------------------------------- /** @@ -460,157 +369,6 @@ public class HgRepositoryHandler //~--- methods -------------------------------------------------------------- - /** - * Method description - * - * - * @param repositoryName - * @param hooks - * @param hookName - * - * @return - */ - private boolean appendHook(String repositoryName, INISection hooks, - String hookName) - { - boolean write = false; - String hook = hooks.getParameter(hookName); - - if (Util.isEmpty(hook)) - { - if (logger.isInfoEnabled()) - { - logger.info("register missing {} hook for respository {}", hookName, - repositoryName); - } - - hooks.setParameter(hookName, "python:scmhooks.callback"); - write = true; - } - - return write; - } - - /** - * Method description - * - * - * @param file - */ - private void createNewFile(File file) - { - try - { - if (!file.createNewFile() && logger.isErrorEnabled()) - { - logger.error("could not create file {}", file); - } - } - catch (IOException ex) - { - logger.error("could not create file {}".concat(file.getPath()), ex); - } - } - - /** - * Method description - * - * - * @param repositoryDir - * - * @return - */ - private boolean registerMissingHook(File repositoryDir) - { - boolean result = false; - File hgrc = new File(repositoryDir, PATH_HGRC); - - if (hgrc.exists()) - { - try - { - INIConfigurationReader reader = new INIConfigurationReader(); - INIConfiguration c = reader.read(hgrc); - String repositoryName = repositoryDir.getName(); - - if (registerMissingHook(c, repositoryName)) - { - if (logger.isDebugEnabled()) - { - logger.debug("rewrite hgrc for repository {}", repositoryName); - } - - INIConfigurationWriter writer = new INIConfigurationWriter(); - - writer.write(c, hgrc); - } - - result = true; - } - catch (IOException ex) - { - logger.error("could not register missing hook", ex); - } - } - - return result; - } - - /** - * Method description - * - */ - private void registerMissingHooks() - { - HgConfig c = getConfig(); - - if (c != null) - { - File repositoryDirectroy = c.getRepositoryDirectory(); - - if (repositoryDirectroy.exists()) - { - File lockFile = new File(repositoryDirectroy, PATH_HOOK); - - if (!lockFile.exists()) - { - File[] dirs = - repositoryDirectroy.listFiles(DirectoryFileFilter.instance); - boolean success = true; - - if (Util.isNotEmpty(dirs)) - { - for (File dir : dirs) - { - if (!registerMissingHook(dir)) - { - success = false; - } - } - } - - if (success) - { - createNewFile(lockFile); - } - } - else if (logger.isDebugEnabled()) - { - logger.debug("hooks allready registered"); - } - } - else if (logger.isDebugEnabled()) - { - logger.debug( - "repository directory does not exists, could not register missing hooks"); - } - } - else if (logger.isDebugEnabled()) - { - logger.debug("config is not available, could not register missing hooks"); - } - } - /** * Method description * diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/AbstractHgPushOrPullCommand.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/AbstractHgPushOrPullCommand.java index 43fb759433..4782d03756 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/AbstractHgPushOrPullCommand.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/AbstractHgPushOrPullCommand.java @@ -78,7 +78,7 @@ public class AbstractHgPushOrPullCommand extends AbstractCommand if (repo != null) { url = - handler.getDirectory(request.getRemoteRepository()).getAbsolutePath(); + handler.getDirectory(request.getRemoteRepository().getId()).getAbsolutePath(); } else if (request.getRemoteUrl() != null) { diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgHookChangesetProvider.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgHookChangesetProvider.java index 59ad0c3345..e4e3dc238e 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgHookChangesetProvider.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgHookChangesetProvider.java @@ -62,11 +62,11 @@ public class HgHookChangesetProvider implements HookChangesetProvider //~--- constructors --------------------------------------------------------- public HgHookChangesetProvider(HgRepositoryHandler handler, - String id, HgHookManager hookManager, String startRev, + File repositoryDirectory, HgHookManager hookManager, String startRev, RepositoryHookType type) { this.handler = handler; - this.id = id; + this.repositoryDirectory = repositoryDirectory; this.hookManager = hookManager; this.startRev = startRev; this.type = type; @@ -123,9 +123,6 @@ public class HgHookChangesetProvider implements HookChangesetProvider */ private Repository open() { - File directory = handler.getConfig().getRepositoryDirectory(); - File repositoryDirectory = new File(directory, id); - // use HG_PENDING only for pre receive hooks boolean pending = type == RepositoryHookType.PRE_RECEIVE; @@ -143,7 +140,7 @@ public class HgHookChangesetProvider implements HookChangesetProvider private HgHookManager hookManager; /** Field description */ - private String id; + private File repositoryDirectory; /** Field description */ private HookChangesetResponse response; diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgHookContextProvider.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgHookContextProvider.java index 5b354ecec4..414cfe27b8 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgHookContextProvider.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgHookContextProvider.java @@ -44,6 +44,7 @@ import sonia.scm.repository.api.HookFeature; import sonia.scm.repository.api.HookMessageProvider; import sonia.scm.repository.api.HookTagProvider; +import java.io.File; import java.util.EnumSet; import java.util.Set; @@ -67,16 +68,16 @@ public class HgHookContextProvider extends HookContextProvider * Constructs a new instance. * * @param handler mercurial repository handler - * @param namespaceAndName namespace and name of changed repository + * @param repositoryDirectory the directory of the changed repository * @param hookManager mercurial hook manager * @param startRev start revision * @param type type of hook */ public HgHookContextProvider(HgRepositoryHandler handler, - String id, HgHookManager hookManager, String startRev, - RepositoryHookType type) + File repositoryDirectory, HgHookManager hookManager, String startRev, + RepositoryHookType type) { - this.hookChangesetProvider = new HgHookChangesetProvider(handler, id, hookManager, startRev, type); + this.hookChangesetProvider = new HgHookChangesetProvider(handler, repositoryDirectory, hookManager, startRev, type); } //~--- get methods ---------------------------------------------------------- diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgIncomingCommand.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgIncomingCommand.java index c60f2c5712..52a4ddf261 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgIncomingCommand.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgIncomingCommand.java @@ -81,7 +81,7 @@ public class HgIncomingCommand extends AbstractCommand @Override @SuppressWarnings("unchecked") public ChangesetPagingResult getIncomingChangesets(IncomingCommandRequest request) { - File remoteRepository = handler.getDirectory(request.getRemoteRepository()); + File remoteRepository = handler.getDirectory(request.getRemoteRepository().getId()); com.aragost.javahg.Repository repository = open(); diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgModificationsCommand.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgModificationsCommand.java index c67b9ff5d9..f9a67f8656 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgModificationsCommand.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgModificationsCommand.java @@ -4,8 +4,6 @@ import sonia.scm.repository.Modifications; import sonia.scm.repository.Repository; import sonia.scm.repository.spi.javahg.HgLogChangesetCommand; -import java.text.MessageFormat; - public class HgModificationsCommand extends AbstractCommand implements ModificationsCommand { HgModificationsCommand(HgCommandContext context, Repository repository) { @@ -17,8 +15,7 @@ public class HgModificationsCommand extends AbstractCommand implements Modificat public Modifications getModifications(String revision) { com.aragost.javahg.Repository repository = open(); HgLogChangesetCommand hgLogChangesetCommand = HgLogChangesetCommand.on(repository, getContext().getConfig()); - int hgRevision = hgLogChangesetCommand.rev(revision).singleRevision(); - Modifications modifications = hgLogChangesetCommand.rev(MessageFormat.format("{0}:{0}", hgRevision)).extractModifications(); + Modifications modifications = hgLogChangesetCommand.rev(revision).extractModifications(); modifications.setRevision(revision); return modifications; } diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgOutgoingCommand.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgOutgoingCommand.java index 81bee6b9ff..4bbf30e936 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgOutgoingCommand.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgOutgoingCommand.java @@ -83,7 +83,7 @@ public class HgOutgoingCommand extends AbstractCommand public ChangesetPagingResult getOutgoingChangesets( OutgoingCommandRequest request) { - File remoteRepository = handler.getDirectory(request.getRemoteRepository()); + File remoteRepository = handler.getDirectory(request.getRemoteRepository().getId()); com.aragost.javahg.Repository repository = open(); diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgRepositoryServiceProvider.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgRepositoryServiceProvider.java index cf9beb5a0c..2c18aea2c3 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgRepositoryServiceProvider.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgRepositoryServiceProvider.java @@ -81,7 +81,7 @@ public class HgRepositoryServiceProvider extends RepositoryServiceProvider { this.repository = repository; this.handler = handler; - this.repositoryDirectory = handler.getDirectory(repository); + this.repositoryDirectory = handler.getDirectory(repository.getId()); this.context = new HgCommandContext(hookManager, handler, repository, repositoryDirectory); } diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/javahg/HgFileviewCommand.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/javahg/HgFileviewCommand.java index f351ffa572..0897a191a1 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/javahg/HgFileviewCommand.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/javahg/HgFileviewCommand.java @@ -41,7 +41,6 @@ import com.aragost.javahg.internals.AbstractCommand; import com.aragost.javahg.internals.HgInputStream; import com.google.common.base.Strings; -import com.google.common.collect.Lists; import sonia.scm.repository.FileObject; import sonia.scm.repository.SubRepository; @@ -52,7 +51,6 @@ import java.io.IOException; import java.util.Deque; import java.util.LinkedList; -import java.util.List; /** * Mercurial command to list files of a repository. diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/javahg/HgLogChangesetCommand.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/javahg/HgLogChangesetCommand.java index f57c2a63d9..12a77ac717 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/javahg/HgLogChangesetCommand.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/javahg/HgLogChangesetCommand.java @@ -1,19 +1,19 @@ /** * Copyright (c) 2010, Sebastian Sdorra * All rights reserved. - * + * <p> * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: - * + * <p> * 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. + * this list of conditions and the following disclaimer. * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. * 3. Neither the name of SCM-Manager; nor the names of its - * contributors may be used to endorse or promote products derived from this - * software without specific prior written permission. - * + * contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + * <p> * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE @@ -24,99 +24,64 @@ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - * + * <p> * http://bitbucket.org/sdorra/scm-manager - * */ - package sonia.scm.repository.spi.javahg; -//~--- non-JDK imports -------------------------------------------------------- - import com.aragost.javahg.Repository; import com.aragost.javahg.internals.HgInputStream; import com.aragost.javahg.internals.Utils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import sonia.scm.repository.Changeset; import sonia.scm.repository.HgConfig; import sonia.scm.repository.Modifications; +import java.io.IOException; import java.util.List; -//~--- JDK imports ------------------------------------------------------------ - /** - * * @author Sebastian Sdorra */ -public class HgLogChangesetCommand extends AbstractChangesetCommand -{ +public class HgLogChangesetCommand extends AbstractChangesetCommand { - /** - * Constructs ... - * - * - * @param repository - * @param config - */ - private HgLogChangesetCommand(Repository repository, HgConfig config) - { + private static final Logger LOG = LoggerFactory.getLogger(HgLogChangesetCommand.class); + + private HgLogChangesetCommand(Repository repository, HgConfig config) { super(repository, config); } - //~--- methods -------------------------------------------------------------- - /** - * Method description - * - * - * @param repository - * @param config - * - * @return - */ - public static HgLogChangesetCommand on(Repository repository, HgConfig config) - { + public static HgLogChangesetCommand on(Repository repository, HgConfig config) { return new HgLogChangesetCommand(repository, config); } - /** - * Method description - * - * - * @param branch - * - * @return - */ - public HgLogChangesetCommand branch(String branch) - { + + public HgLogChangesetCommand branch(String branch) { cmdAppend("-b", branch); return this; } - /** - * Method description - * - * - * @param files - * - * @return - */ - public List<Changeset> execute(String... files) - { + + public List<Changeset> execute(String... files) { return readListFromStream(getHgInputStream(files, CHANGESET_EAGER_STYLE_PATH)); } - /** - * Extract Modifications from the Repository files - * - * @param files repo files - * @return modifications - */ public Modifications extractModifications(String... files) { - return readModificationsFromStream(getHgInputStream(files, CHANGESET_EAGER_STYLE_PATH)); + HgInputStream hgInputStream = getHgInputStream(files, CHANGESET_EAGER_STYLE_PATH); + try { + return readModificationsFromStream(hgInputStream); + } finally { + try { + hgInputStream.close(); + } catch (IOException e) { + LOG.error("Could not close HgInputStream", e); + } + } } HgInputStream getHgInputStream(String[] files, String changesetStylePath) { @@ -124,93 +89,39 @@ public class HgLogChangesetCommand extends AbstractChangesetCommand return launchStream(files); } - /** - * Method description - * - * - * @param limit - * - * @return - */ - public HgLogChangesetCommand limit(int limit) - { + public HgLogChangesetCommand limit(int limit) { cmdAppend("-l", limit); return this; } - /** - * Method description - * - * - * @param files - * - * @return - */ - public List<Integer> loadRevisions(String... files) - { + + public List<Integer> loadRevisions(String... files) { return loadRevisionsFromStream(getHgInputStream(files, CHANGESET_LAZY_STYLE_PATH)); } - /** - * Method description - * - * - * @param rev - * - * @return - */ - public HgLogChangesetCommand rev(String... rev) - { + public HgLogChangesetCommand rev(String... rev) { cmdAppend("-r", rev); return this; } - /** - * Method description - * - * - * @param files - * - * @return - */ - public Changeset single(String... files) - { + public Changeset single(String... files) { return Utils.single(execute(files)); } - /** - * Method description - * - * - * @param files - * - * @return - */ - public int singleRevision(String... files) - { + public int singleRevision(String... files) { Integer rev = Utils.single(loadRevisions(files)); - if (rev == null) - { + if (rev == null) { rev = -1; } return rev; } - //~--- get methods ---------------------------------------------------------- - - /** - * Method description - * - * - * @return - */ @Override - public String getCommandName() - { + public String getCommandName() { return "log"; } } diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/web/HgCGIServlet.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/web/HgCGIServlet.java index 1821f92fa4..45eba94442 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/web/HgCGIServlet.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/web/HgCGIServlet.java @@ -56,15 +56,20 @@ import sonia.scm.web.cgi.CGIExecutor; import sonia.scm.web.cgi.CGIExecutorFactory; import sonia.scm.web.cgi.EnvList; +//~--- JDK imports ------------------------------------------------------------ + +import java.io.File; +import java.io.IOException; + +import java.util.Enumeration; +import java.util.Map; + import javax.servlet.ServletException; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; -import java.io.File; -import java.io.IOException; import java.util.Base64; -import java.util.Enumeration; /** * @@ -74,12 +79,19 @@ import java.util.Enumeration; public class HgCGIServlet extends HttpServlet implements ScmProviderHttpServlet { + private static final String ENV_PYTHON_HTTPS_VERIFY = "PYTHONHTTPSVERIFY"; + /** Field description */ public static final String ENV_REPOSITORY_NAME = "REPO_NAME"; /** Field description */ public static final String ENV_REPOSITORY_PATH = "SCM_REPOSITORY_PATH"; + /** Field description */ + public static final String ENV_REPOSITORY_ID = "SCM_REPOSITORY_ID"; + + private static final String ENV_HTTP_POST_ARGS = "SCM_HTTP_POST_ARGS"; + /** Field description */ public static final String ENV_SESSION_PREFIX = "SCM_"; @@ -250,8 +262,7 @@ public class HgCGIServlet extends HttpServlet implements ScmProviderHttpServlet HttpServletResponse response, Repository repository) throws IOException, ServletException { - String name = repository.getName(); - File directory = handler.getDirectory(repository); + File directory = handler.getDirectory(repository.getId()); CGIExecutor executor = cgiExecutorFactory.createExecutor(configuration, getServletContext(), request, response); @@ -261,15 +272,27 @@ public class HgCGIServlet extends HttpServlet implements ScmProviderHttpServlet executor.setStatusCodeHandler(exceptionHandler); executor.setContentLengthWorkaround(true); executor.getEnvironment().set(ENV_REPOSITORY_NAME, repository.getNamespace() + "/" + repository.getName()); + executor.getEnvironment().set(ENV_REPOSITORY_ID, repository.getId()); executor.getEnvironment().set(ENV_REPOSITORY_PATH, directory.getAbsolutePath()); // add hook environment + Map<String, String> environment = executor.getEnvironment().asMutableMap(); + if (handler.getConfig().isDisableHookSSLValidation()) { + // disable ssl validation + // Issue 959: https://goo.gl/zH5eY8 + environment.put(ENV_PYTHON_HTTPS_VERIFY, "0"); + } + + // enable experimental httppostargs protocol of mercurial + // Issue 970: https://goo.gl/poascp + environment.put(ENV_HTTP_POST_ARGS, String.valueOf(handler.getConfig().isEnableHttpPostArgs())); + //J- HgEnvironment.prepareEnvironment( - executor.getEnvironment().asMutableMap(), + environment, handler, - hookManager, + hookManager, request ); //J+ diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/web/HgHookCallbackServlet.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/web/HgHookCallbackServlet.java index 25a368d25b..1b31eb11ca 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/web/HgHookCallbackServlet.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/web/HgHookCallbackServlet.java @@ -35,6 +35,7 @@ package sonia.scm.web; //~--- non-JDK imports -------------------------------------------------------- +import com.google.common.base.Preconditions; import com.google.common.base.Strings; import com.google.common.io.Closeables; import com.google.inject.Inject; @@ -49,7 +50,6 @@ import sonia.scm.repository.HgContext; import sonia.scm.repository.HgHookManager; import sonia.scm.repository.HgRepositoryHandler; import sonia.scm.repository.RepositoryHookType; -import sonia.scm.repository.RepositoryUtil; import sonia.scm.repository.api.HgHookMessage; import sonia.scm.repository.api.HgHookMessage.Severity; import sonia.scm.repository.spi.HgHookContextProvider; @@ -63,6 +63,7 @@ import javax.servlet.ServletException; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import java.io.File; import java.io.IOException; import java.io.PrintWriter; import java.util.List; @@ -86,7 +87,7 @@ public class HgHookCallbackServlet extends HttpServlet public static final String HGHOOK_PRE_RECEIVE = "pretxnchangegroup"; /** Field description */ - public static final String PARAM_REPOSITORYPATH = "repositoryPath"; + public static final String PARAM_REPOSITORYID = "repositoryId"; /** Field description */ private static final String PARAM_CHALLENGE = "challenge"; @@ -113,20 +114,10 @@ public class HgHookCallbackServlet extends HttpServlet //~--- constructors --------------------------------------------------------- - /** - * Constructs ... - * - * - * - * @param hookEventFacade - * @param handler - * @param hookManager - * @param contextProvider - */ @Inject public HgHookCallbackServlet(HookEventFacade hookEventFacade, - HgRepositoryHandler handler, HgHookManager hookManager, - Provider<HgContext> contextProvider) + HgRepositoryHandler handler, HgHookManager hookManager, + Provider<HgContext> contextProvider) { this.hookEventFacade = hookEventFacade; this.handler = handler; @@ -148,7 +139,6 @@ public class HgHookCallbackServlet extends HttpServlet */ @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) - throws ServletException, IOException { String ping = request.getParameter(PARAM_PING); @@ -179,7 +169,7 @@ public class HgHookCallbackServlet extends HttpServlet if (m.matches()) { - String id = getRepositoryId(request); + String repositoryId = getRepositoryId(request); String type = m.group(1); String challenge = request.getParameter(PARAM_CHALLENGE); @@ -196,7 +186,7 @@ public class HgHookCallbackServlet extends HttpServlet authenticate(request, credentials); } - hookCallback(response, id, type, challenge, node); + hookCallback(response, type, repositoryId, challenge, node); } else if (logger.isDebugEnabled()) { @@ -255,8 +245,7 @@ public class HgHookCallbackServlet extends HttpServlet } } - private void fireHook(HttpServletResponse response, String id, - String node, RepositoryHookType type) + private void fireHook(HttpServletResponse response, String repositoryId, String node, RepositoryHookType type) throws IOException { HgHookContextProvider context = null; @@ -268,10 +257,11 @@ public class HgHookCallbackServlet extends HttpServlet contextProvider.get().setPending(true); } - context = new HgHookContextProvider(handler, id, hookManager, + File repositoryDirectory = handler.getDirectory(repositoryId); + context = new HgHookContextProvider(handler, repositoryDirectory, hookManager, node, type); - hookEventFacade.handle(id).fireHookEvent(type, context); + hookEventFacade.handle(repositoryId).fireHookEvent(type, context); printMessages(response, context); } @@ -289,7 +279,7 @@ public class HgHookCallbackServlet extends HttpServlet } } - private void hookCallback(HttpServletResponse response, String id, String typeName, String challenge, String node) throws IOException { + private void hookCallback(HttpServletResponse response, String typeName, String repositoryId, String challenge, String node) throws IOException { if (hookManager.isAcceptAble(challenge)) { RepositoryHookType type = null; @@ -305,7 +295,7 @@ public class HgHookCallbackServlet extends HttpServlet if (type != null) { - fireHook(response, id, node, type); + fireHook(response, repositoryId, node, type); } else { @@ -450,40 +440,10 @@ public class HgHookCallbackServlet extends HttpServlet //~--- get methods ---------------------------------------------------------- - /** - * Method description - * - * - * @param request - * - * @return - */ private String getRepositoryId(HttpServletRequest request) { - String id = null; - String path = request.getParameter(PARAM_REPOSITORYPATH); - - if (Util.isNotEmpty(path)) - { - - /** - * use canonical path to fix symbolic links - * https://bitbucket.org/sdorra/scm-manager/issue/82/symbolic-link-in-hg-repository-path - */ - try - { - id = RepositoryUtil.getRepositoryId(handler, path); - } - catch (IOException ex) - { - logger.error("could not find namespace and name of repository", ex); - } - } - else if (logger.isWarnEnabled()) - { - logger.warn("no repository path parameter found"); - } - + String id = request.getParameter(PARAM_REPOSITORYID); + Preconditions.checkArgument(!Strings.isNullOrEmpty(id), "repository id not found in request"); return id; } diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/web/HgPermissionFilter.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/web/HgPermissionFilter.java index 7f92cc0357..18b716b665 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/web/HgPermissionFilter.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/web/HgPermissionFilter.java @@ -33,13 +33,22 @@ package sonia.scm.web; +//~--- non-JDK imports -------------------------------------------------------- + +import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.ImmutableSet; import sonia.scm.config.ScmConfiguration; +import sonia.scm.repository.Repository; import sonia.scm.repository.spi.ScmProviderHttpServlet; import sonia.scm.web.filter.PermissionFilter; +import sonia.scm.repository.HgRepositoryHandler; + +import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import java.util.Set; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; /** * Permission filter for mercurial repositories. @@ -51,14 +60,48 @@ public class HgPermissionFilter extends PermissionFilter private static final Set<String> READ_METHODS = ImmutableSet.of("GET", "HEAD", "OPTIONS", "TRACE"); - public HgPermissionFilter(ScmConfiguration configuration, ScmProviderHttpServlet delegate) + private final HgRepositoryHandler repositoryHandler; + + public HgPermissionFilter(ScmConfiguration configuration, ScmProviderHttpServlet delegate, HgRepositoryHandler repositoryHandler) { super(configuration, delegate); + this.repositoryHandler = repositoryHandler; + } + + @Override + public void service(HttpServletRequest request, HttpServletResponse response, Repository repository) throws IOException, ServletException { + super.service(wrapRequestIfRequired(request), response, repository); + } + + @VisibleForTesting + HttpServletRequest wrapRequestIfRequired(HttpServletRequest request) { + if (isHttpPostArgsEnabled()) { + return new HgServletRequest(request); + } + return request; } @Override public boolean isWriteRequest(HttpServletRequest request) { - return !READ_METHODS.contains(request.getMethod()); + if (isHttpPostArgsEnabled()) { + return isHttpPostArgsWriteRequest(request); + } + return isDefaultWriteRequest(request); + } + + private boolean isHttpPostArgsEnabled() { + return repositoryHandler.getConfig().isEnableHttpPostArgs(); + } + + private boolean isHttpPostArgsWriteRequest(HttpServletRequest request) { + return WireProtocol.isWriteRequest(request); + } + + private boolean isDefaultWriteRequest(HttpServletRequest request) { + if (READ_METHODS.contains(request.getMethod())) { + return WireProtocol.isWriteRequest(request); + } + return true; } } diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/web/HgPermissionFilterFactory.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/web/HgPermissionFilterFactory.java index 90f53a1fea..479c3bd986 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/web/HgPermissionFilterFactory.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/web/HgPermissionFilterFactory.java @@ -12,10 +12,12 @@ import javax.inject.Inject; public class HgPermissionFilterFactory implements ScmProviderHttpServletDecoratorFactory { private final ScmConfiguration configuration; + private final HgRepositoryHandler repositoryHandler; @Inject - public HgPermissionFilterFactory(ScmConfiguration configuration) { + public HgPermissionFilterFactory(ScmConfiguration configuration, HgRepositoryHandler repositoryHandler) { this.configuration = configuration; + this.repositoryHandler = repositoryHandler; } @Override @@ -25,6 +27,6 @@ public class HgPermissionFilterFactory implements ScmProviderHttpServletDecorato @Override public ScmProviderHttpServlet createDecorator(ScmProviderHttpServlet delegate) { - return new HgPermissionFilter(configuration, delegate); + return new HgPermissionFilter(configuration, delegate,repositoryHandler); } } diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/web/HgServletInputStream.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/web/HgServletInputStream.java new file mode 100644 index 0000000000..b0b2f8ef0d --- /dev/null +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/web/HgServletInputStream.java @@ -0,0 +1,55 @@ +package sonia.scm.web; + +import com.google.common.base.Preconditions; + +import javax.servlet.ServletInputStream; +import java.io.ByteArrayInputStream; +import java.io.IOException; + +/** + * HgServletInputStream is a wrapper around the original {@link ServletInputStream} and provides some extra + * functionality to support the mercurial client. + */ +public class HgServletInputStream extends ServletInputStream { + + private final ServletInputStream original; + private ByteArrayInputStream captured; + + HgServletInputStream(ServletInputStream original) { + this.original = original; + } + + /** + * Reads the given amount of bytes from the stream and captures them, if the {@link #read()} methods is called the + * captured bytes are returned before the rest of the stream. + * + * @param size amount of bytes to read + * + * @return byte array + * + * @throws IOException if the method is called twice + */ + public byte[] readAndCapture(int size) throws IOException { + Preconditions.checkState(captured == null, "readAndCapture can only be called once per request"); + + // TODO should we enforce a limit? to prevent OOM? + byte[] bytes = new byte[size]; + original.read(bytes); + captured = new ByteArrayInputStream(bytes); + + return bytes; + } + + @Override + public int read() throws IOException { + if (captured != null && captured.available() > 0) { + return captured.read(); + } + return original.read(); + } + + @Override + public void close() throws IOException { + original.close(); + } +} diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/web/HgServletRequest.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/web/HgServletRequest.java new file mode 100644 index 0000000000..80251c140a --- /dev/null +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/web/HgServletRequest.java @@ -0,0 +1,31 @@ +package sonia.scm.web; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletRequestWrapper; +import java.io.IOException; + +/** + * {@link HttpServletRequestWrapper} which adds some functionality in order to support the mercurial client. + */ +public final class HgServletRequest extends HttpServletRequestWrapper { + + private HgServletInputStream hgServletInputStream; + + /** + * Constructs a request object wrapping the given request. + * + * @param request + * @throws IllegalArgumentException if the request is null + */ + public HgServletRequest(HttpServletRequest request) { + super(request); + } + + @Override + public HgServletInputStream getInputStream() throws IOException { + if (hgServletInputStream == null) { + hgServletInputStream = new HgServletInputStream(super.getInputStream()); + } + return hgServletInputStream; + } +} diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/web/HgUtil.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/web/HgUtil.java index ec35762de7..b6b085f3ac 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/web/HgUtil.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/web/HgUtil.java @@ -134,6 +134,8 @@ public final class HgUtil repoConfiguration.setHgBin(handler.getConfig().getHgBinary()); + logger.debug("open hg repository {}: encoding: {}, pending: {}", directory, enc, pending); + return Repository.open(repoConfiguration, directory); } diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/web/WireProtocol.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/web/WireProtocol.java new file mode 100644 index 0000000000..fb84692805 --- /dev/null +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/web/WireProtocol.java @@ -0,0 +1,236 @@ +/** + * Copyright (c) 2018, Sebastian Sdorra + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * 3. Neither the name of SCM-Manager; nor the names of its + * contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * http://bitbucket.org/sdorra/scm-manager + * + */ + + +package sonia.scm.web; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Charsets; +import com.google.common.base.Preconditions; +import com.google.common.base.Strings; +import com.google.common.base.Throwables; +import com.google.common.collect.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.util.HttpUtil; + +import javax.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.util.*; + +/** + * WireProtocol provides methods for handling the mercurial wire protocol. + * + * @see <a href="https://goo.gl/WaVJzw">Mercurial Wire Protocol</a> + */ +public final class WireProtocol { + + private static final Logger LOG = LoggerFactory.getLogger(WireProtocol.class); + + private static final Set<String> READ_COMMANDS = ImmutableSet.of( + "batch", "between", "branchmap", "branches", "capabilities", "changegroup", "changegroupsubset", "clonebundles", + "getbundle", "heads", "hello", "listkeys", "lookup", "known", "stream_out", + // could not find lheads in the wireprotocol description but mercurial 4.5.2 uses it for clone + "lheads" + ); + + private static final Set<String> WRITE_COMMANDS = ImmutableSet.of( + "pushkey", "unbundle" + ); + + private WireProtocol() { + } + + /** + * Returns {@code true} if the request is a write request. The method will always return {@code true}, expect for the + * following cases: + * + * - no command was specified with the request (is required for the hgweb ui) + * - the command in the query string was found in the list of read request + * - if query string contains the batch command, then all commands specified in X-HgArg headers must be + * in the list of read requests + * - in case of enabled HttpPostArgs protocol and query string container the batch command, the header X-HgArgs-Post + * is read and the commands which are specified in the body from 0 to the value of X-HgArgs-Post must be in the list + * of read requests + * + * @param request http request + * + * @return {@code true} for write requests. + */ + public static boolean isWriteRequest(HttpServletRequest request) { + List<String> commands = commandsOf(request); + boolean write = isWriteRequest(commands); + LOG.trace("mercurial request {} is write: {}", commands, write); + return write; + } + + @VisibleForTesting + static boolean isWriteRequest(List<String> commands) { + return !READ_COMMANDS.containsAll(commands); + } + + @VisibleForTesting + static List<String> commandsOf(HttpServletRequest request) { + List<String> listOfCmds = Lists.newArrayList(); + + String cmd = getCommandFromQueryString(request); + if (cmd != null) { + listOfCmds.add(cmd); + if (isBatchCommand(cmd)) { + parseHgArgHeaders(request, listOfCmds); + handleHttpPostArgs(request, listOfCmds); + } + } + return Collections.unmodifiableList(listOfCmds); + } + + private static void handleHttpPostArgs(HttpServletRequest request, List<String> listOfCmds) { + int hgArgsPostSize = request.getIntHeader("X-HgArgs-Post"); + if (hgArgsPostSize > 0) { + + if (request instanceof HgServletRequest) { + HgServletRequest hgRequest = (HgServletRequest) request; + + parseHttpPostArgs(listOfCmds, hgArgsPostSize, hgRequest); + } else { + throw new IllegalArgumentException("could not process the httppostargs protocol without HgServletRequest"); + } + + } + } + + private static void parseHttpPostArgs(List<String> listOfCmds, int hgArgsPostSize, HgServletRequest hgRequest) { + try { + byte[] bytes = hgRequest.getInputStream().readAndCapture(hgArgsPostSize); + // we use iso-8859-1 for encoding, because the post args are normally http headers which are using iso-8859-1 + // see https://tools.ietf.org/html/rfc7230#section-3.2.4 + String hgArgs = new String(bytes, Charsets.ISO_8859_1); + String decoded = decodeValue(hgArgs); + parseHgCommandHeader(listOfCmds, decoded); + } catch (IOException ex) { + throw Throwables.propagate(ex); + } + } + + private static void parseHgArgHeaders(HttpServletRequest request, List<String> listOfCmds) { + Enumeration headerNames = request.getHeaderNames(); + while (headerNames.hasMoreElements()) { + String header = (String) headerNames.nextElement(); + parseHgArgHeader(request, listOfCmds, header); + } + } + + private static void parseHgArgHeader(HttpServletRequest request, List<String> listOfCmds, String header) { + if (isHgArgHeader(header)) { + String value = getHeaderDecoded(request, header); + parseHgArgValue(listOfCmds, value); + } + } + + private static void parseHgArgValue(List<String> listOfCmds, String value) { + if (isHgArgCommandHeader(value)) { + parseHgCommandHeader(listOfCmds, value); + } + } + + private static void parseHgCommandHeader(List<String> listOfCmds, String value) { + String[] cmds = value.substring(5).split(";"); + for (String cmd : cmds ) { + String normalizedCmd = normalize(cmd); + int index = normalizedCmd.indexOf(' '); + if (index > 0) { + listOfCmds.add(normalizedCmd.substring(0, index)); + } else { + listOfCmds.add(normalizedCmd); + } + } + } + + private static String normalize(String cmd) { + return cmd.trim().toLowerCase(Locale.ENGLISH); + } + + private static boolean isHgArgCommandHeader(String value) { + return value.startsWith("cmds="); + } + + private static String getHeaderDecoded(HttpServletRequest request, String header) { + return decodeValue(request.getHeader(header)); + } + + private static String decodeValue(String value) { + return HttpUtil.decode(Strings.nullToEmpty(value)); + } + + private static boolean isHgArgHeader(String header) { + return header.toLowerCase(Locale.ENGLISH).startsWith("x-hgarg-"); + } + + private static boolean isBatchCommand(String cmd) { + return "batch".equalsIgnoreCase(cmd); + } + + private static String getCommandFromQueryString(HttpServletRequest request) { + // we can't use getParameter, because this would inspect the body for form parameters as well + Multimap<String, String> queryParameterMap = createQueryParameterMap(request); + + Collection<String> cmd = queryParameterMap.get("cmd"); + Preconditions.checkArgument(cmd.size() <= 1, "found more than one cmd query parameter"); + Iterator<String> iterator = cmd.iterator(); + + String command = null; + if (iterator.hasNext()) { + command = iterator.next(); + } + return command; + } + + private static Multimap<String,String> createQueryParameterMap(HttpServletRequest request) { + Multimap<String,String> parameterMap = HashMultimap.create(); + + String queryString = request.getQueryString(); + if (!Strings.isNullOrEmpty(queryString)) { + + String[] parameters = queryString.split("&"); + for (String parameter : parameters) { + int index = parameter.indexOf('='); + if (index > 0) { + parameterMap.put(parameter.substring(0, index), parameter.substring(index + 1)); + } else { + parameterMap.put(parameter, "true"); + } + } + + } + + return parameterMap; + } +} diff --git a/scm-plugins/scm-hg-plugin/src/main/js/HgConfigurationForm.js b/scm-plugins/scm-hg-plugin/src/main/js/HgConfigurationForm.js index 2b6fc130bc..8e370a70c6 100644 --- a/scm-plugins/scm-hg-plugin/src/main/js/HgConfigurationForm.js +++ b/scm-plugins/scm-hg-plugin/src/main/js/HgConfigurationForm.js @@ -8,10 +8,11 @@ type Configuration = { "hgBinary": string, "pythonBinary": string, "pythonPath"?: string, - "repositoryDirectory": string, "encoding": string, "useOptimizedBytecode": boolean, "showRevisionInId": boolean, + "disableHookSSLValidation": boolean, + "enableHttpPostArgs": boolean, "disabled": boolean, "_links": Links }; @@ -39,7 +40,7 @@ class HgConfigurationForm extends React.Component<Props, State> { updateValidationStatus = () => { const requiredFields = [ - "hgBinary", "pythonBinary", "repositoryDirectory", "encoding" + "hgBinary", "pythonBinary", "encoding" ]; const validationErrors = []; @@ -99,10 +100,11 @@ class HgConfigurationForm extends React.Component<Props, State> { {this.inputField("hgBinary")} {this.inputField("pythonBinary")} {this.inputField("pythonPath")} - {this.inputField("repositoryDirectory")} {this.inputField("encoding")} {this.checkbox("useOptimizedBytecode")} {this.checkbox("showRevisionInId")} + {this.checkbox("disableHookSSLValidation")} + {this.checkbox("enableHttpPostArgs")} {this.checkbox("disabled")} </> ); diff --git a/scm-plugins/scm-hg-plugin/src/main/js/HgGlobalConfiguration.js b/scm-plugins/scm-hg-plugin/src/main/js/HgGlobalConfiguration.js index e92672a282..4eb4e0da41 100644 --- a/scm-plugins/scm-hg-plugin/src/main/js/HgGlobalConfiguration.js +++ b/scm-plugins/scm-hg-plugin/src/main/js/HgGlobalConfiguration.js @@ -1,6 +1,6 @@ //@flow import React from "react"; -import { Title, GlobalConfiguration } from "@scm-manager/ui-components"; +import { Title, Configuration } from "@scm-manager/ui-components"; import { translate } from "react-i18next"; import HgConfigurationForm from "./HgConfigurationForm"; @@ -18,7 +18,7 @@ class HgGlobalConfiguration extends React.Component<Props> { return ( <div> <Title title={t("scm-hg-plugin.config.title")}/> - <GlobalConfiguration link={link} render={props => <HgConfigurationForm {...props} />}/> + <Configuration link={link} render={props => <HgConfigurationForm {...props} />}/> </div> ); } diff --git a/scm-plugins/scm-hg-plugin/src/main/resources/META-INF/scm/permissions.xml b/scm-plugins/scm-hg-plugin/src/main/resources/META-INF/scm/permissions.xml new file mode 100644 index 0000000000..951bca4d76 --- /dev/null +++ b/scm-plugins/scm-hg-plugin/src/main/resources/META-INF/scm/permissions.xml @@ -0,0 +1,43 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + + Copyright (c) 2010, Sebastian Sdorra + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + 3. Neither the name of SCM-Manager; nor the names of its + contributors may be used to endorse or promote products derived from this + software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY + DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.dd7s + + http://bitbucket.org/sdorra/scm-manager + + +--> +<permissions> + + <permission> + <value>configuration:read,write:hg</value> + </permission> + <permission> + <value>repository:hg:*</value> + </permission> + +</permissions> diff --git a/scm-plugins/scm-hg-plugin/src/main/resources/META-INF/scm/repository-permissions.xml b/scm-plugins/scm-hg-plugin/src/main/resources/META-INF/scm/repository-permissions.xml new file mode 100644 index 0000000000..3b83051504 --- /dev/null +++ b/scm-plugins/scm-hg-plugin/src/main/resources/META-INF/scm/repository-permissions.xml @@ -0,0 +1,7 @@ +<repository-permissions> + <verbs> + <verb>hg</verb> + </verbs> + <roles> + </roles> +</repository-permissions> diff --git a/scm-plugins/scm-hg-plugin/src/main/resources/locales/de/plugins.json b/scm-plugins/scm-hg-plugin/src/main/resources/locales/de/plugins.json index 0824a4ad38..63a8cc8a98 100644 --- a/scm-plugins/scm-hg-plugin/src/main/resources/locales/de/plugins.json +++ b/scm-plugins/scm-hg-plugin/src/main/resources/locales/de/plugins.json @@ -1,9 +1,58 @@ { "scm-hg-plugin": { "information": { - "clone" : "Repository Klonen", - "create" : "Neue Repository erstellen", - "replace" : "Eine existierende Repository aktualisieren" + "clone" : "Repository klonen", + "create" : "Neues Repository erstellen", + "replace" : "Ein bestehendes Repository aktualisieren" + }, + "config": { + "link": "Mercurial", + "title": "Mercurial Konfiguration", + "hgBinary": "HG Binary", + "hgBinaryHelpText": "Pfad des Mercurial Binary.", + "pythonBinary": "Python Binary", + "pythonBinaryHelpText": "Pfad des Python binary.", + "pythonPath": "Python Module Such Pfad", + "pythonPathHelpText": "Python Module Such Pfad (PYTHONPATH).", + "encoding": "Encoding", + "encodingHelpText": "Repository Encoding.", + "useOptimizedBytecode": "Optimized Bytecode (.pyo)", + "useOptimizedBytecodeHelpText": "Verwende den Python '-O' Switch.", + "showRevisionInId": "Revision anzeigen", + "showRevisionInIdHelpText": "Die Revision als Teil der Node ID anzeigen.", + "enableHttpPostArgs": "HttpPostArgs Protocol aktivieren", + "enableHttpPostArgsHelpText": "Aktiviert das experimentelle HttpPostArgs Protokoll von Mercurial. Das HttpPostArgs Protokoll verwendet den Post Request Body anstatt des HTTP Headers um Meta Informationen zu versenden. Dieses Vorgehen reduziert die Header Größe der Mercurial Requests. HttpPostArgs wird seit Mercurial 3.8 unterstützt.", + "disableHookSSLValidation": "SSL Validierung für Hooks deaktivieren", + "disableHookSSLValidationHelpText": "Deaktiviert die Validierung von SSL Zertifikaten für den Mercurial Hook, der die Repositoryänderungen wieder zurück an den SCM-Manager leitet. Diese Option sollte nur benutzt werden, wenn der SCM-Manager ein selbstsigniertes Zertifikat verwendet.", + "disabled": "Deaktiviert", + "disabledHelpText": "Aktiviert oder deaktiviert das Mercurial Plugin.", + "required": "Dieser Konfigurationswert wird benötigt" + } + }, + "permissions" : { + "configuration": { + "read,write": { + "hg": { + "displayName": "Mercurial Konfiguration ändern", + "description": "Darf die Mercurial Konfiguration verändern" + } + } + }, + "repository": { + "hg": { + "*": { + "displayName": "Repository-spezifische Mercurial Konfiguration ändern", + "description": "Darf die Mercurial Konfiguration für alle Repositories verändern." + } + } + } + }, + "verbs": { + "repository": { + "hg": { + "displayName": "Mercurial konfigurieren", + "description": "Darf die Mercurial Konfiguration für dieses Repository verändern." + } } } } diff --git a/scm-plugins/scm-hg-plugin/src/main/resources/locales/en/plugins.json b/scm-plugins/scm-hg-plugin/src/main/resources/locales/en/plugins.json index 903f906c7e..a5d05d5796 100644 --- a/scm-plugins/scm-hg-plugin/src/main/resources/locales/en/plugins.json +++ b/scm-plugins/scm-hg-plugin/src/main/resources/locales/en/plugins.json @@ -14,17 +14,45 @@ "pythonBinaryHelpText": "Location of Python binary.", "pythonPath": "Python Module Search Path", "pythonPathHelpText": "Python Module Search Path (PYTHONPATH).", - "repositoryDirectory": "Repository directory", - "repositoryDirectoryHelpText": "Location of Mercurial repositories.", "encoding": "Encoding", "encodingHelpText": "Repository Encoding.", "useOptimizedBytecode": "Optimized Bytecode (.pyo)", "useOptimizedBytecodeHelpText": "Use the Python '-O' switch.", "showRevisionInId": "Show Revision", "showRevisionInIdHelpText": "Show revision as part of the node id.", + "enableHttpPostArgs": "Enable HttpPostArgs Protocol", + "enableHttpPostArgsHelpText": "Enables the experimental HttpPostArgs Protocol of mercurial. The HttpPostArgs Protocol uses the body of post requests to send the meta information instead of http headers. This helps to reduce the header size of mercurial requests. HttpPostArgs is supported since mercurial 3.8.", + "disableHookSSLValidation": "Disable SSL Validation on Hooks", + "disableHookSSLValidationHelpText": "Disables the validation of ssl certificates for the mercurial hook, which forwards the repository changes back to scm-manager. This option should only be used, if SCM-Manager uses a self signed certificate.", "disabled": "Disabled", "disabledHelpText": "Enable or disable the Mercurial plugin.", "required": "This configuration value is required" } + }, + "permissions" : { + "configuration": { + "read,write": { + "hg": { + "displayName": "Modify Mercurial configuration", + "description": "May change the Mercurial configuration" + } + } + }, + "repository": { + "hg": { + "*": { + "displayName": "Modify repository specific Mercurial configuration", + "description": "May change the Mercurial configuration for repositories" + } + } + } + }, + "verbs": { + "repository": { + "hg": { + "displayName": "configure Mercurial", + "description": "May change the Mercurial configuration for this repository" + } + } } } diff --git a/scm-plugins/scm-hg-plugin/src/main/resources/sonia/scm/hg/ext/fileview.py b/scm-plugins/scm-hg-plugin/src/main/resources/sonia/scm/hg/ext/fileview.py index 6aa9bac2f8..c123660a83 100644 --- a/scm-plugins/scm-hg-plugin/src/main/resources/sonia/scm/hg/ext/fileview.py +++ b/scm-plugins/scm-hg-plugin/src/main/resources/sonia/scm/hg/ext/fileview.py @@ -37,7 +37,22 @@ from collections import defaultdict from mercurial import cmdutil,util cmdtable = {} -command = cmdutil.command(cmdtable) + +try: + from mercurial import registrar + command = registrar.command(cmdtable) +except (AttributeError, ImportError): + # Fallback to hg < 4.3 support + from mercurial import cmdutil + command = cmdutil.command(cmdtable) + +try: + from mercurial.utils import dateutil + _parsedate = dateutil.parsedate +except ImportError: + # compat with hg < 4.6 + from mercurial import util + _parsedate = util.parsedate FILE_MARKER = '<files>' @@ -166,7 +181,7 @@ def collect_sub_repositories(revCtx): subrepos[parts[0].strip()] = subrepo except Exception: pass - + try: hgsubstate = revCtx.filectx('.hgsubstate').data().split('\n') for line in hgsubstate: @@ -201,7 +216,7 @@ class File_Printer: description = 'n/a' if not self.disableLastCommit: linkrev = self.repo[file.linkrev()] - date = '%d %d' % util.parsedate(linkrev.date()) + date = '%d %d' % _parsedate(linkrev.date()) description = linkrev.description() format = '%s %i %s %s\n' if self.transport: diff --git a/scm-plugins/scm-hg-plugin/src/main/resources/sonia/scm/python/hgweb.py b/scm-plugins/scm-hg-plugin/src/main/resources/sonia/scm/python/hgweb.py index 66d5fadc3c..aeb5d6d588 100644 --- a/scm-plugins/scm-hg-plugin/src/main/resources/sonia/scm/python/hgweb.py +++ b/scm-plugins/scm-hg-plugin/src/main/resources/sonia/scm/python/hgweb.py @@ -31,12 +31,31 @@ import os -from mercurial import demandimport +from mercurial import demandimport, ui as uimod, hg from mercurial.hgweb import hgweb, wsgicgi -repositoryPath = os.environ['SCM_REPOSITORY_PATH'] - demandimport.enable() -application = hgweb(repositoryPath) +try: + u = uimod.ui.load() +except AttributeError: + # For installations earlier than Mercurial 4.1 + u = uimod.ui() + +u.setconfig('web', 'push_ssl', 'false') +u.setconfig('web', 'allow_read', '*') +u.setconfig('web', 'allow_push', '*') + +u.setconfig('hooks', 'changegroup.scm', 'python:scmhooks.postHook') +u.setconfig('hooks', 'pretxnchangegroup.scm', 'python:scmhooks.preHook') + +# pass SCM_HTTP_POST_ARGS to enable experimental httppostargs protocol of mercurial +# SCM_HTTP_POST_ARGS is set by HgCGIServlet +# Issue 970: https://goo.gl/poascp +u.setconfig('experimental', 'httppostargs', os.environ['SCM_HTTP_POST_ARGS']) + +# open repository +# SCM_REPOSITORY_PATH contains the repository path and is set by HgCGIServlet +r = hg.repository(u, os.environ['SCM_REPOSITORY_PATH']) +application = hgweb(r) wsgicgi.launch(application) diff --git a/scm-plugins/scm-hg-plugin/src/main/resources/sonia/scm/python/scmhooks.py b/scm-plugins/scm-hg-plugin/src/main/resources/sonia/scm/python/scmhooks.py index e9a58d589f..da4da7f742 100644 --- a/scm-plugins/scm-hg-plugin/src/main/resources/sonia/scm/python/scmhooks.py +++ b/scm-plugins/scm-hg-plugin/src/main/resources/sonia/scm/python/scmhooks.py @@ -41,19 +41,20 @@ import os, urllib, urllib2 baseUrl = os.environ['SCM_URL'] challenge = os.environ['SCM_CHALLENGE'] credentials = os.environ['SCM_CREDENTIALS'] +repositoryId = os.environ['SCM_REPOSITORY_ID'] def printMessages(ui, msgs): for line in msgs: if line.startswith("_e") or line.startswith("_n"): line = line[2:]; - ui.warn(line); + ui.warn('%s\n' % line.rstrip()) def callHookUrl(ui, repo, hooktype, node): abort = True try: url = baseUrl + hooktype ui.debug( "send scm-hook to " + url + " and " + node + "\n" ) - data = urllib.urlencode({'node': node, 'challenge': challenge, 'credentials': credentials, 'repositoryPath': repo.root}) + data = urllib.urlencode({'node': node, 'challenge': challenge, 'credentials': credentials, 'repositoryPath': repo.root, 'repositoryId': repositoryId}) # open url but ignore proxy settings proxy_handler = urllib2.ProxyHandler({}) opener = urllib2.build_opener(proxy_handler) @@ -78,13 +79,13 @@ def callHookUrl(ui, repo, hooktype, node): printMessages(ui, msg.splitlines(True)) else: ui.warn( "ERROR: scm-hook failed with an unknown error\n" ) + ui.traceback() except ValueError: ui.warn( "scm-hook failed with an exception\n" ) + ui.traceback() return abort -def callback(ui, repo, hooktype, node=None, source=None, pending=None, **kwargs): - if pending != None: - pending() +def callback(ui, repo, hooktype, node=None): abort = True if node != None: if len(baseUrl) > 0: @@ -95,3 +96,32 @@ def callback(ui, repo, hooktype, node=None, source=None, pending=None, **kwargs) else: ui.warn("changeset node is not available") return abort + +def preHook(ui, repo, hooktype, node=None, source=None, pending=None, **kwargs): + log_file = open("/tmp/hg_callback.log", "a") + log_file.write("in callHookUrl\n") + log_file.close() + + # older mercurial versions + if pending != None: + pending() + + # newer mercurial version + # we have to make in-memory changes visible to external process + # this does not happen automatically, because mercurial treat our hooks as internal hooks + # see hook.py at mercurial sources _exthook + try: + if repo is not None: + tr = repo.currenttransaction() + repo.dirstate.write(tr) + if tr and not tr.writepending(): + ui.warn("no pending write transaction found") + except AttributeError: + ui.debug("mercurial does not support currenttransation") + # do nothing + + return callback(ui, repo, hooktype, node) + +def postHook(ui, repo, hooktype, node=None, source=None, pending=None, **kwargs): + return callback(ui, repo, hooktype, node) + diff --git a/scm-plugins/scm-hg-plugin/src/main/resources/sonia/scm/styles/changesets-eager.style b/scm-plugins/scm-hg-plugin/src/main/resources/sonia/scm/styles/changesets-eager.style index 5c462fc459..2185c47a05 100644 --- a/scm-plugins/scm-hg-plugin/src/main/resources/sonia/scm/styles/changesets-eager.style +++ b/scm-plugins/scm-hg-plugin/src/main/resources/sonia/scm/styles/changesets-eager.style @@ -1,7 +1,8 @@ header = "%{pattern}" -changeset = "{rev}:{node}{author}\n{date|hgdate}\n{branch}\n{parents}{join(extras,',')}\n{tags}{file_adds}{file_mods}{file_dels}\n{desc}\0" +changeset = "{rev}:{node}{author}\n{date|hgdate}\n{branch}\n{parents}{extras}\n{tags}{file_adds}{file_mods}{file_dels}\n{desc}\0" tag = "t {tag}\n" file_add = "a {file_add}\n" file_mod = "m {file_mod}\n" file_del = "d {file_del}\n" +extra = "{key}={value|stringescape}," footer = "%{pattern}" \ No newline at end of file diff --git a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigAutoConfigurationResourceTest.java b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigAutoConfigurationResourceTest.java index 4b66444bbe..1f88bfe665 100644 --- a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigAutoConfigurationResourceTest.java +++ b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigAutoConfigurationResourceTest.java @@ -14,7 +14,7 @@ import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.runners.MockitoJUnitRunner; +import org.mockito.junit.MockitoJUnitRunner; import sonia.scm.repository.HgConfig; import sonia.scm.repository.HgRepositoryHandler; import sonia.scm.web.HgVndMediaType; diff --git a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigDtoToHgConfigMapperTest.java b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigDtoToHgConfigMapperTest.java index b95056892e..6e181f4886 100644 --- a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigDtoToHgConfigMapperTest.java +++ b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigDtoToHgConfigMapperTest.java @@ -3,11 +3,9 @@ package sonia.scm.api.v2.resources; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.InjectMocks; -import org.mockito.runners.MockitoJUnitRunner; +import org.mockito.junit.MockitoJUnitRunner; import sonia.scm.repository.HgConfig; -import java.io.File; - import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; @@ -23,7 +21,6 @@ public class HgConfigDtoToHgConfigMapperTest { HgConfig config = mapper.map(dto); assertTrue(config.isDisabled()); - assertEquals("repository/directory", config.getRepositoryDirectory().getPath()); assertEquals("ABC", config.getEncoding()); assertEquals("/etc/hg", config.getHgBinary()); @@ -31,18 +28,21 @@ public class HgConfigDtoToHgConfigMapperTest { assertEquals("/etc/", config.getPythonPath()); assertTrue(config.isShowRevisionInId()); assertTrue(config.isUseOptimizedBytecode()); + assertTrue(config.isDisableHookSSLValidation()); + assertTrue(config.isEnableHttpPostArgs()); } private HgConfigDto createDefaultDto() { HgConfigDto configDto = new HgConfigDto(); configDto.setDisabled(true); - configDto.setRepositoryDirectory(new File("repository/directory")); configDto.setEncoding("ABC"); configDto.setHgBinary("/etc/hg"); configDto.setPythonBinary("/py"); configDto.setPythonPath("/etc/"); configDto.setShowRevisionInId(true); configDto.setUseOptimizedBytecode(true); + configDto.setDisableHookSSLValidation(true); + configDto.setEnableHttpPostArgs(true); return configDto; } diff --git a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigInIndexResourceTest.java b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigInIndexResourceTest.java index 27ab74932c..d699a7b836 100644 --- a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigInIndexResourceTest.java +++ b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigInIndexResourceTest.java @@ -15,6 +15,7 @@ import java.net.URI; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; @SubjectAware(configuration = "classpath:sonia/scm/configuration/shiro.ini") public class HgConfigInIndexResourceTest { @@ -50,7 +51,7 @@ public class HgConfigInIndexResourceTest { hgConfigInIndexResource.enrich(context); - assertFalse(root.get("_links").iterator().hasNext()); + assertTrue(root.get("_links").iterator().hasNext()); } @Test diff --git a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigInstallationsResourceTest.java b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigInstallationsResourceTest.java index 65b9c262cb..bcd9543d28 100644 --- a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigInstallationsResourceTest.java +++ b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigInstallationsResourceTest.java @@ -14,7 +14,7 @@ import org.junit.runner.RunWith; import org.mockito.Answers; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.runners.MockitoJUnitRunner; +import org.mockito.junit.MockitoJUnitRunner; import javax.inject.Provider; import javax.servlet.http.HttpServletResponse; diff --git a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigInstallationsToDtoMapperTest.java b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigInstallationsToDtoMapperTest.java index 7cae1d9f7e..80f8ec32b1 100644 --- a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigInstallationsToDtoMapperTest.java +++ b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigInstallationsToDtoMapperTest.java @@ -6,7 +6,7 @@ import org.junit.runner.RunWith; import org.mockito.Answers; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.runners.MockitoJUnitRunner; +import org.mockito.junit.MockitoJUnitRunner; import java.net.URI; import java.util.Arrays; diff --git a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigPackageResourceTest.java b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigPackageResourceTest.java index f1558b6efb..473ddfe4b4 100644 --- a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigPackageResourceTest.java +++ b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigPackageResourceTest.java @@ -17,7 +17,7 @@ import org.junit.runner.RunWith; import org.mockito.Answers; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.runners.MockitoJUnitRunner; +import org.mockito.junit.MockitoJUnitRunner; import sonia.scm.installer.HgPackage; import sonia.scm.installer.HgPackageReader; import sonia.scm.net.ahc.AdvancedHttpClient; diff --git a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigPackagesToDtoMapperTest.java b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigPackagesToDtoMapperTest.java index c4431da6d5..0b5d7b14d0 100644 --- a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigPackagesToDtoMapperTest.java +++ b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigPackagesToDtoMapperTest.java @@ -6,7 +6,7 @@ import org.junit.runner.RunWith; import org.mockito.Answers; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.runners.MockitoJUnitRunner; +import org.mockito.junit.MockitoJUnitRunner; import sonia.scm.installer.HgPackage; import sonia.scm.installer.HgPackages; diff --git a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigResourceTest.java b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigResourceTest.java index 9cd04a0789..e0253ad86a 100644 --- a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigResourceTest.java +++ b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigResourceTest.java @@ -16,15 +16,15 @@ import org.junit.runner.RunWith; import org.mockito.Answers; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.runners.MockitoJUnitRunner; +import org.mockito.junit.MockitoJUnitRunner; import sonia.scm.repository.HgConfig; import sonia.scm.repository.HgRepositoryHandler; import sonia.scm.web.HgVndMediaType; import javax.inject.Provider; import javax.servlet.http.HttpServletResponse; -import java.io.File; import java.io.IOException; +import java.io.UnsupportedEncodingException; import java.net.URI; import java.net.URISyntaxException; @@ -93,14 +93,13 @@ public class HgConfigResourceTest { ObjectNode responseJson = new ObjectMapper().readValue(responseString, ObjectNode.class); assertTrue(responseString.contains("\"disabled\":false")); - assertTrue(responseJson.get("repositoryDirectory").asText().endsWith("repository/directory")); assertTrue(responseString.contains("\"self\":{\"href\":\"/v2/config/hg")); assertTrue(responseString.contains("\"update\":{\"href\":\"/v2/config/hg")); } @Test @SubjectAware(username = "readWrite") - public void shouldGetHgConfigEvenWhenItsEmpty() throws URISyntaxException { + public void shouldGetHgConfigEvenWhenItsEmpty() throws URISyntaxException, UnsupportedEncodingException { when(repositoryHandler.getConfig()).thenReturn(null); MockHttpResponse response = get(); @@ -111,7 +110,7 @@ public class HgConfigResourceTest { @Test @SubjectAware(username = "readOnly") - public void shouldGetHgConfigWithoutUpdateLink() throws URISyntaxException { + public void shouldGetHgConfigWithoutUpdateLink() throws URISyntaxException, UnsupportedEncodingException { MockHttpResponse response = get(); assertEquals(HttpServletResponse.SC_OK, response.getStatus()); @@ -162,7 +161,6 @@ public class HgConfigResourceTest { private HgConfig createConfiguration() { HgConfig config = new HgConfig(); config.setDisabled(false); - config.setRepositoryDirectory(new File("repository/directory")); return config; } diff --git a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigTests.java b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigTests.java index 4167321344..a3430aac43 100644 --- a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigTests.java +++ b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigTests.java @@ -3,8 +3,6 @@ package sonia.scm.api.v2.resources; import sonia.scm.installer.HgPackage; import sonia.scm.repository.HgConfig; -import java.io.File; - import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; @@ -16,7 +14,6 @@ class HgConfigTests { static HgConfig createConfiguration() { HgConfig config = new HgConfig(); config.setDisabled(true); - config.setRepositoryDirectory(new File("repository/directory")); config.setEncoding("ABC"); config.setHgBinary("/etc/hg"); @@ -30,7 +27,6 @@ class HgConfigTests { static void assertEqualsConfiguration(HgConfigDto dto) { assertTrue(dto.isDisabled()); - assertEquals("repository/directory", dto.getRepositoryDirectory().getPath()); assertEquals("ABC", dto.getEncoding()); assertEquals("/etc/hg", dto.getHgBinary()); diff --git a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigToHgConfigDtoMapperTest.java b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigToHgConfigDtoMapperTest.java index 81c50f3d58..d4bc8be549 100644 --- a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigToHgConfigDtoMapperTest.java +++ b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigToHgConfigDtoMapperTest.java @@ -11,7 +11,7 @@ import org.junit.runner.RunWith; import org.mockito.Answers; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.runners.MockitoJUnitRunner; +import org.mockito.junit.MockitoJUnitRunner; import sonia.scm.repository.HgConfig; import java.net.URI; diff --git a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/HgContextProviderTest.java b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/HgContextProviderTest.java new file mode 100644 index 0000000000..de31e2b11e --- /dev/null +++ b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/HgContextProviderTest.java @@ -0,0 +1,87 @@ +package sonia.scm.repository; + +import com.google.inject.AbstractModule; +import com.google.inject.Guice; +import com.google.inject.Injector; +import com.google.inject.Key; +import com.google.inject.OutOfScopeException; +import com.google.inject.Provider; +import com.google.inject.ProvisionException; +import com.google.inject.Scope; +import com.google.inject.servlet.RequestScoped; +import com.google.inject.util.Providers; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class HgContextProviderTest { + + @Mock + private Scope scope; + + @Test + void shouldThrowNonOutOfScopeProvisionExceptions() { + Provider<HgContextRequestStore> provider = () -> { + throw new RuntimeException("something different"); + }; + + when(scope.scope(any(Key.class), any(Provider.class))).thenReturn(provider); + + Injector injector = Guice.createInjector(new HgContextModule(scope)); + + assertThrows(ProvisionException.class, () -> injector.getInstance(HgContext.class)); + } + + @Test + void shouldCreateANewInstanceIfOutOfRequestScope() { + Provider<HgContextRequestStore> provider = () -> { + throw new OutOfScopeException("no request"); + }; + when(scope.scope(any(Key.class), any(Provider.class))).thenReturn(provider); + + Injector injector = Guice.createInjector(new HgContextModule(scope)); + + HgContext contextOne = injector.getInstance(HgContext.class); + HgContext contextTwo = injector.getInstance(HgContext.class); + + assertThat(contextOne).isNotSameAs(contextTwo); + } + + @Test + void shouldInjectFromRequestScope() { + HgContextRequestStore requestStore = new HgContextRequestStore(); + Provider<HgContextRequestStore> provider = Providers.of(requestStore); + + when(scope.scope(any(Key.class), any(Provider.class))).thenReturn(provider); + + Injector injector = Guice.createInjector(new HgContextModule(scope)); + + HgContext contextOne = injector.getInstance(HgContext.class); + HgContext contextTwo = injector.getInstance(HgContext.class); + + assertThat(contextOne).isSameAs(contextTwo); + } + + private static class HgContextModule extends AbstractModule { + + private Scope scope; + + private HgContextModule(Scope scope) { + this.scope = scope; + } + + @Override + protected void configure() { + bindScope(RequestScoped.class, scope); + bind(HgContextRequestStore.class); + bind(HgContext.class).toProvider(HgContextProvider.class); + } + } +} diff --git a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/HgRepositoryHandlerTest.java b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/HgRepositoryHandlerTest.java index 10df3f5009..ed222f5119 100644 --- a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/HgRepositoryHandlerTest.java +++ b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/HgRepositoryHandlerTest.java @@ -34,25 +34,26 @@ package sonia.scm.repository; //~--- non-JDK imports -------------------------------------------------------- +import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; -import sonia.scm.io.DefaultFileSystem; import sonia.scm.store.ConfigurationStoreFactory; import java.io.File; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; //~--- JDK imports ------------------------------------------------------------ /** - * * @author Sebastian Sdorra */ -@RunWith(MockitoJUnitRunner.class) +@RunWith(MockitoJUnitRunner.Silent.class) public class HgRepositoryHandlerTest extends SimpleRepositoryHandlerTestBase { @Mock @@ -67,24 +68,18 @@ public class HgRepositoryHandlerTest extends SimpleRepositoryHandlerTestBase { assertTrue(hgDirectory.exists()); assertTrue(hgDirectory.isDirectory()); + } - File hgrc = new File(hgDirectory, "hgrc"); - - assertTrue(hgrc.exists()); - assertTrue(hgrc.isFile()); - assertTrue(hgrc.length() > 0); + @Before + public void initFactory() { + when(factory.withType(any())).thenCallRealMethod(); } @Override - protected RepositoryHandler createRepositoryHandler(ConfigurationStoreFactory factory, - File directory) { - HgRepositoryHandler handler = new HgRepositoryHandler(factory, - new DefaultFileSystem(), - new HgContextProvider()); + protected RepositoryHandler createRepositoryHandler(ConfigurationStoreFactory factory, RepositoryLocationResolver locationResolver, File directory) { + HgRepositoryHandler handler = new HgRepositoryHandler(factory, new HgContextProvider(), locationResolver, null); handler.init(contextProvider); - handler.getConfig().setRepositoryDirectory(directory); - HgTestUtil.checkForSkip(handler); return handler; @@ -92,18 +87,15 @@ public class HgRepositoryHandlerTest extends SimpleRepositoryHandlerTestBase { @Test public void getDirectory() { - HgRepositoryHandler repositoryHandler = new HgRepositoryHandler(factory, - new DefaultFileSystem(), provider); + HgRepositoryHandler repositoryHandler = new HgRepositoryHandler(factory, provider, locationResolver, null); HgConfig hgConfig = new HgConfig(); - hgConfig.setRepositoryDirectory(new File("/path")); hgConfig.setHgBinary("hg"); hgConfig.setPythonBinary("python"); repositoryHandler.setConfig(hgConfig); - Repository repository = new Repository("id", "git", "Space", "Name"); - - File path = repositoryHandler.getDirectory(repository); - assertEquals("/path/id", path.getAbsolutePath()); + initRepository(); + File path = repositoryHandler.getDirectory(repository.getId()); + assertEquals(repoPath.toString() + File.separator + AbstractSimpleRepositoryHandler.REPOSITORIES_NATIVE_DIRECTORY, path.getAbsolutePath()); } } diff --git a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/HgTestUtil.java b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/HgTestUtil.java index 532038ff2d..131dad0837 100644 --- a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/HgTestUtil.java +++ b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/HgTestUtil.java @@ -36,19 +36,19 @@ package sonia.scm.repository; //~--- non-JDK imports -------------------------------------------------------- import org.junit.Assume; - import sonia.scm.SCMContext; -import sonia.scm.io.FileSystem; import sonia.scm.store.InMemoryConfigurationStoreFactory; -import static org.mockito.Mockito.*; +import javax.servlet.http.HttpServletRequest; +import java.io.File; +import java.nio.file.Path; + +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; //~--- JDK imports ------------------------------------------------------------ -import java.io.File; - -import javax.servlet.http.HttpServletRequest; - /** * * @author Sebastian Sdorra @@ -95,19 +95,19 @@ public final class HgTestUtil * * @return */ - public static HgRepositoryHandler createHandler(File directory) - { + public static HgRepositoryHandler createHandler(File directory) { TempSCMContextProvider context = (TempSCMContextProvider) SCMContext.getContext(); context.setBaseDirectory(directory); - FileSystem fileSystem = mock(FileSystem.class); + PathBasedRepositoryDAO repoDao = mock(PathBasedRepositoryDAO.class); + RepositoryLocationResolver repositoryLocationResolver = new RepositoryLocationResolver(context, repoDao, new InitialRepositoryLocationResolver()); HgRepositoryHandler handler = - new HgRepositoryHandler(new InMemoryConfigurationStoreFactory(), fileSystem, - new HgContextProvider()); - + new HgRepositoryHandler(new InMemoryConfigurationStoreFactory(), new HgContextProvider(), repositoryLocationResolver, null); + Path repoDir = directory.toPath(); + when(repoDao.getPath(any())).thenReturn(repoDir); handler.init(context); return handler; diff --git a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/TempSCMContextProvider.java b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/TempSCMContextProvider.java index bc6794e5a5..0a0064ad44 100644 --- a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/TempSCMContextProvider.java +++ b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/TempSCMContextProvider.java @@ -41,6 +41,7 @@ import sonia.scm.Stage; import java.io.File; import java.io.IOException; +import java.nio.file.Path; /** * @@ -136,6 +137,11 @@ public class TempSCMContextProvider implements SCMContextProvider this.baseDirectory = baseDirectory; } + @Override + public Path resolve(Path path) { + return baseDirectory.toPath().resolve(path); + } + //~--- fields --------------------------------------------------------------- /** Field description */ diff --git a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/AbstractHgCommandTestBase.java b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/AbstractHgCommandTestBase.java index b5284c737c..2e06d4c6ea 100644 --- a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/AbstractHgCommandTestBase.java +++ b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/AbstractHgCommandTestBase.java @@ -40,6 +40,7 @@ import org.junit.Before; import sonia.scm.repository.HgRepositoryHandler; import sonia.scm.repository.HgTestUtil; +import sonia.scm.repository.RepositoryPathNotFoundException; import sonia.scm.repository.RepositoryTestData; import sonia.scm.util.MockUtil; @@ -76,8 +77,7 @@ public class AbstractHgCommandTestBase extends ZippedRepositoryTestBase * @throws IOException */ @Before - public void initHgHandler() throws IOException - { + public void initHgHandler() throws IOException, RepositoryPathNotFoundException { this.handler = HgTestUtil.createHandler(tempFolder.newFolder()); HgTestUtil.checkForSkip(handler); diff --git a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/IncomingOutgoingTestBase.java b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/IncomingOutgoingTestBase.java index 8dca8fdfe6..6c654b0e54 100644 --- a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/IncomingOutgoingTestBase.java +++ b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/IncomingOutgoingTestBase.java @@ -51,6 +51,7 @@ import sonia.scm.repository.HgConfig; import sonia.scm.repository.HgContext; import sonia.scm.repository.HgRepositoryHandler; import sonia.scm.repository.HgTestUtil; +import sonia.scm.repository.RepositoryPathNotFoundException; import sonia.scm.user.User; import sonia.scm.user.UserTestData; import sonia.scm.util.MockUtil; @@ -78,8 +79,7 @@ public abstract class IncomingOutgoingTestBase extends AbstractTestBase * @throws IOException */ @Before - public void initHgHandler() throws IOException - { + public void initHgHandler() throws IOException, RepositoryPathNotFoundException { HgRepositoryHandler temp = HgTestUtil.createHandler(tempFolder.newFolder()); HgTestUtil.checkForSkip(temp); @@ -94,9 +94,9 @@ public abstract class IncomingOutgoingTestBase extends AbstractTestBase outgoing = Repository.create(createConfig(temp), outgoingDirectory); handler = mock(HgRepositoryHandler.class); - when(handler.getDirectory(incomingRepository)).thenReturn( + when(handler.getDirectory(incomingRepository.getId())).thenReturn( incomingDirectory); - when(handler.getDirectory(outgoingRepository)).thenReturn( + when(handler.getDirectory(outgoingRepository.getId())).thenReturn( outgoingDirectory); when(handler.getConfig()).thenReturn(temp.getConfig()); when(handler.getHgContext()).thenReturn(new HgContext()); diff --git a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/web/HgHookCallbackServletTest.java b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/web/HgHookCallbackServletTest.java index a586962bb8..efe9983951 100644 --- a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/web/HgHookCallbackServletTest.java +++ b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/web/HgHookCallbackServletTest.java @@ -1,21 +1,19 @@ package sonia.scm.web; import org.junit.Test; -import sonia.scm.repository.HgConfig; import sonia.scm.repository.HgRepositoryHandler; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; -import java.io.File; import java.io.IOException; -import static org.mockito.Matchers.anyInt; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import static sonia.scm.web.HgHookCallbackServlet.PARAM_REPOSITORYPATH; +import static sonia.scm.web.HgHookCallbackServlet.PARAM_REPOSITORYID; public class HgHookCallbackServletTest { @@ -25,14 +23,11 @@ public class HgHookCallbackServletTest { HgHookCallbackServlet servlet = new HgHookCallbackServlet(null, handler, null, null); HttpServletRequest request = mock(HttpServletRequest.class); HttpServletResponse response = mock(HttpServletResponse.class); - HgConfig config = mock(HgConfig.class); when(request.getContextPath()).thenReturn("http://example.com/scm"); when(request.getRequestURI()).thenReturn("http://example.com/scm/hook/hg/pretxnchangegroup"); - when(request.getParameter(PARAM_REPOSITORYPATH)).thenReturn("/tmp/hg/12345"); - - when(handler.getConfig()).thenReturn(config); - when(config.getRepositoryDirectory()).thenReturn(new File("/tmp/hg")); + String path = "/tmp/hg/12345"; + when(request.getParameter(PARAM_REPOSITORYID)).thenReturn(path); servlet.doPost(request, response); diff --git a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/web/HgPermissionFilterTest.java b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/web/HgPermissionFilterTest.java index 5608bea6e8..f9bc77bbda 100644 --- a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/web/HgPermissionFilterTest.java +++ b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/web/HgPermissionFilterTest.java @@ -31,21 +31,30 @@ package sonia.scm.web; -import javax.servlet.http.HttpServletRequest; +import org.junit.Before; import org.junit.Test; -import static org.junit.Assert.*; - import org.junit.runner.RunWith; import org.mockito.InjectMocks; import org.mockito.Mock; import static org.mockito.Mockito.*; import org.mockito.junit.MockitoJUnitRunner; import sonia.scm.config.ScmConfiguration; +import sonia.scm.repository.HgConfig; +import sonia.scm.repository.HgRepositoryHandler; import sonia.scm.repository.RepositoryProvider; +import javax.servlet.http.HttpServletRequest; + +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.*; +import static sonia.scm.web.WireProtocolRequestMockFactory.CMDS_HEADS_KNOWN_NODES; +import static sonia.scm.web.WireProtocolRequestMockFactory.Namespace.BOOKMARKS; +import static sonia.scm.web.WireProtocolRequestMockFactory.Namespace.PHASES; + /** * Unit tests for {@link HgPermissionFilter}. - * + * * @author Sebastian Sdorra */ @RunWith(MockitoJUnitRunner.class) @@ -56,13 +65,37 @@ public class HgPermissionFilterTest { @Mock private ScmConfiguration configuration; - + @Mock private RepositoryProvider repositoryProvider; - + + @Mock + private HgRepositoryHandler hgRepositoryHandler; + + private WireProtocolRequestMockFactory wireProtocol = new WireProtocolRequestMockFactory("/scm/hg/repo"); + @InjectMocks private HgPermissionFilter filter; - + + @Before + public void setUp() { + when(hgRepositoryHandler.getConfig()).thenReturn(new HgConfig()); + } + + /** + * Tests {@link HgPermissionFilter#wrapRequestIfRequired(HttpServletRequest)}. + */ + @Test + public void testWrapRequestIfRequired() { + assertSame(request, filter.wrapRequestIfRequired(request)); + + HgConfig hgConfig = new HgConfig(); + hgConfig.setEnableHttpPostArgs(true); + when(hgRepositoryHandler.getConfig()).thenReturn(hgConfig); + + assertThat(filter.wrapRequestIfRequired(request), is(instanceOf(HgServletRequest.class))); + } + /** * Tests {@link HgPermissionFilter#isWriteRequest(HttpServletRequest)}. */ @@ -73,7 +106,7 @@ public class HgPermissionFilterTest { assertFalse(isWriteRequest("HEAD")); assertFalse(isWriteRequest("TRACE")); assertFalse(isWriteRequest("OPTIONS")); - + // write methods assertTrue(isWriteRequest("POST")); assertTrue(isWriteRequest("PUT")); @@ -81,8 +114,121 @@ public class HgPermissionFilterTest { assertTrue(isWriteRequest("KA")); } + /** + * Tests {@link HgPermissionFilter#isWriteRequest(HttpServletRequest)} with enabled httppostargs option. + */ + @Test + public void testIsWriteRequestWithEnabledHttpPostArgs() { + HgConfig config = new HgConfig(); + config.setEnableHttpPostArgs(true); + when(hgRepositoryHandler.getConfig()).thenReturn(config); + + assertFalse(isWriteRequest("POST")); + assertFalse(isWriteRequest("POST", "heads")); + assertTrue(isWriteRequest("POST", "unbundle")); + } + private boolean isWriteRequest(String method) { + return isWriteRequest(method, "capabilities"); + } + + private boolean isWriteRequest(String method, String command) { + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getQueryString()).thenReturn("cmd=" + command); when(request.getMethod()).thenReturn(method); return filter.isWriteRequest(request); } + + /** + * Tests {@link HgPermissionFilter#isWriteRequest(HttpServletRequest)} with a set of requests, which are used for a + * fresh clone of a repository. + */ + @Test + public void testIsWriteRequestWithClone() { + assertIsReadRequest(wireProtocol.capabilities()); + assertIsReadRequest(wireProtocol.listkeys(BOOKMARKS)); + assertIsReadRequest(wireProtocol.batch(CMDS_HEADS_KNOWN_NODES)); + assertIsReadRequest(wireProtocol.listkeys(PHASES)); + } + + /** + * Tests {@link HgPermissionFilter#isWriteRequest(HttpServletRequest)} with a set of requests, which are used for a + * push of a single changeset. + */ + @Test + public void testIsWriteRequestWithSingleChangesetPush() { + assertIsReadRequest(wireProtocol.capabilities()); + assertIsReadRequest(wireProtocol.batch(CMDS_HEADS_KNOWN_NODES.concat("c0ceccb3b2f0f5c977ff32b9337519e5f37942c2"))); + assertIsReadRequest(wireProtocol.listkeys(PHASES)); + assertIsReadRequest(wireProtocol.listkeys(BOOKMARKS)); + assertIsWriteRequest(wireProtocol.unbundle(261L, "686173686564+6768033e216468247bd031a0a2d9876d79818f8f")); + assertIsReadRequest(wireProtocol.listkeys(PHASES)); + assertIsWriteRequest(wireProtocol.pushkey("c0ceccb3b2f0f5c977ff32b9337519e5f37942c2&namespace=phases&new=0&old=1")); + } + + /** + * Tests {@link HgPermissionFilter#isWriteRequest(HttpServletRequest)} with a set of requests, which are used for a + * push to a single changeset. + */ + @Test + public void testIsWriteRequestWithMultipleChangesetsPush() { + assertIsReadRequest(wireProtocol.capabilities()); + assertIsReadRequest(wireProtocol.batch(CMDS_HEADS_KNOWN_NODES.concat("ef5993bb4abb32a0565c347844c6d939fc4f4b98"))); + assertIsReadRequest(wireProtocol.listkeys(PHASES)); + assertIsReadRequest(wireProtocol.listkeys(BOOKMARKS)); + assertIsReadRequest(wireProtocol.branchmap()); + assertIsReadRequest(wireProtocol.listkeys(BOOKMARKS)); + assertIsWriteRequest(wireProtocol.unbundle(746L, "686173686564+95373ca7cd5371cb6c49bb755ee451d9ec585845")); + assertIsReadRequest(wireProtocol.listkeys(PHASES)); + assertIsWriteRequest(wireProtocol.pushkey("ef5993bb4abb32a0565c347844c6d939fc4f4b98&namespace=phases&new=0&old=1")); + } + + /** + * Tests {@link HgPermissionFilter#isWriteRequest(HttpServletRequest)} with a set of requests, which are used for a + * push of multiple branches to a new repository. + */ + @Test + public void testIsWriteRequestWithMutlipleBranchesToNewRepositoryPush() { + assertIsReadRequest(wireProtocol.capabilities()); + assertIsReadRequest(wireProtocol.batch(CMDS_HEADS_KNOWN_NODES.concat("ef5993bb4abb32a0565c347844c6d939fc4f4b98"))); + assertIsReadRequest(wireProtocol.known("c0ceccb3b2f0f5c977ff32b9337519e5f37942c2+187ddf37e237c370514487a0bb1a226f11a780b3+b5914611f84eae14543684b2721eec88b0edac12+8b63a323606f10c86b30465570c2574eb7a3a989")); + assertIsReadRequest(wireProtocol.listkeys(PHASES)); + assertIsReadRequest(wireProtocol.listkeys(BOOKMARKS)); + assertIsWriteRequest(wireProtocol.unbundle(913L, "686173686564+6768033e216468247bd031a0a2d9876d79818f8f")); + assertIsReadRequest(wireProtocol.listkeys(PHASES)); + assertIsWriteRequest(wireProtocol.pushkey("ef5993bb4abb32a0565c347844c6d939fc4f4b98&namespace=phases&new=0&old=1")); + } + + /** + * Tests {@link HgPermissionFilter#isWriteRequest(HttpServletRequest)} with a set of requests, which are used for a + * push of a bookmark. + */ + @Test + public void testIsWriteRequestWithBookmarkPush() { + assertIsReadRequest(wireProtocol.capabilities()); + assertIsReadRequest(wireProtocol.batch(CMDS_HEADS_KNOWN_NODES.concat("ef5993bb4abb32a0565c347844c6d939fc4f4b98"))); + assertIsReadRequest(wireProtocol.listkeys(PHASES)); + assertIsReadRequest(wireProtocol.listkeys(BOOKMARKS)); + assertIsReadRequest(wireProtocol.listkeys(PHASES)); + assertIsWriteRequest(wireProtocol.pushkey("markone&namespace=bookmarks&new=ef5993bb4abb32a0565c347844c6d939fc4f4b98&old=")); + } + + /** + * Tests {@link HgPermissionFilter#isWriteRequest(HttpServletRequest)} with a write request hidden in a batch GET + * request. + * + * @see <a href="https://goo.gl/poascp">Issue #970</a> + */ + @Test + public void testIsWriteRequestWithBookmarkPushInABatch() { + assertIsWriteRequest(wireProtocol.batch("pushkey key=markthree,namespace=bookmarks,new=187ddf37e237c370514487a0bb1a226f11a780b3,old=")); + } + + private void assertIsReadRequest(HttpServletRequest request) { + assertFalse(filter.isWriteRequest(request)); + } + + private void assertIsWriteRequest(HttpServletRequest request) { + assertTrue(filter.isWriteRequest(request)); + } } diff --git a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/web/HgServletInputStreamTest.java b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/web/HgServletInputStreamTest.java new file mode 100644 index 0000000000..51b0a050fc --- /dev/null +++ b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/web/HgServletInputStreamTest.java @@ -0,0 +1,50 @@ +package sonia.scm.web; + +import com.google.common.base.Charsets; +import com.google.common.io.ByteStreams; +import org.junit.Test; + +import javax.servlet.ServletInputStream; +import java.io.ByteArrayInputStream; +import java.io.IOException; + +import static org.junit.Assert.assertEquals; + +public class HgServletInputStreamTest { + + @Test + public void testReadAndCapture() throws IOException { + SampleServletInputStream original = new SampleServletInputStream("trillian.mcmillian@hitchhiker.com"); + HgServletInputStream hgServletInputStream = new HgServletInputStream(original); + + byte[] prefix = hgServletInputStream.readAndCapture(8); + assertEquals("trillian", new String(prefix, Charsets.US_ASCII)); + + byte[] wholeBytes = ByteStreams.toByteArray(hgServletInputStream); + assertEquals("trillian.mcmillian@hitchhiker.com", new String(wholeBytes, Charsets.US_ASCII)); + } + + @Test(expected = IllegalStateException.class) + public void testReadAndCaptureCalledTwice() throws IOException { + SampleServletInputStream original = new SampleServletInputStream("trillian.mcmillian@hitchhiker.com"); + HgServletInputStream hgServletInputStream = new HgServletInputStream(original); + + hgServletInputStream.readAndCapture(1); + hgServletInputStream.readAndCapture(1); + } + + private static class SampleServletInputStream extends ServletInputStream { + + private ByteArrayInputStream input; + + private SampleServletInputStream(String data) { + input = new ByteArrayInputStream(data.getBytes()); + } + + @Override + public int read() { + return input.read(); + } + } + +} diff --git a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/web/WireProtocolRequestMockFactory.java b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/web/WireProtocolRequestMockFactory.java new file mode 100644 index 0000000000..d1f5124b3a --- /dev/null +++ b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/web/WireProtocolRequestMockFactory.java @@ -0,0 +1,114 @@ +package sonia.scm.web; + +import com.google.common.collect.Lists; + +import javax.servlet.http.HttpServletRequest; + +import java.util.Collections; +import java.util.List; +import java.util.Locale; + +import static org.mockito.Mockito.*; + +public class WireProtocolRequestMockFactory { + + public enum Namespace { + PHASES, BOOKMARKS; + } + + public static final String CMDS_HEADS_KNOWN_NODES = "heads+%3Bknown+nodes%3D"; + + private String repositoryPath; + + public WireProtocolRequestMockFactory(String repositoryPath) { + this.repositoryPath = repositoryPath; + } + + public HttpServletRequest capabilities() { + return base("GET", "cmd=capabilities"); + } + + public HttpServletRequest listkeys(Namespace namespace) { + HttpServletRequest request = base("GET", "cmd=capabilities"); + header(request, "vary", "X-HgArg-1"); + header(request, "x-hgarg-1", namespaceValue(namespace)); + return request; + } + + public HttpServletRequest branchmap() { + return base("GET", "cmd=branchmap"); + } + + public HttpServletRequest batch(String... args) { + HttpServletRequest request = base("GET", "cmd=batch"); + args(request, "cmds", args); + return request; + } + + public HttpServletRequest unbundle(long contentLength, String... heads) { + HttpServletRequest request = base("POST", "cmd=unbundle"); + header(request, "Content-Length", String.valueOf(contentLength)); + args(request, "heads", heads); + return request; + } + + public HttpServletRequest pushkey(String... keys) { + HttpServletRequest request = base("POST", "cmd=pushkey"); + args(request, "key", keys); + return request; + } + + public HttpServletRequest known(String... nodes) { + HttpServletRequest request = base("GET", "cmd=known"); + args(request, "nodes", nodes); + return request; + } + + private void args(HttpServletRequest request, String prefix, String[] values) { + List<String> headers = Lists.newArrayList(); + + StringBuilder vary = new StringBuilder(); + for ( int i=0; i<values.length; i++ ) { + String header = "X-HgArg-" + (i+1); + + if (i>0) { + vary.append(","); + } + + vary.append(header); + headers.add(header); + + header(request, header, prefix + "=" + values[i]); + } + header(request, "Vary", vary.toString()); + + when(request.getHeaderNames()).thenReturn(Collections.enumeration(headers)); + } + + private HttpServletRequest base(String method, String queryStringValue) { + HttpServletRequest request = mock(HttpServletRequest.class); + + when(request.getRequestURI()).thenReturn(repositoryPath); + when(request.getMethod()).thenReturn(method); + + queryString(request, queryStringValue); + + header(request, "Accept", "application/mercurial-0.1"); + header(request, "Accept-Encoding", "identity"); + header(request, "User-Agent", "mercurial/proto-1.0 (Mercurial 4.3.1)"); + return request; + } + + private void queryString(HttpServletRequest request, String queryString) { + when(request.getQueryString()).thenReturn(queryString); + } + + private void header(HttpServletRequest request, String header, String value) { + when(request.getHeader(header)).thenReturn(value); + } + + private String namespaceValue(Namespace namespace) { + return "namespace=" + namespace.toString().toLowerCase(Locale.ENGLISH); + } + +} diff --git a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/web/WireProtocolTest.java b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/web/WireProtocolTest.java new file mode 100644 index 0000000000..9237127c88 --- /dev/null +++ b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/web/WireProtocolTest.java @@ -0,0 +1,192 @@ +/** + * Copyright (c) 2018, Sebastian Sdorra + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * 3. Neither the name of SCM-Manager; nor the names of its + * contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * http://bitbucket.org/sdorra/scm-manager + * + */ + + +package sonia.scm.web; + +import com.google.common.base.Charsets; +import com.google.common.collect.Lists; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import javax.servlet.ServletInputStream; +import javax.servlet.http.HttpServletRequest; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.Collections; +import java.util.List; + +import static org.hamcrest.Matchers.contains; +import static org.junit.Assert.*; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link WireProtocol}. + */ +@RunWith(MockitoJUnitRunner.class) +public class WireProtocolTest { + + @Mock + private HttpServletRequest request; + + @Test + public void testIsWriteRequestOnPost() { + assertIsWriteRequest("capabilities", "unbundle"); + } + + @Test + public void testIsWriteRequest() { + assertIsWriteRequest("unbundle"); + assertIsWriteRequest("capabilities", "unbundle"); + assertIsWriteRequest("capabilities", "postkeys"); + assertIsReadRequest(); + assertIsReadRequest("capabilities"); + assertIsReadRequest("capabilities", "branches", "branchmap"); + } + + private void assertIsWriteRequest(String... commands) { + List<String> cmdList = Lists.newArrayList(commands); + assertTrue(WireProtocol.isWriteRequest(cmdList)); + } + + private void assertIsReadRequest(String... commands) { + List<String> cmdList = Lists.newArrayList(commands); + assertFalse(WireProtocol.isWriteRequest(cmdList)); + } + + @Test + public void testGetCommandsOf() { + expectQueryCommand("capabilities", "cmd=capabilities"); + expectQueryCommand("unbundle", "cmd=unbundle"); + expectQueryCommand("unbundle", "prefix=stuff&cmd=unbundle"); + expectQueryCommand("unbundle", "cmd=unbundle&suffix=stuff"); + expectQueryCommand("unbundle", "prefix=stuff&cmd=unbundle&suffix=stuff"); + expectQueryCommand("unbundle", "bool=&cmd=unbundle"); + expectQueryCommand("unbundle", "bool&cmd=unbundle"); + expectQueryCommand("unbundle", "prefix=stu==ff&cmd=unbundle"); + } + + @Test + public void testGetCommandsOfWithHgArgsPost() throws IOException { + when(request.getMethod()).thenReturn("POST"); + when(request.getQueryString()).thenReturn("cmd=batch"); + when(request.getIntHeader("X-HgArgs-Post")).thenReturn(29); + when(request.getHeaderNames()).thenReturn(Collections.enumeration(Lists.newArrayList("X-HgArgs-Post"))); + when(request.getInputStream()).thenReturn(new BufferedServletInputStream("cmds=lheads+%3Bknown+nodes%3D")); + + List<String> commands = WireProtocol.commandsOf(new HgServletRequest(request)); + assertThat(commands, contains("batch", "lheads", "known")); + } + + @Test + public void testGetCommandsOfWithBatch() { + prepareBatch("cmds=heads ;known nodes,ef5993bb4abb32a0565c347844c6d939fc4f4b98"); + List<String> commands = WireProtocol.commandsOf(request); + assertThat(commands, contains("batch", "heads", "known")); + } + + @Test + public void testGetCommandsOfWithBatchEncoded() { + prepareBatch("cmds=heads+%3Bknown+nodes%3Def5993bb4abb32a0565c347844c6d939fc4f4b98"); + List<String> commands = WireProtocol.commandsOf(request); + assertThat(commands, contains("batch", "heads", "known")); + } + + @Test + public void testGetCommandsOfWithBatchAndMutlipleLines() { + prepareBatch( + "cmds=heads+%3Bknown+nodes%3Def5993bb4abb32a0565c347844c6d939fc4f4b98", + "cmds=unbundle; postkeys", + "cmds= branchmap p1=r2,p2=r4; listkeys" + ); + List<String> commands = WireProtocol.commandsOf(request); + assertThat(commands, contains("batch", "heads", "known", "unbundle", "postkeys", "branchmap", "listkeys")); + } + + private void prepareBatch(String... args) { + when(request.getQueryString()).thenReturn("cmd=batch"); + List<String> headers = Lists.newArrayList(); + for (int i=0; i<args.length; i++) { + String header = "X-HgArg-" + (i+1); + headers.add(header); + when(request.getHeader(header)).thenReturn(args[i]); + } + when(request.getHeaderNames()).thenReturn(Collections.enumeration(headers)); + } + + @Test(expected = IllegalArgumentException.class) + public void testGetCommandsOfWithMultipleCommandsInQueryString() { + when(request.getQueryString()).thenReturn("cmd=abc&cmd=def"); + WireProtocol.commandsOf(request); + } + + @Test + public void testGetCommandsOfWithoutCmdInQueryString() { + when(request.getQueryString()).thenReturn("abc=def&123=456"); + assertTrue(WireProtocol.commandsOf(request).isEmpty()); + } + + @Test + public void testGetCommandsOfWithEmptyQueryString() { + when(request.getQueryString()).thenReturn(""); + assertTrue(WireProtocol.commandsOf(request).isEmpty()); + } + + @Test + public void testGetCommandsOfWithNullQueryString() { + assertTrue(WireProtocol.commandsOf(request).isEmpty()); + } + + private void expectQueryCommand(String expected, String queryString) { + when(request.getQueryString()).thenReturn(queryString); + List<String> commands = WireProtocol.commandsOf(request); + assertEquals(1, commands.size()); + assertTrue(commands.contains(expected)); + } + + private static class BufferedServletInputStream extends ServletInputStream { + + private ByteArrayInputStream input; + + BufferedServletInputStream(String content) { + this.input = new ByteArrayInputStream(content.getBytes(Charsets.US_ASCII)); + } + + @Override + public int read() { + return input.read(); + } + + } + +} diff --git a/scm-plugins/scm-hg-plugin/yarn.lock b/scm-plugins/scm-hg-plugin/yarn.lock index a211aa0ca1..b47e6e6e32 100644 --- a/scm-plugins/scm-hg-plugin/yarn.lock +++ b/scm-plugins/scm-hg-plugin/yarn.lock @@ -641,9 +641,9 @@ version "0.0.2" resolved "https://registry.yarnpkg.com/@scm-manager/eslint-config/-/eslint-config-0.0.2.tgz#94cc8c3fb4f51f870b235893dc134fc6c423ae85" -"@scm-manager/ui-bundler@^0.0.21": - version "0.0.21" - resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.21.tgz#f8b5fa355415cc67b8aaf8744e1701a299dff647" +"@scm-manager/ui-bundler@^0.0.24": + version "0.0.24" + resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.24.tgz#034d5500c79b438c48d8f7ee985be07c4ea46d1e" dependencies: "@babel/core" "^7.0.0" "@babel/plugin-proposal-class-properties" "^7.0.0" @@ -681,9 +681,10 @@ vinyl-source-stream "^2.0.0" watchify "^3.11.0" -"@scm-manager/ui-extensions@^0.1.1": - version "0.1.1" - resolved "https://registry.yarnpkg.com/@scm-manager/ui-extensions/-/ui-extensions-0.1.1.tgz#966e62d89981e92a14adf7e674e646e76de96d45" +"@scm-manager/ui-extensions@^0.1.2": + version "0.1.2" + resolved "https://registry.yarnpkg.com/@scm-manager/ui-extensions/-/ui-extensions-0.1.2.tgz#0689427ca45c8e4e045b5b9dbc89036f1d2c45fc" + integrity sha512-oIkXcc/VWssnK/yjWKC/Wnq5DZ01rArsz76n4X/0DT0hkGNIKmwk/Fdp7OoXiUEb7+aaPjUX1VvDqlTwCNKPmA== dependencies: react "^16.4.2" react-dom "^16.4.2" diff --git a/scm-plugins/scm-svn-plugin/package.json b/scm-plugins/scm-svn-plugin/package.json index 41f1c88a18..e5cddc0bba 100644 --- a/scm-plugins/scm-svn-plugin/package.json +++ b/scm-plugins/scm-svn-plugin/package.json @@ -6,9 +6,9 @@ "build": "ui-bundler plugin" }, "dependencies": { - "@scm-manager/ui-extensions": "^0.1.1" + "@scm-manager/ui-extensions": "^0.1.2" }, "devDependencies": { - "@scm-manager/ui-bundler": "^0.0.21" + "@scm-manager/ui-bundler": "^0.0.24" } } diff --git a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/api/v2/resources/SvnConfigDto.java b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/api/v2/resources/SvnConfigDto.java index 548944a49c..879e92a186 100644 --- a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/api/v2/resources/SvnConfigDto.java +++ b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/api/v2/resources/SvnConfigDto.java @@ -7,15 +7,12 @@ import lombok.NoArgsConstructor; import lombok.Setter; import sonia.scm.repository.Compatibility; -import java.io.File; - @NoArgsConstructor @Getter @Setter public class SvnConfigDto extends HalRepresentation { private boolean disabled; - private File repositoryDirectory; private boolean enabledGZip; private Compatibility compatibility; diff --git a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/api/v2/resources/SvnConfigInIndexResource.java b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/api/v2/resources/SvnConfigInIndexResource.java index 5ee1de3169..918d38a346 100644 --- a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/api/v2/resources/SvnConfigInIndexResource.java +++ b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/api/v2/resources/SvnConfigInIndexResource.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import sonia.scm.config.ConfigurationPermissions; import sonia.scm.plugin.Extension; +import sonia.scm.repository.SvnConfig; import sonia.scm.web.JsonEnricherBase; import sonia.scm.web.JsonEnricherContext; @@ -26,7 +27,7 @@ public class SvnConfigInIndexResource extends JsonEnricherBase { @Override public void enrich(JsonEnricherContext context) { - if (resultHasMediaType(INDEX, context) && ConfigurationPermissions.list().isPermitted()) { + if (resultHasMediaType(INDEX, context) && ConfigurationPermissions.read(SvnConfig.PERMISSION).isPermitted()) { String svnConfigUrl = new LinkBuilder(scmPathInfoStore.get().get(), SvnConfigResource.class) .method("get") .parameters() diff --git a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/SvnRepositoryHandler.java b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/SvnRepositoryHandler.java index cd242faa2d..639a16968c 100644 --- a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/SvnRepositoryHandler.java +++ b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/SvnRepositoryHandler.java @@ -46,15 +46,21 @@ import org.tmatesoft.svn.core.internal.io.fs.FSRepositoryFactory; import org.tmatesoft.svn.core.io.SVNRepository; import org.tmatesoft.svn.core.io.SVNRepositoryFactory; import org.tmatesoft.svn.util.SVNDebugLog; -import sonia.scm.io.FileSystem; +import sonia.scm.ContextEntry; +import sonia.scm.io.INIConfiguration; +import sonia.scm.io.INIConfigurationReader; +import sonia.scm.io.INIConfigurationWriter; +import sonia.scm.io.INISection; import sonia.scm.logging.SVNKitLogger; import sonia.scm.plugin.Extension; +import sonia.scm.plugin.PluginLoader; import sonia.scm.repository.spi.HookEventFacade; import sonia.scm.repository.spi.SvnRepositoryServiceProvider; import sonia.scm.store.ConfigurationStoreFactory; import sonia.scm.util.Util; import java.io.File; +import java.io.IOException; import static sonia.scm.ContextEntry.ContextBuilder.entity; @@ -81,15 +87,20 @@ public class SvnRepositoryHandler public static final RepositoryType TYPE = new RepositoryType(TYPE_NAME, TYPE_DISPLAYNAME, SvnRepositoryServiceProvider.COMMANDS); + private static final String CONFIG_FILE_NAME = "scm-manager.conf"; + private static final String CONFIG_SECTION_SCMM = "scmm"; + private static final String CONFIG_KEY_REPOSITORY_ID = "repositoryid"; private static final Logger logger = LoggerFactory.getLogger(SvnRepositoryHandler.class); @Inject - public SvnRepositoryHandler(ConfigurationStoreFactory storeFactory, FileSystem fileSystem, - HookEventFacade eventFacade) + public SvnRepositoryHandler(ConfigurationStoreFactory storeFactory, + HookEventFacade eventFacade, + RepositoryLocationResolver repositoryLocationResolver, + PluginLoader pluginLoader) { - super(storeFactory, fileSystem); + super(storeFactory, repositoryLocationResolver, pluginLoader); // register logger SVNDebugLog.setDefaultLog(new SVNKitLogger()); @@ -209,4 +220,21 @@ public class SvnRepositoryHandler { return SvnConfig.class; } + + @Override + protected void postCreate(Repository repository, File directory) throws IOException { + INISection iniSection = new INISection(CONFIG_SECTION_SCMM); + iniSection.setParameter(CONFIG_KEY_REPOSITORY_ID, repository.getId()); + INIConfiguration iniConfiguration = new INIConfiguration(); + iniConfiguration.addSection(iniSection); + new INIConfigurationWriter().write(iniConfiguration, new File(directory, CONFIG_FILE_NAME)); + } + + String getRepositoryId(File directory) { + try { + return new INIConfigurationReader().read(new File(directory, CONFIG_FILE_NAME)).getSection(CONFIG_SECTION_SCMM).getParameter(CONFIG_KEY_REPOSITORY_ID); + } catch (IOException e) { + throw new InternalRepositoryException(ContextEntry.ContextBuilder.entity("directory", directory.toString()), "could not read scm configuration file", e); + } + } } diff --git a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/SvnRepositoryHook.java b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/SvnRepositoryHook.java index 00958174a4..c0a440f8d1 100644 --- a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/SvnRepositoryHook.java +++ b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/SvnRepositoryHook.java @@ -70,16 +70,7 @@ public class SvnRepositoryHook implements FSHook //~--- constructors --------------------------------------------------------- - /** - * Constructs ... - * - * - * - * @param hookEventFacade - * @param handler - */ - public SvnRepositoryHook(HookEventFacade hookEventFacade, - SvnRepositoryHandler handler) + public SvnRepositoryHook(HookEventFacade hookEventFacade, SvnRepositoryHandler handler) { this.hookEventFacade = hookEventFacade; this.handler = handler; @@ -163,10 +154,10 @@ public class SvnRepositoryHook implements FSHook { try { - String id = getRepositoryId(directory); + String repositoryId = getRepositoryId(directory); //J- - hookEventFacade.handle(id) + hookEventFacade.handle(repositoryId) .fireHookEvent( changesetProvider.getType(), new SvnHookContextProvider(changesetProvider) @@ -197,18 +188,16 @@ public class SvnRepositoryHook implements FSHook * * @throws IOException */ - private String getRepositoryId(File directory) throws IOException + private String getRepositoryId(File directory) { AssertUtil.assertIsNotNull(directory); - - return RepositoryUtil.getRepositoryId(handler, directory); + return handler.getRepositoryId(directory); } //~--- fields --------------------------------------------------------------- - /** Field description */ - private SvnRepositoryHandler handler; - /** Field description */ private HookEventFacade hookEventFacade; + + private final SvnRepositoryHandler handler; } diff --git a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnBrowseCommand.java b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnBrowseCommand.java index e2f58b593b..df266a11af 100644 --- a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnBrowseCommand.java +++ b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnBrowseCommand.java @@ -36,7 +36,6 @@ package sonia.scm.repository.spi; //~--- non-JDK imports -------------------------------------------------------- import com.google.common.base.Strings; -import com.google.common.collect.Lists; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.tmatesoft.svn.core.SVNDirEntry; @@ -53,7 +52,6 @@ import sonia.scm.repository.SvnUtil; import sonia.scm.util.Util; import java.util.Collection; -import java.util.List; //~--- JDK imports ------------------------------------------------------------ diff --git a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnModificationsCommand.java b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnModificationsCommand.java index 4b4f655b12..580bc0b77d 100644 --- a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnModificationsCommand.java +++ b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnModificationsCommand.java @@ -4,6 +4,8 @@ import lombok.extern.slf4j.Slf4j; import org.tmatesoft.svn.core.SVNException; import org.tmatesoft.svn.core.SVNLogEntry; import org.tmatesoft.svn.core.io.SVNRepository; +import org.tmatesoft.svn.core.wc.SVNClientManager; +import org.tmatesoft.svn.core.wc.admin.SVNLookClient; import sonia.scm.repository.InternalRepositoryException; import sonia.scm.repository.Modifications; import sonia.scm.repository.Repository; @@ -19,23 +21,45 @@ public class SvnModificationsCommand extends AbstractSvnCommand implements Modif super(context, repository); } - @Override - @SuppressWarnings("unchecked") - public Modifications getModifications(String revision) { - Modifications modifications = null; - log.debug("get modifications {}", revision); + public Modifications getModifications(String revisionOrTransactionId) { + Modifications modifications; try { - long revisionNumber = SvnUtil.parseRevision(revision, repository); - SVNRepository repo = open(); - Collection<SVNLogEntry> entries = repo.log(null, null, revisionNumber, - revisionNumber, true, true); - if (Util.isNotEmpty(entries)) { - modifications = SvnUtil.createModifications(entries.iterator().next(), revision); + if (SvnUtil.isTransactionEntryId(revisionOrTransactionId)) { + modifications = getModificationsFromTransaction(SvnUtil.getTransactionId(revisionOrTransactionId)); + } else { + modifications = getModificationFromRevision(revisionOrTransactionId); } + return modifications; } catch (SVNException ex) { - throw new InternalRepositoryException(repository, "could not open repository", ex); + throw new InternalRepositoryException( + repository, + "failed to get svn modifications for " + revisionOrTransactionId, + ex + ); } + } + + @SuppressWarnings("unchecked") + private Modifications getModificationFromRevision(String revision) throws SVNException { + log.debug("get svn modifications from revision: {}", revision); + long revisionNumber = SvnUtil.getRevisionNumber(revision, repository); + SVNRepository repo = open(); + Collection<SVNLogEntry> entries = repo.log(null, null, revisionNumber, + revisionNumber, true, true); + if (Util.isNotEmpty(entries)) { + return SvnUtil.createModifications(entries.iterator().next(), revision); + } + return null; + } + + private Modifications getModificationsFromTransaction(String transaction) throws SVNException { + log.debug("get svn modifications from transaction: {}", transaction); + final Modifications modifications = new Modifications(); + SVNLookClient client = SVNClientManager.newInstance().getLookClient(); + client.doGetChanged(context.getDirectory(), transaction, + e -> SvnUtil.appendModification(modifications, e.getType(), e.getPath()), true); + return modifications; } @@ -44,5 +68,4 @@ public class SvnModificationsCommand extends AbstractSvnCommand implements Modif return getModifications(request.getRevision()); } - } diff --git a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnRepositoryServiceProvider.java b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnRepositoryServiceProvider.java index 5c1a076ddc..ff277947bd 100644 --- a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnRepositoryServiceProvider.java +++ b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnRepositoryServiceProvider.java @@ -63,7 +63,7 @@ public class SvnRepositoryServiceProvider extends RepositoryServiceProvider Repository repository) { this.repository = repository; - this.context = new SvnContext(handler.getDirectory(repository)); + this.context = new SvnContext(handler.getDirectory(repository.getId())); } //~--- methods -------------------------------------------------------------- diff --git a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/web/SvnDAVConfig.java b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/web/SvnDAVConfig.java index b220737ecb..a544e8051b 100644 --- a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/web/SvnDAVConfig.java +++ b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/web/SvnDAVConfig.java @@ -292,7 +292,7 @@ public class SvnDAVConfig extends DAVConfig if (repository != null) { - directory = handler.getDirectory(repository); + directory = handler.getDirectory(repository.getId()); } return directory; diff --git a/scm-plugins/scm-svn-plugin/src/main/js/SvnConfigurationForm.js b/scm-plugins/scm-svn-plugin/src/main/js/SvnConfigurationForm.js index 9470550ef2..3fde8c7888 100644 --- a/scm-plugins/scm-svn-plugin/src/main/js/SvnConfigurationForm.js +++ b/scm-plugins/scm-svn-plugin/src/main/js/SvnConfigurationForm.js @@ -5,7 +5,6 @@ import { translate } from "react-i18next"; import { InputField, Checkbox, Select } from "@scm-manager/ui-components"; type Configuration = { - repositoryDirectory: string, compatibility: string, enabledGZip: boolean, disabled: boolean, @@ -31,14 +30,11 @@ class HgConfigurationForm extends React.Component<Props, State> { this.state = { ...props.initialConfiguration, validationErrors: [] }; } - isValid = () => { - return !!this.state.repositoryDirectory; - }; handleChange = (value: any, name: string) => { this.setState({ [name]: value - }, () => this.props.onConfigurationChange(this.state, this.isValid())); + }, () => this.props.onConfigurationChange(this.state, true)); }; compatibilityOptions = (values: string[]) => { @@ -64,16 +60,6 @@ class HgConfigurationForm extends React.Component<Props, State> { return ( <> - <InputField - name="repositoryDirectory" - label={t("scm-svn-plugin.config.directory")} - helpText={t("scm-svn-plugin.config.directoryHelpText")} - value={this.state.repositoryDirectory} - errorMessage={t("scm-svn-plugin.config.required")} - validationError={!this.state.repositoryDirectory} - onChange={this.handleChange} - disabled={readOnly} - /> <Select name="compatibility" label={t("scm-svn-plugin.config.compatibility")} diff --git a/scm-plugins/scm-svn-plugin/src/main/js/SvnGlobalConfiguration.js b/scm-plugins/scm-svn-plugin/src/main/js/SvnGlobalConfiguration.js index c17829a67f..e6ea1783d7 100644 --- a/scm-plugins/scm-svn-plugin/src/main/js/SvnGlobalConfiguration.js +++ b/scm-plugins/scm-svn-plugin/src/main/js/SvnGlobalConfiguration.js @@ -1,7 +1,7 @@ //@flow import React from "react"; import { translate } from "react-i18next"; -import { Title, GlobalConfiguration } from "@scm-manager/ui-components"; +import { Title, Configuration } from "@scm-manager/ui-components"; import SvnConfigurationForm from "./SvnConfigurationForm"; type Props = { @@ -18,7 +18,7 @@ class SvnGlobalConfiguration extends React.Component<Props> { return ( <div> <Title title={t("scm-svn-plugin.config.title")}/> - <GlobalConfiguration link={link} render={props => <SvnConfigurationForm {...props} />}/> + <Configuration link={link} render={props => <SvnConfigurationForm {...props} />}/> </div> ); } diff --git a/scm-plugins/scm-svn-plugin/src/main/resources/META-INF/scm/permissions.xml b/scm-plugins/scm-svn-plugin/src/main/resources/META-INF/scm/permissions.xml new file mode 100644 index 0000000000..602b1606e6 --- /dev/null +++ b/scm-plugins/scm-svn-plugin/src/main/resources/META-INF/scm/permissions.xml @@ -0,0 +1,43 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + + Copyright (c) 2010, Sebastian Sdorra + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + 3. Neither the name of SCM-Manager; nor the names of its + contributors may be used to endorse or promote products derived from this + software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY + DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.dd7s + + http://bitbucket.org/sdorra/scm-manager + + +--> +<permissions> + + <permission> + <value>configuration:read,write:svn</value> + </permission> + <permission> + <value>repository:svn:*</value> + </permission> + +</permissions> diff --git a/scm-plugins/scm-svn-plugin/src/main/resources/META-INF/scm/repository-permissions.xml b/scm-plugins/scm-svn-plugin/src/main/resources/META-INF/scm/repository-permissions.xml new file mode 100644 index 0000000000..7c7cd48b79 --- /dev/null +++ b/scm-plugins/scm-svn-plugin/src/main/resources/META-INF/scm/repository-permissions.xml @@ -0,0 +1,7 @@ +<repository-permissions> + <verbs> + <verb>svn</verb> + </verbs> + <roles> + </roles> +</repository-permissions> diff --git a/scm-plugins/scm-svn-plugin/src/main/resources/locales/de/plugins.json b/scm-plugins/scm-svn-plugin/src/main/resources/locales/de/plugins.json index 7c58498ef1..1b27a23564 100644 --- a/scm-plugins/scm-svn-plugin/src/main/resources/locales/de/plugins.json +++ b/scm-plugins/scm-svn-plugin/src/main/resources/locales/de/plugins.json @@ -1,7 +1,52 @@ { "scm-svn-plugin": { "information": { - "checkout" : "Repository auschecken" + "checkout": "Repository auschecken" + }, + "config": { + "link": "Subversion", + "title": "Subversion Konfiguration", + "compatibility": "Version Kompatibilität", + "compatibilityHelpText": "Gibt an, mit welcher Subversion Version die Repositories kompatibel sind.", + "compatibility-values": { + "none": "Keine Kompatibilität", + "pre14": "Vor 1.4 kompatibel", + "pre15": "Vor 1.5 kompatibel", + "pre16": "Vor 1.6 kompatibel", + "pre17": "Vor 1.7 kompatibel", + "with17": "Mit 1.7 kompatibel" + }, + "enabledGZip": "GZip Compression aktivieren", + "enabledGZipHelpText": "Aktiviert GZip Kompression für SVN Responses", + "disabled": "Deaktiviert", + "disabledHelpText": "Aktiviert oder deaktiviert das SVN Plugin", + "required": "Dieser Konfigurationswert wird benötigt" + } + }, + "permissions": { + "configuration": { + "read,write": { + "svn": { + "displayName": "Subversion Konfiguration ändern", + "description": "Darf die Subversion Konfiguration verändern" + } + } + }, + "repository": { + "svn": { + "*": { + "displayName": "Repository-spezifische Subversion Konfiguration ändern", + "description": "Darf die Subversion Konfiguration für alle Repositories verändern." + } + } + } + }, + "verbs": { + "repository": { + "svn": { + "displayName": "Subversion konfigurieren", + "description": "Darf die Subversion Konfiguration für dieses Repository verändern." + } } } } diff --git a/scm-plugins/scm-svn-plugin/src/main/resources/locales/en/plugins.json b/scm-plugins/scm-svn-plugin/src/main/resources/locales/en/plugins.json index 5181f76941..0d487e1f3d 100644 --- a/scm-plugins/scm-svn-plugin/src/main/resources/locales/en/plugins.json +++ b/scm-plugins/scm-svn-plugin/src/main/resources/locales/en/plugins.json @@ -1,15 +1,13 @@ { "scm-svn-plugin": { "information": { - "checkout" : "Checkout repository" + "checkout": "Checkout repository" }, "config": { "link": "Subversion", "title": "Subversion Configuration", - "directory": "Repository Directory", - "directoryHelpText": "Location of Subversion repositories.", "compatibility": "Version Compatibility", - "compatibilityHelpText": "Specifies with which subversion version repositories are compatible.", + "compatibilityHelpText": "Specifies with which Subversion version repositories are compatible.", "compatibility-values": { "none": "No compatibility", "pre14": "Pre 1.4 Compatible", @@ -19,10 +17,36 @@ "with17": "With 1.7 Compatible" }, "enabledGZip": "Enable GZip Compression", - "enabledGZipHelpText": "Enable GZip compression for svn responses.", + "enabledGZipHelpText": "Enable GZip compression for SVN responses.", "disabled": "Disabled", - "disabledHelpText": "Enable or disable the Git plugin", + "disabledHelpText": "Enable or disable the SVN plugin", "required": "This configuration value is required" } + }, + "permissions": { + "configuration": { + "read,write": { + "svn": { + "displayName": "Modify Subversion configuration", + "description": "May modify the Subversion configuration" + } + } + }, + "repository": { + "svn": { + "*": { + "displayName": "Modify repository specific Subversion configuration", + "description": "May change the Subversion configuration for repositories" + } + } + } + }, + "verbs": { + "repository": { + "svn": { + "displayName": "configure Subversion", + "description": "May change the Subversion configuration for this repository" + } + } } } diff --git a/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/api/v2/resources/SvnConfigDtoToSvnConfigMapperTest.java b/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/api/v2/resources/SvnConfigDtoToSvnConfigMapperTest.java index b111d0229f..27ca6d5635 100644 --- a/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/api/v2/resources/SvnConfigDtoToSvnConfigMapperTest.java +++ b/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/api/v2/resources/SvnConfigDtoToSvnConfigMapperTest.java @@ -3,12 +3,10 @@ package sonia.scm.api.v2.resources; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.InjectMocks; -import org.mockito.runners.MockitoJUnitRunner; +import org.mockito.junit.MockitoJUnitRunner; import sonia.scm.repository.Compatibility; import sonia.scm.repository.SvnConfig; -import java.io.File; - import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; @@ -24,7 +22,6 @@ public class SvnConfigDtoToSvnConfigMapperTest { SvnConfig config = mapper.map(dto); assertTrue(config.isDisabled()); - assertEquals("repository/directory", config.getRepositoryDirectory().getPath()); assertEquals(Compatibility.PRE15, config.getCompatibility()); assertTrue(config.isEnabledGZip()); @@ -33,7 +30,6 @@ public class SvnConfigDtoToSvnConfigMapperTest { private SvnConfigDto createDefaultDto() { SvnConfigDto configDto = new SvnConfigDto(); configDto.setDisabled(true); - configDto.setRepositoryDirectory(new File("repository/directory")); configDto.setCompatibility(Compatibility.PRE15); configDto.setEnabledGZip(true); diff --git a/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/api/v2/resources/SvnConfigInIndexResourceTest.java b/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/api/v2/resources/SvnConfigInIndexResourceTest.java index 8b87b57c6c..5d4fa36fe6 100644 --- a/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/api/v2/resources/SvnConfigInIndexResourceTest.java +++ b/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/api/v2/resources/SvnConfigInIndexResourceTest.java @@ -15,6 +15,7 @@ import java.net.URI; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; @SubjectAware(configuration = "classpath:sonia/scm/configuration/shiro.ini") public class SvnConfigInIndexResourceTest { @@ -50,7 +51,7 @@ public class SvnConfigInIndexResourceTest { svnConfigInIndexResource.enrich(context); - assertFalse(root.get("_links").iterator().hasNext()); + assertTrue(root.get("_links").iterator().hasNext()); } @Test diff --git a/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/api/v2/resources/SvnConfigResourceTest.java b/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/api/v2/resources/SvnConfigResourceTest.java index 28dbe09e92..f7ccf039b2 100644 --- a/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/api/v2/resources/SvnConfigResourceTest.java +++ b/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/api/v2/resources/SvnConfigResourceTest.java @@ -16,14 +16,14 @@ import org.junit.runner.RunWith; import org.mockito.Answers; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.runners.MockitoJUnitRunner; +import org.mockito.junit.MockitoJUnitRunner; import sonia.scm.repository.SvnConfig; import sonia.scm.repository.SvnRepositoryHandler; import sonia.scm.web.SvnVndMediaType; import javax.servlet.http.HttpServletResponse; -import java.io.File; import java.io.IOException; +import java.io.UnsupportedEncodingException; import java.net.URI; import java.net.URISyntaxException; @@ -81,7 +81,6 @@ public class SvnConfigResourceTest { ObjectNode responseJson = new ObjectMapper().readValue(responseString, ObjectNode.class); assertTrue(responseString.contains("\"disabled\":false")); - assertTrue(responseJson.get("repositoryDirectory").asText().endsWith("repository/directory")); assertTrue(responseString.contains("\"self\":{\"href\":\"/v2/config/svn")); assertTrue(responseString.contains("\"update\":{\"href\":\"/v2/config/svn")); } @@ -99,7 +98,7 @@ public class SvnConfigResourceTest { @Test @SubjectAware(username = "readOnly") - public void shouldGetSvnConfigWithoutUpdateLink() throws URISyntaxException { + public void shouldGetSvnConfigWithoutUpdateLink() throws URISyntaxException, UnsupportedEncodingException { MockHttpResponse response = get(); assertEquals(HttpServletResponse.SC_OK, response.getStatus()); @@ -150,7 +149,6 @@ public class SvnConfigResourceTest { private SvnConfig createConfiguration() { SvnConfig config = new SvnConfig(); config.setDisabled(false); - config.setRepositoryDirectory(new File("repository/directory")); return config; } diff --git a/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/api/v2/resources/SvnConfigToSvnConfigDtoMapperTest.java b/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/api/v2/resources/SvnConfigToSvnConfigDtoMapperTest.java index 5184aa3d41..07ead15322 100644 --- a/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/api/v2/resources/SvnConfigToSvnConfigDtoMapperTest.java +++ b/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/api/v2/resources/SvnConfigToSvnConfigDtoMapperTest.java @@ -11,11 +11,10 @@ import org.junit.runner.RunWith; import org.mockito.Answers; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.runners.MockitoJUnitRunner; +import org.mockito.junit.MockitoJUnitRunner; import sonia.scm.repository.Compatibility; import sonia.scm.repository.SvnConfig; -import java.io.File; import java.net.URI; import static org.junit.Assert.assertEquals; @@ -61,7 +60,6 @@ public class SvnConfigToSvnConfigDtoMapperTest { SvnConfigDto dto = mapper.map(config); assertTrue(dto.isDisabled()); - assertEquals("repository/directory", dto.getRepositoryDirectory().getPath()); assertEquals(Compatibility.PRE15, dto.getCompatibility()); assertTrue(dto.isEnabledGZip()); @@ -84,7 +82,6 @@ public class SvnConfigToSvnConfigDtoMapperTest { private SvnConfig createConfiguration() { SvnConfig config = new SvnConfig(); config.setDisabled(true); - config.setRepositoryDirectory(new File("repository/directory")); config.setCompatibility(Compatibility.PRE15); config.setEnabledGZip(true); diff --git a/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/SvnRepositoryHandlerTest.java b/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/SvnRepositoryHandlerTest.java index e5e9511872..c81c6311e1 100644 --- a/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/SvnRepositoryHandlerTest.java +++ b/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/SvnRepositoryHandlerTest.java @@ -33,22 +33,20 @@ package sonia.scm.repository; import org.junit.Test; -import org.junit.runner.RunWith; import org.mockito.Mock; -import org.mockito.junit.MockitoJUnitRunner; -import sonia.scm.io.DefaultFileSystem; import sonia.scm.repository.api.HookContextFactory; import sonia.scm.repository.spi.HookEventFacade; -import sonia.scm.store.ConfigurationStore; import sonia.scm.store.ConfigurationStoreFactory; import java.io.File; +import java.io.IOException; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; -import static org.mockito.Matchers.any; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import static org.mockito.MockitoAnnotations.initMocks; //~--- JDK imports ------------------------------------------------------------ @@ -56,15 +54,11 @@ import static org.mockito.Mockito.when; * * @author Sebastian Sdorra */ -@RunWith(MockitoJUnitRunner.class) public class SvnRepositoryHandlerTest extends SimpleRepositoryHandlerTestBase { @Mock private ConfigurationStoreFactory factory; - @Mock - private ConfigurationStore store; - @Mock private com.google.inject.Provider<RepositoryManager> repositoryManagerProvider; @@ -72,6 +66,12 @@ public class SvnRepositoryHandlerTest extends SimpleRepositoryHandlerTestBase { private HookEventFacade facade = new HookEventFacade(repositoryManagerProvider, hookContextFactory); + @Override + protected void postSetUp() throws IOException, RepositoryPathNotFoundException { + initMocks(this); + super.postSetUp(); + } + @Override protected void checkDirectory(File directory) { File format = new File(directory, "format"); @@ -87,16 +87,14 @@ public class SvnRepositoryHandlerTest extends SimpleRepositoryHandlerTestBase { @Override protected RepositoryHandler createRepositoryHandler(ConfigurationStoreFactory factory, - File directory) { - SvnRepositoryHandler handler = new SvnRepositoryHandler(factory, - new DefaultFileSystem(), null); + RepositoryLocationResolver locationResolver, + File directory) { + SvnRepositoryHandler handler = new SvnRepositoryHandler(factory, null, locationResolver, null); handler.init(contextProvider); SvnConfig config = new SvnConfig(); - config.setRepositoryDirectory(directory); - // TODO fix event bus exception handler.setConfig(config); @@ -105,17 +103,15 @@ public class SvnRepositoryHandlerTest extends SimpleRepositoryHandlerTestBase { @Test public void getDirectory() { - when(factory.getStore(any(), any())).thenReturn(store); + when(factory.withType(any())).thenCallRealMethod(); SvnRepositoryHandler repositoryHandler = new SvnRepositoryHandler(factory, - new DefaultFileSystem(), facade); + facade, locationResolver, null); SvnConfig svnConfig = new SvnConfig(); - svnConfig.setRepositoryDirectory(new File("/path")); repositoryHandler.setConfig(svnConfig); - Repository repository = new Repository("id", "svn", "Space", "Name"); - - File path = repositoryHandler.getDirectory(repository); - assertEquals("/path/id", path.getAbsolutePath()); + initRepository(); + File path = repositoryHandler.getDirectory(repository.getId()); + assertEquals(repoPath.toString()+File.separator+ AbstractSimpleRepositoryHandler.REPOSITORIES_NATIVE_DIRECTORY, path.getAbsolutePath()); } } diff --git a/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/spi/SvnLogCommandTest.java b/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/spi/SvnLogCommandTest.java index f2511a9ad9..0cfeaa3a1c 100644 --- a/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/spi/SvnLogCommandTest.java +++ b/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/spi/SvnLogCommandTest.java @@ -39,8 +39,6 @@ import sonia.scm.repository.Changeset; import sonia.scm.repository.ChangesetPagingResult; import sonia.scm.repository.Modifications; -import java.io.IOException; - import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; diff --git a/scm-plugins/scm-svn-plugin/yarn.lock b/scm-plugins/scm-svn-plugin/yarn.lock index a211aa0ca1..b47e6e6e32 100644 --- a/scm-plugins/scm-svn-plugin/yarn.lock +++ b/scm-plugins/scm-svn-plugin/yarn.lock @@ -641,9 +641,9 @@ version "0.0.2" resolved "https://registry.yarnpkg.com/@scm-manager/eslint-config/-/eslint-config-0.0.2.tgz#94cc8c3fb4f51f870b235893dc134fc6c423ae85" -"@scm-manager/ui-bundler@^0.0.21": - version "0.0.21" - resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.21.tgz#f8b5fa355415cc67b8aaf8744e1701a299dff647" +"@scm-manager/ui-bundler@^0.0.24": + version "0.0.24" + resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.24.tgz#034d5500c79b438c48d8f7ee985be07c4ea46d1e" dependencies: "@babel/core" "^7.0.0" "@babel/plugin-proposal-class-properties" "^7.0.0" @@ -681,9 +681,10 @@ vinyl-source-stream "^2.0.0" watchify "^3.11.0" -"@scm-manager/ui-extensions@^0.1.1": - version "0.1.1" - resolved "https://registry.yarnpkg.com/@scm-manager/ui-extensions/-/ui-extensions-0.1.1.tgz#966e62d89981e92a14adf7e674e646e76de96d45" +"@scm-manager/ui-extensions@^0.1.2": + version "0.1.2" + resolved "https://registry.yarnpkg.com/@scm-manager/ui-extensions/-/ui-extensions-0.1.2.tgz#0689427ca45c8e4e045b5b9dbc89036f1d2c45fc" + integrity sha512-oIkXcc/VWssnK/yjWKC/Wnq5DZ01rArsz76n4X/0DT0hkGNIKmwk/Fdp7OoXiUEb7+aaPjUX1VvDqlTwCNKPmA== dependencies: react "^16.4.2" react-dom "^16.4.2" diff --git a/scm-server/pom.xml b/scm-server/pom.xml index c8d004cec1..74f6466821 100644 --- a/scm-server/pom.xml +++ b/scm-server/pom.xml @@ -64,7 +64,6 @@ <assembleDirectory>${exploded.directory}</assembleDirectory> <repoPath>lib</repoPath> <repositoryLayout>flat</repositoryLayout> - <includeConfigurationDirectoryInClasspath>true</includeConfigurationDirectoryInClasspath> <daemons> <daemon> @@ -306,8 +305,8 @@ </profiles> <properties> - <commons.daemon.version>1.0.15</commons.daemon.version> - <commons.daemon.native.version>1.0.15.1</commons.daemon.native.version> + <commons.daemon.version>1.1.0</commons.daemon.version> + <commons.daemon.native.version>1.1.0</commons.daemon.native.version> <exploded.directory>${project.build.directory}/appassembler/commons-daemon/scm-server</exploded.directory> </properties> diff --git a/scm-test/pom.xml b/scm-test/pom.xml index 98441ec898..bddbcf2c85 100644 --- a/scm-test/pom.xml +++ b/scm-test/pom.xml @@ -27,7 +27,7 @@ <artifactId>scm-core</artifactId> <version>2.0.0-SNAPSHOT</version> </dependency> - + <dependency> <groupId>com.github.sdorra</groupId> <artifactId>shiro-unit</artifactId> @@ -47,7 +47,7 @@ <artifactId>slf4j-simple</artifactId> <version>${slf4j.version}</version> </dependency> - + </dependencies> <!-- for svnkit and jgit --> diff --git a/scm-test/src/main/java/sonia/scm/AbstractTestBase.java b/scm-test/src/main/java/sonia/scm/AbstractTestBase.java index 13cde0391e..040b347e4a 100644 --- a/scm-test/src/main/java/sonia/scm/AbstractTestBase.java +++ b/scm-test/src/main/java/sonia/scm/AbstractTestBase.java @@ -46,10 +46,15 @@ import org.junit.After; import org.junit.AfterClass; import org.junit.Before; +import sonia.scm.io.DefaultFileSystem; +import sonia.scm.repository.InitialRepositoryLocationResolver; +import sonia.scm.repository.RepositoryDAO; +import sonia.scm.repository.RepositoryLocationResolver; import sonia.scm.util.IOUtil; import sonia.scm.util.MockUtil; import static org.junit.Assert.*; +import static org.mockito.Mockito.mock; //~--- JDK imports ------------------------------------------------------------ @@ -66,10 +71,29 @@ import java.util.logging.Logger; public class AbstractTestBase { - /** Field description */ private static ThreadState subjectThreadState; - //~--- methods -------------------------------------------------------------- + protected SCMContextProvider contextProvider; + + private File tempDirectory; + + protected DefaultFileSystem fileSystem; + + protected RepositoryDAO repositoryDAO = mock(RepositoryDAO.class); + protected RepositoryLocationResolver repositoryLocationResolver; + + @Before + public void setUpTest() throws Exception + { + tempDirectory = new File(System.getProperty("java.io.tmpdir"), + UUID.randomUUID().toString()); + assertTrue(tempDirectory.mkdirs()); + contextProvider = MockUtil.getSCMContextProvider(tempDirectory); + fileSystem = new DefaultFileSystem(); + InitialRepositoryLocationResolver initialRepoLocationResolver = new InitialRepositoryLocationResolver(); + repositoryLocationResolver = new RepositoryLocationResolver(contextProvider, repositoryDAO, initialRepoLocationResolver); + postSetUp(); + } /** * Method description @@ -165,25 +189,6 @@ public class AbstractTestBase } } - //~--- set methods ---------------------------------------------------------- - - /** - * Method description - * - * - * @throws Exception - */ - @Before - public void setUpTest() throws Exception - { - tempDirectory = new File(System.getProperty("java.io.tmpdir"), - UUID.randomUUID().toString()); - assertTrue(tempDirectory.mkdirs()); - contextProvider = MockUtil.getSCMContextProvider(tempDirectory); - postSetUp(); - } - - //~--- methods -------------------------------------------------------------- /** * Clears Shiro's thread state, ensuring the thread remains clean for @@ -249,12 +254,4 @@ public class AbstractTestBase subjectThreadState = createThreadState(subject); subjectThreadState.bind(); } - - //~--- fields --------------------------------------------------------------- - - /** Field description */ - protected SCMContextProvider contextProvider; - - /** Field description */ - private File tempDirectory; } diff --git a/scm-test/src/main/java/sonia/scm/ManagerTestBase.java b/scm-test/src/main/java/sonia/scm/ManagerTestBase.java index eda3182638..823e88c9fc 100644 --- a/scm-test/src/main/java/sonia/scm/ManagerTestBase.java +++ b/scm-test/src/main/java/sonia/scm/ManagerTestBase.java @@ -37,10 +37,16 @@ import org.junit.After; import org.junit.Before; import org.junit.Rule; import org.junit.rules.TemporaryFolder; +import sonia.scm.repository.InitialRepositoryLocationResolver; +import sonia.scm.repository.RepositoryDAO; +import sonia.scm.repository.RepositoryLocationResolver; import sonia.scm.util.MockUtil; +import java.io.File; import java.io.IOException; +import static org.mockito.Mockito.mock; + /** * * @author Sebastian Sdorra @@ -54,12 +60,21 @@ public abstract class ManagerTestBase<T extends ModelObject> public TemporaryFolder tempFolder = new TemporaryFolder(); protected SCMContextProvider contextProvider; - + protected RepositoryLocationResolver locationResolver; + protected Manager<T> manager; - + + protected File temp ; + @Before public void setUp() throws IOException { - contextProvider = MockUtil.getSCMContextProvider(tempFolder.newFolder()); + if (temp == null){ + temp = tempFolder.newFolder(); + } + contextProvider = MockUtil.getSCMContextProvider(temp); + InitialRepositoryLocationResolver initialRepositoryLocationResolver = new InitialRepositoryLocationResolver(); + RepositoryDAO repoDao = mock(RepositoryDAO.class); + locationResolver = new RepositoryLocationResolver(contextProvider, repoDao ,initialRepositoryLocationResolver); manager = createManager(); manager.init(contextProvider); } diff --git a/scm-test/src/main/java/sonia/scm/repository/DummyRepositoryHandler.java b/scm-test/src/main/java/sonia/scm/repository/DummyRepositoryHandler.java index df974ab566..ccaeee8631 100644 --- a/scm-test/src/main/java/sonia/scm/repository/DummyRepositoryHandler.java +++ b/scm-test/src/main/java/sonia/scm/repository/DummyRepositoryHandler.java @@ -35,7 +35,6 @@ package sonia.scm.repository; import com.google.common.collect.Sets; import sonia.scm.AlreadyExistsException; -import sonia.scm.io.DefaultFileSystem; import sonia.scm.store.ConfigurationStoreFactory; import javax.xml.bind.annotation.XmlRootElement; @@ -46,7 +45,6 @@ import java.util.Set; //~--- JDK imports ------------------------------------------------------------ /** - * * @author Sebastian Sdorra */ public class DummyRepositoryHandler @@ -60,8 +58,8 @@ public class DummyRepositoryHandler private final Set<String> existingRepoNames = new HashSet<>(); - public DummyRepositoryHandler(ConfigurationStoreFactory storeFactory) { - super(storeFactory, new DefaultFileSystem()); + public DummyRepositoryHandler(ConfigurationStoreFactory storeFactory, RepositoryLocationResolver repositoryLocationResolver) { + super(storeFactory, repositoryLocationResolver, null); } @Override diff --git a/scm-test/src/main/java/sonia/scm/repository/RepositoryTestData.java b/scm-test/src/main/java/sonia/scm/repository/RepositoryTestData.java index b81c39ca00..82c6e24108 100644 --- a/scm-test/src/main/java/sonia/scm/repository/RepositoryTestData.java +++ b/scm-test/src/main/java/sonia/scm/repository/RepositoryTestData.java @@ -33,6 +33,9 @@ package sonia.scm.repository; public final class RepositoryTestData { + public static final String NAMESPACE = "hitchhiker"; + public static final String MAIL_DOMAIN = "@hitchhiker.com"; + private RepositoryTestData() { } @@ -43,8 +46,9 @@ public final class RepositoryTestData { public static Repository create42Puzzle(String type) { return new RepositoryBuilder() .type(type) - .contact("douglas.adams@hitchhiker.com") + .contact("douglas.adams" + MAIL_DOMAIN) .name("42Puzzle") + .namespace(NAMESPACE) .description("The 42 Puzzle") .build(); } @@ -57,8 +61,9 @@ public final class RepositoryTestData { public static Repository createHappyVerticalPeopleTransporter(String type) { return new RepositoryBuilder() .type(type) - .contact("zaphod.beeblebrox@hitchhiker.com") + .contact("zaphod.beeblebrox" + MAIL_DOMAIN) .name("happyVerticalPeopleTransporter") + .namespace(NAMESPACE) .description("Happy Vertical People Transporter") .build(); } @@ -70,8 +75,9 @@ public final class RepositoryTestData { public static Repository createHeartOfGold(String type) { return new RepositoryBuilder() .type(type) - .contact("zaphod.beeblebrox@hitchhiker.com") + .contact("zaphod.beeblebrox" + MAIL_DOMAIN) .name("HeartOfGold") + .namespace(NAMESPACE) .description( "Heart of Gold is the first prototype ship to successfully utilise the revolutionary Infinite Improbability Drive") .build(); @@ -85,8 +91,9 @@ public final class RepositoryTestData { public static Repository createRestaurantAtTheEndOfTheUniverse(String type) { return new RepositoryBuilder() .type(type) - .contact("douglas.adams@hitchhiker.com") + .contact("douglas.adams" + MAIL_DOMAIN) .name("RestaurantAtTheEndOfTheUniverse") + .namespace(NAMESPACE) .description("The Restaurant at the End of the Universe") .build(); } diff --git a/scm-test/src/main/java/sonia/scm/repository/SimpleRepositoryHandlerTestBase.java b/scm-test/src/main/java/sonia/scm/repository/SimpleRepositoryHandlerTestBase.java index d9985d3332..f48744d460 100644 --- a/scm-test/src/main/java/sonia/scm/repository/SimpleRepositoryHandlerTestBase.java +++ b/scm-test/src/main/java/sonia/scm/repository/SimpleRepositoryHandlerTestBase.java @@ -40,57 +40,50 @@ import sonia.scm.store.InMemoryConfigurationStoreFactory; import sonia.scm.util.IOUtil; import java.io.File; +import java.io.IOException; +import java.nio.file.Path; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; //~--- JDK imports ------------------------------------------------------------ /** - * * @author Sebastian Sdorra */ public abstract class SimpleRepositoryHandlerTestBase extends AbstractTestBase { + protected PathBasedRepositoryDAO repoDao = mock(PathBasedRepositoryDAO.class); + protected Path repoPath; + protected Repository repository; + protected abstract void checkDirectory(File directory); protected abstract RepositoryHandler createRepositoryHandler( - ConfigurationStoreFactory factory, File directory); + ConfigurationStoreFactory factory, RepositoryLocationResolver locationResolver, File directory) throws IOException, RepositoryPathNotFoundException; @Test public void testCreate() { createRepository(); } - @Test - public void testCreateResourcePath() { - Repository repository = createRepository(); - String path = handler.createResourcePath(repository); - - assertNotNull(path); - assertTrue(path.trim().length() > 0); - assertTrue(path.contains(repository.getId())); - } - - @Test - public void testDelete() { - Repository repository = createRepository(); - - handler.delete(repository); - - File directory = new File(baseDirectory, repository.getId()); - - assertFalse(directory.exists()); - } - @Override - protected void postSetUp() { + protected void postSetUp() throws IOException, RepositoryPathNotFoundException { InMemoryConfigurationStoreFactory storeFactory = new InMemoryConfigurationStoreFactory(); baseDirectory = new File(contextProvider.getBaseDirectory(), "repositories"); IOUtil.mkdirs(baseDirectory); - handler = createRepositoryHandler(storeFactory, baseDirectory); + + locationResolver = mock(RepositoryLocationResolver.class); + + when(locationResolver.getPath(anyString())).then(ic -> { + String id = ic.getArgument(0); + return baseDirectory.toPath().resolve(id); + }); + + handler = createRepositoryHandler(storeFactory, locationResolver, baseDirectory); } @Override @@ -100,21 +93,26 @@ public abstract class SimpleRepositoryHandlerTestBase extends AbstractTestBase { } } - private Repository createRepository() { - Repository repository = RepositoryTestData.createHeartOfGold(); + private void createRepository() { + File nativeRepoDirectory = initRepository(); handler.create(repository); - File directory = new File(baseDirectory, repository.getId()); + assertTrue(nativeRepoDirectory.exists()); + assertTrue(nativeRepoDirectory.isDirectory()); + checkDirectory(nativeRepoDirectory); + } - assertTrue(directory.exists()); - assertTrue(directory.isDirectory()); - checkDirectory(directory); - - return repository; + protected File initRepository() { + repository = RepositoryTestData.createHeartOfGold(); + File repoDirectory = new File(baseDirectory, repository.getId()); + repoPath = repoDirectory.toPath(); + when(repoDao.getPath(repository.getId())).thenReturn(repoPath); + return new File(repoDirectory, AbstractSimpleRepositoryHandler.REPOSITORIES_NATIVE_DIRECTORY); } protected File baseDirectory; + protected RepositoryLocationResolver locationResolver; private RepositoryHandler handler; } diff --git a/scm-test/src/main/java/sonia/scm/store/BlobStoreTestBase.java b/scm-test/src/main/java/sonia/scm/store/BlobStoreTestBase.java index 48504feaf2..f3f252053d 100644 --- a/scm-test/src/main/java/sonia/scm/store/BlobStoreTestBase.java +++ b/scm-test/src/main/java/sonia/scm/store/BlobStoreTestBase.java @@ -35,22 +35,24 @@ package sonia.scm.store; //~--- non-JDK imports -------------------------------------------------------- import com.google.common.io.ByteStreams; - import org.junit.Before; import org.junit.Test; - import sonia.scm.AbstractTestBase; - -import static org.junit.Assert.*; - -//~--- JDK imports ------------------------------------------------------------ +import sonia.scm.repository.RepositoryTestData; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; - import java.util.List; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +//~--- JDK imports ------------------------------------------------------------ + /** * * @author Sebastian Sdorra @@ -58,12 +60,6 @@ import java.util.List; public abstract class BlobStoreTestBase extends AbstractTestBase { - /** - * Method description - * - * - * @return - */ protected abstract BlobStoreFactory createBlobStoreFactory(); /** @@ -73,7 +69,10 @@ public abstract class BlobStoreTestBase extends AbstractTestBase @Before public void createBlobStore() { - store = createBlobStoreFactory().getBlobStore("test"); + store = createBlobStoreFactory() + .withName("test") + .forRepository(RepositoryTestData.createHeartOfGold()) + .build(); store.clear(); } diff --git a/scm-test/src/main/java/sonia/scm/store/ConfigurationEntryStoreTestBase.java b/scm-test/src/main/java/sonia/scm/store/ConfigurationEntryStoreTestBase.java index 8d3a63717a..140bd54e65 100644 --- a/scm-test/src/main/java/sonia/scm/store/ConfigurationEntryStoreTestBase.java +++ b/scm-test/src/main/java/sonia/scm/store/ConfigurationEntryStoreTestBase.java @@ -32,12 +32,13 @@ package sonia.scm.store; +import sonia.scm.repository.Repository; + /** * * @author Sebastian Sdorra */ -public abstract class ConfigurationEntryStoreTestBase extends KeyValueStoreTestBase -{ +public abstract class ConfigurationEntryStoreTestBase extends KeyValueStoreTestBase { /** * Method description @@ -48,17 +49,20 @@ public abstract class ConfigurationEntryStoreTestBase extends KeyValueStoreTestB protected abstract ConfigurationEntryStoreFactory createConfigurationStoreFactory(); //~--- get methods ---------------------------------------------------------- - - /** - * Method description - * - * - * @return - */ @Override - protected ConfigurationEntryStore<StoreObject> getDataStore() - { - return createConfigurationStoreFactory().getStore(StoreObject.class, - "test"); + protected ConfigurationEntryStore getDataStore(Class type) { + return this.createConfigurationStoreFactory() + .withType(type) + .withName(storeName) + .build(); + } + + @Override + protected ConfigurationEntryStore getDataStore(Class type, Repository repository) { + return this.createConfigurationStoreFactory() + .withType(type) + .withName(repoStoreName) + .forRepository(repository) + .build(); } } diff --git a/scm-test/src/main/java/sonia/scm/store/DataStoreTestBase.java b/scm-test/src/main/java/sonia/scm/store/DataStoreTestBase.java index 3129d3a339..39ce021715 100644 --- a/scm-test/src/main/java/sonia/scm/store/DataStoreTestBase.java +++ b/scm-test/src/main/java/sonia/scm/store/DataStoreTestBase.java @@ -33,6 +33,13 @@ package sonia.scm.store; +import org.junit.Test; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryTestData; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + /** * * @author Sebastian Sdorra @@ -48,17 +55,29 @@ public abstract class DataStoreTestBase extends KeyValueStoreTestBase */ protected abstract DataStoreFactory createDataStoreFactory(); + //~--- get methods ---------------------------------------------------------- - /** - * Method description - * - * - * @return - */ - @Override - protected DataStore<StoreObject> getDataStore() + + + + @Test + public void shouldStoreRepositorySpecificData() { - return createDataStoreFactory().getStore(StoreObject.class, "test"); + DataStoreFactory dataStoreFactory = createDataStoreFactory(); + StoreObject obj = new StoreObject("test-1"); + Repository repository = RepositoryTestData.createHeartOfGold(); + + DataStore<StoreObject> store = dataStoreFactory + .withType(StoreObject.class) + .withName("test") + .forRepository(repository) + .build(); + + String id = store.put(obj); + + assertNotNull(id); + + assertEquals(obj, store.get(id)); } } diff --git a/scm-test/src/main/java/sonia/scm/store/InMemoryConfigurationEntryStore.java b/scm-test/src/main/java/sonia/scm/store/InMemoryConfigurationEntryStore.java new file mode 100644 index 0000000000..40124dd717 --- /dev/null +++ b/scm-test/src/main/java/sonia/scm/store/InMemoryConfigurationEntryStore.java @@ -0,0 +1,52 @@ +package sonia.scm.store; + +import com.google.common.base.Predicate; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +public class InMemoryConfigurationEntryStore<V> implements ConfigurationEntryStore<V> { + + private final Map<String, V> values = new HashMap<>(); + + @Override + public Collection<V> getMatchingValues(Predicate<V> predicate) { + return values.values().stream().filter(predicate).collect(Collectors.toList()); + } + + @Override + public String put(V item) { + String key = UUID.randomUUID().toString(); + values.put(key, item); + return key; + } + + @Override + public void put(String id, V item) { + values.put(id, item); + } + + @Override + public Map<String, V> getAll() { + return Collections.unmodifiableMap(values); + } + + @Override + public void clear() { + values.clear(); + } + + @Override + public void remove(String id) { + values.remove(id); + } + + @Override + public V get(String id) { + return values.get(id); + } +} diff --git a/scm-test/src/main/java/sonia/scm/store/InMemoryConfigurationEntryStoreFactory.java b/scm-test/src/main/java/sonia/scm/store/InMemoryConfigurationEntryStoreFactory.java new file mode 100644 index 0000000000..48e60684b6 --- /dev/null +++ b/scm-test/src/main/java/sonia/scm/store/InMemoryConfigurationEntryStoreFactory.java @@ -0,0 +1,28 @@ +package sonia.scm.store; + +public class InMemoryConfigurationEntryStoreFactory implements ConfigurationEntryStoreFactory { + + + + + private ConfigurationEntryStore store; + + public static ConfigurationEntryStoreFactory create() { + return new InMemoryConfigurationEntryStoreFactory(new InMemoryConfigurationEntryStore()); + } + + public InMemoryConfigurationEntryStoreFactory() { + } + + public InMemoryConfigurationEntryStoreFactory(ConfigurationEntryStore store) { + this.store = store; + } + + @Override + public <T> ConfigurationEntryStore<T> getStore(TypedStoreParameters<T> storeParameters) { + if (store != null) { + return store; + } + return new InMemoryConfigurationEntryStore<>(); + } +} diff --git a/scm-test/src/main/java/sonia/scm/store/InMemoryConfigurationStoreFactory.java b/scm-test/src/main/java/sonia/scm/store/InMemoryConfigurationStoreFactory.java index d5e9474ff5..ee21703c4a 100644 --- a/scm-test/src/main/java/sonia/scm/store/InMemoryConfigurationStoreFactory.java +++ b/scm-test/src/main/java/sonia/scm/store/InMemoryConfigurationStoreFactory.java @@ -37,14 +37,31 @@ package sonia.scm.store; /** * In memory configuration store factory for testing purposes. + * + * Use {@link #create()} to get a store that creates the same store on each request. * * @author Sebastian Sdorra */ public class InMemoryConfigurationStoreFactory implements ConfigurationStoreFactory { + private ConfigurationStore store; + + public static ConfigurationStoreFactory create() { + return new InMemoryConfigurationStoreFactory(new InMemoryConfigurationStore()); + } + + public InMemoryConfigurationStoreFactory() { + } + + public InMemoryConfigurationStoreFactory(ConfigurationStore store) { + this.store = store; + } + @Override - public <T> ConfigurationStore<T> getStore(Class<T> type, String name) - { + public ConfigurationStore getStore(TypedStoreParameters storeParameters) { + if (store != null) { + return store; + } return new InMemoryConfigurationStore<>(); } } diff --git a/scm-test/src/main/java/sonia/scm/store/InMemoryDataStore.java b/scm-test/src/main/java/sonia/scm/store/InMemoryDataStore.java new file mode 100644 index 0000000000..06198d89bf --- /dev/null +++ b/scm-test/src/main/java/sonia/scm/store/InMemoryDataStore.java @@ -0,0 +1,53 @@ +package sonia.scm.store; + +import sonia.scm.security.KeyGenerator; +import sonia.scm.security.UUIDKeyGenerator; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * In memory store implementation of {@link DataStore}. + * + * @author Sebastian Sdorra + * + * @param <T> type of stored object + */ +public class InMemoryDataStore<T> implements DataStore<T> { + + private final Map<String, T> store = new HashMap<>(); + private KeyGenerator generator = new UUIDKeyGenerator(); + + @Override + public String put(T item) { + String key = generator.createKey(); + store.put(key, item); + return key; + } + + @Override + public void put(String id, T item) { + store.put(id, item); + } + + @Override + public Map<String, T> getAll() { + return Collections.unmodifiableMap(store); + } + + @Override + public void clear() { + store.clear(); + } + + @Override + public void remove(String id) { + store.remove(id); + } + + @Override + public T get(String id) { + return store.get(id); + } +} diff --git a/scm-test/src/main/java/sonia/scm/store/InMemoryDataStoreFactory.java b/scm-test/src/main/java/sonia/scm/store/InMemoryDataStoreFactory.java new file mode 100644 index 0000000000..b0e95e9f9c --- /dev/null +++ b/scm-test/src/main/java/sonia/scm/store/InMemoryDataStoreFactory.java @@ -0,0 +1,26 @@ +package sonia.scm.store; + +/** + * In memory configuration store factory for testing purposes. + * + * @author Sebastian Sdorra + */ +public class InMemoryDataStoreFactory implements DataStoreFactory { + + private InMemoryDataStore store; + + public InMemoryDataStoreFactory() { + } + + public InMemoryDataStoreFactory(InMemoryDataStore store) { + this.store = store; + } + + @Override + public <T> DataStore<T> getStore(TypedStoreParameters<T> storeParameters) { + if (store != null) { + return store; + } + return new InMemoryDataStore<>(); + } +} diff --git a/scm-test/src/main/java/sonia/scm/store/KeyValueStoreTestBase.java b/scm-test/src/main/java/sonia/scm/store/KeyValueStoreTestBase.java index 0abad4f558..a54b58178f 100644 --- a/scm-test/src/main/java/sonia/scm/store/KeyValueStoreTestBase.java +++ b/scm-test/src/main/java/sonia/scm/store/KeyValueStoreTestBase.java @@ -38,6 +38,8 @@ import org.junit.Before; import org.junit.Test; import sonia.scm.AbstractTestBase; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryTestData; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -56,13 +58,21 @@ import java.util.Map; public abstract class KeyValueStoreTestBase extends AbstractTestBase { + protected Repository repository = RepositoryTestData.createHeartOfGold(); + protected DataStore<StoreObject> store; + protected DataStore<StoreObject> repoStore; + protected String repoStoreName = "testRepoStore"; + protected String storeName = "testStore"; + /** * Method description * * * @return */ - protected abstract DataStore<StoreObject> getDataStore(); + protected abstract <STORE_OBJECT> DataStore<STORE_OBJECT> getDataStore(Class<STORE_OBJECT> type , Repository repository); + protected abstract <STORE_OBJECT> DataStore<STORE_OBJECT> getDataStore(Class<STORE_OBJECT> type ); + //~--- methods -------------------------------------------------------------- @@ -73,8 +83,10 @@ public abstract class KeyValueStoreTestBase extends AbstractTestBase @Before public void before() { - store = getDataStore(); + store = getDataStore(StoreObject.class); + repoStore = getDataStore(StoreObject.class, repository); store.clear(); + repoStore.clear(); } /** @@ -215,8 +227,5 @@ public abstract class KeyValueStoreTestBase extends AbstractTestBase assertNull(store.get("2")); } - //~--- fields --------------------------------------------------------------- - /** Field description */ - private DataStore<StoreObject> store; } diff --git a/scm-test/src/main/java/sonia/scm/store/StoreTestBase.java b/scm-test/src/main/java/sonia/scm/store/StoreTestBase.java index c39efa3ffe..ef806c79f8 100644 --- a/scm-test/src/main/java/sonia/scm/store/StoreTestBase.java +++ b/scm-test/src/main/java/sonia/scm/store/StoreTestBase.java @@ -35,10 +35,11 @@ package sonia.scm.store; //~--- non-JDK imports -------------------------------------------------------- import org.junit.Test; - import sonia.scm.AbstractTestBase; -import static org.junit.Assert.*; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; //~--- JDK imports ------------------------------------------------------------ @@ -65,8 +66,7 @@ public abstract class StoreTestBase extends AbstractTestBase @Test public void testGet() { - ConfigurationStore<StoreObject> store = createStoreFactory().getStore(StoreObject.class, - "test"); + ConfigurationStore<StoreObject> store = createStoreFactory().withType(StoreObject.class).withName("test").build(); assertNotNull(store); @@ -82,8 +82,7 @@ public abstract class StoreTestBase extends AbstractTestBase @Test public void testSet() { - ConfigurationStore<StoreObject> store = createStoreFactory().getStore(StoreObject.class, - "test"); + ConfigurationStore<StoreObject> store = createStoreFactory().withType(StoreObject.class).withName("test").build(); assertNotNull(store); diff --git a/scm-test/src/main/java/sonia/scm/util/MockUtil.java b/scm-test/src/main/java/sonia/scm/util/MockUtil.java index 756b2632be..76bf4ae24d 100644 --- a/scm-test/src/main/java/sonia/scm/util/MockUtil.java +++ b/scm-test/src/main/java/sonia/scm/util/MockUtil.java @@ -55,6 +55,7 @@ import static org.mockito.Mockito.*; import java.io.File; +import java.nio.file.Path; import java.util.Arrays; import java.util.List; @@ -213,6 +214,10 @@ public final class MockUtil SCMContextProvider provider = mock(SCMContextProvider.class); when(provider.getBaseDirectory()).thenReturn(directory); + when(provider.resolve(any(Path.class))).then(ic -> { + Path p = ic.getArgument(0); + return directory.toPath().resolve(p); + }); return provider; } diff --git a/scm-ui-components/packages/ui-components/package.json b/scm-ui-components/packages/ui-components/package.json index 4a4b4dc82e..bb8e5c738e 100644 --- a/scm-ui-components/packages/ui-components/package.json +++ b/scm-ui-components/packages/ui-components/package.json @@ -14,7 +14,7 @@ "eslint-fix": "eslint src --fix" }, "devDependencies": { - "@scm-manager/ui-bundler": "^0.0.21", + "@scm-manager/ui-bundler": "^0.0.24", "create-index": "^2.3.0", "enzyme": "^3.5.0", "enzyme-adapter-react-16": "^1.3.1", @@ -26,7 +26,7 @@ "react-router-enzyme-context": "^1.2.0" }, "dependencies": { - "@scm-manager/ui-extensions": "^0.1.1", + "@scm-manager/ui-extensions": "^0.1.2", "@scm-manager/ui-types": "2.0.0-SNAPSHOT", "classnames": "^2.2.6", "moment": "^2.22.2", @@ -34,7 +34,9 @@ "react-dom": "^16.5.2", "react-i18next": "^7.11.0", "react-jss": "^8.6.1", - "react-router-dom": "^4.3.1" + "react-router-dom": "^4.3.1", + "react-select": "^2.1.2", + "diff2html": "^2.5.0" }, "browserify": { "transform": [ diff --git a/scm-ui-components/packages/ui-components/src/Autocomplete.js b/scm-ui-components/packages/ui-components/src/Autocomplete.js new file mode 100644 index 0000000000..f3023e268b --- /dev/null +++ b/scm-ui-components/packages/ui-components/src/Autocomplete.js @@ -0,0 +1,73 @@ +// @flow +import React from "react"; +import { AsyncCreatable } from "react-select"; +import type { AutocompleteObject, SelectValue } from "@scm-manager/ui-types"; +import LabelWithHelpIcon from "./forms/LabelWithHelpIcon"; + + +type Props = { + loadSuggestions: string => Promise<AutocompleteObject>, + valueSelected: SelectValue => void, + label: string, + helpText?: string, + value?: SelectValue, + placeholder: string, + loadingMessage: string, + noOptionsMessage: string +}; + + +type State = {}; + +class Autocomplete extends React.Component<Props, State> { + + + static defaultProps = { + placeholder: "Type here", + loadingMessage: "Loading...", + noOptionsMessage: "No suggestion available" + }; + + handleInputChange = (newValue: SelectValue) => { + this.props.valueSelected(newValue); + }; + + // We overwrite this to avoid running into a bug (https://github.com/JedWatson/react-select/issues/2944) + isValidNewOption = (inputValue: string, selectValue: SelectValue, selectOptions: SelectValue[]) => { + const isNotDuplicated = !selectOptions + .map(option => option.label) + .includes(inputValue); + const isNotEmpty = inputValue !== ""; + return isNotEmpty && isNotDuplicated; + }; + + render() { + const { label, helpText, value, placeholder, loadingMessage, noOptionsMessage, loadSuggestions } = this.props; + return ( + <div className="field"> + <LabelWithHelpIcon label={label} helpText={helpText} /> + <div className="control"> + <AsyncCreatable + cacheOptions + loadOptions={loadSuggestions} + onChange={this.handleInputChange} + value={value} + placeholder={placeholder} + loadingMessage={() => loadingMessage} + noOptionsMessage={() => noOptionsMessage} + isValidNewOption={this.isValidNewOption} + onCreateOption={value => { + this.handleInputChange({ + label: value, + value: { id: value, displayName: value } + }); + }} + /> + </div> + </div> + ); + } +} + + +export default Autocomplete; diff --git a/scm-ui/src/repos/containers/BranchSelector.js b/scm-ui-components/packages/ui-components/src/BranchSelector.js similarity index 54% rename from scm-ui/src/repos/containers/BranchSelector.js rename to scm-ui-components/packages/ui-components/src/BranchSelector.js index 2183e13b69..c473527472 100644 --- a/scm-ui/src/repos/containers/BranchSelector.js +++ b/scm-ui-components/packages/ui-components/src/BranchSelector.js @@ -2,26 +2,33 @@ import React from "react"; import type { Branch } from "@scm-manager/ui-types"; -import DropDown from "../components/DropDown"; -import { translate } from "react-i18next"; import injectSheet from "react-jss"; -import { compose } from "redux"; import classNames from "classnames"; +import DropDown from "./forms/DropDown"; const styles = { zeroflex: { flexGrow: 0 + }, + minWidthOfLabel: { + minWidth: "4.5rem" + }, + labelSizing: { + fontSize: "1rem !important" + }, + noBottomMargin: { + marginBottom: "0 !important" } }; type Props = { branches: Branch[], // TODO: Use generics? selected: (branch?: Branch) => void, - selectedBranch: string, + selectedBranch?: string, + label: string, // context props - classes: Object, - t: string => string + classes: Object }; type State = { selectedBranch?: Branch }; @@ -33,24 +40,40 @@ class BranchSelector extends React.Component<Props, State> { } componentDidMount() { - this.props.branches - .filter(branch => branch.name === this.props.selectedBranch) - .forEach(branch => this.setState({ selectedBranch: branch })); + const selectedBranch = this.props.branches.find( + branch => branch.name === this.props.selectedBranch + ); + this.setState({ selectedBranch }); } render() { - const { branches, classes, t } = this.props; + const { branches, classes, label } = this.props; if (branches) { return ( - <div className="box field is-horizontal"> + <div + className={classNames( + "field", + "is-horizontal", + classes.noBottomMargin + )} + > <div - className={classNames("field-label", "is-normal", classes.zeroflex)} + className={classNames( + "field-label", + "is-normal", + classes.zeroflex, + classes.minWidthOfLabel + )} > - <label className="label">{t("branch-selector.label")}</label> + <label className={classNames("label", classes.labelSizing)}> + {label} + </label> </div> <div className="field-body"> - <div className="field is-narrow"> + <div + className={classNames("field is-narrow", classes.noBottomMargin)} + > <div className="control"> <DropDown className="is-fullwidth" @@ -74,6 +97,12 @@ class BranchSelector extends React.Component<Props, State> { branchSelected = (branchName: string) => { const { branches, selected } = this.props; + + if (!branchName) { + this.setState({ selectedBranch: undefined }); + selected(undefined); + return; + } const branch = branches.find(b => b.name === branchName); selected(branch); @@ -81,7 +110,4 @@ class BranchSelector extends React.Component<Props, State> { }; } -export default compose( - injectSheet(styles), - translate("repos") -)(BranchSelector); +export default injectSheet(styles)(BranchSelector); diff --git a/scm-ui-components/packages/ui-components/src/DateFromNow.js b/scm-ui-components/packages/ui-components/src/DateFromNow.js index b47de49a3d..be15bcdad2 100644 --- a/scm-ui-components/packages/ui-components/src/DateFromNow.js +++ b/scm-ui-components/packages/ui-components/src/DateFromNow.js @@ -2,31 +2,40 @@ import React from "react"; import moment from "moment"; import { translate } from "react-i18next"; +import injectSheet from "react-jss"; + +const styles = { + date: { + borderBottom: "1px dotted rgba(219, 219, 219)", + cursor: "help" + } +}; type Props = { date?: string, // context props + classes: any, i18n: any }; class DateFromNow extends React.Component<Props> { - static format(locale: string, date?: string) { - let fromNow = ""; - if (date) { - fromNow = moment(date) - .locale(locale) - .fromNow(); - } - return fromNow; - } render() { - const { i18n, date } = this.props; + const { i18n, date, classes } = this.props; - const fromNow = DateFromNow.format(i18n.language, date); - return <span>{fromNow}</span>; + if (date) { + const dateWithLocale = moment(date).locale(i18n.language); + + return ( + <time title={dateWithLocale.format()} className={classes.date}> + {dateWithLocale.fromNow()} + </time> + ); + } + + return null; } } -export default translate()(DateFromNow); +export default injectSheet(styles)(translate()(DateFromNow)); diff --git a/scm-ui-components/packages/ui-components/src/ErrorNotification.js b/scm-ui-components/packages/ui-components/src/ErrorNotification.js index 6a0dc202eb..64a31c8da4 100644 --- a/scm-ui-components/packages/ui-components/src/ErrorNotification.js +++ b/scm-ui-components/packages/ui-components/src/ErrorNotification.js @@ -2,7 +2,7 @@ import React from "react"; import { translate } from "react-i18next"; import Notification from "./Notification"; -import { BackendError } from "./errors"; +import {BackendError, UnauthorizedError} from "./errors"; type Props = { t: string => string, @@ -11,18 +11,18 @@ type Props = { class ErrorNotification extends React.Component<Props> { - renderMoreInformationsLink(error: BackendError) { + renderMoreInformationLink(error: BackendError) { if (error.url) { // TODO i18n return ( <p> - For more informations, see <a href={error.url} target="_blank">{error.errorCode}</a> + For more information, see <a href={error.url} target="_blank">{error.errorCode}</a> </p> ); } } - renderBackendError(error: BackendError) { + renderBackendError = (error: BackendError) => { // TODO i18n // how should we handle i18n for the message? // we could add translation for known error codes to i18n and pass the context objects as parameters, @@ -47,7 +47,7 @@ class ErrorNotification extends React.Component<Props> { ); })} </ul> - { this.renderMoreInformationsLink(error) } + { this.renderMoreInformationLink(error) } <div className="level is-size-7"> <div className="left"> ErrorCode: {error.errorCode} @@ -60,25 +60,20 @@ class ErrorNotification extends React.Component<Props> { ); } - renderError(error: Error) { - if (error instanceof BackendError) { - return this.renderBackendError(error); - } else { - return error.message; - } - } - render() { - const { error } = this.props; - + const { t, error } = this.props; if (error) { - return ( - <Notification type="danger"> - {this.renderError(error)} - </Notification> - ); + if (error instanceof BackendError) { + return this.renderBackendError(error) + } else { + return ( + <Notification type="danger"> + <strong>{t("error-notification.prefix")}:</strong> {error.message} + </Notification> + ); + } } - return ""; + return null; } } diff --git a/scm-ui-components/packages/ui-components/src/Help.js b/scm-ui-components/packages/ui-components/src/Help.js index 047f79244f..9cb37e722d 100644 --- a/scm-ui-components/packages/ui-components/src/Help.js +++ b/scm-ui-components/packages/ui-components/src/Help.js @@ -7,7 +7,8 @@ import HelpIcon from './HelpIcon'; const styles = { tooltip: { display: "inline-block", - paddingLeft: "3px" + paddingLeft: "3px", + position: "absolute" } }; diff --git a/scm-ui-components/packages/ui-components/src/HelpIcon.js b/scm-ui-components/packages/ui-components/src/HelpIcon.js index fba8ead422..9e095bd8a7 100644 --- a/scm-ui-components/packages/ui-components/src/HelpIcon.js +++ b/scm-ui-components/packages/ui-components/src/HelpIcon.js @@ -1,14 +1,23 @@ //@flow import React from "react"; +import injectSheet from "react-jss"; import classNames from "classnames"; type Props = { + classes: any +}; + +const styles = { + textinfo: { + color: "#98d8f3 !important" + } }; class HelpIcon extends React.Component<Props> { render() { - return <i className={classNames("fa fa-question has-text-info")} /> + const { classes } = this.props; + return <i className={classNames("fa fa-question-circle has-text-info", classes.textinfo)}></i>; } } -export default HelpIcon; +export default injectSheet(styles)(HelpIcon); diff --git a/scm-ui-components/packages/ui-components/src/LinkPaginator.js b/scm-ui-components/packages/ui-components/src/LinkPaginator.js index aaf13d7b15..d09306e04c 100644 --- a/scm-ui-components/packages/ui-components/src/LinkPaginator.js +++ b/scm-ui-components/packages/ui-components/src/LinkPaginator.js @@ -25,13 +25,13 @@ class LinkPaginator extends React.Component<Props> { ); } - renderPreviousButton(label?: string) { + renderPreviousButton(className: string, label?: string) { const { page } = this.props; const previousPage = page - 1; return ( <Button - className={"pagination-previous"} + className={className} label={label ? label : previousPage.toString()} disabled={!this.hasLink("prev")} link={`${previousPage}`} @@ -44,12 +44,12 @@ class LinkPaginator extends React.Component<Props> { return collection._links[name]; } - renderNextButton(label?: string) { + renderNextButton(className: string, label?: string) { const { page } = this.props; const nextPage = page + 1; return ( <Button - className={"pagination-next"} + className={className} label={label ? label : nextPage.toString()} disabled={!this.hasLink("next")} link={`${nextPage}`} @@ -96,13 +96,13 @@ class LinkPaginator extends React.Component<Props> { links.push(this.separator()); } if (page > 2) { - links.push(this.renderPreviousButton()); + links.push(this.renderPreviousButton("pagination-link")); } links.push(this.currentPage(page)); if (page + 1 < pageTotal) { - links.push(this.renderNextButton()); + links.push(this.renderNextButton("pagination-link")); } if (page + 2 < pageTotal) //if there exists pages between next and last @@ -118,13 +118,13 @@ class LinkPaginator extends React.Component<Props> { const { t } = this.props; return ( <nav className="pagination is-centered" aria-label="pagination"> - {this.renderPreviousButton(t("paginator.previous"))} + {this.renderPreviousButton("pagination-previous", t("paginator.previous"))} <ul className="pagination-list"> {this.pageLinks().map((link, index) => { return <li key={index}>{link}</li>; })} </ul> - {this.renderNextButton(t("paginator.next"))} + {this.renderNextButton("pagination-next", t("paginator.next"))} </nav> ); } diff --git a/scm-ui-components/packages/ui-components/src/Paginator.test.js b/scm-ui-components/packages/ui-components/src/Paginator.test.js index f3ac840f67..d32b4df702 100644 --- a/scm-ui-components/packages/ui-components/src/Paginator.test.js +++ b/scm-ui-components/packages/ui-components/src/Paginator.test.js @@ -6,7 +6,7 @@ import "./tests/i18n"; import ReactRouterEnzymeContext from "react-router-enzyme-context"; import Paginator from "./Paginator"; -describe("paginator rendering tests", () => { +xdescribe("paginator rendering tests", () => { const options = new ReactRouterEnzymeContext(); @@ -18,7 +18,8 @@ describe("paginator rendering tests", () => { const collection = { page: 10, pageTotal: 20, - _links: {} + _links: {}, + _embedded: {} }; const paginator = shallow( @@ -40,7 +41,8 @@ describe("paginator rendering tests", () => { first: dummyLink, next: dummyLink, last: dummyLink - } + }, + _embedded: {} }; const paginator = shallow( @@ -79,7 +81,8 @@ describe("paginator rendering tests", () => { prev: dummyLink, next: dummyLink, last: dummyLink - } + }, + _embedded: {} }; const paginator = shallow( @@ -121,7 +124,8 @@ describe("paginator rendering tests", () => { _links: { first: dummyLink, prev: dummyLink - } + }, + _embedded: {} }; const paginator = shallow( @@ -160,7 +164,8 @@ describe("paginator rendering tests", () => { prev: dummyLink, next: dummyLink, last: dummyLink - } + }, + _embedded: {} }; const paginator = shallow( @@ -204,7 +209,8 @@ describe("paginator rendering tests", () => { prev: dummyLink, next: dummyLink, last: dummyLink - } + }, + _embedded: {} }; const paginator = shallow( @@ -256,7 +262,8 @@ describe("paginator rendering tests", () => { }, next: dummyLink, last: dummyLink - } + }, + _embedded: {} }; let urlToOpen; diff --git a/scm-ui-components/packages/ui-components/src/StatePaginator.js b/scm-ui-components/packages/ui-components/src/StatePaginator.js new file mode 100644 index 0000000000..04f70ead52 --- /dev/null +++ b/scm-ui-components/packages/ui-components/src/StatePaginator.js @@ -0,0 +1,138 @@ +//@flow +import React from "react"; +import { translate } from "react-i18next"; +import type { PagedCollection } from "@scm-manager/ui-types"; +import { Button } from "./index"; + +type Props = { + collection: PagedCollection, + page: number, + updatePage: number => void, + + // context props + t: string => string +}; + +class StatePaginator extends React.Component<Props> { + renderFirstButton() { + return ( + <Button + className={"pagination-link"} + label={"1"} + disabled={false} + action={() => this.updateCurrentPage(1)} + /> + ); + } + + updateCurrentPage = (newPage: number) => { + this.props.updatePage(newPage); + }; + + renderPreviousButton(label?: string) { + const { page } = this.props; + const previousPage = page - 1; + + return ( + <Button + className={"pagination-previous"} + label={label ? label : previousPage.toString()} + disabled={!this.hasLink("prev")} + action={() => this.updateCurrentPage(previousPage)} + /> + ); + } + + hasLink(name: string) { + const { collection } = this.props; + return collection._links[name]; + } + + renderNextButton(label?: string) { + const { page } = this.props; + const nextPage = page + 1; + return ( + <Button + className={"pagination-next"} + label={label ? label : nextPage.toString()} + disabled={!this.hasLink("next")} + action={() => this.updateCurrentPage(nextPage)} + /> + ); + } + + renderLastButton() { + const { collection } = this.props; + return ( + <Button + className={"pagination-link"} + label={`${collection.pageTotal}`} + disabled={false} + action={() => this.updateCurrentPage(collection.pageTotal)} + /> + ); + } + + separator() { + return <span className="pagination-ellipsis">…</span>; + } + + currentPage(page: number) { + return ( + <Button + className="pagination-link is-current" + label={page} + disabled={true} + action={() => this.updateCurrentPage(page)} + /> + ); + } + + pageLinks() { + const { collection } = this.props; + + const links = []; + const page = collection.page + 1; + const pageTotal = collection.pageTotal; + if (page > 1) { + links.push(this.renderFirstButton()); + } + if (page > 3) { + links.push(this.separator()); + } + if (page > 2) { + links.push(this.renderPreviousButton()); + } + + links.push(this.currentPage(page)); + + if (page + 1 < pageTotal) { + links.push(this.renderNextButton()); + } + if (page + 2 < pageTotal) + //if there exists pages between next and last + links.push(this.separator()); + if (page < pageTotal) { + links.push(this.renderLastButton()); + } + + return links; + } + + render() { + const { t } = this.props; + return ( + <nav className="pagination is-centered" aria-label="pagination"> + {this.renderPreviousButton(t("paginator.previous"))} + <ul className="pagination-list"> + {this.pageLinks().map((link, index) => { + return <li key={index}>{link}</li>; + })} + </ul> + {this.renderNextButton(t("paginator.next"))} + </nav> + ); + } +} + +export default translate("commons")(StatePaginator); diff --git a/scm-ui-components/packages/ui-components/src/Tooltip.js b/scm-ui-components/packages/ui-components/src/Tooltip.js index d935b323c7..cecb8d349b 100644 --- a/scm-ui-components/packages/ui-components/src/Tooltip.js +++ b/scm-ui-components/packages/ui-components/src/Tooltip.js @@ -4,21 +4,27 @@ import classNames from "classnames"; type Props = { message: string, - className: string, + className?: string, + location: string, children: React.Node }; class Tooltip extends React.Component<Props> { + + static defaultProps = { + location: "right" + }; + render() { - const { className, message, children } = this.props; + const { className, message, location, children } = this.props; const multiline = message.length > 60 ? "is-tooltip-multiline" : ""; return ( - <div - className={classNames("tooltip", "is-tooltip-right", multiline, className)} + <span + className={classNames("tooltip", "is-tooltip-" + location, multiline, className)} data-tooltip={message} > {children} - </div> + </span> ); } } diff --git a/scm-ui-components/packages/ui-components/src/apiclient.js b/scm-ui-components/packages/ui-components/src/apiclient.js index 348371fddd..0d52167d95 100644 --- a/scm-ui-components/packages/ui-components/src/apiclient.js +++ b/scm-ui-components/packages/ui-components/src/apiclient.js @@ -10,7 +10,6 @@ const fetchOptions: RequestOptions = { } }; - function isBackendError(response) { return response.headers.get("Content-Type") === "application/vnd.scmm-error+json;v=2"; } diff --git a/scm-ui-components/packages/ui-components/src/avatar/Avatar.js b/scm-ui-components/packages/ui-components/src/avatar/Avatar.js new file mode 100644 index 0000000000..4108ae354b --- /dev/null +++ b/scm-ui-components/packages/ui-components/src/avatar/Avatar.js @@ -0,0 +1,8 @@ +// @flow + +export type Person = { + name: string, + mail?: string +}; + +export const EXTENSION_POINT = "avatar.factory"; diff --git a/scm-ui/src/repos/components/changesets/AvatarImage.js b/scm-ui-components/packages/ui-components/src/avatar/AvatarImage.js similarity index 53% rename from scm-ui/src/repos/components/changesets/AvatarImage.js rename to scm-ui-components/packages/ui-components/src/avatar/AvatarImage.js index 6d730e87cd..930c172b0b 100644 --- a/scm-ui/src/repos/components/changesets/AvatarImage.js +++ b/scm-ui-components/packages/ui-components/src/avatar/AvatarImage.js @@ -1,26 +1,28 @@ //@flow import React from "react"; import {binder} from "@scm-manager/ui-extensions"; -import type {Changeset} from "@scm-manager/ui-types"; -import {Image} from "@scm-manager/ui-components"; +import {Image} from ".."; +import type { Person } from "./Avatar"; +import { EXTENSION_POINT } from "./Avatar"; + type Props = { - changeset: Changeset + person: Person }; class AvatarImage extends React.Component<Props> { render() { - const { changeset } = this.props; + const { person } = this.props; - const avatarFactory = binder.getExtension("changeset.avatar-factory"); + const avatarFactory = binder.getExtension(EXTENSION_POINT); if (avatarFactory) { - const avatar = avatarFactory(changeset); + const avatar = avatarFactory(person); return ( <Image className="has-rounded-border" src={avatar} - alt={changeset.author.name} + alt={person.name} /> ); } diff --git a/scm-ui/src/repos/components/changesets/AvatarWrapper.js b/scm-ui-components/packages/ui-components/src/avatar/AvatarWrapper.js similarity index 76% rename from scm-ui/src/repos/components/changesets/AvatarWrapper.js rename to scm-ui-components/packages/ui-components/src/avatar/AvatarWrapper.js index c014b33281..50f584f753 100644 --- a/scm-ui/src/repos/components/changesets/AvatarWrapper.js +++ b/scm-ui-components/packages/ui-components/src/avatar/AvatarWrapper.js @@ -1,6 +1,7 @@ //@flow import * as React from "react"; import {binder} from "@scm-manager/ui-extensions"; +import { EXTENSION_POINT } from "./Avatar"; type Props = { children: React.Node @@ -8,7 +9,7 @@ type Props = { class AvatarWrapper extends React.Component<Props> { render() { - if (binder.hasExtension("changeset.avatar-factory")) { + if (binder.hasExtension(EXTENSION_POINT)) { return <>{this.props.children}</>; } return null; diff --git a/scm-ui-components/packages/ui-components/src/avatar/index.js b/scm-ui-components/packages/ui-components/src/avatar/index.js new file mode 100644 index 0000000000..c1f09fcaec --- /dev/null +++ b/scm-ui-components/packages/ui-components/src/avatar/index.js @@ -0,0 +1,4 @@ +// @flow + +export { default as AvatarWrapper } from "./AvatarWrapper"; +export { default as AvatarImage } from "./AvatarImage"; diff --git a/scm-ui-components/packages/ui-components/src/buttons/AddButton.js b/scm-ui-components/packages/ui-components/src/buttons/AddButton.js index de72bdaab9..8f46fb66a4 100644 --- a/scm-ui-components/packages/ui-components/src/buttons/AddButton.js +++ b/scm-ui-components/packages/ui-components/src/buttons/AddButton.js @@ -1,11 +1,11 @@ -//@flow -import React from "react"; -import Button, { type ButtonProps } from "./Button"; - -class AddButton extends React.Component<ButtonProps> { - render() { - return <Button color="default" {...this.props} />; - } -} - -export default AddButton; +//@flow +import React from "react"; +import Button, { type ButtonProps } from "./Button"; + +class AddButton extends React.Component<ButtonProps> { + render() { + return <Button color="default" {...this.props} />; + } +} + +export default AddButton; diff --git a/scm-ui-components/packages/ui-components/src/buttons/Button.js b/scm-ui-components/packages/ui-components/src/buttons/Button.js index 4ad88f4eac..2102bb540a 100644 --- a/scm-ui-components/packages/ui-components/src/buttons/Button.js +++ b/scm-ui-components/packages/ui-components/src/buttons/Button.js @@ -1,16 +1,17 @@ //@flow -import React from "react"; +import * as React from "react"; import classNames from "classnames"; import { withRouter } from "react-router-dom"; export type ButtonProps = { - label: string, + label?: string, loading?: boolean, disabled?: boolean, action?: (event: Event) => void, link?: string, fullWidth?: boolean, className?: string, + children?: React.Node, classes: any }; @@ -45,6 +46,7 @@ class Button extends React.Component<Props> { type, color, fullWidth, + children, className } = this.props; const loadingClass = loading ? "is-loading" : ""; @@ -62,7 +64,7 @@ class Button extends React.Component<Props> { className )} > - {label} + {label} {children} </button> ); }; diff --git a/scm-ui-components/packages/ui-components/src/buttons/ButtonGroup.js b/scm-ui-components/packages/ui-components/src/buttons/ButtonGroup.js new file mode 100644 index 0000000000..fb48a3ba4f --- /dev/null +++ b/scm-ui-components/packages/ui-components/src/buttons/ButtonGroup.js @@ -0,0 +1,33 @@ +// @flow +import * as React from "react"; + +type Props = { + addons?: boolean, + className?: string, + children: React.Node +}; + +class ButtonGroup extends React.Component<Props> { + + static defaultProps = { + addons: true + }; + + render() { + const { addons, className, children } = this.props; + let styleClasses = "buttons"; + if (addons) { + styleClasses += " has-addons"; + } + if (className) { + styleClasses += " " + className; + } + return ( + <div className={styleClasses}> + { children } + </div> + ); + } +} + +export default ButtonGroup; diff --git a/scm-ui-components/packages/ui-components/src/buttons/CreateButton.js b/scm-ui-components/packages/ui-components/src/buttons/CreateButton.js index aa5510deaf..155669754c 100644 --- a/scm-ui-components/packages/ui-components/src/buttons/CreateButton.js +++ b/scm-ui-components/packages/ui-components/src/buttons/CreateButton.js @@ -1,21 +1,25 @@ //@flow import React from "react"; import injectSheet from "react-jss"; -import AddButton, { type ButtonProps } from "./Button"; +import { type ButtonProps } from "./Button"; import classNames from "classnames"; +import Button from "./Button"; const styles = { spacing: { - margin: "1em 0 0 1em" + marginTop: "2em", + border: "2px solid #e9f7fd", + padding: "1em 1em" } + }; class CreateButton extends React.Component<ButtonProps> { render() { const { classes } = this.props; return ( - <div className={classNames("is-pulled-right", classes.spacing)}> - <AddButton {...this.props} /> + <div className={classNames("has-text-centered", classes.spacing)}> + <Button color="primary" {...this.props} /> </div> ); } diff --git a/scm-ui-components/packages/ui-components/src/buttons/DownloadButton.js b/scm-ui-components/packages/ui-components/src/buttons/DownloadButton.js index 3a99dc876b..382bd500c5 100644 --- a/scm-ui-components/packages/ui-components/src/buttons/DownloadButton.js +++ b/scm-ui-components/packages/ui-components/src/buttons/DownloadButton.js @@ -1,18 +1,19 @@ //@flow import React from "react"; -import Button, { type ButtonProps } from "./Button"; -import type {File} from "@scm-manager/ui-types"; type Props = { displayName: string, - url: string + url: string, + disabled: boolean, + onClick?: () => void }; class DownloadButton extends React.Component<Props> { render() { - const {displayName, url} = this.props; + const { displayName, url, disabled, onClick } = this.props; + const onClickOrDefault = !!onClick ? onClick : () => {}; return ( - <a className="button is-large is-info" href={url}> + <a className="button is-large is-link" href={url} disabled={disabled} onClick={onClickOrDefault}> <span className="icon is-medium"> <i className="fas fa-arrow-circle-down" /> </span> diff --git a/scm-ui-components/packages/ui-components/src/buttons/SubmitButton.js b/scm-ui-components/packages/ui-components/src/buttons/SubmitButton.js index 1a8604834b..0f03d850b2 100644 --- a/scm-ui-components/packages/ui-components/src/buttons/SubmitButton.js +++ b/scm-ui-components/packages/ui-components/src/buttons/SubmitButton.js @@ -4,7 +4,20 @@ import Button, { type ButtonProps } from "./Button"; class SubmitButton extends React.Component<ButtonProps> { render() { - return <Button type="submit" color="primary" {...this.props} />; + const { action } = this.props; + return ( + <Button + type="submit" + color="primary" + {...this.props} + action={(event) => { + if (action) { + action(event) + } + window.scrollTo(0, 0); + }} + /> + ); } } diff --git a/scm-ui-components/packages/ui-components/src/buttons/index.js b/scm-ui-components/packages/ui-components/src/buttons/index.js index 2e166e1d93..014d92958d 100644 --- a/scm-ui-components/packages/ui-components/src/buttons/index.js +++ b/scm-ui-components/packages/ui-components/src/buttons/index.js @@ -5,6 +5,9 @@ export { default as Button } from "./Button.js"; export { default as CreateButton } from "./CreateButton.js"; export { default as DeleteButton } from "./DeleteButton.js"; export { default as EditButton } from "./EditButton.js"; -export { default as RemoveEntryOfTableButton } from "./RemoveEntryOfTableButton.js"; export { default as SubmitButton } from "./SubmitButton.js"; -export {default as DownloadButton} from "./DownloadButton.js"; +export { default as DownloadButton } from "./DownloadButton.js"; +export { default as ButtonGroup } from "./ButtonGroup.js"; +export { + default as RemoveEntryOfTableButton +} from "./RemoveEntryOfTableButton.js"; diff --git a/scm-ui-components/packages/ui-components/src/config/GlobalConfiguration.js b/scm-ui-components/packages/ui-components/src/config/Configuration.js similarity index 60% rename from scm-ui-components/packages/ui-components/src/config/GlobalConfiguration.js rename to scm-ui-components/packages/ui-components/src/config/Configuration.js index b2b7dca647..0eb6f6ffc2 100644 --- a/scm-ui-components/packages/ui-components/src/config/GlobalConfiguration.js +++ b/scm-ui-components/packages/ui-components/src/config/Configuration.js @@ -2,17 +2,12 @@ import React from "react"; import { translate } from "react-i18next"; import type { Links } from "@scm-manager/ui-types"; -import { - apiClient, - SubmitButton, - Loading, - ErrorNotification -} from "../"; +import { apiClient, SubmitButton, Loading, ErrorNotification } from "../"; type RenderProps = { readOnly: boolean, - initialConfiguration: Configuration, - onConfigurationChange: (Configuration, boolean) => void + initialConfiguration: ConfigurationType, + onConfigurationChange: (ConfigurationType, boolean) => void }; type Props = { @@ -20,10 +15,10 @@ type Props = { render: (props: RenderProps) => any, // ??? // context props - t: (string) => string + t: string => string }; -type Configuration = { +type ConfigurationType = { _links: Links } & Object; @@ -32,9 +27,10 @@ type State = { fetching: boolean, modifying: boolean, contentType?: string, + configChanged: boolean, - configuration?: Configuration, - modifiedConfiguration?: Configuration, + configuration?: ConfigurationType, + modifiedConfiguration?: ConfigurationType, valid: boolean }; @@ -42,13 +38,13 @@ type State = { * GlobalConfiguration uses the render prop pattern to encapsulate the logic for * synchronizing the configuration with the backend. */ -class GlobalConfiguration extends React.Component<Props, State> { - +class Configuration extends React.Component<Props, State> { constructor(props: Props) { super(props); this.state = { fetching: true, modifying: false, + configChanged: false, valid: false }; } @@ -56,7 +52,8 @@ class GlobalConfiguration extends React.Component<Props, State> { componentDidMount() { const { link } = this.props; - apiClient.get(link) + apiClient + .get(link) .then(this.captureContentType) .then(response => response.json()) .then(this.loadConfig) @@ -84,7 +81,7 @@ class GlobalConfiguration extends React.Component<Props, State> { }); }; - loadConfig = (configuration: Configuration) => { + loadConfig = (configuration: ConfigurationType) => { this.setState({ configuration, fetching: false, @@ -107,7 +104,7 @@ class GlobalConfiguration extends React.Component<Props, State> { return !modificationUrl; }; - configurationChanged = (configuration: Configuration, valid: boolean) => { + configurationChanged = (configuration: ConfigurationType, valid: boolean) => { this.setState({ modifiedConfiguration: configuration, valid @@ -119,19 +116,39 @@ class GlobalConfiguration extends React.Component<Props, State> { this.setState({ modifying: true }); - const {modifiedConfiguration} = this.state; + const { modifiedConfiguration } = this.state; - apiClient.put(this.getModificationUrl(), modifiedConfiguration, this.getContentType()) - .then(() => this.setState({ modifying: false })) + apiClient + .put( + this.getModificationUrl(), + modifiedConfiguration, + this.getContentType() + ) + .then(() => this.setState({ modifying: false, configChanged: true, valid: false })) .catch(this.handleError); }; + renderConfigChangedNotification = () => { + if (this.state.configChanged) { + return ( + <div className="notification is-primary"> + <button + className="delete" + onClick={() => this.setState({ configChanged: false })} + /> + {this.props.t("config-form.submit-success-notification")} + </div> + ); + } + return null; + }; + render() { const { t } = this.props; const { fetching, error, configuration, modifying, valid } = this.state; if (error) { - return <ErrorNotification error={error}/>; + return <ErrorNotification error={error} />; } else if (fetching || !configuration) { return <Loading />; } else { @@ -144,19 +161,21 @@ class GlobalConfiguration extends React.Component<Props, State> { }; return ( - <form onSubmit={this.modifyConfiguration}> - { this.props.render(renderProps) } - <hr/> - <SubmitButton - label={t("config-form.submit")} - disabled={!valid || readOnly} - loading={modifying} - /> - </form> + <> + {this.renderConfigChangedNotification()} + <form onSubmit={this.modifyConfiguration}> + {this.props.render(renderProps)} + <hr /> + <SubmitButton + label={t("config-form.submit")} + disabled={!valid || readOnly} + loading={modifying} + /> + </form> + </> ); } } - } -export default translate("config")(GlobalConfiguration); +export default translate("config")(Configuration); diff --git a/scm-ui-components/packages/ui-components/src/config/ConfigurationBinder.js b/scm-ui-components/packages/ui-components/src/config/ConfigurationBinder.js index 477eee5238..7d0dfe7ed8 100644 --- a/scm-ui-components/packages/ui-components/src/config/ConfigurationBinder.js +++ b/scm-ui-components/packages/ui-components/src/config/ConfigurationBinder.js @@ -9,6 +9,16 @@ class ConfigurationBinder { i18nNamespace: string = "plugins"; + navLink(to: string, labelI18nKey: string, t: any){ + return <NavLink to={to} label={t(labelI18nKey)} />; + } + + route(path: string, Component: any){ + return <Route path={path} + render={() => Component} + exact/>; + } + bindGlobal(to: string, labelI18nKey: string, linkName: string, ConfigurationComponent: any) { // create predicate based on the link name of the index resource @@ -19,25 +29,76 @@ class ConfigurationBinder { // create NavigationLink with translated label const ConfigNavLink = translate(this.i18nNamespace)(({t}) => { - return <NavLink to={"/config" + to} label={t(labelI18nKey)} />; + return this.navLink("/config" + to, labelI18nKey, t); }); // bind navigation link to extension point binder.bind("config.navigation", ConfigNavLink, configPredicate); - // route for global configuration, passes the link from the index resource to component - const ConfigRoute = ({ url, links }) => { + const ConfigRoute = ({ url, links, ...additionalProps }) => { const link = links[linkName].href; - return <Route path={url + to} - render={() => <ConfigurationComponent link={link}/>} - exact/>; + return this.route(url + to, <ConfigurationComponent link={link} {...additionalProps} />); }; // bind config route to extension point binder.bind("config.route", ConfigRoute, configPredicate); } + bindRepository(to: string, labelI18nKey: string, linkName: string, RepositoryComponent: any) { + + // create predicate based on the link name of the current repository route + // if the linkname is not available, the navigation link and the route are not bound to the extension points + const repoPredicate = (props: Object) => { + return props.repository && props.repository._links && props.repository._links[linkName]; + }; + + // create NavigationLink with translated label + const RepoNavLink = translate(this.i18nNamespace)(({t, url}) => { + return this.navLink(url + to, labelI18nKey, t); + }); + + // bind navigation link to extension point + binder.bind("repository.navigation", RepoNavLink, repoPredicate); + + + // route for global configuration, passes the current repository to component + const RepoRoute = ({url, repository, ...additionalProps}) => { + const link = repository._links[linkName].href; + return this.route(url + to, <RepositoryComponent repository={repository} link={link} {...additionalProps}/>); + }; + + // bind config route to extension point + binder.bind("repository.route", RepoRoute, repoPredicate); + } + + bindRepositorySetting(to: string, labelI18nKey: string, linkName: string, RepositoryComponent: any) { + + // create predicate based on the link name of the current repository route + // if the linkname is not available, the navigation link and the route are not bound to the extension points + const repoPredicate = (props: Object) => { + return props.repository && props.repository._links && props.repository._links[linkName]; + }; + + // create NavigationLink with translated label + const RepoNavLink = translate(this.i18nNamespace)(({t, url}) => { + return this.navLink(url + "/settings" + to, labelI18nKey, t); + }); + + // bind navigation link to extension point + binder.bind("repository.setting", RepoNavLink, repoPredicate); + + + // route for global configuration, passes the current repository to component + const RepoRoute = ({url, repository, ...additionalProps}) => { + const link = repository._links[linkName].href; + return this.route(url + "/settings" + to, <RepositoryComponent repository={repository} link={link} {...additionalProps}/>); + }; + + // bind config route to extension point + binder.bind("repository.route", RepoRoute, repoPredicate); + } + } export default new ConfigurationBinder(); diff --git a/scm-ui-components/packages/ui-components/src/config/index.js b/scm-ui-components/packages/ui-components/src/config/index.js index 9596e9cda5..6833632a0d 100644 --- a/scm-ui-components/packages/ui-components/src/config/index.js +++ b/scm-ui-components/packages/ui-components/src/config/index.js @@ -1,3 +1,3 @@ // @flow export { default as ConfigurationBinder } from "./ConfigurationBinder"; -export { default as GlobalConfiguration } from "./GlobalConfiguration"; +export { default as Configuration } from "./Configuration"; diff --git a/scm-ui-components/packages/ui-components/src/forms/AddEntryToTableField.js b/scm-ui-components/packages/ui-components/src/forms/AddEntryToTableField.js index e5c04eb613..24b1ced28a 100644 --- a/scm-ui-components/packages/ui-components/src/forms/AddEntryToTableField.js +++ b/scm-ui-components/packages/ui-components/src/forms/AddEntryToTableField.js @@ -10,7 +10,8 @@ type Props = { buttonLabel: string, fieldLabel: string, errorMessage: string, - helpText?: string + helpText?: string, + validateEntry?: string => boolean }; type State = { @@ -25,6 +26,15 @@ class AddEntryToTableField extends React.Component<Props, State> { }; } + isValid = () => { + const {validateEntry} = this.props; + if (!this.state.entryToAdd || this.state.entryToAdd === "" || !validateEntry) { + return true; + } else { + return validateEntry(this.state.entryToAdd); + } + }; + render() { const { disabled, @@ -39,7 +49,7 @@ class AddEntryToTableField extends React.Component<Props, State> { label={fieldLabel} errorMessage={errorMessage} onChange={this.handleAddEntryChange} - validationError={false} + validationError={!this.isValid()} value={this.state.entryToAdd} onReturnPressed={this.appendEntry} disabled={disabled} @@ -48,7 +58,7 @@ class AddEntryToTableField extends React.Component<Props, State> { <AddButton label={buttonLabel} action={this.addButtonClicked} - disabled={disabled} + disabled={disabled || this.state.entryToAdd ==="" || !this.isValid()} /> </div> ); diff --git a/scm-ui-components/packages/ui-components/src/forms/AutocompleteAddEntryToTableField.js b/scm-ui-components/packages/ui-components/src/forms/AutocompleteAddEntryToTableField.js new file mode 100644 index 0000000000..f3af79e658 --- /dev/null +++ b/scm-ui-components/packages/ui-components/src/forms/AutocompleteAddEntryToTableField.js @@ -0,0 +1,88 @@ +//@flow +import React from "react"; + +import type { AutocompleteObject, SelectValue } from "@scm-manager/ui-types"; +import Autocomplete from "../Autocomplete"; +import AddButton from "../buttons/AddButton"; + +type Props = { + addEntry: SelectValue => void, + disabled: boolean, + buttonLabel: string, + fieldLabel: string, + helpText?: string, + loadSuggestions: string => Promise<AutocompleteObject>, + placeholder?: string, + loadingMessage?: string, + noOptionsMessage?: string +}; + +type State = { + selectedValue?: SelectValue +}; + +class AutocompleteAddEntryToTableField extends React.Component<Props, State> { + constructor(props: Props) { + super(props); + this.state = { selectedValue: undefined }; + } + render() { + const { + disabled, + buttonLabel, + fieldLabel, + helpText, + loadSuggestions, + placeholder, + loadingMessage, + noOptionsMessage + } = this.props; + + const { selectedValue } = this.state; + return ( + <div className="field"> + <Autocomplete + label={fieldLabel} + loadSuggestions={loadSuggestions} + valueSelected={this.handleAddEntryChange} + helpText={helpText} + value={selectedValue} + placeholder={placeholder} + loadingMessage={loadingMessage} + noOptionsMessage={noOptionsMessage} + /> + + <AddButton + label={buttonLabel} + action={this.addButtonClicked} + disabled={disabled} + /> + </div> + ); + } + + addButtonClicked = (event: Event) => { + event.preventDefault(); + this.appendEntry(); + }; + + appendEntry = () => { + const { selectedValue } = this.state; + if (!selectedValue) { + return; + } + // $FlowFixMe null is needed to clear the selection; undefined does not work + this.setState({ ...this.state, selectedValue: null }, () => + this.props.addEntry(selectedValue) + ); + }; + + handleAddEntryChange = (selection: SelectValue) => { + this.setState({ + ...this.state, + selectedValue: selection + }); + }; +} + +export default AutocompleteAddEntryToTableField; diff --git a/scm-ui/src/repos/components/DropDown.js b/scm-ui-components/packages/ui-components/src/forms/DropDown.js similarity index 85% rename from scm-ui/src/repos/components/DropDown.js rename to scm-ui-components/packages/ui-components/src/forms/DropDown.js index 5098a901f3..62a7f1ebe1 100644 --- a/scm-ui/src/repos/components/DropDown.js +++ b/scm-ui-components/packages/ui-components/src/forms/DropDown.js @@ -7,17 +7,19 @@ type Props = { options: string[], optionSelected: string => void, preselectedOption?: string, - className: any + className: any, + disabled?: boolean }; class DropDown extends React.Component<Props> { render() { - const { options, preselectedOption, className } = this.props; + const { options, preselectedOption, className, disabled } = this.props; return ( <div className={classNames(className, "select")}> <select value={preselectedOption ? preselectedOption : ""} onChange={this.change} + disabled={disabled} > <option key="" /> {options.map(option => { diff --git a/scm-ui-components/packages/ui-components/src/forms/LabelWithHelpIcon.js b/scm-ui-components/packages/ui-components/src/forms/LabelWithHelpIcon.js index 8a917828ec..c0e1dffb6a 100644 --- a/scm-ui-components/packages/ui-components/src/forms/LabelWithHelpIcon.js +++ b/scm-ui-components/packages/ui-components/src/forms/LabelWithHelpIcon.js @@ -1,6 +1,6 @@ //@flow import React from "react"; -import Help from '../Help'; +import Help from "../Help.js"; type Props = { label?: string, diff --git a/scm-ui/src/groups/components/MemberNameTable.js b/scm-ui-components/packages/ui-components/src/forms/MemberNameTable.js similarity index 84% rename from scm-ui/src/groups/components/MemberNameTable.js rename to scm-ui-components/packages/ui-components/src/forms/MemberNameTable.js index 59f12461fe..0db3751239 100644 --- a/scm-ui/src/groups/components/MemberNameTable.js +++ b/scm-ui-components/packages/ui-components/src/forms/MemberNameTable.js @@ -1,10 +1,7 @@ //@flow import React from "react"; import { translate } from "react-i18next"; -import { - RemoveEntryOfTableButton, - LabelWithHelpIcon -} from "@scm-manager/ui-components"; +import RemoveEntryOfTableButton from "../buttons/RemoveEntryOfTableButton"; type Props = { members: string[], @@ -19,10 +16,6 @@ class MemberNameTable extends React.Component<Props, State> { const { t } = this.props; return ( <div> - <LabelWithHelpIcon - label={t("group.members")} - helpText={t("group-form.help.memberHelpText")} - /> <table className="table is-hoverable is-fullwidth"> <tbody> {this.props.members.map(member => { diff --git a/scm-ui-components/packages/ui-components/src/forms/PasswordConfirmation.js b/scm-ui-components/packages/ui-components/src/forms/PasswordConfirmation.js new file mode 100644 index 0000000000..b0f53cddeb --- /dev/null +++ b/scm-ui-components/packages/ui-components/src/forms/PasswordConfirmation.js @@ -0,0 +1,110 @@ +// @flow + +import React from "react"; +import {translate} from "react-i18next"; +import InputField from "./InputField"; + +type State = { + password: string, + confirmedPassword: string, + passwordValid: boolean, + passwordConfirmationFailed: boolean +}; +type Props = { + passwordChanged: (string, boolean) => void, + passwordValidator?: string => boolean, + // Context props + t: string => string +}; + +class PasswordConfirmation extends React.Component<Props, State> { + constructor(props: Props) { + super(props); + this.state = { + password: "", + confirmedPassword: "", + passwordValid: true, + passwordConfirmationFailed: false + }; + } + + componentDidMount() { + this.setState({ + password: "", + confirmedPassword: "", + passwordValid: true, + passwordConfirmationFailed: false + }); + } + + render() { + const { t } = this.props; + return ( + <> + <InputField + label={t("password.newPassword")} + type="password" + onChange={this.handlePasswordChange} + value={this.state.password ? this.state.password : ""} + validationError={!this.state.passwordValid} + errorMessage={t("password.passwordInvalid")} + helpText={t("password.passwordHelpText")} + /> + <InputField + label={t("password.confirmPassword")} + type="password" + onChange={this.handlePasswordValidationChange} + value={this.state ? this.state.confirmedPassword : ""} + validationError={this.state.passwordConfirmationFailed} + errorMessage={t("password.passwordConfirmFailed")} + helpText={t("password.passwordConfirmHelpText")} + /> + </> + ); + } + + validatePassword = password => { + const { passwordValidator } = this.props; + if (passwordValidator) { + return passwordValidator(password); + } + + return password.length >= 6 && password.length < 32; + }; + + handlePasswordValidationChange = (confirmedPassword: string) => { + const passwordConfirmed = this.state.password === confirmedPassword; + + this.setState( + { + confirmedPassword, + passwordConfirmationFailed: !passwordConfirmed + }, + this.propagateChange + ); + }; + + handlePasswordChange = (password: string) => { + const passwordConfirmationFailed = + password !== this.state.confirmedPassword; + + this.setState( + { + passwordValid: this.validatePassword(password), + passwordConfirmationFailed, + password: password + }, + this.propagateChange + ); + }; + + isValid = () => { + return this.state.passwordValid && !this.state.passwordConfirmationFailed + }; + + propagateChange = () => { + this.props.passwordChanged(this.state.password, this.isValid()); + }; +} + +export default translate("commons")(PasswordConfirmation); diff --git a/scm-ui-components/packages/ui-components/src/forms/Radio.js b/scm-ui-components/packages/ui-components/src/forms/Radio.js new file mode 100644 index 0000000000..6460e67070 --- /dev/null +++ b/scm-ui-components/packages/ui-components/src/forms/Radio.js @@ -0,0 +1,42 @@ +//@flow +import React from "react"; +import { Help } from "../index"; + +type Props = { + label?: string, + name?: string, + value?: string, + checked: boolean, + onChange?: (value: boolean, name?: string) => void, + disabled?: boolean, + helpText?: string +}; + +class Radio extends React.Component<Props> { + renderHelp = () => { + const helpText = this.props.helpText; + if (helpText) { + return <Help message={helpText} />; + } + }; + + render() { + return ( + + <label className="radio" disabled={this.props.disabled}> + <input + type="radio" + name={this.props.name} + value={this.props.value} + checked={this.props.checked} + onChange={this.props.onChange} + disabled={this.props.disabled} + />{" "} + {this.props.label} + {this.renderHelp()} + </label> + ); + } +} + +export default Radio; diff --git a/scm-ui-components/packages/ui-components/src/forms/Select.js b/scm-ui-components/packages/ui-components/src/forms/Select.js index ccb82e62da..38e1cdab33 100644 --- a/scm-ui-components/packages/ui-components/src/forms/Select.js +++ b/scm-ui-components/packages/ui-components/src/forms/Select.js @@ -54,7 +54,7 @@ class Select extends React.Component<Props> { > {options.map(opt => { return ( - <option value={opt.value} key={opt.value}> + <option value={opt.value} key={"KEY_" + opt.value}> {opt.label} </option> ); diff --git a/scm-ui-components/packages/ui-components/src/forms/index.js b/scm-ui-components/packages/ui-components/src/forms/index.js index c96f3a8196..ef4d7a1ae4 100644 --- a/scm-ui-components/packages/ui-components/src/forms/index.js +++ b/scm-ui-components/packages/ui-components/src/forms/index.js @@ -1,9 +1,14 @@ // @create-index export { default as AddEntryToTableField } from "./AddEntryToTableField.js"; +export { default as AutocompleteAddEntryToTableField } from "./AutocompleteAddEntryToTableField.js"; +export { default as MemberNameTable } from "./MemberNameTable.js"; export { default as Checkbox } from "./Checkbox.js"; +export { default as Radio } from "./Radio.js"; export { default as InputField } from "./InputField.js"; export { default as Select } from "./Select.js"; export { default as Textarea } from "./Textarea.js"; -export { default as LabelWithHelpIcon } from "./LabelWithHelpIcon"; +export { default as PasswordConfirmation } from "./PasswordConfirmation.js"; +export { default as LabelWithHelpIcon } from "./LabelWithHelpIcon.js"; +export { default as DropDown } from "./DropDown.js"; diff --git a/scm-ui-components/packages/ui-components/src/index.js b/scm-ui-components/packages/ui-components/src/index.js index 9dc4af1aac..b403ea78b7 100644 --- a/scm-ui-components/packages/ui-components/src/index.js +++ b/scm-ui-components/packages/ui-components/src/index.js @@ -16,18 +16,24 @@ export { default as MailLink } from "./MailLink.js"; export { default as Notification } from "./Notification.js"; export { default as Paginator } from "./Paginator.js"; export { default as LinkPaginator } from "./LinkPaginator.js"; +export { default as StatePaginator } from "./StatePaginator.js"; + export { default as ProtectedRoute } from "./ProtectedRoute.js"; export { default as Help } from "./Help"; export { default as HelpIcon } from "./HelpIcon"; export { default as Tooltip } from "./Tooltip"; export { getPageFromMatch } from "./urls"; +export { default as Autocomplete} from "./Autocomplete"; +export { default as BranchSelector } from "./BranchSelector"; export { apiClient } from "./apiclient.js"; export * from "./errors"; +export * from "./avatar"; export * from "./buttons"; export * from "./config"; export * from "./forms"; export * from "./layout"; export * from "./modals"; export * from "./navigation"; +export * from "./repos"; diff --git a/scm-ui-components/packages/ui-components/src/layout/Header.js b/scm-ui-components/packages/ui-components/src/layout/Header.js index 0bb3f378a6..685bbff69d 100644 --- a/scm-ui-components/packages/ui-components/src/layout/Header.js +++ b/scm-ui-components/packages/ui-components/src/layout/Header.js @@ -1,31 +1,31 @@ -//@flow -import * as React from "react"; -import Logo from "./../Logo"; - -type Props = { - children?: React.Node -}; - -class Header extends React.Component<Props> { - render() { - const { children } = this.props; - return ( - <section className="hero is-dark is-small"> - <div className="hero-body"> - <div className="container"> - <div className="columns is-vcentered"> - <div className="column"> - <Logo /> - </div> - </div> - </div> - </div> - <div className="hero-foot"> - <div className="container">{children}</div> - </div> - </section> - ); - } -} - -export default Header; +//@flow +import * as React from "react"; +import Logo from "./../Logo"; + +type Props = { + children?: React.Node +}; + +class Header extends React.Component<Props> { + render() { + const { children } = this.props; + return ( + <section className="hero is-dark is-small"> + <div className="hero-body"> + <div className="container"> + <div className="columns is-vcentered"> + <div className="column"> + <Logo /> + </div> + </div> + </div> + </div> + <div className="hero-foot"> + <div className="container">{children}</div> + </div> + </section> + ); + } +} + +export default Header; diff --git a/scm-ui-components/packages/ui-components/src/layout/Page.js b/scm-ui-components/packages/ui-components/src/layout/Page.js index f0f2d5d971..eea9f66f72 100644 --- a/scm-ui-components/packages/ui-components/src/layout/Page.js +++ b/scm-ui-components/packages/ui-components/src/layout/Page.js @@ -4,6 +4,9 @@ import Loading from "./../Loading"; import ErrorNotification from "./../ErrorNotification"; import Title from "./Title"; import Subtitle from "./Subtitle"; +import injectSheet from "react-jss"; +import classNames from "classnames"; +import PageActions from "./PageActions"; type Props = { title?: string, @@ -11,17 +14,26 @@ type Props = { loading?: boolean, error?: Error, showContentOnError?: boolean, - children: React.Node + children: React.Node, + + // context props + classes: Object +}; + +const styles = { + spacing: { + marginTop: "1.25rem", + textAlign: "right" + } }; class Page extends React.Component<Props> { render() { - const { title, error, subtitle } = this.props; + const { error } = this.props; return ( <section className="section"> <div className="container"> - <Title title={title} /> - <Subtitle subtitle={subtitle} /> + {this.renderPageHeader()} <ErrorNotification error={error} /> {this.renderContent()} </div> @@ -29,16 +41,64 @@ class Page extends React.Component<Props> { ); } + renderPageHeader() { + const { title, subtitle, children, classes } = this.props; + + let pageActions = null; + let pageActionsExists = false; + React.Children.forEach(children, child => { + if (child && child.type.name === PageActions.name) { + pageActions = ( + <div className="column is-two-fifths"> + <div + className={classNames( + classes.spacing, + "is-mobile-create-button-spacing" + )} + > + {child} + </div> + </div> + ); + pageActionsExists = true; + } + }); + let underline = pageActionsExists ? ( + <hr className="header-with-actions" /> + ) : null; + + return ( + <> + <div className="columns"> + <div className="column"> + <Title title={title} /> + <Subtitle subtitle={subtitle} /> + </div> + {pageActions} + </div> + {underline} + </> + ); + } + renderContent() { const { loading, children, showContentOnError, error } = this.props; + if (error && !showContentOnError) { return null; } if (loading) { return <Loading />; } - return children; + + let content = []; + React.Children.forEach(children, child => { + if (child && child.type.name !== PageActions.name) { + content.push(child); + } + }); + return content; } } -export default Page; +export default injectSheet(styles)(Page); diff --git a/scm-ui-components/packages/ui-components/src/layout/PageActions.js b/scm-ui-components/packages/ui-components/src/layout/PageActions.js new file mode 100644 index 0000000000..eb055a5605 --- /dev/null +++ b/scm-ui-components/packages/ui-components/src/layout/PageActions.js @@ -0,0 +1,28 @@ +//@flow +import * as React from "react"; +import Loading from "./../Loading"; + +type Props = { + loading?: boolean, + error?: Error, + children: React.Node +}; + +class PageActions extends React.Component<Props> { + render() { + return <>{this.renderContent()}</>; + } + + renderContent() { + const { loading, children, error } = this.props; + if (error) { + return null; + } + if (loading) { + return <Loading />; + } + return children; + } +} + +export default PageActions; diff --git a/scm-ui-components/packages/ui-components/src/layout/Subtitle.js b/scm-ui-components/packages/ui-components/src/layout/Subtitle.js index 249c34023f..4558faeb30 100644 --- a/scm-ui-components/packages/ui-components/src/layout/Subtitle.js +++ b/scm-ui-components/packages/ui-components/src/layout/Subtitle.js @@ -9,7 +9,7 @@ class Subtitle extends React.Component<Props> { render() { const { subtitle } = this.props; if (subtitle) { - return <h1 className="subtitle">{subtitle}</h1>; + return <h2 className="subtitle">{subtitle}</h2>; } return null; } diff --git a/scm-ui-components/packages/ui-components/src/layout/index.js b/scm-ui-components/packages/ui-components/src/layout/index.js index c93fe3b496..7708c45c99 100644 --- a/scm-ui-components/packages/ui-components/src/layout/index.js +++ b/scm-ui-components/packages/ui-components/src/layout/index.js @@ -3,6 +3,7 @@ export { default as Footer } from "./Footer.js"; export { default as Header } from "./Header.js"; export { default as Page } from "./Page.js"; +export { default as PageActions } from "./PageActions.js"; export { default as Subtitle } from "./Subtitle.js"; export { default as Title } from "./Title.js"; diff --git a/scm-ui-components/packages/ui-components/src/modals/ConfirmAlert.css b/scm-ui-components/packages/ui-components/src/modals/ConfirmAlert.css deleted file mode 100644 index 96246a44a5..0000000000 --- a/scm-ui-components/packages/ui-components/src/modals/ConfirmAlert.css +++ /dev/null @@ -1,102 +0,0 @@ -/*modified from https://github.com/GA-MO/react-confirm-alert*/ -.react-confirm-alert-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - z-index: 99; - background: rgba(255, 255, 255, 0.9); - display: -webkit-flex; - display: -moz-flex; - display: -ms-flex; - display: -o-flex; - display: flex; - justify-content: center; - -ms-align-items: center; - align-items: center; - opacity: 0; - -webkit-animation: react-confirm-alert-fadeIn 0.5s 0.2s forwards; - -moz-animation: react-confirm-alert-fadeIn 0.5s 0.2s forwards; - -o-animation: react-confirm-alert-fadeIn 0.5s 0.2s forwards; - animation: react-confirm-alert-fadeIn 0.5s 0.2s forwards; -} - -.react-confirm-alert-body { - font-family: Arial, Helvetica, sans-serif; - width: 400px; - padding: 30px; - text-align: left; - background: #fff; - border-radius: 10px; - box-shadow: 0 20px 75px rgba(0, 0, 0, 0.13); - color: #666; -} - -.react-confirm-alert-body > h1 { - margin-top: 0; -} - -.react-confirm-alert-body > h3 { - margin: 0; - font-size: 16px; -} - -.react-confirm-alert-button-group { - display: -webkit-flex; - display: -moz-flex; - display: -ms-flex; - display: -o-flex; - display: flex; - justify-content: flex-start; - margin-top: 20px; -} - -.react-confirm-alert-button-group > button { - outline: none; - background: #333; - border: none; - display: inline-block; - padding: 6px 18px; - color: #eee; - margin-right: 10px; - border-radius: 5px; - font-size: 12px; - cursor: pointer; -} - -@-webkit-keyframes react-confirm-alert-fadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -@-moz-keyframes react-confirm-alert-fadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -@-o-keyframes react-confirm-alert-fadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -@keyframes react-confirm-alert-fadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } -} diff --git a/scm-ui-components/packages/ui-components/src/modals/ConfirmAlert.js b/scm-ui-components/packages/ui-components/src/modals/ConfirmAlert.js index a14a3560b8..6fe08c8ce8 100644 --- a/scm-ui-components/packages/ui-components/src/modals/ConfirmAlert.js +++ b/scm-ui-components/packages/ui-components/src/modals/ConfirmAlert.js @@ -1,9 +1,7 @@ // @flow -//modified from https://github.com/GA-MO/react-confirm-alert - import * as React from "react"; -import { render, unmountComponentAtNode } from "react-dom"; -import "./ConfirmAlert.css"; +import ReactDOM from "react-dom"; +import Modal from "./Modal"; type Button = { label: string, @@ -25,58 +23,47 @@ class ConfirmAlert extends React.Component<Props> { }; close = () => { - removeElementReconfirm(); + ReactDOM.unmountComponentAtNode(document.getElementById("modalRoot")); }; render() { const { title, message, buttons } = this.props; - return ( - <div className="react-confirm-alert-overlay"> - <div className="react-confirm-alert"> - { - <div className="react-confirm-alert-body"> - {title && <h1>{title}</h1>} - {message} - <div className="react-confirm-alert-button-group"> - {buttons.map((button, i) => ( - <button - key={i} - onClick={() => this.handleClickButton(button)} - > - {button.label} - </button> - ))} - </div> - </div> - } - </div> + const body = <>{message}</>; + + const footer = ( + <div className="field is-grouped"> + {buttons.map((button, i) => ( + <p className="control"> + <a + className="button is-info" + key={i} + onClick={() => this.handleClickButton(button)} + > + {button.label} + </a> + </p> + ))} </div> ); + + return ( + <Modal + title={title} + closeFunction={() => this.close()} + body={body} + active={true} + footer={footer} + /> + ); } } -function createElementReconfirm(properties: Props) { - const divTarget = document.createElement("div"); - divTarget.id = "react-confirm-alert"; - if (document.body) { - document.body.appendChild(divTarget); - render(<ConfirmAlert {...properties} />, divTarget); - } -} - -function removeElementReconfirm() { - const target = document.getElementById("react-confirm-alert"); - if (target) { - unmountComponentAtNode(target); - if (target.parentNode) { - target.parentNode.removeChild(target); - } - } -} - export function confirmAlert(properties: Props) { - createElementReconfirm(properties); + const root = document.getElementById("modalRoot"); + if (root) { + ReactDOM.render(<ConfirmAlert {...properties} />, root); + } } export default ConfirmAlert; diff --git a/scm-ui-components/packages/ui-components/src/modals/Modal.js b/scm-ui-components/packages/ui-components/src/modals/Modal.js new file mode 100644 index 0000000000..2811364ec1 --- /dev/null +++ b/scm-ui-components/packages/ui-components/src/modals/Modal.js @@ -0,0 +1,46 @@ +// @flow +import * as React from "react"; +import classNames from "classnames"; + +type Props = { + title: string, + closeFunction: () => void, + body: any, + footer?: any, + active: boolean, +}; + + + +class Modal extends React.Component<Props> { + render() { + const { title, closeFunction, body, footer, active } = this.props; + + const isActive = active ? "is-active" : null; + + let showFooter = null; + if (footer) { + showFooter = <footer className="modal-card-foot">{footer}</footer>; + } + + return ( + <div className={classNames("modal", isActive)}> + <div className="modal-background" /> + <div className="modal-card"> + <header className="modal-card-head"> + <p className="modal-card-title">{title}</p> + <button + className="delete" + aria-label="close" + onClick={closeFunction} + /> + </header> + <section className="modal-card-body">{body}</section> + {showFooter} + </div> + </div> + ); + } +} + +export default Modal; diff --git a/scm-ui-components/packages/ui-components/src/modals/index.js b/scm-ui-components/packages/ui-components/src/modals/index.js index e75d082c1c..13b205dd1c 100644 --- a/scm-ui-components/packages/ui-components/src/modals/index.js +++ b/scm-ui-components/packages/ui-components/src/modals/index.js @@ -1,4 +1,5 @@ // @create-index export { default as ConfirmAlert, confirmAlert } from "./ConfirmAlert.js"; +export { default as Modal } from "./Modal.js"; diff --git a/scm-ui-components/packages/ui-components/src/navigation/NavAction.js b/scm-ui-components/packages/ui-components/src/navigation/NavAction.js index 5eacbb7407..e93fd8dd52 100644 --- a/scm-ui-components/packages/ui-components/src/navigation/NavAction.js +++ b/scm-ui-components/packages/ui-components/src/navigation/NavAction.js @@ -2,16 +2,23 @@ import React from "react"; type Props = { + icon?: string, label: string, action: () => void }; class NavAction extends React.Component<Props> { render() { - const { label, action } = this.props; + const { label, icon, action } = this.props; + + let showIcon = null; + if (icon) { + showIcon = (<><i className={icon}></i>{" "}</>); + } + return ( <li> - <a onClick={action}>{label}</a> + <a onClick={action} href="javascript:void(0);">{showIcon}{label}</a> </li> ); } diff --git a/scm-ui-components/packages/ui-components/src/navigation/NavLink.js b/scm-ui-components/packages/ui-components/src/navigation/NavLink.js index 9a7c72adb1..98c3138a8f 100644 --- a/scm-ui-components/packages/ui-components/src/navigation/NavLink.js +++ b/scm-ui-components/packages/ui-components/src/navigation/NavLink.js @@ -6,6 +6,7 @@ import {Link, Route} from "react-router-dom"; type Props = { to: string, + icon?: string, label: string, activeOnlyWhenExact?: boolean, activeWhenMatch?: (route: any) => boolean @@ -23,10 +24,17 @@ class NavLink extends React.Component<Props> { } renderLink = (route: any) => { - const { to, label } = this.props; + const { to, icon, label } = this.props; + + let showIcon = null; + if (icon) { + showIcon = (<><i className={icon} />{" "}</>); + } + return ( <li> <Link className={this.isActive(route) ? "is-active" : ""} to={to}> + {showIcon} {label} </Link> </li> @@ -35,6 +43,7 @@ class NavLink extends React.Component<Props> { render() { const { to, activeOnlyWhenExact } = this.props; + return ( <Route path={to} exact={activeOnlyWhenExact} children={this.renderLink} /> ); diff --git a/scm-ui-components/packages/ui-components/src/navigation/PrimaryNavigation.js b/scm-ui-components/packages/ui-components/src/navigation/PrimaryNavigation.js index 06ff997e2d..897c63138e 100644 --- a/scm-ui-components/packages/ui-components/src/navigation/PrimaryNavigation.js +++ b/scm-ui-components/packages/ui-components/src/navigation/PrimaryNavigation.js @@ -2,60 +2,92 @@ import React from "react"; import { translate } from "react-i18next"; import PrimaryNavigationLink from "./PrimaryNavigationLink"; +import type { Links } from "@scm-manager/ui-types"; +import { binder, ExtensionPoint } from "@scm-manager/ui-extensions"; type Props = { t: string => string, - repositoriesLink: string, - usersLink: string, - groupsLink: string, - configLink: string, - logoutLink: string + links: Links, }; class PrimaryNavigation extends React.Component<Props> { - render() { - const { t, repositoriesLink, usersLink, groupsLink, configLink, logoutLink } = this.props; - const links = [ - repositoriesLink ? ( - <PrimaryNavigationLink - to="/repos" - match="/(repo|repos)" - label={t("primary-navigation.repositories")} - key={"repositoriesLink"} - />): null, - usersLink ? ( - <PrimaryNavigationLink - to="/users" - match="/(user|users)" - label={t("primary-navigation.users")} - key={"usersLink"} - />) : null, - groupsLink ? ( - <PrimaryNavigationLink - to="/groups" - match="/(group|groups)" - label={t("primary-navigation.groups")} - key={"groupsLink"} - />) : null, - configLink ? ( - <PrimaryNavigationLink - to="/config" - label={t("primary-navigation.config")} - key={"configLink"} - />) : null, - logoutLink ? ( - <PrimaryNavigationLink - to="/logout" - label={t("primary-navigation.logout")} - key={"logoutLink"} - />) : null - ]; + createNavigationAppender = (navigationItems) => { + const { t, links } = this.props; + + return (to: string, match: string, label: string, linkName: string) => { + const link = links[linkName]; + if (link) { + const navigationItem = ( + <PrimaryNavigationLink + to={to} + match={match} + label={t(label)} + key={linkName} + />) + ; + navigationItems.push(navigationItem); + } + }; + }; + + appendLogout = (navigationItems, append) => { + const { t, links } = this.props; + + const props = { + links, + label: t("primary-navigation.logout") + }; + + if (binder.hasExtension("primary-navigation.logout", props)) { + navigationItems.push( + <ExtensionPoint name="primary-navigation.logout" props={props} /> + ); + } else { + append("/logout", "/logout", "primary-navigation.logout", "logout"); + } + }; + + createNavigationItems = () => { + const navigationItems = []; + const { t, links } = this.props; + + const props = { + links, + label: t("primary-navigation.first-menu") + }; + + const append = this.createNavigationAppender(navigationItems); + if (binder.hasExtension("primary-navigation.first-menu", props)) { + navigationItems.push( + <ExtensionPoint name="primary-navigation.first-menu" props={props} /> + ); + } + append("/repos", "/(repo|repos)", "primary-navigation.repositories", "repositories"); + append("/users", "/(user|users)", "primary-navigation.users", "users"); + append("/groups", "/(group|groups)", "primary-navigation.groups", "groups"); + append("/config", "/config", "primary-navigation.config", "config"); + + navigationItems.push( + <ExtensionPoint + name="primary-navigation" + renderAll={true} + props={{links: this.props.links}} + /> + ); + + this.appendLogout(navigationItems, append); + + return navigationItems; + }; + + render() { + const navigationItems = this.createNavigationItems(); return ( <nav className="tabs is-boxed"> <ul> - {links} + {navigationItems} </ul> </nav> ); diff --git a/scm-ui-components/packages/ui-components/src/navigation/SubNavigation.js b/scm-ui-components/packages/ui-components/src/navigation/SubNavigation.js new file mode 100644 index 0000000000..0a6612a173 --- /dev/null +++ b/scm-ui-components/packages/ui-components/src/navigation/SubNavigation.js @@ -0,0 +1,65 @@ +//@flow +import * as React from "react"; +import { Link, Route } from "react-router-dom"; + +type Props = { + to: string, + icon?: string, + label: string, + activeOnlyWhenExact?: boolean, + activeWhenMatch?: (route: any) => boolean, + children?: React.Node +}; + +class SubNavigation extends React.Component<Props> { + static defaultProps = { + activeOnlyWhenExact: false + }; + + isActive(route: any) { + const { activeWhenMatch } = this.props; + return route.match || (activeWhenMatch && activeWhenMatch(route)); + } + + renderLink = (route: any) => { + const { to, icon, label } = this.props; + + let defaultIcon = "fas fa-cog"; + if (icon) { + defaultIcon = icon; + } + + let children = null; + if (this.isActive(route)) { + children = <ul className="sub-menu">{this.props.children}</ul>; + } + + return ( + <li> + <Link className={this.isActive(route) ? "is-active" : ""} to={to}> + <i className={defaultIcon} /> {label} + </Link> + {children} + </li> + ); + }; + + render() { + const { to, activeOnlyWhenExact } = this.props; + + // removes last part of url + let parents = to.split("/"); + parents.splice(-1, 1); + let parent = parents.join("/"); + + return ( + <Route + path={parent} + exact={activeOnlyWhenExact} + children={this.renderLink} + /> + ); + } +} + +export default SubNavigation; diff --git a/scm-ui-components/packages/ui-components/src/navigation/index.js b/scm-ui-components/packages/ui-components/src/navigation/index.js index ca82073b56..b696f98328 100644 --- a/scm-ui-components/packages/ui-components/src/navigation/index.js +++ b/scm-ui-components/packages/ui-components/src/navigation/index.js @@ -3,6 +3,7 @@ export { default as NavAction } from "./NavAction.js"; export { default as NavLink } from "./NavLink.js"; export { default as Navigation } from "./Navigation.js"; +export { default as SubNavigation } from "./SubNavigation.js"; export { default as PrimaryNavigation } from "./PrimaryNavigation.js"; export { default as PrimaryNavigationLink } from "./PrimaryNavigationLink.js"; export { default as Section } from "./Section.js"; diff --git a/scm-ui-components/packages/ui-components/src/repos/Diff.js b/scm-ui-components/packages/ui-components/src/repos/Diff.js new file mode 100644 index 0000000000..0b0b31a3d4 --- /dev/null +++ b/scm-ui-components/packages/ui-components/src/repos/Diff.js @@ -0,0 +1,36 @@ +//@flow +import React from "react"; +import { Diff2Html } from "diff2html"; + +type Props = { + diff: string, + sideBySide: boolean +}; + +class Diff extends React.Component<Props> { + + static defaultProps = { + sideBySide: false + }; + + render() { + const { diff, sideBySide } = this.props; + + const options = { + inputFormat: "diff", + outputFormat: sideBySide ? "side-by-side" : "line-by-line", + showFiles: false, + matching: "lines" + }; + + const outputHtml = Diff2Html.getPrettyHtml(diff, options); + + return ( + // eslint-disable-next-line react/no-danger + <div dangerouslySetInnerHTML={{ __html: outputHtml }} /> + ); + } + +} + +export default Diff; diff --git a/scm-ui-components/packages/ui-components/src/repos/LoadingDiff.js b/scm-ui-components/packages/ui-components/src/repos/LoadingDiff.js new file mode 100644 index 0000000000..2ebc4a170b --- /dev/null +++ b/scm-ui-components/packages/ui-components/src/repos/LoadingDiff.js @@ -0,0 +1,77 @@ +//@flow +import React from "react"; +import { apiClient } from "../apiclient"; +import ErrorNotification from "../ErrorNotification"; +import Loading from "../Loading"; +import Diff from "./Diff"; + +type Props = { + url: string, + sideBySide: boolean +}; + +type State = { + diff?: string, + loading: boolean, + error?: Error +}; + +class LoadingDiff extends React.Component<Props, State> { + + static defaultProps = { + sideBySide: false + }; + + constructor(props: Props) { + super(props); + this.state = { + loading: true + }; + } + + componentDidMount() { + this.fetchDiff(); + } + + componentDidUpdate(prevProps: Props) { + if(prevProps.url !== this.props.url){ + this.fetchDiff(); + } + } + + fetchDiff = () => { + const { url } = this.props; + apiClient + .get(url) + .then(response => response.text()) + .then(text => { + this.setState({ + loading: false, + diff: text + }); + }) + .catch(error => { + this.setState({ + loading: false, + error + }); + }); + }; + + render() { + const { diff, loading, error } = this.state; + if (error) { + return <ErrorNotification error={error} />; + } else if (loading) { + return <Loading />; + } else if(!diff){ + return null; + } + else { + return <Diff diff={diff} />; + } + } + +} + +export default LoadingDiff; diff --git a/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetAuthor.js b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetAuthor.js new file mode 100644 index 0000000000..4035cbd899 --- /dev/null +++ b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetAuthor.js @@ -0,0 +1,52 @@ +//@flow +import React from "react"; +import type { Changeset } from "@scm-manager/ui-types"; +import { ExtensionPoint } from "@scm-manager/ui-extensions"; +import {translate} from "react-i18next"; + +type Props = { + changeset: Changeset, + + // context props + t: (string) => string +}; + +class ChangesetAuthor extends React.Component<Props> { + render() { + const { changeset } = this.props; + if (!changeset.author) { + return null; + } + + const { name, mail } = changeset.author; + if (mail) { + return this.withExtensionPoint(this.renderWithMail(name, mail)); + } + return this.withExtensionPoint(<>{name}</>); + } + + renderWithMail(name: string, mail: string) { + const { t } = this.props; + return ( + <a href={"mailto: " + mail} title={t("changeset.author.mailto") + " " + mail}> + {name} + </a> + ); + } + + withExtensionPoint(child: any) { + const { t } = this.props; + return ( + <> + {t("changeset.author.prefix")} {child} + <ExtensionPoint + name="changesets.author.suffix" + props={{ changeset: this.props.changeset }} + renderAll={true} + /> + </> + ); + } +} + +export default translate("repos")(ChangesetAuthor); diff --git a/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetButtonGroup.js b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetButtonGroup.js new file mode 100644 index 0000000000..de05efb46e --- /dev/null +++ b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetButtonGroup.js @@ -0,0 +1,49 @@ +//@flow +import React from "react"; +import type { Changeset, Repository } from "@scm-manager/ui-types"; +import ButtonGroup from "../../buttons/ButtonGroup"; +import Button from "../../buttons/Button"; +import { createChangesetLink, createSourcesLink } from "./changesets"; +import { translate } from "react-i18next"; + +type Props = { + repository: Repository, + changeset: Changeset, + + // context props + t: (string) => string +} + +class ChangesetButtonGroup extends React.Component<Props> { + + render() { + const { repository, changeset, t } = this.props; + + const changesetLink = createChangesetLink(repository, changeset); + const sourcesLink = createSourcesLink(repository, changeset); + + return ( + <ButtonGroup className="is-pulled-right"> + <Button link={changesetLink}> + <span className="icon"> + <i className="fas fa-code-branch"></i> + </span> + <span className="is-hidden-mobile is-hidden-tablet-only"> + {t("changeset.buttons.details")} + </span> + </Button> + <Button link={sourcesLink}> + <span className="icon"> + <i className="fas fa-code"></i> + </span> + <span className="is-hidden-mobile is-hidden-tablet-only"> + {t("changeset.buttons.sources")} + </span> + </Button> + </ButtonGroup> + ); + } + +} + +export default translate("repos")(ChangesetButtonGroup); diff --git a/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetDiff.js b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetDiff.js new file mode 100644 index 0000000000..d5d3c4e665 --- /dev/null +++ b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetDiff.js @@ -0,0 +1,37 @@ +//@flow +import React from "react"; +import type { Changeset } from "@scm-manager/ui-types"; +import LoadingDiff from "../LoadingDiff"; +import Notification from "../../Notification"; +import {translate} from "react-i18next"; + +type Props = { + changeset: Changeset, + + // context props + t: string => string +}; + +class ChangesetDiff extends React.Component<Props> { + + isDiffSupported(changeset: Changeset) { + return !!changeset._links.diff; + } + + createUrl(changeset: Changeset) { + return changeset._links.diff.href + "?format=GIT"; + } + + render() { + const { changeset, t } = this.props; + if (!this.isDiffSupported(changeset)) { + return <Notification type="danger">{t("changeset.diffNotSupported")}</Notification>; + } else { + const url = this.createUrl(changeset); + return <LoadingDiff url={url} />; + } + } + +} + +export default translate("repos")(ChangesetDiff); diff --git a/scm-ui/src/repos/components/changesets/ChangesetId.js b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetId.js similarity index 80% rename from scm-ui/src/repos/components/changesets/ChangesetId.js rename to scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetId.js index aec1029427..a3cc3c73b7 100644 --- a/scm-ui/src/repos/components/changesets/ChangesetId.js +++ b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetId.js @@ -3,6 +3,7 @@ import {Link} from "react-router-dom"; import React from "react"; import type {Changeset, Repository} from "@scm-manager/ui-types"; +import { createChangesetLink } from "./changesets"; type Props = { repository: Repository, @@ -20,13 +21,11 @@ export default class ChangesetId extends React.Component<Props> { }; renderLink = () => { - const { changeset, repository } = this.props; + const { repository, changeset } = this.props; + const link = createChangesetLink(repository, changeset); + return ( - <Link - to={`/repo/${repository.namespace}/${repository.name}/changeset/${ - changeset.id - }`} - > + <Link to={link}> {this.shortId(changeset)} </Link> ); diff --git a/scm-ui/src/repos/components/changesets/ChangesetList.js b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetList.js similarity index 85% rename from scm-ui/src/repos/components/changesets/ChangesetList.js rename to scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetList.js index 860b7ba8b0..4cd8ec319c 100644 --- a/scm-ui/src/repos/components/changesets/ChangesetList.js +++ b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetList.js @@ -1,8 +1,8 @@ // @flow import ChangesetRow from "./ChangesetRow"; import React from "react"; + import type { Changeset, Repository } from "@scm-manager/ui-types"; -import classNames from "classnames"; type Props = { repository: Repository, @@ -21,7 +21,7 @@ class ChangesetList extends React.Component<Props> { /> ); }); - return <div className={classNames("box")}>{content}</div>; + return <>{content}</>; } } diff --git a/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetRow.js b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetRow.js new file mode 100644 index 0000000000..90a568f615 --- /dev/null +++ b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetRow.js @@ -0,0 +1,121 @@ +//@flow +import React from "react"; +import type { Changeset, Repository, Tag } from "@scm-manager/ui-types"; + +import classNames from "classnames"; +import { Interpolate, translate } from "react-i18next"; +import ChangesetId from "./ChangesetId"; +import injectSheet from "react-jss"; +import { DateFromNow } from "../.."; +import ChangesetAuthor from "./ChangesetAuthor"; +import { parseDescription } from "./changesets"; +import { AvatarWrapper, AvatarImage } from "../../avatar"; +import { ExtensionPoint } from "@scm-manager/ui-extensions"; +import ChangesetTags from "./ChangesetTags"; +import ChangesetButtonGroup from "./ChangesetButtonGroup"; + +const styles = { + changeset: { + // & references parent rule + // have a look at https://cssinjs.org/jss-plugin-nested?v=v10.0.0-alpha.9 + "& + &": { + borderTop: "1px solid rgba(219, 219, 219, 0.5)", + marginTop: "1rem", + paddingTop: "1rem" + } + }, + avatarFigure: { + marginTop: ".25rem", + marginRight: ".5rem", + }, + avatarImage: { + height: "35px", + width: "35px" + }, + isVcentered: { + marginTop: "auto", + marginBottom: "auto" + }, + metadata: { + marginLeft: 0 + }, + tag: { + marginTop: ".5rem" + } +}; + +type Props = { + repository: Repository, + changeset: Changeset, + t: any, + classes: any +}; + +class ChangesetRow extends React.Component<Props> { + createChangesetId = (changeset: Changeset) => { + const { repository } = this.props; + return <ChangesetId changeset={changeset} repository={repository} />; + }; + + render() { + const { repository, changeset, classes } = this.props; + const description = parseDescription(changeset.description); + const changesetId = this.createChangesetId(changeset); + const dateFromNow = <DateFromNow date={changeset.date} />; + + return ( + <div className={classes.changeset}> + <div className="columns"> + <div className="column is-three-fifths"> + + <h4 className="has-text-weight-bold is-ellipsis-overflow"> + <ExtensionPoint + name="changeset.description" + props={{ changeset, value: description.title }} + renderAll={false} + > + {description.title} + </ExtensionPoint> + </h4> + + <div className="media"> + <AvatarWrapper> + <figure className={classNames(classes.avatarFigure, "media-left")}> + <div className={classNames("image", classes.avatarImage)}> + <AvatarImage person={changeset.author} /> + </div> + </figure> + </AvatarWrapper> + <div className={classNames(classes.metadata, "media-right")}> + <p className="is-hidden-mobile is-hidden-tablet-only"> + <Interpolate + i18nKey="changeset.summary" + id={changesetId} + time={dateFromNow} + /> + </p> + <p className="is-hidden-desktop"> + <Interpolate + i18nKey="changeset.shortSummary" + id={changesetId} + time={dateFromNow} + /> + </p> + <p className="is-size-7"> + <ChangesetAuthor changeset={changeset} /> + </p> + </div> + </div> + + </div> + <div className={classNames("column", classes.isVcentered)}> + <ChangesetTags changeset={changeset} /> + <ChangesetButtonGroup repository={repository} changeset={changeset} /> + </div> + </div> + </div> + ); + } +} + +export default injectSheet(styles)(translate("repos")(ChangesetRow)); diff --git a/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetTag.js b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetTag.js new file mode 100644 index 0000000000..03c73a8e9f --- /dev/null +++ b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetTag.js @@ -0,0 +1,17 @@ +//@flow +import React from "react"; +import type { Tag } from "@scm-manager/ui-types"; +import ChangesetTagBase from "./ChangesetTagBase"; + +type Props = { + tag: Tag +}; + +class ChangesetTag extends React.Component<Props> { + render() { + const { tag } = this.props; + return <ChangesetTagBase icon={"fa-tag"} label={tag.name} />; + } +} + +export default ChangesetTag; diff --git a/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetTagBase.js b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetTagBase.js new file mode 100644 index 0000000000..7fc8dabcd2 --- /dev/null +++ b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetTagBase.js @@ -0,0 +1,34 @@ +//@flow +import React from "react"; +import injectSheet from "react-jss"; +import classNames from "classnames"; + +const styles = { + tag: { + marginTop: ".5rem" + }, + spacing: { + marginRight: ".25rem" + } +}; + +type Props = { + icon: string, + label: string, + + // context props + classes: Object +}; + +class ChangesetTagBase extends React.Component<Props> { + render() { + const { icon, label, classes } = this.props; + return ( + <span className={classNames(classes.tag, "tag", "is-info")}> + <span className={classNames("fa", icon, classes.spacing)} /> {label} + </span> + ); + } +} + +export default injectSheet(styles)(ChangesetTagBase); diff --git a/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetTags.js b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetTags.js new file mode 100644 index 0000000000..b8bff8ddd2 --- /dev/null +++ b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetTags.js @@ -0,0 +1,31 @@ +//@flow +import React from "react"; +import type { Changeset} from "@scm-manager/ui-types"; +import ChangesetTag from "./ChangesetTag"; +import ChangesetTagsCollapsed from "./ChangesetTagsCollapsed"; + +type Props = { + changeset: Changeset +}; + +class ChangesetTags extends React.Component<Props> { + + getTags = () => { + const { changeset } = this.props; + return changeset._embedded.tags || []; + }; + + render() { + const tags = this.getTags(); + + if (tags.length === 1) { + return <ChangesetTag tag={tags[0]} />; + } else if (tags.length > 1) { + return <ChangesetTagsCollapsed tags={tags} />; + } else { + return null; + } + } +} + +export default ChangesetTags; diff --git a/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetTagsCollapsed.js b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetTagsCollapsed.js new file mode 100644 index 0000000000..50b09d4d65 --- /dev/null +++ b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetTagsCollapsed.js @@ -0,0 +1,27 @@ +//@flow +import React from "react"; +import type { Tag } from "@scm-manager/ui-types"; +import ChangesetTagBase from "./ChangesetTagBase"; +import { translate } from "react-i18next"; +import Tooltip from "../../Tooltip"; + +type Props = { + tags: Tag[], + + // context props + t: (string) => string +}; + +class ChangesetTagsCollapsed extends React.Component<Props> { + render() { + const { tags, t } = this.props; + const message = tags.map((tag) => tag.name).join(", "); + return ( + <Tooltip location="top" message={message}> + <ChangesetTagBase icon={"fa-tags"} label={ tags.length + " " + t("changeset.tags") } /> + </Tooltip> + ); + } +} + +export default translate("repos")(ChangesetTagsCollapsed); diff --git a/scm-ui/src/repos/components/changesets/changesets.js b/scm-ui-components/packages/ui-components/src/repos/changesets/changesets.js similarity index 52% rename from scm-ui/src/repos/components/changesets/changesets.js rename to scm-ui-components/packages/ui-components/src/repos/changesets/changesets.js index f61a89c74b..69227ad75d 100644 --- a/scm-ui/src/repos/components/changesets/changesets.js +++ b/scm-ui-components/packages/ui-components/src/repos/changesets/changesets.js @@ -1,9 +1,19 @@ // @flow +import type { Changeset, Repository } from "@scm-manager/ui-types"; + export type Description = { title: string, message: string }; +export function createChangesetLink(repository: Repository, changeset: Changeset) { + return `/repo/${repository.namespace}/${repository.name}/changeset/${changeset.id}`; +} + +export function createSourcesLink(repository: Repository, changeset: Changeset) { + return `/repo/${repository.namespace}/${repository.name}/sources/${changeset.id}`; +} + export function parseDescription(description?: string): Description { const desc = description ? description : ""; const lineBreak = desc.indexOf("\n"); diff --git a/scm-ui/src/repos/components/changesets/changesets.test.js b/scm-ui-components/packages/ui-components/src/repos/changesets/changesets.test.js similarity index 100% rename from scm-ui/src/repos/components/changesets/changesets.test.js rename to scm-ui-components/packages/ui-components/src/repos/changesets/changesets.test.js diff --git a/scm-ui-components/packages/ui-components/src/repos/changesets/index.js b/scm-ui-components/packages/ui-components/src/repos/changesets/index.js new file mode 100644 index 0000000000..7a07ad8f42 --- /dev/null +++ b/scm-ui-components/packages/ui-components/src/repos/changesets/index.js @@ -0,0 +1,13 @@ +// @flow +import * as changesets from "./changesets"; +export { changesets }; + +export { default as ChangesetAuthor } from "./ChangesetAuthor"; +export { default as ChangesetButtonGroup } from "./ChangesetButtonGroup"; +export { default as ChangesetDiff } from "./ChangesetDiff"; +export { default as ChangesetId } from "./ChangesetId"; +export { default as ChangesetList } from "./ChangesetList"; +export { default as ChangesetRow } from "./ChangesetRow"; +export { default as ChangesetTag } from "./ChangesetTag"; +export { default as ChangesetTags } from "./ChangesetTags"; +export { default as ChangesetTagsCollapsed } from "./ChangesetTagsCollapsed"; diff --git a/scm-ui-components/packages/ui-components/src/repos/index.js b/scm-ui-components/packages/ui-components/src/repos/index.js new file mode 100644 index 0000000000..9ebd1e9c55 --- /dev/null +++ b/scm-ui-components/packages/ui-components/src/repos/index.js @@ -0,0 +1,5 @@ +// @flow + +export * from "./changesets"; +export { default as Diff } from "./Diff"; +export { default as LoadingDiff } from "./LoadingDiff"; diff --git a/scm-ui-components/packages/ui-components/src/validation.js b/scm-ui-components/packages/ui-components/src/validation.js index 561b0d79bc..fcfffcee45 100644 --- a/scm-ui-components/packages/ui-components/src/validation.js +++ b/scm-ui-components/packages/ui-components/src/validation.js @@ -5,7 +5,7 @@ export const isNameValid = (name: string) => { return nameRegex.test(name); }; -const mailRegex = /^[A-z0-9][\w.-]*@[A-z0-9][\w\-.]*\.[A-z0-9][A-z0-9-]+$/; +const mailRegex = /^[ -~]+@[A-Za-z0-9][\w\-.]*\.[A-Za-z0-9][A-Za-z0-9-]+$/; export const isMailValid = (mail: string) => { return mailRegex.test(mail); diff --git a/scm-ui-components/packages/ui-components/src/validation.test.js b/scm-ui-components/packages/ui-components/src/validation.test.js index 8394c61854..d50996ff2b 100644 --- a/scm-ui-components/packages/ui-components/src/validation.test.js +++ b/scm-ui-components/packages/ui-components/src/validation.test.js @@ -59,9 +59,8 @@ describe("test mail validation", () => { "@ostfalia.de", "s.sdorra@", "s.sdorra@ostfalia", - "s.sdorra@@ostfalia.de", "s.sdorra@ ostfalia.de", - "s.sdorra @ostfalia.de" + "s.sdorra@[ostfalia.de" ]; for (let mail of invalid) { expect(validator.isMailValid(mail)).toBe(false); @@ -78,7 +77,9 @@ describe("test mail validation", () => { "s.sdorra@t.co", "s.sdorra@ucla.college", "s.sdorra@example.xn--p1ai", - "s.sdorra@scm.solutions" + "s.sdorra@scm.solutions", + "s'sdorra@scm.solutions", + "\"S Sdorra\"@scm.solutions" ]; for (let mail of valid) { expect(validator.isMailValid(mail)).toBe(true); diff --git a/scm-ui-components/packages/ui-components/yarn.lock b/scm-ui-components/packages/ui-components/yarn.lock index 94816787ec..2fd20f2870 100644 --- a/scm-ui-components/packages/ui-components/yarn.lock +++ b/scm-ui-components/packages/ui-components/yarn.lock @@ -576,6 +576,12 @@ "@babel/plugin-transform-react-jsx-self" "^7.0.0" "@babel/plugin-transform-react-jsx-source" "^7.0.0" +"@babel/runtime@^7.1.2": + version "7.1.5" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.1.5.tgz#4170907641cf1f61508f563ece3725150cc6fe39" + dependencies: + regenerator-runtime "^0.12.0" + "@babel/template@^7.1.0", "@babel/template@^7.1.2": version "7.1.2" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.1.2.tgz#090484a574fef5a2d2d7726a674eceda5c5b5644" @@ -606,6 +612,46 @@ lodash "^4.17.10" to-fast-properties "^2.0.0" +"@emotion/babel-utils@^0.6.4": + version "0.6.10" + resolved "https://registry.yarnpkg.com/@emotion/babel-utils/-/babel-utils-0.6.10.tgz#83dbf3dfa933fae9fc566e54fbb45f14674c6ccc" + dependencies: + "@emotion/hash" "^0.6.6" + "@emotion/memoize" "^0.6.6" + "@emotion/serialize" "^0.9.1" + convert-source-map "^1.5.1" + find-root "^1.1.0" + source-map "^0.7.2" + +"@emotion/hash@^0.6.2", "@emotion/hash@^0.6.6": + version "0.6.6" + resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.6.6.tgz#62266c5f0eac6941fece302abad69f2ee7e25e44" + +"@emotion/memoize@^0.6.1", "@emotion/memoize@^0.6.6": + version "0.6.6" + resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.6.6.tgz#004b98298d04c7ca3b4f50ca2035d4f60d2eed1b" + +"@emotion/serialize@^0.9.1": + version "0.9.1" + resolved "https://registry.yarnpkg.com/@emotion/serialize/-/serialize-0.9.1.tgz#a494982a6920730dba6303eb018220a2b629c145" + dependencies: + "@emotion/hash" "^0.6.6" + "@emotion/memoize" "^0.6.6" + "@emotion/unitless" "^0.6.7" + "@emotion/utils" "^0.8.2" + +"@emotion/stylis@^0.7.0": + version "0.7.1" + resolved "https://registry.yarnpkg.com/@emotion/stylis/-/stylis-0.7.1.tgz#50f63225e712d99e2b2b39c19c70fff023793ca5" + +"@emotion/unitless@^0.6.2", "@emotion/unitless@^0.6.7": + version "0.6.7" + resolved "https://registry.yarnpkg.com/@emotion/unitless/-/unitless-0.6.7.tgz#53e9f1892f725b194d5e6a1684a7b394df592397" + +"@emotion/utils@^0.8.2": + version "0.8.2" + resolved "https://registry.yarnpkg.com/@emotion/utils/-/utils-0.8.2.tgz#576ff7fb1230185b619a75d258cbc98f0867a8dc" + "@gulp-sourcemaps/identity-map@1.X": version "1.0.2" resolved "https://registry.yarnpkg.com/@gulp-sourcemaps/identity-map/-/identity-map-1.0.2.tgz#1e6fe5d8027b1f285dc0d31762f566bccd73d5a9" @@ -641,9 +687,9 @@ version "0.0.2" resolved "https://registry.yarnpkg.com/@scm-manager/eslint-config/-/eslint-config-0.0.2.tgz#94cc8c3fb4f51f870b235893dc134fc6c423ae85" -"@scm-manager/ui-bundler@^0.0.21": - version "0.0.21" - resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.21.tgz#f8b5fa355415cc67b8aaf8744e1701a299dff647" +"@scm-manager/ui-bundler@^0.0.24": + version "0.0.24" + resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.24.tgz#034d5500c79b438c48d8f7ee985be07c4ea46d1e" dependencies: "@babel/core" "^7.0.0" "@babel/plugin-proposal-class-properties" "^7.0.0" @@ -681,9 +727,9 @@ vinyl-source-stream "^2.0.0" watchify "^3.11.0" -"@scm-manager/ui-extensions@^0.1.1": - version "0.1.1" - resolved "https://registry.yarnpkg.com/@scm-manager/ui-extensions/-/ui-extensions-0.1.1.tgz#966e62d89981e92a14adf7e674e646e76de96d45" +"@scm-manager/ui-extensions@^0.1.2": + version "0.1.2" + resolved "https://registry.yarnpkg.com/@scm-manager/ui-extensions/-/ui-extensions-0.1.2.tgz#0689427ca45c8e4e045b5b9dbc89036f1d2c45fc" dependencies: react "^16.4.2" react-dom "^16.4.2" @@ -1121,6 +1167,23 @@ babel-messages@^6.23.0: dependencies: babel-runtime "^6.22.0" +babel-plugin-emotion@^9.2.11: + version "9.2.11" + resolved "https://registry.yarnpkg.com/babel-plugin-emotion/-/babel-plugin-emotion-9.2.11.tgz#319c005a9ee1d15bb447f59fe504c35fd5807728" + dependencies: + "@babel/helper-module-imports" "^7.0.0" + "@emotion/babel-utils" "^0.6.4" + "@emotion/hash" "^0.6.2" + "@emotion/memoize" "^0.6.1" + "@emotion/stylis" "^0.7.0" + babel-plugin-macros "^2.0.0" + babel-plugin-syntax-jsx "^6.18.0" + convert-source-map "^1.5.0" + find-root "^1.1.0" + mkdirp "^0.5.1" + source-map "^0.5.7" + touch "^2.0.1" + babel-plugin-istanbul@^4.1.6: version "4.1.6" resolved "http://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-4.1.6.tgz#36c59b2192efce81c5b378321b74175add1c9a45" @@ -1134,6 +1197,17 @@ babel-plugin-jest-hoist@^23.2.0: version "23.2.0" resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-23.2.0.tgz#e61fae05a1ca8801aadee57a6d66b8cefaf44167" +babel-plugin-macros@^2.0.0: + version "2.4.2" + resolved "https://registry.yarnpkg.com/babel-plugin-macros/-/babel-plugin-macros-2.4.2.tgz#21b1a2e82e2130403c5ff785cba6548e9b644b28" + dependencies: + cosmiconfig "^5.0.5" + resolve "^1.8.1" + +babel-plugin-syntax-jsx@^6.18.0: + version "6.18.0" + resolved "https://registry.yarnpkg.com/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz#0af32a9a6e13ca7a3fd5069e62d7b0f58d0d8946" + babel-plugin-syntax-object-rest-spread@^6.13.0: version "6.13.0" resolved "http://registry.npmjs.org/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.13.0.tgz#fd6536f2bce13836ffa3a5458c4903a597bb3bf5" @@ -1633,12 +1707,24 @@ cached-path-relative@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/cached-path-relative/-/cached-path-relative-1.0.1.tgz#d09c4b52800aa4c078e2dd81a869aac90d2e54e7" +caller-callsite@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/caller-callsite/-/caller-callsite-2.0.0.tgz#847e0fce0a223750a9a027c54b33731ad3154134" + dependencies: + callsites "^2.0.0" + caller-path@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/caller-path/-/caller-path-0.1.0.tgz#94085ef63581ecd3daa92444a8fe94e82577751f" dependencies: callsites "^0.2.0" +caller-path@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/caller-path/-/caller-path-2.0.0.tgz#468f83044e369ab2010fac5f06ceee15bb2cb1f4" + dependencies: + caller-callsite "^2.0.0" + callsite@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/callsite/-/callsite-1.0.0.tgz#280398e5d664bd74038b6f0905153e6e8af1bc20" @@ -1782,7 +1868,7 @@ class-utils@^0.3.5: isobject "^3.0.0" static-extend "^0.1.1" -classnames@^2.2.6: +classnames@^2.2.5, classnames@^2.2.6: version "2.2.6" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce" @@ -1966,7 +2052,7 @@ contains-path@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/contains-path/-/contains-path-0.1.0.tgz#fe8cf184ff6670b6baef01a9d4861a5cbec4120a" -convert-source-map@1.X, convert-source-map@^1.1.0, convert-source-map@^1.4.0, convert-source-map@^1.5.1: +convert-source-map@1.X, convert-source-map@^1.1.0, convert-source-map@^1.4.0, convert-source-map@^1.5.0, convert-source-map@^1.5.1: version "1.6.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.6.0.tgz#51b537a8c43e0f04dec1993bffcdd504e758ac20" dependencies: @@ -1992,6 +2078,15 @@ core-util-is@1.0.2, core-util-is@~1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" +cosmiconfig@^5.0.5: + version "5.0.7" + resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-5.0.7.tgz#39826b292ee0d78eda137dfa3173bd1c21a43b04" + dependencies: + import-fresh "^2.0.0" + is-directory "^0.3.1" + js-yaml "^3.9.0" + parse-json "^4.0.0" + coveralls@^2.11.3: version "2.13.3" resolved "https://registry.yarnpkg.com/coveralls/-/coveralls-2.13.3.tgz#9ad7c2ae527417f361e8b626483f48ee92dd2bc7" @@ -2009,6 +2104,18 @@ create-ecdh@^4.0.0: bn.js "^4.1.0" elliptic "^6.0.0" +create-emotion@^9.2.12: + version "9.2.12" + resolved "https://registry.yarnpkg.com/create-emotion/-/create-emotion-9.2.12.tgz#0fc8e7f92c4f8bb924b0fef6781f66b1d07cb26f" + dependencies: + "@emotion/hash" "^0.6.2" + "@emotion/memoize" "^0.6.1" + "@emotion/stylis" "^0.7.0" + "@emotion/unitless" "^0.6.2" + csstype "^2.5.2" + stylis "^3.5.0" + stylis-rule-sheet "^0.0.10" + create-hash@^1.1.0, create-hash@^1.1.2: version "1.2.0" resolved "http://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz#889078af11a63756bcfb59bd221996be3a9ef196" @@ -2122,6 +2229,10 @@ cssstyle@^1.0.0: dependencies: cssom "0.3.x" +csstype@^2.5.2: + version "2.5.7" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.5.7.tgz#bf9235d5872141eccfb2d16d82993c6b149179ff" + d@1: version "1.0.0" resolved "https://registry.yarnpkg.com/d/-/d-1.0.0.tgz#754bb5bfe55451da69a58b94d45f4c5b0462d58f" @@ -2333,7 +2444,16 @@ dev-ip@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/dev-ip/-/dev-ip-1.0.1.tgz#a76a3ed1855be7a012bb8ac16cb80f3c00dc28f0" -diff@^3.2.0: +diff2html@^2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/diff2html/-/diff2html-2.5.0.tgz#2d16f1a8f115354733b16b0264a594fa7db98aa2" + dependencies: + diff "^3.5.0" + hogan.js "^3.0.2" + lodash "^4.17.11" + whatwg-fetch "^3.0.0" + +diff@^3.2.0, diff@^3.5.0: version "3.5.0" resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12" @@ -2362,6 +2482,12 @@ doctrine@^2.1.0: dependencies: esutils "^2.0.2" +dom-helpers@^3.3.1: + version "3.4.0" + resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.4.0.tgz#e9b369700f959f62ecde5a6babde4bccd9169af8" + dependencies: + "@babel/runtime" "^7.1.2" + dom-serializer@0, dom-serializer@~0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.1.0.tgz#073c697546ce0780ce23be4a28e293e40bc30c82" @@ -2466,6 +2592,13 @@ emoji-regex@^6.5.1: version "6.5.1" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-6.5.1.tgz#9baea929b155565c11ea41c6626eaa65cef992c2" +emotion@^9.1.2: + version "9.2.12" + resolved "https://registry.yarnpkg.com/emotion/-/emotion-9.2.12.tgz#53925aaa005614e65c6e43db8243c843574d1ea9" + dependencies: + babel-plugin-emotion "^9.2.11" + create-emotion "^9.2.12" + encodeurl@~1.0.1, encodeurl@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" @@ -2561,7 +2694,7 @@ enzyme@^3.5.0: rst-selector-parser "^2.2.3" string.prototype.trim "^1.1.2" -error-ex@^1.2.0: +error-ex@^1.2.0, error-ex@^1.3.1: version "1.3.2" resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" dependencies: @@ -3070,6 +3203,10 @@ find-node-modules@^1.0.4: findup-sync "0.4.2" merge "^1.2.0" +find-root@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/find-root/-/find-root-1.1.0.tgz#abcfc8ba76f708c42a97b3d685b7e9450bfb9ce4" + find-up@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/find-up/-/find-up-1.1.2.tgz#6b2e9822b1a2ce0a60ab64d610eccad53cb24d0f" @@ -3730,6 +3867,13 @@ hoek@2.x.x: version "2.16.3" resolved "https://registry.yarnpkg.com/hoek/-/hoek-2.16.3.tgz#20bb7403d3cea398e91dc4710a8ff1b8274a25ed" +hogan.js@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/hogan.js/-/hogan.js-3.0.2.tgz#4cd9e1abd4294146e7679e41d7898732b02c7bfd" + dependencies: + mkdirp "0.3.0" + nopt "1.0.10" + hoist-non-react-statics@^2.3.1, hoist-non-react-statics@^2.5.0: version "2.5.5" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz#c5903cf409c0dfd908f388e619d86b9c1174cb47" @@ -3862,6 +4006,13 @@ immutable@^3: version "3.8.2" resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.8.2.tgz#c2439951455bb39913daf281376f1530e104adf3" +import-fresh@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-2.0.0.tgz#d81355c15612d386c61f9ddd3922d4304822a546" + dependencies: + caller-path "^2.0.0" + resolve-from "^3.0.0" + import-local@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/import-local/-/import-local-1.0.0.tgz#5e4ffdc03f4fe6c009c6729beb29631c2f8227bc" @@ -4038,6 +4189,10 @@ is-descriptor@^1.0.0, is-descriptor@^1.0.2: is-data-descriptor "^1.0.0" kind-of "^6.0.2" +is-directory@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/is-directory/-/is-directory-0.3.1.tgz#61339b6f2475fc772fd9c9d83f5c8575dc154ae1" + is-dotfile@^1.0.0: version "1.0.3" resolved "https://registry.yarnpkg.com/is-dotfile/-/is-dotfile-1.0.3.tgz#a6a2f32ffd2dfb04f5ca25ecd0f6b83cf798a1e1" @@ -4689,7 +4844,7 @@ js-yaml@3.6.1: argparse "^1.0.7" esprima "^2.6.0" -js-yaml@^3.12.0, js-yaml@^3.7.0: +js-yaml@^3.12.0, js-yaml@^3.7.0, js-yaml@^3.9.0: version "3.12.0" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.12.0.tgz#eaed656ec8344f10f527c6bfa1b6e2244de167d1" dependencies: @@ -4743,6 +4898,10 @@ jsesc@~0.5.0: version "0.5.0" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d" +json-parse-better-errors@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9" + json-schema-traverse@^0.3.0: version "0.3.1" resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz#349a6d44c53a51de89b40805c5d5e59b417d3340" @@ -5115,7 +5274,7 @@ lodash.templatesettings@^3.0.0: lodash._reinterpolate "^3.0.0" lodash.escape "^3.0.0" -lodash@^4.13.1, lodash@^4.15.0, lodash@^4.16.6, lodash@^4.17.10, lodash@^4.17.4, lodash@^4.17.5: +lodash@^4.13.1, lodash@^4.15.0, lodash@^4.16.6, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.4, lodash@^4.17.5: version "4.17.11" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d" @@ -5127,7 +5286,7 @@ log-driver@1.2.5: version "1.2.5" resolved "https://registry.yarnpkg.com/log-driver/-/log-driver-1.2.5.tgz#7ae4ec257302fd790d557cb10c97100d857b0056" -loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1: +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" dependencies: @@ -5216,6 +5375,10 @@ mem@^1.1.0: dependencies: mimic-fn "^1.0.0" +memoize-one@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-4.0.3.tgz#cdfdd942853f1a1b4c71c5336b8c49da0bf0273c" + memoizee@0.4.X: version "0.4.14" resolved "https://registry.yarnpkg.com/memoizee/-/memoizee-0.4.14.tgz#07a00f204699f9a95c2d9e77218271c7cd610d57" @@ -5371,6 +5534,10 @@ mixin-deep@^1.2.0: for-in "^1.0.2" is-extendable "^1.0.1" +mkdirp@0.3.0: + version "0.3.0" + resolved "http://registry.npmjs.org/mkdirp/-/mkdirp-0.3.0.tgz#1bbf5ab1ba827af23575143490426455f481fe1e" + "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1: version "0.5.1" resolved "http://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" @@ -5549,6 +5716,12 @@ nomnom@~1.6.2: colors "0.5.x" underscore "~1.4.4" +nopt@1.0.10, nopt@~1.0.10: + version "1.0.10" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-1.0.10.tgz#6ddd21bd2a31417b92727dd585f8a6f37608ebee" + dependencies: + abbrev "1" + nopt@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.1.tgz#d0d4685afd5415193c8c7505602d0d17cd64474d" @@ -5907,6 +6080,13 @@ parse-json@^2.2.0: dependencies: error-ex "^1.2.0" +parse-json@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-4.0.0.tgz#be35f5425be1f7f6c747184f98a788cb99477ee0" + dependencies: + error-ex "^1.3.1" + json-parse-better-errors "^1.0.1" + parse-passwd@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/parse-passwd/-/parse-passwd-1.0.0.tgz#6d5b934a456993b23d37f40a382d6f1666a8e5c6" @@ -6279,6 +6459,12 @@ react-i18next@^7.11.0: html-parse-stringify2 "2.0.1" prop-types "^15.6.0" +react-input-autosize@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/react-input-autosize/-/react-input-autosize-2.2.1.tgz#ec428fa15b1592994fb5f9aa15bb1eb6baf420f8" + dependencies: + prop-types "^15.5.8" + react-is@^16.5.2: version "16.5.2" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.5.2.tgz#e2a7b7c3f5d48062eb769fcb123505eb928722e3" @@ -6293,6 +6479,10 @@ react-jss@^8.6.1: prop-types "^15.6.0" theming "^1.3.0" +react-lifecycles-compat@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" + react-router-dom@^4.3.1: version "4.3.1" resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-4.3.1.tgz#4c2619fc24c4fa87c9fd18f4fb4a43fe63fbd5c6" @@ -6323,6 +6513,18 @@ react-router@^4.3.1: prop-types "^15.6.1" warning "^4.0.1" +react-select@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/react-select/-/react-select-2.1.2.tgz#7a3e4c2b9efcd8c44ae7cf6ebb8b060ef69c513c" + dependencies: + classnames "^2.2.5" + emotion "^9.1.2" + memoize-one "^4.0.0" + prop-types "^15.6.0" + raf "^3.4.0" + react-input-autosize "^2.2.1" + react-transition-group "^2.2.1" + react-test-renderer@^16.0.0-0: version "16.5.2" resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.5.2.tgz#92e9d2c6f763b9821b2e0b22f994ee675068b5ae" @@ -6332,6 +6534,15 @@ react-test-renderer@^16.0.0-0: react-is "^16.5.2" schedule "^0.5.0" +react-transition-group@^2.2.1: + version "2.5.0" + resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.5.0.tgz#70bca0e3546102c4dc5cf3f5f57f73447cce6874" + dependencies: + dom-helpers "^3.3.1" + loose-envify "^1.4.0" + prop-types "^15.6.2" + react-lifecycles-compat "^3.0.4" + react@^16.4.2: version "16.6.0" resolved "https://registry.yarnpkg.com/react/-/react-16.6.0.tgz#b34761cfaf3e30f5508bc732fb4736730b7da246" @@ -6466,6 +6677,10 @@ regenerator-runtime@^0.11.0: version "0.11.1" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9" +regenerator-runtime@^0.12.0: + version "0.12.1" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz#fa1a71544764c036f8c49b13a08b2594c9f8a0de" + regenerator-transform@^0.13.3: version "0.13.3" resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.13.3.tgz#264bd9ff38a8ce24b06e0636496b2c856b57bcbb" @@ -6659,7 +6874,7 @@ resolve@1.1.7: version "1.1.7" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b" -resolve@^1.1.4, resolve@^1.1.6, resolve@^1.1.7, resolve@^1.3.2, resolve@^1.4.0, resolve@^1.5.0, resolve@^1.6.0: +resolve@^1.1.4, resolve@^1.1.6, resolve@^1.1.7, resolve@^1.3.2, resolve@^1.4.0, resolve@^1.5.0, resolve@^1.6.0, resolve@^1.8.1: version "1.8.1" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.8.1.tgz#82f1ec19a423ac1fbd080b0bab06ba36e84a7a26" dependencies: @@ -7033,6 +7248,10 @@ source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" +source-map@^0.7.2: + version "0.7.3" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383" + sparkles@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/sparkles/-/sparkles-1.0.1.tgz#008db65edce6c50eec0c5e228e1945061dd0437c" @@ -7243,6 +7462,14 @@ strip-json-comments@^2.0.1, strip-json-comments@~2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" +stylis-rule-sheet@^0.0.10: + version "0.0.10" + resolved "https://registry.yarnpkg.com/stylis-rule-sheet/-/stylis-rule-sheet-0.0.10.tgz#44e64a2b076643f4b52e5ff71efc04d8c3c4a430" + +stylis@^3.5.0: + version "3.5.4" + resolved "https://registry.yarnpkg.com/stylis/-/stylis-3.5.4.tgz#f665f25f5e299cf3d64654ab949a57c768b73fbe" + subarg@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/subarg/-/subarg-1.0.0.tgz#f62cf17581e996b48fc965699f54c06ae268b8d2" @@ -7442,6 +7669,12 @@ to-regex@^3.0.1, to-regex@^3.0.2: regex-not "^1.0.2" safe-regex "^1.1.0" +touch@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/touch/-/touch-2.0.2.tgz#ca0b2a3ae3211246a61b16ba9e6cbf1596287164" + dependencies: + nopt "~1.0.10" + tough-cookie@>=2.3.3, tough-cookie@^2.3.4, tough-cookie@~2.4.3: version "2.4.3" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781" @@ -7827,6 +8060,10 @@ whatwg-encoding@^1.0.1, whatwg-encoding@^1.0.3: dependencies: iconv-lite "0.4.24" +whatwg-fetch@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz#fc804e458cc460009b1a2b966bc8817d2578aefb" + whatwg-mimetype@^2.1.0: version "2.2.0" resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-2.2.0.tgz#a3d58ef10b76009b042d03e25591ece89b88d171" diff --git a/scm-ui-components/packages/ui-types/package.json b/scm-ui-components/packages/ui-types/package.json index 78452c2ef5..4d87265379 100644 --- a/scm-ui-components/packages/ui-types/package.json +++ b/scm-ui-components/packages/ui-types/package.json @@ -14,7 +14,7 @@ "check": "flow check" }, "devDependencies": { - "@scm-manager/ui-bundler": "^0.0.21" + "@scm-manager/ui-bundler": "^0.0.24" }, "browserify": { "transform": [ @@ -33,4 +33,4 @@ ] ] } -} \ No newline at end of file +} diff --git a/scm-ui-components/packages/ui-types/src/Autocomplete.js b/scm-ui-components/packages/ui-types/src/Autocomplete.js new file mode 100644 index 0000000000..407108c115 --- /dev/null +++ b/scm-ui-components/packages/ui-types/src/Autocomplete.js @@ -0,0 +1,10 @@ +// @flow +export type AutocompleteObject = { + id: string, + displayName: string +}; + +export type SelectValue = { + value: AutocompleteObject, + label: string +}; diff --git a/scm-ui-components/packages/ui-types/src/AvailableRepositoryPermissions.js b/scm-ui-components/packages/ui-types/src/AvailableRepositoryPermissions.js new file mode 100644 index 0000000000..ab7e8d82e4 --- /dev/null +++ b/scm-ui-components/packages/ui-types/src/AvailableRepositoryPermissions.js @@ -0,0 +1,11 @@ +// @flow + +export type RepositoryRole = { + name: string, + verbs: string[] +}; + +export type AvailableRepositoryPermissions = { + availableVerbs: string[], + availableRoles: RepositoryRole[] +}; diff --git a/scm-ui-components/packages/ui-types/src/Me.js b/scm-ui-components/packages/ui-types/src/Me.js index ab67debae7..f6cb9c2036 100644 --- a/scm-ui-components/packages/ui-types/src/Me.js +++ b/scm-ui-components/packages/ui-types/src/Me.js @@ -1,7 +1,11 @@ // @flow +import type { Links } from "./hal"; + export type Me = { name: string, displayName: string, - mail: string + mail: string, + groups: [], + _links: Links }; diff --git a/scm-ui-components/packages/ui-types/src/RepositoryPermissions.js b/scm-ui-components/packages/ui-types/src/RepositoryPermissions.js index 4352c21da6..ed3c925283 100644 --- a/scm-ui-components/packages/ui-types/src/RepositoryPermissions.js +++ b/scm-ui-components/packages/ui-types/src/RepositoryPermissions.js @@ -7,7 +7,7 @@ export type Permission = PermissionCreateEntry & { export type PermissionCreateEntry = { name: string, - type: string, + verbs: string[], groupPermission: boolean } diff --git a/scm-ui-components/packages/ui-types/src/Sources.js b/scm-ui-components/packages/ui-types/src/Sources.js index c8b3fafe0c..83274290df 100644 --- a/scm-ui-components/packages/ui-types/src/Sources.js +++ b/scm-ui-components/packages/ui-types/src/Sources.js @@ -1,6 +1,6 @@ // @flow -import type { Collection, Links } from "./hal"; +import type { Links } from "./hal"; // TODO ?? check ?? links export type SubRepository = { @@ -20,6 +20,6 @@ export type File = { subRepository?: SubRepository, // TODO _links: Links, _embedded: { - children: File[] + children: ?File[] } }; diff --git a/scm-ui-components/packages/ui-types/src/index.js b/scm-ui-components/packages/ui-types/src/index.js index 883272b4d4..f7b375ac98 100644 --- a/scm-ui-components/packages/ui-types/src/index.js +++ b/scm-ui-components/packages/ui-types/src/index.js @@ -22,3 +22,7 @@ export type { IndexResources } from "./IndexResources"; export type { Permission, PermissionCreateEntry, PermissionCollection } from "./RepositoryPermissions"; export type { SubRepository, File } from "./Sources"; + +export type { SelectValue, AutocompleteObject } from "./Autocomplete"; + +export type { AvailableRepositoryPermissions, RepositoryRole } from "./AvailableRepositoryPermissions"; diff --git a/scm-ui-components/packages/ui-types/yarn.lock b/scm-ui-components/packages/ui-types/yarn.lock index fe2df2f76a..ee367343ee 100644 --- a/scm-ui-components/packages/ui-types/yarn.lock +++ b/scm-ui-components/packages/ui-types/yarn.lock @@ -707,9 +707,9 @@ version "0.0.2" resolved "https://registry.yarnpkg.com/@scm-manager/eslint-config/-/eslint-config-0.0.2.tgz#94cc8c3fb4f51f870b235893dc134fc6c423ae85" -"@scm-manager/ui-bundler@^0.0.21": - version "0.0.21" - resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.21.tgz#f8b5fa355415cc67b8aaf8744e1701a299dff647" +"@scm-manager/ui-bundler@^0.0.24": + version "0.0.24" + resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.24.tgz#034d5500c79b438c48d8f7ee985be07c4ea46d1e" dependencies: "@babel/core" "^7.0.0" "@babel/plugin-proposal-class-properties" "^7.0.0" diff --git a/scm-ui/package.json b/scm-ui/package.json index c4b7cb3983..2144e50e6c 100644 --- a/scm-ui/package.json +++ b/scm-ui/package.json @@ -5,50 +5,55 @@ "private": true, "main": "src/index.js", "dependencies": { + "@babel/polyfill": "^7.0.0", "@fortawesome/fontawesome-free": "^5.3.1", - "@scm-manager/ui-extensions": "^0.1.1", + "@scm-manager/ui-extensions": "^0.1.2", "bulma": "^0.7.1", "bulma-tooltip": "^2.0.2", "classnames": "^2.2.5", - "diff2html": "^2.4.0", + "diff2html": "^2.5.0", "font-awesome": "^4.7.0", "history": "^4.7.2", "i18next": "^11.4.0", "i18next-browser-languagedetector": "^2.2.2", "i18next-fetch-backend": "^0.1.0", + "jss-nested": "^6.0.1", "moment": "^2.22.2", "node-sass": "^4.9.3", "postcss-easy-import": "^3.0.0", "react": "^16.4.2", - "react-diff-view": "^1.7.0", "react-dom": "^16.4.2", "react-i18next": "^7.9.0", "react-jss": "^8.6.0", "react-redux": "^5.0.7", "react-router-dom": "^4.3.1", "react-router-redux": "^5.0.0-alpha.9", + "react-select": "^2.1.2", "react-syntax-highlighter": "^9.0.1", "redux": "^4.0.0", "redux-devtools-extension": "^2.13.5", "redux-logger": "^3.0.6", - "redux-thunk": "^2.3.0" + "redux-thunk": "^2.3.0", + "whatwg-fetch": "^3.0.0" }, "scripts": { + "polyfills": "concat node_modules/@babel/polyfill/dist/polyfill.min.js node_modules/whatwg-fetch/dist/fetch.umd.js -o target/scm-ui/polyfills.bundle.js", "webfonts": "copyfiles -f node_modules/@fortawesome/fontawesome-free/webfonts/* target/scm-ui/styles/webfonts", "build-css": "node-sass-chokidar --include-path ./styles --include-path ./node_modules styles/ -o target/scm-ui/styles", "watch-css": "npm run build-css && node-sass-chokidar --include-path ./styles --include-path ./node_modules styles/ -o target/scm-ui/styles --watch --recursive", "start-js": "ui-bundler serve --target target/scm-ui --vendor vendor.bundle.js", - "start": "npm-run-all -p webfonts watch-css start-js", + "start": "npm-run-all -p webfonts watch-css polyfills start-js", "build-js": "ui-bundler bundle --mode=production target/scm-ui/scm-ui.bundle.js", "build-vendor": "ui-bundler vendor --mode=production target/scm-ui/vendor.bundle.js", - "build": "npm-run-all -s webfonts build-css build-vendor build-js", + "build": "npm-run-all -s webfonts build-css polyfills build-vendor build-js", "test": "ui-bundler test", "test-ci": "ui-bundler test --ci", "flow": "flow", "pre-commit": "jest && flow && eslint src" }, "devDependencies": { - "@scm-manager/ui-bundler": "^0.0.21", + "@scm-manager/ui-bundler": "^0.0.24", + "concat": "^1.0.3", "copyfiles": "^2.0.0", "enzyme": "^3.3.0", "enzyme-adapter-react-16": "^1.1.1", diff --git a/scm-ui/public/images/blib.jpg b/scm-ui/public/images/blib.jpg index 5fa47ab2bc..55c2ea62d8 100644 Binary files a/scm-ui/public/images/blib.jpg and b/scm-ui/public/images/blib.jpg differ diff --git a/scm-ui/public/images/scmManagerHero.jpg b/scm-ui/public/images/scmManagerHero.jpg new file mode 100644 index 0000000000..ae6b200a92 Binary files /dev/null and b/scm-ui/public/images/scmManagerHero.jpg differ diff --git a/scm-ui/public/index.mustache b/scm-ui/public/index.mustache index 62a40d8e93..75efc78088 100644 --- a/scm-ui/public/index.mustache +++ b/scm-ui/public/index.mustache @@ -21,6 +21,8 @@ You need to enable JavaScript to run this app. </noscript> <div id="root"></div> + <div id="modalRoot"></div> + <!-- This HTML file is a template. If you open it directly in the browser, you will see an empty page. @@ -34,6 +36,7 @@ <script> window.ctxPath = "{{ contextPath }}"; </script> + <script src="{{ contextPath }}/polyfills.bundle.js"></script> <script src="{{ contextPath }}/vendor.bundle.js"></script> <script src="{{ contextPath }}/scm-ui.bundle.js"></script> diff --git a/scm-ui/public/locales/de/commons.json b/scm-ui/public/locales/de/commons.json new file mode 100644 index 0000000000..f32d764ce8 --- /dev/null +++ b/scm-ui/public/locales/de/commons.json @@ -0,0 +1,74 @@ +{ + "login": { + "title": "Anmeldung", + "subtitle": "Bitte anmelden, um fortzufahren.", + "logo-alt": "SCM-Manager", + "username-placeholder": "Benutzername", + "password-placeholder": "Passwort", + "submit": "Anmelden" + }, + "logout": { + "error": { + "title": "Abmeldung fehlgeschlagen", + "subtitle": "Während der Abmeldung ist ein Fehler aufgetreten." + } + }, + "app": { + "error": { + "title": "Fehler", + "subtitle": "Ein unbekannter Fehler ist aufgetreten." + } + }, + "error-notification": { + "prefix": "Fehler", + "loginLink": "Erneute Anmeldung", + "timeout": "Die Session ist abgelaufen.", + "wrong-login-credentials": "Ungültige Anmeldedaten" + }, + "loading": { + "alt": "Lade ..." + }, + "logo": { + "alt": "SCM-Manager" + }, + "primary-navigation": { + "repositories": "Repositories", + "users": "Benutzer", + "logout": "Abmelden", + "groups": "Gruppen", + "config": "Einstellungen" + }, + "paginator": { + "next": "Weiter", + "previous": "Zurück" + }, + "profile": { + "navigationLabel": "Profil Navigation", + "informationNavLink": "Information", + "changePasswordNavLink": "Passwort ändern", + "settingsNavLink": "Einstellungen", + "username": "Benutzername", + "displayName": "Anzeigename", + "mail": "E-Mail", + "groups": "Gruppen", + "information": "Informationen", + "change-password": "Passwort ändern", + "error-title": "Fehler", + "error-subtitle": "Das Profil kann nicht angezeigt werden.", + "error": "Fehler", + "error-message": "'me' ist nicht definiert" + }, + "password": { + "label": "Passwort", + "newPassword": "Neues Passwort", + "passwordHelpText": "Klartext Passwort des Benutzers.", + "passwordConfirmHelpText": "Passwort zur Bestätigen wiederholen.", + "currentPassword": "Aktuelles Passwort", + "currentPasswordHelpText": "Dieses Passwort wird momentan bereits verwendet.", + "confirmPassword": "Passwort wiederholen", + "passwordInvalid": "Das Passwort muss zwischen 6 und 32 Zeichen lang sein!", + "passwordConfirmFailed": "Passwörter müssen identisch sein!", + "submit": "Speichern", + "changedSuccessfully": "Passwort erfolgreich geändert!" + } +} diff --git a/scm-ui/public/locales/de/config.json b/scm-ui/public/locales/de/config.json new file mode 100644 index 0000000000..5767a2b376 --- /dev/null +++ b/scm-ui/public/locales/de/config.json @@ -0,0 +1,91 @@ +{ + "config": { + "navigationLabel": "Einstellungs Navigation", + "globalConfigurationNavLink": "Globale Einstellungen", + "title": "Einstellungen", + "errorTitle": "Fehler", + "errorSubtitle": "Unbekannter Einstellungen Fehler" + }, + "config-form": { + "submit": "Speichern", + "submit-success-notification": "Einstellungen wurden erfolgreich geändert!", + "no-permission-notification": "Hinweis: Es fehlen Berechtigungen zum Bearbeiten der Einstellungen!" + }, + "proxy-settings": { + "name": "Proxy Einstellungen", + "proxy-password": "Proxy Passwort", + "proxy-port": "Proxy Port", + "proxy-server": "Proxy Server", + "proxy-user": "Proxy Benutzer", + "enable-proxy": "Proxy aktivieren", + "proxy-excludes": "Proxy Excludes", + "remove-proxy-exclude-button": "Proxy Exclude löschen", + "add-proxy-exclude-error": "Der Proxy Exclude ist ungültig", + "add-proxy-exclude-textfield": "Neue Proxy Excludes hinzufügen", + "add-proxy-exclude-button": "Proxy Exclude hinzufügen" + }, + "base-url-settings": { + "name": "Base URL Einstellungen", + "base-url": "Base URL", + "force-base-url": "Base URL erzwingen" + }, + "admin-settings": { + "name": "Administrations Einstellungen", + "admin-groups": "Admin Gruppen", + "admin-users": "Admin Benutzer", + "remove-group-button": "Admin Group löschen", + "remove-user-button": "Admin Benutzer löschen", + "add-group-error": "Der eingegebene Gruppenname ist ungültig", + "add-group-textfield": "Neue Gruppe mit Administrationsrechten hinzufügen", + "add-group-button": "Admin Gruppe hinzufügen", + "add-user-error": "Der eingegebene Benutzername ist ungültig", + "add-user-textfield": "Neuen Benutzer mit Administrationsrechten hinzufügen", + "add-user-button": "Admin Benutzer hinzufügen" + }, + "login-attempt": { + "name": "Anmeldeversuche", + "login-attempt-limit": "Limit für Anmeldeversuche", + "login-attempt-limit-timeout": "Timeout bei fehlgeschlagenen Anmeldeversuchen" + }, + "general-settings": { + "realm-description": "Realm Beschreibung", + "enable-repository-archive": "Repository Archiv aktivieren", + "disable-grouping-grid": "Gruppen deaktivieren", + "date-format": "Datumsformat", + "anonymous-access-enabled": "Anonyme Zugriffe erlauben", + "skip-failed-authenticators": "Fehlgeschlagene Authentifizierer überspringen", + "plugin-url": "Plugin URL", + "enabled-xsrf-protection": "XSRF Protection aktivieren", + "default-namespace-strategy": "Default Namespace Strategie" + }, + "validation": { + "date-format-invalid": "Das Datumsformat ist ungültig", + "login-attempt-limit-timeout-invalid": "Dies ist keine Zahl", + "login-attempt-limit-invalid": "Dies ist keine Zahl", + "plugin-url-invalid": "Dies ist keine gültige URL" + }, + "help": { + "realmDescriptionHelpText": "Beschreibung des Authentication Realm.", + "dateFormatHelpText": "Moments Datumsformat. Zulässige Formate sind in der MomentJS Dokumentation beschrieben.", + "pluginRepositoryHelpText": "Die URL des Plugin Repositories. Beschreibung der Platzhalter: version = SCM-Manager Version; os = Betriebssystem; arch = Architektur", + "enableForwardingHelpText": "mod_proxy Port Weiterleitung aktivieren.", + "enableRepositoryArchiveHelpText": "Repository Archive aktivieren. Nach einer Änderung an dieser Einstellung muss die Seite komplett neu geladen werden.", + "disableGroupingGridHelpText": "Repository Gruppen deaktivieren. Nach einer Änderung an dieser Einstellung muss die Seite komplett neu geladen werden.", + "allowAnonymousAccessHelpText": "Anonyme Benutzer haben Zugriff auf öffentliche Repositories.", + "skipFailedAuthenticatorsHelpText": "Die Kette der Authentifikatoren wird nicht beendet, wenn ein Authentifikator einen Benutzer findet, ihn aber nicht erfolgreich authentifizieren kann.", + "adminGroupsHelpText": "Namen von Gruppen mit Admin-Berechtigungen.", + "adminUsersHelpText": "Namen von Benutzern mit Admin-Berechtigungen.", + "forceBaseUrlHelpText": "Zugriffe, die von einer anderen URL kommen, werden auf die Base URL weiter geleitet.", + "baseUrlHelpText": "Die URL der Applikation mit Kontextpfad, z.B. http://localhost:8080/scm", + "loginAttemptLimitHelpText": "Maximale Anzahl von Anmeldeversuchen. Durch Verwendung von -1 wird die Begrenzung der Anmeldeversuche deaktiviert.", + "loginAttemptLimitTimeoutHelpText": "Timeout in Sekunden für Benutzer, die vorübergehend wegen zu vieler fehlgeschlagener Anmeldeversuche, deaktiviert wurden.", + "enableProxyHelpText": "Proxy aktivieren", + "proxyPortHelpText": "Der Proxy Port", + "proxyPasswordHelpText": "Das Passwort für die Proxy Server Anmeldung.", + "proxyServerHelpText": "Der Proxy Server", + "proxyUserHelpText": "Der Benutzername für die Proxy Server Anmeldung.", + "proxyExcludesHelpText": "Glob patterns für Hostnamen, die von den Proxy-Einstellungen ausgeschlossen werden sollen.", + "enableXsrfProtectionHelpText": "Xsrf Cookie Protection aktivieren. Hinweis: Dieses Feature befindet sich noch im Experimentalstatus.", + "defaultNameSpaceStrategyHelpText": "Die Standardstrategie für Namespaces." + } +} diff --git a/scm-ui/public/locales/de/groups.json b/scm-ui/public/locales/de/groups.json new file mode 100644 index 0000000000..1b2504a644 --- /dev/null +++ b/scm-ui/public/locales/de/groups.json @@ -0,0 +1,71 @@ +{ + "group": { + "name": "Name", + "description": "Beschreibung", + "creationDate": "Erstellt", + "lastModified": "Zuletzt bearbeitet", + "type": "Typ", + "members": "Mitglieder" + }, + "groups": { + "title": "Gruppen", + "subtitle": "Verwaltung der Gruppen" + }, + "singleGroup": { + "errorTitle": "Fehler", + "errorSubtitle": "Unbekannter Gruppen Fehler", + "menu": { + "navigationLabel": "Gruppen Navigation", + "informationNavLink": "Informationen", + "settingsNavLink": "Einstellungen", + "generalNavLink": "Generell", + "setPermissionsNavLink": "Berechtigungen" + } + }, + "add-group": { + "title": "Gruppe erstellen", + "subtitle": "Erstellen einer neuen Gruppe" + }, + "create-group-button": { + "label": "Gruppe erstellen" + }, + "edit-group-button": { + "label": "Bearbeiten" + }, + "add-member-button": { + "label": "Mitglied hinzufügen" + }, + "remove-member-button": { + "label": "Mitglied entfernen" + }, + "add-member-textfield": { + "label": "Mitglied hinzufügen", + "error": "Ungültiger Name für Mitglied" + }, + "add-member-autocomplete": { + "placeholder": "Benutzername eingeben", + "loading": "Suche...", + "no-options": "Kein Vorschlag für Benutzername verfügbar" + }, + "groupForm": { + "subtitle": "Gruppe bearbeiten", + "submit": "Speichern", + "nameError": "Name ist ungültig", + "descriptionError": "Beschreibung ist ungültig", + "help": { + "nameHelpText": "Eindeutiger Name der Gruppe", + "descriptionHelpText": "Eine kurze Beschreibung der Gruppe", + "memberHelpText": "Benutzername des Mitglieds der Gruppe" + } + }, + "deleteGroup": { + "subtitle": "Gruppe löschen", + "button": "Löschen", + "confirmAlert": { + "title": "Gruppe löschen", + "message": "Soll die Gruppe wirklich gelöscht werden?", + "submit": "Ja", + "cancel": "Nein" + } + } +} diff --git a/scm-ui/public/locales/de/permissions.json b/scm-ui/public/locales/de/permissions.json new file mode 100644 index 0000000000..57c061743a --- /dev/null +++ b/scm-ui/public/locales/de/permissions.json @@ -0,0 +1,6 @@ +{ + "setPermissions": { + "button": "Berechtigungen speichern", + "setPermissionsSuccessful": "Berechtigungen erfolgreich gespeichert" + } +} diff --git a/scm-ui/public/locales/de/repos.json b/scm-ui/public/locales/de/repos.json new file mode 100644 index 0000000000..922a2b270f --- /dev/null +++ b/scm-ui/public/locales/de/repos.json @@ -0,0 +1,148 @@ +{ + "repository": { + "name": "Name", + "type": "Typ", + "contact": "Kontakt", + "description": "Beschreibung", + "creationDate": "Erstellt", + "lastModified": "Zuletzt bearbeitet" + }, + "validation": { + "name-invalid": "Der Name des Repository ist ungültig", + "contact-invalid": "Der Kontakt muss eine gültige E-Mail Adresse sein" + }, + "help": { + "nameHelpText": "Der Name des Repository. Dieser wird Teil der URL des Repository sein.", + "typeHelpText": "Der Typ des Repository (Mercurial, Git oder Subversion).", + "contactHelpText": "E-Mail Adresse der Person, die für das Repository verantwortlich ist.", + "descriptionHelpText": "Eine kurze Beschreibung des Repository." + }, + "repositoryRoot": { + "errorTitle": "Fehler", + "errorSubtitle": "Unbekannter Repository Fehler", + "menu": { + "navigationLabel": "Repository Navigation", + "informationNavLink": "Informationen", + "historyNavLink": "Commits", + "sourcesNavLink": "Sources", + "settingsNavLink": "Einstellungen", + "generalNavLink": "Generell", + "permissionsNavLink": "Berechtigungen" + } + }, + "overview": { + "title": "Repositories", + "subtitle": "Übersicht aller verfügbaren Repositories", + "createButton": "Repository erstellen" + }, + "create": { + "title": "Repository erstellen", + "subtitle": "Erstellen eines neuen Repository" + }, + "changesets": { + "errorTitle": "Fehler", + "errorSubtitle": "Changesets konnten nicht abgerufen werden", + "branchSelectorLabel": "Branches" + }, + "changeset": { + "description": "Beschreibung", + "summary": "Changeset {{id}} wurde committet {{time}}", + "shortSummary": "Committet {{id}} {{time}}", + "tags": "Tags", + "diffNotSupported": "Diff des Changesets wird von diesem Repositorytyp nicht unterstützt", + "author": { + "prefix": "Verfasst von", + "mailto": "Mail senden an" + }, + "buttons": { + "details": "Details", + "sources": "Sources" + } + }, + "repositoryForm": { + "subtitle": "Repository bearbeiten", + "submit": "Speichern" + }, + "sources": { + "file-tree": { + "name": "Name", + "length": "Größe", + "lastModified": "Zuletzt bearbeitet", + "description": "Beschreibung", + "branch": "Branch" + }, + "content": { + "historyButton": "History", + "sourcesButton": "Sources", + "downloadButton": "Download", + "path": "Pfad", + "branch": "Branch", + "lastModified": "Zuletzt bearbeitet", + "description": "Beschreibung", + "size": "Größe" + } + }, + "permission": { + "title": "Berechtigungen bearbeiten", + "user": "Benutzer", + "group": "Gruppe", + "error-title": "Fehler", + "error-subtitle": "Unbekannter Fehler bei Berechtigung", + "name": "Benutzer oder Gruppe", + "role": "Rolle", + "permissions": "Berechtigung", + "group-permission": "Gruppenberechtigung", + "user-permission": "Benutzerberechtigung", + "edit-permission": { + "delete-button": "Löschen", + "save-button": "Änderungen speichern" + }, + "advanced-button": { + "label": "Erweitert" + }, + "delete-permission-button": { + "label": "Löschen", + "confirm-alert": { + "title": "Berechtigung löschen", + "message": "Soll die Berechtigung wirklich gelöscht werden?", + "submit": "Ja", + "cancel": "Nein" + } + }, + "add-permission": { + "add-permission-heading": "Neue Berechtigung hinzufügen", + "submit-button": "Speichern", + "name-input-invalid": "Die Berechtigung darf nicht leer sein! Falls sie nicht leer ist, ist der Name ungültig oder die Berechtigung besteht bereits!" + }, + "help": { + "groupPermissionHelpText": "Zeigt ob es sich bei der Berechtigung um eine Gruppenberechtigung handelt. Wenn hier kein Haken gesetzt ist, handelt es sich um eine Benutzerberechtigung.", + "nameHelpText": "Verwaltung von Berechtigungen für Benutzer und Gruppen", + "roleHelpText": "READ = read; WRITE = read und write; OWNER = read, write und auch die Möglichkeit Einstellungen und Berechtigungen zu verwalten. Wenn hier nichts angezeigt wird, den Erweitert-Button benutzen, um Details zu sehen.", + "permissionsHelpText": "Hier können individuelle Berechtigungen unabhängig von vordefinierten Rollen vergeben werden." + }, + "autocomplete": { + "no-group-options": "Kein Gruppenname als Vorschlag verfügbar", + "group-placeholder": "Gruppe eingeben", + "no-user-options": "Kein Benutzername als Vorschlag verfügbar", + "user-placeholder": "Benutzer eingeben", + "loading": "suche..." + }, + "advanced": { + "dialog": { + "title": "Erweiterte Berechtigungen", + "submit": "Speichern", + "abort": "Abbrechen" + } + } + }, + "deleteRepo": { + "subtitle": "Repository löschen", + "button": "Löschen", + "confirmAlert": { + "title": "Repository löschen", + "message": "Soll das Repository wirklich gelöscht werden?", + "submit": "Ja", + "cancel": "Nein" + } + } +} diff --git a/scm-ui/public/locales/de/users.json b/scm-ui/public/locales/de/users.json new file mode 100644 index 0000000000..b38dd9c2fb --- /dev/null +++ b/scm-ui/public/locales/de/users.json @@ -0,0 +1,64 @@ +{ + "user": { + "name": "Benutzername", + "displayName": "Anzeigename", + "mail": "E-Mail", + "password": "Passwort", + "admin": "Admin", + "active": "Aktiv", + "type": "Typ", + "creationDate": "Erstellt", + "lastModified": "Zuletzt bearbeitet" + }, + "validation": { + "mail-invalid": "Diese E-Mail ist ungültig", + "name-invalid": "Dieser Name ist ungültig", + "displayname-invalid": "Dieser Anzeigename ist ungültig" + }, + "help": { + "usernameHelpText": "Einzigartiger Name des Benutzers", + "displayNameHelpText": "Anzeigename des Benutzers", + "mailHelpText": "E-Mail Adresse des Benutzers", + "adminHelpText": "Ein Administrator kann Repositories, Gruppen und Benutzer erstellen, bearbeiten und löschen.", + "activeHelpText": "Aktivierung oder Deaktivierung eines Benutzers" + }, + "users": { + "title": "Benutzer", + "subtitle": "Verwaltung der Benutzer", + "createButton": "Benutzer erstellen" + }, + "singleUser": { + "errorTitle": "Fehler", + "errorSubtitle": "Unbekannter Benutzer Fehler", + "menu": { + "navigationLabel": "Benutzer Navigation", + "informationNavLink": "Informationen", + "settingsNavLink": "Einstellungen", + "generalNavLink": "Generell", + "setPasswordNavLink": "Passwort", + "setPermissionsNavLink": "Berechtigungen" + } + }, + "addUser": { + "title": "Benutzer erstellen", + "subtitle": "Erstellen eines neuen Benutzers" + }, + "deleteUser": { + "subtitle": "Benutzer löschen", + "button": "Löschen", + "confirmAlert": { + "title": "Benutzer löschen", + "message": "Soll der Benutzer wirklich gelöscht werden?", + "submit": "Ja", + "cancel": "Nein" + } + }, + "singleUserPassword": { + "button": "Passwort setzen", + "setPasswordSuccessful": "Das Passwort wurde erfolgreich gespeichert." + }, + "userForm": { + "subtitle": "Benutzer bearbeiten", + "button": "Speichern" + } +} diff --git a/scm-ui/public/locales/en/commons.json b/scm-ui/public/locales/en/commons.json index 05e9f79d16..fed749a200 100644 --- a/scm-ui/public/locales/en/commons.json +++ b/scm-ui/public/locales/en/commons.json @@ -1,7 +1,7 @@ { "login": { "title": "Login", - "subtitle": "Please login to proceed.", + "subtitle": "Please login to proceed", "logo-alt": "SCM-Manager", "username-placeholder": "Your Username", "password-placeholder": "Your Password", @@ -20,7 +20,10 @@ } }, "error-notification": { - "prefix": "Error" + "prefix": "Error", + "loginLink": "You can login here again.", + "timeout": "The session has expired", + "wrong-login-credentials": "Invalid credentials" }, "loading": { "alt": "Loading ..." @@ -40,12 +43,32 @@ "previous": "Previous" }, "profile": { - "actions-label": "Actions", + "navigationLabel": "Profile Navigation", + "informationNavLink": "Information", + "changePasswordNavLink": "Change password", + "settingsNavLink": "Settings", "username": "Username", "displayName": "Display Name", "mail": "E-Mail", - "change-password": "Change password", + "groups": "Groups", + "information": "Information", + "change-password": "Change Password", "error-title": "Error", - "error-subtitle": "Cannot display profile" + "error-subtitle": "Cannot display profile", + "error": "Error", + "error-message": "'me' is undefined" + }, + "password": { + "label": "Password", + "newPassword": "New password", + "passwordHelpText": "Plain text password of the user", + "passwordConfirmHelpText": "Repeat the password for confirmation", + "currentPassword": "Current password", + "currentPasswordHelpText": "The password currently in use", + "confirmPassword": "Confirm password", + "passwordInvalid": "Password has to be between 6 and 32 characters", + "passwordConfirmFailed": "Passwords have to be identical", + "submit": "Submit", + "changedSuccessfully": "Password changed successfully" } } diff --git a/scm-ui/public/locales/en/config.json b/scm-ui/public/locales/en/config.json index de02006e79..b08c5c2d1b 100644 --- a/scm-ui/public/locales/en/config.json +++ b/scm-ui/public/locales/en/config.json @@ -1,15 +1,14 @@ { "config": { - "navigation-title": "Navigation" - }, - "global-config": { + "navigationLabel": "Configuration Navigation", + "globalConfigurationNavLink": "Global Configuration", "title": "Configuration", - "navigation-label": "Global Configuration", - "error-title": "Error", - "error-subtitle": "Unknown Config Error" + "errorTitle": "Error", + "errorSubtitle": "Unknown Config Error" }, "config-form": { "submit": "Submit", + "submit-success-notification": "Configuration changed successfully!", "no-permission-notification": "Please note: You do not have the permission to edit the config!" }, "proxy-settings": { @@ -66,17 +65,17 @@ "plugin-url-invalid": "This is not a valid url" }, "help": { - "realmDescriptionHelpText": "Enter authentication realm description", - "dateFormatHelpText": "Moments date format. Please have a look at the momentjs documentation.", + "realmDescriptionHelpText": "Enter authentication realm description.", + "dateFormatHelpText": "Moments date format. Please have a look at the MomentJS documentation.", "pluginRepositoryHelpText": "The url of the plugin repository. Explanation of the placeholders: version = SCM-Manager Version; os = Operation System; arch = Architecture", - "enableForwardingHelpText": "Enbale mod_proxy port forwarding.", + "enableForwardingHelpText": "Enable mod_proxy port forwarding.", "enableRepositoryArchiveHelpText": "Enable repository archives. A complete page reload is required after a change of this value.", "disableGroupingGridHelpText": "Disable repository Groups. A complete page reload is required after a change of this value.", "allowAnonymousAccessHelpText": "Anonymous users have read access on public repositories.", "skipFailedAuthenticatorsHelpText": "Do not stop the authentication chain, if an authenticator finds the user but fails to authenticate the user.", "adminGroupsHelpText": "Names of groups with admin permissions.", "adminUsersHelpText": "Names of users with admin permissions.", - "forceBaseUrlHelpText": "Redirects to the base url if the request comes from a other url", + "forceBaseUrlHelpText": "Redirects to the base url if the request comes from a other url.", "baseUrlHelpText": "The url of the application (with context path), i.e. http://localhost:8080/scm", "loginAttemptLimitHelpText": "Maximum allowed login attempts. Use -1 to disable the login attempt limit.", "loginAttemptLimitTimeoutHelpText": "Timeout in seconds for users which are temporary disabled, because of too many failed login attempts.", @@ -85,8 +84,8 @@ "proxyPasswordHelpText": "The password for the proxy server authentication.", "proxyServerHelpText": "The proxy server", "proxyUserHelpText": "The username for the proxy server authentication.", - "proxyExcludesHelpText": "Glob patterns for hostnames which should be excluded from proxy settings.", - "enableXsrfProtectionHelpText": "Enable Xsrf Cookie Protection. Note: This feature is still experimental.", - "defaultNameSpaceStrategyHelpText": "The default namespace strategy" + "proxyExcludesHelpText": "Glob patterns for hostnames, which should be excluded from proxy settings.", + "enableXsrfProtectionHelpText": "Enable XSRF Cookie Protection. Note: This feature is still experimental.", + "defaultNameSpaceStrategyHelpText": "The default namespace strategy." } } diff --git a/scm-ui/public/locales/en/groups.json b/scm-ui/public/locales/en/groups.json index daa2cc651a..3b34e5722b 100644 --- a/scm-ui/public/locales/en/groups.json +++ b/scm-ui/public/locales/en/groups.json @@ -11,47 +11,57 @@ "title": "Groups", "subtitle": "Create, read, update and delete groups" }, - "single-group": { - "error-title": "Error", - "error-subtitle": "Unknown group error", - "navigation-label": "Navigation", - "actions-label": "Actions", - "information-label": "Information", - "back-label": "Back" + "singleGroup": { + "errorTitle": "Error", + "errorSubtitle": "Unknown group error", + "menu": { + "navigationLabel": "Group Navigation", + "informationNavLink": "Information", + "settingsNavLink": "Settings", + "generalNavLink": "General", + "setPermissionsNavLink": "Permissions" + } }, "add-group": { "title": "Create Group", "subtitle": "Create a new group" }, "create-group-button": { - "label": "Create" + "label": "Create Group" }, "edit-group-button": { "label": "Edit" }, "add-member-button": { - "label": "Add member" + "label": "Add Member" }, "remove-member-button": { - "label": "Remove member" + "label": "Remove Member" }, "add-member-textfield": { - "label": "Add member", + "label": "Add Member", "error": "Invalid member name" }, - "group-form": { + "add-member-autocomplete": { + "placeholder": "Enter Member", + "loading": "Loading...", + "no-options": "No suggestion available" + }, + "groupForm": { + "subtitle": "Edit Group", "submit": "Submit", - "name-error": "Group name is invalid", - "description-error": "Description is invalid", + "nameError": "Group name is invalid", + "descriptionError": "Description is invalid", "help": { "nameHelpText": "Unique name of the group", "descriptionHelpText": "A short description of the group", "memberHelpText": "Usernames of the group members" } }, - "delete-group-button": { - "label": "Delete", - "confirm-alert": { + "deleteGroup": { + "subtitle": "Delete Group", + "button": "Delete", + "confirmAlert": { "title": "Delete Group", "message": "Do you really want to delete the group?", "submit": "Yes", diff --git a/scm-ui/public/locales/en/permissions.json b/scm-ui/public/locales/en/permissions.json new file mode 100644 index 0000000000..9c3663e77b --- /dev/null +++ b/scm-ui/public/locales/en/permissions.json @@ -0,0 +1,6 @@ +{ + "setPermissions": { + "button": "Set permissions", + "setPermissionsSuccessful": "Permissions set successfully" + } +} diff --git a/scm-ui/public/locales/en/repos.json b/scm-ui/public/locales/en/repos.json index d4fd950c45..bdb0491489 100644 --- a/scm-ui/public/locales/en/repos.json +++ b/scm-ui/public/locales/en/repos.json @@ -11,41 +11,58 @@ "name-invalid": "The repository name is invalid", "contact-invalid": "Contact must be a valid mail address" }, + "help": { + "nameHelpText": "The name of the repository. This name will be part of the repository url.", + "typeHelpText": "The type of the repository (e.g. Mercurial, Git or Subversion).", + "contactHelpText": "Email address of the person who is responsible for this repository.", + "descriptionHelpText": "A short description of the repository." + }, + "repositoryRoot": { + "errorTitle": "Error", + "errorSubtitle": "Unknown repository error", + "menu": { + "navigationLabel": "Repository Navigation", + "informationNavLink": "Information", + "historyNavLink": "Commits", + "sourcesNavLink": "Sources", + "settingsNavLink": "Settings", + "generalNavLink": "General", + "permissionsNavLink": "Permissions" + } + }, "overview": { "title": "Repositories", "subtitle": "Overview of available repositories", - "create-button": "Create" - }, - "repository-root": { - "error-title": "Error", - "error-subtitle": "Unknown repository error", - "actions-label": "Actions", - "back-label": "Back", - "navigation-label": "Navigation", - "history": "Commits", - "information": "Information", - "permissions": "Permissions", - "sources": "Sources" + "createButton": "Create Repository" }, "create": { "title": "Create Repository", "subtitle": "Create a new repository" }, - "repository-form": { - "submit": "Save" + "changesets": { + "errorTitle": "Error", + "errorSubtitle": "Could not fetch changesets", + "branchSelectorLabel": "Branches" }, - "edit-nav-link": { - "label": "Edit" - }, - "delete-nav-action": { - "label": "Delete", - "confirm-alert": { - "title": "Delete repository", - "message": "Do you really want to delete the repository?", - "submit": "Yes", - "cancel": "No" + "changeset": { + "description": "Description", + "summary": "Changeset {{id}} was committed {{time}}", + "shortSummary": "Committed {{id}} {{time}}", + "tags": "Tags", + "diffNotSupported": "Diff of changesets is not supported by the type of repository", + "author": { + "prefix": "Authored by", + "mailto": "Send mail to" + }, + "buttons": { + "details": "Details", + "sources": "Sources" } }, + "repositoryForm": { + "subtitle": "Edit Repository", + "submit": "Save" + }, "sources": { "file-tree": { "name": "Name", @@ -55,61 +72,77 @@ "branch": "Branch" }, "content": { - "downloadButton": "Download" - } - }, - "changesets": { - "error-title": "Error", - "error-subtitle": "Could not fetch changesets", - "changeset": { - "id": "ID", + "historyButton": "History", + "sourcesButton": "Sources", + "downloadButton": "Download", + "path": "Path", + "branch": "Branch", + "lastModified": "Last modified", "description": "Description", - "contact": "Contact", - "date": "Date", - "summary": "Changeset {{id}} was committed {{time}}" - }, - "author": { - "name": "Author", - "mail": "Mail" + "size": "Size" } }, - "branch-selector": { - "label": "Branches" - }, "permission": { - "error-title": "Error", - "error-subtitle": "Unknown permissions error", - "name": "User or Group", - "type": "Type", - "group-permission": "Group Permission", - "edit-permission": { - "delete-button": "Delete", - "save-button": "Save Changes" - }, - "delete-permission-button": { - "label": "Delete", - "confirm-alert": { - "title": "Delete permission", - "message": "Do you really want to delete the permission?", - "submit": "Yes", - "cancel": "No" - } - }, - "add-permission": { - "add-permission-heading": "Add new Permission", - "submit-button": "Submit", - "name-input-invalid": "Permission is not allowed to be empty! If it is not empty, your input name is invalid or it already exists!" - }, - "help": { - "groupPermissionHelpText": "States if a permission is a group permission.", - "nameHelpText": "Manage permissions for a specific user or group", - "typeHelpText": "READ = read; WRITE = read and write; OWNER = read, write and also the ability to manage the properties and permissions" + "title": "Edit Permissions", + "user": "User", + "group": "Group", + "error-title": "Error", + "error-subtitle": "Unknown permissions error", + "name": "User or group", + "role": "Role", + "permissions": "Permissions", + "group-permission": "Group Permission", + "user-permission": "User Permission", + "edit-permission": { + "delete-button": "Delete", + "save-button": "Save Changes" + }, + "advanced-button": { + "label": "Advanced" + }, + "delete-permission-button": { + "label": "Delete", + "confirm-alert": { + "title": "Delete permission", + "message": "Do you really want to delete the permission?", + "submit": "Yes", + "cancel": "No" } + }, + "add-permission": { + "add-permission-heading": "Add new Permission", + "submit-button": "Submit", + "name-input-invalid": "Permission is not allowed to be empty! If it is not empty, your input name is invalid or it already exists!" + }, + "help": { + "groupPermissionHelpText": "States if a permission is a group permission. If this is not checked, it is a user permission.", + "nameHelpText": "Manage permissions for a specific user or group.", + "roleHelpText": "READ = read; WRITE = read and write; OWNER = read, write and also the ability to manage the properties and permissions. If nothing is selected here, use the 'Advanced' Button to see detailed permissions.", + "permissionsHelpText": "Use this to specify your own set of permissions regardless of predefined roles." + }, + "autocomplete": { + "no-group-options": "No group suggestion available", + "group-placeholder": "Enter group", + "no-user-options": "No user suggestion available", + "user-placeholder": "Enter user", + "loading": "Loading..." + }, + "advanced": { + "dialog": { + "title": "Advanced Permissions", + "submit": "Submit", + "abort": "Abort" + } + } }, - "help": { - "nameHelpText": "The name of the repository. This name will be part of the repository url.", - "typeHelpText": "The type of the repository (e.g. Mercurial, Git or Subversion).", - "contactHelpText": "Email address of the person who is responsible for this repository.", - "descriptionHelpText": "A short description of the repository." + "deleteRepo": { + "subtitle": "Delete Repository", + "button": "Delete", + "confirmAlert": { + "title": "Delete repository", + "message": "Do you really want to delete the repository?", + "submit": "Yes", + "cancel": "No" + } } } diff --git a/scm-ui/public/locales/en/users.json b/scm-ui/public/locales/en/users.json index 7199cb2135..2b72d85cbc 100644 --- a/scm-ui/public/locales/en/users.json +++ b/scm-ui/public/locales/en/users.json @@ -10,61 +10,55 @@ "creationDate": "Creation Date", "lastModified": "Last Modified" }, + "validation": { + "mail-invalid": "This email is invalid", + "name-invalid": "This name is invalid", + "displayname-invalid": "This displayname is invalid" + }, + "help": { + "usernameHelpText": "Unique name of the user.", + "displayNameHelpText": "Display name of the user.", + "mailHelpText": "Email address of the user.", + "adminHelpText": "An administrator is able to create, modify and delete repositories, groups and users.", + "activeHelpText": "Activate or deactivate the user." + }, "users": { "title": "Users", - "subtitle": "Create, read, update and delete users" + "subtitle": "Create, read, update and delete users", + "createButton": "Create User" }, - "create-user-button": { - "label": "Create" + "singleUser": { + "errorTitle": "Error", + "errorSubtitle": "Unknown user error", + "menu": { + "navigationLabel": "User Navigation", + "informationNavLink": "Information", + "settingsNavLink": "Settings", + "generalNavLink": "General", + "setPasswordNavLink": "Password", + "setPermissionsNavLink": "Permissions" + } }, - "delete-user-button": { - "label": "Delete", - "confirm-alert": { + "addUser": { + "title": "Create User", + "subtitle": "Create a new user" + }, + "deleteUser": { + "subtitle": "Delete User", + "button": "Delete", + "confirmAlert": { "title": "Delete user", "message": "Do you really want to delete the user?", "submit": "Yes", "cancel": "No" } }, - "edit-user-button": { - "label": "Edit" + "singleUserPassword": { + "button": "Set password", + "setPasswordSuccessful": "Password successfully set" }, - "set-password-button": { - "label": "Set password" - }, - "user-form": { - "submit": "Submit" - }, - "add-user": { - "title": "Create User", - "subtitle": "Create a new user" - }, - "single-user": { - "error-title": "Error", - "error-subtitle": "Unknown user error", - "navigation-label": "Navigation", - "actions-label": "Actions", - "information-label": "Information", - "back-label": "Back" - }, - "validation": { - "mail-invalid": "This email is invalid", - "name-invalid": "This name is invalid", - "displayname-invalid": "This displayname is invalid", - "password-invalid": "Password has to be between 6 and 32 characters", - "passwordValidation-invalid": "Passwords have to be identical", - "validatePassword": "Confirm password" - }, - "password": { - "set-password-successful": "Password successfully set" - }, - "help": { - "usernameHelpText": "Unique name of the user.", - "displayNameHelpText": "Display name of the user.", - "mailHelpText": "Email address of the user.", - "passwordHelpText": "Plain text password of the user.", - "passwordConfirmHelpText": "Repeat the password for validation.", - "adminHelpText": "An administrator is able to create, modify and delete repositories, groups and users.", - "activeHelpText": "Activate or deactive the user." + "userForm": { + "subtitle": "Edit User", + "button": "Submit" } } diff --git a/scm-ui/src/config/components/form/AdminSettings.js b/scm-ui/src/config/components/form/AdminSettings.js index 0395ff294f..7f244d4aaf 100644 --- a/scm-ui/src/config/components/form/AdminSettings.js +++ b/scm-ui/src/config/components/form/AdminSettings.js @@ -20,34 +20,41 @@ class AdminSettings extends React.Component<Props> { return ( <div> <Subtitle subtitle={t("admin-settings.name")} /> - <AdminGroupTable - adminGroups={adminGroups} - onChange={(isValid, changedValue, name) => - this.props.onChange(isValid, changedValue, name) - } - disabled={!hasUpdatePermission} - /> - <AddEntryToTableField - addEntry={this.addGroup} - disabled={!hasUpdatePermission} - buttonLabel={t("admin-settings.add-group-button")} - fieldLabel={t("admin-settings.add-group-textfield")} - errorMessage={t("admin-settings.add-group-error")} - /> - <AdminUserTable - adminUsers={adminUsers} - onChange={(isValid, changedValue, name) => - this.props.onChange(isValid, changedValue, name) - } - disabled={!hasUpdatePermission} - /> - <AddEntryToTableField - addEntry={this.addUser} - disabled={!hasUpdatePermission} - buttonLabel={t("admin-settings.add-user-button")} - fieldLabel={t("admin-settings.add-user-textfield")} - errorMessage={t("admin-settings.add-user-error")} - /> + <div className="columns"> + <div className="column is-half"> + <AdminGroupTable + adminGroups={adminGroups} + onChange={(isValid, changedValue, name) => + this.props.onChange(isValid, changedValue, name) + } + disabled={!hasUpdatePermission} + /> + + <AddEntryToTableField + addEntry={this.addGroup} + disabled={!hasUpdatePermission} + buttonLabel={t("admin-settings.add-group-button")} + fieldLabel={t("admin-settings.add-group-textfield")} + errorMessage={t("admin-settings.add-group-error")} + /> + </div> + <div className="column is-half"> + <AdminUserTable + adminUsers={adminUsers} + onChange={(isValid, changedValue, name) => + this.props.onChange(isValid, changedValue, name) + } + disabled={!hasUpdatePermission} + /> + <AddEntryToTableField + addEntry={this.addUser} + disabled={!hasUpdatePermission} + buttonLabel={t("admin-settings.add-user-button")} + fieldLabel={t("admin-settings.add-user-textfield")} + errorMessage={t("admin-settings.add-user-error")} + /> + </div> + </div> </div> ); } diff --git a/scm-ui/src/config/components/form/BaseUrlSettings.js b/scm-ui/src/config/components/form/BaseUrlSettings.js index c53e2724a0..3fa0e67b84 100644 --- a/scm-ui/src/config/components/form/BaseUrlSettings.js +++ b/scm-ui/src/config/components/form/BaseUrlSettings.js @@ -18,20 +18,26 @@ class BaseUrlSettings extends React.Component<Props> { return ( <div> <Subtitle subtitle={t("base-url-settings.name")} /> - <Checkbox - checked={forceBaseUrl} - label={t("base-url-settings.force-base-url")} - onChange={this.handleForceBaseUrlChange} - disabled={!hasUpdatePermission} - helpText={t("help.forceBaseUrlHelpText")} - /> - <InputField - label={t("base-url-settings.base-url")} - onChange={this.handleBaseUrlChange} - value={baseUrl} - disabled={!hasUpdatePermission} - helpText={t("help.baseUrlHelpText")} - /> + <div className="columns"> + <div className="column is-half"> + <Checkbox + checked={forceBaseUrl} + label={t("base-url-settings.force-base-url")} + onChange={this.handleForceBaseUrlChange} + disabled={!hasUpdatePermission} + helpText={t("help.forceBaseUrlHelpText")} + /> + </div> + <div className="column is-half"> + <InputField + label={t("base-url-settings.base-url")} + onChange={this.handleBaseUrlChange} + value={baseUrl} + disabled={!hasUpdatePermission} + helpText={t("help.baseUrlHelpText")} + /> + </div> + </div> </div> ); } diff --git a/scm-ui/src/config/components/form/ConfigForm.js b/scm-ui/src/config/components/form/ConfigForm.js index f5bd6531b5..dc3f20c95d 100644 --- a/scm-ui/src/config/components/form/ConfigForm.js +++ b/scm-ui/src/config/components/form/ConfigForm.js @@ -23,7 +23,8 @@ type State = { error: { loginAttemptLimitTimeout: boolean, loginAttemptLimit: boolean - } + }, + changed: boolean }; class ConfigForm extends React.Component<Props, State> { @@ -59,7 +60,8 @@ class ConfigForm extends React.Component<Props, State> { error: { loginAttemptLimitTimeout: false, loginAttemptLimit: false - } + }, + changed: false }; } @@ -75,6 +77,9 @@ class ConfigForm extends React.Component<Props, State> { submit = (event: Event) => { event.preventDefault(); + this.setState({ + changed: false + }); this.props.submitForm(this.state.config); }; @@ -156,7 +161,9 @@ class ConfigForm extends React.Component<Props, State> { <SubmitButton loading={loading} label={t("config-form.submit")} - disabled={!configUpdatePermission || this.hasError()} + disabled={ + !configUpdatePermission || this.hasError() || !this.state.changed + } /> </form> ); @@ -172,7 +179,8 @@ class ConfigForm extends React.Component<Props, State> { error: { ...this.state.error, [name]: !isValid - } + }, + changed: true }); }; diff --git a/scm-ui/src/config/components/form/GeneralSettings.js b/scm-ui/src/config/components/form/GeneralSettings.js index 661cfb07c0..d2f891fd06 100644 --- a/scm-ui/src/config/components/form/GeneralSettings.js +++ b/scm-ui/src/config/components/form/GeneralSettings.js @@ -36,70 +36,98 @@ class GeneralSettings extends React.Component<Props> { return ( <div> - <InputField - label={t("general-settings.realm-description")} - onChange={this.handleRealmDescriptionChange} - value={realmDescription} - disabled={!hasUpdatePermission} - helpText={t("help.realmDescriptionHelpText")} - /> - <InputField - label={t("general-settings.date-format")} - onChange={this.handleDateFormatChange} - value={dateFormat} - disabled={!hasUpdatePermission} - helpText={t("help.dateFormatHelpText")} - /> - <InputField - label={t("general-settings.plugin-url")} - onChange={this.handlePluginUrlChange} - value={pluginUrl} - disabled={!hasUpdatePermission} - helpText={t("help.pluginRepositoryHelpText")} - /> - <InputField - label={t("general-settings.default-namespace-strategy")} - onChange={this.handleDefaultNamespaceStrategyChange} - value={defaultNamespaceStrategy} - disabled={!hasUpdatePermission} - helpText={t("help.defaultNameSpaceStrategyHelpText")} - /> - <Checkbox - checked={enabledXsrfProtection} - label={t("general-settings.enabled-xsrf-protection")} - onChange={this.handleEnabledXsrfProtectionChange} - disabled={!hasUpdatePermission} - helpText={t("help.enableXsrfProtectionHelpText")} - /> - <Checkbox - checked={enableRepositoryArchive} - label={t("general-settings.enable-repository-archive")} - onChange={this.handleEnableRepositoryArchiveChange} - disabled={!hasUpdatePermission} - helpText={t("help.enableRepositoryArchiveHelpText")} - /> - <Checkbox - checked={disableGroupingGrid} - label={t("general-settings.disable-grouping-grid")} - onChange={this.handleDisableGroupingGridChange} - disabled={!hasUpdatePermission} - helpText={t("help.disableGroupingGridHelpText")} - /> - <Checkbox - checked={anonymousAccessEnabled} - label={t("general-settings.anonymous-access-enabled")} - onChange={this.handleAnonymousAccessEnabledChange} - disabled={!hasUpdatePermission} - helpText={t("help.allowAnonymousAccessHelpText")} - /> - <Checkbox - checked={skipFailedAuthenticators} - label={t("general-settings.skip-failed-authenticators")} - onChange={this.handleSkipFailedAuthenticatorsChange} - disabled={!hasUpdatePermission} - helpText={t("help.skipFailedAuthenticatorsHelpText")} - /> - </div> + <div className="columns"> + <div className="column is-half"> + <InputField + label={t("general-settings.realm-description")} + onChange={this.handleRealmDescriptionChange} + value={realmDescription} + disabled={!hasUpdatePermission} + helpText={t("help.realmDescriptionHelpText")} + /> + </div> + <div className="column is-half"> + <InputField + label={t("general-settings.date-format")} + onChange={this.handleDateFormatChange} + value={dateFormat} + disabled={!hasUpdatePermission} + helpText={t("help.dateFormatHelpText")} + /> + </div> + </div> + <div className="columns"> + <div className="column is-half"> + <InputField + label={t("general-settings.plugin-url")} + onChange={this.handlePluginUrlChange} + value={pluginUrl} + disabled={!hasUpdatePermission} + helpText={t("help.pluginRepositoryHelpText")} + /> + </div> + <div className="column is-half"> + <InputField + label={t("general-settings.default-namespace-strategy")} + onChange={this.handleDefaultNamespaceStrategyChange} + value={defaultNamespaceStrategy} + disabled={!hasUpdatePermission} + helpText={t("help.defaultNameSpaceStrategyHelpText")} + /> + </div> + </div> + <div className="columns"> + <div className="column is-half"> + <Checkbox + checked={enabledXsrfProtection} + label={t("general-settings.enabled-xsrf-protection")} + onChange={this.handleEnabledXsrfProtectionChange} + disabled={!hasUpdatePermission} + helpText={t("help.enableXsrfProtectionHelpText")} + /> + </div> + <div className="column is-half"> + <Checkbox + checked={enableRepositoryArchive} + label={t("general-settings.enable-repository-archive")} + onChange={this.handleEnableRepositoryArchiveChange} + disabled={!hasUpdatePermission} + helpText={t("help.enableRepositoryArchiveHelpText")} + /> + </div> + </div> + <div className="columns"> + <div className="column is-half"> + <Checkbox + checked={disableGroupingGrid} + label={t("general-settings.disable-grouping-grid")} + onChange={this.handleDisableGroupingGridChange} + disabled={!hasUpdatePermission} + helpText={t("help.disableGroupingGridHelpText")} + /> + </div> + <div className="column is-half"> + <Checkbox + checked={anonymousAccessEnabled} + label={t("general-settings.anonymous-access-enabled")} + onChange={this.handleAnonymousAccessEnabledChange} + disabled={!hasUpdatePermission} + helpText={t("help.allowAnonymousAccessHelpText")} + /> + </div> + </div> + <div className="columns"> + <div className="column is-half"> + <Checkbox + checked={skipFailedAuthenticators} + label={t("general-settings.skip-failed-authenticators")} + onChange={this.handleSkipFailedAuthenticatorsChange} + disabled={!hasUpdatePermission} + helpText={t("help.skipFailedAuthenticatorsHelpText")} + /> + </div> + </div> + </div> ); } diff --git a/scm-ui/src/config/components/form/LoginAttempt.js b/scm-ui/src/config/components/form/LoginAttempt.js index ca16761ba7..b0ca580283 100644 --- a/scm-ui/src/config/components/form/LoginAttempt.js +++ b/scm-ui/src/config/components/form/LoginAttempt.js @@ -40,24 +40,30 @@ class LoginAttempt extends React.Component<Props, State> { return ( <div> <Subtitle subtitle={t("login-attempt.name")} /> - <InputField - label={t("login-attempt.login-attempt-limit")} - onChange={this.handleLoginAttemptLimitChange} - value={loginAttemptLimit} - disabled={!hasUpdatePermission} - validationError={this.state.loginAttemptLimitError} - errorMessage={t("validation.login-attempt-limit-invalid")} - helpText={t("help.loginAttemptLimitHelpText")} - /> - <InputField - label={t("login-attempt.login-attempt-limit-timeout")} - onChange={this.handleLoginAttemptLimitTimeoutChange} - value={loginAttemptLimitTimeout} - disabled={!hasUpdatePermission} - validationError={this.state.loginAttemptLimitTimeoutError} - errorMessage={t("validation.login-attempt-limit-timeout-invalid")} - helpText={t("help.loginAttemptLimitTimeoutHelpText")} - /> + <div className="columns"> + <div className="column is-half"> + <InputField + label={t("login-attempt.login-attempt-limit")} + onChange={this.handleLoginAttemptLimitChange} + value={loginAttemptLimit} + disabled={!hasUpdatePermission} + validationError={this.state.loginAttemptLimitError} + errorMessage={t("validation.login-attempt-limit-invalid")} + helpText={t("help.loginAttemptLimitHelpText")} + /> + </div> + <div className="column is-half"> + <InputField + label={t("login-attempt.login-attempt-limit-timeout")} + onChange={this.handleLoginAttemptLimitTimeoutChange} + value={loginAttemptLimitTimeout} + disabled={!hasUpdatePermission} + validationError={this.state.loginAttemptLimitTimeoutError} + errorMessage={t("validation.login-attempt-limit-timeout-invalid")} + helpText={t("help.loginAttemptLimitTimeoutHelpText")} + /> + </div> + </div> </div> ); } diff --git a/scm-ui/src/config/components/form/ProxySettings.js b/scm-ui/src/config/components/form/ProxySettings.js index 7656bc270c..ed92d74b12 100644 --- a/scm-ui/src/config/components/form/ProxySettings.js +++ b/scm-ui/src/config/components/form/ProxySettings.js @@ -37,56 +37,76 @@ class ProxySettings extends React.Component<Props> { return ( <div> <Subtitle subtitle={t("proxy-settings.name")} /> - <Checkbox - checked={enableProxy} - label={t("proxy-settings.enable-proxy")} - onChange={this.handleEnableProxyChange} - disabled={!hasUpdatePermission} - helpText={t("help.enableProxyHelpText")} - /> - <InputField - label={t("proxy-settings.proxy-password")} - onChange={this.handleProxyPasswordChange} - value={proxyPassword} - type="password" - disabled={!enableProxy || !hasUpdatePermission} - helpText={t("help.proxyPasswordHelpText")} - /> - <InputField - label={t("proxy-settings.proxy-port")} - value={proxyPort} - onChange={this.handleProxyPortChange} - disabled={!enableProxy || !hasUpdatePermission} - helpText={t("help.proxyPortHelpText")} - /> - <InputField - label={t("proxy-settings.proxy-server")} - value={proxyServer} - onChange={this.handleProxyServerChange} - disabled={!enableProxy || !hasUpdatePermission} - helpText={t("help.proxyServerHelpText")} - /> - <InputField - label={t("proxy-settings.proxy-user")} - value={proxyUser} - onChange={this.handleProxyUserChange} - disabled={!enableProxy || !hasUpdatePermission} - helpText={t("help.proxyUserHelpText")} - /> - <ProxyExcludesTable - proxyExcludes={proxyExcludes} - onChange={(isValid, changedValue, name) => - this.props.onChange(isValid, changedValue, name) - } - disabled={!enableProxy || !hasUpdatePermission} - /> - <AddEntryToTableField - addEntry={this.addProxyExclude} - disabled={!enableProxy || !hasUpdatePermission} - buttonLabel={t("proxy-settings.add-proxy-exclude-button")} - fieldLabel={t("proxy-settings.add-proxy-exclude-textfield")} - errorMessage={t("proxy-settings.add-proxy-exclude-error")} - /> + <div className="columns"> + <div className="column is-full"> + <Checkbox + checked={enableProxy} + label={t("proxy-settings.enable-proxy")} + onChange={this.handleEnableProxyChange} + disabled={!hasUpdatePermission} + helpText={t("help.enableProxyHelpText")} + /> + </div> + </div> + <div className="columns"> + <div className="column is-half"> + <InputField + label={t("proxy-settings.proxy-password")} + onChange={this.handleProxyPasswordChange} + value={proxyPassword} + type="password" + disabled={!enableProxy || !hasUpdatePermission} + helpText={t("help.proxyPasswordHelpText")} + /> + </div> + <div className="column is-half"> + <InputField + label={t("proxy-settings.proxy-port")} + value={proxyPort} + onChange={this.handleProxyPortChange} + disabled={!enableProxy || !hasUpdatePermission} + helpText={t("help.proxyPortHelpText")} + /> + </div> + </div> + <div className="columns"> + <div className="column is-half"> + <InputField + label={t("proxy-settings.proxy-server")} + value={proxyServer} + onChange={this.handleProxyServerChange} + disabled={!enableProxy || !hasUpdatePermission} + helpText={t("help.proxyServerHelpText")} + /> + </div> + <div className="column is-half"> + <InputField + label={t("proxy-settings.proxy-user")} + value={proxyUser} + onChange={this.handleProxyUserChange} + disabled={!enableProxy || !hasUpdatePermission} + helpText={t("help.proxyUserHelpText")} + /> + </div> + </div> + <div className="columns"> + <div className="column is-full"> + <ProxyExcludesTable + proxyExcludes={proxyExcludes} + onChange={(isValid, changedValue, name) => + this.props.onChange(isValid, changedValue, name) + } + disabled={!enableProxy || !hasUpdatePermission} + /> + <AddEntryToTableField + addEntry={this.addProxyExclude} + disabled={!enableProxy || !hasUpdatePermission} + buttonLabel={t("proxy-settings.add-proxy-exclude-button")} + fieldLabel={t("proxy-settings.add-proxy-exclude-textfield")} + errorMessage={t("proxy-settings.add-proxy-exclude-error")} + /> + </div> + </div> </div> ); } diff --git a/scm-ui/src/config/containers/Config.js b/scm-ui/src/config/containers/Config.js index 6d935fe33a..04de525c95 100644 --- a/scm-ui/src/config/containers/Config.js +++ b/scm-ui/src/config/containers/Config.js @@ -8,8 +8,8 @@ import type { Links } from "@scm-manager/ui-types"; import { Page, Navigation, NavLink, Section } from "@scm-manager/ui-components"; import GlobalConfig from "./GlobalConfig"; import type { History } from "history"; -import {connect} from "react-redux"; -import {compose} from "redux"; +import { connect } from "react-redux"; +import { compose } from "redux"; import { getLinks } from "../../modules/indexResource"; type Props = { @@ -47,21 +47,23 @@ class Config extends React.Component<Props> { <div className="columns"> <div className="column is-three-quarters"> <Route path={url} exact component={GlobalConfig} /> - <ExtensionPoint name="config.route" - props={extensionProps} - renderAll={true} + <ExtensionPoint + name="config.route" + props={extensionProps} + renderAll={true} /> </div> - <div className="column"> + <div className="column is-one-quarter"> <Navigation> - <Section label={t("config.navigation-title")}> + <Section label={t("config.navigationLabel")}> <NavLink to={`${url}`} - label={t("global-config.navigation-label")} + label={t("config.globalConfigurationNavLink")} /> - <ExtensionPoint name="config.navigation" - props={extensionProps} - renderAll={true} + <ExtensionPoint + name="config.navigation" + props={extensionProps} + renderAll={true} /> </Section> </Navigation> @@ -83,4 +85,3 @@ export default compose( connect(mapStateToProps), translate("config") )(Config); - diff --git a/scm-ui/src/config/containers/GlobalConfig.js b/scm-ui/src/config/containers/GlobalConfig.js index 6046aa4a09..eac8e27bee 100644 --- a/scm-ui/src/config/containers/GlobalConfig.js +++ b/scm-ui/src/config/containers/GlobalConfig.js @@ -34,7 +34,19 @@ type Props = { t: string => string }; -class GlobalConfig extends React.Component<Props> { +type State = { + configChanged: boolean +}; + +class GlobalConfig extends React.Component<Props, State> { + constructor(props: Props) { + super(props); + + this.state = { + configChanged: false + }; + } + componentDidMount() { this.props.configReset(); this.props.fetchConfig(this.props.configLink); @@ -42,6 +54,22 @@ class GlobalConfig extends React.Component<Props> { modifyConfig = (config: Config) => { this.props.modifyConfig(config); + this.setState({ configChanged: true }); + }; + + renderConfigChangedNotification = () => { + if (this.state.configChanged) { + return ( + <div className="notification is-primary"> + <button + className="delete" + onClick={() => this.setState({ configChanged: false })} + /> + {this.props.t("config-form.submit-success-notification")} + </div> + ); + } + return null; }; render() { @@ -50,8 +78,8 @@ class GlobalConfig extends React.Component<Props> { if (error) { return ( <ErrorPage - title={t("global-config.error-title")} - subtitle={t("global-config.error-subtitle")} + title={t("config.errorTitle")} + subtitle={t("config.errorSubtitle")} error={error} configUpdatePermission={configUpdatePermission} /> @@ -63,7 +91,8 @@ class GlobalConfig extends React.Component<Props> { return ( <div> - <Title title={t("global-config.title")} /> + <Title title={t("config.title")} /> + {this.renderConfigChangedNotification()} <ConfigForm submitForm={config => this.modifyConfig(config)} config={config} diff --git a/scm-ui/src/config/modules/config.js b/scm-ui/src/config/modules/config.js index 352afefb70..2d14fcfea6 100644 --- a/scm-ui/src/config/modules/config.js +++ b/scm-ui/src/config/modules/config.js @@ -32,9 +32,8 @@ export function fetchConfig(link: string) { .then(data => { dispatch(fetchConfigSuccess(data)); }) - .catch(cause => { - const error = new Error(`could not fetch config: ${cause.message}`); - dispatch(fetchConfigFailure(error)); + .catch(err => { + dispatch(fetchConfigFailure(err)); }); }; } @@ -73,13 +72,8 @@ export function modifyConfig(config: Config, callback?: () => void) { callback(); } }) - .catch(cause => { - dispatch( - modifyConfigFailure( - config, - new Error(`could not modify config: ${cause.message}`) - ) - ); + .catch(err => { + dispatch(modifyConfigFailure(config, err)); }); }; } diff --git a/scm-ui/src/containers/App.js b/scm-ui/src/containers/App.js index 50fc805eb2..1e1387fd70 100644 --- a/scm-ui/src/containers/App.js +++ b/scm-ui/src/containers/App.js @@ -19,15 +19,11 @@ import { Footer, Header } from "@scm-manager/ui-components"; -import type { Me } from "@scm-manager/ui-types"; +import type { Links, Me } from "@scm-manager/ui-types"; import { - getConfigLink, getFetchIndexResourcesFailure, - getGroupsLink, - getLogoutLink, + getLinks, getMeLink, - getRepositoriesLink, - getUsersLink, isFetchIndexResourcesPending } from "../modules/indexResource"; @@ -36,11 +32,7 @@ type Props = { authenticated: boolean, error: Error, loading: boolean, - repositoriesLink: string, - usersLink: string, - groupsLink: string, - configLink: string, - logoutLink: string, + links: Links, meLink: string, // dispatcher functions @@ -63,22 +55,14 @@ class App extends Component<Props> { loading, error, authenticated, - t, - repositoriesLink, - usersLink, - groupsLink, - configLink, - logoutLink + links, + t } = this.props; let content; const navigation = authenticated ? ( <PrimaryNavigation - repositoriesLink={repositoriesLink} - usersLink={usersLink} - groupsLink={groupsLink} - configLink={configLink} - logoutLink={logoutLink} + links={links} /> ) : ( "" @@ -95,7 +79,7 @@ class App extends Component<Props> { /> ); } else { - content = <Main authenticated={authenticated} />; + content = <Main authenticated={authenticated} links={links} />; } return ( <div className="App"> @@ -120,22 +104,14 @@ const mapStateToProps = state => { isFetchMePending(state) || isFetchIndexResourcesPending(state); const error = getFetchMeFailure(state) || getFetchIndexResourcesFailure(state); - const repositoriesLink = getRepositoriesLink(state); - const usersLink = getUsersLink(state); - const groupsLink = getGroupsLink(state); - const configLink = getConfigLink(state); - const logoutLink = getLogoutLink(state); + const links = getLinks(state); const meLink = getMeLink(state); return { authenticated, me, loading, error, - repositoriesLink, - usersLink, - groupsLink, - configLink, - logoutLink, + links, meLink }; }; diff --git a/scm-ui/src/containers/ChangeUserPassword.js b/scm-ui/src/containers/ChangeUserPassword.js new file mode 100644 index 0000000000..28a7af588a --- /dev/null +++ b/scm-ui/src/containers/ChangeUserPassword.js @@ -0,0 +1,147 @@ +// @flow +import React from "react"; +import { + ErrorNotification, + InputField, + Notification, + PasswordConfirmation, + SubmitButton +} from "@scm-manager/ui-components"; +import { translate } from "react-i18next"; +import type { Me } from "@scm-manager/ui-types"; +import { changePassword } from "../modules/changePassword"; + +type Props = { + me: Me, + t: string => string +}; + +type State = { + oldPassword: string, + password: string, + loading: boolean, + error?: Error, + passwordChanged: boolean, + passwordValid: boolean +}; + +class ChangeUserPassword extends React.Component<Props, State> { + constructor(props: Props) { + super(props); + + this.state = { + oldPassword: "", + password: "", + loading: false, + passwordConfirmationError: false, + validatePasswordError: false, + validatePassword: "", + passwordChanged: false, + passwordValid: false + }; + } + + setLoadingState = () => { + this.setState({ + ...this.state, + loading: true + }); + }; + + setErrorState = (error: Error) => { + this.setState({ + ...this.state, + error: error, + loading: false + }); + }; + + setSuccessfulState = () => { + this.setState({ + ...this.state, + loading: false, + passwordChanged: true, + oldPassword: "", + password: "" + }); + }; + + submit = (event: Event) => { + event.preventDefault(); + if (this.state.password) { + const { oldPassword, password } = this.state; + this.setLoadingState(); + changePassword(this.props.me._links.password.href, oldPassword, password) + .then(result => { + if (result.error) { + this.setErrorState(result.error); + } else { + this.setSuccessfulState(); + } + }) + .catch(err => { + this.setErrorState(err); + }); + } + }; + + isValid = () => { + return this.state.oldPassword && this.state.passwordValid; + }; + + render() { + const { t } = this.props; + const { loading, passwordChanged, error } = this.state; + + let message = null; + + if (passwordChanged) { + message = ( + <Notification + type={"success"} + children={t("password.changedSuccessfully")} + onClose={() => this.onClose()} + /> + ); + } else if (error) { + message = <ErrorNotification error={error} />; + } + + return ( + <form onSubmit={this.submit}> + {message} + <InputField + label={t("password.currentPassword")} + type="password" + onChange={oldPassword => + this.setState({ ...this.state, oldPassword }) + } + value={this.state.oldPassword ? this.state.oldPassword : ""} + helpText={t("password.currentPasswordHelpText")} + /> + <PasswordConfirmation + passwordChanged={this.passwordChanged} + key={this.state.passwordChanged ? "changed" : "unchanged"} + /> + <SubmitButton + disabled={!this.isValid()} + loading={loading} + label={t("password.submit")} + /> + </form> + ); + } + + passwordChanged = (password: string, passwordValid: boolean) => { + this.setState({ ...this.state, password, passwordValid: (!!password && passwordValid) }); + }; + + onClose = () => { + this.setState({ + ...this.state, + passwordChanged: false + }); + }; +} + +export default translate("commons")(ChangeUserPassword); diff --git a/scm-ui/src/containers/Index.js b/scm-ui/src/containers/Index.js index 71bd6b35c2..1878195ece 100644 --- a/scm-ui/src/containers/Index.js +++ b/scm-ui/src/containers/Index.js @@ -14,6 +14,7 @@ import { } from "../modules/indexResource"; import PluginLoader from "./PluginLoader"; import type { IndexResources } from "@scm-manager/ui-types"; +import ScrollToTop from "./ScrollToTop"; type Props = { error: Error, @@ -32,7 +33,6 @@ type State = { }; class Index extends Component<Props, State> { - constructor(props: Props) { super(props); this.state = { @@ -66,9 +66,14 @@ class Index extends Component<Props, State> { return <Loading />; } else { return ( - <PluginLoader loaded={ pluginsLoaded } callback={ this.pluginLoaderCallback }> - <App /> - </PluginLoader> + <ScrollToTop> + <PluginLoader + loaded={pluginsLoaded} + callback={this.pluginLoaderCallback} + > + <App /> + </PluginLoader> + </ScrollToTop> ); } } diff --git a/scm-ui/src/containers/Login.js b/scm-ui/src/containers/Login.js index 8a06478045..4ae9b5a622 100644 --- a/scm-ui/src/containers/Login.js +++ b/scm-ui/src/containers/Login.js @@ -15,7 +15,7 @@ import { InputField, SubmitButton, ErrorNotification, - Image + Image, UnauthorizedError } from "@scm-manager/ui-components"; import classNames from "classnames"; import { getLoginLink } from "../modules/indexResource"; @@ -92,13 +92,22 @@ class Login extends React.Component<Props, State> { return !this.isValid(); } + areCredentialsInvalid() { + const { t, error } = this.props; + if (error instanceof UnauthorizedError) { + return new Error(t("error-notification.wrong-login-credentials")); + } else { + return error; + } + } + renderRedirect = () => { const { from } = this.props.location.state || { from: { pathname: "/" } }; return <Redirect to={from} />; }; render() { - const { authenticated, loading, error, t, classes } = this.props; + const { authenticated, loading, t, classes } = this.props; if (authenticated) { return this.renderRedirect(); @@ -119,7 +128,7 @@ class Login extends React.Component<Props, State> { alt={t("login.logo-alt")} /> </figure> - <ErrorNotification error={error} /> + <ErrorNotification error={this.areCredentialsInvalid()} /> <form onSubmit={this.handleSubmit}> <InputField placeholder={t("login.username-placeholder")} @@ -133,7 +142,6 @@ class Login extends React.Component<Props, State> { /> <SubmitButton label={t("login.submit")} - disabled={this.isInValid()} fullWidth={true} loading={loading} /> diff --git a/scm-ui/src/containers/Main.js b/scm-ui/src/containers/Main.js index 1cfa379e74..e963fb00f9 100644 --- a/scm-ui/src/containers/Main.js +++ b/scm-ui/src/containers/Main.js @@ -2,6 +2,7 @@ import React from "react"; import { Redirect, Route, Switch, withRouter } from "react-router-dom"; +import type {Links} from "@scm-manager/ui-types"; import Overview from "../repos/containers/Overview"; import Users from "../users/containers/Users"; @@ -9,6 +10,8 @@ import Login from "../containers/Login"; import Logout from "../containers/Logout"; import { ProtectedRoute } from "@scm-manager/ui-components"; +import {binder, ExtensionPoint } from "@scm-manager/ui-extensions"; + import AddUser from "../users/containers/AddUser"; import SingleUser from "../users/containers/SingleUser"; import RepositoryRoot from "../repos/containers/RepositoryRoot"; @@ -22,16 +25,22 @@ import Config from "../config/containers/Config"; import Profile from "./Profile"; type Props = { - authenticated?: boolean + authenticated?: boolean, + links: Links }; class Main extends React.Component<Props> { render() { - const { authenticated } = this.props; + const { authenticated, links } = this.props; + const redirectUrlFactory = binder.getExtension("main.redirect", this.props); + let url ="/repos"; + if (redirectUrlFactory){ + url = redirectUrlFactory(this.props); + } return ( <div className="main"> <Switch> - <Redirect exact path="/" to="/repos" /> + <Redirect exact path="/" to={url}/> <Route exact path="/login" component={Login} /> <Route path="/logout" component={Logout} /> <ProtectedRoute @@ -79,6 +88,7 @@ class Main extends React.Component<Props> { path="/user/:name" component={SingleUser} /> + <ProtectedRoute exact path="/groups" @@ -107,11 +117,16 @@ class Main extends React.Component<Props> { authenticated={authenticated} /> <ProtectedRoute - exact path="/me" component={Profile} authenticated={authenticated} /> + + <ExtensionPoint + name="main.route" + renderAll={true} + props={{authenticated, links}} + /> </Switch> </div> ); diff --git a/scm-ui/src/containers/PluginLoader.js b/scm-ui/src/containers/PluginLoader.js index 308f532270..599ffcb67c 100644 --- a/scm-ui/src/containers/PluginLoader.js +++ b/scm-ui/src/containers/PluginLoader.js @@ -34,7 +34,7 @@ class PluginLoader extends React.Component<Props, State> { this.setState({ message: "loading plugin information" }); - + this.getPlugins(this.props.link); } } @@ -55,12 +55,19 @@ class PluginLoader extends React.Component<Props, State> { }); const promises = []; - for (let plugin of plugins) { + const sortedPlugins = plugins.sort(comparePluginsByName); + for (let plugin of sortedPlugins) { promises.push(this.loadPlugin(plugin)); } - return Promise.all(promises); + return promises.reduce((chain, current) => { + return chain.then(chainResults => { + return current.then(currentResult => [...chainResults, currentResult]) + } + ); + }, Promise.resolve([])); }; + loadPlugin = (plugin: Plugin) => { this.setState({ message: `loading ${plugin.name}` @@ -94,7 +101,15 @@ class PluginLoader extends React.Component<Props, State> { return <Loading message={message} />; } } - +const comparePluginsByName = (a: Plugin, b: Plugin) => { + if (a.name < b.name) { + return -1; + } + if (a.name > b.name) { + return 1; + } + return 0; +}; const mapStateToProps = state => { const link = getUiPluginsLink(state); return { diff --git a/scm-ui/src/containers/Profile.js b/scm-ui/src/containers/Profile.js index adb1ba5d6c..e578a15b54 100644 --- a/scm-ui/src/containers/Profile.js +++ b/scm-ui/src/containers/Profile.js @@ -2,82 +2,106 @@ import React from "react"; -import { - Page, - Navigation, - Section, - MailLink -} from "../../../scm-ui-components/packages/ui-components/src/index"; -import { NavLink } from "react-router-dom"; +import { Route, withRouter } from "react-router-dom"; import { getMe } from "../modules/auth"; import { compose } from "redux"; import { connect } from "react-redux"; import { translate } from "react-i18next"; -import type { Me } from "../../../scm-ui-components/packages/ui-types/src/index"; -import AvatarWrapper from "../repos/components/changesets/AvatarWrapper"; -import { ErrorPage } from "@scm-manager/ui-components"; +import type { Me } from "@scm-manager/ui-types"; +import { + ErrorPage, + Page, + Navigation, + SubNavigation, + Section, + NavLink +} from "@scm-manager/ui-components"; +import ChangeUserPassword from "./ChangeUserPassword"; +import ProfileInfo from "./ProfileInfo"; +import { ExtensionPoint } from "@scm-manager/ui-extensions"; type Props = { me: Me, // Context props - t: string => string + t: string => string, + match: any }; type State = {}; class Profile extends React.Component<Props, State> { + stripEndingSlash = (url: string) => { + if (url.endsWith("/")) { + return url.substring(0, url.length - 2); + } + return url; + }; + + matchedUrl = () => { + return this.stripEndingSlash(this.props.match.url); + }; + render() { + const url = this.matchedUrl(); + const { me, t } = this.props; - if (me) { + if (!me) { return ( <ErrorPage title={t("profile.error-title")} subtitle={t("profile.error-subtitle")} - error={{ name: "Error", message: "'me' is undefined" }} + error={{ + name: t("profile.error"), + message: t("profile.error-message") + }} /> ); } + + const extensionProps = { + me, + url + }; + return ( <Page title={me.displayName}> <div className="columns"> - <AvatarWrapper> - <div> - <figure className="media-left"> - <p className="image is-64x64"> - { - // TODO: add avatar - } - </p> - </figure> - </div> - </AvatarWrapper> - <div className="column is-two-quarters"> - <table className="table"> - <tbody> - <tr> - <td>{t("profile.username")}</td> - <td>{me.name}</td> - </tr> - <tr> - <td>{t("profile.displayName")}</td> - <td>{me.displayName}</td> - </tr> - <tr> - <td>{t("profile.mail")}</td> - <td> - <MailLink address={me.mail} /> - </td> - </tr> - </tbody> - </table> + <div className="column is-three-quarters"> + <Route path={url} exact render={() => <ProfileInfo me={me} />} /> + <Route + path={`${url}/settings/password`} + render={() => <ChangeUserPassword me={me} />} + /> + <ExtensionPoint + name="profile.route" + props={extensionProps} + renderAll={true} + /> </div> - <div className="column is-one-quarter"> + <div className="column"> <Navigation> - <Section label={t("profile.actions-label")} /> - <NavLink to={"me/password"}> - {t("profile.change-password")} - </NavLink> + <Section label={t("profile.navigationLabel")}> + <NavLink + to={`${url}`} + icon="fas fa-info-circle" + label={t("profile.informationNavLink")} + /> + <SubNavigation + to={`${url}/settings/password`} + label={t("profile.settingsNavLink")} + > + <NavLink + to={`${url}/settings/password`} + label={t("profile.changePasswordNavLink")} + /> + <ExtensionPoint + name="profile.setting" + props={extensionProps} + renderAll={true} + /> + </SubNavigation> + </Section> </Navigation> </div> </div> @@ -94,5 +118,6 @@ const mapStateToProps = state => { export default compose( translate("commons"), - connect(mapStateToProps) + connect(mapStateToProps), + withRouter )(Profile); diff --git a/scm-ui/src/containers/ProfileInfo.js b/scm-ui/src/containers/ProfileInfo.js new file mode 100644 index 0000000000..ab8eacae0e --- /dev/null +++ b/scm-ui/src/containers/ProfileInfo.js @@ -0,0 +1,95 @@ +// @flow +import React from "react"; +import type { Me } from "@scm-manager/ui-types"; +import { + MailLink, + AvatarWrapper, + AvatarImage +} from "@scm-manager/ui-components"; +import { compose } from "redux"; +import { translate } from "react-i18next"; +import injectSheet from "react-jss"; + +type Props = { + me: Me, + + // Context props + classes: any, + t: string => string +}; + +const styles = { + spacing: { + padding: "0 !important" + } +}; + +class ProfileInfo extends React.Component<Props> { + render() { + const { me, t } = this.props; + return ( + <div className="media"> + <AvatarWrapper> + <figure className="media-left"> + <p className="image is-64x64"> + <AvatarImage person={me} /> + </p> + </figure> + </AvatarWrapper> + <div className="media-content"> + <table className="table content"> + <tbody> + <tr> + <th> + {t("profile.username")} + </th> + <td>{me.name}</td> + </tr> + <tr> + <th> + {t("profile.displayName")} + </th> + <td>{me.displayName}</td> + </tr> + <tr> + <th> + {t("profile.mail")} + </th> + <td> + <MailLink address={me.mail} /> + </td> + </tr> + {this.renderGroups()} + </tbody> + </table> + </div> + </div> + ); + } + + renderGroups() { + const { me, t, classes } = this.props; + + let groups = null; + if (me.groups.length > 0) { + groups = ( + <tr> + <th>{t("profile.groups")}</th> + <td className={classes.spacing}> + <ul> + {me.groups.map(group => { + return <li>{group}</li>; + })} + </ul> + </td> + </tr> + ); + } + return groups; + } +} + +export default compose( + injectSheet(styles), + translate("commons") +)(ProfileInfo); diff --git a/scm-ui/src/containers/ScrollToTop.js b/scm-ui/src/containers/ScrollToTop.js new file mode 100644 index 0000000000..d48ea6531a --- /dev/null +++ b/scm-ui/src/containers/ScrollToTop.js @@ -0,0 +1,23 @@ +// @flow +import React from "react"; +import { withRouter } from "react-router-dom"; + + +type Props = { + location: any, + children: any +} + +class ScrollToTop extends React.Component<Props> { + componentDidUpdate(prevProps) { + if (this.props.location.pathname !== prevProps.location.pathname) { + window.scrollTo(0, 0); + } + } + + render() { + return this.props.children; + } +} + +export default withRouter(ScrollToTop); diff --git a/scm-ui/src/groups/components/GroupForm.js b/scm-ui/src/groups/components/GroupForm.js index 4958fbf0fa..8693cb9b47 100644 --- a/scm-ui/src/groups/components/GroupForm.js +++ b/scm-ui/src/groups/components/GroupForm.js @@ -2,21 +2,24 @@ import React from "react"; import { translate } from "react-i18next"; import { + Subtitle, + AutocompleteAddEntryToTableField, + LabelWithHelpIcon, + MemberNameTable, InputField, SubmitButton, - Textarea, - AddEntryToTableField + Textarea } from "@scm-manager/ui-components"; -import type { Group } from "@scm-manager/ui-types"; +import type { Group, SelectValue } from "@scm-manager/ui-types"; import * as validator from "./groupValidation"; -import MemberNameTable from "./MemberNameTable"; type Props = { t: string => string, submitForm: Group => void, loading?: boolean, - group?: Group + group?: Group, + loadUserSuggestions: string => any }; type State = { @@ -69,50 +72,67 @@ class GroupForm extends React.Component<Props, State> { }; render() { - const { t, loading } = this.props; - const group = this.state.group; + const { loading, t } = this.props; + const { group } = this.state; let nameField = null; + let subtitle = null; if (!this.props.group) { + // create new group nameField = ( <InputField label={t("group.name")} - errorMessage={t("group-form.name-error")} + errorMessage={t("groupForm.nameError")} onChange={this.handleGroupNameChange} value={group.name} validationError={this.state.nameValidationError} - helpText={t("group-form.help.nameHelpText")} + helpText={t("groupForm.help.nameHelpText")} /> ); + } else { + // edit existing group + subtitle = <Subtitle subtitle={t("groupForm.subtitle")} />; } return ( - <form onSubmit={this.submit}> - {nameField} - <Textarea - label={t("group.description")} - errorMessage={t("group-form.description-error")} - onChange={this.handleDescriptionChange} - value={group.description} - validationError={false} - helpText={t("group-form.help.descriptionHelpText")} - /> - <MemberNameTable - members={this.state.group.members} - memberListChanged={this.memberListChanged} - /> - <AddEntryToTableField - addEntry={this.addMember} - disabled={false} - buttonLabel={t("add-member-button.label")} - fieldLabel={t("add-member-textfield.label")} - errorMessage={t("add-member-textfield.error")} - /> - <SubmitButton - disabled={!this.isValid()} - label={t("group-form.submit")} - loading={loading} - /> - </form> + <> + {subtitle} + <form onSubmit={this.submit}> + {nameField} + <Textarea + label={t("group.description")} + errorMessage={t("groupForm.descriptionError")} + onChange={this.handleDescriptionChange} + value={group.description} + validationError={false} + helpText={t("groupForm.help.descriptionHelpText")} + /> + <LabelWithHelpIcon + label={t("group.members")} + helpText={t("groupForm.help.memberHelpText")} + /> + <MemberNameTable + members={group.members} + memberListChanged={this.memberListChanged} + /> + + <AutocompleteAddEntryToTableField + addEntry={this.addMember} + disabled={false} + buttonLabel={t("add-member-button.label")} + fieldLabel={t("add-member-textfield.label")} + errorMessage={t("add-member-textfield.error")} + loadSuggestions={this.props.loadUserSuggestions} + placeholder={t("add-member-autocomplete.placeholder")} + loadingMessage={t("add-member-autocomplete.loading")} + noOptionsMessage={t("add-member-autocomplete.no-options")} + /> + <SubmitButton + disabled={!this.isValid()} + label={t("groupForm.submit")} + loading={loading} + /> + </form> + </> ); } @@ -126,8 +146,8 @@ class GroupForm extends React.Component<Props, State> { }); }; - addMember = (membername: string) => { - if (this.isMember(membername)) { + addMember = (value: SelectValue) => { + if (this.isMember(value.value.id)) { return; } @@ -135,7 +155,7 @@ class GroupForm extends React.Component<Props, State> { ...this.state, group: { ...this.state.group, - members: [...this.state.group.members, membername] + members: [...this.state.group.members, value.value.id] } }); }; diff --git a/scm-ui/src/groups/components/navLinks/DeleteGroupNavLink.js b/scm-ui/src/groups/components/navLinks/DeleteGroupNavLink.js deleted file mode 100644 index 45bbdd3026..0000000000 --- a/scm-ui/src/groups/components/navLinks/DeleteGroupNavLink.js +++ /dev/null @@ -1,56 +0,0 @@ -// @flow -import React from "react"; -import { translate } from "react-i18next"; -import type { Group } from "@scm-manager/ui-types"; -import { NavAction, confirmAlert } from "@scm-manager/ui-components"; - -type Props = { - group: Group, - confirmDialog?: boolean, - t: string => string, - deleteGroup: (group: Group) => void -}; - -export class DeleteGroupNavLink extends React.Component<Props> { - static defaultProps = { - confirmDialog: true - }; - - deleteGroup = () => { - this.props.deleteGroup(this.props.group); - }; - - confirmDelete = () => { - const { t } = this.props; - confirmAlert({ - title: t("delete-group-button.confirm-alert.title"), - message: t("delete-group-button.confirm-alert.message"), - buttons: [ - { - label: t("delete-group-button.confirm-alert.submit"), - onClick: () => this.deleteGroup() - }, - { - label: t("delete-group-button.confirm-alert.cancel"), - onClick: () => null - } - ] - }); - }; - - isDeletable = () => { - return this.props.group._links.delete; - }; - - render() { - const { confirmDialog, t } = this.props; - const action = confirmDialog ? this.confirmDelete : this.deleteGroup; - - if (!this.isDeletable()) { - return null; - } - return <NavAction label={t("delete-group-button.label")} action={action} />; - } -} - -export default translate("groups")(DeleteGroupNavLink); diff --git a/scm-ui/src/groups/components/navLinks/DeleteGroupNavLink.test.js b/scm-ui/src/groups/components/navLinks/DeleteGroupNavLink.test.js deleted file mode 100644 index 49f8d95c63..0000000000 --- a/scm-ui/src/groups/components/navLinks/DeleteGroupNavLink.test.js +++ /dev/null @@ -1,82 +0,0 @@ -import React from "react"; -import { mount, shallow } from "enzyme"; -import "../../../tests/enzyme"; -import "../../../tests/i18n"; -import DeleteGroupNavLink from "./DeleteGroupNavLink"; - -import { confirmAlert } from "@scm-manager/ui-components"; -jest.mock("@scm-manager/ui-components", () => ({ - confirmAlert: jest.fn(), - NavAction: require.requireActual("@scm-manager/ui-components").NavAction -})); - -describe("DeleteGroupNavLink", () => { - it("should render nothing, if the delete link is missing", () => { - const group = { - _links: {} - }; - - const navLink = shallow( - <DeleteGroupNavLink group={group} deleteGroup={() => {}} /> - ); - expect(navLink.text()).toBe(""); - }); - - it("should render the navLink", () => { - const group = { - _links: { - delete: { - href: "/groups" - } - } - }; - - const navLink = mount( - <DeleteGroupNavLink group={group} deleteGroup={() => {}} /> - ); - expect(navLink.text()).not.toBe(""); - }); - - it("should open the confirm dialog on navLink click", () => { - const group = { - _links: { - delete: { - href: "/groups" - } - } - }; - - const navLink = mount( - <DeleteGroupNavLink group={group} deleteGroup={() => {}} /> - ); - navLink.find("a").simulate("click"); - - expect(confirmAlert.mock.calls.length).toBe(1); - }); - - it("should call the delete group function with delete url", () => { - const group = { - _links: { - delete: { - href: "/groups" - } - } - }; - - let calledUrl = null; - function capture(group) { - calledUrl = group._links.delete.href; - } - - const navLink = mount( - <DeleteGroupNavLink - group={group} - confirmDialog={false} - deleteGroup={capture} - /> - ); - navLink.find("a").simulate("click"); - - expect(calledUrl).toBe("/groups"); - }); -}); diff --git a/scm-ui/src/groups/components/navLinks/EditGroupNavLink.js b/scm-ui/src/groups/components/navLinks/EditGroupNavLink.js index a0e36bc8d7..9713c5c5c9 100644 --- a/scm-ui/src/groups/components/navLinks/EditGroupNavLink.js +++ b/scm-ui/src/groups/components/navLinks/EditGroupNavLink.js @@ -1,29 +1,28 @@ //@flow import React from "react"; +import type { Group } from "@scm-manager/ui-types"; import { NavLink } from "@scm-manager/ui-components"; import { translate } from "react-i18next"; -import type { Group } from "@scm-manager/ui-types"; type Props = { - t: string => string, + group: Group, editUrl: string, - group: Group + t: string => string }; -type State = {}; - -class EditGroupNavLink extends React.Component<Props, State> { - render() { - const { t, editUrl } = this.props; - if (!this.isEditable()) { - return null; - } - return <NavLink label={t("edit-group-button.label")} to={editUrl} />; - } - +class EditGroupNavLink extends React.Component<Props> { isEditable = () => { return this.props.group._links.update; }; + + render() { + const { t, editUrl } = this.props; + + if (!this.isEditable()) { + return null; + } + return <NavLink to={editUrl} label={t("singleGroup.menu.generalNavLink")} />; + } } export default translate("groups")(EditGroupNavLink); diff --git a/scm-ui/src/groups/components/navLinks/editGroupNavLink.test.js b/scm-ui/src/groups/components/navLinks/EditGroupNavLink.test.js similarity index 100% rename from scm-ui/src/groups/components/navLinks/editGroupNavLink.test.js rename to scm-ui/src/groups/components/navLinks/EditGroupNavLink.test.js diff --git a/scm-ui/src/groups/components/navLinks/SetPermissionsNavLink.js b/scm-ui/src/groups/components/navLinks/SetPermissionsNavLink.js new file mode 100644 index 0000000000..b2d7062f5e --- /dev/null +++ b/scm-ui/src/groups/components/navLinks/SetPermissionsNavLink.js @@ -0,0 +1,28 @@ +//@flow +import React from "react"; +import { translate } from "react-i18next"; +import type { Group } from "@scm-manager/ui-types"; +import { NavLink } from "@scm-manager/ui-components"; + +type Props = { + t: string => string, + group: Group, + permissionsUrl: String +}; + +class ChangePermissionNavLink extends React.Component<Props> { + render() { + const { t, permissionsUrl } = this.props; + + if (!this.hasPermissionToSetPermission()) { + return null; + } + return <NavLink to={permissionsUrl} label={t("singleGroup.menu.setPermissionsNavLink")} />; + } + + hasPermissionToSetPermission = () => { + return this.props.group._links.permissions; + }; +} + +export default translate("groups")(ChangePermissionNavLink); diff --git a/scm-ui/src/groups/components/navLinks/index.js b/scm-ui/src/groups/components/navLinks/index.js index 30fdd34b6d..992e9ab9a3 100644 --- a/scm-ui/src/groups/components/navLinks/index.js +++ b/scm-ui/src/groups/components/navLinks/index.js @@ -1,2 +1,2 @@ -export { default as DeleteGroupNavLink } from "./DeleteGroupNavLink"; -export { default as EditGroupNavLink } from "./EditGroupNavLink"; +export { default as EditGroupNavLink } from "./EditGroupNavLink"; +export { default as SetPermissionsNavLink } from "./SetPermissionsNavLink"; diff --git a/scm-ui/src/groups/components/table/Details.js b/scm-ui/src/groups/components/table/Details.js index 097822a1f3..4391310d01 100644 --- a/scm-ui/src/groups/components/table/Details.js +++ b/scm-ui/src/groups/components/table/Details.js @@ -1,69 +1,81 @@ //@flow import React from "react"; import type { Group } from "@scm-manager/ui-types"; -import { translate } from "react-i18next"; import GroupMember from "./GroupMember"; import { DateFromNow } from "@scm-manager/ui-components"; +import { translate } from "react-i18next"; +import injectSheet from "react-jss"; type Props = { group: Group, + + // Context props + classes: any, t: string => string }; +const styles = { + spacing: { + padding: "0 !important" + } +}; + class Details extends React.Component<Props> { render() { const { group, t } = this.props; return ( <table className="table content"> <tbody> - <tr> - <td>{t("group.name")}</td> - <td>{group.name}</td> - </tr> - <tr> - <td>{t("group.description")}</td> - <td>{group.description}</td> - </tr> - <tr> - <td>{t("group.type")}</td> - <td>{group.type}</td> - </tr> - <tr> - <td>{t("group.creationDate")}</td> - <td> - <DateFromNow date={group.creationDate} /> - </td> - </tr> - <tr> - <td>{t("group.lastModified")}</td> - <td> - <DateFromNow date={group.lastModified} /> - </td> - </tr> - {this.renderMembers()} + <tr> + <th>{t("group.name")}</th> + <td>{group.name}</td> + </tr> + <tr> + <th>{t("group.description")}</th> + <td>{group.description}</td> + </tr> + <tr> + <th>{t("group.type")}</th> + <td>{group.type}</td> + </tr> + <tr> + <th>{t("group.creationDate")}</th> + <td> + <DateFromNow date={group.creationDate} /> + </td> + </tr> + <tr> + <th>{t("group.lastModified")}</th> + <td> + <DateFromNow date={group.lastModified} /> + </td> + </tr> + {this.renderMembers()} </tbody> </table> ); } renderMembers() { - if (this.props.group.members.length > 0) { - return ( + const { group, t, classes } = this.props; + + let member = null; + if (group.members.length > 0) { + member = ( <tr> - <td> - {this.props.t("group.members")} + <th>{t("group.members")}</th> + <td className={classes.spacing}> <ul> - {this.props.group._embedded.members.map((member, index) => { - return <GroupMember key={index} member={member} />; + {group._embedded.members.map((member, index) => { + return <GroupMember key={index} member={member}/>; })} </ul> </td> </tr> ); - } else { - return; } + return member; } } -export default translate("groups")(Details); +export default injectSheet(styles)(translate("groups")(Details)); diff --git a/scm-ui/src/groups/components/table/GroupRow.js b/scm-ui/src/groups/components/table/GroupRow.js index 32a8f946df..ccff8bd193 100644 --- a/scm-ui/src/groups/components/table/GroupRow.js +++ b/scm-ui/src/groups/components/table/GroupRow.js @@ -1,25 +1,25 @@ -// @flow -import React from "react"; -import { Link } from "react-router-dom"; -import type { Group } from "@scm-manager/ui-types"; - -type Props = { - group: Group -}; - -export default class GroupRow extends React.Component<Props> { - renderLink(to: string, label: string) { - return <Link to={to}>{label}</Link>; - } - - render() { - const { group } = this.props; - const to = `/group/${group.name}`; - return ( - <tr> - <td>{this.renderLink(to, group.name)}</td> - <td className="is-hidden-mobile">{group.description}</td> - </tr> - ); - } -} +// @flow +import React from "react"; +import { Link } from "react-router-dom"; +import type { Group } from "@scm-manager/ui-types"; + +type Props = { + group: Group +}; + +export default class GroupRow extends React.Component<Props> { + renderLink(to: string, label: string) { + return <Link to={to}>{label}</Link>; + } + + render() { + const { group } = this.props; + const to = `/group/${group.name}`; + return ( + <tr> + <td>{this.renderLink(to, group.name)}</td> + <td className="is-hidden-mobile">{group.description}</td> + </tr> + ); + } +} diff --git a/scm-ui/src/groups/components/table/GroupTable.js b/scm-ui/src/groups/components/table/GroupTable.js index 30c8401d7d..cd38180fc4 100644 --- a/scm-ui/src/groups/components/table/GroupTable.js +++ b/scm-ui/src/groups/components/table/GroupTable.js @@ -1,33 +1,33 @@ -// @flow -import React from "react"; -import { translate } from "react-i18next"; -import GroupRow from "./GroupRow"; -import type { Group } from "@scm-manager/ui-types"; - -type Props = { - t: string => string, - groups: Group[] -}; - -class GroupTable extends React.Component<Props> { - render() { - const { groups, t } = this.props; - return ( - <table className="table is-hoverable is-fullwidth"> - <thead> - <tr> - <th>{t("group.name")}</th> - <th className="is-hidden-mobile">{t("group.description")}</th> - </tr> - </thead> - <tbody> - {groups.map((group, index) => { - return <GroupRow key={index} group={group} />; - })} - </tbody> - </table> - ); - } -} - -export default translate("groups")(GroupTable); +// @flow +import React from "react"; +import { translate } from "react-i18next"; +import GroupRow from "./GroupRow"; +import type { Group } from "@scm-manager/ui-types"; + +type Props = { + t: string => string, + groups: Group[] +}; + +class GroupTable extends React.Component<Props> { + render() { + const { groups, t } = this.props; + return ( + <table className="card-table table is-hoverable is-fullwidth"> + <thead> + <tr> + <th>{t("group.name")}</th> + <th className="is-hidden-mobile">{t("group.description")}</th> + </tr> + </thead> + <tbody> + {groups.map((group, index) => { + return <GroupRow key={index} group={group} />; + })} + </tbody> + </table> + ); + } +} + +export default translate("groups")(GroupTable); diff --git a/scm-ui/src/groups/containers/AddGroup.js b/scm-ui/src/groups/containers/AddGroup.js index 9b13ac0309..69c1171ea9 100644 --- a/scm-ui/src/groups/containers/AddGroup.js +++ b/scm-ui/src/groups/containers/AddGroup.js @@ -13,7 +13,10 @@ import { } from "../modules/groups"; import type { Group } from "@scm-manager/ui-types"; import type { History } from "history"; -import { getGroupsLink } from "../../modules/indexResource"; +import { + getGroupsLink, + getUserAutoCompleteLink +} from "../../modules/indexResource"; type Props = { t: string => string, @@ -22,7 +25,8 @@ type Props = { loading?: boolean, error?: Error, resetForm: () => void, - createLink: string + createLink: string, + autocompleteLink: string }; type State = {}; @@ -31,6 +35,7 @@ class AddGroup extends React.Component<Props, State> { componentDidMount() { this.props.resetForm(); } + render() { const { t, loading, error } = this.props; return ( @@ -43,17 +48,33 @@ class AddGroup extends React.Component<Props, State> { <GroupForm submitForm={group => this.createGroup(group)} loading={loading} + loadUserSuggestions={this.loadUserAutocompletion} /> </div> </Page> ); } - groupCreated = () => { - this.props.history.push("/groups"); + loadUserAutocompletion = (inputValue: string) => { + const url = this.props.autocompleteLink + "?q="; + return fetch(url + inputValue) + .then(response => response.json()) + .then(json => { + return json.map(element => { + return { + value: element, + label: `${element.displayName} (${element.id})` + }; + }); + }); + }; + groupCreated = (group: Group) => { + this.props.history.push("/group/" + group.name); }; createGroup = (group: Group) => { - this.props.createGroup(this.props.createLink, group, this.groupCreated); + this.props.createGroup(this.props.createLink, group, () => + this.groupCreated(group) + ); }; } @@ -71,10 +92,12 @@ const mapStateToProps = state => { const loading = isCreateGroupPending(state); const error = getCreateGroupFailure(state); const createLink = getGroupsLink(state); + const autocompleteLink = getUserAutoCompleteLink(state); return { createLink, loading, - error + error, + autocompleteLink }; }; diff --git a/scm-ui/src/groups/containers/DeleteGroup.js b/scm-ui/src/groups/containers/DeleteGroup.js new file mode 100644 index 0000000000..d497e4434d --- /dev/null +++ b/scm-ui/src/groups/containers/DeleteGroup.js @@ -0,0 +1,113 @@ +// @flow +import React from "react"; +import { translate } from "react-i18next"; +import type { Group } from "@scm-manager/ui-types"; +import { + Subtitle, + DeleteButton, + confirmAlert, + ErrorNotification +} from "@scm-manager/ui-components"; +import { + deleteGroup, + getDeleteGroupFailure, + isDeleteGroupPending +} from "../modules/groups"; +import { connect } from "react-redux"; +import { withRouter } from "react-router-dom"; +import type { History } from "history"; + +type Props = { + loading: boolean, + error: Error, + group: Group, + confirmDialog?: boolean, + deleteGroup: (group: Group, callback?: () => void) => void, + + // context props + history: History, + t: string => string +}; + +export class DeleteGroup extends React.Component<Props> { + static defaultProps = { + confirmDialog: true + }; + + deleteGroup = () => { + this.props.deleteGroup(this.props.group, this.groupDeleted); + }; + + groupDeleted = () => { + this.props.history.push("/groups"); + }; + + confirmDelete = () => { + const { t } = this.props; + confirmAlert({ + title: t("deleteGroup.confirmAlert.title"), + message: t("deleteGroup.confirmAlert.message"), + buttons: [ + { + label: t("deleteGroup.confirmAlert.submit"), + onClick: () => this.deleteGroup() + }, + { + label: t("deleteGroup.confirmAlert.cancel"), + onClick: () => null + } + ] + }); + }; + + isDeletable = () => { + return this.props.group._links.delete; + }; + + render() { + const { loading, error, confirmDialog, t } = this.props; + const action = confirmDialog ? this.confirmDelete : this.deleteGroup; + + if (!this.isDeletable()) { + return null; + } + + return ( + <> + <Subtitle subtitle={t("deleteGroup.subtitle")} /> + <ErrorNotification error={error} /> + <div className="columns"> + <div className="column"> + <DeleteButton + label={t("deleteGroup.button")} + action={action} + loading={loading} + /> + </div> + </div> + </> + ); + } +} + +const mapStateToProps = (state, ownProps) => { + const loading = isDeleteGroupPending(state, ownProps.group.name); + const error = getDeleteGroupFailure(state, ownProps.group.name); + return { + loading, + error + }; +}; + +const mapDispatchToProps = dispatch => { + return { + deleteGroup: (group: Group, callback?: () => void) => { + dispatch(deleteGroup(group, callback)); + } + }; +}; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(withRouter(translate("groups")(DeleteGroup))); diff --git a/scm-ui/src/groups/containers/EditGroup.js b/scm-ui/src/groups/containers/EditGroup.js index ac6e737dac..a1f9f31e46 100644 --- a/scm-ui/src/groups/containers/EditGroup.js +++ b/scm-ui/src/groups/containers/EditGroup.js @@ -4,20 +4,23 @@ import { connect } from "react-redux"; import GroupForm from "../components/GroupForm"; import { modifyGroup, - modifyGroupReset, + getModifyGroupFailure, isModifyGroupPending, - getModifyGroupFailure + modifyGroupReset } from "../modules/groups"; import type { History } from "history"; import { withRouter } from "react-router-dom"; import type { Group } from "@scm-manager/ui-types"; import { ErrorNotification } from "@scm-manager/ui-components"; +import { getUserAutoCompleteLink } from "../../modules/indexResource"; +import DeleteGroup from "./DeleteGroup"; type Props = { group: Group, + fetchGroup: (name: string) => void, modifyGroup: (group: Group, callback?: () => void) => void, modifyGroupReset: Group => void, - fetchGroup: (name: string) => void, + autocompleteLink: string, history: History, loading?: boolean, error: Error @@ -37,8 +40,22 @@ class EditGroup extends React.Component<Props> { this.props.modifyGroup(group, this.groupModified(group)); }; + loadUserAutocompletion = (inputValue: string) => { + const url = this.props.autocompleteLink + "?q="; + return fetch(url + inputValue) + .then(response => response.json()) + .then(json => { + return json.map(element => { + return { + value: element, + label: `${element.displayName} (${element.id})` + }; + }); + }); + }; + render() { - const { group, loading, error } = this.props; + const { loading, error, group } = this.props; return ( <div> <ErrorNotification error={error} /> @@ -48,7 +65,10 @@ class EditGroup extends React.Component<Props> { this.modifyGroup(group); }} loading={loading} + loadUserSuggestions={this.loadUserAutocompletion} /> + <hr /> + <DeleteGroup group={group} /> </div> ); } @@ -57,9 +77,11 @@ class EditGroup extends React.Component<Props> { const mapStateToProps = (state, ownProps) => { const loading = isModifyGroupPending(state, ownProps.group.name); const error = getModifyGroupFailure(state, ownProps.group.name); + const autocompleteLink = getUserAutoCompleteLink(state); return { loading, - error + error, + autocompleteLink }; }; diff --git a/scm-ui/src/groups/containers/Groups.js b/scm-ui/src/groups/containers/Groups.js index 984055c60f..7bc31a8b79 100644 --- a/scm-ui/src/groups/containers/Groups.js +++ b/scm-ui/src/groups/containers/Groups.js @@ -5,7 +5,12 @@ import { translate } from "react-i18next"; import type { Group } from "@scm-manager/ui-types"; import type { PagedCollection } from "@scm-manager/ui-types"; import type { History } from "history"; -import { Page, Paginator } from "@scm-manager/ui-components"; +import { + Page, + PageActions, + Button, + Paginator +} from "@scm-manager/ui-components"; import { GroupTable } from "./../components/table"; import CreateGroupButton from "../components/buttons/CreateGroupButton"; @@ -73,6 +78,13 @@ class Groups extends React.Component<Props> { <GroupTable groups={groups} /> {this.renderPaginator()} {this.renderCreateButton()} + <PageActions> + <Button + label={t("create-group-button.label")} + link="/groups/add" + color="primary" + /> + </PageActions> </Page> ); } diff --git a/scm-ui/src/groups/containers/SingleGroup.js b/scm-ui/src/groups/containers/SingleGroup.js index 1dd4aa569f..ef99d70370 100644 --- a/scm-ui/src/groups/containers/SingleGroup.js +++ b/scm-ui/src/groups/containers/SingleGroup.js @@ -6,27 +6,30 @@ import { ErrorPage, Loading, Navigation, + SubNavigation, Section, NavLink } from "@scm-manager/ui-components"; import { Route } from "react-router"; import { Details } from "./../components/table"; -import { DeleteGroupNavLink, EditGroupNavLink } from "./../components/navLinks"; +import { + EditGroupNavLink, + SetPermissionsNavLink +} from "./../components/navLinks"; import type { Group } from "@scm-manager/ui-types"; import type { History } from "history"; import { - deleteGroup, fetchGroupByName, getGroupByName, isFetchGroupPending, - getFetchGroupFailure, - getDeleteGroupFailure, - isDeleteGroupPending + getFetchGroupFailure } from "../modules/groups"; import { translate } from "react-i18next"; import EditGroup from "./EditGroup"; import { getGroupsLink } from "../../modules/indexResource"; +import SetPermissions from "../../permissions/components/SetPermissions"; +import { ExtensionPoint } from "@scm-manager/ui-extensions"; type Props = { name: string, @@ -36,7 +39,6 @@ type Props = { groupLink: string, // dispatcher functions - deleteGroup: (group: Group, callback?: () => void) => void, fetchGroupByName: (string, string) => void, // context objects @@ -57,14 +59,6 @@ class SingleGroup extends React.Component<Props> { return url; }; - deleteGroup = (group: Group) => { - this.props.deleteGroup(group, this.groupDeleted); - }; - - groupDeleted = () => { - this.props.history.push("/groups"); - }; - matchedUrl = () => { return this.stripEndingSlash(this.props.match.url); }; @@ -75,8 +69,8 @@ class SingleGroup extends React.Component<Props> { if (error) { return ( <ErrorPage - title={t("single-group.error-title")} - subtitle={t("single-group.error-subtitle")} + title={t("singleGroup.errorTitle")} + subtitle={t("singleGroup.errorSubtitle")} error={error} /> ); @@ -88,6 +82,11 @@ class SingleGroup extends React.Component<Props> { const url = this.matchedUrl(); + const extensionProps = { + group, + url + }; + return ( <Page title={group.name}> <div className="columns"> @@ -98,26 +97,56 @@ class SingleGroup extends React.Component<Props> { component={() => <Details group={group} />} /> <Route - path={`${url}/edit`} + path={`${url}/settings/general`} exact component={() => <EditGroup group={group} />} /> + <Route + path={`${url}/settings/permissions`} + exact + component={() => ( + <SetPermissions + selectedPermissionsLink={group._links.permissions} + /> + )} + /> + <ExtensionPoint + name="group.route" + props={extensionProps} + renderAll={true} + /> </div> <div className="column"> <Navigation> - <Section label={t("single-group.navigation-label")}> + <Section label={t("singleGroup.menu.navigationLabel")}> <NavLink to={`${url}`} - label={t("single-group.information-label")} + icon="fas fa-info-circle" + label={t("singleGroup.menu.informationNavLink")} /> - </Section> - <Section label={t("single-group.actions-label")}> - <DeleteGroupNavLink - group={group} - deleteGroup={this.deleteGroup} + <ExtensionPoint + name="group.navigation" + props={extensionProps} + renderAll={true} /> - <EditGroupNavLink group={group} editUrl={`${url}/edit`} /> - <NavLink to="/groups" label={t("single-group.back-label")} /> + <SubNavigation + to={`${url}/settings/general`} + label={t("singleGroup.menu.settingsNavLink")} + > + <EditGroupNavLink + group={group} + editUrl={`${url}/settings/general`} + /> + <SetPermissionsNavLink + group={group} + permissionsUrl={`${url}/settings/permissions`} + /> + <ExtensionPoint + name="group.setting" + props={extensionProps} + renderAll={true} + /> + </SubNavigation> </Section> </Navigation> </div> @@ -130,10 +159,8 @@ class SingleGroup extends React.Component<Props> { const mapStateToProps = (state, ownProps) => { const name = ownProps.match.params.name; const group = getGroupByName(state, name); - const loading = - isFetchGroupPending(state, name) || isDeleteGroupPending(state, name); - const error = - getFetchGroupFailure(state, name) || getDeleteGroupFailure(state, name); + const loading = isFetchGroupPending(state, name); + const error = getFetchGroupFailure(state, name); const groupLink = getGroupsLink(state); return { @@ -149,9 +176,6 @@ const mapDispatchToProps = dispatch => { return { fetchGroupByName: (link: string, name: string) => { dispatch(fetchGroupByName(link, name)); - }, - deleteGroup: (group: Group, callback?: () => void) => { - dispatch(deleteGroup(group, callback)); } }; }; diff --git a/scm-ui/src/groups/modules/groups.js b/scm-ui/src/groups/modules/groups.js index ad8676fc7b..bbaccf6c4a 100644 --- a/scm-ui/src/groups/modules/groups.js +++ b/scm-ui/src/groups/modules/groups.js @@ -333,7 +333,7 @@ function listReducer(state: any = {}, action: any = {}) { ...state, entries: groupNames, entry: { - groupCreatePermission: action.payload._links.create ? true : false, + groupCreatePermission: !!action.payload._links.create, page: action.payload.page, pageTotal: action.payload.pageTotal, _links: action.payload._links diff --git a/scm-ui/src/index.js b/scm-ui/src/index.js index 08e3e8a58c..fd09a0b75f 100644 --- a/scm-ui/src/index.js +++ b/scm-ui/src/index.js @@ -17,6 +17,12 @@ import { ConnectedRouter } from "react-router-redux"; import { urls } from "@scm-manager/ui-components"; +import jss from "jss"; +import jssNested from "jss-nested"; + +// setup jss and install required plugins +jss.setup(jssNested()); + // Create a history of your choosing (we're using a browser history in this case) const history: BrowserHistory = createHistory({ basename: urls.contextPath diff --git a/scm-ui/src/modules/auth.js b/scm-ui/src/modules/auth.js index 6dcd03847b..4c0d22b23d 100644 --- a/scm-ui/src/modules/auth.js +++ b/scm-ui/src/modules/auth.js @@ -134,13 +134,6 @@ const callFetchMe = (link: string): Promise<Me> => { .get(link) .then(response => { return response.json(); - }) - .then(json => { - return { - name: json.name, - displayName: json.displayName, - mail: json.mail - }; }); }; diff --git a/scm-ui/src/modules/changePassword.js b/scm-ui/src/modules/changePassword.js new file mode 100644 index 0000000000..6cdbdb8ac7 --- /dev/null +++ b/scm-ui/src/modules/changePassword.js @@ -0,0 +1,16 @@ +// @flow +import { apiClient } from "@scm-manager/ui-components"; + +export const CONTENT_TYPE_PASSWORD_CHANGE = + "application/vnd.scmm-passwordChange+json;v=2"; +export function changePassword( + url: string, + oldPassword: string, + newPassword: string +) { + return apiClient + .put(url, { oldPassword, newPassword }, CONTENT_TYPE_PASSWORD_CHANGE) + .then(response => { + return response; + }); +} diff --git a/scm-ui/src/modules/changePassword.test.js b/scm-ui/src/modules/changePassword.test.js new file mode 100644 index 0000000000..ea2263217e --- /dev/null +++ b/scm-ui/src/modules/changePassword.test.js @@ -0,0 +1,25 @@ +import fetchMock from "fetch-mock"; +import { changePassword, CONTENT_TYPE_PASSWORD_CHANGE } from "./changePassword"; + +describe("change password", () => { + const CHANGE_PASSWORD_URL = "/me/password"; + const oldPassword = "old"; + const newPassword = "new"; + + afterEach(() => { + fetchMock.reset(); + fetchMock.restore(); + }); + + it("should update password", done => { + fetchMock.put("/api/v2" + CHANGE_PASSWORD_URL, 204, { + headers: { "content-type": CONTENT_TYPE_PASSWORD_CHANGE } + }); + + changePassword(CHANGE_PASSWORD_URL, oldPassword, newPassword).then( + content => { + done(); + } + ); + }); +}); diff --git a/scm-ui/src/modules/indexResource.js b/scm-ui/src/modules/indexResource.js index 98dd9848dc..df55c63756 100644 --- a/scm-ui/src/modules/indexResource.js +++ b/scm-ui/src/modules/indexResource.js @@ -2,7 +2,7 @@ import * as types from "./types"; import { apiClient } from "@scm-manager/ui-components"; -import type { Action, IndexResources } from "@scm-manager/ui-types"; +import type { Action, IndexResources, Link } from "@scm-manager/ui-types"; import { isPending } from "./pending"; import { getFailure } from "./failure"; @@ -100,6 +100,13 @@ export function getLink(state: Object, name: string) { } } +export function getLinkCollection(state: Object, name: string): Link[] { + if (state.indexResources.links && state.indexResources.links[name]) { + return state.indexResources.links[name]; + } + return []; +} + export function getUiPluginsLink(state: Object) { return getLink(state, "uiPlugins"); } @@ -143,3 +150,23 @@ export function getGitConfigLink(state: Object) { export function getSvnConfigLink(state: Object) { return getLink(state, "svnConfig"); } + +export function getUserAutoCompleteLink(state: Object): string { + const link = getLinkCollection(state, "autocomplete").find( + i => i.name === "users" + ); + if (link) { + return link.href; + } + return ""; +} + +export function getGroupAutoCompleteLink(state: Object): string { + const link = getLinkCollection(state, "autocomplete").find( + i => i.name === "groups" + ); + if (link) { + return link.href; + } + return ""; +} diff --git a/scm-ui/src/modules/indexResource.test.js b/scm-ui/src/modules/indexResource.test.js index 2199da8290..ed3b7cb4d5 100644 --- a/scm-ui/src/modules/indexResource.test.js +++ b/scm-ui/src/modules/indexResource.test.js @@ -20,7 +20,11 @@ import reducer, { getHgConfigLink, getGitConfigLink, getSvnConfigLink, - getLinks, getGroupsLink + getLinks, + getGroupsLink, + getLinkCollection, + getUserAutoCompleteLink, + getGroupAutoCompleteLink } from "./indexResource"; const indexResourcesUnauthenticated = { @@ -73,354 +77,404 @@ const indexResourcesAuthenticated = { }, svnConfig: { href: "http://localhost:8081/scm/api/v2/config/svn" - } + }, + autocomplete: [ + { + href: "http://localhost:8081/scm/api/v2/autocomplete/users", + name: "users" + }, + { + href: "http://localhost:8081/scm/api/v2/autocomplete/groups", + name: "groups" + } + ] } }; -describe("fetch index resource", () => { - const index_url = "/api/v2/"; - const mockStore = configureMockStore([thunk]); +describe("index resource", () => { + describe("fetch index resource", () => { + const index_url = "/api/v2/"; + const mockStore = configureMockStore([thunk]); - afterEach(() => { - fetchMock.reset(); - fetchMock.restore(); - }); + afterEach(() => { + fetchMock.reset(); + fetchMock.restore(); + }); - it("should successfully fetch index resources when unauthenticated", () => { - fetchMock.getOnce(index_url, indexResourcesUnauthenticated); + it("should successfully fetch index resources when unauthenticated", () => { + fetchMock.getOnce(index_url, indexResourcesUnauthenticated); - const expectedActions = [ - { type: FETCH_INDEXRESOURCES_PENDING }, - { - type: FETCH_INDEXRESOURCES_SUCCESS, - payload: indexResourcesUnauthenticated - } - ]; + const expectedActions = [ + { type: FETCH_INDEXRESOURCES_PENDING }, + { + type: FETCH_INDEXRESOURCES_SUCCESS, + payload: indexResourcesUnauthenticated + } + ]; - const store = mockStore({}); - return store.dispatch(fetchIndexResources()).then(() => { - expect(store.getActions()).toEqual(expectedActions); + const store = mockStore({}); + return store.dispatch(fetchIndexResources()).then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); + + it("should successfully fetch index resources when authenticated", () => { + fetchMock.getOnce(index_url, indexResourcesAuthenticated); + + const expectedActions = [ + { type: FETCH_INDEXRESOURCES_PENDING }, + { + type: FETCH_INDEXRESOURCES_SUCCESS, + payload: indexResourcesAuthenticated + } + ]; + + const store = mockStore({}); + return store.dispatch(fetchIndexResources()).then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); + + it("should dispatch FETCH_INDEX_RESOURCES_FAILURE if request fails", () => { + fetchMock.getOnce(index_url, { + status: 500 + }); + + const store = mockStore({}); + return store.dispatch(fetchIndexResources()).then(() => { + const actions = store.getActions(); + expect(actions[0].type).toEqual(FETCH_INDEXRESOURCES_PENDING); + expect(actions[1].type).toEqual(FETCH_INDEXRESOURCES_FAILURE); + expect(actions[1].payload).toBeDefined(); + }); }); }); - it("should successfully fetch index resources when authenticated", () => { - fetchMock.getOnce(index_url, indexResourcesAuthenticated); + describe("index resources reducer", () => { + it("should return empty object, if state and action is undefined", () => { + expect(reducer()).toEqual({}); + }); - const expectedActions = [ - { type: FETCH_INDEXRESOURCES_PENDING }, - { - type: FETCH_INDEXRESOURCES_SUCCESS, - payload: indexResourcesAuthenticated - } - ]; + it("should return the same state, if the action is undefined", () => { + const state = { x: true }; + expect(reducer(state)).toBe(state); + }); - const store = mockStore({}); - return store.dispatch(fetchIndexResources()).then(() => { - expect(store.getActions()).toEqual(expectedActions); + it("should return the same state, if the action is unknown to the reducer", () => { + const state = { x: true }; + expect(reducer(state, { type: "EL_SPECIALE" })).toBe(state); + }); + + it("should store the index resources on FETCH_INDEXRESOURCES_SUCCESS", () => { + const newState = reducer( + {}, + fetchIndexResourcesSuccess(indexResourcesAuthenticated) + ); + expect(newState.links).toBe(indexResourcesAuthenticated._links); }); }); - it("should dispatch FETCH_INDEX_RESOURCES_FAILURE if request fails", () => { - fetchMock.getOnce(index_url, { - status: 500 + describe("index resources selectors", () => { + const error = new Error("something goes wrong"); + + it("should return true, when fetch index resources is pending", () => { + const state = { + pending: { + [FETCH_INDEXRESOURCES]: true + } + }; + expect(isFetchIndexResourcesPending(state)).toEqual(true); }); - const store = mockStore({}); - return store.dispatch(fetchIndexResources()).then(() => { - const actions = store.getActions(); - expect(actions[0].type).toEqual(FETCH_INDEXRESOURCES_PENDING); - expect(actions[1].type).toEqual(FETCH_INDEXRESOURCES_FAILURE); - expect(actions[1].payload).toBeDefined(); + it("should return false, when fetch index resources is not pending", () => { + expect(isFetchIndexResourcesPending({})).toEqual(false); + }); + + it("should return error when fetch index resources did fail", () => { + const state = { + failure: { + [FETCH_INDEXRESOURCES]: error + } + }; + expect(getFetchIndexResourcesFailure(state)).toEqual(error); + }); + + it("should return undefined when fetch index resources did not fail", () => { + expect(getFetchIndexResourcesFailure({})).toBe(undefined); + }); + + it("should return all links", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getLinks(state)).toBe(indexResourcesAuthenticated._links); + }); + + // ui plugins link + it("should return ui plugins link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getUiPluginsLink(state)).toBe( + "http://localhost:8081/scm/api/v2/ui/plugins" + ); + }); + + it("should return ui plugins links when unauthenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getUiPluginsLink(state)).toBe( + "http://localhost:8081/scm/api/v2/ui/plugins" + ); + }); + + // me link + it("should return me link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getMeLink(state)).toBe("http://localhost:8081/scm/api/v2/me/"); + }); + + it("should return undefined for me link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getMeLink(state)).toBe(undefined); + }); + + // logout link + it("should return logout link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getLogoutLink(state)).toBe( + "http://localhost:8081/scm/api/v2/auth/access_token" + ); + }); + + it("should return undefined for logout link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getLogoutLink(state)).toBe(undefined); + }); + + // login link + it("should return login link when unauthenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getLoginLink(state)).toBe( + "http://localhost:8081/scm/api/v2/auth/access_token" + ); + }); + + it("should return undefined for login link when authenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getLoginLink(state)).toBe(undefined); + }); + + // users link + it("should return users link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getUsersLink(state)).toBe( + "http://localhost:8081/scm/api/v2/users/" + ); + }); + + it("should return undefined for users link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getUsersLink(state)).toBe(undefined); + }); + + // groups link + it("should return groups link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getGroupsLink(state)).toBe( + "http://localhost:8081/scm/api/v2/groups/" + ); + }); + + it("should return undefined for groups link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getGroupsLink(state)).toBe(undefined); + }); + + // config link + it("should return config link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getConfigLink(state)).toBe( + "http://localhost:8081/scm/api/v2/config" + ); + }); + + it("should return undefined for config link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getConfigLink(state)).toBe(undefined); + }); + + // repositories link + it("should return repositories link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getRepositoriesLink(state)).toBe( + "http://localhost:8081/scm/api/v2/repositories/" + ); + }); + + it("should return config for repositories link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getRepositoriesLink(state)).toBe(undefined); + }); + + // hgConfig link + it("should return hgConfig link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getHgConfigLink(state)).toBe( + "http://localhost:8081/scm/api/v2/config/hg" + ); + }); + + it("should return config for hgConfig link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getHgConfigLink(state)).toBe(undefined); + }); + + // gitConfig link + it("should return gitConfig link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getGitConfigLink(state)).toBe( + "http://localhost:8081/scm/api/v2/config/git" + ); + }); + + it("should return config for gitConfig link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getGitConfigLink(state)).toBe(undefined); + }); + + // svnConfig link + it("should return svnConfig link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getSvnConfigLink(state)).toBe( + "http://localhost:8081/scm/api/v2/config/svn" + ); + }); + + it("should return config for svnConfig link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getSvnConfigLink(state)).toBe(undefined); + }); + + // Autocomplete links + it("should return link collection", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getLinkCollection(state, "autocomplete")).toEqual( + indexResourcesAuthenticated._links.autocomplete + ); + }); + + it("should return user autocomplete link", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getUserAutoCompleteLink(state)).toEqual( + "http://localhost:8081/scm/api/v2/autocomplete/users" + ); + }); + + it("should return group autocomplete link", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getGroupAutoCompleteLink(state)).toEqual( + "http://localhost:8081/scm/api/v2/autocomplete/groups" + ); }); }); }); - -describe("index resources reducer", () => { - it("should return empty object, if state and action is undefined", () => { - expect(reducer()).toEqual({}); - }); - - it("should return the same state, if the action is undefined", () => { - const state = { x: true }; - expect(reducer(state)).toBe(state); - }); - - it("should return the same state, if the action is unknown to the reducer", () => { - const state = { x: true }; - expect(reducer(state, { type: "EL_SPECIALE" })).toBe(state); - }); - - it("should store the index resources on FETCH_INDEXRESOURCES_SUCCESS", () => { - const newState = reducer( - {}, - fetchIndexResourcesSuccess(indexResourcesAuthenticated) - ); - expect(newState.links).toBe(indexResourcesAuthenticated._links); - }); -}); - -describe("index resources selectors", () => { - const error = new Error("something goes wrong"); - - it("should return true, when fetch index resources is pending", () => { - const state = { - pending: { - [FETCH_INDEXRESOURCES]: true - } - }; - expect(isFetchIndexResourcesPending(state)).toEqual(true); - }); - - it("should return false, when fetch index resources is not pending", () => { - expect(isFetchIndexResourcesPending({})).toEqual(false); - }); - - it("should return error when fetch index resources did fail", () => { - const state = { - failure: { - [FETCH_INDEXRESOURCES]: error - } - }; - expect(getFetchIndexResourcesFailure(state)).toEqual(error); - }); - - it("should return undefined when fetch index resources did not fail", () => { - expect(getFetchIndexResourcesFailure({})).toBe(undefined); - }); - - it("should return all links", () => { - const state = { - indexResources: { - links: indexResourcesAuthenticated._links - } - }; - expect(getLinks(state)).toBe(indexResourcesAuthenticated._links); - }); - - // ui plugins link - it("should return ui plugins link when authenticated and has permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesAuthenticated._links - } - }; - expect(getUiPluginsLink(state)).toBe( - "http://localhost:8081/scm/api/v2/ui/plugins" - ); - }); - - it("should return ui plugins links when unauthenticated and has permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesUnauthenticated._links - } - }; - expect(getUiPluginsLink(state)).toBe( - "http://localhost:8081/scm/api/v2/ui/plugins" - ); - }); - - // me link - it("should return me link when authenticated and has permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesAuthenticated._links - } - }; - expect(getMeLink(state)).toBe("http://localhost:8081/scm/api/v2/me/"); - }); - - it("should return undefined for me link when unauthenticated or has not permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesUnauthenticated._links - } - }; - expect(getMeLink(state)).toBe(undefined); - }); - - // logout link - it("should return logout link when authenticated and has permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesAuthenticated._links - } - }; - expect(getLogoutLink(state)).toBe( - "http://localhost:8081/scm/api/v2/auth/access_token" - ); - }); - - it("should return undefined for logout link when unauthenticated or has not permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesUnauthenticated._links - } - }; - expect(getLogoutLink(state)).toBe(undefined); - }); - - // login link - it("should return login link when unauthenticated and has permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesUnauthenticated._links - } - }; - expect(getLoginLink(state)).toBe( - "http://localhost:8081/scm/api/v2/auth/access_token" - ); - }); - - it("should return undefined for login link when authenticated or has not permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesAuthenticated._links - } - }; - expect(getLoginLink(state)).toBe(undefined); - }); - - // users link - it("should return users link when authenticated and has permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesAuthenticated._links - } - }; - expect(getUsersLink(state)).toBe("http://localhost:8081/scm/api/v2/users/"); - }); - - it("should return undefined for users link when unauthenticated or has not permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesUnauthenticated._links - } - }; - expect(getUsersLink(state)).toBe(undefined); - }); - - // groups link - it("should return groups link when authenticated and has permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesAuthenticated._links - } - }; - expect(getGroupsLink(state)).toBe("http://localhost:8081/scm/api/v2/groups/"); - }); - - it("should return undefined for groups link when unauthenticated or has not permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesUnauthenticated._links - } - }; - expect(getGroupsLink(state)).toBe(undefined); - }); - - // config link - it("should return config link when authenticated and has permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesAuthenticated._links - } - }; - expect(getConfigLink(state)).toBe( - "http://localhost:8081/scm/api/v2/config" - ); - }); - - it("should return undefined for config link when unauthenticated or has not permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesUnauthenticated._links - } - }; - expect(getConfigLink(state)).toBe(undefined); - }); - - // repositories link - it("should return repositories link when authenticated and has permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesAuthenticated._links - } - }; - expect(getRepositoriesLink(state)).toBe( - "http://localhost:8081/scm/api/v2/repositories/" - ); - }); - - it("should return config for repositories link when unauthenticated or has not permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesUnauthenticated._links - } - }; - expect(getRepositoriesLink(state)).toBe(undefined); - }); - - // hgConfig link - it("should return hgConfig link when authenticated and has permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesAuthenticated._links - } - }; - expect(getHgConfigLink(state)).toBe( - "http://localhost:8081/scm/api/v2/config/hg" - ); - }); - - it("should return config for hgConfig link when unauthenticated or has not permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesUnauthenticated._links - } - }; - expect(getHgConfigLink(state)).toBe(undefined); - }); - - // gitConfig link - it("should return gitConfig link when authenticated and has permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesAuthenticated._links - } - }; - expect(getGitConfigLink(state)).toBe( - "http://localhost:8081/scm/api/v2/config/git" - ); - }); - - it("should return config for gitConfig link when unauthenticated or has not permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesUnauthenticated._links - } - }; - expect(getGitConfigLink(state)).toBe(undefined); - }); - - // svnConfig link - it("should return svnConfig link when authenticated and has permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesAuthenticated._links - } - }; - expect(getSvnConfigLink(state)).toBe( - "http://localhost:8081/scm/api/v2/config/svn" - ); - }); - - it("should return config for svnConfig link when unauthenticated or has not permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesUnauthenticated._links - } - }; - expect(getSvnConfigLink(state)).toBe(undefined); - }); -}); diff --git a/scm-ui/src/permissions/components/PermissionCheckbox.js b/scm-ui/src/permissions/components/PermissionCheckbox.js new file mode 100644 index 0000000000..f4ddcae397 --- /dev/null +++ b/scm-ui/src/permissions/components/PermissionCheckbox.js @@ -0,0 +1,47 @@ +// @flow + +import React from "react"; +import { translate } from "react-i18next"; +import { Checkbox } from "../../../../scm-ui-components/packages/ui-components/src"; + +type Props = { + permission: string, + checked: boolean, + onChange: (value: boolean, name: string) => void, + disabled: boolean, + t: string => string +}; + +class PermissionCheckbox extends React.Component<Props> { + render() { + const { t, permission, checked, onChange, disabled } = this.props; + const key = permission.split(":").join("."); + return ( + <Checkbox + name={permission} + label={this.translateOrDefault( + "permissions." + key + ".displayName", + key + )} + checked={checked} + onChange={onChange} + disabled={disabled} + helpText={this.translateOrDefault( + "permissions." + key + ".description", + t("permissions.unknown") + )} + /> + ); + } + + translateOrDefault = (key: string, defaultText: string) => { + const translation = this.props.t(key); + if (translation === key) { + return defaultText; + } else { + return translation; + } + }; +} + +export default translate("plugins")(PermissionCheckbox); diff --git a/scm-ui/src/permissions/components/SetPermissions.js b/scm-ui/src/permissions/components/SetPermissions.js new file mode 100644 index 0000000000..d78177a745 --- /dev/null +++ b/scm-ui/src/permissions/components/SetPermissions.js @@ -0,0 +1,178 @@ +// @flow +import React from "react"; +import type { Link } from "@scm-manager/ui-types"; +import { + Notification, + ErrorNotification, + SubmitButton +} from "@scm-manager/ui-components"; +import { translate } from "react-i18next"; +import { + loadPermissionsForEntity, + setPermissions +} from "./handlePermissions"; +import PermissionCheckbox from "./PermissionCheckbox"; +import { connect } from "react-redux"; +import { getLink } from "../../modules/indexResource"; + +type Props = { + t: string => string, + availablePermissionLink: string, + selectedPermissionsLink: Link +}; + +type State = { + permissions: { [string]: boolean }, + loading: boolean, + error?: Error, + permissionsChanged: boolean, + permissionsSubmitted: boolean, + overwritePermissionsLink?: Link +}; + +class SetPermissions extends React.Component<Props, State> { + constructor(props: Props) { + super(props); + + this.state = { + permissions: {}, + loading: true, + permissionsChanged: false, + permissionsSubmitted: false, + modifiable: false, + overwritePermissionsLink: undefined + }; + } + + setLoadingState = () => { + this.setState({ + loading: true + }); + }; + + setErrorState = (error: Error) => { + this.setState({ + error: error, + loading: false + }); + }; + + setSuccessfulState = () => { + this.setState({ + loading: false, + error: undefined, + permissionsSubmitted: true, + permissionsChanged: false + }); + }; + + componentDidMount(): void { + loadPermissionsForEntity( + this.props.availablePermissionLink, + this.props.selectedPermissionsLink.href + ).then(response => { + const { permissions, overwriteLink } = response; + this.setState({ + permissions: permissions, + loading: false, + overwritePermissionsLink: overwriteLink + }); + }); + } + + submit = (event: Event) => { + event.preventDefault(); + if (this.state.permissions) { + const { permissions } = this.state; + this.setLoadingState(); + const selectedPermissions = Object.entries(permissions) + .filter(e => e[1]) + .map(e => e[0]); + if (this.state.overwritePermissionsLink) { + setPermissions( + this.state.overwritePermissionsLink.href, + selectedPermissions + ) + .then(result => { + this.setSuccessfulState(); + }) + .catch(err => { + this.setErrorState(err); + }); + } + } + }; + + render() { + const { t } = this.props; + const { loading, permissionsSubmitted, error } = this.state; + + let message = null; + + if (permissionsSubmitted) { + message = ( + <Notification + type={"success"} + children={t("setPermissions.setPermissionsSuccessful")} + onClose={() => this.onClose()} + /> + ); + } else if (error) { + message = <ErrorNotification error={error} />; + } + + return ( + <form onSubmit={this.submit}> + {message} + {this.renderPermissions()} + <SubmitButton + disabled={!this.state.permissionsChanged} + loading={loading} + label={t("setPermissions.button")} + /> + </form> + ); + } + + renderPermissions = () => { + const { overwritePermissionsLink, permissions } = this.state; + return Object.keys(permissions).map(p => ( + <div key={p}> + <PermissionCheckbox + permission={p} + checked={permissions[p]} + onChange={this.valueChanged} + disabled={!overwritePermissionsLink} + /> + </div> + )); + }; + + valueChanged = (value: boolean, name: string) => { + this.setState(state => { + const newPermissions = state.permissions; + newPermissions[name] = value; + return { + permissions: newPermissions, + permissionsChanged: true + }; + }); + }; + + onClose = () => { + this.setState({ + permissionsSubmitted: false + }); + }; +} + +const mapStateToProps = state => { + const availablePermissionLink = getLink(state, "permissions"); + return { + availablePermissionLink + }; +}; + +export default connect(mapStateToProps)( + translate("permissions")(SetPermissions) +); diff --git a/scm-ui/src/permissions/components/handlePermissions.js b/scm-ui/src/permissions/components/handlePermissions.js new file mode 100644 index 0000000000..6e48127d6d --- /dev/null +++ b/scm-ui/src/permissions/components/handlePermissions.js @@ -0,0 +1,33 @@ +//@flow +import { apiClient } from "@scm-manager/ui-components"; + +export const CONTENT_TYPE_PERMISSIONS = + "application/vnd.scmm-permissionCollection+json;v=2"; + +export function setPermissions(url: string, permissions: string[]) { + return apiClient + .put(url, { permissions: permissions }, CONTENT_TYPE_PERMISSIONS) + .then(response => { + return response; + }); +} + +export function loadPermissionsForEntity( + availableUrl: string, + userUrl: string +) { + return Promise.all([ + apiClient.get(availableUrl).then(response => { + return response.json(); + }), + apiClient.get(userUrl).then(response => { + return response.json(); + }) + ]).then(values => { + const [availablePermissions, checkedPermissions] = values; + const permissions = {}; + availablePermissions.permissions.forEach(p => (permissions[p] = false)); + checkedPermissions.permissions.forEach(p => (permissions[p] = true)); + return { permissions, overwriteLink: checkedPermissions._links.overwrite }; + }); +} diff --git a/scm-ui/src/permissions/components/handlePermissions.test.js b/scm-ui/src/permissions/components/handlePermissions.test.js new file mode 100644 index 0000000000..9fb697e938 --- /dev/null +++ b/scm-ui/src/permissions/components/handlePermissions.test.js @@ -0,0 +1,67 @@ +//@flow +import fetchMock from "fetch-mock"; +import { loadPermissionsForEntity } from "./handlePermissions"; + +describe("load permissions for entity", () => { + const AVAILABLE_PERMISSIONS_URL = "/permissions"; + const USER_PERMISSIONS_URL = "/user/scmadmin/permissions"; + + const availablePermissions = `{ + "permissions": [ + "repository:read,pull:*", + "repository:read,pull,push:*", + "repository:*:*" + ] + }`; + const userPermissions = `{ + "permissions": [ + "repository:read,pull:*" + ], + "_links": { + "self": { + "href": "/api/v2/users/rene/permissions" + }, + "overwrite": { + "href": "/api/v2/users/rene/permissions" + } + } + }`; + + beforeEach(() => { + fetchMock.getOnce( + "/api/v2" + AVAILABLE_PERMISSIONS_URL, + availablePermissions + ); + fetchMock.getOnce("/api/v2" + USER_PERMISSIONS_URL, userPermissions); + }); + + afterEach(() => { + fetchMock.reset(); + fetchMock.restore(); + }); + + it("should return permissions array", done => { + loadPermissionsForEntity( + AVAILABLE_PERMISSIONS_URL, + USER_PERMISSIONS_URL + ).then(result => { + const { permissions } = result; + expect(Object.entries(permissions).length).toBe(3); + expect(permissions["repository:read,pull:*"]).toBe(true); + expect(permissions["repository:read,pull,push:*"]).toBe(false); + expect(permissions["repository:*:*"]).toBe(false); + done(); + }); + }); + + it("should return overwrite link", done => { + loadPermissionsForEntity( + AVAILABLE_PERMISSIONS_URL, + USER_PERMISSIONS_URL + ).then(result => { + const { overwriteLink } = result; + expect(overwriteLink.href).toBe("/api/v2/users/rene/permissions"); + done(); + }); + }); +}); diff --git a/scm-ui/src/repos/components/DeleteNavAction.js b/scm-ui/src/repos/components/DeleteNavAction.js deleted file mode 100644 index c2369a5bfb..0000000000 --- a/scm-ui/src/repos/components/DeleteNavAction.js +++ /dev/null @@ -1,58 +0,0 @@ -//@flow -import React from "react"; -import { translate } from "react-i18next"; -import { NavAction, confirmAlert } from "@scm-manager/ui-components"; -import type { Repository } from "@scm-manager/ui-types"; - -type Props = { - repository: Repository, - confirmDialog?: boolean, - delete: Repository => void, - - // context props - t: string => string -}; - -class DeleteNavAction extends React.Component<Props> { - static defaultProps = { - confirmDialog: true - }; - - delete = () => { - this.props.delete(this.props.repository); - }; - - confirmDelete = () => { - const { t } = this.props; - confirmAlert({ - title: t("delete-nav-action.confirm-alert.title"), - message: t("delete-nav-action.confirm-alert.message"), - buttons: [ - { - label: t("delete-nav-action.confirm-alert.submit"), - onClick: () => this.delete() - }, - { - label: t("delete-nav-action.confirm-alert.cancel"), - onClick: () => null - } - ] - }); - }; - - isDeletable = () => { - return this.props.repository._links.delete; - }; - - render() { - const { confirmDialog, t } = this.props; - const action = confirmDialog ? this.confirmDelete : this.delete(); - - if (!this.isDeletable()) { - return null; - } - return <NavAction label={t("delete-nav-action.label")} action={action} />; - } -} - -export default translate("repos")(DeleteNavAction); diff --git a/scm-ui/src/repos/components/DeleteNavAction.test.js b/scm-ui/src/repos/components/DeleteNavAction.test.js deleted file mode 100644 index 7c2191864a..0000000000 --- a/scm-ui/src/repos/components/DeleteNavAction.test.js +++ /dev/null @@ -1,82 +0,0 @@ -import React from "react"; -import { mount, shallow } from "enzyme"; -import "../../tests/enzyme"; -import "../../tests/i18n"; -import DeleteNavAction from "./DeleteNavAction"; - -import { confirmAlert } from "@scm-manager/ui-components"; -jest.mock("@scm-manager/ui-components", () => ({ - confirmAlert: jest.fn(), - NavAction: require.requireActual("@scm-manager/ui-components").NavAction -})); - -describe("DeleteNavAction", () => { - it("should render nothing, if the delete link is missing", () => { - const repository = { - _links: {} - }; - - const navLink = shallow( - <DeleteNavAction repository={repository} delete={() => {}} /> - ); - expect(navLink.text()).toBe(""); - }); - - it("should render the navLink", () => { - const repository = { - _links: { - delete: { - href: "/repositories" - } - } - }; - - const navLink = mount( - <DeleteNavAction repository={repository} delete={() => {}} /> - ); - expect(navLink.text()).not.toBe(""); - }); - - it("should open the confirm dialog on navLink click", () => { - const repository = { - _links: { - delete: { - href: "/repositorys" - } - } - }; - - const navLink = mount( - <DeleteNavAction repository={repository} delete={() => {}} /> - ); - navLink.find("a").simulate("click"); - - expect(confirmAlert.mock.calls.length).toBe(1); - }); - - it("should call the delete repository function with delete url", () => { - const repository = { - _links: { - delete: { - href: "/repos" - } - } - }; - - let calledUrl = null; - function capture(repository) { - calledUrl = repository._links.delete.href; - } - - const navLink = mount( - <DeleteNavAction - repository={repository} - confirmDialog={false} - delete={capture} - /> - ); - navLink.find("a").simulate("click"); - - expect(calledUrl).toBe("/repos"); - }); -}); diff --git a/scm-ui/src/repos/components/EditNavLink.js b/scm-ui/src/repos/components/EditRepoNavLink.js similarity index 57% rename from scm-ui/src/repos/components/EditNavLink.js rename to scm-ui/src/repos/components/EditRepoNavLink.js index 1a49fdee81..9a502cdafb 100644 --- a/scm-ui/src/repos/components/EditNavLink.js +++ b/scm-ui/src/repos/components/EditRepoNavLink.js @@ -1,22 +1,28 @@ //@flow import React from "react"; +import type { Repository } from "@scm-manager/ui-types"; import { NavLink } from "@scm-manager/ui-components"; import { translate } from "react-i18next"; -import type { Repository } from "@scm-manager/ui-types"; -type Props = { editUrl: string, t: string => string, repository: Repository }; +type Props = { + repository: Repository, + editUrl: string, + t: string => string +}; -class EditNavLink extends React.Component<Props> { +class EditRepoNavLink extends React.Component<Props> { isEditable = () => { return this.props.repository._links.update; }; + render() { + const { editUrl, t } = this.props; + if (!this.isEditable()) { return null; } - const { editUrl, t } = this.props; - return <NavLink to={editUrl} label={t("edit-nav-link.label")} />; + return <NavLink to={editUrl} label={t("repositoryRoot.menu.generalNavLink")} />; } } -export default translate("repos")(EditNavLink); +export default translate("repos")(EditRepoNavLink); diff --git a/scm-ui/src/repos/components/EditNavLink.test.js b/scm-ui/src/repos/components/EditRepoNavLink.test.js similarity index 71% rename from scm-ui/src/repos/components/EditNavLink.test.js rename to scm-ui/src/repos/components/EditRepoNavLink.test.js index 935b7cf928..22bb06fae0 100644 --- a/scm-ui/src/repos/components/EditNavLink.test.js +++ b/scm-ui/src/repos/components/EditRepoNavLink.test.js @@ -3,9 +3,9 @@ import { shallow, mount } from "enzyme"; import "../../tests/enzyme"; import "../../tests/i18n"; import ReactRouterEnzymeContext from "react-router-enzyme-context"; -import EditNavLink from "./EditNavLink"; +import EditRepoNavLink from "./EditRepoNavLink"; -describe("EditNavLink", () => { +describe("GeneralNavLink", () => { const options = new ReactRouterEnzymeContext(); it("should render nothing, if the modify link is missing", () => { @@ -14,7 +14,7 @@ describe("EditNavLink", () => { }; const navLink = shallow( - <EditNavLink repository={repository} editUrl="" />, + <EditRepoNavLink repository={repository} editUrl="" />, options.get() ); expect(navLink.text()).toBe(""); @@ -30,9 +30,9 @@ describe("EditNavLink", () => { }; const navLink = mount( - <EditNavLink repository={repository} editUrl="" />, + <EditRepoNavLink repository={repository} editUrl="" />, options.get() ); - expect(navLink.text()).toBe("edit-nav-link.label"); + expect(navLink.text()).toBe("repositoryRoot.menu.generalNavLink"); }); }); diff --git a/scm-ui/src/repos/components/PermissionsNavLink.js b/scm-ui/src/repos/components/PermissionsNavLink.js index cb6d0e0723..773ad94246 100644 --- a/scm-ui/src/repos/components/PermissionsNavLink.js +++ b/scm-ui/src/repos/components/PermissionsNavLink.js @@ -20,7 +20,7 @@ class PermissionsNavLink extends React.Component<Props> { } const { permissionUrl, t } = this.props; return ( - <NavLink to={permissionUrl} label={t("repository-root.permissions")} /> + <NavLink to={permissionUrl} label={t("repositoryRoot.menu.permissionsNavLink")} /> ); } } diff --git a/scm-ui/src/repos/components/PermissionsNavLink.test.js b/scm-ui/src/repos/components/PermissionsNavLink.test.js index 450c7f49e6..3f6a95fe7d 100644 --- a/scm-ui/src/repos/components/PermissionsNavLink.test.js +++ b/scm-ui/src/repos/components/PermissionsNavLink.test.js @@ -33,6 +33,6 @@ describe("PermissionsNavLink", () => { <PermissionsNavLink repository={repository} permissionUrl="" />, options.get() ); - expect(navLink.text()).toBe("repository-root.permissions"); + expect(navLink.text()).toBe("repositoryRoot.menu.permissionsNavLink"); }); }); diff --git a/scm-ui/src/repos/components/RepositoryDetailTable.js b/scm-ui/src/repos/components/RepositoryDetailTable.js index db5f9abbc1..ca63398670 100644 --- a/scm-ui/src/repos/components/RepositoryDetailTable.js +++ b/scm-ui/src/repos/components/RepositoryDetailTable.js @@ -1,55 +1,55 @@ -//@flow -import React from "react"; -import type { Repository } from "@scm-manager/ui-types"; -import { MailLink, DateFromNow } from "@scm-manager/ui-components"; -import { translate } from "react-i18next"; - -type Props = { - repository: Repository, - // context props - t: string => string -}; - -class RepositoryDetailTable extends React.Component<Props> { - render() { - const { repository, t } = this.props; - return ( - <table className="table"> - <tbody> - <tr> - <td>{t("repository.name")}</td> - <td>{repository.name}</td> - </tr> - <tr> - <td>{t("repository.type")}</td> - <td>{repository.type}</td> - </tr> - <tr> - <td>{t("repository.contact")}</td> - <td> - <MailLink address={repository.contact} /> - </td> - </tr> - <tr> - <td>{t("repository.description")}</td> - <td>{repository.description}</td> - </tr> - <tr> - <td>{t("repository.creationDate")}</td> - <td> - <DateFromNow date={repository.creationDate} /> - </td> - </tr> - <tr> - <td>{t("repository.lastModified")}</td> - <td> - <DateFromNow date={repository.lastModified} /> - </td> - </tr> - </tbody> - </table> - ); - } -} - -export default translate("repos")(RepositoryDetailTable); +//@flow +import React from "react"; +import type { Repository } from "@scm-manager/ui-types"; +import { MailLink, DateFromNow } from "@scm-manager/ui-components"; +import { translate } from "react-i18next"; + +type Props = { + repository: Repository, + // context props + t: string => string +}; + +class RepositoryDetailTable extends React.Component<Props> { + render() { + const { repository, t } = this.props; + return ( + <table className="table"> + <tbody> + <tr> + <td className="has-text-weight-semibold">{t("repository.name")}</td> + <td>{repository.name}</td> + </tr> + <tr> + <td className="has-text-weight-semibold">{t("repository.type")}</td> + <td>{repository.type}</td> + </tr> + <tr> + <td className="has-text-weight-semibold">{t("repository.contact")}</td> + <td> + <MailLink address={repository.contact} /> + </td> + </tr> + <tr> + <td className="has-text-weight-semibold">{t("repository.description")}</td> + <td>{repository.description}</td> + </tr> + <tr> + <td className="has-text-weight-semibold">{t("repository.creationDate")}</td> + <td> + <DateFromNow date={repository.creationDate} /> + </td> + </tr> + <tr> + <td className="has-text-weight-semibold">{t("repository.lastModified")}</td> + <td> + <DateFromNow date={repository.lastModified} /> + </td> + </tr> + </tbody> + </table> + ); + } +} + +export default translate("repos")(RepositoryDetailTable); diff --git a/scm-ui/src/repos/components/RepositoryDetails.js b/scm-ui/src/repos/components/RepositoryDetails.js index 99c88fec94..02d7f2f5ac 100644 --- a/scm-ui/src/repos/components/RepositoryDetails.js +++ b/scm-ui/src/repos/components/RepositoryDetails.js @@ -1,29 +1,30 @@ -//@flow -import React from "react"; -import type { Repository } from "@scm-manager/ui-types"; -import RepositoryDetailTable from "./RepositoryDetailTable"; -import { ExtensionPoint } from "@scm-manager/ui-extensions"; - -type Props = { - repository: Repository -}; - -class RepositoryDetails extends React.Component<Props> { - render() { - const { repository } = this.props; - return ( - <div> - <RepositoryDetailTable repository={repository} /> - <div className="content"> - <ExtensionPoint - name="repos.repository-details.information" - renderAll={true} - props={{ repository }} - /> - </div> - </div> - ); - } -} - -export default RepositoryDetails; +//@flow +import React from "react"; +import type { Repository } from "@scm-manager/ui-types"; +import RepositoryDetailTable from "./RepositoryDetailTable"; +import { ExtensionPoint } from "@scm-manager/ui-extensions"; + +type Props = { + repository: Repository +}; + +class RepositoryDetails extends React.Component<Props> { + render() { + const { repository } = this.props; + return ( + <div> + <RepositoryDetailTable repository={repository} /> + <hr /> + <div className="content"> + <ExtensionPoint + name="repos.repository-details.information" + renderAll={true} + props={{ repository }} + /> + </div> + </div> + ); + } +} + +export default RepositoryDetails; diff --git a/scm-ui/src/repos/components/RepositoryNavLink.test.js b/scm-ui/src/repos/components/RepositoryNavLink.test.js index 0d93cb7c4d..a4c060dfe0 100644 --- a/scm-ui/src/repos/components/RepositoryNavLink.test.js +++ b/scm-ui/src/repos/components/RepositoryNavLink.test.js @@ -11,6 +11,9 @@ describe("RepositoryNavLink", () => { it("should render nothing, if the sources link is missing", () => { const repository = { + namespace: "Namespace", + name: "Repo", + type: "GIT", _links: {} }; @@ -20,6 +23,7 @@ describe("RepositoryNavLink", () => { linkName="sources" to="/sources" label="Sources" + activeOnlyWhenExact={true} />, options.get() ); @@ -28,6 +32,9 @@ describe("RepositoryNavLink", () => { it("should render the navLink", () => { const repository = { + namespace: "Namespace", + name: "Repo", + type: "GIT", _links: { sources: { href: "/sources" @@ -41,6 +48,7 @@ describe("RepositoryNavLink", () => { linkName="sources" to="/sources" label="Sources" + activeOnlyWhenExact={true} />, options.get() ); diff --git a/scm-ui/src/repos/components/changesets/ChangesetAuthor.js b/scm-ui/src/repos/components/changesets/ChangesetAuthor.js deleted file mode 100644 index 778d4b5073..0000000000 --- a/scm-ui/src/repos/components/changesets/ChangesetAuthor.js +++ /dev/null @@ -1,37 +0,0 @@ -//@flow - -import React from "react"; -import type { Changeset } from "@scm-manager/ui-types"; - -type Props = { - changeset: Changeset -}; - -export default class ChangesetAuthor extends React.Component<Props> { - render() { - const { changeset } = this.props; - if (!changeset.author) { - return null; - } - - const { name } = changeset.author; - return ( - <> - {name} {this.renderMail()} - </> - ); - } - - renderMail() { - const { mail } = this.props.changeset.author; - if (mail) { - return ( - <a className="is-hidden-mobile" href={"mailto:" + mail}> - < - {mail} - > - </a> - ); - } - } -} diff --git a/scm-ui/src/repos/components/changesets/ChangesetDetails.js b/scm-ui/src/repos/components/changesets/ChangesetDetails.js index 14f48362b8..33d63a30d4 100644 --- a/scm-ui/src/repos/components/changesets/ChangesetDetails.js +++ b/scm-ui/src/repos/components/changesets/ChangesetDetails.js @@ -3,20 +3,30 @@ import React from "react"; import type { Changeset, Repository } from "@scm-manager/ui-types"; import { Interpolate, translate } from "react-i18next"; import injectSheet from "react-jss"; -import ChangesetTag from "./ChangesetTag"; -import ChangesetAuthor from "./ChangesetAuthor"; -import { parseDescription } from "./changesets"; -import { DateFromNow } from "@scm-manager/ui-components"; -import AvatarWrapper from "./AvatarWrapper"; -import AvatarImage from "./AvatarImage"; + +import { + DateFromNow, + ChangesetId, + ChangesetTag, + ChangesetAuthor, + ChangesetDiff, + AvatarWrapper, + AvatarImage, + changesets +} from "@scm-manager/ui-components"; + import classNames from "classnames"; -import ChangesetId from "./ChangesetId"; import type { Tag } from "@scm-manager/ui-types"; -import ScmDiff from "../../containers/ScmDiff"; +import { ExtensionPoint } from "@scm-manager/ui-extensions"; const styles = { spacing: { marginRight: "1em" + }, + tags: { + "& .tag": { + marginLeft: ".25rem" + } } }; @@ -31,7 +41,7 @@ class ChangesetDetails extends React.Component<Props> { render() { const { changeset, repository, classes } = this.props; - const description = parseDescription(changeset.description); + const description = changesets.parseDescription(changeset.description); const id = ( <ChangesetId repository={repository} changeset={changeset} link={false} /> @@ -41,11 +51,19 @@ class ChangesetDetails extends React.Component<Props> { return ( <div> <div className="content"> - <h4>{description.title}</h4> + <h4> + <ExtensionPoint + name="changeset.description" + props={{ changeset, value: description.title }} + renderAll={false} + > + {description.title} + </ExtensionPoint> + </h4> <article className="media"> <AvatarWrapper> <p className={classNames("image", "is-64x64", classes.spacing)}> - <AvatarImage changeset={changeset} /> + <AvatarImage person={changeset.author} /> </p> </AvatarWrapper> <div className="media-content"> @@ -54,7 +72,7 @@ class ChangesetDetails extends React.Component<Props> { </p> <p> <Interpolate - i18nKey="changesets.changeset.summary" + i18nKey="changeset.summary" id={id} time={date} /> @@ -62,11 +80,18 @@ class ChangesetDetails extends React.Component<Props> { </div> <div className="media-right">{this.renderTags()}</div> </article> + <p> {description.message.split("\n").map((item, key) => { return ( <span key={key}> - {item} + <ExtensionPoint + name="changeset.description" + props={{ changeset, value: item }} + renderAll={false} + > + {item} + </ExtensionPoint> <br /> </span> ); @@ -74,7 +99,7 @@ class ChangesetDetails extends React.Component<Props> { </p> </div> <div> - <ScmDiff changeset={changeset} sideBySide={false} /> + <ChangesetDiff changeset={changeset} /> </div> </div> ); @@ -86,10 +111,11 @@ class ChangesetDetails extends React.Component<Props> { }; renderTags = () => { + const { classes } = this.props; const tags = this.getTags(); if (tags.length > 0) { return ( - <div className="level-item"> + <div className={classNames("level-item", classes.tags)}> {tags.map((tag: Tag) => { return <ChangesetTag key={tag.name} tag={tag} />; })} diff --git a/scm-ui/src/repos/components/changesets/ChangesetRow.js b/scm-ui/src/repos/components/changesets/ChangesetRow.js deleted file mode 100644 index 0215abedd1..0000000000 --- a/scm-ui/src/repos/components/changesets/ChangesetRow.js +++ /dev/null @@ -1,101 +0,0 @@ -//@flow -import React from "react"; -import type {Changeset, Repository, Tag} from "@scm-manager/ui-types"; -import classNames from "classnames"; -import {Interpolate, translate} from "react-i18next"; -import ChangesetId from "./ChangesetId"; -import injectSheet from "react-jss"; -import {DateFromNow} from "@scm-manager/ui-components"; -import ChangesetAuthor from "./ChangesetAuthor"; -import ChangesetTag from "./ChangesetTag"; -import {compose} from "redux"; -import {parseDescription} from "./changesets"; -import AvatarWrapper from "./AvatarWrapper"; -import AvatarImage from "./AvatarImage"; - -const styles = { - pointer: { - cursor: "pointer" - }, - changesetGroup: { - marginBottom: "1em" - }, - withOverflow: { - overflow: "auto" - } -}; - -type Props = { - repository: Repository, - changeset: Changeset, - t: any, - classes: any -}; - -class ChangesetRow extends React.Component<Props> { - createLink = (changeset: Changeset) => { - const { repository } = this.props; - return <ChangesetId changeset={changeset} repository={repository} />; - }; - - getTags = () => { - const { changeset } = this.props; - return changeset._embedded.tags || []; - }; - - render() { - const { changeset, classes } = this.props; - const changesetLink = this.createLink(changeset); - const dateFromNow = <DateFromNow date={changeset.date} />; - const authorLine = <ChangesetAuthor changeset={changeset} />; - const description = parseDescription(changeset.description); - - return ( - <article className={classNames("media", classes.inner)}> - <AvatarWrapper> - <div> - <figure className="media-left"> - <p className="image is-64x64"> - <AvatarImage changeset={changeset} /> - </p> - </figure> - </div> - </AvatarWrapper> - <div className={classNames("media-content", classes.withOverflow)}> - <div className="content"> - <p className="is-ellipsis-overflow"> - <strong>{description.title}</strong> - <br /> - <Interpolate - i18nKey="changesets.changeset.summary" - id={changesetLink} - time={dateFromNow} - /> - </p>{" "} - <div className="is-size-7">{authorLine}</div> - </div> - </div> - {this.renderTags()} - </article> - ); - } - - renderTags = () => { - const tags = this.getTags(); - if (tags.length > 0) { - return ( - <div className="media-right"> - {tags.map((tag: Tag) => { - return <ChangesetTag key={tag.name} tag={tag} />; - })} - </div> - ); - } - return null; - }; -} - -export default compose( - injectSheet(styles), - translate("repos") -)(ChangesetRow); diff --git a/scm-ui/src/repos/components/changesets/ChangesetTag.js b/scm-ui/src/repos/components/changesets/ChangesetTag.js deleted file mode 100644 index 6a87400d2e..0000000000 --- a/scm-ui/src/repos/components/changesets/ChangesetTag.js +++ /dev/null @@ -1,32 +0,0 @@ -//@flow -import React from "react"; -import type { Tag } from "@scm-manager/ui-types"; -import injectSheet from "react-jss"; -import classNames from "classnames"; - -const styles = { - spacing: { - marginRight: "4px" - } -}; - -type Props = { - tag: Tag, - - // context props - classes: Object -}; - -class ChangesetTag extends React.Component<Props> { - render() { - const { tag, classes } = this.props; - return ( - <span className="tag is-info"> - <span className={classNames("fa", "fa-tag", classes.spacing)} />{" "} - {tag.name} - </span> - ); - } -} - -export default injectSheet(styles)(ChangesetTag); diff --git a/scm-ui/src/repos/components/form/RepositoryForm.js b/scm-ui/src/repos/components/form/RepositoryForm.js index 8f5d932778..fcd88f0417 100644 --- a/scm-ui/src/repos/components/form/RepositoryForm.js +++ b/scm-ui/src/repos/components/form/RepositoryForm.js @@ -2,6 +2,7 @@ import React from "react"; import { translate } from "react-i18next"; import { + Subtitle, InputField, Select, SubmitButton, @@ -81,30 +82,39 @@ class RepositoryForm extends React.Component<Props, State> { const { loading, t } = this.props; const repository = this.state.repository; - return ( - <form onSubmit={this.submit}> - {this.renderCreateOnlyFields()} - <InputField - label={t("repository.contact")} - onChange={this.handleContactChange} - value={repository ? repository.contact : ""} - validationError={this.state.contactValidationError} - errorMessage={t("validation.contact-invalid")} - helpText={t("help.contactHelpText")} - /> + let subtitle = null; + if (this.props.repository) { + // edit existing repo + subtitle = <Subtitle subtitle={t("repositoryForm.subtitle")} />; + } - <Textarea - label={t("repository.description")} - onChange={this.handleDescriptionChange} - value={repository ? repository.description : ""} - helpText={t("help.descriptionHelpText")} - /> - <SubmitButton - disabled={!this.isValid()} - loading={loading} - label={t("repository-form.submit")} - /> - </form> + return ( + <> + {subtitle} + <form onSubmit={this.submit}> + {this.renderCreateOnlyFields()} + <InputField + label={t("repository.contact")} + onChange={this.handleContactChange} + value={repository ? repository.contact : ""} + validationError={this.state.contactValidationError} + errorMessage={t("validation.contact-invalid")} + helpText={t("help.contactHelpText")} + /> + + <Textarea + label={t("repository.description")} + onChange={this.handleDescriptionChange} + value={repository ? repository.description : ""} + helpText={t("help.descriptionHelpText")} + /> + <SubmitButton + disabled={!this.isValid()} + loading={loading} + label={t("repositoryForm.submit")} + /> + </form> + </> ); } diff --git a/scm-ui/src/repos/components/list/RepositoryEntry.js b/scm-ui/src/repos/components/list/RepositoryEntry.js index bc170144aa..b8b03a3523 100644 --- a/scm-ui/src/repos/components/list/RepositoryEntry.js +++ b/scm-ui/src/repos/components/list/RepositoryEntry.js @@ -9,16 +9,6 @@ import classNames from "classnames"; import RepositoryAvatar from "./RepositoryAvatar"; const styles = { - outer: { - position: "relative" - }, - overlay: { - position: "absolute", - left: 0, - top: 0, - bottom: 0, - right: 0 - }, inner: { position: "relative", pointerEvents: "none", @@ -26,11 +16,16 @@ const styles = { }, innerLink: { pointerEvents: "all" + }, + centerImage: { + marginTop: "0.8em", + marginLeft: "1em !important" } }; type Props = { repository: Repository, + fullColumnWidth?: boolean, // context props classes: any }; @@ -44,7 +39,7 @@ class RepositoryEntry extends React.Component<Props> { if (repository._links["changesets"]) { return ( <RepositoryEntryLink - iconClass="fa-code-branch" + iconClass="fa-code-branch fa-lg" to={repositoryLink + "/changesets"} /> ); @@ -56,7 +51,7 @@ class RepositoryEntry extends React.Component<Props> { if (repository._links["sources"]) { return ( <RepositoryEntryLink - iconClass="fa-code" + iconClass="fa-code fa-lg" to={repositoryLink + "/sources"} /> ); @@ -67,29 +62,43 @@ class RepositoryEntry extends React.Component<Props> { renderModifyLink = (repository: Repository, repositoryLink: string) => { if (repository._links["update"]) { return ( - <RepositoryEntryLink iconClass="fa-cog" to={repositoryLink + "/edit"} /> + <RepositoryEntryLink + iconClass="fa-cog fa-lg" + to={repositoryLink + "/settings/general"} + /> ); } return null; }; render() { - const { repository, classes } = this.props; + const { repository, classes, fullColumnWidth } = this.props; const repositoryLink = this.createLink(repository); + const halfColumn = fullColumnWidth ? "is-full" : "is-half"; + const overlayLinkClass = fullColumnWidth + ? "overlay-full-column" + : "overlay-half-column"; return ( - <div className={classNames("box", "box-link-shadow", classes.outer)}> - <Link className={classes.overlay} to={repositoryLink} /> + <div + className={classNames( + "box", + "box-link-shadow", + "column", + "is-clipped", + halfColumn + )} + > + <Link className={classNames(overlayLinkClass)} to={repositoryLink} /> <article className={classNames("media", classes.inner)}> - <figure className="media-left"> + <figure className={classNames(classes.centerImage, "media-left")}> <RepositoryAvatar repository={repository} /> </figure> - <div className="media-content"> + <div className={classNames("media-content", "text-box")}> <div className="content"> - <p> + <p className="is-marginless"> <strong>{repository.name}</strong> - <br /> - {repository.description} </p> + <p className={"shorten-text"}>{repository.description}</p> </div> <nav className="level is-mobile"> <div className="level-left"> diff --git a/scm-ui/src/repos/components/list/RepositoryEntryLink.js b/scm-ui/src/repos/components/list/RepositoryEntryLink.js index 289ec7d326..e4fa2a623c 100644 --- a/scm-ui/src/repos/components/list/RepositoryEntryLink.js +++ b/scm-ui/src/repos/components/list/RepositoryEntryLink.js @@ -1,34 +1,35 @@ -//@flow -import React from "react"; -import { Link } from "react-router-dom"; -import injectSheet from "react-jss"; -import classNames from "classnames"; - -const styles = { - link: { - pointerEvents: "all" - } -}; - -type Props = { - to: string, - iconClass: string, - - // context props - classes: any -}; - -class RepositoryEntryLink extends React.Component<Props> { - render() { - const { to, iconClass, classes } = this.props; - return ( - <Link className={classNames("level-item", classes.link)} to={to}> - <span className="icon is-small"> - <i className={classNames("fa", iconClass)} /> - </span> - </Link> - ); - } -} - -export default injectSheet(styles)(RepositoryEntryLink); +//@flow +import React from "react"; +import { Link } from "react-router-dom"; +import injectSheet from "react-jss"; +import classNames from "classnames"; + +const styles = { + link: { + pointerEvents: "all", + marginRight: "1.25rem !important" + } +}; + +type Props = { + to: string, + iconClass: string, + + // context props + classes: any +}; + +class RepositoryEntryLink extends React.Component<Props> { + render() { + const { to, iconClass, classes } = this.props; + return ( + <Link className={classNames("level-item", classes.link)} to={to}> + <span className="icon is-small"> + <i className={classNames("fa", iconClass)} /> + </span> + </Link> + ); + } +} + +export default injectSheet(styles)(RepositoryEntryLink); diff --git a/scm-ui/src/repos/components/list/RepositoryGroupEntry.js b/scm-ui/src/repos/components/list/RepositoryGroupEntry.js index 66d52d544b..98e7925150 100644 --- a/scm-ui/src/repos/components/list/RepositoryGroupEntry.js +++ b/scm-ui/src/repos/components/list/RepositoryGroupEntry.js @@ -1,16 +1,23 @@ //@flow import React from "react"; -import type { RepositoryGroup } from "@scm-manager/ui-types"; +import type { RepositoryGroup, Repository } from "@scm-manager/ui-types"; import injectSheet from "react-jss"; import classNames from "classnames"; import RepositoryEntry from "./RepositoryEntry"; const styles = { pointer: { - cursor: "pointer" + cursor: "pointer", + fontSize: "1.5rem" }, repoGroup: { marginBottom: "1em" + }, + wrapper: { + padding: "0 0.75rem" + }, + clearfix: { + clear: "both" } }; @@ -39,6 +46,18 @@ class RepositoryGroupEntry extends React.Component<Props, State> { })); }; + isLastEntry = (array: Repository[], index: number) => { + return index === array.length - 1; + }; + + isLengthOdd = (array: Repository[]) => { + return array.length % 2 !== 0; + }; + + isFullSize = (array: Repository[], index: number) => { + return this.isLastEntry(array, index) && this.isLengthOdd(array); + }; + render() { const { group, classes } = this.props; const { collapsed } = this.state; @@ -47,7 +66,10 @@ class RepositoryGroupEntry extends React.Component<Props, State> { let content = null; if (!collapsed) { content = group.repositories.map((repository, index) => { - return <RepositoryEntry repository={repository} key={index} />; + const fullColumnWidth = this.isFullSize(group.repositories, index); + return ( + <RepositoryEntry repository={repository} fullColumnWidth={fullColumnWidth} key={index} /> + ); }); } return ( @@ -58,7 +80,10 @@ class RepositoryGroupEntry extends React.Component<Props, State> { </span> </h2> <hr /> - {content} + <div className={classNames("columns", "is-multiline", classes.wrapper)}> + {content} + </div> + <div className={classes.clearfix} /> </div> ); } diff --git a/scm-ui/src/repos/containers/ChangesetView.js b/scm-ui/src/repos/containers/ChangesetView.js index 80ab0b71d6..dc53b5d798 100644 --- a/scm-ui/src/repos/containers/ChangesetView.js +++ b/scm-ui/src/repos/containers/ChangesetView.js @@ -37,8 +37,8 @@ class ChangesetView extends React.Component<Props> { if (error) { return ( <ErrorPage - title={t("changeset-error.title")} - subtitle={t("changeset-error.subtitle")} + title={t("changesets.errorTitle")} + subtitle={t("changesets.errorSubtitle")} error={error} /> ); diff --git a/scm-ui/src/repos/containers/Changesets.js b/scm-ui/src/repos/containers/Changesets.js index 3d30e9f8be..5edc677e60 100644 --- a/scm-ui/src/repos/containers/Changesets.js +++ b/scm-ui/src/repos/containers/Changesets.js @@ -1,8 +1,13 @@ // @flow import React from "react"; -import {withRouter} from "react-router-dom"; -import type {Branch, Changeset, PagedCollection, Repository} from "@scm-manager/ui-types"; +import { withRouter } from "react-router-dom"; +import type { + Branch, + Changeset, + PagedCollection, + Repository +} from "@scm-manager/ui-types"; import { fetchChangesets, getChangesets, @@ -11,10 +16,15 @@ import { selectListAsCollection } from "../modules/changesets"; -import {connect} from "react-redux"; -import ChangesetList from "../components/changesets/ChangesetList"; -import {ErrorNotification, getPageFromMatch, LinkPaginator, Loading} from "@scm-manager/ui-components"; -import {compose} from "redux"; +import { connect } from "react-redux"; +import { + ErrorNotification, + getPageFromMatch, + LinkPaginator, + ChangesetList, + Loading +} from "@scm-manager/ui-components"; +import { compose } from "redux"; type Props = { repository: Repository, @@ -65,13 +75,21 @@ class Changesets extends React.Component<Props> { renderList = () => { const { repository, changesets } = this.props; - return <ChangesetList repository={repository} changesets={changesets} />; + return ( + <div className="panel-block"> + <ChangesetList repository={repository} changesets={changesets} /> + </div> + ); }; renderPaginator = () => { const { page, list } = this.props; if (list) { - return <LinkPaginator page={page} collection={list} />; + return ( + <div className="panel-footer"> + <LinkPaginator page={page} collection={list} /> + </div> + ); } return null; }; diff --git a/scm-ui/src/repos/containers/ChangesetsRoot.js b/scm-ui/src/repos/containers/ChangesetsRoot.js index 1f3f0c1e3b..f6e0c3d359 100644 --- a/scm-ui/src/repos/containers/ChangesetsRoot.js +++ b/scm-ui/src/repos/containers/ChangesetsRoot.js @@ -2,11 +2,15 @@ import React from "react"; import type { Branch, Repository } from "@scm-manager/ui-types"; +import { translate } from "react-i18next"; import { Route, withRouter } from "react-router-dom"; import Changesets from "./Changesets"; -import BranchSelector from "./BranchSelector"; import { connect } from "react-redux"; -import { ErrorNotification, Loading } from "@scm-manager/ui-components"; +import { + BranchSelector, + ErrorNotification, + Loading +} from "@scm-manager/ui-components"; import { fetchBranches, getBranches, @@ -32,7 +36,8 @@ type Props = { // Context props history: any, // TODO flow type - match: any + match: any, + t: string => string }; class BranchRoot extends React.Component<Props> { @@ -84,18 +89,21 @@ class BranchRoot extends React.Component<Props> { const changesets = <Changesets repository={repository} branch={branch} />; return ( - <> + <div className="panel"> + <div className="panel-heading"> {this.renderBranchSelector()} + </div> <Route path={`${url}/:page?`} component={() => changesets} /> - </> + </div> ); } renderBranchSelector = () => { - const { repository, branches, selected } = this.props; + const { repository, branches, selected, t } = this.props; if (repository._links.branches) { return ( <BranchSelector + label={t("changesets.branchSelectorLabel")} branches={branches} selectedBranch={selected} selected={(b: Branch) => { @@ -133,6 +141,7 @@ const mapStateToProps = (state: any, ownProps: Props) => { export default compose( withRouter, + translate("repos"), connect( mapStateToProps, mapDispatchToProps diff --git a/scm-ui/src/repos/containers/Create.js b/scm-ui/src/repos/containers/Create.js index 4cf8d468de..2cdd61fbbd 100644 --- a/scm-ui/src/repos/containers/Create.js +++ b/scm-ui/src/repos/containers/Create.js @@ -29,7 +29,11 @@ type Props = { // dispatch functions fetchRepositoryTypesIfNeeded: () => void, - createRepo: (link: string, Repository, callback: () => void) => void, + createRepo: ( + link: string, + Repository, + callback: (repo: Repository) => void + ) => void, resetForm: () => void, // context props @@ -43,9 +47,10 @@ class Create extends React.Component<Props> { this.props.fetchRepositoryTypesIfNeeded(); } - repoCreated = () => { + repoCreated = (repo: Repository) => { const { history } = this.props; - history.push("/repos"); + + history.push("/repo/" + repo.namespace + "/" + repo.name); }; render() { @@ -70,7 +75,9 @@ class Create extends React.Component<Props> { repositoryTypes={repositoryTypes} loading={createLoading} submitForm={repo => { - createRepo(repoLink, repo, this.repoCreated); + createRepo(repoLink, repo, (repo: Repository) => + this.repoCreated(repo) + ); }} /> </Page> diff --git a/scm-ui/src/repos/containers/DeleteRepo.js b/scm-ui/src/repos/containers/DeleteRepo.js new file mode 100644 index 0000000000..b621a1998b --- /dev/null +++ b/scm-ui/src/repos/containers/DeleteRepo.js @@ -0,0 +1,114 @@ +//@flow +import React from "react"; +import { translate } from "react-i18next"; +import type { Repository } from "@scm-manager/ui-types"; +import { + Subtitle, + DeleteButton, + confirmAlert, + ErrorNotification +} from "@scm-manager/ui-components"; +import { + deleteRepo, + getDeleteRepoFailure, + isDeleteRepoPending +} from "../modules/repos"; +import { connect } from "react-redux"; +import { withRouter } from "react-router-dom"; +import type { History } from "history"; + +type Props = { + loading: boolean, + error: Error, + repository: Repository, + confirmDialog?: boolean, + deleteRepo: (Repository, () => void) => void, + + // context props + history: History, + t: string => string +}; + +class DeleteRepo extends React.Component<Props> { + static defaultProps = { + confirmDialog: true + }; + + deleted = () => { + this.props.history.push("/repos"); + }; + + deleteRepo = () => { + this.props.deleteRepo(this.props.repository, this.deleted); + }; + + confirmDelete = () => { + const { t } = this.props; + confirmAlert({ + title: t("deleteRepo.confirmAlert.title"), + message: t("deleteRepo.confirmAlert.message"), + buttons: [ + { + label: t("deleteRepo.confirmAlert.submit"), + onClick: () => this.deleteRepo() + }, + { + label: t("deleteRepo.confirmAlert.cancel"), + onClick: () => null + } + ] + }); + }; + + isDeletable = () => { + return this.props.repository._links.delete; + }; + + render() { + const { loading, error, confirmDialog, t } = this.props; + const action = confirmDialog ? this.confirmDelete : this.deleteRepo; + + if (!this.isDeletable()) { + return null; + } + + return ( + <> + <Subtitle subtitle={t("deleteRepo.subtitle")} /> + <ErrorNotification error={error} /> + <div className="columns"> + <div className="column"> + <DeleteButton + label={t("deleteRepo.button")} + action={action} + loading={loading} + /> + </div> + </div> + </> + ); + } +} + +const mapStateToProps = (state, ownProps) => { + const { namespace, name } = ownProps.repository; + const loading = isDeleteRepoPending(state, namespace, name); + const error = getDeleteRepoFailure(state, namespace, name); + return { + loading, + error + }; +}; + +const mapDispatchToProps = dispatch => { + return { + deleteRepo: (repo: Repository, callback: () => void) => { + dispatch(deleteRepo(repo, callback)); + } + }; +}; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(withRouter(translate("repos")(DeleteRepo))); diff --git a/scm-ui/src/repos/containers/Edit.js b/scm-ui/src/repos/containers/EditRepo.js similarity index 69% rename from scm-ui/src/repos/containers/Edit.js rename to scm-ui/src/repos/containers/EditRepo.js index 816dae8de9..2e5bb69172 100644 --- a/scm-ui/src/repos/containers/Edit.js +++ b/scm-ui/src/repos/containers/EditRepo.js @@ -1,8 +1,9 @@ // @flow import React from "react"; import { connect } from "react-redux"; -import { translate } from "react-i18next"; +import { withRouter } from "react-router-dom"; import RepositoryForm from "../components/form"; +import DeleteRepo from "./DeleteRepo"; import type { Repository } from "@scm-manager/ui-types"; import { modifyRepo, @@ -10,34 +11,55 @@ import { getModifyRepoFailure, modifyRepoReset } from "../modules/repos"; -import { withRouter } from "react-router-dom"; import type { History } from "history"; import { ErrorNotification } from "@scm-manager/ui-components"; +import { ExtensionPoint } from "@scm-manager/ui-extensions"; type Props = { - repository: Repository, - modifyRepo: (Repository, () => void) => void, - modifyRepoReset: Repository => void, loading: boolean, error: Error, + modifyRepo: (Repository, () => void) => void, + modifyRepoReset: Repository => void, + // context props - t: string => string, - history: History + repository: Repository, + history: History, + match: any }; -class Edit extends React.Component<Props> { +class EditRepo extends React.Component<Props> { componentDidMount() { const { modifyRepoReset, repository } = this.props; modifyRepoReset(repository); } + repoModified = () => { const { history, repository } = this.props; history.push(`/repo/${repository.namespace}/${repository.name}`); }; + stripEndingSlash = (url: string) => { + if (url.endsWith("/")) { + return url.substring(0, url.length - 2); + } + return url; + }; + + matchedUrl = () => { + return this.stripEndingSlash(this.props.match.url); + }; + render() { - const { loading, error } = this.props; + const { loading, error, repository } = this.props; + + const url = this.matchedUrl(); + + const extensionProps = { + repository, + url + }; + return ( <div> <ErrorNotification error={error} /> @@ -48,6 +70,13 @@ class Edit extends React.Component<Props> { this.props.modifyRepo(repo, this.repoModified); }} /> + <hr /> + <ExtensionPoint + name="repo-config.route" + props={extensionProps} + renderAll={true} + /> + <DeleteRepo repository={repository} /> </div> ); } @@ -77,4 +106,4 @@ const mapDispatchToProps = dispatch => { export default connect( mapStateToProps, mapDispatchToProps -)(translate("repos")(withRouter(Edit))); +)(withRouter(EditRepo)); diff --git a/scm-ui/src/repos/containers/Overview.js b/scm-ui/src/repos/containers/Overview.js index bbafe14539..ac044b5225 100644 --- a/scm-ui/src/repos/containers/Overview.js +++ b/scm-ui/src/repos/containers/Overview.js @@ -14,7 +14,13 @@ import { isFetchReposPending } from "../modules/repos"; import { translate } from "react-i18next"; -import { CreateButton, Page, Paginator } from "@scm-manager/ui-components"; +import { + Page, + PageActions, + Button, + CreateButton, + Paginator +} from "@scm-manager/ui-components"; import RepositoryList from "../components/list"; import { withRouter } from "react-router-dom"; import type { History } from "history"; @@ -67,6 +73,13 @@ class Overview extends React.Component<Props> { error={error} > {this.renderList()} + <PageActions> + <Button + label={t("overview.createButton")} + link="/repos/create" + color="primary" + /> + </PageActions> </Page> ); } @@ -89,10 +102,7 @@ class Overview extends React.Component<Props> { const { showCreateButton, t } = this.props; if (showCreateButton) { return ( - <CreateButton - label={t("overview.create-button")} - link="/repos/create" - /> + <CreateButton label={t("overview.createButton")} link="/repos/create" /> ); } return null; diff --git a/scm-ui/src/repos/containers/RepositoryRoot.js b/scm-ui/src/repos/containers/RepositoryRoot.js index 9b8991a91a..8608b9c957 100644 --- a/scm-ui/src/repos/containers/RepositoryRoot.js +++ b/scm-ui/src/repos/containers/RepositoryRoot.js @@ -1,7 +1,6 @@ //@flow import React from "react"; import { - deleteRepo, fetchRepoByName, getFetchRepoFailure, getRepository, @@ -16,26 +15,26 @@ import { ErrorPage, Loading, Navigation, + SubNavigation, NavLink, Page, Section } from "@scm-manager/ui-components"; import { translate } from "react-i18next"; import RepositoryDetails from "../components/RepositoryDetails"; -import DeleteNavAction from "../components/DeleteNavAction"; -import Edit from "../containers/Edit"; +import EditRepo from "./EditRepo"; import Permissions from "../permissions/containers/Permissions"; import type { History } from "history"; -import EditNavLink from "../components/EditNavLink"; +import EditRepoNavLink from "../components/EditRepoNavLink"; import BranchRoot from "./ChangesetsRoot"; import ChangesetView from "./ChangesetView"; import PermissionsNavLink from "../components/PermissionsNavLink"; import Sources from "../sources/containers/Sources"; import RepositoryNavLink from "../components/RepositoryNavLink"; -import { getRepositoriesLink } from "../../modules/indexResource"; -import {ExtensionPoint} from '@scm-manager/ui-extensions'; +import {getLinks, getRepositoriesLink} from "../../modules/indexResource"; +import {ExtensionPoint} from "@scm-manager/ui-extensions"; type Props = { namespace: string, @@ -44,10 +43,10 @@ type Props = { loading: boolean, error: Error, repoLink: string, + indexLinks: Object, // dispatch functions fetchRepoByName: (link: string, namespace: string, name: string) => void, - deleteRepo: (repository: Repository, () => void) => void, // context props t: string => string, @@ -73,14 +72,6 @@ class RepositoryRoot extends React.Component<Props> { return this.stripEndingSlash(this.props.match.url); }; - deleted = () => { - this.props.history.push("/repos"); - }; - - delete = (repository: Repository) => { - this.props.deleteRepo(repository, this.deleted); - }; - matches = (route: any) => { const url = this.matchedUrl(); const regex = new RegExp(`${url}(/branches)?/?[^/]*/changesets?.*`); @@ -88,13 +79,13 @@ class RepositoryRoot extends React.Component<Props> { }; render() { - const { loading, error, repository, t } = this.props; + const { loading, error, indexLinks, repository, t } = this.props; if (error) { return ( <ErrorPage - title={t("repository-root.error-title")} - subtitle={t("repository-root.error-subtitle")} + title={t("repositoryRoot.errorTitle")} + subtitle={t("repositoryRoot.errorSubtitle")} error={error} /> ); @@ -108,13 +99,14 @@ class RepositoryRoot extends React.Component<Props> { const extensionProps = { repository, - url + url, + indexLinks }; return ( <Page title={repository.namespace + "/" + repository.name}> <div className="columns"> - <div className="column is-three-quarters"> + <div className="column is-three-quarters is-clipped"> <Switch> <Route path={url} @@ -122,11 +114,11 @@ class RepositoryRoot extends React.Component<Props> { component={() => <RepositoryDetails repository={repository} />} /> <Route - path={`${url}/edit`} - component={() => <Edit repository={repository} />} + path={`${url}/settings/general`} + component={() => <EditRepo repository={repository} />} /> <Route - path={`${url}/permissions`} + path={`${url}/settings/permissions`} render={() => ( <Permissions namespace={this.props.repository.namespace} @@ -172,21 +164,27 @@ class RepositoryRoot extends React.Component<Props> { /> )} /> - <ExtensionPoint name="repository.route" - props={extensionProps} - renderAll={true} + <ExtensionPoint + name="repository.route" + props={extensionProps} + renderAll={true} /> </Switch> </div> <div className="column"> <Navigation> - <Section label={t("repository-root.navigation-label")}> - <NavLink to={url} label={t("repository-root.information")} /> + <Section label={t("repositoryRoot.menu.navigationLabel")}> + <NavLink + to={url} + icon="fas fa-info-circle" + label={t("repositoryRoot.menu.informationNavLink")} + /> <RepositoryNavLink repository={repository} linkName="changesets" to={`${url}/changesets/`} - label={t("repository-root.history")} + icon="fas fa-code-branch" + label={t("repositoryRoot.menu.historyNavLink")} activeWhenMatch={this.matches} activeOnlyWhenExact={false} /> @@ -194,22 +192,33 @@ class RepositoryRoot extends React.Component<Props> { repository={repository} linkName="sources" to={`${url}/sources`} - label={t("repository-root.sources")} + icon="fas fa-code" + label={t("repositoryRoot.menu.sourcesNavLink")} activeOnlyWhenExact={false} /> - <ExtensionPoint name="repository.navigation" - props={extensionProps} - renderAll={true} + <ExtensionPoint + name="repository.navigation" + props={extensionProps} + renderAll={true} /> - <PermissionsNavLink - permissionUrl={`${url}/permissions`} - repository={repository} - /> - <EditNavLink repository={repository} editUrl={`${url}/edit`} /> - </Section> - <Section label={t("repository-root.actions-label")}> - <DeleteNavAction repository={repository} delete={this.delete} /> - <NavLink to="/repos" label={t("repository-root.back-label")} /> + <SubNavigation + to={`${url}/settings/general`} + label={t("repositoryRoot.menu.settingsNavLink")} + > + <EditRepoNavLink + repository={repository} + editUrl={`${url}/settings/general`} + /> + <PermissionsNavLink + permissionUrl={`${url}/settings/permissions`} + repository={repository} + /> + <ExtensionPoint + name="repository.setting" + props={extensionProps} + renderAll={true} + /> + </SubNavigation> </Section> </Navigation> </div> @@ -225,13 +234,15 @@ const mapStateToProps = (state, ownProps) => { const loading = isFetchRepoPending(state, namespace, name); const error = getFetchRepoFailure(state, namespace, name); const repoLink = getRepositoriesLink(state); + const indexLinks = getLinks(state); return { namespace, name, repository, loading, error, - repoLink + repoLink, + indexLinks }; }; @@ -239,9 +250,6 @@ const mapDispatchToProps = dispatch => { return { fetchRepoByName: (link: string, namespace: string, name: string) => { dispatch(fetchRepoByName(link, namespace, name)); - }, - deleteRepo: (repository: Repository, callback: () => void) => { - dispatch(deleteRepo(repository, callback)); } }; }; diff --git a/scm-ui/src/repos/containers/ScmDiff.js b/scm-ui/src/repos/containers/ScmDiff.js deleted file mode 100644 index b2df677eb3..0000000000 --- a/scm-ui/src/repos/containers/ScmDiff.js +++ /dev/null @@ -1,51 +0,0 @@ -// @flow - -import React from "react"; -import { apiClient } from "@scm-manager/ui-components"; -import type { Changeset } from "@scm-manager/ui-types"; -import { Diff2Html } from "diff2html"; - -type Props = { - changeset: Changeset, - sideBySide: boolean -}; - -type State = { - diff: string, - error?: Error -}; - -class ScmDiff extends React.Component<Props, State> { - constructor(props: Props) { - super(props); - this.state = { diff: "" }; - } - - componentDidMount() { - const { changeset } = this.props; - const url = changeset._links.diff.href+"?format=GIT"; - apiClient - .get(url) - .then(response => response.text()) - .then(text => this.setState({ ...this.state, diff: text })) - .catch(error => this.setState({ ...this.state, error })); - } - - render() { - const options = { - inputFormat: "diff", - outputFormat: this.props.sideBySide ? "side-by-side" : "line-by-line", - showFiles: false, - matching: "lines" - }; - - const outputHtml = Diff2Html.getPrettyHtml(this.state.diff, options); - - return ( - // eslint-disable-next-line react/no-danger - <div dangerouslySetInnerHTML={{ __html: outputHtml }} /> - ); - } -} - -export default ScmDiff; diff --git a/scm-ui/src/repos/modules/repos.js b/scm-ui/src/repos/modules/repos.js index dd5985d444..fa89dc42a6 100644 --- a/scm-ui/src/repos/modules/repos.js +++ b/scm-ui/src/repos/modules/repos.js @@ -164,16 +164,21 @@ export function fetchRepoFailure( export function createRepo( link: string, repository: Repository, - callback?: () => void + callback?: (repo: Repository) => void ) { return function(dispatch: any) { dispatch(createRepoPending()); return apiClient .post(link, repository, CONTENT_TYPE) - .then(() => { + .then(response => { + const location = response.headers.get("Location"); dispatch(createRepoSuccess()); + return apiClient.get(location); + }) + .then(response => response.json()) + .then(response => { if (callback) { - callback(); + callback(response); } }) .catch(err => { diff --git a/scm-ui/src/repos/modules/repos.test.js b/scm-ui/src/repos/modules/repos.test.js index e8d9873e99..ca4b6802b8 100644 --- a/scm-ui/src/repos/modules/repos.test.js +++ b/scm-ui/src/repos/modules/repos.test.js @@ -415,9 +415,14 @@ describe("repos fetch", () => { it("should successfully create repo slarti/fjords", () => { fetchMock.postOnce(REPOS_URL, { - status: 201 + status: 201, + headers: { + location: "repositories/slarti/fjords" + } }); + fetchMock.getOnce(REPOS_URL + "/slarti/fjords", slartiFjords); + const expectedActions = [ { type: CREATE_REPO_PENDING @@ -435,12 +440,19 @@ describe("repos fetch", () => { it("should successfully create repo slarti/fjords and call the callback", () => { fetchMock.postOnce(REPOS_URL, { - status: 201 + status: 201, + headers: { + location: "repositories/slarti/fjords" + } }); + + fetchMock.getOnce(REPOS_URL + "/slarti/fjords", slartiFjords); + let callMe = "not yet"; - const callback = () => { + const callback = (r: any) => { + expect(r).toEqual(slartiFjords); callMe = "yeah"; }; diff --git a/scm-ui/src/repos/permissions/components/CreatePermissionForm.js b/scm-ui/src/repos/permissions/components/CreatePermissionForm.js deleted file mode 100644 index a674f6b41f..0000000000 --- a/scm-ui/src/repos/permissions/components/CreatePermissionForm.js +++ /dev/null @@ -1,122 +0,0 @@ -// @flow -import React from "react"; -import {translate} from "react-i18next"; -import {Checkbox, InputField, SubmitButton} from "@scm-manager/ui-components"; -import TypeSelector from "./TypeSelector"; -import type {PermissionCollection, PermissionCreateEntry} from "@scm-manager/ui-types"; -import * as validator from "./permissionValidation"; - -type Props = { - t: string => string, - createPermission: (permission: PermissionCreateEntry) => void, - loading: boolean, - currentPermissions: PermissionCollection -}; - -type State = { - name: string, - type: string, - groupPermission: boolean, - valid: boolean -}; - -class CreatePermissionForm extends React.Component<Props, State> { - constructor(props: Props) { - super(props); - - this.state = { - name: "", - type: "READ", - groupPermission: false, - valid: true - }; - } - - render() { - const { t, loading } = this.props; - const { name, type, groupPermission } = this.state; - - return ( - <div> - <h2 className="subtitle"> - {t("permission.add-permission.add-permission-heading")} - </h2> - <form onSubmit={this.submit}> - <InputField - label={t("permission.name")} - value={name ? name : ""} - onChange={this.handleNameChange} - validationError={!this.state.valid} - errorMessage={t("permission.add-permission.name-input-invalid")} - helpText={t("permission.help.nameHelpText")} - /> - <Checkbox - label={t("permission.group-permission")} - checked={groupPermission ? groupPermission : false} - onChange={this.handleGroupPermissionChange} - helpText={t("permission.help.groupPermissionHelpText")} - /> - <TypeSelector - label={t("permission.type")} - helpText={t("permission.help.typeHelpText")} - handleTypeChange={this.handleTypeChange} - type={type ? type : "READ"} - /> - <SubmitButton - label={t("permission.add-permission.submit-button")} - loading={loading} - disabled={!this.state.valid || this.state.name === ""} - /> - </form> - </div> - ); - } - - submit = e => { - this.props.createPermission({ - name: this.state.name, - type: this.state.type, - groupPermission: this.state.groupPermission - }); - this.removeState(); - e.preventDefault(); - }; - - removeState = () => { - this.setState({ - name: "", - type: "READ", - groupPermission: false, - valid: true - }); - }; - - handleTypeChange = (type: string) => { - this.setState({ - type: type - }); - }; - - handleNameChange = (name: string) => { - this.setState({ - name: name, - valid: validator.isPermissionValid( - name, - this.state.groupPermission, - this.props.currentPermissions - ) - }); - }; - handleGroupPermissionChange = (groupPermission: boolean) => { - this.setState({ - groupPermission: groupPermission, - valid: validator.isPermissionValid( - this.state.name, - groupPermission, - this.props.currentPermissions - ) - }); - }; -} - -export default translate("repos")(CreatePermissionForm); diff --git a/scm-ui/src/repos/permissions/components/PermissionCheckbox.js b/scm-ui/src/repos/permissions/components/PermissionCheckbox.js new file mode 100644 index 0000000000..4ee6b6b768 --- /dev/null +++ b/scm-ui/src/repos/permissions/components/PermissionCheckbox.js @@ -0,0 +1,31 @@ +// @flow +import React from "react"; +import { translate } from "react-i18next"; +import { Checkbox } from "@scm-manager/ui-components"; + +type Props = { + t: string => string, + disabled: boolean, + name: string, + checked: boolean, + onChange?: (value: boolean, name?: string) => void +}; + +class PermissionCheckbox extends React.Component<Props> { + render() { + const { t } = this.props; + return ( + <Checkbox + key={this.props.name} + name={this.props.name} + helpText={t("verbs.repository." + this.props.name + ".description")} + label={t("verbs.repository." + this.props.name + ".displayName")} + checked={this.props.checked} + onChange={this.props.onChange} + disabled={this.props.disabled} + /> + ); + } +} + +export default translate("plugins")(PermissionCheckbox); diff --git a/scm-ui/src/repos/permissions/components/RoleSelector.js b/scm-ui/src/repos/permissions/components/RoleSelector.js new file mode 100644 index 0000000000..d472f17c4b --- /dev/null +++ b/scm-ui/src/repos/permissions/components/RoleSelector.js @@ -0,0 +1,55 @@ +// @flow +import React from "react"; +import { translate } from "react-i18next"; +import { Select } from "@scm-manager/ui-components"; + +type Props = { + t: string => string, + availableRoles: string[], + handleRoleChange: string => void, + role: string, + label?: string, + helpText?: string, + loading?: boolean +}; + +class RoleSelector extends React.Component<Props> { + render() { + const { + availableRoles, + role, + handleRoleChange, + loading, + label, + helpText + } = this.props; + + if (!availableRoles) return null; + + const options = role + ? this.createSelectOptions(availableRoles) + : ["", ...this.createSelectOptions(availableRoles)]; + + return ( + <Select + onChange={handleRoleChange} + value={role ? role : ""} + options={options} + loading={loading} + label={label} + helpText={helpText} + /> + ); + } + + createSelectOptions(roles: string[]) { + return roles.map(role => { + return { + label: role, + value: role + }; + }); + } +} + +export default translate("repos")(RoleSelector); diff --git a/scm-ui/src/repos/permissions/components/TypeSelector.js b/scm-ui/src/repos/permissions/components/TypeSelector.js deleted file mode 100644 index de1950fa78..0000000000 --- a/scm-ui/src/repos/permissions/components/TypeSelector.js +++ /dev/null @@ -1,42 +0,0 @@ -// @flow -import React from "react"; -import { translate } from "react-i18next"; -import { Select } from "@scm-manager/ui-components"; - -type Props = { - t: string => string, - handleTypeChange: string => void, - type: string, - label?: string, - helpText?: string, - loading?: boolean -}; - -class TypeSelector extends React.Component<Props> { - render() { - const { type, handleTypeChange, loading, label, helpText } = this.props; - const types = ["READ", "OWNER", "WRITE"]; - - return ( - <Select - onChange={handleTypeChange} - value={type ? type : "READ"} - options={this.createSelectOptions(types)} - loading={loading} - label={label} - helpText={helpText} - /> - ); - } - - createSelectOptions(types: string[]) { - return types.map(type => { - return { - label: type, - value: type - }; - }); - } -} - -export default translate("repos")(TypeSelector); diff --git a/scm-ui/src/repos/permissions/components/buttons/DeletePermissionButton.js b/scm-ui/src/repos/permissions/components/buttons/DeletePermissionButton.js index a3ba8616c9..46e68ad72f 100644 --- a/scm-ui/src/repos/permissions/components/buttons/DeletePermissionButton.js +++ b/scm-ui/src/repos/permissions/components/buttons/DeletePermissionButton.js @@ -2,7 +2,7 @@ import React from "react"; import { translate } from "react-i18next"; import type { Permission } from "@scm-manager/ui-types"; -import { confirmAlert, DeleteButton } from "@scm-manager/ui-components"; +import { confirmAlert } from "@scm-manager/ui-components"; type Props = { permission: Permission, @@ -54,18 +54,18 @@ class DeletePermissionButton extends React.Component<Props> { }; render() { - const { confirmDialog, loading, t } = this.props; + const { confirmDialog } = this.props; const action = confirmDialog ? this.confirmDelete : this.deletePermission; if (!this.isDeletable()) { return null; } return ( - <DeleteButton - label={t("permission.delete-permission-button.label")} - action={action} - loading={loading} - /> + <a className="level-item" onClick={action}> + <span className="icon is-small"> + <i className="fas fa-trash" /> + </span> + </a> ); } } diff --git a/scm-ui/src/repos/permissions/components/buttons/DeletePermissionButton.test.js b/scm-ui/src/repos/permissions/components/buttons/DeletePermissionButton.test.js index 811c9dc335..03eebe3c50 100644 --- a/scm-ui/src/repos/permissions/components/buttons/DeletePermissionButton.test.js +++ b/scm-ui/src/repos/permissions/components/buttons/DeletePermissionButton.test.js @@ -29,7 +29,7 @@ describe("DeletePermissionButton", () => { expect(navLink.text()).toBe(""); }); - it("should render the navLink", () => { + it("should render the delete icon", () => { const permission = { _links: { delete: { @@ -38,14 +38,14 @@ describe("DeletePermissionButton", () => { } }; - const navLink = mount( + const deleteIcon = mount( <DeletePermissionButton permission={permission} deletePermission={() => {}} />, options.get() ); - expect(navLink.text()).not.toBe(""); + expect(deleteIcon.html()).not.toBe(""); }); it("should open the confirm dialog on button click", () => { @@ -64,7 +64,7 @@ describe("DeletePermissionButton", () => { />, options.get() ); - button.find("button").simulate("click"); + button.find(".fa-trash").simulate("click"); expect(confirmAlert.mock.calls.length).toBe(1); }); @@ -91,7 +91,7 @@ describe("DeletePermissionButton", () => { />, options.get() ); - button.find("button").simulate("click"); + button.find(".fa-trash").simulate("click"); expect(calledUrl).toBe("/permission"); }); diff --git a/scm-ui/src/repos/permissions/components/permissionValidation.test.js b/scm-ui/src/repos/permissions/components/permissionValidation.test.js index b2e7e8be68..1149075537 100644 --- a/scm-ui/src/repos/permissions/components/permissionValidation.test.js +++ b/scm-ui/src/repos/permissions/components/permissionValidation.test.js @@ -18,7 +18,8 @@ describe("permission validation", () => { name: "PermissionName", groupPermission: true, type: "READ", - _links: {} + _links: {}, + verbs: [] } ]; const name = "PermissionName"; @@ -35,7 +36,8 @@ describe("permission validation", () => { name: "PermissionName", groupPermission: false, type: "READ", - _links: {} + _links: {}, + verbs: [] } ]; const name = "PermissionName"; diff --git a/scm-ui/src/repos/permissions/containers/AdvancedPermissionsDialog.js b/scm-ui/src/repos/permissions/containers/AdvancedPermissionsDialog.js new file mode 100644 index 0000000000..cc662e5f06 --- /dev/null +++ b/scm-ui/src/repos/permissions/containers/AdvancedPermissionsDialog.js @@ -0,0 +1,95 @@ +// @flow + +import React from "react"; +import { Button, SubmitButton, Modal } from "@scm-manager/ui-components"; +import { translate } from "react-i18next"; +import PermissionCheckbox from "../components/PermissionCheckbox"; + +type Props = { + readOnly: boolean, + availableVerbs: string[], + selectedVerbs: string[], + onSubmit: (string[]) => void, + onClose: () => void, + + // context props + t: string => string +}; + +type State = { + verbs: any +}; + +class AdvancedPermissionsDialog extends React.Component<Props, State> { + constructor(props: Props) { + super(props); + + const verbs = {}; + props.availableVerbs.forEach( + verb => (verbs[verb] = props.selectedVerbs.includes(verb)) + ); + + this.state = { verbs }; + } + + render() { + const { t, onClose, readOnly } = this.props; + const { verbs } = this.state; + + const verbSelectBoxes = Object.entries(verbs).map(e => ( + <PermissionCheckbox + key={e[0]} + disabled={readOnly} + name={e[0]} + checked={e[1]} + onChange={this.handleChange} + /> + )); + + const submitButton = !readOnly ? ( + <SubmitButton label={t("permission.advanced.dialog.submit")} /> + ) : null; + + const body = <>{verbSelectBoxes}</>; + + const footer = ( + <form onSubmit={this.onSubmit}> + <div className="field is-grouped"> + <p className="control">{submitButton}</p> + <p className="control"> + <Button + label={t("permission.advanced.dialog.abort")} + action={onClose} + /> + </p> + </div> + </form> + ); + + return ( + <Modal + title={t("permission.advanced.dialog.title")} + closeFunction={() => onClose()} + body={body} + footer={footer} + active={true} + /> + ); + } + + handleChange = (value: boolean, name: string) => { + const { verbs } = this.state; + const newVerbs = { ...verbs, [name]: value }; + this.setState({ verbs: newVerbs }); + }; + + onSubmit = () => { + this.props.onSubmit( + Object.entries(this.state.verbs) + .filter(e => e[1]) + .map(e => e[0]) + ); + }; +} + +export default translate("repos")(AdvancedPermissionsDialog); diff --git a/scm-ui/src/repos/permissions/containers/CreatePermissionForm.js b/scm-ui/src/repos/permissions/containers/CreatePermissionForm.js new file mode 100644 index 0000000000..1432b95382 --- /dev/null +++ b/scm-ui/src/repos/permissions/containers/CreatePermissionForm.js @@ -0,0 +1,270 @@ +// @flow +import React from "react"; +import { translate } from "react-i18next"; +import { + Autocomplete, + SubmitButton, + Button, + LabelWithHelpIcon, + Radio +} from "@scm-manager/ui-components"; +import RoleSelector from "../components/RoleSelector"; +import type { + AvailableRepositoryPermissions, + PermissionCollection, + PermissionCreateEntry, + SelectValue +} from "@scm-manager/ui-types"; +import * as validator from "../components/permissionValidation"; +import { findMatchingRoleName } from "../modules/permissions"; +import AdvancedPermissionsDialog from "./AdvancedPermissionsDialog"; + +type Props = { + t: string => string, + availablePermissions: AvailableRepositoryPermissions, + createPermission: (permission: PermissionCreateEntry) => void, + loading: boolean, + currentPermissions: PermissionCollection, + groupAutoCompleteLink: string, + userAutoCompleteLink: string +}; + +type State = { + name: string, + verbs: string[], + groupPermission: boolean, + valid: boolean, + value?: SelectValue, + showAdvancedDialog: boolean +}; + +class CreatePermissionForm extends React.Component<Props, State> { + constructor(props: Props) { + super(props); + + this.state = { + name: "", + verbs: props.availablePermissions.availableRoles[0].verbs, + groupPermission: false, + valid: true, + value: undefined, + showAdvancedDialog: false + }; + } + + permissionScopeChanged = event => { + const groupPermission = event.target.value === "GROUP_PERMISSION"; + this.setState({ + value: undefined, + name: "", + groupPermission: groupPermission, + valid: false + }); + }; + + loadUserAutocompletion = (inputValue: string) => { + return this.loadAutocompletion(this.props.userAutoCompleteLink, inputValue); + }; + + loadGroupAutocompletion = (inputValue: string) => { + return this.loadAutocompletion( + this.props.groupAutoCompleteLink, + inputValue + ); + }; + + loadAutocompletion(url: string, inputValue: string) { + const link = url + "?q="; + return fetch(link + inputValue) + .then(response => response.json()) + .then(json => { + return json.map(element => { + const label = element.displayName + ? `${element.displayName} (${element.id})` + : element.id; + return { + value: element, + label + }; + }); + }); + } + renderAutocompletionField = () => { + const { t } = this.props; + if (this.state.groupPermission) { + return ( + <Autocomplete + loadSuggestions={this.loadGroupAutocompletion} + valueSelected={this.groupOrUserSelected} + value={this.state.value ? this.state.value : ""} + label={t("permission.group")} + noOptionsMessage={t("permission.autocomplete.no-group-options")} + loadingMessage={t("permission.autocomplete.loading")} + placeholder={t("permission.autocomplete.group-placeholder")} + /> + ); + } + return ( + <Autocomplete + loadSuggestions={this.loadUserAutocompletion} + valueSelected={this.groupOrUserSelected} + value={this.state.value ? this.state.value : ""} + label={t("permission.user")} + noOptionsMessage={t("permission.autocomplete.no-user-options")} + loadingMessage={t("permission.autocomplete.loading")} + placeholder={t("permission.autocomplete.user-placeholder")} + /> + ); + }; + + groupOrUserSelected = (value: SelectValue) => { + this.setState({ + value, + name: value.value.id, + valid: validator.isPermissionValid( + value.value.id, + this.state.groupPermission, + this.props.currentPermissions + ) + }); + }; + + render() { + const { t, availablePermissions, loading } = this.props; + + const { verbs, showAdvancedDialog } = this.state; + + const availableRoleNames = availablePermissions.availableRoles.map( + r => r.name + ); + const matchingRole = findMatchingRoleName(availablePermissions, verbs); + + const advancedDialog = showAdvancedDialog ? ( + <AdvancedPermissionsDialog + availableVerbs={availablePermissions.availableVerbs} + selectedVerbs={verbs} + onClose={this.closeAdvancedPermissionsDialog} + onSubmit={this.submitAdvancedPermissionsDialog} + /> + ) : null; + + return ( + <div> + <hr /> + <h2 className="subtitle"> + {t("permission.add-permission.add-permission-heading")} + </h2> + {advancedDialog} + <form onSubmit={this.submit}> + <div className="field is-grouped"> + <div className="control"> + <Radio + name="permission_scope" + value="USER_PERMISSION" + checked={!this.state.groupPermission} + label={t("permission.user-permission")} + onChange={this.permissionScopeChanged} + /> + <Radio + name="permission_scope" + value="GROUP_PERMISSION" + checked={this.state.groupPermission} + label={t("permission.group-permission")} + onChange={this.permissionScopeChanged} + /> + </div> + </div> + <div className="columns"> + <div className="column is-three-fifths"> + {this.renderAutocompletionField()} + </div> + <div className="column is-two-fifths"> + <div className="columns"> + <div className="column is-narrow"> + <RoleSelector + availableRoles={availableRoleNames} + label={t("permission.role")} + helpText={t("permission.help.roleHelpText")} + handleRoleChange={this.handleRoleChange} + role={matchingRole} + /> + </div> + <div className="column"> + <LabelWithHelpIcon + label={t("permission.permissions")} + helpText={t("permission.help.permissionsHelpText")} + /> + <Button + label={t("permission.advanced-button.label")} + action={this.handleDetailedPermissionsPressed} + /> + </div> + </div> + </div> + </div> + <div className="columns"> + <div className="column"> + <SubmitButton + label={t("permission.add-permission.submit-button")} + loading={loading} + disabled={!this.state.valid || this.state.name === ""} + /> + </div> + </div> + </form> + </div> + ); + } + + handleDetailedPermissionsPressed = () => { + this.setState({ showAdvancedDialog: true }); + }; + + closeAdvancedPermissionsDialog = () => { + this.setState({ showAdvancedDialog: false }); + }; + + submitAdvancedPermissionsDialog = (newVerbs: string[]) => { + this.setState({ + showAdvancedDialog: false, + verbs: newVerbs + }); + }; + + submit = e => { + this.props.createPermission({ + name: this.state.name, + verbs: this.state.verbs, + groupPermission: this.state.groupPermission + }); + this.removeState(); + e.preventDefault(); + }; + + removeState = () => { + this.setState({ + name: "", + verbs: this.props.availablePermissions.availableRoles[0].verbs, + valid: true, + value: undefined + }); + }; + + handleRoleChange = (role: string) => { + const selectedRole = this.findAvailableRole(role); + if (!selectedRole) { + return; + } + this.setState({ + verbs: selectedRole.verbs + }); + }; + + findAvailableRole = (roleName: string) => { + return this.props.availablePermissions.availableRoles.find( + role => role.name === roleName + ); + }; +} + +export default translate("repos")(CreatePermissionForm); diff --git a/scm-ui/src/repos/permissions/containers/Permissions.js b/scm-ui/src/repos/permissions/containers/Permissions.js index ee9ac281a5..6fd9cbab38 100644 --- a/scm-ui/src/repos/permissions/containers/Permissions.js +++ b/scm-ui/src/repos/permissions/containers/Permissions.js @@ -3,8 +3,12 @@ import React from "react"; import { connect } from "react-redux"; import { translate } from "react-i18next"; import { + fetchAvailablePermissionsIfNeeded, fetchPermissions, + getFetchAvailablePermissionsFailure, + getAvailablePermissions, getFetchPermissionsFailure, + isFetchAvailablePermissionsPending, isFetchPermissionsPending, getPermissionsOfRepo, hasCreatePermission, @@ -17,18 +21,29 @@ import { modifyPermissionReset, deletePermissionReset } from "../modules/permissions"; -import { Loading, ErrorPage } from "@scm-manager/ui-components"; +import { + Loading, + ErrorPage, + Subtitle, + LabelWithHelpIcon +} from "@scm-manager/ui-components"; import type { + AvailableRepositoryPermissions, Permission, PermissionCollection, PermissionCreateEntry } from "@scm-manager/ui-types"; import SinglePermission from "./SinglePermission"; -import CreatePermissionForm from "../components/CreatePermissionForm"; +import CreatePermissionForm from "./CreatePermissionForm"; import type { History } from "history"; import { getPermissionsLink } from "../../modules/repos"; +import { + getGroupAutoCompleteLink, + getUserAutoCompleteLink +} from "../../../modules/indexResource"; type Props = { + availablePermissions: AvailableRepositoryPermissions, namespace: string, repoName: string, loading: boolean, @@ -37,8 +52,11 @@ type Props = { hasPermissionToCreate: boolean, loadingCreatePermission: boolean, permissionsLink: string, + groupAutoCompleteLink: string, + userAutoCompleteLink: string, //dispatch functions + fetchAvailablePermissionsIfNeeded: () => void, fetchPermissions: (link: string, namespace: string, repoName: string) => void, createPermission: ( link: string, @@ -56,9 +74,11 @@ type Props = { history: History }; + class Permissions extends React.Component<Props> { componentDidMount() { const { + fetchAvailablePermissionsIfNeeded, fetchPermissions, namespace, repoName, @@ -71,6 +91,7 @@ class Permissions extends React.Component<Props> { createPermissionReset(namespace, repoName); modifyPermissionReset(namespace, repoName); deletePermissionReset(namespace, repoName); + fetchAvailablePermissionsIfNeeded(); fetchPermissions(permissionsLink, namespace, repoName); } @@ -85,6 +106,7 @@ class Permissions extends React.Component<Props> { render() { const { + availablePermissions, loading, error, permissions, @@ -92,7 +114,9 @@ class Permissions extends React.Component<Props> { namespace, repoName, loadingCreatePermission, - hasPermissionToCreate + hasPermissionToCreate, + userAutoCompleteLink, + groupAutoCompleteLink } = this.props; if (error) { return ( @@ -104,28 +128,45 @@ class Permissions extends React.Component<Props> { ); } - if (loading || !permissions) { + if (loading || !permissions || !availablePermissions) { return <Loading />; } const createPermissionForm = hasPermissionToCreate ? ( <CreatePermissionForm + availablePermissions={availablePermissions} createPermission={permission => this.createPermission(permission)} loading={loadingCreatePermission} currentPermissions={permissions} + userAutoCompleteLink={userAutoCompleteLink} + groupAutoCompleteLink={groupAutoCompleteLink} /> ) : null; return ( <div> - <table className="table is-hoverable is-fullwidth"> + <Subtitle subtitle={t("permission.title")} /> + <table className="has-background-light table is-hoverable is-fullwidth"> <thead> <tr> - <th>{t("permission.name")}</th> - <th className="is-hidden-mobile"> - {t("permission.group-permission")} + <th> + <LabelWithHelpIcon + label={t("permission.name")} + helpText={t("permission.help.nameHelpText")} + /> + </th> + <th> + <LabelWithHelpIcon + label={t("permission.role")} + helpText={t("permission.help.roleHelpText")} + /> + </th> + <th> + <LabelWithHelpIcon + label={t("permission.permissions")} + helpText={t("permission.help.permissionsHelpText")} + /> </th> - <th>{t("permission.type")}</th> <th /> </tr> </thead> @@ -133,6 +174,7 @@ class Permissions extends React.Component<Props> { {permissions.map(permission => { return ( <SinglePermission + availablePermissions={availablePermissions} key={permission.name + permission.groupPermission.toString()} namespace={namespace} repoName={repoName} @@ -155,8 +197,11 @@ const mapStateToProps = (state, ownProps) => { getFetchPermissionsFailure(state, namespace, repoName) || getCreatePermissionFailure(state, namespace, repoName) || getDeletePermissionsFailure(state, namespace, repoName) || - getModifyPermissionsFailure(state, namespace, repoName); - const loading = isFetchPermissionsPending(state, namespace, repoName); + getModifyPermissionsFailure(state, namespace, repoName) || + getFetchAvailablePermissionsFailure(state); + const loading = + isFetchPermissionsPending(state, namespace, repoName) || + isFetchAvailablePermissionsPending(state); const permissions = getPermissionsOfRepo(state, namespace, repoName); const loadingCreatePermission = isCreatePermissionPending( state, @@ -165,7 +210,11 @@ const mapStateToProps = (state, ownProps) => { ); const hasPermissionToCreate = hasCreatePermission(state, namespace, repoName); const permissionsLink = getPermissionsLink(state, namespace, repoName); + const groupAutoCompleteLink = getGroupAutoCompleteLink(state); + const userAutoCompleteLink = getUserAutoCompleteLink(state); + const availablePermissions = getAvailablePermissions(state); return { + availablePermissions, namespace, repoName, error, @@ -173,7 +222,9 @@ const mapStateToProps = (state, ownProps) => { permissions, hasPermissionToCreate, loadingCreatePermission, - permissionsLink + permissionsLink, + groupAutoCompleteLink, + userAutoCompleteLink }; }; @@ -182,6 +233,9 @@ const mapDispatchToProps = dispatch => { fetchPermissions: (link: string, namespace: string, repoName: string) => { dispatch(fetchPermissions(link, namespace, repoName)); }, + fetchAvailablePermissionsIfNeeded: () => { + dispatch(fetchAvailablePermissionsIfNeeded()); + }, createPermission: ( link: string, permission: PermissionCreateEntry, @@ -189,7 +243,9 @@ const mapDispatchToProps = dispatch => { repoName: string, callback?: () => void ) => { - dispatch(createPermission(link, permission, namespace, repoName, callback)); + dispatch( + createPermission(link, permission, namespace, repoName, callback) + ); }, createPermissionReset: (namespace: string, repoName: string) => { dispatch(createPermissionReset(namespace, repoName)); diff --git a/scm-ui/src/repos/permissions/containers/SinglePermission.js b/scm-ui/src/repos/permissions/containers/SinglePermission.js index 9426fbcd9f..e5a9c604a6 100644 --- a/scm-ui/src/repos/permissions/containers/SinglePermission.js +++ b/scm-ui/src/repos/permissions/containers/SinglePermission.js @@ -1,22 +1,34 @@ // @flow import React from "react"; -import type { Permission } from "@scm-manager/ui-types"; +import type { + AvailableRepositoryPermissions, + Permission +} from "@scm-manager/ui-types"; import { translate } from "react-i18next"; import { modifyPermission, isModifyPermissionPending, deletePermission, - isDeletePermissionPending + isDeletePermissionPending, + findMatchingRoleName } from "../modules/permissions"; import { connect } from "react-redux"; import type { History } from "history"; -import { Checkbox } from "@scm-manager/ui-components"; +import { Button } from "@scm-manager/ui-components"; import DeletePermissionButton from "../components/buttons/DeletePermissionButton"; -import TypeSelector from "../components/TypeSelector"; +import RoleSelector from "../components/RoleSelector"; +import AdvancedPermissionsDialog from "./AdvancedPermissionsDialog"; +import classNames from "classnames"; +import injectSheet from "react-jss"; type Props = { + availablePermissions: AvailableRepositoryPermissions, submitForm: Permission => void, - modifyPermission: (Permission, string, string) => void, + modifyPermission: ( + permission: Permission, + namespace: string, + name: string + ) => void, permission: Permission, t: string => string, namespace: string, @@ -24,38 +36,71 @@ type Props = { match: any, history: History, loading: boolean, - deletePermission: (Permission, string, string) => void, - deleteLoading: boolean + deletePermission: ( + permission: Permission, + namespace: string, + name: string + ) => void, + deleteLoading: boolean, + classes: any }; type State = { - permission: Permission + role: string, + permission: Permission, + showAdvancedDialog: boolean +}; + +const styles = { + iconColor: { + color: "#9a9a9a" + }, + centerMiddle: { + display: "table-cell", + verticalAlign: "middle !important" + }, + columnWidth: { + width: "100%" + } }; class SinglePermission extends React.Component<Props, State> { constructor(props: Props) { super(props); + const defaultPermission = props.availablePermissions.availableRoles + ? props.availablePermissions.availableRoles[0] + : {}; + this.state = { permission: { name: "", - type: "READ", + verbs: defaultPermission.verbs, groupPermission: false, _links: {} - } + }, + role: defaultPermission.name, + showAdvancedDialog: false }; } componentDidMount() { - const { permission } = this.props; + const { availablePermissions, permission } = this.props; + + const matchingRole = findMatchingRoleName( + availablePermissions, + permission.verbs + ); + if (permission) { this.setState({ permission: { name: permission.name, - type: permission.type, + verbs: permission.verbs, groupPermission: permission.groupPermission, _links: permission._links - } + }, + role: matchingRole }); } } @@ -69,29 +114,62 @@ class SinglePermission extends React.Component<Props, State> { }; render() { - const { permission } = this.state; - const { loading, namespace, repoName } = this.props; - const typeSelector = - this.props.permission._links && this.props.permission._links.update ? ( - <td> - <TypeSelector - handleTypeChange={this.handleTypeChange} - type={permission.type ? permission.type : "READ"} - loading={loading} - /> - </td> + const { role, permission, showAdvancedDialog } = this.state; + const { + t, + availablePermissions, + loading, + namespace, + repoName, + classes + } = this.props; + const availableRoleNames = availablePermissions.availableRoles.map( + r => r.name + ); + const readOnly = !this.mayChangePermissions(); + const roleSelector = readOnly ? ( + <td>{role}</td> + ) : ( + <td> + <RoleSelector + handleRoleChange={this.handleRoleChange} + availableRoles={availableRoleNames} + role={role} + loading={loading} + /> + </td> + ); + + const advancedDialg = showAdvancedDialog ? ( + <AdvancedPermissionsDialog + readOnly={readOnly} + availableVerbs={availablePermissions.availableVerbs} + selectedVerbs={permission.verbs} + onClose={this.closeAdvancedPermissionsDialog} + onSubmit={this.submitAdvancedPermissionsDialog} + /> + ) : null; + + const iconType = + permission && permission.groupPermission ? ( + <i title={t("permission.group")} className={classNames("fas fa-user-friends", classes.iconColor)} /> ) : ( - <td>{permission.type}</td> + <i title={t("permission.user")} className={classNames("fas fa-user", classes.iconColor)} /> ); return ( - <tr> - <td>{permission.name}</td> - <td> - <Checkbox checked={permission ? permission.groupPermission : false} /> + <tr className={classes.columnWidth}> + <td className={classes.centerMiddle}> + {iconType} {permission.name} </td> - {typeSelector} - <td> + {roleSelector} + <td className={classes.centerMiddle}> + <Button + label={t("permission.advanced-button.label")} + action={this.handleDetailedPermissionsPressed} + /> + </td> + <td className={classes.centerMiddle}> <DeletePermissionButton permission={permission} namespace={namespace} @@ -99,39 +177,69 @@ class SinglePermission extends React.Component<Props, State> { deletePermission={this.deletePermission} loading={this.props.deleteLoading} /> + {advancedDialg} </td> </tr> ); } - handleTypeChange = (type: string) => { - this.setState({ - permission: { - ...this.state.permission, - type: type - } - }); - this.modifyPermission(type); + mayChangePermissions = () => { + return this.props.permission._links && this.props.permission._links.update; }; - modifyPermission = (type: string) => { + handleDetailedPermissionsPressed = () => { + this.setState({ showAdvancedDialog: true }); + }; + + closeAdvancedPermissionsDialog = () => { + this.setState({ showAdvancedDialog: false }); + }; + + submitAdvancedPermissionsDialog = (newVerbs: string[]) => { + const { permission } = this.state; + const newRole = findMatchingRoleName( + this.props.availablePermissions, + newVerbs + ); + this.setState( + { + showAdvancedDialog: false, + permission: { ...permission, verbs: newVerbs }, + role: newRole + }, + () => this.modifyPermission(newVerbs) + ); + }; + + handleRoleChange = (role: string) => { + const selectedRole = this.findAvailableRole(role); + this.setState( + { + permission: { + ...this.state.permission, + verbs: selectedRole.verbs + }, + role: role + }, + () => this.modifyPermission(selectedRole.verbs) + ); + }; + + findAvailableRole = (roleName: string) => { + return this.props.availablePermissions.availableRoles.find( + role => role.name === roleName + ); + }; + + modifyPermission = (verbs: string[]) => { let permission = this.state.permission; - permission.type = type; + permission.verbs = verbs; this.props.modifyPermission( permission, this.props.namespace, this.props.repoName ); }; - - createSelectOptions(types: string[]) { - return types.map(type => { - return { - label: type, - value: type - }; - }); - } } const mapStateToProps = (state, ownProps) => { @@ -173,4 +281,4 @@ const mapDispatchToProps = dispatch => { export default connect( mapStateToProps, mapDispatchToProps -)(translate("repos")(SinglePermission)); +)(translate("repos")(injectSheet(styles)(SinglePermission))); diff --git a/scm-ui/src/repos/permissions/modules/permissions.js b/scm-ui/src/repos/permissions/modules/permissions.js index 154ee8123f..cb6b013c61 100644 --- a/scm-ui/src/repos/permissions/modules/permissions.js +++ b/scm-ui/src/repos/permissions/modules/permissions.js @@ -1,13 +1,29 @@ // @flow -import type {Action} from "@scm-manager/ui-components"; -import {apiClient} from "@scm-manager/ui-components"; +import type { Action } from "@scm-manager/ui-components"; +import { apiClient } from "@scm-manager/ui-components"; import * as types from "../../../modules/types"; -import type {Permission, PermissionCollection, PermissionCreateEntry} from "@scm-manager/ui-types"; -import {isPending} from "../../../modules/pending"; -import {getFailure} from "../../../modules/failure"; -import {Dispatch} from "redux"; +import type { + AvailableRepositoryPermissions, + Permission, + PermissionCollection, + PermissionCreateEntry +} from "@scm-manager/ui-types"; +import { isPending } from "../../../modules/pending"; +import { getFailure } from "../../../modules/failure"; +import { Dispatch } from "redux"; +import { getLinks } from "../../../modules/indexResource"; +export const FETCH_AVAILABLE = "scm/permissions/FETCH_AVAILABLE"; +export const FETCH_AVAILABLE_PENDING = `${FETCH_AVAILABLE}_${ + types.PENDING_SUFFIX +}`; +export const FETCH_AVAILABLE_SUCCESS = `${FETCH_AVAILABLE}_${ + types.SUCCESS_SUFFIX +}`; +export const FETCH_AVAILABLE_FAILURE = `${FETCH_AVAILABLE}_${ + types.FAILURE_SUFFIX +}`; export const FETCH_PERMISSIONS = "scm/permissions/FETCH_PERMISSIONS"; export const FETCH_PERMISSIONS_PENDING = `${FETCH_PERMISSIONS}_${ types.PENDING_SUFFIX @@ -58,7 +74,71 @@ export const DELETE_PERMISSION_RESET = `${DELETE_PERMISSION}_${ types.RESET_SUFFIX }`; -const CONTENT_TYPE = "application/vnd.scmm-permission+json"; +const CONTENT_TYPE = "application/vnd.scmm-repositoryPermission+json"; + +// fetch available permissions + +export function fetchAvailablePermissionsIfNeeded() { + return function(dispatch: any, getState: () => Object) { + if (shouldFetchAvailablePermissions(getState())) { + return fetchAvailablePermissions(dispatch, getState); + } + }; +} + +export function fetchAvailablePermissions( + dispatch: any, + getState: () => Object +) { + dispatch(fetchAvailablePending()); + return apiClient + .get(getLinks(getState()).availableRepositoryPermissions.href) + .then(response => response.json()) + .then(available => { + dispatch(fetchAvailableSuccess(available)); + }) + .catch(err => { + dispatch(fetchAvailableFailure(err)); + }); +} + +export function shouldFetchAvailablePermissions(state: Object) { + if ( + isFetchAvailablePermissionsPending(state) || + getFetchAvailablePermissionsFailure(state) + ) { + return false; + } + return !state.available; +} + +export function fetchAvailablePending(): Action { + return { + type: FETCH_AVAILABLE_PENDING, + payload: {}, + itemId: "available" + }; +} + +export function fetchAvailableSuccess( + available: AvailableRepositoryPermissions +): Action { + return { + type: FETCH_AVAILABLE_SUCCESS, + payload: available, + itemId: "available" + }; +} + +export function fetchAvailableFailure(error: Error): Action { + return { + type: FETCH_AVAILABLE_FAILURE, + payload: { + error + }, + itemId: "available" + }; +} // fetch permissions @@ -141,13 +221,8 @@ export function modifyPermission( callback(); } }) - .catch(cause => { - const error = new Error( - `failed to modify permission: ${cause.message}` - ); - dispatch( - modifyPermissionFailure(permission, error, namespace, repoName) - ); + .catch(err => { + dispatch(modifyPermissionFailure(permission, err, namespace, repoName)); }); }; } @@ -241,15 +316,7 @@ export function createPermission( } }) .catch(err => - dispatch( - createPermissionFailure( - new Error( - `failed to add permission ${permission.name}: ${err.message}` - ), - namespace, - repoName - ) - ) + dispatch(createPermissionFailure(err, namespace, repoName)) ); }; } @@ -318,13 +385,8 @@ export function deletePermission( callback(); } }) - .catch(cause => { - const error = new Error( - `could not delete permission ${permission.name}: ${cause.message}` - ); - dispatch( - deletePermissionFailure(permission, namespace, repoName, error) - ); + .catch(err => { + dispatch(deletePermissionFailure(permission, namespace, repoName, err)); }); }; } @@ -382,6 +444,7 @@ export function deletePermissionReset(namespace: string, repoName: string) { itemId: namespace + "/" + repoName }; } + function deletePermissionFromState( oldPermissions: PermissionCollection, permission: Permission @@ -413,12 +476,17 @@ export default function reducer( return state; } switch (action.type) { + case FETCH_AVAILABLE_SUCCESS: + return { + ...state, + available: action.payload + }; case FETCH_PERMISSIONS_SUCCESS: return { ...state, [action.itemId]: { entries: action.payload._embedded.permissions, - createPermission: action.payload._links.create ? true : false + createPermission: !!action.payload._links.create } }; case MODIFY_PERMISSION_SUCCESS: @@ -466,6 +534,12 @@ export default function reducer( // selectors +export function getAvailablePermissions(state: Object) { + if (state.permissions) { + return state.permissions.available; + } +} + export function getPermissionsOfRepo( state: Object, namespace: string, @@ -477,6 +551,10 @@ export function getPermissionsOfRepo( } } +export function isFetchAvailablePermissionsPending(state: Object) { + return isPending(state, FETCH_AVAILABLE, "available"); +} + export function isFetchPermissionsPending( state: Object, namespace: string, @@ -485,6 +563,10 @@ export function isFetchPermissionsPending( return isPending(state, FETCH_PERMISSIONS, namespace + "/" + repoName); } +export function getFetchAvailablePermissionsFailure(state: Object) { + return getFailure(state, FETCH_AVAILABLE, "available"); +} + export function getFetchPermissionsFailure( state: Object, namespace: string, @@ -536,6 +618,7 @@ export function isCreatePermissionPending( ) { return isPending(state, CREATE_PERMISSION, namespace + "/" + repoName); } + export function getCreatePermissionFailure( state: Object, namespace: string, @@ -617,3 +700,33 @@ export function getModifyPermissionsFailure( } return null; } + +export function findMatchingRoleName( + availablePermissions: AvailableRepositoryPermissions, + verbs: string[] +) { + if (!verbs) { + return ""; + } + const matchingRole = availablePermissions.availableRoles.find(role => { + return equalVerbs(role.verbs, verbs); + }); + + if (matchingRole) { + return matchingRole.name; + } else { + return ""; + } +} + +function equalVerbs(verbs1: string[], verbs2: string[]) { + if (!verbs1 || !verbs2) { + return false; + } + + if (verbs1.length !== verbs2.length) { + return false; + } + + return verbs1.every(verb => verbs2.includes(verb)); +} diff --git a/scm-ui/src/repos/permissions/modules/permissions.test.js b/scm-ui/src/repos/permissions/modules/permissions.test.js index 8ac02b7ec0..5c3e9cce31 100644 --- a/scm-ui/src/repos/permissions/modules/permissions.test.js +++ b/scm-ui/src/repos/permissions/modules/permissions.test.js @@ -59,7 +59,8 @@ const hitchhiker_puzzle42Permission_user_eins: Permission = { href: "http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/puzzle42/permissions/user_eins" } - } + }, + verbs: [] }; const hitchhiker_puzzle42Permission_user_zwei: Permission = { @@ -79,7 +80,8 @@ const hitchhiker_puzzle42Permission_user_zwei: Permission = { href: "http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/puzzle42/permissions/user_zwei" } - } + }, + verbs: [] }; const hitchhiker_puzzle42Permissions: PermissionCollection = [ @@ -175,8 +177,7 @@ describe("permission fetch", () => { } ); - let editedPermission = { ...hitchhiker_puzzle42Permission_user_eins }; - editedPermission.type = "OWNER"; + let editedPermission = { ...hitchhiker_puzzle42Permission_user_eins, type: "OWNER" }; const store = mockStore({}); @@ -197,8 +198,7 @@ describe("permission fetch", () => { } ); - let editedPermission = { ...hitchhiker_puzzle42Permission_user_eins }; - editedPermission.type = "OWNER"; + let editedPermission = { ...hitchhiker_puzzle42Permission_user_eins, type: "OWNER" }; const store = mockStore({}); @@ -227,8 +227,7 @@ describe("permission fetch", () => { } ); - let editedPermission = { ...hitchhiker_puzzle42Permission_user_eins }; - editedPermission.type = "OWNER"; + let editedPermission = { ...hitchhiker_puzzle42Permission_user_eins, type: "OWNER" }; const store = mockStore({}); @@ -451,8 +450,7 @@ describe("permissions reducer", () => { entries: [hitchhiker_puzzle42Permission_user_eins] } }; - let permissionEdited = { ...hitchhiker_puzzle42Permission_user_eins }; - permissionEdited.type = "OWNER"; + let permissionEdited = { ...hitchhiker_puzzle42Permission_user_eins, type: "OWNER" }; let expectedState = { "hitchhiker/puzzle42": { entries: [permissionEdited] diff --git a/scm-ui/src/repos/sources/components/FileTree.js b/scm-ui/src/repos/sources/components/FileTree.js index e9b5c70d3d..18ef1b01c5 100644 --- a/scm-ui/src/repos/sources/components/FileTree.js +++ b/scm-ui/src/repos/sources/components/FileTree.js @@ -96,7 +96,7 @@ class FileTree extends React.Component<Props> { }); } - if (tree._embedded) { + if (tree._embedded && tree._embedded.children) { files.push(...tree._embedded.children.sort(compareFiles)); } @@ -108,30 +108,34 @@ class FileTree extends React.Component<Props> { } return ( - <table className="table table-hover table-sm is-fullwidth"> - <thead> - <tr> - <th className={classes.iconColumn} /> - <th>{t("sources.file-tree.name")}</th> - <th className="is-hidden-mobile"> - {t("sources.file-tree.length")} - </th> - <th className="is-hidden-mobile"> - {t("sources.file-tree.lastModified")} - </th> - <th>{t("sources.file-tree.description")}</th> - </tr> - </thead> - <tbody> - {files.map(file => ( - <FileTreeLeaf - key={file.name} - file={file} - baseUrl={baseUrlWithRevision} - /> - ))} - </tbody> - </table> + <div className="panel-block"> + <table className="table table-hover table-sm is-fullwidth"> + <thead> + <tr> + <th className={classes.iconColumn} /> + <th>{t("sources.file-tree.name")}</th> + <th className="is-hidden-mobile"> + {t("sources.file-tree.length")} + </th> + <th className="is-hidden-mobile"> + {t("sources.file-tree.lastModified")} + </th> + <th className="is-hidden-mobile"> + {t("sources.file-tree.description")} + </th> + </tr> + </thead> + <tbody> + {files.map(file => ( + <FileTreeLeaf + key={file.name} + file={file} + baseUrl={baseUrlWithRevision} + /> + ))} + </tbody> + </table> + </div> ); } } diff --git a/scm-ui/src/repos/sources/components/FileTreeLeaf.js b/scm-ui/src/repos/sources/components/FileTreeLeaf.js index b4e2ad59ea..20905a8354 100644 --- a/scm-ui/src/repos/sources/components/FileTreeLeaf.js +++ b/scm-ui/src/repos/sources/components/FileTreeLeaf.js @@ -6,10 +6,14 @@ import FileSize from "./FileSize"; import FileIcon from "./FileIcon"; import { Link } from "react-router-dom"; import type { File } from "@scm-manager/ui-types"; +import classNames from "classnames"; const styles = { iconColumn: { width: "16px" + }, + wordBreakMinWidth: { + minWidth: "10em" } }; @@ -71,12 +75,14 @@ class FileTreeLeaf extends React.Component<Props> { return ( <tr> <td className={classes.iconColumn}>{this.createFileIcon(file)}</td> - <td>{this.createFileName(file)}</td> + <td className={classNames(classes.wordBreakMinWidth, "is-word-break")}>{this.createFileName(file)}</td> <td className="is-hidden-mobile">{fileSize}</td> <td className="is-hidden-mobile"> <DateFromNow date={file.lastModified} /> </td> - <td>{file.description}</td> + <td className={classNames(classes.wordBreakMinWidth, "is-word-break", "is-hidden-mobile")}> + {file.description} + </td> </tr> ); } diff --git a/scm-ui/src/repos/sources/components/FileTreeLeaf.test.js b/scm-ui/src/repos/sources/components/FileTreeLeaf.test.js index d5004521c8..ba36e7a2db 100644 --- a/scm-ui/src/repos/sources/components/FileTreeLeaf.test.js +++ b/scm-ui/src/repos/sources/components/FileTreeLeaf.test.js @@ -8,7 +8,13 @@ describe("create link tests", () => { return { name: "dir", path: path, - directory: true + directory: true, + length: 1, + revision: "1a", + _links: {}, + _embedded: { + children: [] + } }; } diff --git a/scm-ui/src/repos/sources/components/content/FileButtonGroup.js b/scm-ui/src/repos/sources/components/content/FileButtonGroup.js new file mode 100644 index 0000000000..6318425711 --- /dev/null +++ b/scm-ui/src/repos/sources/components/content/FileButtonGroup.js @@ -0,0 +1,51 @@ +// @flow +import React from "react"; +import { translate } from "react-i18next"; +import { ButtonGroup, Button } from "@scm-manager/ui-components"; + +type Props = { + t: string => string, + historyIsSelected: boolean, + showHistory: boolean => void +}; + +class FileButtonGroup extends React.Component<Props> { + showHistory = () => { + this.props.showHistory(true); + }; + + showSources = () => { + this.props.showHistory(false); + }; + + color = (selected: boolean) => { + return selected ? "link is-selected" : null; + }; + + render() { + const { t, historyIsSelected } = this.props; + + return ( + <ButtonGroup> + <Button action={this.showSources} color={ this.color(!historyIsSelected) }> + <span className="icon"> + <i className="fas fa-code"/> + </span> + <span className="is-hidden-mobile"> + {t("sources.content.sourcesButton")} + </span> + </Button> + <Button action={this.showHistory} color={ this.color(historyIsSelected) }> + <span className="icon"> + <i className="fas fa-history"/> + </span> + <span className="is-hidden-mobile"> + {t("sources.content.historyButton")} + </span> + </Button> + </ButtonGroup> + ); + } +} + +export default translate("repos")(FileButtonGroup); diff --git a/scm-ui/src/repos/sources/containers/Content.js b/scm-ui/src/repos/sources/containers/Content.js index 0b764aa6a1..2f3f5ba853 100644 --- a/scm-ui/src/repos/sources/containers/Content.js +++ b/scm-ui/src/repos/sources/containers/Content.js @@ -1,22 +1,16 @@ // @flow import React from "react"; import { translate } from "react-i18next"; -import { getSources } from "../modules/sources"; -import type { Repository, File } from "@scm-manager/ui-types"; -import { - ErrorNotification, - Loading, - DateFromNow -} from "@scm-manager/ui-components"; -import { connect } from "react-redux"; -import ImageViewer from "../components/content/ImageViewer"; -import SourcecodeViewer from "../components/content/SourcecodeViewer"; -import DownloadViewer from "../components/content/DownloadViewer"; +import type { File, Repository } from "@scm-manager/ui-types"; +import { DateFromNow } from "@scm-manager/ui-components"; import FileSize from "../components/FileSize"; import injectSheet from "react-jss"; import classNames from "classnames"; -import { ExtensionPoint } from "@scm-manager/ui-extensions"; -import { getContentType } from "./contentType"; +import FileButtonGroup from "../components/content/FileButtonGroup"; +import SourcesView from "./SourcesView"; +import HistoryView from "./HistoryView"; +import { getSources } from "../modules/sources"; +import { connect } from "react-redux"; type Props = { loading: boolean, @@ -30,19 +24,23 @@ type Props = { }; type State = { - contentType: string, - language: string, - loaded: boolean, collapsed: boolean, - error?: Error + showHistory: boolean }; const styles = { - toCenterContent: { - display: "block" - }, pointer: { cursor: "pointer" + }, + marginInHeader: { + marginRight: "0.5em" + }, + isVerticalCenter: { + display: "flex", + alignItems: "center" + }, + hasBackground: { + backgroundColor: "#FBFBFB" } }; @@ -51,57 +49,53 @@ class Content extends React.Component<Props, State> { super(props); this.state = { - contentType: "", - language: "", - loaded: false, - collapsed: true + collapsed: true, + showHistory: false }; } - componentDidMount() { - const { file } = this.props; - getContentType(file._links.self.href) - .then(result => { - if (result.error) { - this.setState({ - ...this.state, - error: result.error, - loaded: true - }); - } else { - this.setState({ - ...this.state, - contentType: result.type, - language: result.language, - loaded: true - }); - } - }) - .catch(err => {}); - } - toggleCollapse = () => { this.setState(prevState => ({ collapsed: !prevState.collapsed })); }; + setShowHistoryState(showHistory: boolean) { + this.setState({ + ...this.state, + showHistory + }); + } + showHeader() { const { file, classes } = this.props; - const collapsed = this.state.collapsed; + const { showHistory, collapsed } = this.state; const icon = collapsed ? "fa-angle-right" : "fa-angle-down"; - const fileSize = file.directory ? "" : <FileSize bytes={file.length} />; + + const selector = file._links.history ? ( + <FileButtonGroup + file={file} + historyIsSelected={showHistory} + showHistory={(changeShowHistory: boolean) => + this.setShowHistoryState(changeShowHistory) + } + /> + ) : null; return ( - <span className={classes.pointer} onClick={this.toggleCollapse}> - <article className="media"> - <div className="media-left"> - <i className={classNames("fa", icon)} /> + <span className={classes.pointer}> + <article className={classNames("media", classes.isVerticalCenter)}> + <div className="media-content" onClick={this.toggleCollapse}> + <i + className={classNames( + "fa is-medium", + icon, + classes.marginInHeader + )} + /> + <span className="is-word-break">{file.name}</span> </div> - <div className="media-content"> - <div className="content">{file.name}</div> - </div> - <p className="media-right">{fileSize}</p> + <div className="media-right">{selector}</div> </article> </span> ); @@ -109,7 +103,7 @@ class Content extends React.Component<Props, State> { showMoreInformation() { const collapsed = this.state.collapsed; - const { classes, file, revision } = this.props; + const { classes, file, revision, t } = this.props; const date = <DateFromNow date={file.lastModified} />; const description = file.description ? ( <p> @@ -123,26 +117,36 @@ class Content extends React.Component<Props, State> { })} </p> ) : null; + const fileSize = file.directory ? "" : <FileSize bytes={file.length} />; if (!collapsed) { return ( - <div className={classNames("panel-block", classes.toCenterContent)}> - <table className="table"> + <div + className={classNames( + "panel-block", + classes.hasBackground + )} + > + <table className={classNames("table", classes.hasBackground)}> <tbody> <tr> - <td>Path</td> - <td>{file.path}</td> + <td>{t("sources.content.path")}</td> + <td className="is-word-break">{file.path}</td> </tr> <tr> - <td>Branch</td> - <td>{revision}</td> + <td>{t("sources.content.branch")}</td> + <td className="is-word-break">{revision}</td> </tr> <tr> - <td>Last modified</td> + <td>{t("sources.content.size")}</td> + <td>{fileSize}</td> + </tr> + <tr> + <td>{t("sources.content.lastModified")}</td> <td>{date}</td> </tr> <tr> - <td>Description</td> - <td>{description}</td> + <td>{t("sources.content.description")}</td> + <td className="is-word-break">{description}</td> </tr> </tbody> </table> @@ -152,51 +156,31 @@ class Content extends React.Component<Props, State> { return null; } - showContent() { - const { file, revision } = this.props; - const { contentType, language } = this.state; - if (contentType.startsWith("image/")) { - return <ImageViewer file={file} />; - } else if (language) { - return <SourcecodeViewer file={file} language={language} />; - } else if (contentType.startsWith("text/")) { - return <SourcecodeViewer file={file} language="none" />; - } else { - return ( - <ExtensionPoint - name="repos.sources.view" - props={{ file, contentType, revision }} - > - <DownloadViewer file={file} /> - </ExtensionPoint> - ); - } - } - render() { - const { file, classes } = this.props; - const { loaded, error } = this.state; - - if (!file || !loaded) { - return <Loading />; - } - if (error) { - return <ErrorNotification error={error} />; - } + const { file, revision, repository, path } = this.props; + const { showHistory } = this.state; const header = this.showHeader(); - const content = this.showContent(); + const content = + showHistory && file._links.history ? ( + <HistoryView file={file} repository={repository} /> + ) : ( + <SourcesView + revision={revision} + file={file} + repository={repository} + path={path} + /> + ); const moreInformation = this.showMoreInformation(); return ( <div> - <nav className="panel"> - <article className="panel-heading">{header}</article> + <div className="panel"> + <div className="panel-heading">{header}</div> {moreInformation} - <div className={classNames("panel-block", classes.toCenterContent)}> - {content} - </div> - </nav> + {content} + </div> </div> ); } diff --git a/scm-ui/src/repos/sources/containers/HistoryView.js b/scm-ui/src/repos/sources/containers/HistoryView.js new file mode 100644 index 0000000000..d13b5904d2 --- /dev/null +++ b/scm-ui/src/repos/sources/containers/HistoryView.js @@ -0,0 +1,113 @@ +// @flow +import React from "react"; +import type { + File, + Changeset, + Repository, + PagedCollection +} from "@scm-manager/ui-types"; +import { + ErrorNotification, + Loading, + StatePaginator, + ChangesetList +} from "@scm-manager/ui-components"; +import { getHistory } from "./history"; + +type Props = { + file: File, + repository: Repository +}; + +type State = { + loaded: boolean, + changesets: Changeset[], + page: number, + pageCollection?: PagedCollection, + error?: Error +}; + +class HistoryView extends React.Component<Props, State> { + constructor(props: Props) { + super(props); + + this.state = { + loaded: false, + page: 1, + changesets: [] + }; + } + + componentDidMount() { + const { file } = this.props; + this.updateHistory(file._links.history.href); + } + + updateHistory(link: string) { + getHistory(link) + .then(result => { + if (result.error) { + this.setState({ + ...this.state, + error: result.error, + loaded: true + }); + } else { + this.setState({ + ...this.state, + loaded: true, + changesets: result.changesets, + pageCollection: result.pageCollection, + page: result.pageCollection.page + }); + } + }) + .catch(err => {}); + } + + updatePage(page: number) { + const { file } = this.props; + const internalPage = page - 1; + this.updateHistory( + file._links.history.href + "?page=" + internalPage.toString() + ); + } + + showHistory() { + const { repository } = this.props; + const { changesets, page, pageCollection } = this.state; + const currentPage = page + 1; + return ( + <> + <div className="panel-block"> + <ChangesetList repository={repository} changesets={changesets} /> + </div> + <div className="panel-footer"> + <StatePaginator + page={currentPage} + collection={pageCollection} + updatePage={(newPage: number) => this.updatePage(newPage)} + /> + </div> + </> + ); + } + + render() { + const { file } = this.props; + const { loaded, error } = this.state; + + if (!file || !loaded) { + return <Loading />; + } + if (error) { + return <ErrorNotification error={error} />; + } + + const history = this.showHistory(); + + return <>{history}</>; + } +} + +export default HistoryView; diff --git a/scm-ui/src/repos/sources/containers/Sources.js b/scm-ui/src/repos/sources/containers/Sources.js index 1a9f1d62e7..05705a61d1 100644 --- a/scm-ui/src/repos/sources/containers/Sources.js +++ b/scm-ui/src/repos/sources/containers/Sources.js @@ -2,10 +2,11 @@ import React from "react"; import { connect } from "react-redux"; import { withRouter } from "react-router-dom"; -import type { Repository, Branch } from "@scm-manager/ui-types"; +import type { Branch, Repository } from "@scm-manager/ui-types"; import FileTree from "../components/FileTree"; import { ErrorNotification, Loading } from "@scm-manager/ui-components"; -import BranchSelector from "../../containers/BranchSelector"; +import BranchSelector from "../../../../../scm-ui-components/packages/ui-components/src/BranchSelector"; +import { translate } from "react-i18next"; import { fetchBranches, getBranches, @@ -32,7 +33,8 @@ type Props = { // Context props history: any, - match: any + match: any, + t: string => string }; class Sources extends React.Component<Props> { @@ -91,15 +93,17 @@ class Sources extends React.Component<Props> { if (currentFileIsDirectory) { return ( - <> - {this.renderBranchSelector()} + <div className="panel"> + <div className="panel-heading"> + {this.renderBranchSelector()} + </div> <FileTree repository={repository} revision={revision} path={path} baseUrl={baseUrl} /> - </> + </div> ); } else { return ( @@ -109,13 +113,14 @@ class Sources extends React.Component<Props> { } renderBranchSelector = () => { - const { repository, branches, revision } = this.props; + const { branches, revision, t } = this.props; - if (repository._links.branches) { + if (branches) { return ( <BranchSelector branches={branches} selectedBranch={revision} + label={t("changesets.branchSelectorLabel")} selected={(b: Branch) => { this.branchSelected(b); }} @@ -160,6 +165,7 @@ const mapDispatchToProps = dispatch => { }; export default compose( + translate("repos"), withRouter, connect( mapStateToProps, diff --git a/scm-ui/src/repos/sources/containers/SourcesView.js b/scm-ui/src/repos/sources/containers/SourcesView.js new file mode 100644 index 0000000000..0f729beb2e --- /dev/null +++ b/scm-ui/src/repos/sources/containers/SourcesView.js @@ -0,0 +1,97 @@ +// @flow +import React from "react"; + +import SourcecodeViewer from "../components/content/SourcecodeViewer"; +import ImageViewer from "../components/content/ImageViewer"; +import DownloadViewer from "../components/content/DownloadViewer"; +import { ExtensionPoint } from "@scm-manager/ui-extensions"; +import { getContentType } from "./contentType"; +import type { File, Repository } from "@scm-manager/ui-types"; +import { ErrorNotification, Loading } from "@scm-manager/ui-components"; + +type Props = { + repository: Repository, + file: File, + revision: string, + path: string +}; + +type State = { + contentType: string, + language: string, + loaded: boolean, + error?: Error +}; + +class SourcesView extends React.Component<Props, State> { + constructor(props: Props) { + super(props); + + this.state = { + contentType: "", + language: "", + loaded: false + }; + } + + componentDidMount() { + const { file } = this.props; + getContentType(file._links.self.href) + .then(result => { + if (result.error) { + this.setState({ + ...this.state, + error: result.error, + loaded: true + }); + } else { + this.setState({ + ...this.state, + contentType: result.type, + language: result.language, + loaded: true + }); + } + }) + .catch(err => {}); + } + + showSources() { + const { file, revision } = this.props; + const { contentType, language } = this.state; + if (contentType.startsWith("image/")) { + return <ImageViewer file={file} />; + } else if (language) { + return <SourcecodeViewer file={file} language={language} />; + } else if (contentType.startsWith("text/")) { + return <SourcecodeViewer file={file} language="none" />; + } else { + return ( + <ExtensionPoint + name="repos.sources.view" + props={{ file, contentType, revision }} + > + <DownloadViewer file={file} /> + </ExtensionPoint> + ); + } + } + + render() { + const { file } = this.props; + const { loaded, error } = this.state; + + if (!file || !loaded) { + return <Loading />; + } + if (error) { + return <ErrorNotification error={error} />; + } + + const sources = this.showSources(); + + return <div className="panel-block">{sources}</div>; + } +} + +export default SourcesView; diff --git a/scm-ui/src/repos/sources/containers/history.js b/scm-ui/src/repos/sources/containers/history.js new file mode 100644 index 0000000000..1c58523407 --- /dev/null +++ b/scm-ui/src/repos/sources/containers/history.js @@ -0,0 +1,22 @@ +//@flow +import { apiClient } from "@scm-manager/ui-components"; + +export function getHistory(url: string) { + return apiClient + .get(url) + .then(response => response.json()) + .then(result => { + return { + changesets: result._embedded.changesets, + pageCollection: { + _embedded: result._embedded, + _links: result._links, + page: result.page, + pageTotal: result.pageTotal + } + }; + }) + .catch(err => { + return { error: err }; + }); +} diff --git a/scm-ui/src/repos/sources/containers/history.test.js b/scm-ui/src/repos/sources/containers/history.test.js new file mode 100644 index 0000000000..b97c799888 --- /dev/null +++ b/scm-ui/src/repos/sources/containers/history.test.js @@ -0,0 +1,53 @@ +//@flow +import fetchMock from "fetch-mock"; +import { getHistory } from "./history"; + +describe("get content type", () => { + const FILE_URL = "/repositories/scmadmin/TestRepo/history/file"; + + afterEach(() => { + fetchMock.reset(); + fetchMock.restore(); + }); + + const history = { + page: 0, + pageTotal: 10, + _links: { + self: { + href: "/repositories/scmadmin/TestRepo/history/file?page=0&pageSize=10" + }, + first: { + href: "/repositories/scmadmin/TestRepo/history/file?page=0&pageSize=10" + }, + next: { + href: "/repositories/scmadmin/TestRepo/history/file?page=1&pageSize=10" + }, + last: { + href: "/repositories/scmadmin/TestRepo/history/file?page=9&pageSize=10" + } + }, + _embedded: { + changesets: [ + { + id: "1234" + }, + { + id: "2345" + } + ] + } + }; + + it("should return history", done => { + fetchMock.get("/api/v2" + FILE_URL, history); + + getHistory(FILE_URL).then(content => { + expect(content.changesets).toEqual(history._embedded.changesets); + expect(content.pageCollection.page).toEqual(history.page); + expect(content.pageCollection.pageTotal).toEqual(history.pageTotal); + expect(content.pageCollection._links).toEqual(history._links); + done(); + }); + }); +}); diff --git a/scm-ui/src/repos/sources/modules/sources.js b/scm-ui/src/repos/sources/modules/sources.js index 641c1550b6..41ee7935df 100644 --- a/scm-ui/src/repos/sources/modules/sources.js +++ b/scm-ui/src/repos/sources/modules/sources.js @@ -25,8 +25,7 @@ export function fetchSources( dispatch(fetchSourcesSuccess(repository, revision, path, sources)); }) .catch(err => { - const error = new Error(`failed to fetch sources: ${err.message}`); - dispatch(fetchSourcesFailure(repository, revision, path, error)); + dispatch(fetchSourcesFailure(repository, revision, path, err)); }); }; } @@ -91,10 +90,10 @@ export default function reducer( state: any = {}, action: Action = { type: "UNKNOWN" } ): any { - if (action.type === FETCH_SOURCES_SUCCESS) { + if (action.itemId && action.type === FETCH_SOURCES_SUCCESS) { return { - [action.itemId]: action.payload, - ...state + ...state, + [action.itemId]: action.payload }; } return state; diff --git a/scm-ui/src/repos/sources/modules/sources.test.js b/scm-ui/src/repos/sources/modules/sources.test.js index 1a5c81e908..dea63eb3d0 100644 --- a/scm-ui/src/repos/sources/modules/sources.test.js +++ b/scm-ui/src/repos/sources/modules/sources.test.js @@ -33,7 +33,13 @@ const repository: Repository = { }; const collection = { + name: "src", + path: "src", + directory: true, + description: "foo", + length: 176, revision: "76aae4bb4ceacf0e88938eb5b6832738b7d537b4", + subRepository: undefined, _links: { self: { href: @@ -41,20 +47,24 @@ const collection = { } }, _embedded: { - files: [ + children: [ { name: "src", path: "src", directory: true, - description: null, + description: "", length: 176, - lastModified: null, - subRepository: null, + revision: "76aae4bb4ceacf0e88938eb5b6832738b7d537b4", + lastModified: "", + subRepository: undefined, _links: { self: { href: "http://localhost:8081/scm/rest/api/v2/repositories/scm/core/sources/76aae4bb4ceacf0e88938eb5b6832738b7d537b4/src" } + }, + _embedded: { + children: [] } }, { @@ -63,8 +73,9 @@ const collection = { directory: false, description: "bump version", length: 780, + revision: "76aae4bb4ceacf0e88938eb5b6832738b7d537b4", lastModified: "2017-07-31T11:17:19Z", - subRepository: null, + subRepository: undefined, _links: { self: { href: @@ -74,6 +85,9 @@ const collection = { href: "http://localhost:8081/scm/rest/api/v2/repositories/scm/core/sources/history/76aae4bb4ceacf0e88938eb5b6832738b7d537b4/package.json" } + }, + _embedded: { + children: [] } } ] @@ -92,7 +106,9 @@ const noDirectory: File = { "http://localhost:8081/scm/rest/api/v2/repositories/scm/core/sources/76aae4bb4ceacf0e88938eb5b6832738b7d537b4/src" } }, - _embedded: collection + _embedded: { + children: [] + } }; describe("sources fetch", () => { @@ -116,7 +132,7 @@ describe("sources fetch", () => { ]; const store = mockStore({}); - return store.dispatch(fetchSources(repository)).then(() => { + return store.dispatch(fetchSources(repository, "", "")).then(() => { expect(store.getActions()).toEqual(expectedActions); }); }); @@ -145,7 +161,7 @@ describe("sources fetch", () => { }); const store = mockStore({}); - return store.dispatch(fetchSources(repository)).then(() => { + return store.dispatch(fetchSources(repository, "", "")).then(() => { const actions = store.getActions(); expect(actions[0].type).toBe(FETCH_SOURCES_PENDING); expect(actions[1].type).toBe(FETCH_SOURCES_FAILURE); @@ -166,7 +182,7 @@ describe("reducer tests", () => { "scm/core/_/": collection }; expect( - reducer({}, fetchSourcesSuccess(repository, null, null, collection)) + reducer({}, fetchSourcesSuccess(repository, "", "", collection)) ).toEqual(expectedState); }); @@ -207,7 +223,7 @@ describe("selector tests", () => { }); it("should return null", () => { - expect(getSources({}, repository)).toBeFalsy(); + expect(getSources({}, repository, "", "")).toBeFalsy(); }); it("should return the source collection without revision and path", () => { @@ -216,7 +232,7 @@ describe("selector tests", () => { "scm/core/_/": collection } }; - expect(getSources(state, repository)).toBe(collection); + expect(getSources(state, repository, "", "")).toBe(collection); }); it("should return the source collection with revision and path", () => { @@ -234,11 +250,11 @@ describe("selector tests", () => { [FETCH_SOURCES + "/scm/core/_/"]: true } }; - expect(isFetchSourcesPending(state, repository)).toEqual(true); + expect(isFetchSourcesPending(state, repository, "", "")).toEqual(true); }); it("should return false, when fetch sources is not pending", () => { - expect(isFetchSourcesPending({}, repository)).toEqual(false); + expect(isFetchSourcesPending({}, repository, "", "")).toEqual(false); }); const error = new Error("incredible error from hell"); @@ -249,10 +265,10 @@ describe("selector tests", () => { [FETCH_SOURCES + "/scm/core/_/"]: error } }; - expect(getFetchSourcesFailure(state, repository)).toEqual(error); + expect(getFetchSourcesFailure(state, repository, "", "")).toEqual(error); }); it("should return undefined when fetch sources did not fail", () => { - expect(getFetchSourcesFailure({}, repository)).toBe(undefined); + expect(getFetchSourcesFailure({}, repository, "", "")).toBe(undefined); }); }); diff --git a/scm-ui/src/users/components/SetUserPassword.js b/scm-ui/src/users/components/SetUserPassword.js index ff859f59bf..a0fe844b0c 100644 --- a/scm-ui/src/users/components/SetUserPassword.js +++ b/scm-ui/src/users/components/SetUserPassword.js @@ -2,14 +2,13 @@ import React from "react"; import type { User } from "@scm-manager/ui-types"; import { - InputField, SubmitButton, Notification, - ErrorNotification + ErrorNotification, + PasswordConfirmation } from "@scm-manager/ui-components"; -import * as userValidator from "./userValidation"; import { translate } from "react-i18next"; -import { updatePassword } from "./updatePassword"; +import { setPassword } from "./setPassword"; type Props = { user: User, @@ -19,11 +18,9 @@ type Props = { type State = { password: string, loading: boolean, - passwordConfirmationError: boolean, - validatePasswordError: boolean, - validatePassword: string, error?: Error, - passwordChanged: boolean + passwordChanged: boolean, + passwordValid: boolean }; class SetUserPassword extends React.Component<Props, State> { @@ -36,16 +33,11 @@ class SetUserPassword extends React.Component<Props, State> { passwordConfirmationError: false, validatePasswordError: false, validatePassword: "", - passwordChanged: false + passwordChanged: false, + passwordValid: false }; } - passwordIsValid = () => { - return !( - this.state.validatePasswordError || this.state.passwordConfirmationError - ); - }; - setLoadingState = () => { this.setState({ ...this.state, @@ -66,20 +58,17 @@ class SetUserPassword extends React.Component<Props, State> { ...this.state, loading: false, passwordChanged: true, - password: "", - validatePassword: "", - validatePasswordError: false, - passwordConfirmationError: false + password: "" }); }; submit = (event: Event) => { event.preventDefault(); - if (this.passwordIsValid()) { + if (this.state.password) { const { user } = this.props; const { password } = this.state; this.setLoadingState(); - updatePassword(user._links.password.href, password) + setPassword(user._links.password.href, password) .then(result => { if (result.error) { this.setErrorState(result.error); @@ -101,7 +90,7 @@ class SetUserPassword extends React.Component<Props, State> { message = ( <Notification type={"success"} - children={t("password.set-password-successful")} + children={t("singleUserPassword.setPasswordSuccessful")} onClose={() => this.onClose()} /> ); @@ -112,58 +101,21 @@ class SetUserPassword extends React.Component<Props, State> { return ( <form onSubmit={this.submit}> {message} - <InputField - label={t("user.password")} - type="password" - onChange={this.handlePasswordChange} - value={this.state.password ? this.state.password : ""} - validationError={this.state.validatePasswordError} - errorMessage={t("validation.password-invalid")} - helpText={t("help.passwordHelpText")} - /> - <InputField - label={t("validation.validatePassword")} - type="password" - onChange={this.handlePasswordValidationChange} - value={this.state ? this.state.validatePassword : ""} - validationError={this.state.passwordConfirmationError} - errorMessage={t("validation.passwordValidation-invalid")} - helpText={t("help.passwordConfirmHelpText")} + <PasswordConfirmation + passwordChanged={this.passwordChanged} + key={this.state.passwordChanged ? "changed" : "unchanged"} /> <SubmitButton - disabled={!this.passwordIsValid()} + disabled={!this.state.passwordValid} loading={loading} - label={t("user-form.submit")} + label={t("singleUserPassword.button")} /> </form> ); } - handlePasswordChange = (password: string) => { - const validatePasswordError = !this.checkPasswords( - password, - this.state.validatePassword - ); - this.setState({ - validatePasswordError: !userValidator.isPasswordValid(password), - passwordConfirmationError: validatePasswordError, - password: password - }); - }; - - handlePasswordValidationChange = (validatePassword: string) => { - const passwordConfirmed = this.checkPasswords( - this.state.password, - validatePassword - ); - this.setState({ - validatePassword, - passwordConfirmationError: !passwordConfirmed - }); - }; - - checkPasswords = (password1: string, password2: string) => { - return password1 === password2; + passwordChanged = (password: string, passwordValid: boolean) => { + this.setState({ ...this.state, password, passwordValid: (!!password && passwordValid) }); }; onClose = () => { diff --git a/scm-ui/src/users/components/UserForm.js b/scm-ui/src/users/components/UserForm.js index 0f2407f192..c8fae1ff0a 100644 --- a/scm-ui/src/users/components/UserForm.js +++ b/scm-ui/src/users/components/UserForm.js @@ -3,8 +3,10 @@ import React from "react"; import { translate } from "react-i18next"; import type { User } from "@scm-manager/ui-types"; import { + Subtitle, Checkbox, InputField, + PasswordConfirmation, SubmitButton, validation as validator } from "@scm-manager/ui-components"; @@ -22,9 +24,7 @@ type State = { mailValidationError: boolean, nameValidationError: boolean, displayNameValidationError: boolean, - passwordConfirmationError: boolean, - validatePasswordError: boolean, - validatePassword: string + passwordValid: boolean }; class UserForm extends React.Component<Props, State> { @@ -44,9 +44,7 @@ class UserForm extends React.Component<Props, State> { mailValidationError: false, displayNameValidationError: false, nameValidationError: false, - passwordConfirmationError: false, - validatePasswordError: false, - validatePassword: "" + passwordValid: false }; } @@ -64,15 +62,40 @@ class UserForm extends React.Component<Props, State> { return false; } + createUserComponentsAreInvalid = () => { + const user = this.state.user; + if (!this.props.user) { + return ( + this.state.nameValidationError || + this.isFalsy(user.name) || + !this.state.passwordValid + ); + } else { + return false; + } + }; + + editUserComponentsAreUnchanged = () => { + const user = this.state.user; + if (this.props.user) { + return ( + this.props.user.displayName === user.displayName && + this.props.user.mail === user.mail && + this.props.user.admin === user.admin && + this.props.user.active === user.active + ); + } else { + return false; + } + }; + isValid = () => { const user = this.state.user; return !( - this.state.validatePasswordError || - this.state.nameValidationError || + this.createUserComponentsAreInvalid() || + this.editUserComponentsAreUnchanged() || this.state.mailValidationError || - this.state.passwordConfirmationError || this.state.displayNameValidationError || - this.isFalsy(user.name) || this.isFalsy(user.displayName) || this.isFalsy(user.mail) ); @@ -90,79 +113,85 @@ class UserForm extends React.Component<Props, State> { const user = this.state.user; let nameField = null; - let passwordFields = null; + let passwordChangeField = null; + let subtitle = null; if (!this.props.user) { + // create new user nameField = ( - <InputField - label={t("user.name")} - onChange={this.handleUsernameChange} - value={user ? user.name : ""} - validationError={this.state.nameValidationError} - errorMessage={t("validation.name-invalid")} - helpText={t("help.usernameHelpText")} - /> - ); - passwordFields = ( - <> + <div className="column is-half"> <InputField - label={t("user.password")} - type="password" - onChange={this.handlePasswordChange} - value={user ? user.password : ""} - validationError={this.state.validatePasswordError} - errorMessage={t("validation.password-invalid")} - helpText={t("help.passwordHelpText")} + label={t("user.name")} + onChange={this.handleUsernameChange} + value={user ? user.name : ""} + validationError={this.state.nameValidationError} + errorMessage={t("validation.name-invalid")} + helpText={t("help.usernameHelpText")} /> - <InputField - label={t("validation.validatePassword")} - type="password" - onChange={this.handlePasswordValidationChange} - value={this.state ? this.state.validatePassword : ""} - validationError={this.state.passwordConfirmationError} - errorMessage={t("validation.passwordValidation-invalid")} - helpText={t("help.passwordConfirmHelpText")} - /> - </> + </div> ); + + passwordChangeField = ( + <PasswordConfirmation passwordChanged={this.handlePasswordChange} /> + ); + } else { + // edit existing user + subtitle = <Subtitle subtitle={t("userForm.subtitle")} />; } return ( - <form onSubmit={this.submit}> - {nameField} - <InputField - label={t("user.displayName")} - onChange={this.handleDisplayNameChange} - value={user ? user.displayName : ""} - validationError={this.state.displayNameValidationError} - errorMessage={t("validation.displayname-invalid")} - helpText={t("help.displayNameHelpText")} - /> - <InputField - label={t("user.mail")} - onChange={this.handleEmailChange} - value={user ? user.mail : ""} - validationError={this.state.mailValidationError} - errorMessage={t("validation.mail-invalid")} - helpText={t("help.mailHelpText")} - /> - {passwordFields} - <Checkbox - label={t("user.admin")} - onChange={this.handleAdminChange} - checked={user ? user.admin : false} - helpText={t("help.adminHelpText")} - /> - <Checkbox - label={t("user.active")} - onChange={this.handleActiveChange} - checked={user ? user.active : false} - helpText={t("help.activeHelpText")} - /> - <SubmitButton - disabled={!this.isValid()} - loading={loading} - label={t("user-form.submit")} - /> - </form> + <> + {subtitle} + <form onSubmit={this.submit}> + <div className="columns is-multiline"> + {nameField} + <div className="column is-half"> + <InputField + label={t("user.displayName")} + onChange={this.handleDisplayNameChange} + value={user ? user.displayName : ""} + validationError={this.state.displayNameValidationError} + errorMessage={t("validation.displayname-invalid")} + helpText={t("help.displayNameHelpText")} + /> + </div> + <div className="column is-half"> + <InputField + label={t("user.mail")} + onChange={this.handleEmailChange} + value={user ? user.mail : ""} + validationError={this.state.mailValidationError} + errorMessage={t("validation.mail-invalid")} + helpText={t("help.mailHelpText")} + /> + </div> + </div> + <div className="columns"> + <div className="column"> + {passwordChangeField} + <Checkbox + label={t("user.admin")} + onChange={this.handleAdminChange} + checked={user ? user.admin : false} + helpText={t("help.adminHelpText")} + /> + <Checkbox + label={t("user.active")} + onChange={this.handleActiveChange} + checked={user ? user.active : false} + helpText={t("help.activeHelpText")} + /> + </div> + </div> + <div className="columns"> + <div className="column"> + <SubmitButton + disabled={!this.isValid()} + loading={loading} + label={t("userForm.button")} + /> + </div> + </div> + </form> + </> ); } @@ -189,33 +218,13 @@ class UserForm extends React.Component<Props, State> { }); }; - handlePasswordChange = (password: string) => { - const validatePasswordError = !this.checkPasswords( - password, - this.state.validatePassword - ); + handlePasswordChange = (password: string, passwordValid: boolean) => { this.setState({ - validatePasswordError: !userValidator.isPasswordValid(password), - passwordConfirmationError: validatePasswordError, - user: { ...this.state.user, password } + user: { ...this.state.user, password }, + passwordValid: !this.isFalsy(password) && passwordValid }); }; - handlePasswordValidationChange = (validatePassword: string) => { - const validatePasswordError = this.checkPasswords( - this.state.user.password, - validatePassword - ); - this.setState({ - validatePassword, - passwordConfirmationError: !validatePasswordError - }); - }; - - checkPasswords = (password1: string, password2: string) => { - return password1 === password2; - }; - handleAdminChange = (admin: boolean) => { this.setState({ user: { ...this.state.user, admin } }); }; diff --git a/scm-ui/src/users/components/buttons/CreateUserButton.js b/scm-ui/src/users/components/buttons/CreateUserButton.js deleted file mode 100644 index f34820cd0d..0000000000 --- a/scm-ui/src/users/components/buttons/CreateUserButton.js +++ /dev/null @@ -1,20 +0,0 @@ -//@flow -import React from "react"; -import { translate } from "react-i18next"; -import { CreateButton } from "@scm-manager/ui-components"; - -// TODO remove -type Props = { - t: string => string -}; - -class CreateUserButton extends React.Component<Props> { - render() { - const { t } = this.props; - return ( - <CreateButton label={t("create-user-button.label")} link="/users/add" /> - ); - } -} - -export default translate("users")(CreateUserButton); diff --git a/scm-ui/src/users/components/navLinks/DeleteUserNavLink.js b/scm-ui/src/users/components/navLinks/DeleteUserNavLink.js deleted file mode 100644 index 47fdae0f92..0000000000 --- a/scm-ui/src/users/components/navLinks/DeleteUserNavLink.js +++ /dev/null @@ -1,56 +0,0 @@ -// @flow -import React from "react"; -import { translate } from "react-i18next"; -import type { User } from "@scm-manager/ui-types"; -import { NavAction, confirmAlert } from "@scm-manager/ui-components"; - -type Props = { - user: User, - confirmDialog?: boolean, - t: string => string, - deleteUser: (user: User) => void -}; - -class DeleteUserNavLink extends React.Component<Props> { - static defaultProps = { - confirmDialog: true - }; - - deleteUser = () => { - this.props.deleteUser(this.props.user); - }; - - confirmDelete = () => { - const { t } = this.props; - confirmAlert({ - title: t("delete-user-button.confirm-alert.title"), - message: t("delete-user-button.confirm-alert.message"), - buttons: [ - { - label: t("delete-user-button.confirm-alert.submit"), - onClick: () => this.deleteUser() - }, - { - label: t("delete-user-button.confirm-alert.cancel"), - onClick: () => null - } - ] - }); - }; - - isDeletable = () => { - return this.props.user._links.delete; - }; - - render() { - const { confirmDialog, t } = this.props; - const action = confirmDialog ? this.confirmDelete : this.deleteUser; - - if (!this.isDeletable()) { - return null; - } - return <NavAction label={t("delete-user-button.label")} action={action} />; - } -} - -export default translate("users")(DeleteUserNavLink); diff --git a/scm-ui/src/users/components/navLinks/DeleteUserNavLink.test.js b/scm-ui/src/users/components/navLinks/DeleteUserNavLink.test.js deleted file mode 100644 index 500235ab94..0000000000 --- a/scm-ui/src/users/components/navLinks/DeleteUserNavLink.test.js +++ /dev/null @@ -1,82 +0,0 @@ -import React from "react"; -import { mount, shallow } from "enzyme"; -import "../../../tests/enzyme"; -import "../../../tests/i18n"; -import DeleteUserNavLink from "./DeleteUserNavLink"; - -import { confirmAlert } from "@scm-manager/ui-components"; -jest.mock("@scm-manager/ui-components", () => ({ - confirmAlert: jest.fn(), - NavAction: require.requireActual("@scm-manager/ui-components").NavAction -})); - -describe("DeleteUserNavLink", () => { - it("should render nothing, if the delete link is missing", () => { - const user = { - _links: {} - }; - - const navLink = shallow( - <DeleteUserNavLink user={user} deleteUser={() => {}} /> - ); - expect(navLink.text()).toBe(""); - }); - - it("should render the navLink", () => { - const user = { - _links: { - delete: { - href: "/users" - } - } - }; - - const navLink = mount( - <DeleteUserNavLink user={user} deleteUser={() => {}} /> - ); - expect(navLink.text()).not.toBe(""); - }); - - it("should open the confirm dialog on navLink click", () => { - const user = { - _links: { - delete: { - href: "/users" - } - } - }; - - const navLink = mount( - <DeleteUserNavLink user={user} deleteUser={() => {}} /> - ); - navLink.find("a").simulate("click"); - - expect(confirmAlert.mock.calls.length).toBe(1); - }); - - it("should call the delete user function with delete url", () => { - const user = { - _links: { - delete: { - href: "/users" - } - } - }; - - let calledUrl = null; - function capture(user) { - calledUrl = user._links.delete.href; - } - - const navLink = mount( - <DeleteUserNavLink - user={user} - confirmDialog={false} - deleteUser={capture} - /> - ); - navLink.find("a").simulate("click"); - - expect(calledUrl).toBe("/users"); - }); -}); diff --git a/scm-ui/src/users/components/navLinks/EditUserNavLink.js b/scm-ui/src/users/components/navLinks/EditUserNavLink.js index 9999428212..051bf9a4bd 100644 --- a/scm-ui/src/users/components/navLinks/EditUserNavLink.js +++ b/scm-ui/src/users/components/navLinks/EditUserNavLink.js @@ -1,28 +1,28 @@ //@flow import React from "react"; -import { translate } from "react-i18next"; import type { User } from "@scm-manager/ui-types"; import { NavLink } from "@scm-manager/ui-components"; +import { translate } from "react-i18next"; type Props = { - t: string => string, user: User, - editUrl: String + editUrl: String, + t: string => string }; class EditUserNavLink extends React.Component<Props> { + isEditable = () => { + return this.props.user._links.update; + }; + render() { const { t, editUrl } = this.props; if (!this.isEditable()) { return null; } - return <NavLink label={t("edit-user-button.label")} to={editUrl} />; + return <NavLink to={editUrl} label={t("singleUser.menu.generalNavLink")} />; } - - isEditable = () => { - return this.props.user._links.update; - }; } export default translate("users")(EditUserNavLink); diff --git a/scm-ui/src/users/components/navLinks/SetPasswordNavLink.js b/scm-ui/src/users/components/navLinks/SetPasswordNavLink.js index 43b7a4b5a4..79234308aa 100644 --- a/scm-ui/src/users/components/navLinks/SetPasswordNavLink.js +++ b/scm-ui/src/users/components/navLinks/SetPasswordNavLink.js @@ -17,7 +17,7 @@ class ChangePasswordNavLink extends React.Component<Props> { if (!this.hasPermissionToSetPassword()) { return null; } - return <NavLink label={t("set-password-button.label")} to={passwordUrl} />; + return <NavLink to={passwordUrl} label={t("singleUser.menu.setPasswordNavLink")} />; } hasPermissionToSetPassword = () => { diff --git a/scm-ui/src/users/components/navLinks/SetPermissionsNavLink.js b/scm-ui/src/users/components/navLinks/SetPermissionsNavLink.js new file mode 100644 index 0000000000..84b0f9da76 --- /dev/null +++ b/scm-ui/src/users/components/navLinks/SetPermissionsNavLink.js @@ -0,0 +1,28 @@ +//@flow +import React from "react"; +import { translate } from "react-i18next"; +import type { User } from "@scm-manager/ui-types"; +import { NavLink } from "@scm-manager/ui-components"; + +type Props = { + t: string => string, + user: User, + permissionsUrl: String +}; + +class ChangePermissionNavLink extends React.Component<Props> { + render() { + const { t, permissionsUrl } = this.props; + + if (!this.hasPermissionToSetPermission()) { + return null; + } + return <NavLink to={permissionsUrl} label={t("singleUser.menu.setPermissionsNavLink")} />; + } + + hasPermissionToSetPermission = () => { + return this.props.user._links.permissions; + }; +} + +export default translate("users")(ChangePermissionNavLink); diff --git a/scm-ui/src/users/components/navLinks/SetPermissionsNavLink.test.js b/scm-ui/src/users/components/navLinks/SetPermissionsNavLink.test.js new file mode 100644 index 0000000000..0e5fcf4125 --- /dev/null +++ b/scm-ui/src/users/components/navLinks/SetPermissionsNavLink.test.js @@ -0,0 +1,31 @@ +import React from "react"; +import { shallow } from "enzyme"; +import "../../../tests/enzyme"; +import "../../../tests/i18n"; +import SetPermissionsNavLink from "./SetPermissionsNavLink"; + +it("should render nothing, if the permissions link is missing", () => { + const user = { + _links: {} + }; + + const navLink = shallow( + <SetPermissionsNavLink user={user} permissionsUrl="/user/permissions" /> + ); + expect(navLink.text()).toBe(""); +}); + +it("should render the navLink", () => { + const user = { + _links: { + permissions: { + href: "/permissions" + } + } + }; + + const navLink = shallow( + <SetPermissionsNavLink user={user} permissionsUrl="/user/permissions" /> + ); + expect(navLink.text()).not.toBe(""); +}); diff --git a/scm-ui/src/users/components/navLinks/index.js b/scm-ui/src/users/components/navLinks/index.js index a6d8370c00..cb97c57e3f 100644 --- a/scm-ui/src/users/components/navLinks/index.js +++ b/scm-ui/src/users/components/navLinks/index.js @@ -1,3 +1,3 @@ -export { default as DeleteUserNavLink } from "./DeleteUserNavLink"; export { default as EditUserNavLink } from "./EditUserNavLink"; export { default as SetPasswordNavLink } from "./SetPasswordNavLink"; +export { default as SetPermissionsNavLink } from "./SetPermissionsNavLink"; diff --git a/scm-ui/src/users/components/updatePassword.js b/scm-ui/src/users/components/setPassword.js similarity index 62% rename from scm-ui/src/users/components/updatePassword.js rename to scm-ui/src/users/components/setPassword.js index 3915c90bd9..d96c76a4b7 100644 --- a/scm-ui/src/users/components/updatePassword.js +++ b/scm-ui/src/users/components/setPassword.js @@ -1,15 +1,13 @@ //@flow import { apiClient } from "@scm-manager/ui-components"; -const CONTENT_TYPE_PASSWORD_OVERWRITE = + +export const CONTENT_TYPE_PASSWORD_OVERWRITE = "application/vnd.scmm-passwordOverwrite+json;v=2"; -export function updatePassword(url: string, password: string) { +export function setPassword(url: string, password: string) { return apiClient .put(url, { newPassword: password }, CONTENT_TYPE_PASSWORD_OVERWRITE) .then(response => { return response; - }) - .catch(err => { - return { error: err }; }); } diff --git a/scm-ui/src/users/components/setPassword.test.js b/scm-ui/src/users/components/setPassword.test.js new file mode 100644 index 0000000000..8414010c36 --- /dev/null +++ b/scm-ui/src/users/components/setPassword.test.js @@ -0,0 +1,25 @@ +//@flow +import fetchMock from "fetch-mock"; +import { CONTENT_TYPE_PASSWORD_OVERWRITE, setPassword } from "./setPassword"; + +describe("password change", () => { + const SET_PASSWORD_URL = "/users/testuser/password"; + const newPassword = "testpw123"; + + afterEach(() => { + fetchMock.reset(); + fetchMock.restore(); + }); + + it("should set password", done => { + fetchMock.put("/api/v2" + SET_PASSWORD_URL, 204, { + headers: { + "content-type": CONTENT_TYPE_PASSWORD_OVERWRITE + } + }); + + setPassword(SET_PASSWORD_URL, newPassword).then(content => { + done(); + }); + }); +}); diff --git a/scm-ui/src/users/components/table/Details.js b/scm-ui/src/users/components/table/Details.js index c967e4d0b4..1db5d2154c 100644 --- a/scm-ui/src/users/components/table/Details.js +++ b/scm-ui/src/users/components/table/Details.js @@ -1,66 +1,66 @@ -//@flow -import React from "react"; -import type { User } from "@scm-manager/ui-types"; -import { translate } from "react-i18next"; -import { Checkbox, MailLink, DateFromNow } from "@scm-manager/ui-components"; - -type Props = { - user: User, - t: string => string -}; - -class Details extends React.Component<Props> { - render() { - const { user, t } = this.props; - return ( - <table className="table"> - <tbody> - <tr> - <td>{t("user.name")}</td> - <td>{user.name}</td> - </tr> - <tr> - <td>{t("user.displayName")}</td> - <td>{user.displayName}</td> - </tr> - <tr> - <td>{t("user.mail")}</td> - <td> - <MailLink address={user.mail} /> - </td> - </tr> - <tr> - <td>{t("user.admin")}</td> - <td> - <Checkbox checked={user.admin} /> - </td> - </tr> - <tr> - <td>{t("user.active")}</td> - <td> - <Checkbox checked={user.active} /> - </td> - </tr> - <tr> - <td>{t("user.type")}</td> - <td>{user.type}</td> - </tr> - <tr> - <td>{t("user.creationDate")}</td> - <td> - <DateFromNow date={user.creationDate} /> - </td> - </tr> - <tr> - <td>{t("user.lastModified")}</td> - <td> - <DateFromNow date={user.lastModified} /> - </td> - </tr> - </tbody> - </table> - ); - } -} - -export default translate("users")(Details); +//@flow +import React from "react"; +import type { User } from "@scm-manager/ui-types"; +import { translate } from "react-i18next"; +import { Checkbox, MailLink, DateFromNow } from "@scm-manager/ui-components"; + +type Props = { + user: User, + t: string => string +}; + +class Details extends React.Component<Props> { + render() { + const { user, t } = this.props; + return ( + <table className="table"> + <tbody> + <tr> + <td className="has-text-weight-semibold">{t("user.name")}</td> + <td>{user.name}</td> + </tr> + <tr> + <td className="has-text-weight-semibold">{t("user.displayName")}</td> + <td>{user.displayName}</td> + </tr> + <tr> + <td className="has-text-weight-semibold">{t("user.mail")}</td> + <td> + <MailLink address={user.mail} /> + </td> + </tr> + <tr> + <td className="has-text-weight-semibold">{t("user.admin")}</td> + <td> + <Checkbox checked={user.admin} /> + </td> + </tr> + <tr> + <td className="has-text-weight-semibold">{t("user.active")}</td> + <td> + <Checkbox checked={user.active} /> + </td> + </tr> + <tr> + <td className="has-text-weight-semibold">{t("user.type")}</td> + <td>{user.type}</td> + </tr> + <tr> + <td className="has-text-weight-semibold">{t("user.creationDate")}</td> + <td> + <DateFromNow date={user.creationDate} /> + </td> + </tr> + <tr> + <td className="has-text-weight-semibold">{t("user.lastModified")}</td> + <td> + <DateFromNow date={user.lastModified} /> + </td> + </tr> + </tbody> + </table> + ); + } +} + +export default translate("users")(Details); diff --git a/scm-ui/src/users/components/table/UserRow.js b/scm-ui/src/users/components/table/UserRow.js index 6960009bea..c879d5e8f7 100644 --- a/scm-ui/src/users/components/table/UserRow.js +++ b/scm-ui/src/users/components/table/UserRow.js @@ -1,31 +1,31 @@ -// @flow -import React from "react"; -import { Link } from "react-router-dom"; -import type { User } from "@scm-manager/ui-types"; - -type Props = { - user: User -}; - -export default class UserRow extends React.Component<Props> { - renderLink(to: string, label: string) { - return <Link to={to}>{label}</Link>; - } - - render() { - const { user } = this.props; - const to = `/user/${user.name}`; - return ( - <tr> - <td className="is-hidden-mobile">{this.renderLink(to, user.name)}</td> - <td>{this.renderLink(to, user.displayName)}</td> - <td> - <a href={`mailto: ${user.mail}`}>{user.mail}</a> - </td> - <td className="is-hidden-mobile"> - <input type="checkbox" id="admin" checked={user.admin} readOnly /> - </td> - </tr> - ); - } -} +// @flow +import React from "react"; +import { Link } from "react-router-dom"; +import type { User } from "@scm-manager/ui-types"; + +type Props = { + user: User +}; + +export default class UserRow extends React.Component<Props> { + renderLink(to: string, label: string) { + return <Link to={to}>{label}</Link>; + } + + render() { + const { user } = this.props; + const to = `/user/${user.name}`; + return ( + <tr> + <td className="is-hidden-mobile">{this.renderLink(to, user.name)}</td> + <td>{this.renderLink(to, user.displayName)}</td> + <td> + <a href={`mailto: ${user.mail}`}>{user.mail}</a> + </td> + <td className="is-hidden-mobile"> + <input type="checkbox" id="admin" checked={user.admin} readOnly /> + </td> + </tr> + ); + } +} diff --git a/scm-ui/src/users/components/table/UserTable.js b/scm-ui/src/users/components/table/UserTable.js index 11d44da3ca..8febdb6011 100644 --- a/scm-ui/src/users/components/table/UserTable.js +++ b/scm-ui/src/users/components/table/UserTable.js @@ -1,35 +1,37 @@ -// @flow -import React from "react"; -import { translate } from "react-i18next"; -import UserRow from "./UserRow"; -import type { User } from "@scm-manager/ui-types"; - -type Props = { - t: string => string, - users: User[] -}; - -class UserTable extends React.Component<Props> { - render() { - const { users, t } = this.props; - return ( - <table className="table is-hoverable is-fullwidth"> - <thead> - <tr> - <th className="is-hidden-mobile">{t("user.name")}</th> - <th>{t("user.displayName")}</th> - <th>{t("user.mail")}</th> - <th className="is-hidden-mobile">{t("user.admin")}</th> - </tr> - </thead> - <tbody> - {users.map((user, index) => { - return <UserRow key={index} user={user} />; - })} - </tbody> - </table> - ); - } -} - -export default translate("users")(UserTable); +// @flow +import React from "react"; +import { translate } from "react-i18next"; +import UserRow from "./UserRow"; +import type { User } from "@scm-manager/ui-types"; + +type Props = { + t: string => string, + users: User[] +}; + +; + +class UserTable extends React.Component<Props> { + render() { + const { users, t } = this.props; + return ( + <table className="card-table table is-hoverable is-fullwidth"> + <thead> + <tr> + <th className="is-hidden-mobile">{t("user.name")}</th> + <th>{t("user.displayName")}</th> + <th>{t("user.mail")}</th> + <th className="is-hidden-mobile">{t("user.admin")}</th> + </tr> + </thead> + <tbody> + {users.map((user, index) => { + return <UserRow key={index} user={user} />; + })} + </tbody> + </table> + ); + } +} + +export default translate("users")(UserTable); \ No newline at end of file diff --git a/scm-ui/src/users/components/updatePassword.test.js b/scm-ui/src/users/components/updatePassword.test.js deleted file mode 100644 index a5762406b2..0000000000 --- a/scm-ui/src/users/components/updatePassword.test.js +++ /dev/null @@ -1,23 +0,0 @@ -//@flow -import fetchMock from "fetch-mock"; -import { updatePassword } from "./updatePassword"; - -describe("get content type", () => { - const PASSWORD_URL = "/users/testuser/password"; - const password = "testpw123"; - - afterEach(() => { - fetchMock.reset(); - fetchMock.restore(); - }); - - it("should update password", done => { - - fetchMock.put("/api/v2" + PASSWORD_URL, 204); - - updatePassword(PASSWORD_URL, password).then(content => { - - done(); - }); - }); -}); diff --git a/scm-ui/src/users/containers/AddUser.js b/scm-ui/src/users/containers/AddUser.js index 1ee6fc759d..069df04187 100644 --- a/scm-ui/src/users/containers/AddUser.js +++ b/scm-ui/src/users/containers/AddUser.js @@ -12,7 +12,7 @@ import { } from "../modules/users"; import { Page } from "@scm-manager/ui-components"; import { translate } from "react-i18next"; -import {getUsersLink} from "../../modules/indexResource"; +import { getUsersLink } from "../../modules/indexResource"; type Props = { loading?: boolean, @@ -33,13 +33,15 @@ class AddUser extends React.Component<Props> { this.props.resetForm(); } - userCreated = () => { + userCreated = (user: User) => { const { history } = this.props; - history.push("/users"); + history.push("/user/" + user.name); }; createUser = (user: User) => { - this.props.addUser(this.props.usersLink, user, this.userCreated); + this.props.addUser(this.props.usersLink, user, () => + this.userCreated(user) + ); }; render() { @@ -47,8 +49,8 @@ class AddUser extends React.Component<Props> { return ( <Page - title={t("add-user.title")} - subtitle={t("add-user.subtitle")} + title={t("addUser.title")} + subtitle={t("addUser.subtitle")} error={error} showContentOnError={true} > diff --git a/scm-ui/src/users/containers/DeleteUser.js b/scm-ui/src/users/containers/DeleteUser.js new file mode 100644 index 0000000000..b8b42fd9e8 --- /dev/null +++ b/scm-ui/src/users/containers/DeleteUser.js @@ -0,0 +1,113 @@ +// @flow +import React from "react"; +import { translate } from "react-i18next"; +import type { User } from "@scm-manager/ui-types"; +import { + Subtitle, + DeleteButton, + confirmAlert, + ErrorNotification +} from "@scm-manager/ui-components"; +import { + deleteUser, + getDeleteUserFailure, + isDeleteUserPending +} from "../modules/users"; +import { connect } from "react-redux"; +import { withRouter } from "react-router-dom"; +import type { History } from "history"; + +type Props = { + loading: boolean, + error: Error, + user: User, + confirmDialog?: boolean, + deleteUser: (user: User, callback?: () => void) => void, + + // context props + history: History, + t: string => string +}; + +class DeleteUser extends React.Component<Props> { + static defaultProps = { + confirmDialog: true + }; + + userDeleted = () => { + this.props.history.push("/users"); + }; + + deleteUser = () => { + this.props.deleteUser(this.props.user, this.userDeleted); + }; + + confirmDelete = () => { + const { t } = this.props; + confirmAlert({ + title: t("deleteUser.confirmAlert.title"), + message: t("deleteUser.confirmAlert.message"), + buttons: [ + { + label: t("deleteUser.confirmAlert.submit"), + onClick: () => this.deleteUser() + }, + { + label: t("deleteUser.confirmAlert.cancel"), + onClick: () => null + } + ] + }); + }; + + isDeletable = () => { + return this.props.user._links.delete; + }; + + render() { + const { loading, error, confirmDialog, t } = this.props; + const action = confirmDialog ? this.confirmDelete : this.deleteUser; + + if (!this.isDeletable()) { + return null; + } + + return ( + <> + <Subtitle subtitle={t("deleteUser.subtitle")} /> + <ErrorNotification error={error} /> + <div className="columns"> + <div className="column"> + <DeleteButton + label={t("deleteUser.button")} + action={action} + loading={loading} + /> + </div> + </div> + </> + ); + } +} + +const mapStateToProps = (state, ownProps) => { + const loading = isDeleteUserPending(state, ownProps.user.name); + const error = getDeleteUserFailure(state, ownProps.user.name); + return { + loading, + error + }; +}; + +const mapDispatchToProps = dispatch => { + return { + deleteUser: (user: User, callback?: () => void) => { + dispatch(deleteUser(user, callback)); + } + }; +}; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(withRouter(translate("users")(DeleteUser))); diff --git a/scm-ui/src/users/containers/EditUser.js b/scm-ui/src/users/containers/EditUser.js index 55062ecb5b..942d5182e7 100644 --- a/scm-ui/src/users/containers/EditUser.js +++ b/scm-ui/src/users/containers/EditUser.js @@ -2,7 +2,8 @@ import React from "react"; import { connect } from "react-redux"; import { withRouter } from "react-router-dom"; -import UserForm from "./../components/UserForm"; +import UserForm from "../components/UserForm"; +import DeleteUser from "./DeleteUser"; import type { User } from "@scm-manager/ui-types"; import { modifyUser, @@ -31,6 +32,7 @@ class EditUser extends React.Component<Props> { const { modifyUserReset, user } = this.props; modifyUserReset(user); } + userModified = (user: User) => () => { this.props.history.push(`/user/${user.name}`); }; @@ -49,11 +51,22 @@ class EditUser extends React.Component<Props> { user={user} loading={loading} /> + <hr /> + <DeleteUser user={user} /> </div> ); } } +const mapStateToProps = (state, ownProps) => { + const loading = isModifyUserPending(state, ownProps.user.name); + const error = getModifyUserFailure(state, ownProps.user.name); + return { + loading, + error + }; +}; + const mapDispatchToProps = dispatch => { return { modifyUser: (user: User, callback?: () => void) => { @@ -65,15 +78,6 @@ const mapDispatchToProps = dispatch => { }; }; -const mapStateToProps = (state, ownProps) => { - const loading = isModifyUserPending(state, ownProps.user.name); - const error = getModifyUserFailure(state, ownProps.user.name); - return { - loading, - error - }; -}; - export default connect( mapStateToProps, mapDispatchToProps diff --git a/scm-ui/src/users/containers/SingleUser.js b/scm-ui/src/users/containers/SingleUser.js index 5f20598962..2d89f82ac6 100644 --- a/scm-ui/src/users/containers/SingleUser.js +++ b/scm-ui/src/users/containers/SingleUser.js @@ -5,6 +5,7 @@ import { Page, Loading, Navigation, + SubNavigation, Section, NavLink, ErrorPage @@ -16,22 +17,16 @@ import type { User } from "@scm-manager/ui-types"; import type { History } from "history"; import { fetchUserByName, - deleteUser, getUserByName, isFetchUserPending, - getFetchUserFailure, - isDeleteUserPending, - getDeleteUserFailure + getFetchUserFailure } from "../modules/users"; - -import { - DeleteUserNavLink, - EditUserNavLink, - SetPasswordNavLink -} from "./../components/navLinks"; +import { EditUserNavLink, SetPasswordNavLink, SetPermissionsNavLink } from "./../components/navLinks"; import { translate } from "react-i18next"; import { getUsersLink } from "../../modules/indexResource"; import SetUserPassword from "../components/SetUserPassword"; +import SetPermissions from "../../permissions/components/SetPermissions"; +import {ExtensionPoint} from "@scm-manager/ui-extensions"; type Props = { name: string, @@ -40,8 +35,7 @@ type Props = { error: Error, usersLink: string, - // dispatcher functions - deleteUser: (user: User, callback?: () => void) => void, + // dispatcher function fetchUserByName: (string, string) => void, // context objects @@ -55,14 +49,6 @@ class SingleUser extends React.Component<Props> { this.props.fetchUserByName(this.props.usersLink, this.props.name); } - userDeleted = () => { - this.props.history.push("/users"); - }; - - deleteUser = (user: User) => { - this.props.deleteUser(user, this.userDeleted); - }; - stripEndingSlash = (url: string) => { if (url.endsWith("/")) { return url.substring(0, url.length - 2); @@ -80,8 +66,8 @@ class SingleUser extends React.Component<Props> { if (error) { return ( <ErrorPage - title={t("single-user.error-title")} - subtitle={t("single-user.error-subtitle")} + title={t("singleUser.errorTitle")} + subtitle={t("singleUser.errorSubtitle")} error={error} /> ); @@ -93,36 +79,68 @@ class SingleUser extends React.Component<Props> { const url = this.matchedUrl(); + const extensionProps = { + user, + url + }; + return ( <Page title={user.displayName}> <div className="columns"> <div className="column is-three-quarters"> <Route path={url} exact component={() => <Details user={user} />} /> <Route - path={`${url}/edit`} + path={`${url}/settings/general`} component={() => <EditUser user={user} />} /> <Route - path={`${url}/password`} + path={`${url}/settings/password`} component={() => <SetUserPassword user={user} />} /> + <Route + path={`${url}/settings/permissions`} + component={() => ( + <SetPermissions + selectedPermissionsLink={user._links.permissions} + /> + )} + /> + <ExtensionPoint + name="user.route" + props={extensionProps} + renderAll={true} + /> </div> <div className="column"> <Navigation> - <Section label={t("single-user.navigation-label")}> + <Section label={t("singleUser.menu.navigationLabel")}> <NavLink to={`${url}`} - label={t("single-user.information-label")} + icon="fas fa-info-circle" + label={t("singleUser.menu.informationNavLink")} /> - <EditUserNavLink user={user} editUrl={`${url}/edit`} /> - <SetPasswordNavLink - user={user} - passwordUrl={`${url}/password`} - /> - </Section> - <Section label={t("single-user.actions-label")}> - <DeleteUserNavLink user={user} deleteUser={this.deleteUser} /> - <NavLink to="/users" label={t("single-user.back-label")} /> + <SubNavigation + to={`${url}/settings/general`} + label={t("singleUser.menu.settingsNavLink")} + > + <EditUserNavLink + user={user} + editUrl={`${url}/settings/general`} + /> + <SetPasswordNavLink + user={user} + passwordUrl={`${url}/settings/password`} + /> + <SetPermissionsNavLink + user={user} + permissionsUrl={`${url}/settings/permissions`} + /> + <ExtensionPoint + name="user.setting" + props={extensionProps} + renderAll={true} + /> + </SubNavigation> </Section> </Navigation> </div> @@ -135,10 +153,8 @@ class SingleUser extends React.Component<Props> { const mapStateToProps = (state, ownProps) => { const name = ownProps.match.params.name; const user = getUserByName(state, name); - const loading = - isFetchUserPending(state, name) || isDeleteUserPending(state, name); - const error = - getFetchUserFailure(state, name) || getDeleteUserFailure(state, name); + const loading = isFetchUserPending(state, name); + const error = getFetchUserFailure(state, name); const usersLink = getUsersLink(state); return { usersLink, @@ -153,9 +169,6 @@ const mapDispatchToProps = dispatch => { return { fetchUserByName: (link: string, name: string) => { dispatch(fetchUserByName(link, name)); - }, - deleteUser: (user: User, callback?: () => void) => { - dispatch(deleteUser(user, callback)); } }; }; diff --git a/scm-ui/src/users/containers/Users.js b/scm-ui/src/users/containers/Users.js index 48c20c88ec..10dedde32d 100644 --- a/scm-ui/src/users/containers/Users.js +++ b/scm-ui/src/users/containers/Users.js @@ -14,10 +14,15 @@ import { getFetchUsersFailure } from "../modules/users"; -import { Page, Paginator } from "@scm-manager/ui-components"; +import { + Page, + PageActions, + Button, + CreateButton, + Paginator +} from "@scm-manager/ui-components"; import { UserTable } from "./../components/table"; import type { User, PagedCollection } from "@scm-manager/ui-types"; -import CreateUserButton from "../components/buttons/CreateUserButton"; import { getUsersLink } from "../../modules/indexResource"; type Props = { @@ -73,6 +78,13 @@ class Users extends React.Component<Props> { <UserTable users={users} /> {this.renderPaginator()} {this.renderCreateButton()} + <PageActions> + <Button + label={t("users.createButton")} + link="/users/add" + color="primary" + /> + </PageActions> </Page> ); } @@ -86,8 +98,9 @@ class Users extends React.Component<Props> { } renderCreateButton() { + const { t } = this.props; if (this.props.canAddUsers) { - return <CreateUserButton />; + return <CreateButton label={t("users.createButton")} link="/users/add" />; } else { return; } diff --git a/scm-ui/styles/scm.css b/scm-ui/styles/scm.css index 8de75b3a6c..9027cb9f87 100644 --- a/scm-ui/styles/scm.css +++ b/scm-ui/styles/scm.css @@ -1,10223 +1,54 @@ -.is-ellipsis-overflow { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; } - -.has-rounded-border { - border-radius: 0.25rem; } - -.is-full-width { - width: 100%; } - -.fitParent { - margin: 0 !important; - padding: 0 0 0 3.8em !important; } - -.main { - min-height: calc(100vh - 260px); } - -.footer { - height: 50px; } - -/*! bulma.io v0.7.1 | MIT License | github.com/jgthms/bulma */ -@keyframes spinAround { - from { - transform: rotate(0deg); } - to { - transform: rotate(359deg); } } - -.delete, .modal-close, .is-unselectable, .button, .file, .breadcrumb, .pagination-previous, -.pagination-next, -.pagination-link, -.pagination-ellipsis, .tabs { - -webkit-touch-callout: none; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; } - -.select:not(.is-multiple):not(.is-loading)::after, .navbar-link::after { - border: 3px solid transparent; - border-radius: 2px; - border-right: 0; - border-top: 0; - content: " "; - display: block; - height: 0.625em; - margin-top: -0.4375em; - pointer-events: none; - position: absolute; - top: 50%; - transform: rotate(-45deg); - transform-origin: center; - width: 0.625em; } - -.box:not(:last-child), .content:not(:last-child), .notification:not(:last-child), .progress:not(:last-child), .table:not(:last-child), .table-container:not(:last-child), .title:not(:last-child), -.subtitle:not(:last-child), .block:not(:last-child), .highlight:not(:last-child), .breadcrumb:not(:last-child), .level:not(:last-child), .message:not(:last-child), .tabs:not(:last-child) { - margin-bottom: 1.5rem; } - -.delete, .modal-close { - -moz-appearance: none; - -webkit-appearance: none; - background-color: rgba(10, 10, 10, 0.2); - border: none; - border-radius: 290486px; - cursor: pointer; - display: inline-block; - flex-grow: 0; - flex-shrink: 0; - font-size: 0; - height: 20px; - max-height: 20px; - max-width: 20px; - min-height: 20px; - min-width: 20px; - outline: none; - position: relative; - vertical-align: top; - width: 20px; } - .delete::before, .modal-close::before, .delete::after, .modal-close::after { - background-color: white; - content: ""; - display: block; - left: 50%; - position: absolute; - top: 50%; - transform: translateX(-50%) translateY(-50%) rotate(45deg); - transform-origin: center center; } - .delete::before, .modal-close::before { - height: 2px; - width: 50%; } - .delete::after, .modal-close::after { - height: 50%; - width: 2px; } - .delete:hover, .modal-close:hover, .delete:focus, .modal-close:focus { - background-color: rgba(10, 10, 10, 0.3); } - .delete:active, .modal-close:active { - background-color: rgba(10, 10, 10, 0.4); } - .is-small.delete, .is-small.modal-close { - height: 16px; - max-height: 16px; - max-width: 16px; - min-height: 16px; - min-width: 16px; - width: 16px; } - .is-medium.delete, .is-medium.modal-close { - height: 24px; - max-height: 24px; - max-width: 24px; - min-height: 24px; - min-width: 24px; - width: 24px; } - .is-large.delete, .is-large.modal-close { - height: 32px; - max-height: 32px; - max-width: 32px; - min-height: 32px; - min-width: 32px; - width: 32px; } - -.button.is-loading::after, .select.is-loading::after, .control.is-loading::after, .loader { - animation: spinAround 500ms infinite linear; - border: 2px solid #dbdbdb; - border-radius: 290486px; - border-right-color: transparent; - border-top-color: transparent; - content: ""; - display: block; - height: 1em; - position: relative; - width: 1em; } - -.is-overlay, .image.is-square img, .image.is-1by1 img, .image.is-5by4 img, .image.is-4by3 img, .image.is-3by2 img, .image.is-5by3 img, .image.is-16by9 img, .image.is-2by1 img, .image.is-3by1 img, .image.is-4by5 img, .image.is-3by4 img, .image.is-2by3 img, .image.is-3by5 img, .image.is-9by16 img, .image.is-1by2 img, .image.is-1by3 img, .modal, .modal-background, .hero-video { - bottom: 0; - left: 0; - position: absolute; - right: 0; - top: 0; } - -.button, .input, -.textarea, .select select, .file-cta, -.file-name, .pagination-previous, -.pagination-next, -.pagination-link, -.pagination-ellipsis { - -moz-appearance: none; - -webkit-appearance: none; - align-items: center; - border: 1px solid transparent; - border-radius: 4px; - box-shadow: none; - display: inline-flex; - font-size: 1rem; - height: 2.25em; - justify-content: flex-start; - line-height: 1.5; - padding-bottom: calc(0.375em - 1px); - padding-left: calc(0.625em - 1px); - padding-right: calc(0.625em - 1px); - padding-top: calc(0.375em - 1px); - position: relative; - vertical-align: top; } - .button:focus, .input:focus, - .textarea:focus, .select select:focus, .file-cta:focus, - .file-name:focus, .pagination-previous:focus, - .pagination-next:focus, - .pagination-link:focus, - .pagination-ellipsis:focus, .is-focused.button, .is-focused.input, - .is-focused.textarea, .select select.is-focused, .is-focused.file-cta, - .is-focused.file-name, .is-focused.pagination-previous, - .is-focused.pagination-next, - .is-focused.pagination-link, - .is-focused.pagination-ellipsis, .button:active, .input:active, - .textarea:active, .select select:active, .file-cta:active, - .file-name:active, .pagination-previous:active, - .pagination-next:active, - .pagination-link:active, - .pagination-ellipsis:active, .is-active.button, .is-active.input, - .is-active.textarea, .select select.is-active, .is-active.file-cta, - .is-active.file-name, .is-active.pagination-previous, - .is-active.pagination-next, - .is-active.pagination-link, - .is-active.pagination-ellipsis { - outline: none; } - .button[disabled], .input[disabled], - .textarea[disabled], .select select[disabled], .file-cta[disabled], - .file-name[disabled], .pagination-previous[disabled], - .pagination-next[disabled], - .pagination-link[disabled], - .pagination-ellipsis[disabled] { - cursor: not-allowed; } - -/*! minireset.css v0.0.3 | MIT License | github.com/jgthms/minireset.css */ -html, -body, -p, -ol, -ul, -li, -dl, -dt, -dd, -blockquote, -figure, -fieldset, -legend, -textarea, -pre, -iframe, -hr, -h1, -h2, -h3, -h4, -h5, -h6 { - margin: 0; - padding: 0; } - -h1, -h2, -h3, -h4, -h5, -h6 { - font-size: 100%; - font-weight: normal; } - -ul { - list-style: none; } - -button, -input, -select, -textarea { - margin: 0; } - -html { - box-sizing: border-box; } - -*, *::before, *::after { - box-sizing: inherit; } - -img, -audio, -video { - height: auto; - max-width: 100%; } - -iframe { - border: 0; } - -table { - border-collapse: collapse; - border-spacing: 0; } - -td, -th { - padding: 0; - text-align: left; } - -html { - background-color: white; - font-size: 16px; - -moz-osx-font-smoothing: grayscale; - -webkit-font-smoothing: antialiased; - min-width: 300px; - overflow-x: hidden; - overflow-y: scroll; - text-rendering: optimizeLegibility; - text-size-adjust: 100%; } - -article, -aside, -figure, -footer, -header, -hgroup, -section { - display: block; } - -body, -button, -input, -select, -textarea { - font-family: BlinkMacSystemFont, -apple-system, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", "Helvetica", "Arial", sans-serif; } - -code, -pre { - -moz-osx-font-smoothing: auto; - -webkit-font-smoothing: auto; - font-family: monospace; } - -body { - color: #4a4a4a; - font-size: 1rem; - font-weight: 400; - line-height: 1.5; } - -a { - color: #33B2E8; - cursor: pointer; - text-decoration: none; } - a strong { - color: currentColor; } - a:hover { - color: #363636; } - -code { - background-color: whitesmoke; - color: #ff3860; - font-size: 0.875em; - font-weight: normal; - padding: 0.25em 0.5em 0.25em; } - -hr { - background-color: whitesmoke; - border: none; - display: block; - height: 2px; - margin: 1.5rem 0; } - -img { - height: auto; - max-width: 100%; } - -input[type="checkbox"], -input[type="radio"] { - vertical-align: baseline; } - -small { - font-size: 0.875em; } - -span { - font-style: inherit; - font-weight: inherit; } - -strong { - color: #363636; - font-weight: 700; } - -pre { - -webkit-overflow-scrolling: touch; - background-color: whitesmoke; - color: #4a4a4a; - font-size: 0.875em; - overflow-x: auto; - padding: 1.25rem 1.5rem; - white-space: pre; - word-wrap: normal; } - pre code { - background-color: transparent; - color: currentColor; - font-size: 1em; - padding: 0; } - -table td, -table th { - text-align: left; - vertical-align: top; } - -table th { - color: #363636; } - -.is-clearfix::after { - clear: both; - content: " "; - display: table; } - -.is-pulled-left { - float: left !important; } - -.is-pulled-right { - float: right !important; } - -.is-clipped { - overflow: hidden !important; } - -.is-size-1 { - font-size: 3rem !important; } - -.is-size-2 { - font-size: 2.5rem !important; } - -.is-size-3 { - font-size: 2rem !important; } - -.is-size-4 { - font-size: 1.5rem !important; } - -.is-size-5 { - font-size: 1.25rem !important; } - -.is-size-6 { - font-size: 1rem !important; } - -.is-size-7 { - font-size: 0.75rem !important; } - -@media screen and (max-width: 768px) { - .is-size-1-mobile { - font-size: 3rem !important; } - .is-size-2-mobile { - font-size: 2.5rem !important; } - .is-size-3-mobile { - font-size: 2rem !important; } - .is-size-4-mobile { - font-size: 1.5rem !important; } - .is-size-5-mobile { - font-size: 1.25rem !important; } - .is-size-6-mobile { - font-size: 1rem !important; } - .is-size-7-mobile { - font-size: 0.75rem !important; } } - -@media screen and (min-width: 769px), print { - .is-size-1-tablet { - font-size: 3rem !important; } - .is-size-2-tablet { - font-size: 2.5rem !important; } - .is-size-3-tablet { - font-size: 2rem !important; } - .is-size-4-tablet { - font-size: 1.5rem !important; } - .is-size-5-tablet { - font-size: 1.25rem !important; } - .is-size-6-tablet { - font-size: 1rem !important; } - .is-size-7-tablet { - font-size: 0.75rem !important; } } - -@media screen and (max-width: 1087px) { - .is-size-1-touch { - font-size: 3rem !important; } - .is-size-2-touch { - font-size: 2.5rem !important; } - .is-size-3-touch { - font-size: 2rem !important; } - .is-size-4-touch { - font-size: 1.5rem !important; } - .is-size-5-touch { - font-size: 1.25rem !important; } - .is-size-6-touch { - font-size: 1rem !important; } - .is-size-7-touch { - font-size: 0.75rem !important; } } - -@media screen and (min-width: 1088px) { - .is-size-1-desktop { - font-size: 3rem !important; } - .is-size-2-desktop { - font-size: 2.5rem !important; } - .is-size-3-desktop { - font-size: 2rem !important; } - .is-size-4-desktop { - font-size: 1.5rem !important; } - .is-size-5-desktop { - font-size: 1.25rem !important; } - .is-size-6-desktop { - font-size: 1rem !important; } - .is-size-7-desktop { - font-size: 0.75rem !important; } } - -@media screen and (min-width: 1280px) { - .is-size-1-widescreen { - font-size: 3rem !important; } - .is-size-2-widescreen { - font-size: 2.5rem !important; } - .is-size-3-widescreen { - font-size: 2rem !important; } - .is-size-4-widescreen { - font-size: 1.5rem !important; } - .is-size-5-widescreen { - font-size: 1.25rem !important; } - .is-size-6-widescreen { - font-size: 1rem !important; } - .is-size-7-widescreen { - font-size: 0.75rem !important; } } - -@media screen and (min-width: 1472px) { - .is-size-1-fullhd { - font-size: 3rem !important; } - .is-size-2-fullhd { - font-size: 2.5rem !important; } - .is-size-3-fullhd { - font-size: 2rem !important; } - .is-size-4-fullhd { - font-size: 1.5rem !important; } - .is-size-5-fullhd { - font-size: 1.25rem !important; } - .is-size-6-fullhd { - font-size: 1rem !important; } - .is-size-7-fullhd { - font-size: 0.75rem !important; } } - -.has-text-centered { - text-align: center !important; } - -.has-text-justified { - text-align: justify !important; } - -.has-text-left { - text-align: left !important; } - -.has-text-right { - text-align: right !important; } - -@media screen and (max-width: 768px) { - .has-text-centered-mobile { - text-align: center !important; } } - -@media screen and (min-width: 769px), print { - .has-text-centered-tablet { - text-align: center !important; } } - -@media screen and (min-width: 769px) and (max-width: 1087px) { - .has-text-centered-tablet-only { - text-align: center !important; } } - -@media screen and (max-width: 1087px) { - .has-text-centered-touch { - text-align: center !important; } } - -@media screen and (min-width: 1088px) { - .has-text-centered-desktop { - text-align: center !important; } } - -@media screen and (min-width: 1088px) and (max-width: 1279px) { - .has-text-centered-desktop-only { - text-align: center !important; } } - -@media screen and (min-width: 1280px) { - .has-text-centered-widescreen { - text-align: center !important; } } - -@media screen and (min-width: 1280px) and (max-width: 1471px) { - .has-text-centered-widescreen-only { - text-align: center !important; } } - -@media screen and (min-width: 1472px) { - .has-text-centered-fullhd { - text-align: center !important; } } - -@media screen and (max-width: 768px) { - .has-text-justified-mobile { - text-align: justify !important; } } - -@media screen and (min-width: 769px), print { - .has-text-justified-tablet { - text-align: justify !important; } } - -@media screen and (min-width: 769px) and (max-width: 1087px) { - .has-text-justified-tablet-only { - text-align: justify !important; } } - -@media screen and (max-width: 1087px) { - .has-text-justified-touch { - text-align: justify !important; } } - -@media screen and (min-width: 1088px) { - .has-text-justified-desktop { - text-align: justify !important; } } - -@media screen and (min-width: 1088px) and (max-width: 1279px) { - .has-text-justified-desktop-only { - text-align: justify !important; } } - -@media screen and (min-width: 1280px) { - .has-text-justified-widescreen { - text-align: justify !important; } } - -@media screen and (min-width: 1280px) and (max-width: 1471px) { - .has-text-justified-widescreen-only { - text-align: justify !important; } } - -@media screen and (min-width: 1472px) { - .has-text-justified-fullhd { - text-align: justify !important; } } - -@media screen and (max-width: 768px) { - .has-text-left-mobile { - text-align: left !important; } } - -@media screen and (min-width: 769px), print { - .has-text-left-tablet { - text-align: left !important; } } - -@media screen and (min-width: 769px) and (max-width: 1087px) { - .has-text-left-tablet-only { - text-align: left !important; } } - -@media screen and (max-width: 1087px) { - .has-text-left-touch { - text-align: left !important; } } - -@media screen and (min-width: 1088px) { - .has-text-left-desktop { - text-align: left !important; } } - -@media screen and (min-width: 1088px) and (max-width: 1279px) { - .has-text-left-desktop-only { - text-align: left !important; } } - -@media screen and (min-width: 1280px) { - .has-text-left-widescreen { - text-align: left !important; } } - -@media screen and (min-width: 1280px) and (max-width: 1471px) { - .has-text-left-widescreen-only { - text-align: left !important; } } - -@media screen and (min-width: 1472px) { - .has-text-left-fullhd { - text-align: left !important; } } - -@media screen and (max-width: 768px) { - .has-text-right-mobile { - text-align: right !important; } } - -@media screen and (min-width: 769px), print { - .has-text-right-tablet { - text-align: right !important; } } - -@media screen and (min-width: 769px) and (max-width: 1087px) { - .has-text-right-tablet-only { - text-align: right !important; } } - -@media screen and (max-width: 1087px) { - .has-text-right-touch { - text-align: right !important; } } - -@media screen and (min-width: 1088px) { - .has-text-right-desktop { - text-align: right !important; } } - -@media screen and (min-width: 1088px) and (max-width: 1279px) { - .has-text-right-desktop-only { - text-align: right !important; } } - -@media screen and (min-width: 1280px) { - .has-text-right-widescreen { - text-align: right !important; } } - -@media screen and (min-width: 1280px) and (max-width: 1471px) { - .has-text-right-widescreen-only { - text-align: right !important; } } - -@media screen and (min-width: 1472px) { - .has-text-right-fullhd { - text-align: right !important; } } - -.is-capitalized { - text-transform: capitalize !important; } - -.is-lowercase { - text-transform: lowercase !important; } - -.is-uppercase { - text-transform: uppercase !important; } - -.is-italic { - font-style: italic !important; } - -.has-text-white { - color: white !important; } - -a.has-text-white:hover, a.has-text-white:focus { - color: #e6e6e6 !important; } - -.has-background-white { - background-color: white !important; } - -.has-text-black { - color: #0a0a0a !important; } - -a.has-text-black:hover, a.has-text-black:focus { - color: black !important; } - -.has-background-black { - background-color: #0a0a0a !important; } - -.has-text-light { - color: whitesmoke !important; } - -a.has-text-light:hover, a.has-text-light:focus { - color: #dbdbdb !important; } - -.has-background-light { - background-color: whitesmoke !important; } - -.has-text-dark { - color: #363636 !important; } - -a.has-text-dark:hover, a.has-text-dark:focus { - color: #1c1c1c !important; } - -.has-background-dark { - background-color: #363636 !important; } - -.has-text-primary { - color: #00d1b2 !important; } - -a.has-text-primary:hover, a.has-text-primary:focus { - color: #009e86 !important; } - -.has-background-primary { - background-color: #00d1b2 !important; } - -.has-text-link { - color: #33B2E8 !important; } - -a.has-text-link:hover, a.has-text-link:focus { - color: #1899d0 !important; } - -.has-background-link { - background-color: #33B2E8 !important; } - -.has-text-info { - color: #209cee !important; } - -a.has-text-info:hover, a.has-text-info:focus { - color: #0f81cc !important; } - -.has-background-info { - background-color: #209cee !important; } - -.has-text-success { - color: #23d160 !important; } - -a.has-text-success:hover, a.has-text-success:focus { - color: #1ca64c !important; } - -.has-background-success { - background-color: #23d160 !important; } - -.has-text-warning { - color: #ffdd57 !important; } - -a.has-text-warning:hover, a.has-text-warning:focus { - color: #ffd324 !important; } - -.has-background-warning { - background-color: #ffdd57 !important; } - -.has-text-danger { - color: #ff3860 !important; } - -a.has-text-danger:hover, a.has-text-danger:focus { - color: #ff0537 !important; } - -.has-background-danger { - background-color: #ff3860 !important; } - -.has-text-black-bis { - color: #121212 !important; } - -.has-background-black-bis { - background-color: #121212 !important; } - -.has-text-black-ter { - color: #242424 !important; } - -.has-background-black-ter { - background-color: #242424 !important; } - -.has-text-grey-darker { - color: #363636 !important; } - -.has-background-grey-darker { - background-color: #363636 !important; } - -.has-text-grey-dark { - color: #4a4a4a !important; } - -.has-background-grey-dark { - background-color: #4a4a4a !important; } - -.has-text-grey { - color: #7a7a7a !important; } - -.has-background-grey { - background-color: #7a7a7a !important; } - -.has-text-grey-light { - color: #b5b5b5 !important; } - -.has-background-grey-light { - background-color: #b5b5b5 !important; } - -.has-text-grey-lighter { - color: #dbdbdb !important; } - -.has-background-grey-lighter { - background-color: #dbdbdb !important; } - -.has-text-white-ter { - color: whitesmoke !important; } - -.has-background-white-ter { - background-color: whitesmoke !important; } - -.has-text-white-bis { - color: #fafafa !important; } - -.has-background-white-bis { - background-color: #fafafa !important; } - -.has-text-weight-light { - font-weight: 300 !important; } - -.has-text-weight-normal { - font-weight: 400 !important; } - -.has-text-weight-semibold { - font-weight: 600 !important; } - -.has-text-weight-bold { - font-weight: 700 !important; } - -.is-block { - display: block !important; } - -@media screen and (max-width: 768px) { - .is-block-mobile { - display: block !important; } } - -@media screen and (min-width: 769px), print { - .is-block-tablet { - display: block !important; } } - -@media screen and (min-width: 769px) and (max-width: 1087px) { - .is-block-tablet-only { - display: block !important; } } - -@media screen and (max-width: 1087px) { - .is-block-touch { - display: block !important; } } - -@media screen and (min-width: 1088px) { - .is-block-desktop { - display: block !important; } } - -@media screen and (min-width: 1088px) and (max-width: 1279px) { - .is-block-desktop-only { - display: block !important; } } - -@media screen and (min-width: 1280px) { - .is-block-widescreen { - display: block !important; } } - -@media screen and (min-width: 1280px) and (max-width: 1471px) { - .is-block-widescreen-only { - display: block !important; } } - -@media screen and (min-width: 1472px) { - .is-block-fullhd { - display: block !important; } } - -.is-flex { - display: flex !important; } - -@media screen and (max-width: 768px) { - .is-flex-mobile { - display: flex !important; } } - -@media screen and (min-width: 769px), print { - .is-flex-tablet { - display: flex !important; } } - -@media screen and (min-width: 769px) and (max-width: 1087px) { - .is-flex-tablet-only { - display: flex !important; } } - -@media screen and (max-width: 1087px) { - .is-flex-touch { - display: flex !important; } } - -@media screen and (min-width: 1088px) { - .is-flex-desktop { - display: flex !important; } } - -@media screen and (min-width: 1088px) and (max-width: 1279px) { - .is-flex-desktop-only { - display: flex !important; } } - -@media screen and (min-width: 1280px) { - .is-flex-widescreen { - display: flex !important; } } - -@media screen and (min-width: 1280px) and (max-width: 1471px) { - .is-flex-widescreen-only { - display: flex !important; } } - -@media screen and (min-width: 1472px) { - .is-flex-fullhd { - display: flex !important; } } - -.is-inline { - display: inline !important; } - -@media screen and (max-width: 768px) { - .is-inline-mobile { - display: inline !important; } } - -@media screen and (min-width: 769px), print { - .is-inline-tablet { - display: inline !important; } } - -@media screen and (min-width: 769px) and (max-width: 1087px) { - .is-inline-tablet-only { - display: inline !important; } } - -@media screen and (max-width: 1087px) { - .is-inline-touch { - display: inline !important; } } - -@media screen and (min-width: 1088px) { - .is-inline-desktop { - display: inline !important; } } - -@media screen and (min-width: 1088px) and (max-width: 1279px) { - .is-inline-desktop-only { - display: inline !important; } } - -@media screen and (min-width: 1280px) { - .is-inline-widescreen { - display: inline !important; } } - -@media screen and (min-width: 1280px) and (max-width: 1471px) { - .is-inline-widescreen-only { - display: inline !important; } } - -@media screen and (min-width: 1472px) { - .is-inline-fullhd { - display: inline !important; } } - -.is-inline-block { - display: inline-block !important; } - -@media screen and (max-width: 768px) { - .is-inline-block-mobile { - display: inline-block !important; } } - -@media screen and (min-width: 769px), print { - .is-inline-block-tablet { - display: inline-block !important; } } - -@media screen and (min-width: 769px) and (max-width: 1087px) { - .is-inline-block-tablet-only { - display: inline-block !important; } } - -@media screen and (max-width: 1087px) { - .is-inline-block-touch { - display: inline-block !important; } } - -@media screen and (min-width: 1088px) { - .is-inline-block-desktop { - display: inline-block !important; } } - -@media screen and (min-width: 1088px) and (max-width: 1279px) { - .is-inline-block-desktop-only { - display: inline-block !important; } } - -@media screen and (min-width: 1280px) { - .is-inline-block-widescreen { - display: inline-block !important; } } - -@media screen and (min-width: 1280px) and (max-width: 1471px) { - .is-inline-block-widescreen-only { - display: inline-block !important; } } - -@media screen and (min-width: 1472px) { - .is-inline-block-fullhd { - display: inline-block !important; } } - -.is-inline-flex { - display: inline-flex !important; } - -@media screen and (max-width: 768px) { - .is-inline-flex-mobile { - display: inline-flex !important; } } - -@media screen and (min-width: 769px), print { - .is-inline-flex-tablet { - display: inline-flex !important; } } - -@media screen and (min-width: 769px) and (max-width: 1087px) { - .is-inline-flex-tablet-only { - display: inline-flex !important; } } - -@media screen and (max-width: 1087px) { - .is-inline-flex-touch { - display: inline-flex !important; } } - -@media screen and (min-width: 1088px) { - .is-inline-flex-desktop { - display: inline-flex !important; } } - -@media screen and (min-width: 1088px) and (max-width: 1279px) { - .is-inline-flex-desktop-only { - display: inline-flex !important; } } - -@media screen and (min-width: 1280px) { - .is-inline-flex-widescreen { - display: inline-flex !important; } } - -@media screen and (min-width: 1280px) and (max-width: 1471px) { - .is-inline-flex-widescreen-only { - display: inline-flex !important; } } - -@media screen and (min-width: 1472px) { - .is-inline-flex-fullhd { - display: inline-flex !important; } } - -.is-hidden { - display: none !important; } - -@media screen and (max-width: 768px) { - .is-hidden-mobile { - display: none !important; } } - -@media screen and (min-width: 769px), print { - .is-hidden-tablet { - display: none !important; } } - -@media screen and (min-width: 769px) and (max-width: 1087px) { - .is-hidden-tablet-only { - display: none !important; } } - -@media screen and (max-width: 1087px) { - .is-hidden-touch { - display: none !important; } } - -@media screen and (min-width: 1088px) { - .is-hidden-desktop { - display: none !important; } } - -@media screen and (min-width: 1088px) and (max-width: 1279px) { - .is-hidden-desktop-only { - display: none !important; } } - -@media screen and (min-width: 1280px) { - .is-hidden-widescreen { - display: none !important; } } - -@media screen and (min-width: 1280px) and (max-width: 1471px) { - .is-hidden-widescreen-only { - display: none !important; } } - -@media screen and (min-width: 1472px) { - .is-hidden-fullhd { - display: none !important; } } - -.is-invisible { - visibility: hidden !important; } - -@media screen and (max-width: 768px) { - .is-invisible-mobile { - visibility: hidden !important; } } - -@media screen and (min-width: 769px), print { - .is-invisible-tablet { - visibility: hidden !important; } } - -@media screen and (min-width: 769px) and (max-width: 1087px) { - .is-invisible-tablet-only { - visibility: hidden !important; } } - -@media screen and (max-width: 1087px) { - .is-invisible-touch { - visibility: hidden !important; } } - -@media screen and (min-width: 1088px) { - .is-invisible-desktop { - visibility: hidden !important; } } - -@media screen and (min-width: 1088px) and (max-width: 1279px) { - .is-invisible-desktop-only { - visibility: hidden !important; } } - -@media screen and (min-width: 1280px) { - .is-invisible-widescreen { - visibility: hidden !important; } } - -@media screen and (min-width: 1280px) and (max-width: 1471px) { - .is-invisible-widescreen-only { - visibility: hidden !important; } } - -@media screen and (min-width: 1472px) { - .is-invisible-fullhd { - visibility: hidden !important; } } - -.is-marginless { - margin: 0 !important; } - -.is-paddingless { - padding: 0 !important; } - -.is-radiusless { - border-radius: 0 !important; } - -.is-shadowless { - box-shadow: none !important; } - -.box { - background-color: white; - border-radius: 6px; - box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1), 0 0 0 1px rgba(10, 10, 10, 0.1); - color: #4a4a4a; - display: block; - padding: 1.25rem; } - -a.box:hover, a.box:focus { - box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1), 0 0 0 1px #33B2E8; } - -a.box:active { - box-shadow: inset 0 1px 2px rgba(10, 10, 10, 0.2), 0 0 0 1px #33B2E8; } - -.button { - background-color: white; - border-color: #dbdbdb; - border-width: 1px; - color: #363636; - cursor: pointer; - justify-content: center; - padding-bottom: calc(0.375em - 1px); - padding-left: 0.75em; - padding-right: 0.75em; - padding-top: calc(0.375em - 1px); - text-align: center; - white-space: nowrap; } - .button strong { - color: inherit; } - .button .icon, .button .icon.is-small, .button .icon.is-medium, .button .icon.is-large { - height: 1.5em; - width: 1.5em; } - .button .icon:first-child:not(:last-child) { - margin-left: calc(-0.375em - 1px); - margin-right: 0.1875em; } - .button .icon:last-child:not(:first-child) { - margin-left: 0.1875em; - margin-right: calc(-0.375em - 1px); } - .button .icon:first-child:last-child { - margin-left: calc(-0.375em - 1px); - margin-right: calc(-0.375em - 1px); } - .button:hover, .button.is-hovered { - border-color: #b5b5b5; - color: #363636; } - .button:focus, .button.is-focused { - border-color: #33B2E8; - color: #363636; } - .button:focus:not(:active), .button.is-focused:not(:active) { - box-shadow: 0 0 0 0.125em rgba(51, 178, 232, 0.25); } - .button:active, .button.is-active { - border-color: #4a4a4a; - color: #363636; } - .button.is-text { - background-color: transparent; - border-color: transparent; - color: #4a4a4a; - text-decoration: underline; } - .button.is-text:hover, .button.is-text.is-hovered, .button.is-text:focus, .button.is-text.is-focused { - background-color: whitesmoke; - color: #363636; } - .button.is-text:active, .button.is-text.is-active { - background-color: #e8e8e8; - color: #363636; } - .button.is-text[disabled] { - background-color: transparent; - border-color: transparent; - box-shadow: none; } - .button.is-white { - background-color: white; - border-color: transparent; - color: #0a0a0a; } - .button.is-white:hover, .button.is-white.is-hovered { - background-color: #f9f9f9; - border-color: transparent; - color: #0a0a0a; } - .button.is-white:focus, .button.is-white.is-focused { - border-color: transparent; - color: #0a0a0a; } - .button.is-white:focus:not(:active), .button.is-white.is-focused:not(:active) { - box-shadow: 0 0 0 0.125em rgba(255, 255, 255, 0.25); } - .button.is-white:active, .button.is-white.is-active { - background-color: #f2f2f2; - border-color: transparent; - color: #0a0a0a; } - .button.is-white[disabled] { - background-color: white; - border-color: transparent; - box-shadow: none; } - .button.is-white.is-inverted { - background-color: #0a0a0a; - color: white; } - .button.is-white.is-inverted:hover { - background-color: black; } - .button.is-white.is-inverted[disabled] { - background-color: #0a0a0a; - border-color: transparent; - box-shadow: none; - color: white; } - .button.is-white.is-loading::after { - border-color: transparent transparent #0a0a0a #0a0a0a !important; } - .button.is-white.is-outlined { - background-color: transparent; - border-color: white; - color: white; } - .button.is-white.is-outlined:hover, .button.is-white.is-outlined:focus { - background-color: white; - border-color: white; - color: #0a0a0a; } - .button.is-white.is-outlined.is-loading::after { - border-color: transparent transparent white white !important; } - .button.is-white.is-outlined[disabled] { - background-color: transparent; - border-color: white; - box-shadow: none; - color: white; } - .button.is-white.is-inverted.is-outlined { - background-color: transparent; - border-color: #0a0a0a; - color: #0a0a0a; } - .button.is-white.is-inverted.is-outlined:hover, .button.is-white.is-inverted.is-outlined:focus { - background-color: #0a0a0a; - color: white; } - .button.is-white.is-inverted.is-outlined[disabled] { - background-color: transparent; - border-color: #0a0a0a; - box-shadow: none; - color: #0a0a0a; } - .button.is-black { - background-color: #0a0a0a; - border-color: transparent; - color: white; } - .button.is-black:hover, .button.is-black.is-hovered { - background-color: #040404; - border-color: transparent; - color: white; } - .button.is-black:focus, .button.is-black.is-focused { - border-color: transparent; - color: white; } - .button.is-black:focus:not(:active), .button.is-black.is-focused:not(:active) { - box-shadow: 0 0 0 0.125em rgba(10, 10, 10, 0.25); } - .button.is-black:active, .button.is-black.is-active { - background-color: black; - border-color: transparent; - color: white; } - .button.is-black[disabled] { - background-color: #0a0a0a; - border-color: transparent; - box-shadow: none; } - .button.is-black.is-inverted { - background-color: white; - color: #0a0a0a; } - .button.is-black.is-inverted:hover { - background-color: #f2f2f2; } - .button.is-black.is-inverted[disabled] { - background-color: white; - border-color: transparent; - box-shadow: none; - color: #0a0a0a; } - .button.is-black.is-loading::after { - border-color: transparent transparent white white !important; } - .button.is-black.is-outlined { - background-color: transparent; - border-color: #0a0a0a; - color: #0a0a0a; } - .button.is-black.is-outlined:hover, .button.is-black.is-outlined:focus { - background-color: #0a0a0a; - border-color: #0a0a0a; - color: white; } - .button.is-black.is-outlined.is-loading::after { - border-color: transparent transparent #0a0a0a #0a0a0a !important; } - .button.is-black.is-outlined[disabled] { - background-color: transparent; - border-color: #0a0a0a; - box-shadow: none; - color: #0a0a0a; } - .button.is-black.is-inverted.is-outlined { - background-color: transparent; - border-color: white; - color: white; } - .button.is-black.is-inverted.is-outlined:hover, .button.is-black.is-inverted.is-outlined:focus { - background-color: white; - color: #0a0a0a; } - .button.is-black.is-inverted.is-outlined[disabled] { - background-color: transparent; - border-color: white; - box-shadow: none; - color: white; } - .button.is-light { - background-color: whitesmoke; - border-color: transparent; - color: #363636; } - .button.is-light:hover, .button.is-light.is-hovered { - background-color: #eeeeee; - border-color: transparent; - color: #363636; } - .button.is-light:focus, .button.is-light.is-focused { - border-color: transparent; - color: #363636; } - .button.is-light:focus:not(:active), .button.is-light.is-focused:not(:active) { - box-shadow: 0 0 0 0.125em rgba(245, 245, 245, 0.25); } - .button.is-light:active, .button.is-light.is-active { - background-color: #e8e8e8; - border-color: transparent; - color: #363636; } - .button.is-light[disabled] { - background-color: whitesmoke; - border-color: transparent; - box-shadow: none; } - .button.is-light.is-inverted { - background-color: #363636; - color: whitesmoke; } - .button.is-light.is-inverted:hover { - background-color: #292929; } - .button.is-light.is-inverted[disabled] { - background-color: #363636; - border-color: transparent; - box-shadow: none; - color: whitesmoke; } - .button.is-light.is-loading::after { - border-color: transparent transparent #363636 #363636 !important; } - .button.is-light.is-outlined { - background-color: transparent; - border-color: whitesmoke; - color: whitesmoke; } - .button.is-light.is-outlined:hover, .button.is-light.is-outlined:focus { - background-color: whitesmoke; - border-color: whitesmoke; - color: #363636; } - .button.is-light.is-outlined.is-loading::after { - border-color: transparent transparent whitesmoke whitesmoke !important; } - .button.is-light.is-outlined[disabled] { - background-color: transparent; - border-color: whitesmoke; - box-shadow: none; - color: whitesmoke; } - .button.is-light.is-inverted.is-outlined { - background-color: transparent; - border-color: #363636; - color: #363636; } - .button.is-light.is-inverted.is-outlined:hover, .button.is-light.is-inverted.is-outlined:focus { - background-color: #363636; - color: whitesmoke; } - .button.is-light.is-inverted.is-outlined[disabled] { - background-color: transparent; - border-color: #363636; - box-shadow: none; - color: #363636; } - .button.is-dark { - background-color: #363636; - border-color: transparent; - color: whitesmoke; } - .button.is-dark:hover, .button.is-dark.is-hovered { - background-color: #2f2f2f; - border-color: transparent; - color: whitesmoke; } - .button.is-dark:focus, .button.is-dark.is-focused { - border-color: transparent; - color: whitesmoke; } - .button.is-dark:focus:not(:active), .button.is-dark.is-focused:not(:active) { - box-shadow: 0 0 0 0.125em rgba(54, 54, 54, 0.25); } - .button.is-dark:active, .button.is-dark.is-active { - background-color: #292929; - border-color: transparent; - color: whitesmoke; } - .button.is-dark[disabled] { - background-color: #363636; - border-color: transparent; - box-shadow: none; } - .button.is-dark.is-inverted { - background-color: whitesmoke; - color: #363636; } - .button.is-dark.is-inverted:hover { - background-color: #e8e8e8; } - .button.is-dark.is-inverted[disabled] { - background-color: whitesmoke; - border-color: transparent; - box-shadow: none; - color: #363636; } - .button.is-dark.is-loading::after { - border-color: transparent transparent whitesmoke whitesmoke !important; } - .button.is-dark.is-outlined { - background-color: transparent; - border-color: #363636; - color: #363636; } - .button.is-dark.is-outlined:hover, .button.is-dark.is-outlined:focus { - background-color: #363636; - border-color: #363636; - color: whitesmoke; } - .button.is-dark.is-outlined.is-loading::after { - border-color: transparent transparent #363636 #363636 !important; } - .button.is-dark.is-outlined[disabled] { - background-color: transparent; - border-color: #363636; - box-shadow: none; - color: #363636; } - .button.is-dark.is-inverted.is-outlined { - background-color: transparent; - border-color: whitesmoke; - color: whitesmoke; } - .button.is-dark.is-inverted.is-outlined:hover, .button.is-dark.is-inverted.is-outlined:focus { - background-color: whitesmoke; - color: #363636; } - .button.is-dark.is-inverted.is-outlined[disabled] { - background-color: transparent; - border-color: whitesmoke; - box-shadow: none; - color: whitesmoke; } - .button.is-primary { - background-color: #00d1b2; - border-color: transparent; - color: #fff; } - .button.is-primary:hover, .button.is-primary.is-hovered { - background-color: #00c4a7; - border-color: transparent; - color: #fff; } - .button.is-primary:focus, .button.is-primary.is-focused { - border-color: transparent; - color: #fff; } - .button.is-primary:focus:not(:active), .button.is-primary.is-focused:not(:active) { - box-shadow: 0 0 0 0.125em rgba(0, 209, 178, 0.25); } - .button.is-primary:active, .button.is-primary.is-active { - background-color: #00b89c; - border-color: transparent; - color: #fff; } - .button.is-primary[disabled] { - background-color: #00d1b2; - border-color: transparent; - box-shadow: none; } - .button.is-primary.is-inverted { - background-color: #fff; - color: #00d1b2; } - .button.is-primary.is-inverted:hover { - background-color: #f2f2f2; } - .button.is-primary.is-inverted[disabled] { - background-color: #fff; - border-color: transparent; - box-shadow: none; - color: #00d1b2; } - .button.is-primary.is-loading::after { - border-color: transparent transparent #fff #fff !important; } - .button.is-primary.is-outlined { - background-color: transparent; - border-color: #00d1b2; - color: #00d1b2; } - .button.is-primary.is-outlined:hover, .button.is-primary.is-outlined:focus { - background-color: #00d1b2; - border-color: #00d1b2; - color: #fff; } - .button.is-primary.is-outlined.is-loading::after { - border-color: transparent transparent #00d1b2 #00d1b2 !important; } - .button.is-primary.is-outlined[disabled] { - background-color: transparent; - border-color: #00d1b2; - box-shadow: none; - color: #00d1b2; } - .button.is-primary.is-inverted.is-outlined { - background-color: transparent; - border-color: #fff; - color: #fff; } - .button.is-primary.is-inverted.is-outlined:hover, .button.is-primary.is-inverted.is-outlined:focus { - background-color: #fff; - color: #00d1b2; } - .button.is-primary.is-inverted.is-outlined[disabled] { - background-color: transparent; - border-color: #fff; - box-shadow: none; - color: #fff; } - .button.is-link { - background-color: #33B2E8; - border-color: transparent; - color: #fff; } - .button.is-link:hover, .button.is-link.is-hovered { - background-color: #28aee7; - border-color: transparent; - color: #fff; } - .button.is-link:focus, .button.is-link.is-focused { - border-color: transparent; - color: #fff; } - .button.is-link:focus:not(:active), .button.is-link.is-focused:not(:active) { - box-shadow: 0 0 0 0.125em rgba(51, 178, 232, 0.25); } - .button.is-link:active, .button.is-link.is-active { - background-color: #1ca9e5; - border-color: transparent; - color: #fff; } - .button.is-link[disabled] { - background-color: #33B2E8; - border-color: transparent; - box-shadow: none; } - .button.is-link.is-inverted { - background-color: #fff; - color: #33B2E8; } - .button.is-link.is-inverted:hover { - background-color: #f2f2f2; } - .button.is-link.is-inverted[disabled] { - background-color: #fff; - border-color: transparent; - box-shadow: none; - color: #33B2E8; } - .button.is-link.is-loading::after { - border-color: transparent transparent #fff #fff !important; } - .button.is-link.is-outlined { - background-color: transparent; - border-color: #33B2E8; - color: #33B2E8; } - .button.is-link.is-outlined:hover, .button.is-link.is-outlined:focus { - background-color: #33B2E8; - border-color: #33B2E8; - color: #fff; } - .button.is-link.is-outlined.is-loading::after { - border-color: transparent transparent #33B2E8 #33B2E8 !important; } - .button.is-link.is-outlined[disabled] { - background-color: transparent; - border-color: #33B2E8; - box-shadow: none; - color: #33B2E8; } - .button.is-link.is-inverted.is-outlined { - background-color: transparent; - border-color: #fff; - color: #fff; } - .button.is-link.is-inverted.is-outlined:hover, .button.is-link.is-inverted.is-outlined:focus { - background-color: #fff; - color: #33B2E8; } - .button.is-link.is-inverted.is-outlined[disabled] { - background-color: transparent; - border-color: #fff; - box-shadow: none; - color: #fff; } - .button.is-info { - background-color: #209cee; - border-color: transparent; - color: #fff; } - .button.is-info:hover, .button.is-info.is-hovered { - background-color: #1496ed; - border-color: transparent; - color: #fff; } - .button.is-info:focus, .button.is-info.is-focused { - border-color: transparent; - color: #fff; } - .button.is-info:focus:not(:active), .button.is-info.is-focused:not(:active) { - box-shadow: 0 0 0 0.125em rgba(32, 156, 238, 0.25); } - .button.is-info:active, .button.is-info.is-active { - background-color: #118fe4; - border-color: transparent; - color: #fff; } - .button.is-info[disabled] { - background-color: #209cee; - border-color: transparent; - box-shadow: none; } - .button.is-info.is-inverted { - background-color: #fff; - color: #209cee; } - .button.is-info.is-inverted:hover { - background-color: #f2f2f2; } - .button.is-info.is-inverted[disabled] { - background-color: #fff; - border-color: transparent; - box-shadow: none; - color: #209cee; } - .button.is-info.is-loading::after { - border-color: transparent transparent #fff #fff !important; } - .button.is-info.is-outlined { - background-color: transparent; - border-color: #209cee; - color: #209cee; } - .button.is-info.is-outlined:hover, .button.is-info.is-outlined:focus { - background-color: #209cee; - border-color: #209cee; - color: #fff; } - .button.is-info.is-outlined.is-loading::after { - border-color: transparent transparent #209cee #209cee !important; } - .button.is-info.is-outlined[disabled] { - background-color: transparent; - border-color: #209cee; - box-shadow: none; - color: #209cee; } - .button.is-info.is-inverted.is-outlined { - background-color: transparent; - border-color: #fff; - color: #fff; } - .button.is-info.is-inverted.is-outlined:hover, .button.is-info.is-inverted.is-outlined:focus { - background-color: #fff; - color: #209cee; } - .button.is-info.is-inverted.is-outlined[disabled] { - background-color: transparent; - border-color: #fff; - box-shadow: none; - color: #fff; } - .button.is-success { - background-color: #23d160; - border-color: transparent; - color: #fff; } - .button.is-success:hover, .button.is-success.is-hovered { - background-color: #22c65b; - border-color: transparent; - color: #fff; } - .button.is-success:focus, .button.is-success.is-focused { - border-color: transparent; - color: #fff; } - .button.is-success:focus:not(:active), .button.is-success.is-focused:not(:active) { - box-shadow: 0 0 0 0.125em rgba(35, 209, 96, 0.25); } - .button.is-success:active, .button.is-success.is-active { - background-color: #20bc56; - border-color: transparent; - color: #fff; } - .button.is-success[disabled] { - background-color: #23d160; - border-color: transparent; - box-shadow: none; } - .button.is-success.is-inverted { - background-color: #fff; - color: #23d160; } - .button.is-success.is-inverted:hover { - background-color: #f2f2f2; } - .button.is-success.is-inverted[disabled] { - background-color: #fff; - border-color: transparent; - box-shadow: none; - color: #23d160; } - .button.is-success.is-loading::after { - border-color: transparent transparent #fff #fff !important; } - .button.is-success.is-outlined { - background-color: transparent; - border-color: #23d160; - color: #23d160; } - .button.is-success.is-outlined:hover, .button.is-success.is-outlined:focus { - background-color: #23d160; - border-color: #23d160; - color: #fff; } - .button.is-success.is-outlined.is-loading::after { - border-color: transparent transparent #23d160 #23d160 !important; } - .button.is-success.is-outlined[disabled] { - background-color: transparent; - border-color: #23d160; - box-shadow: none; - color: #23d160; } - .button.is-success.is-inverted.is-outlined { - background-color: transparent; - border-color: #fff; - color: #fff; } - .button.is-success.is-inverted.is-outlined:hover, .button.is-success.is-inverted.is-outlined:focus { - background-color: #fff; - color: #23d160; } - .button.is-success.is-inverted.is-outlined[disabled] { - background-color: transparent; - border-color: #fff; - box-shadow: none; - color: #fff; } - .button.is-warning { - background-color: #ffdd57; - border-color: transparent; - color: rgba(0, 0, 0, 0.7); } - .button.is-warning:hover, .button.is-warning.is-hovered { - background-color: #ffdb4a; - border-color: transparent; - color: rgba(0, 0, 0, 0.7); } - .button.is-warning:focus, .button.is-warning.is-focused { - border-color: transparent; - color: rgba(0, 0, 0, 0.7); } - .button.is-warning:focus:not(:active), .button.is-warning.is-focused:not(:active) { - box-shadow: 0 0 0 0.125em rgba(255, 221, 87, 0.25); } - .button.is-warning:active, .button.is-warning.is-active { - background-color: #ffd83d; - border-color: transparent; - color: rgba(0, 0, 0, 0.7); } - .button.is-warning[disabled] { - background-color: #ffdd57; - border-color: transparent; - box-shadow: none; } - .button.is-warning.is-inverted { - background-color: rgba(0, 0, 0, 0.7); - color: #ffdd57; } - .button.is-warning.is-inverted:hover { - background-color: rgba(0, 0, 0, 0.7); } - .button.is-warning.is-inverted[disabled] { - background-color: rgba(0, 0, 0, 0.7); - border-color: transparent; - box-shadow: none; - color: #ffdd57; } - .button.is-warning.is-loading::after { - border-color: transparent transparent rgba(0, 0, 0, 0.7) rgba(0, 0, 0, 0.7) !important; } - .button.is-warning.is-outlined { - background-color: transparent; - border-color: #ffdd57; - color: #ffdd57; } - .button.is-warning.is-outlined:hover, .button.is-warning.is-outlined:focus { - background-color: #ffdd57; - border-color: #ffdd57; - color: rgba(0, 0, 0, 0.7); } - .button.is-warning.is-outlined.is-loading::after { - border-color: transparent transparent #ffdd57 #ffdd57 !important; } - .button.is-warning.is-outlined[disabled] { - background-color: transparent; - border-color: #ffdd57; - box-shadow: none; - color: #ffdd57; } - .button.is-warning.is-inverted.is-outlined { - background-color: transparent; - border-color: rgba(0, 0, 0, 0.7); - color: rgba(0, 0, 0, 0.7); } - .button.is-warning.is-inverted.is-outlined:hover, .button.is-warning.is-inverted.is-outlined:focus { - background-color: rgba(0, 0, 0, 0.7); - color: #ffdd57; } - .button.is-warning.is-inverted.is-outlined[disabled] { - background-color: transparent; - border-color: rgba(0, 0, 0, 0.7); - box-shadow: none; - color: rgba(0, 0, 0, 0.7); } - .button.is-danger { - background-color: #ff3860; - border-color: transparent; - color: #fff; } - .button.is-danger:hover, .button.is-danger.is-hovered { - background-color: #ff2b56; - border-color: transparent; - color: #fff; } - .button.is-danger:focus, .button.is-danger.is-focused { - border-color: transparent; - color: #fff; } - .button.is-danger:focus:not(:active), .button.is-danger.is-focused:not(:active) { - box-shadow: 0 0 0 0.125em rgba(255, 56, 96, 0.25); } - .button.is-danger:active, .button.is-danger.is-active { - background-color: #ff1f4b; - border-color: transparent; - color: #fff; } - .button.is-danger[disabled] { - background-color: #ff3860; - border-color: transparent; - box-shadow: none; } - .button.is-danger.is-inverted { - background-color: #fff; - color: #ff3860; } - .button.is-danger.is-inverted:hover { - background-color: #f2f2f2; } - .button.is-danger.is-inverted[disabled] { - background-color: #fff; - border-color: transparent; - box-shadow: none; - color: #ff3860; } - .button.is-danger.is-loading::after { - border-color: transparent transparent #fff #fff !important; } - .button.is-danger.is-outlined { - background-color: transparent; - border-color: #ff3860; - color: #ff3860; } - .button.is-danger.is-outlined:hover, .button.is-danger.is-outlined:focus { - background-color: #ff3860; - border-color: #ff3860; - color: #fff; } - .button.is-danger.is-outlined.is-loading::after { - border-color: transparent transparent #ff3860 #ff3860 !important; } - .button.is-danger.is-outlined[disabled] { - background-color: transparent; - border-color: #ff3860; - box-shadow: none; - color: #ff3860; } - .button.is-danger.is-inverted.is-outlined { - background-color: transparent; - border-color: #fff; - color: #fff; } - .button.is-danger.is-inverted.is-outlined:hover, .button.is-danger.is-inverted.is-outlined:focus { - background-color: #fff; - color: #ff3860; } - .button.is-danger.is-inverted.is-outlined[disabled] { - background-color: transparent; - border-color: #fff; - box-shadow: none; - color: #fff; } - .button.is-small { - border-radius: 2px; - font-size: 0.75rem; } - .button.is-medium { - font-size: 1.25rem; } - .button.is-large { - font-size: 1.5rem; } - .button[disabled] { - background-color: white; - border-color: #dbdbdb; - box-shadow: none; - opacity: 0.5; } - .button.is-fullwidth { - display: flex; - width: 100%; } - .button.is-loading { - color: transparent !important; - pointer-events: none; } - .button.is-loading::after { - position: absolute; - left: calc(50% - (1em / 2)); - top: calc(50% - (1em / 2)); - position: absolute !important; } - .button.is-static { - background-color: whitesmoke; - border-color: #dbdbdb; - color: #7a7a7a; - box-shadow: none; - pointer-events: none; } - .button.is-rounded { - border-radius: 290486px; - padding-left: 1em; - padding-right: 1em; } - -.buttons { - align-items: center; - display: flex; - flex-wrap: wrap; - justify-content: flex-start; } - .buttons .button { - margin-bottom: 0.5rem; } - .buttons .button:not(:last-child) { - margin-right: 0.5rem; } - .buttons:last-child { - margin-bottom: -0.5rem; } - .buttons:not(:last-child) { - margin-bottom: 1rem; } - .buttons.has-addons .button:not(:first-child) { - border-bottom-left-radius: 0; - border-top-left-radius: 0; } - .buttons.has-addons .button:not(:last-child) { - border-bottom-right-radius: 0; - border-top-right-radius: 0; - margin-right: -1px; } - .buttons.has-addons .button:last-child { - margin-right: 0; } - .buttons.has-addons .button:hover, .buttons.has-addons .button.is-hovered { - z-index: 2; } - .buttons.has-addons .button:focus, .buttons.has-addons .button.is-focused, .buttons.has-addons .button:active, .buttons.has-addons .button.is-active, .buttons.has-addons .button.is-selected { - z-index: 3; } - .buttons.has-addons .button:focus:hover, .buttons.has-addons .button.is-focused:hover, .buttons.has-addons .button:active:hover, .buttons.has-addons .button.is-active:hover, .buttons.has-addons .button.is-selected:hover { - z-index: 4; } - .buttons.has-addons .button.is-expanded { - flex-grow: 1; } - .buttons.is-centered { - justify-content: center; } - .buttons.is-right { - justify-content: flex-end; } - -.container { - margin: 0 auto; - position: relative; } - @media screen and (min-width: 1088px) { - .container { - max-width: 960px; - width: 960px; } - .container.is-fluid { - margin-left: 64px; - margin-right: 64px; - max-width: none; - width: auto; } } - @media screen and (max-width: 1279px) { - .container.is-widescreen { - max-width: 1152px; - width: auto; } } - @media screen and (max-width: 1471px) { - .container.is-fullhd { - max-width: 1344px; - width: auto; } } - @media screen and (min-width: 1280px) { - .container { - max-width: 1152px; - width: 1152px; } } - @media screen and (min-width: 1472px) { - .container { - max-width: 1344px; - width: 1344px; } } - -.content li + li { - margin-top: 0.25em; } - -.content p:not(:last-child), -.content dl:not(:last-child), -.content ol:not(:last-child), -.content ul:not(:last-child), -.content blockquote:not(:last-child), -.content pre:not(:last-child), -.content table:not(:last-child) { - margin-bottom: 1em; } - -.content h1, -.content h2, -.content h3, -.content h4, -.content h5, -.content h6 { - color: #363636; - font-weight: 600; - line-height: 1.125; } - -.content h1 { - font-size: 2em; - margin-bottom: 0.5em; } - .content h1:not(:first-child) { - margin-top: 1em; } - -.content h2 { - font-size: 1.75em; - margin-bottom: 0.5714em; } - .content h2:not(:first-child) { - margin-top: 1.1428em; } - -.content h3 { - font-size: 1.5em; - margin-bottom: 0.6666em; } - .content h3:not(:first-child) { - margin-top: 1.3333em; } - -.content h4 { - font-size: 1.25em; - margin-bottom: 0.8em; } - -.content h5 { - font-size: 1.125em; - margin-bottom: 0.8888em; } - -.content h6 { - font-size: 1em; - margin-bottom: 1em; } - -.content blockquote { - background-color: whitesmoke; - border-left: 5px solid #dbdbdb; - padding: 1.25em 1.5em; } - -.content ol { - list-style: decimal outside; - margin-left: 2em; - margin-top: 1em; } - -.content ul { - list-style: disc outside; - margin-left: 2em; - margin-top: 1em; } - .content ul ul { - list-style-type: circle; - margin-top: 0.5em; } - .content ul ul ul { - list-style-type: square; } - -.content dd { - margin-left: 2em; } - -.content figure { - margin-left: 2em; - margin-right: 2em; - text-align: center; } - .content figure:not(:first-child) { - margin-top: 2em; } - .content figure:not(:last-child) { - margin-bottom: 2em; } - .content figure img { - display: inline-block; } - .content figure figcaption { - font-style: italic; } - -.content pre { - -webkit-overflow-scrolling: touch; - overflow-x: auto; - padding: 1.25em 1.5em; - white-space: pre; - word-wrap: normal; } - -.content sup, -.content sub { - font-size: 75%; } - -.content table { - width: 100%; } - .content table td, - .content table th { - border: 1px solid #dbdbdb; - border-width: 0 0 1px; - padding: 0.5em 0.75em; - vertical-align: top; } - .content table th { - color: #363636; - text-align: left; } - .content table thead td, - .content table thead th { - border-width: 0 0 2px; - color: #363636; } - .content table tfoot td, - .content table tfoot th { - border-width: 2px 0 0; - color: #363636; } - .content table tbody tr:last-child td, - .content table tbody tr:last-child th { - border-bottom-width: 0; } - -.content.is-small { - font-size: 0.75rem; } - -.content.is-medium { - font-size: 1.25rem; } - -.content.is-large { - font-size: 1.5rem; } - -.input, -.textarea { - background-color: white; - border-color: #dbdbdb; - color: #363636; - box-shadow: inset 0 1px 2px rgba(10, 10, 10, 0.1); - max-width: 100%; - width: 100%; } - .input::-moz-placeholder, - .textarea::-moz-placeholder { - color: rgba(54, 54, 54, 0.3); } - .input::-webkit-input-placeholder, - .textarea::-webkit-input-placeholder { - color: rgba(54, 54, 54, 0.3); } - .input:-moz-placeholder, - .textarea:-moz-placeholder { - color: rgba(54, 54, 54, 0.3); } - .input:-ms-input-placeholder, - .textarea:-ms-input-placeholder { - color: rgba(54, 54, 54, 0.3); } - .input:hover, .input.is-hovered, - .textarea:hover, - .textarea.is-hovered { - border-color: #b5b5b5; } - .input:focus, .input.is-focused, .input:active, .input.is-active, - .textarea:focus, - .textarea.is-focused, - .textarea:active, - .textarea.is-active { - border-color: #33B2E8; - box-shadow: 0 0 0 0.125em rgba(51, 178, 232, 0.25); } - .input[disabled], - .textarea[disabled] { - background-color: whitesmoke; - border-color: whitesmoke; - box-shadow: none; - color: #7a7a7a; } - .input[disabled]::-moz-placeholder, - .textarea[disabled]::-moz-placeholder { - color: rgba(122, 122, 122, 0.3); } - .input[disabled]::-webkit-input-placeholder, - .textarea[disabled]::-webkit-input-placeholder { - color: rgba(122, 122, 122, 0.3); } - .input[disabled]:-moz-placeholder, - .textarea[disabled]:-moz-placeholder { - color: rgba(122, 122, 122, 0.3); } - .input[disabled]:-ms-input-placeholder, - .textarea[disabled]:-ms-input-placeholder { - color: rgba(122, 122, 122, 0.3); } - .input[readonly], - .textarea[readonly] { - box-shadow: none; } - .input.is-white, - .textarea.is-white { - border-color: white; } - .input.is-white:focus, .input.is-white.is-focused, .input.is-white:active, .input.is-white.is-active, - .textarea.is-white:focus, - .textarea.is-white.is-focused, - .textarea.is-white:active, - .textarea.is-white.is-active { - box-shadow: 0 0 0 0.125em rgba(255, 255, 255, 0.25); } - .input.is-black, - .textarea.is-black { - border-color: #0a0a0a; } - .input.is-black:focus, .input.is-black.is-focused, .input.is-black:active, .input.is-black.is-active, - .textarea.is-black:focus, - .textarea.is-black.is-focused, - .textarea.is-black:active, - .textarea.is-black.is-active { - box-shadow: 0 0 0 0.125em rgba(10, 10, 10, 0.25); } - .input.is-light, - .textarea.is-light { - border-color: whitesmoke; } - .input.is-light:focus, .input.is-light.is-focused, .input.is-light:active, .input.is-light.is-active, - .textarea.is-light:focus, - .textarea.is-light.is-focused, - .textarea.is-light:active, - .textarea.is-light.is-active { - box-shadow: 0 0 0 0.125em rgba(245, 245, 245, 0.25); } - .input.is-dark, - .textarea.is-dark { - border-color: #363636; } - .input.is-dark:focus, .input.is-dark.is-focused, .input.is-dark:active, .input.is-dark.is-active, - .textarea.is-dark:focus, - .textarea.is-dark.is-focused, - .textarea.is-dark:active, - .textarea.is-dark.is-active { - box-shadow: 0 0 0 0.125em rgba(54, 54, 54, 0.25); } - .input.is-primary, - .textarea.is-primary { - border-color: #00d1b2; } - .input.is-primary:focus, .input.is-primary.is-focused, .input.is-primary:active, .input.is-primary.is-active, - .textarea.is-primary:focus, - .textarea.is-primary.is-focused, - .textarea.is-primary:active, - .textarea.is-primary.is-active { - box-shadow: 0 0 0 0.125em rgba(0, 209, 178, 0.25); } - .input.is-link, - .textarea.is-link { - border-color: #33B2E8; } - .input.is-link:focus, .input.is-link.is-focused, .input.is-link:active, .input.is-link.is-active, - .textarea.is-link:focus, - .textarea.is-link.is-focused, - .textarea.is-link:active, - .textarea.is-link.is-active { - box-shadow: 0 0 0 0.125em rgba(51, 178, 232, 0.25); } - .input.is-info, - .textarea.is-info { - border-color: #209cee; } - .input.is-info:focus, .input.is-info.is-focused, .input.is-info:active, .input.is-info.is-active, - .textarea.is-info:focus, - .textarea.is-info.is-focused, - .textarea.is-info:active, - .textarea.is-info.is-active { - box-shadow: 0 0 0 0.125em rgba(32, 156, 238, 0.25); } - .input.is-success, - .textarea.is-success { - border-color: #23d160; } - .input.is-success:focus, .input.is-success.is-focused, .input.is-success:active, .input.is-success.is-active, - .textarea.is-success:focus, - .textarea.is-success.is-focused, - .textarea.is-success:active, - .textarea.is-success.is-active { - box-shadow: 0 0 0 0.125em rgba(35, 209, 96, 0.25); } - .input.is-warning, - .textarea.is-warning { - border-color: #ffdd57; } - .input.is-warning:focus, .input.is-warning.is-focused, .input.is-warning:active, .input.is-warning.is-active, - .textarea.is-warning:focus, - .textarea.is-warning.is-focused, - .textarea.is-warning:active, - .textarea.is-warning.is-active { - box-shadow: 0 0 0 0.125em rgba(255, 221, 87, 0.25); } - .input.is-danger, - .textarea.is-danger { - border-color: #ff3860; } - .input.is-danger:focus, .input.is-danger.is-focused, .input.is-danger:active, .input.is-danger.is-active, - .textarea.is-danger:focus, - .textarea.is-danger.is-focused, - .textarea.is-danger:active, - .textarea.is-danger.is-active { - box-shadow: 0 0 0 0.125em rgba(255, 56, 96, 0.25); } - .input.is-small, - .textarea.is-small { - border-radius: 2px; - font-size: 0.75rem; } - .input.is-medium, - .textarea.is-medium { - font-size: 1.25rem; } - .input.is-large, - .textarea.is-large { - font-size: 1.5rem; } - .input.is-fullwidth, - .textarea.is-fullwidth { - display: block; - width: 100%; } - .input.is-inline, - .textarea.is-inline { - display: inline; - width: auto; } - -.input.is-rounded { - border-radius: 290486px; - padding-left: 1em; - padding-right: 1em; } - -.input.is-static { - background-color: transparent; - border-color: transparent; - box-shadow: none; - padding-left: 0; - padding-right: 0; } - -.textarea { - display: block; - max-width: 100%; - min-width: 100%; - padding: 0.625em; - resize: vertical; } - .textarea:not([rows]) { - max-height: 600px; - min-height: 120px; } - .textarea[rows] { - height: initial; } - .textarea.has-fixed-size { - resize: none; } - -.checkbox, -.radio { - cursor: pointer; - display: inline-block; - line-height: 1.25; - position: relative; } - .checkbox input, - .radio input { - cursor: pointer; } - .checkbox:hover, - .radio:hover { - color: #363636; } - .checkbox[disabled], - .radio[disabled] { - color: #7a7a7a; - cursor: not-allowed; } - -.radio + .radio { - margin-left: 0.5em; } - -.select { - display: inline-block; - max-width: 100%; - position: relative; - vertical-align: top; } - .select:not(.is-multiple) { - height: 2.25em; } - .select:not(.is-multiple):not(.is-loading)::after { - border-color: #33B2E8; - right: 1.125em; - z-index: 4; } - .select.is-rounded select { - border-radius: 290486px; - padding-left: 1em; } - .select select { - background-color: white; - border-color: #dbdbdb; - color: #363636; - cursor: pointer; - display: block; - font-size: 1em; - max-width: 100%; - outline: none; } - .select select::-moz-placeholder { - color: rgba(54, 54, 54, 0.3); } - .select select::-webkit-input-placeholder { - color: rgba(54, 54, 54, 0.3); } - .select select:-moz-placeholder { - color: rgba(54, 54, 54, 0.3); } - .select select:-ms-input-placeholder { - color: rgba(54, 54, 54, 0.3); } - .select select:hover, .select select.is-hovered { - border-color: #b5b5b5; } - .select select:focus, .select select.is-focused, .select select:active, .select select.is-active { - border-color: #33B2E8; - box-shadow: 0 0 0 0.125em rgba(51, 178, 232, 0.25); } - .select select[disabled] { - background-color: whitesmoke; - border-color: whitesmoke; - box-shadow: none; - color: #7a7a7a; } - .select select[disabled]::-moz-placeholder { - color: rgba(122, 122, 122, 0.3); } - .select select[disabled]::-webkit-input-placeholder { - color: rgba(122, 122, 122, 0.3); } - .select select[disabled]:-moz-placeholder { - color: rgba(122, 122, 122, 0.3); } - .select select[disabled]:-ms-input-placeholder { - color: rgba(122, 122, 122, 0.3); } - .select select::-ms-expand { - display: none; } - .select select[disabled]:hover { - border-color: whitesmoke; } - .select select:not([multiple]) { - padding-right: 2.5em; } - .select select[multiple] { - height: initial; - padding: 0; } - .select select[multiple] option { - padding: 0.5em 1em; } - .select:not(.is-multiple):not(.is-loading):hover::after { - border-color: #363636; } - .select.is-white:not(:hover)::after { - border-color: white; } - .select.is-white select { - border-color: white; } - .select.is-white select:hover, .select.is-white select.is-hovered { - border-color: #f2f2f2; } - .select.is-white select:focus, .select.is-white select.is-focused, .select.is-white select:active, .select.is-white select.is-active { - box-shadow: 0 0 0 0.125em rgba(255, 255, 255, 0.25); } - .select.is-black:not(:hover)::after { - border-color: #0a0a0a; } - .select.is-black select { - border-color: #0a0a0a; } - .select.is-black select:hover, .select.is-black select.is-hovered { - border-color: black; } - .select.is-black select:focus, .select.is-black select.is-focused, .select.is-black select:active, .select.is-black select.is-active { - box-shadow: 0 0 0 0.125em rgba(10, 10, 10, 0.25); } - .select.is-light:not(:hover)::after { - border-color: whitesmoke; } - .select.is-light select { - border-color: whitesmoke; } - .select.is-light select:hover, .select.is-light select.is-hovered { - border-color: #e8e8e8; } - .select.is-light select:focus, .select.is-light select.is-focused, .select.is-light select:active, .select.is-light select.is-active { - box-shadow: 0 0 0 0.125em rgba(245, 245, 245, 0.25); } - .select.is-dark:not(:hover)::after { - border-color: #363636; } - .select.is-dark select { - border-color: #363636; } - .select.is-dark select:hover, .select.is-dark select.is-hovered { - border-color: #292929; } - .select.is-dark select:focus, .select.is-dark select.is-focused, .select.is-dark select:active, .select.is-dark select.is-active { - box-shadow: 0 0 0 0.125em rgba(54, 54, 54, 0.25); } - .select.is-primary:not(:hover)::after { - border-color: #00d1b2; } - .select.is-primary select { - border-color: #00d1b2; } - .select.is-primary select:hover, .select.is-primary select.is-hovered { - border-color: #00b89c; } - .select.is-primary select:focus, .select.is-primary select.is-focused, .select.is-primary select:active, .select.is-primary select.is-active { - box-shadow: 0 0 0 0.125em rgba(0, 209, 178, 0.25); } - .select.is-link:not(:hover)::after { - border-color: #33B2E8; } - .select.is-link select { - border-color: #33B2E8; } - .select.is-link select:hover, .select.is-link select.is-hovered { - border-color: #1ca9e5; } - .select.is-link select:focus, .select.is-link select.is-focused, .select.is-link select:active, .select.is-link select.is-active { - box-shadow: 0 0 0 0.125em rgba(51, 178, 232, 0.25); } - .select.is-info:not(:hover)::after { - border-color: #209cee; } - .select.is-info select { - border-color: #209cee; } - .select.is-info select:hover, .select.is-info select.is-hovered { - border-color: #118fe4; } - .select.is-info select:focus, .select.is-info select.is-focused, .select.is-info select:active, .select.is-info select.is-active { - box-shadow: 0 0 0 0.125em rgba(32, 156, 238, 0.25); } - .select.is-success:not(:hover)::after { - border-color: #23d160; } - .select.is-success select { - border-color: #23d160; } - .select.is-success select:hover, .select.is-success select.is-hovered { - border-color: #20bc56; } - .select.is-success select:focus, .select.is-success select.is-focused, .select.is-success select:active, .select.is-success select.is-active { - box-shadow: 0 0 0 0.125em rgba(35, 209, 96, 0.25); } - .select.is-warning:not(:hover)::after { - border-color: #ffdd57; } - .select.is-warning select { - border-color: #ffdd57; } - .select.is-warning select:hover, .select.is-warning select.is-hovered { - border-color: #ffd83d; } - .select.is-warning select:focus, .select.is-warning select.is-focused, .select.is-warning select:active, .select.is-warning select.is-active { - box-shadow: 0 0 0 0.125em rgba(255, 221, 87, 0.25); } - .select.is-danger:not(:hover)::after { - border-color: #ff3860; } - .select.is-danger select { - border-color: #ff3860; } - .select.is-danger select:hover, .select.is-danger select.is-hovered { - border-color: #ff1f4b; } - .select.is-danger select:focus, .select.is-danger select.is-focused, .select.is-danger select:active, .select.is-danger select.is-active { - box-shadow: 0 0 0 0.125em rgba(255, 56, 96, 0.25); } - .select.is-small { - border-radius: 2px; - font-size: 0.75rem; } - .select.is-medium { - font-size: 1.25rem; } - .select.is-large { - font-size: 1.5rem; } - .select.is-disabled::after { - border-color: #7a7a7a; } - .select.is-fullwidth { - width: 100%; } - .select.is-fullwidth select { - width: 100%; } - .select.is-loading::after { - margin-top: 0; - position: absolute; - right: 0.625em; - top: 0.625em; - transform: none; } - .select.is-loading.is-small:after { - font-size: 0.75rem; } - .select.is-loading.is-medium:after { - font-size: 1.25rem; } - .select.is-loading.is-large:after { - font-size: 1.5rem; } - -.file { - align-items: stretch; - display: flex; - justify-content: flex-start; - position: relative; } - .file.is-white .file-cta { - background-color: white; - border-color: transparent; - color: #0a0a0a; } - .file.is-white:hover .file-cta, .file.is-white.is-hovered .file-cta { - background-color: #f9f9f9; - border-color: transparent; - color: #0a0a0a; } - .file.is-white:focus .file-cta, .file.is-white.is-focused .file-cta { - border-color: transparent; - box-shadow: 0 0 0.5em rgba(255, 255, 255, 0.25); - color: #0a0a0a; } - .file.is-white:active .file-cta, .file.is-white.is-active .file-cta { - background-color: #f2f2f2; - border-color: transparent; - color: #0a0a0a; } - .file.is-black .file-cta { - background-color: #0a0a0a; - border-color: transparent; - color: white; } - .file.is-black:hover .file-cta, .file.is-black.is-hovered .file-cta { - background-color: #040404; - border-color: transparent; - color: white; } - .file.is-black:focus .file-cta, .file.is-black.is-focused .file-cta { - border-color: transparent; - box-shadow: 0 0 0.5em rgba(10, 10, 10, 0.25); - color: white; } - .file.is-black:active .file-cta, .file.is-black.is-active .file-cta { - background-color: black; - border-color: transparent; - color: white; } - .file.is-light .file-cta { - background-color: whitesmoke; - border-color: transparent; - color: #363636; } - .file.is-light:hover .file-cta, .file.is-light.is-hovered .file-cta { - background-color: #eeeeee; - border-color: transparent; - color: #363636; } - .file.is-light:focus .file-cta, .file.is-light.is-focused .file-cta { - border-color: transparent; - box-shadow: 0 0 0.5em rgba(245, 245, 245, 0.25); - color: #363636; } - .file.is-light:active .file-cta, .file.is-light.is-active .file-cta { - background-color: #e8e8e8; - border-color: transparent; - color: #363636; } - .file.is-dark .file-cta { - background-color: #363636; - border-color: transparent; - color: whitesmoke; } - .file.is-dark:hover .file-cta, .file.is-dark.is-hovered .file-cta { - background-color: #2f2f2f; - border-color: transparent; - color: whitesmoke; } - .file.is-dark:focus .file-cta, .file.is-dark.is-focused .file-cta { - border-color: transparent; - box-shadow: 0 0 0.5em rgba(54, 54, 54, 0.25); - color: whitesmoke; } - .file.is-dark:active .file-cta, .file.is-dark.is-active .file-cta { - background-color: #292929; - border-color: transparent; - color: whitesmoke; } - .file.is-primary .file-cta { - background-color: #00d1b2; - border-color: transparent; - color: #fff; } - .file.is-primary:hover .file-cta, .file.is-primary.is-hovered .file-cta { - background-color: #00c4a7; - border-color: transparent; - color: #fff; } - .file.is-primary:focus .file-cta, .file.is-primary.is-focused .file-cta { - border-color: transparent; - box-shadow: 0 0 0.5em rgba(0, 209, 178, 0.25); - color: #fff; } - .file.is-primary:active .file-cta, .file.is-primary.is-active .file-cta { - background-color: #00b89c; - border-color: transparent; - color: #fff; } - .file.is-link .file-cta { - background-color: #33B2E8; - border-color: transparent; - color: #fff; } - .file.is-link:hover .file-cta, .file.is-link.is-hovered .file-cta { - background-color: #28aee7; - border-color: transparent; - color: #fff; } - .file.is-link:focus .file-cta, .file.is-link.is-focused .file-cta { - border-color: transparent; - box-shadow: 0 0 0.5em rgba(51, 178, 232, 0.25); - color: #fff; } - .file.is-link:active .file-cta, .file.is-link.is-active .file-cta { - background-color: #1ca9e5; - border-color: transparent; - color: #fff; } - .file.is-info .file-cta { - background-color: #209cee; - border-color: transparent; - color: #fff; } - .file.is-info:hover .file-cta, .file.is-info.is-hovered .file-cta { - background-color: #1496ed; - border-color: transparent; - color: #fff; } - .file.is-info:focus .file-cta, .file.is-info.is-focused .file-cta { - border-color: transparent; - box-shadow: 0 0 0.5em rgba(32, 156, 238, 0.25); - color: #fff; } - .file.is-info:active .file-cta, .file.is-info.is-active .file-cta { - background-color: #118fe4; - border-color: transparent; - color: #fff; } - .file.is-success .file-cta { - background-color: #23d160; - border-color: transparent; - color: #fff; } - .file.is-success:hover .file-cta, .file.is-success.is-hovered .file-cta { - background-color: #22c65b; - border-color: transparent; - color: #fff; } - .file.is-success:focus .file-cta, .file.is-success.is-focused .file-cta { - border-color: transparent; - box-shadow: 0 0 0.5em rgba(35, 209, 96, 0.25); - color: #fff; } - .file.is-success:active .file-cta, .file.is-success.is-active .file-cta { - background-color: #20bc56; - border-color: transparent; - color: #fff; } - .file.is-warning .file-cta { - background-color: #ffdd57; - border-color: transparent; - color: rgba(0, 0, 0, 0.7); } - .file.is-warning:hover .file-cta, .file.is-warning.is-hovered .file-cta { - background-color: #ffdb4a; - border-color: transparent; - color: rgba(0, 0, 0, 0.7); } - .file.is-warning:focus .file-cta, .file.is-warning.is-focused .file-cta { - border-color: transparent; - box-shadow: 0 0 0.5em rgba(255, 221, 87, 0.25); - color: rgba(0, 0, 0, 0.7); } - .file.is-warning:active .file-cta, .file.is-warning.is-active .file-cta { - background-color: #ffd83d; - border-color: transparent; - color: rgba(0, 0, 0, 0.7); } - .file.is-danger .file-cta { - background-color: #ff3860; - border-color: transparent; - color: #fff; } - .file.is-danger:hover .file-cta, .file.is-danger.is-hovered .file-cta { - background-color: #ff2b56; - border-color: transparent; - color: #fff; } - .file.is-danger:focus .file-cta, .file.is-danger.is-focused .file-cta { - border-color: transparent; - box-shadow: 0 0 0.5em rgba(255, 56, 96, 0.25); - color: #fff; } - .file.is-danger:active .file-cta, .file.is-danger.is-active .file-cta { - background-color: #ff1f4b; - border-color: transparent; - color: #fff; } - .file.is-small { - font-size: 0.75rem; } - .file.is-medium { - font-size: 1.25rem; } - .file.is-medium .file-icon .fa { - font-size: 21px; } - .file.is-large { - font-size: 1.5rem; } - .file.is-large .file-icon .fa { - font-size: 28px; } - .file.has-name .file-cta { - border-bottom-right-radius: 0; - border-top-right-radius: 0; } - .file.has-name .file-name { - border-bottom-left-radius: 0; - border-top-left-radius: 0; } - .file.has-name.is-empty .file-cta { - border-radius: 4px; } - .file.has-name.is-empty .file-name { - display: none; } - .file.is-boxed .file-label { - flex-direction: column; } - .file.is-boxed .file-cta { - flex-direction: column; - height: auto; - padding: 1em 3em; } - .file.is-boxed .file-name { - border-width: 0 1px 1px; } - .file.is-boxed .file-icon { - height: 1.5em; - width: 1.5em; } - .file.is-boxed .file-icon .fa { - font-size: 21px; } - .file.is-boxed.is-small .file-icon .fa { - font-size: 14px; } - .file.is-boxed.is-medium .file-icon .fa { - font-size: 28px; } - .file.is-boxed.is-large .file-icon .fa { - font-size: 35px; } - .file.is-boxed.has-name .file-cta { - border-radius: 4px 4px 0 0; } - .file.is-boxed.has-name .file-name { - border-radius: 0 0 4px 4px; - border-width: 0 1px 1px; } - .file.is-centered { - justify-content: center; } - .file.is-fullwidth .file-label { - width: 100%; } - .file.is-fullwidth .file-name { - flex-grow: 1; - max-width: none; } - .file.is-right { - justify-content: flex-end; } - .file.is-right .file-cta { - border-radius: 0 4px 4px 0; } - .file.is-right .file-name { - border-radius: 4px 0 0 4px; - border-width: 1px 0 1px 1px; - order: -1; } - -.file-label { - align-items: stretch; - display: flex; - cursor: pointer; - justify-content: flex-start; - overflow: hidden; - position: relative; } - .file-label:hover .file-cta { - background-color: #eeeeee; - color: #363636; } - .file-label:hover .file-name { - border-color: #d5d5d5; } - .file-label:active .file-cta { - background-color: #e8e8e8; - color: #363636; } - .file-label:active .file-name { - border-color: #cfcfcf; } - -.file-input { - height: 0.01em; - left: 0; - outline: none; - position: absolute; - top: 0; - width: 0.01em; } - -.file-cta, -.file-name { - border-color: #dbdbdb; - border-radius: 4px; - font-size: 1em; - padding-left: 1em; - padding-right: 1em; - white-space: nowrap; } - -.file-cta { - background-color: whitesmoke; - color: #4a4a4a; } - -.file-name { - border-color: #dbdbdb; - border-style: solid; - border-width: 1px 1px 1px 0; - display: block; - max-width: 16em; - overflow: hidden; - text-align: left; - text-overflow: ellipsis; } - -.file-icon { - align-items: center; - display: flex; - height: 1em; - justify-content: center; - margin-right: 0.5em; - width: 1em; } - .file-icon .fa { - font-size: 14px; } - -.label { - color: #363636; - display: block; - font-size: 1rem; - font-weight: 700; } - .label:not(:last-child) { - margin-bottom: 0.5em; } - .label.is-small { - font-size: 0.75rem; } - .label.is-medium { - font-size: 1.25rem; } - .label.is-large { - font-size: 1.5rem; } - -.help { - display: block; - font-size: 0.75rem; - margin-top: 0.25rem; } - .help.is-white { - color: white; } - .help.is-black { - color: #0a0a0a; } - .help.is-light { - color: whitesmoke; } - .help.is-dark { - color: #363636; } - .help.is-primary { - color: #00d1b2; } - .help.is-link { - color: #33B2E8; } - .help.is-info { - color: #209cee; } - .help.is-success { - color: #23d160; } - .help.is-warning { - color: #ffdd57; } - .help.is-danger { - color: #ff3860; } - -.field:not(:last-child) { - margin-bottom: 0.75rem; } - -.field.has-addons { - display: flex; - justify-content: flex-start; } - .field.has-addons .control:not(:last-child) { - margin-right: -1px; } - .field.has-addons .control:not(:first-child):not(:last-child) .button, - .field.has-addons .control:not(:first-child):not(:last-child) .input, - .field.has-addons .control:not(:first-child):not(:last-child) .select select { - border-radius: 0; } - .field.has-addons .control:first-child .button, - .field.has-addons .control:first-child .input, - .field.has-addons .control:first-child .select select { - border-bottom-right-radius: 0; - border-top-right-radius: 0; } - .field.has-addons .control:last-child .button, - .field.has-addons .control:last-child .input, - .field.has-addons .control:last-child .select select { - border-bottom-left-radius: 0; - border-top-left-radius: 0; } - .field.has-addons .control .button:hover, .field.has-addons .control .button.is-hovered, - .field.has-addons .control .input:hover, - .field.has-addons .control .input.is-hovered, - .field.has-addons .control .select select:hover, - .field.has-addons .control .select select.is-hovered { - z-index: 2; } - .field.has-addons .control .button:focus, .field.has-addons .control .button.is-focused, .field.has-addons .control .button:active, .field.has-addons .control .button.is-active, - .field.has-addons .control .input:focus, - .field.has-addons .control .input.is-focused, - .field.has-addons .control .input:active, - .field.has-addons .control .input.is-active, - .field.has-addons .control .select select:focus, - .field.has-addons .control .select select.is-focused, - .field.has-addons .control .select select:active, - .field.has-addons .control .select select.is-active { - z-index: 3; } - .field.has-addons .control .button:focus:hover, .field.has-addons .control .button.is-focused:hover, .field.has-addons .control .button:active:hover, .field.has-addons .control .button.is-active:hover, - .field.has-addons .control .input:focus:hover, - .field.has-addons .control .input.is-focused:hover, - .field.has-addons .control .input:active:hover, - .field.has-addons .control .input.is-active:hover, - .field.has-addons .control .select select:focus:hover, - .field.has-addons .control .select select.is-focused:hover, - .field.has-addons .control .select select:active:hover, - .field.has-addons .control .select select.is-active:hover { - z-index: 4; } - .field.has-addons .control.is-expanded { - flex-grow: 1; } - .field.has-addons.has-addons-centered { - justify-content: center; } - .field.has-addons.has-addons-right { - justify-content: flex-end; } - .field.has-addons.has-addons-fullwidth .control { - flex-grow: 1; - flex-shrink: 0; } - -.field.is-grouped { - display: flex; - justify-content: flex-start; } - .field.is-grouped > .control { - flex-shrink: 0; } - .field.is-grouped > .control:not(:last-child) { - margin-bottom: 0; - margin-right: 0.75rem; } - .field.is-grouped > .control.is-expanded { - flex-grow: 1; - flex-shrink: 1; } - .field.is-grouped.is-grouped-centered { - justify-content: center; } - .field.is-grouped.is-grouped-right { - justify-content: flex-end; } - .field.is-grouped.is-grouped-multiline { - flex-wrap: wrap; } - .field.is-grouped.is-grouped-multiline > .control:last-child, .field.is-grouped.is-grouped-multiline > .control:not(:last-child) { - margin-bottom: 0.75rem; } - .field.is-grouped.is-grouped-multiline:last-child { - margin-bottom: -0.75rem; } - .field.is-grouped.is-grouped-multiline:not(:last-child) { - margin-bottom: 0; } - -@media screen and (min-width: 769px), print { - .field.is-horizontal { - display: flex; } } - -.field-label .label { - font-size: inherit; } - -@media screen and (max-width: 768px) { - .field-label { - margin-bottom: 0.5rem; } } - -@media screen and (min-width: 769px), print { - .field-label { - flex-basis: 0; - flex-grow: 1; - flex-shrink: 0; - margin-right: 1.5rem; - text-align: right; } - .field-label.is-small { - font-size: 0.75rem; - padding-top: 0.375em; } - .field-label.is-normal { - padding-top: 0.375em; } - .field-label.is-medium { - font-size: 1.25rem; - padding-top: 0.375em; } - .field-label.is-large { - font-size: 1.5rem; - padding-top: 0.375em; } } - -.field-body .field .field { - margin-bottom: 0; } - -@media screen and (min-width: 769px), print { - .field-body { - display: flex; - flex-basis: 0; - flex-grow: 5; - flex-shrink: 1; } - .field-body .field { - margin-bottom: 0; } - .field-body > .field { - flex-shrink: 1; } - .field-body > .field:not(.is-narrow) { - flex-grow: 1; } - .field-body > .field:not(:last-child) { - margin-right: 0.75rem; } } - -.control { - font-size: 1rem; - position: relative; - text-align: left; } - .control.has-icon .icon { - color: #dbdbdb; - height: 2.25em; - pointer-events: none; - position: absolute; - top: 0; - width: 2.25em; - z-index: 4; } - .control.has-icon .input:focus + .icon { - color: #7a7a7a; } - .control.has-icon .input.is-small + .icon { - font-size: 0.75rem; } - .control.has-icon .input.is-medium + .icon { - font-size: 1.25rem; } - .control.has-icon .input.is-large + .icon { - font-size: 1.5rem; } - .control.has-icon:not(.has-icon-right) .icon { - left: 0; } - .control.has-icon:not(.has-icon-right) .input { - padding-left: 2.25em; } - .control.has-icon.has-icon-right .icon { - right: 0; } - .control.has-icon.has-icon-right .input { - padding-right: 2.25em; } - .control.has-icons-left .input:focus ~ .icon, - .control.has-icons-left .select:focus ~ .icon, .control.has-icons-right .input:focus ~ .icon, - .control.has-icons-right .select:focus ~ .icon { - color: #7a7a7a; } - .control.has-icons-left .input.is-small ~ .icon, - .control.has-icons-left .select.is-small ~ .icon, .control.has-icons-right .input.is-small ~ .icon, - .control.has-icons-right .select.is-small ~ .icon { - font-size: 0.75rem; } - .control.has-icons-left .input.is-medium ~ .icon, - .control.has-icons-left .select.is-medium ~ .icon, .control.has-icons-right .input.is-medium ~ .icon, - .control.has-icons-right .select.is-medium ~ .icon { - font-size: 1.25rem; } - .control.has-icons-left .input.is-large ~ .icon, - .control.has-icons-left .select.is-large ~ .icon, .control.has-icons-right .input.is-large ~ .icon, - .control.has-icons-right .select.is-large ~ .icon { - font-size: 1.5rem; } - .control.has-icons-left .icon, .control.has-icons-right .icon { - color: #dbdbdb; - height: 2.25em; - pointer-events: none; - position: absolute; - top: 0; - width: 2.25em; - z-index: 4; } - .control.has-icons-left .input, - .control.has-icons-left .select select { - padding-left: 2.25em; } - .control.has-icons-left .icon.is-left { - left: 0; } - .control.has-icons-right .input, - .control.has-icons-right .select select { - padding-right: 2.25em; } - .control.has-icons-right .icon.is-right { - right: 0; } - .control.is-loading::after { - position: absolute !important; - right: 0.625em; - top: 0.625em; - z-index: 4; } - .control.is-loading.is-small:after { - font-size: 0.75rem; } - .control.is-loading.is-medium:after { - font-size: 1.25rem; } - .control.is-loading.is-large:after { - font-size: 1.5rem; } - -.icon { - align-items: center; - display: inline-flex; - justify-content: center; - height: 1.5rem; - width: 1.5rem; } - .icon.is-small { - height: 1rem; - width: 1rem; } - .icon.is-medium { - height: 2rem; - width: 2rem; } - .icon.is-large { - height: 3rem; - width: 3rem; } - -.image { - display: block; - position: relative; } - .image img { - display: block; - height: auto; - width: 100%; } - .image img.is-rounded { - border-radius: 290486px; } - .image.is-square img, .image.is-1by1 img, .image.is-5by4 img, .image.is-4by3 img, .image.is-3by2 img, .image.is-5by3 img, .image.is-16by9 img, .image.is-2by1 img, .image.is-3by1 img, .image.is-4by5 img, .image.is-3by4 img, .image.is-2by3 img, .image.is-3by5 img, .image.is-9by16 img, .image.is-1by2 img, .image.is-1by3 img { - height: 100%; - width: 100%; } - .image.is-square, .image.is-1by1 { - padding-top: 100%; } - .image.is-5by4 { - padding-top: 80%; } - .image.is-4by3 { - padding-top: 75%; } - .image.is-3by2 { - padding-top: 66.6666%; } - .image.is-5by3 { - padding-top: 60%; } - .image.is-16by9 { - padding-top: 56.25%; } - .image.is-2by1 { - padding-top: 50%; } - .image.is-3by1 { - padding-top: 33.3333%; } - .image.is-4by5 { - padding-top: 125%; } - .image.is-3by4 { - padding-top: 133.3333%; } - .image.is-2by3 { - padding-top: 150%; } - .image.is-3by5 { - padding-top: 166.6666%; } - .image.is-9by16 { - padding-top: 177.7777%; } - .image.is-1by2 { - padding-top: 200%; } - .image.is-1by3 { - padding-top: 300%; } - .image.is-16x16 { - height: 16px; - width: 16px; } - .image.is-24x24 { - height: 24px; - width: 24px; } - .image.is-32x32 { - height: 32px; - width: 32px; } - .image.is-48x48 { - height: 48px; - width: 48px; } - .image.is-64x64 { - height: 64px; - width: 64px; } - .image.is-96x96 { - height: 96px; - width: 96px; } - .image.is-128x128 { - height: 128px; - width: 128px; } - -.notification { - background-color: whitesmoke; - border-radius: 4px; - padding: 1.25rem 2.5rem 1.25rem 1.5rem; - position: relative; } - .notification a:not(.button) { - color: currentColor; - text-decoration: underline; } - .notification strong { - color: currentColor; } - .notification code, - .notification pre { - background: white; } - .notification pre code { - background: transparent; } - .notification > .delete { - position: absolute; - right: 0.5rem; - top: 0.5rem; } - .notification .title, - .notification .subtitle, - .notification .content { - color: currentColor; } - .notification.is-white { - background-color: white; - color: #0a0a0a; } - .notification.is-black { - background-color: #0a0a0a; - color: white; } - .notification.is-light { - background-color: whitesmoke; - color: #363636; } - .notification.is-dark { - background-color: #363636; - color: whitesmoke; } - .notification.is-primary { - background-color: #00d1b2; - color: #fff; } - .notification.is-link { - background-color: #33B2E8; - color: #fff; } - .notification.is-info { - background-color: #209cee; - color: #fff; } - .notification.is-success { - background-color: #23d160; - color: #fff; } - .notification.is-warning { - background-color: #ffdd57; - color: rgba(0, 0, 0, 0.7); } - .notification.is-danger { - background-color: #ff3860; - color: #fff; } - -.progress { - -moz-appearance: none; - -webkit-appearance: none; - border: none; - border-radius: 290486px; - display: block; - height: 1rem; - overflow: hidden; - padding: 0; - width: 100%; } - .progress::-webkit-progress-bar { - background-color: #dbdbdb; } - .progress::-webkit-progress-value { - background-color: #4a4a4a; } - .progress::-moz-progress-bar { - background-color: #4a4a4a; } - .progress::-ms-fill { - background-color: #4a4a4a; - border: none; } - .progress.is-white::-webkit-progress-value { - background-color: white; } - .progress.is-white::-moz-progress-bar { - background-color: white; } - .progress.is-white::-ms-fill { - background-color: white; } - .progress.is-black::-webkit-progress-value { - background-color: #0a0a0a; } - .progress.is-black::-moz-progress-bar { - background-color: #0a0a0a; } - .progress.is-black::-ms-fill { - background-color: #0a0a0a; } - .progress.is-light::-webkit-progress-value { - background-color: whitesmoke; } - .progress.is-light::-moz-progress-bar { - background-color: whitesmoke; } - .progress.is-light::-ms-fill { - background-color: whitesmoke; } - .progress.is-dark::-webkit-progress-value { - background-color: #363636; } - .progress.is-dark::-moz-progress-bar { - background-color: #363636; } - .progress.is-dark::-ms-fill { - background-color: #363636; } - .progress.is-primary::-webkit-progress-value { - background-color: #00d1b2; } - .progress.is-primary::-moz-progress-bar { - background-color: #00d1b2; } - .progress.is-primary::-ms-fill { - background-color: #00d1b2; } - .progress.is-link::-webkit-progress-value { - background-color: #33B2E8; } - .progress.is-link::-moz-progress-bar { - background-color: #33B2E8; } - .progress.is-link::-ms-fill { - background-color: #33B2E8; } - .progress.is-info::-webkit-progress-value { - background-color: #209cee; } - .progress.is-info::-moz-progress-bar { - background-color: #209cee; } - .progress.is-info::-ms-fill { - background-color: #209cee; } - .progress.is-success::-webkit-progress-value { - background-color: #23d160; } - .progress.is-success::-moz-progress-bar { - background-color: #23d160; } - .progress.is-success::-ms-fill { - background-color: #23d160; } - .progress.is-warning::-webkit-progress-value { - background-color: #ffdd57; } - .progress.is-warning::-moz-progress-bar { - background-color: #ffdd57; } - .progress.is-warning::-ms-fill { - background-color: #ffdd57; } - .progress.is-danger::-webkit-progress-value { - background-color: #ff3860; } - .progress.is-danger::-moz-progress-bar { - background-color: #ff3860; } - .progress.is-danger::-ms-fill { - background-color: #ff3860; } - .progress.is-small { - height: 0.75rem; } - .progress.is-medium { - height: 1.25rem; } - .progress.is-large { - height: 1.5rem; } - -.table { - background-color: white; - color: #363636; } - .table td, - .table th { - border: 1px solid #dbdbdb; - border-width: 0 0 1px; - padding: 0.5em 0.75em; - vertical-align: top; } - .table td.is-white, - .table th.is-white { - background-color: white; - border-color: white; - color: #0a0a0a; } - .table td.is-black, - .table th.is-black { - background-color: #0a0a0a; - border-color: #0a0a0a; - color: white; } - .table td.is-light, - .table th.is-light { - background-color: whitesmoke; - border-color: whitesmoke; - color: #363636; } - .table td.is-dark, - .table th.is-dark { - background-color: #363636; - border-color: #363636; - color: whitesmoke; } - .table td.is-primary, - .table th.is-primary { - background-color: #00d1b2; - border-color: #00d1b2; - color: #fff; } - .table td.is-link, - .table th.is-link { - background-color: #33B2E8; - border-color: #33B2E8; - color: #fff; } - .table td.is-info, - .table th.is-info { - background-color: #209cee; - border-color: #209cee; - color: #fff; } - .table td.is-success, - .table th.is-success { - background-color: #23d160; - border-color: #23d160; - color: #fff; } - .table td.is-warning, - .table th.is-warning { - background-color: #ffdd57; - border-color: #ffdd57; - color: rgba(0, 0, 0, 0.7); } - .table td.is-danger, - .table th.is-danger { - background-color: #ff3860; - border-color: #ff3860; - color: #fff; } - .table td.is-narrow, - .table th.is-narrow { - white-space: nowrap; - width: 1%; } - .table td.is-selected, - .table th.is-selected { - background-color: #00d1b2; - color: #fff; } - .table td.is-selected a, - .table td.is-selected strong, - .table th.is-selected a, - .table th.is-selected strong { - color: currentColor; } - .table th { - color: #363636; - text-align: left; } - .table tr.is-selected { - background-color: #00d1b2; - color: #fff; } - .table tr.is-selected a, - .table tr.is-selected strong { - color: currentColor; } - .table tr.is-selected td, - .table tr.is-selected th { - border-color: #fff; - color: currentColor; } - .table thead td, - .table thead th { - border-width: 0 0 2px; - color: #363636; } - .table tfoot td, - .table tfoot th { - border-width: 2px 0 0; - color: #363636; } - .table tbody tr:last-child td, - .table tbody tr:last-child th { - border-bottom-width: 0; } - .table.is-bordered td, - .table.is-bordered th { - border-width: 1px; } - .table.is-bordered tr:last-child td, - .table.is-bordered tr:last-child th { - border-bottom-width: 1px; } - .table.is-fullwidth { - width: 100%; } - .table.is-hoverable tbody tr:not(.is-selected):hover { - background-color: #fafafa; } - .table.is-hoverable.is-striped tbody tr:not(.is-selected):hover { - background-color: whitesmoke; } - .table.is-narrow td, - .table.is-narrow th { - padding: 0.25em 0.5em; } - .table.is-striped tbody tr:not(.is-selected):nth-child(even) { - background-color: #fafafa; } - -.table-container { - -webkit-overflow-scrolling: touch; - overflow: auto; - overflow-y: hidden; - max-width: 100%; } - -.tags { - align-items: center; - display: flex; - flex-wrap: wrap; - justify-content: flex-start; } - .tags .tag { - margin-bottom: 0.5rem; } - .tags .tag:not(:last-child) { - margin-right: 0.5rem; } - .tags:last-child { - margin-bottom: -0.5rem; } - .tags:not(:last-child) { - margin-bottom: 1rem; } - .tags.has-addons .tag { - margin-right: 0; } - .tags.has-addons .tag:not(:first-child) { - border-bottom-left-radius: 0; - border-top-left-radius: 0; } - .tags.has-addons .tag:not(:last-child) { - border-bottom-right-radius: 0; - border-top-right-radius: 0; } - .tags.is-centered { - justify-content: center; } - .tags.is-centered .tag { - margin-right: 0.25rem; - margin-left: 0.25rem; } - .tags.is-right { - justify-content: flex-end; } - .tags.is-right .tag:not(:first-child) { - margin-left: 0.5rem; } - .tags.is-right .tag:not(:last-child) { - margin-right: 0; } - -.tag:not(body) { - align-items: center; - background-color: whitesmoke; - border-radius: 4px; - color: #4a4a4a; - display: inline-flex; - font-size: 0.75rem; - height: 2em; - justify-content: center; - line-height: 1.5; - padding-left: 0.75em; - padding-right: 0.75em; - white-space: nowrap; } - .tag:not(body) .delete { - margin-left: 0.25rem; - margin-right: -0.375rem; } - .tag:not(body).is-white { - background-color: white; - color: #0a0a0a; } - .tag:not(body).is-black { - background-color: #0a0a0a; - color: white; } - .tag:not(body).is-light { - background-color: whitesmoke; - color: #363636; } - .tag:not(body).is-dark { - background-color: #363636; - color: whitesmoke; } - .tag:not(body).is-primary { - background-color: #00d1b2; - color: #fff; } - .tag:not(body).is-link { - background-color: #33B2E8; - color: #fff; } - .tag:not(body).is-info { - background-color: #209cee; - color: #fff; } - .tag:not(body).is-success { - background-color: #23d160; - color: #fff; } - .tag:not(body).is-warning { - background-color: #ffdd57; - color: rgba(0, 0, 0, 0.7); } - .tag:not(body).is-danger { - background-color: #ff3860; - color: #fff; } - .tag:not(body).is-medium { - font-size: 1rem; } - .tag:not(body).is-large { - font-size: 1.25rem; } - .tag:not(body) .icon:first-child:not(:last-child) { - margin-left: -0.375em; - margin-right: 0.1875em; } - .tag:not(body) .icon:last-child:not(:first-child) { - margin-left: 0.1875em; - margin-right: -0.375em; } - .tag:not(body) .icon:first-child:last-child { - margin-left: -0.375em; - margin-right: -0.375em; } - .tag:not(body).is-delete { - margin-left: 1px; - padding: 0; - position: relative; - width: 2em; } - .tag:not(body).is-delete::before, .tag:not(body).is-delete::after { - background-color: currentColor; - content: ""; - display: block; - left: 50%; - position: absolute; - top: 50%; - transform: translateX(-50%) translateY(-50%) rotate(45deg); - transform-origin: center center; } - .tag:not(body).is-delete::before { - height: 1px; - width: 50%; } - .tag:not(body).is-delete::after { - height: 50%; - width: 1px; } - .tag:not(body).is-delete:hover, .tag:not(body).is-delete:focus { - background-color: #e8e8e8; } - .tag:not(body).is-delete:active { - background-color: #dbdbdb; } - .tag:not(body).is-rounded { - border-radius: 290486px; } - -a.tag:hover { - text-decoration: underline; } - -.title, -.subtitle { - word-break: break-word; } - .title em, - .title span, - .subtitle em, - .subtitle span { - font-weight: inherit; } - .title sub, - .subtitle sub { - font-size: 0.75em; } - .title sup, - .subtitle sup { - font-size: 0.75em; } - .title .tag, - .subtitle .tag { - vertical-align: middle; } - -.title { - color: #363636; - font-size: 2rem; - font-weight: 600; - line-height: 1.125; } - .title strong { - color: inherit; - font-weight: inherit; } - .title + .highlight { - margin-top: -0.75rem; } - .title:not(.is-spaced) + .subtitle { - margin-top: -1.25rem; } - .title.is-1 { - font-size: 3rem; } - .title.is-2 { - font-size: 2.5rem; } - .title.is-3 { - font-size: 2rem; } - .title.is-4 { - font-size: 1.5rem; } - .title.is-5 { - font-size: 1.25rem; } - .title.is-6 { - font-size: 1rem; } - .title.is-7 { - font-size: 0.75rem; } - -.subtitle { - color: #4a4a4a; - font-size: 1.25rem; - font-weight: 400; - line-height: 1.25; } - .subtitle strong { - color: #363636; - font-weight: 600; } - .subtitle:not(.is-spaced) + .title { - margin-top: -1.25rem; } - .subtitle.is-1 { - font-size: 3rem; } - .subtitle.is-2 { - font-size: 2.5rem; } - .subtitle.is-3 { - font-size: 2rem; } - .subtitle.is-4 { - font-size: 1.5rem; } - .subtitle.is-5 { - font-size: 1.25rem; } - .subtitle.is-6 { - font-size: 1rem; } - .subtitle.is-7 { - font-size: 0.75rem; } - -.heading { - display: block; - font-size: 11px; - letter-spacing: 1px; - margin-bottom: 5px; - text-transform: uppercase; } - -.highlight { - font-weight: 400; - max-width: 100%; - overflow: hidden; - padding: 0; } - .highlight pre { - overflow: auto; - max-width: 100%; } - -.number { - align-items: center; - background-color: whitesmoke; - border-radius: 290486px; - display: inline-flex; - font-size: 1.25rem; - height: 2em; - justify-content: center; - margin-right: 1.5rem; - min-width: 2.5em; - padding: 0.25rem 0.5rem; - text-align: center; - vertical-align: top; } - -.breadcrumb { - font-size: 1rem; - white-space: nowrap; } - .breadcrumb a { - align-items: center; - color: #33B2E8; - display: flex; - justify-content: center; - padding: 0 0.75em; } - .breadcrumb a:hover { - color: #363636; } - .breadcrumb li { - align-items: center; - display: flex; } - .breadcrumb li:first-child a { - padding-left: 0; } - .breadcrumb li.is-active a { - color: #363636; - cursor: default; - pointer-events: none; } - .breadcrumb li + li::before { - color: #b5b5b5; - content: "\0002f"; } - .breadcrumb ul, - .breadcrumb ol { - align-items: flex-start; - display: flex; - flex-wrap: wrap; - justify-content: flex-start; } - .breadcrumb .icon:first-child { - margin-right: 0.5em; } - .breadcrumb .icon:last-child { - margin-left: 0.5em; } - .breadcrumb.is-centered ol, - .breadcrumb.is-centered ul { - justify-content: center; } - .breadcrumb.is-right ol, - .breadcrumb.is-right ul { - justify-content: flex-end; } - .breadcrumb.is-small { - font-size: 0.75rem; } - .breadcrumb.is-medium { - font-size: 1.25rem; } - .breadcrumb.is-large { - font-size: 1.5rem; } - .breadcrumb.has-arrow-separator li + li::before { - content: "\02192"; } - .breadcrumb.has-bullet-separator li + li::before { - content: "\02022"; } - .breadcrumb.has-dot-separator li + li::before { - content: "\000b7"; } - .breadcrumb.has-succeeds-separator li + li::before { - content: "\0227B"; } - -.card { - background-color: white; - box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1), 0 0 0 1px rgba(10, 10, 10, 0.1); - color: #4a4a4a; - max-width: 100%; - position: relative; } - -.card-header { - background-color: none; - align-items: stretch; - box-shadow: 0 1px 2px rgba(10, 10, 10, 0.1); - display: flex; } - -.card-header-title { - align-items: center; - color: #363636; - display: flex; - flex-grow: 1; - font-weight: 700; - padding: 0.75rem; } - .card-header-title.is-centered { - justify-content: center; } - -.card-header-icon { - align-items: center; - cursor: pointer; - display: flex; - justify-content: center; - padding: 0.75rem; } - -.card-image { - display: block; - position: relative; } - -.card-content { - background-color: none; - padding: 1.5rem; } - -.card-footer { - background-color: none; - border-top: 1px solid #dbdbdb; - align-items: stretch; - display: flex; } - -.card-footer-item { - align-items: center; - display: flex; - flex-basis: 0; - flex-grow: 1; - flex-shrink: 0; - justify-content: center; - padding: 0.75rem; } - .card-footer-item:not(:last-child) { - border-right: 1px solid #dbdbdb; } - -.card .media:not(:last-child) { - margin-bottom: 0.75rem; } - -.dropdown { - display: inline-flex; - position: relative; - vertical-align: top; } - .dropdown.is-active .dropdown-menu, .dropdown.is-hoverable:hover .dropdown-menu { - display: block; } - .dropdown.is-right .dropdown-menu { - left: auto; - right: 0; } - .dropdown.is-up .dropdown-menu { - bottom: 100%; - padding-bottom: 4px; - padding-top: initial; - top: auto; } - -.dropdown-menu { - display: none; - left: 0; - min-width: 12rem; - padding-top: 4px; - position: absolute; - top: 100%; - z-index: 20; } - -.dropdown-content { - background-color: white; - border-radius: 4px; - box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1), 0 0 0 1px rgba(10, 10, 10, 0.1); - padding-bottom: 0.5rem; - padding-top: 0.5rem; } - -.dropdown-item { - color: #4a4a4a; - display: block; - font-size: 0.875rem; - line-height: 1.5; - padding: 0.375rem 1rem; - position: relative; } - -a.dropdown-item { - padding-right: 3rem; - white-space: nowrap; } - a.dropdown-item:hover { - background-color: whitesmoke; - color: #0a0a0a; } - a.dropdown-item.is-active { - background-color: #33B2E8; - color: #fff; } - -.dropdown-divider { - background-color: #dbdbdb; - border: none; - display: block; - height: 1px; - margin: 0.5rem 0; } - -.level { - align-items: center; - justify-content: space-between; } - .level code { - border-radius: 4px; } - .level img { - display: inline-block; - vertical-align: top; } - .level.is-mobile { - display: flex; } - .level.is-mobile .level-left, - .level.is-mobile .level-right { - display: flex; } - .level.is-mobile .level-left + .level-right { - margin-top: 0; } - .level.is-mobile .level-item { - margin-right: 0.75rem; } - .level.is-mobile .level-item:not(:last-child) { - margin-bottom: 0; } - .level.is-mobile .level-item:not(.is-narrow) { - flex-grow: 1; } - @media screen and (min-width: 769px), print { - .level { - display: flex; } - .level > .level-item:not(.is-narrow) { - flex-grow: 1; } } - -.level-item { - align-items: center; - display: flex; - flex-basis: auto; - flex-grow: 0; - flex-shrink: 0; - justify-content: center; } - .level-item .title, - .level-item .subtitle { - margin-bottom: 0; } - @media screen and (max-width: 768px) { - .level-item:not(:last-child) { - margin-bottom: 0.75rem; } } - -.level-left, -.level-right { - flex-basis: auto; - flex-grow: 0; - flex-shrink: 0; } - .level-left .level-item.is-flexible, - .level-right .level-item.is-flexible { - flex-grow: 1; } - @media screen and (min-width: 769px), print { - .level-left .level-item:not(:last-child), - .level-right .level-item:not(:last-child) { - margin-right: 0.75rem; } } - -.level-left { - align-items: center; - justify-content: flex-start; } - @media screen and (max-width: 768px) { - .level-left + .level-right { - margin-top: 1.5rem; } } - @media screen and (min-width: 769px), print { - .level-left { - display: flex; } } - -.level-right { - align-items: center; - justify-content: flex-end; } - @media screen and (min-width: 769px), print { - .level-right { - display: flex; } } - -.media { - align-items: flex-start; - display: flex; - text-align: left; } - .media .content:not(:last-child) { - margin-bottom: 0.75rem; } - .media .media { - border-top: 1px solid rgba(219, 219, 219, 0.5); - display: flex; - padding-top: 0.75rem; } - .media .media .content:not(:last-child), - .media .media .control:not(:last-child) { - margin-bottom: 0.5rem; } - .media .media .media { - padding-top: 0.5rem; } - .media .media .media + .media { - margin-top: 0.5rem; } - .media + .media { - border-top: 1px solid rgba(219, 219, 219, 0.5); - margin-top: 1rem; - padding-top: 1rem; } - .media.is-large + .media { - margin-top: 1.5rem; - padding-top: 1.5rem; } - -.media-left, -.media-right { - flex-basis: auto; - flex-grow: 0; - flex-shrink: 0; } - -.media-left { - margin-right: 1rem; } - -.media-right { - margin-left: 1rem; } - -.media-content { - flex-basis: auto; - flex-grow: 1; - flex-shrink: 1; - text-align: left; } - -.menu { - font-size: 1rem; } - .menu.is-small { - font-size: 0.75rem; } - .menu.is-medium { - font-size: 1.25rem; } - .menu.is-large { - font-size: 1.5rem; } - -.menu-list { - line-height: 1.25; } - .menu-list a { - border-radius: 2px; - color: #4a4a4a; - display: block; - padding: 0.5em 0.75em; } - .menu-list a:hover { - background-color: whitesmoke; - color: #363636; } - .menu-list a.is-active { - background-color: #33B2E8; - color: #fff; } - .menu-list li ul { - border-left: 1px solid #dbdbdb; - margin: 0.75em; - padding-left: 0.75em; } - -.menu-label { - color: #7a7a7a; - font-size: 0.75em; - letter-spacing: 0.1em; - text-transform: uppercase; } - .menu-label:not(:first-child) { - margin-top: 1em; } - .menu-label:not(:last-child) { - margin-bottom: 1em; } - -.message { - background-color: whitesmoke; - border-radius: 4px; - font-size: 1rem; } - .message strong { - color: currentColor; } - .message a:not(.button):not(.tag) { - color: currentColor; - text-decoration: underline; } - .message.is-small { - font-size: 0.75rem; } - .message.is-medium { - font-size: 1.25rem; } - .message.is-large { - font-size: 1.5rem; } - .message.is-white { - background-color: white; } - .message.is-white .message-header { - background-color: white; - color: #0a0a0a; } - .message.is-white .message-body { - border-color: white; - color: #4d4d4d; } - .message.is-black { - background-color: #fafafa; } - .message.is-black .message-header { - background-color: #0a0a0a; - color: white; } - .message.is-black .message-body { - border-color: #0a0a0a; - color: #090909; } - .message.is-light { - background-color: #fafafa; } - .message.is-light .message-header { - background-color: whitesmoke; - color: #363636; } - .message.is-light .message-body { - border-color: whitesmoke; - color: #505050; } - .message.is-dark { - background-color: #fafafa; } - .message.is-dark .message-header { - background-color: #363636; - color: whitesmoke; } - .message.is-dark .message-body { - border-color: #363636; - color: #2a2a2a; } - .message.is-primary { - background-color: #f5fffd; } - .message.is-primary .message-header { - background-color: #00d1b2; - color: #fff; } - .message.is-primary .message-body { - border-color: #00d1b2; - color: #021310; } - .message.is-link { - background-color: #f6fcfe; } - .message.is-link .message-header { - background-color: #33B2E8; - color: #fff; } - .message.is-link .message-body { - border-color: #33B2E8; - color: #15516a; } - .message.is-info { - background-color: #f6fbfe; } - .message.is-info .message-header { - background-color: #209cee; - color: #fff; } - .message.is-info .message-body { - border-color: #209cee; - color: #12537e; } - .message.is-success { - background-color: #f6fef9; } - .message.is-success .message-header { - background-color: #23d160; - color: #fff; } - .message.is-success .message-body { - border-color: #23d160; - color: #0e301a; } - .message.is-warning { - background-color: #fffdf5; } - .message.is-warning .message-header { - background-color: #ffdd57; - color: rgba(0, 0, 0, 0.7); } - .message.is-warning .message-body { - border-color: #ffdd57; - color: #3b3108; } - .message.is-danger { - background-color: #fff5f7; } - .message.is-danger .message-header { - background-color: #ff3860; - color: #fff; } - .message.is-danger .message-body { - border-color: #ff3860; - color: #cd0930; } - -.message-header { - align-items: center; - background-color: #4a4a4a; - border-radius: 4px 4px 0 0; - color: #fff; - display: flex; - font-weight: 700; - justify-content: space-between; - line-height: 1.25; - padding: 0.75em 1em; - position: relative; } - .message-header .delete { - flex-grow: 0; - flex-shrink: 0; - margin-left: 0.75em; } - .message-header + .message-body { - border-width: 0; - border-top-left-radius: 0; - border-top-right-radius: 0; } - -.message-body { - border-color: #dbdbdb; - border-radius: 4px; - border-style: solid; - border-width: 0 0 0 4px; - color: #4a4a4a; - padding: 1.25em 1.5em; } - .message-body code, - .message-body pre { - background-color: white; } - .message-body pre code { - background-color: transparent; } - -.modal { - align-items: center; - display: none; - justify-content: center; - overflow: hidden; - position: fixed; - z-index: 40; } - .modal.is-active { - display: flex; } - -.modal-background { - background-color: rgba(10, 10, 10, 0.86); } - -.modal-content, -.modal-card { - margin: 0 20px; - max-height: calc(100vh - 160px); - overflow: auto; - position: relative; - width: 100%; } - @media screen and (min-width: 769px), print { - .modal-content, - .modal-card { - margin: 0 auto; - max-height: calc(100vh - 40px); - width: 640px; } } - -.modal-close { - background: none; - height: 40px; - position: fixed; - right: 20px; - top: 20px; - width: 40px; } - -.modal-card { - display: flex; - flex-direction: column; - max-height: calc(100vh - 40px); - overflow: hidden; } - -.modal-card-head, -.modal-card-foot { - align-items: center; - background-color: whitesmoke; - display: flex; - flex-shrink: 0; - justify-content: flex-start; - padding: 20px; - position: relative; } - -.modal-card-head { - border-bottom: 1px solid #dbdbdb; - border-top-left-radius: 6px; - border-top-right-radius: 6px; } - -.modal-card-title { - color: #363636; - flex-grow: 1; - flex-shrink: 0; - font-size: 1.5rem; - line-height: 1; } - -.modal-card-foot { - border-bottom-left-radius: 6px; - border-bottom-right-radius: 6px; - border-top: 1px solid #dbdbdb; } - .modal-card-foot .button:not(:last-child) { - margin-right: 10px; } - -.modal-card-body { - -webkit-overflow-scrolling: touch; - background-color: white; - flex-grow: 1; - flex-shrink: 1; - overflow: auto; - padding: 20px; } - -.navbar { - background-color: white; - min-height: 3.25rem; - position: relative; - z-index: 30; } - .navbar.is-white { - background-color: white; - color: #0a0a0a; } - .navbar.is-white .navbar-brand > .navbar-item, - .navbar.is-white .navbar-brand .navbar-link { - color: #0a0a0a; } - .navbar.is-white .navbar-brand > a.navbar-item:hover, .navbar.is-white .navbar-brand > a.navbar-item.is-active, - .navbar.is-white .navbar-brand .navbar-link:hover, - .navbar.is-white .navbar-brand .navbar-link.is-active { - background-color: #f2f2f2; - color: #0a0a0a; } - .navbar.is-white .navbar-brand .navbar-link::after { - border-color: #0a0a0a; } - @media screen and (min-width: 1088px) { - .navbar.is-white .navbar-start > .navbar-item, - .navbar.is-white .navbar-start .navbar-link, - .navbar.is-white .navbar-end > .navbar-item, - .navbar.is-white .navbar-end .navbar-link { - color: #0a0a0a; } - .navbar.is-white .navbar-start > a.navbar-item:hover, .navbar.is-white .navbar-start > a.navbar-item.is-active, - .navbar.is-white .navbar-start .navbar-link:hover, - .navbar.is-white .navbar-start .navbar-link.is-active, - .navbar.is-white .navbar-end > a.navbar-item:hover, - .navbar.is-white .navbar-end > a.navbar-item.is-active, - .navbar.is-white .navbar-end .navbar-link:hover, - .navbar.is-white .navbar-end .navbar-link.is-active { - background-color: #f2f2f2; - color: #0a0a0a; } - .navbar.is-white .navbar-start .navbar-link::after, - .navbar.is-white .navbar-end .navbar-link::after { - border-color: #0a0a0a; } - .navbar.is-white .navbar-item.has-dropdown:hover .navbar-link, - .navbar.is-white .navbar-item.has-dropdown.is-active .navbar-link { - background-color: #f2f2f2; - color: #0a0a0a; } - .navbar.is-white .navbar-dropdown a.navbar-item.is-active { - background-color: white; - color: #0a0a0a; } } - .navbar.is-black { - background-color: #0a0a0a; - color: white; } - .navbar.is-black .navbar-brand > .navbar-item, - .navbar.is-black .navbar-brand .navbar-link { - color: white; } - .navbar.is-black .navbar-brand > a.navbar-item:hover, .navbar.is-black .navbar-brand > a.navbar-item.is-active, - .navbar.is-black .navbar-brand .navbar-link:hover, - .navbar.is-black .navbar-brand .navbar-link.is-active { - background-color: black; - color: white; } - .navbar.is-black .navbar-brand .navbar-link::after { - border-color: white; } - @media screen and (min-width: 1088px) { - .navbar.is-black .navbar-start > .navbar-item, - .navbar.is-black .navbar-start .navbar-link, - .navbar.is-black .navbar-end > .navbar-item, - .navbar.is-black .navbar-end .navbar-link { - color: white; } - .navbar.is-black .navbar-start > a.navbar-item:hover, .navbar.is-black .navbar-start > a.navbar-item.is-active, - .navbar.is-black .navbar-start .navbar-link:hover, - .navbar.is-black .navbar-start .navbar-link.is-active, - .navbar.is-black .navbar-end > a.navbar-item:hover, - .navbar.is-black .navbar-end > a.navbar-item.is-active, - .navbar.is-black .navbar-end .navbar-link:hover, - .navbar.is-black .navbar-end .navbar-link.is-active { - background-color: black; - color: white; } - .navbar.is-black .navbar-start .navbar-link::after, - .navbar.is-black .navbar-end .navbar-link::after { - border-color: white; } - .navbar.is-black .navbar-item.has-dropdown:hover .navbar-link, - .navbar.is-black .navbar-item.has-dropdown.is-active .navbar-link { - background-color: black; - color: white; } - .navbar.is-black .navbar-dropdown a.navbar-item.is-active { - background-color: #0a0a0a; - color: white; } } - .navbar.is-light { - background-color: whitesmoke; - color: #363636; } - .navbar.is-light .navbar-brand > .navbar-item, - .navbar.is-light .navbar-brand .navbar-link { - color: #363636; } - .navbar.is-light .navbar-brand > a.navbar-item:hover, .navbar.is-light .navbar-brand > a.navbar-item.is-active, - .navbar.is-light .navbar-brand .navbar-link:hover, - .navbar.is-light .navbar-brand .navbar-link.is-active { - background-color: #e8e8e8; - color: #363636; } - .navbar.is-light .navbar-brand .navbar-link::after { - border-color: #363636; } - @media screen and (min-width: 1088px) { - .navbar.is-light .navbar-start > .navbar-item, - .navbar.is-light .navbar-start .navbar-link, - .navbar.is-light .navbar-end > .navbar-item, - .navbar.is-light .navbar-end .navbar-link { - color: #363636; } - .navbar.is-light .navbar-start > a.navbar-item:hover, .navbar.is-light .navbar-start > a.navbar-item.is-active, - .navbar.is-light .navbar-start .navbar-link:hover, - .navbar.is-light .navbar-start .navbar-link.is-active, - .navbar.is-light .navbar-end > a.navbar-item:hover, - .navbar.is-light .navbar-end > a.navbar-item.is-active, - .navbar.is-light .navbar-end .navbar-link:hover, - .navbar.is-light .navbar-end .navbar-link.is-active { - background-color: #e8e8e8; - color: #363636; } - .navbar.is-light .navbar-start .navbar-link::after, - .navbar.is-light .navbar-end .navbar-link::after { - border-color: #363636; } - .navbar.is-light .navbar-item.has-dropdown:hover .navbar-link, - .navbar.is-light .navbar-item.has-dropdown.is-active .navbar-link { - background-color: #e8e8e8; - color: #363636; } - .navbar.is-light .navbar-dropdown a.navbar-item.is-active { - background-color: whitesmoke; - color: #363636; } } - .navbar.is-dark { - background-color: #363636; - color: whitesmoke; } - .navbar.is-dark .navbar-brand > .navbar-item, - .navbar.is-dark .navbar-brand .navbar-link { - color: whitesmoke; } - .navbar.is-dark .navbar-brand > a.navbar-item:hover, .navbar.is-dark .navbar-brand > a.navbar-item.is-active, - .navbar.is-dark .navbar-brand .navbar-link:hover, - .navbar.is-dark .navbar-brand .navbar-link.is-active { - background-color: #292929; - color: whitesmoke; } - .navbar.is-dark .navbar-brand .navbar-link::after { - border-color: whitesmoke; } - @media screen and (min-width: 1088px) { - .navbar.is-dark .navbar-start > .navbar-item, - .navbar.is-dark .navbar-start .navbar-link, - .navbar.is-dark .navbar-end > .navbar-item, - .navbar.is-dark .navbar-end .navbar-link { - color: whitesmoke; } - .navbar.is-dark .navbar-start > a.navbar-item:hover, .navbar.is-dark .navbar-start > a.navbar-item.is-active, - .navbar.is-dark .navbar-start .navbar-link:hover, - .navbar.is-dark .navbar-start .navbar-link.is-active, - .navbar.is-dark .navbar-end > a.navbar-item:hover, - .navbar.is-dark .navbar-end > a.navbar-item.is-active, - .navbar.is-dark .navbar-end .navbar-link:hover, - .navbar.is-dark .navbar-end .navbar-link.is-active { - background-color: #292929; - color: whitesmoke; } - .navbar.is-dark .navbar-start .navbar-link::after, - .navbar.is-dark .navbar-end .navbar-link::after { - border-color: whitesmoke; } - .navbar.is-dark .navbar-item.has-dropdown:hover .navbar-link, - .navbar.is-dark .navbar-item.has-dropdown.is-active .navbar-link { - background-color: #292929; - color: whitesmoke; } - .navbar.is-dark .navbar-dropdown a.navbar-item.is-active { - background-color: #363636; - color: whitesmoke; } } - .navbar.is-primary { - background-color: #00d1b2; - color: #fff; } - .navbar.is-primary .navbar-brand > .navbar-item, - .navbar.is-primary .navbar-brand .navbar-link { - color: #fff; } - .navbar.is-primary .navbar-brand > a.navbar-item:hover, .navbar.is-primary .navbar-brand > a.navbar-item.is-active, - .navbar.is-primary .navbar-brand .navbar-link:hover, - .navbar.is-primary .navbar-brand .navbar-link.is-active { - background-color: #00b89c; - color: #fff; } - .navbar.is-primary .navbar-brand .navbar-link::after { - border-color: #fff; } - @media screen and (min-width: 1088px) { - .navbar.is-primary .navbar-start > .navbar-item, - .navbar.is-primary .navbar-start .navbar-link, - .navbar.is-primary .navbar-end > .navbar-item, - .navbar.is-primary .navbar-end .navbar-link { - color: #fff; } - .navbar.is-primary .navbar-start > a.navbar-item:hover, .navbar.is-primary .navbar-start > a.navbar-item.is-active, - .navbar.is-primary .navbar-start .navbar-link:hover, - .navbar.is-primary .navbar-start .navbar-link.is-active, - .navbar.is-primary .navbar-end > a.navbar-item:hover, - .navbar.is-primary .navbar-end > a.navbar-item.is-active, - .navbar.is-primary .navbar-end .navbar-link:hover, - .navbar.is-primary .navbar-end .navbar-link.is-active { - background-color: #00b89c; - color: #fff; } - .navbar.is-primary .navbar-start .navbar-link::after, - .navbar.is-primary .navbar-end .navbar-link::after { - border-color: #fff; } - .navbar.is-primary .navbar-item.has-dropdown:hover .navbar-link, - .navbar.is-primary .navbar-item.has-dropdown.is-active .navbar-link { - background-color: #00b89c; - color: #fff; } - .navbar.is-primary .navbar-dropdown a.navbar-item.is-active { - background-color: #00d1b2; - color: #fff; } } - .navbar.is-link { - background-color: #33B2E8; - color: #fff; } - .navbar.is-link .navbar-brand > .navbar-item, - .navbar.is-link .navbar-brand .navbar-link { - color: #fff; } - .navbar.is-link .navbar-brand > a.navbar-item:hover, .navbar.is-link .navbar-brand > a.navbar-item.is-active, - .navbar.is-link .navbar-brand .navbar-link:hover, - .navbar.is-link .navbar-brand .navbar-link.is-active { - background-color: #1ca9e5; - color: #fff; } - .navbar.is-link .navbar-brand .navbar-link::after { - border-color: #fff; } - @media screen and (min-width: 1088px) { - .navbar.is-link .navbar-start > .navbar-item, - .navbar.is-link .navbar-start .navbar-link, - .navbar.is-link .navbar-end > .navbar-item, - .navbar.is-link .navbar-end .navbar-link { - color: #fff; } - .navbar.is-link .navbar-start > a.navbar-item:hover, .navbar.is-link .navbar-start > a.navbar-item.is-active, - .navbar.is-link .navbar-start .navbar-link:hover, - .navbar.is-link .navbar-start .navbar-link.is-active, - .navbar.is-link .navbar-end > a.navbar-item:hover, - .navbar.is-link .navbar-end > a.navbar-item.is-active, - .navbar.is-link .navbar-end .navbar-link:hover, - .navbar.is-link .navbar-end .navbar-link.is-active { - background-color: #1ca9e5; - color: #fff; } - .navbar.is-link .navbar-start .navbar-link::after, - .navbar.is-link .navbar-end .navbar-link::after { - border-color: #fff; } - .navbar.is-link .navbar-item.has-dropdown:hover .navbar-link, - .navbar.is-link .navbar-item.has-dropdown.is-active .navbar-link { - background-color: #1ca9e5; - color: #fff; } - .navbar.is-link .navbar-dropdown a.navbar-item.is-active { - background-color: #33B2E8; - color: #fff; } } - .navbar.is-info { - background-color: #209cee; - color: #fff; } - .navbar.is-info .navbar-brand > .navbar-item, - .navbar.is-info .navbar-brand .navbar-link { - color: #fff; } - .navbar.is-info .navbar-brand > a.navbar-item:hover, .navbar.is-info .navbar-brand > a.navbar-item.is-active, - .navbar.is-info .navbar-brand .navbar-link:hover, - .navbar.is-info .navbar-brand .navbar-link.is-active { - background-color: #118fe4; - color: #fff; } - .navbar.is-info .navbar-brand .navbar-link::after { - border-color: #fff; } - @media screen and (min-width: 1088px) { - .navbar.is-info .navbar-start > .navbar-item, - .navbar.is-info .navbar-start .navbar-link, - .navbar.is-info .navbar-end > .navbar-item, - .navbar.is-info .navbar-end .navbar-link { - color: #fff; } - .navbar.is-info .navbar-start > a.navbar-item:hover, .navbar.is-info .navbar-start > a.navbar-item.is-active, - .navbar.is-info .navbar-start .navbar-link:hover, - .navbar.is-info .navbar-start .navbar-link.is-active, - .navbar.is-info .navbar-end > a.navbar-item:hover, - .navbar.is-info .navbar-end > a.navbar-item.is-active, - .navbar.is-info .navbar-end .navbar-link:hover, - .navbar.is-info .navbar-end .navbar-link.is-active { - background-color: #118fe4; - color: #fff; } - .navbar.is-info .navbar-start .navbar-link::after, - .navbar.is-info .navbar-end .navbar-link::after { - border-color: #fff; } - .navbar.is-info .navbar-item.has-dropdown:hover .navbar-link, - .navbar.is-info .navbar-item.has-dropdown.is-active .navbar-link { - background-color: #118fe4; - color: #fff; } - .navbar.is-info .navbar-dropdown a.navbar-item.is-active { - background-color: #209cee; - color: #fff; } } - .navbar.is-success { - background-color: #23d160; - color: #fff; } - .navbar.is-success .navbar-brand > .navbar-item, - .navbar.is-success .navbar-brand .navbar-link { - color: #fff; } - .navbar.is-success .navbar-brand > a.navbar-item:hover, .navbar.is-success .navbar-brand > a.navbar-item.is-active, - .navbar.is-success .navbar-brand .navbar-link:hover, - .navbar.is-success .navbar-brand .navbar-link.is-active { - background-color: #20bc56; - color: #fff; } - .navbar.is-success .navbar-brand .navbar-link::after { - border-color: #fff; } - @media screen and (min-width: 1088px) { - .navbar.is-success .navbar-start > .navbar-item, - .navbar.is-success .navbar-start .navbar-link, - .navbar.is-success .navbar-end > .navbar-item, - .navbar.is-success .navbar-end .navbar-link { - color: #fff; } - .navbar.is-success .navbar-start > a.navbar-item:hover, .navbar.is-success .navbar-start > a.navbar-item.is-active, - .navbar.is-success .navbar-start .navbar-link:hover, - .navbar.is-success .navbar-start .navbar-link.is-active, - .navbar.is-success .navbar-end > a.navbar-item:hover, - .navbar.is-success .navbar-end > a.navbar-item.is-active, - .navbar.is-success .navbar-end .navbar-link:hover, - .navbar.is-success .navbar-end .navbar-link.is-active { - background-color: #20bc56; - color: #fff; } - .navbar.is-success .navbar-start .navbar-link::after, - .navbar.is-success .navbar-end .navbar-link::after { - border-color: #fff; } - .navbar.is-success .navbar-item.has-dropdown:hover .navbar-link, - .navbar.is-success .navbar-item.has-dropdown.is-active .navbar-link { - background-color: #20bc56; - color: #fff; } - .navbar.is-success .navbar-dropdown a.navbar-item.is-active { - background-color: #23d160; - color: #fff; } } - .navbar.is-warning { - background-color: #ffdd57; - color: rgba(0, 0, 0, 0.7); } - .navbar.is-warning .navbar-brand > .navbar-item, - .navbar.is-warning .navbar-brand .navbar-link { - color: rgba(0, 0, 0, 0.7); } - .navbar.is-warning .navbar-brand > a.navbar-item:hover, .navbar.is-warning .navbar-brand > a.navbar-item.is-active, - .navbar.is-warning .navbar-brand .navbar-link:hover, - .navbar.is-warning .navbar-brand .navbar-link.is-active { - background-color: #ffd83d; - color: rgba(0, 0, 0, 0.7); } - .navbar.is-warning .navbar-brand .navbar-link::after { - border-color: rgba(0, 0, 0, 0.7); } - @media screen and (min-width: 1088px) { - .navbar.is-warning .navbar-start > .navbar-item, - .navbar.is-warning .navbar-start .navbar-link, - .navbar.is-warning .navbar-end > .navbar-item, - .navbar.is-warning .navbar-end .navbar-link { - color: rgba(0, 0, 0, 0.7); } - .navbar.is-warning .navbar-start > a.navbar-item:hover, .navbar.is-warning .navbar-start > a.navbar-item.is-active, - .navbar.is-warning .navbar-start .navbar-link:hover, - .navbar.is-warning .navbar-start .navbar-link.is-active, - .navbar.is-warning .navbar-end > a.navbar-item:hover, - .navbar.is-warning .navbar-end > a.navbar-item.is-active, - .navbar.is-warning .navbar-end .navbar-link:hover, - .navbar.is-warning .navbar-end .navbar-link.is-active { - background-color: #ffd83d; - color: rgba(0, 0, 0, 0.7); } - .navbar.is-warning .navbar-start .navbar-link::after, - .navbar.is-warning .navbar-end .navbar-link::after { - border-color: rgba(0, 0, 0, 0.7); } - .navbar.is-warning .navbar-item.has-dropdown:hover .navbar-link, - .navbar.is-warning .navbar-item.has-dropdown.is-active .navbar-link { - background-color: #ffd83d; - color: rgba(0, 0, 0, 0.7); } - .navbar.is-warning .navbar-dropdown a.navbar-item.is-active { - background-color: #ffdd57; - color: rgba(0, 0, 0, 0.7); } } - .navbar.is-danger { - background-color: #ff3860; - color: #fff; } - .navbar.is-danger .navbar-brand > .navbar-item, - .navbar.is-danger .navbar-brand .navbar-link { - color: #fff; } - .navbar.is-danger .navbar-brand > a.navbar-item:hover, .navbar.is-danger .navbar-brand > a.navbar-item.is-active, - .navbar.is-danger .navbar-brand .navbar-link:hover, - .navbar.is-danger .navbar-brand .navbar-link.is-active { - background-color: #ff1f4b; - color: #fff; } - .navbar.is-danger .navbar-brand .navbar-link::after { - border-color: #fff; } - @media screen and (min-width: 1088px) { - .navbar.is-danger .navbar-start > .navbar-item, - .navbar.is-danger .navbar-start .navbar-link, - .navbar.is-danger .navbar-end > .navbar-item, - .navbar.is-danger .navbar-end .navbar-link { - color: #fff; } - .navbar.is-danger .navbar-start > a.navbar-item:hover, .navbar.is-danger .navbar-start > a.navbar-item.is-active, - .navbar.is-danger .navbar-start .navbar-link:hover, - .navbar.is-danger .navbar-start .navbar-link.is-active, - .navbar.is-danger .navbar-end > a.navbar-item:hover, - .navbar.is-danger .navbar-end > a.navbar-item.is-active, - .navbar.is-danger .navbar-end .navbar-link:hover, - .navbar.is-danger .navbar-end .navbar-link.is-active { - background-color: #ff1f4b; - color: #fff; } - .navbar.is-danger .navbar-start .navbar-link::after, - .navbar.is-danger .navbar-end .navbar-link::after { - border-color: #fff; } - .navbar.is-danger .navbar-item.has-dropdown:hover .navbar-link, - .navbar.is-danger .navbar-item.has-dropdown.is-active .navbar-link { - background-color: #ff1f4b; - color: #fff; } - .navbar.is-danger .navbar-dropdown a.navbar-item.is-active { - background-color: #ff3860; - color: #fff; } } - .navbar > .container { - align-items: stretch; - display: flex; - min-height: 3.25rem; - width: 100%; } - .navbar.has-shadow { - box-shadow: 0 2px 0 0 whitesmoke; } - .navbar.is-fixed-bottom, .navbar.is-fixed-top { - left: 0; - position: fixed; - right: 0; - z-index: 30; } - .navbar.is-fixed-bottom { - bottom: 0; } - .navbar.is-fixed-bottom.has-shadow { - box-shadow: 0 -2px 0 0 whitesmoke; } - .navbar.is-fixed-top { - top: 0; } - -html.has-navbar-fixed-top, -body.has-navbar-fixed-top { - padding-top: 3.25rem; } - -html.has-navbar-fixed-bottom, -body.has-navbar-fixed-bottom { - padding-bottom: 3.25rem; } - -.navbar-brand, -.navbar-tabs { - align-items: stretch; - display: flex; - flex-shrink: 0; - min-height: 3.25rem; } - -.navbar-brand a.navbar-item:hover { - background-color: transparent; } - -.navbar-tabs { - -webkit-overflow-scrolling: touch; - max-width: 100vw; - overflow-x: auto; - overflow-y: hidden; } - -.navbar-burger { - cursor: pointer; - display: block; - height: 3.25rem; - position: relative; - width: 3.25rem; - margin-left: auto; } - .navbar-burger span { - background-color: currentColor; - display: block; - height: 1px; - left: calc(50% - 8px); - position: absolute; - transform-origin: center; - transition-duration: 86ms; - transition-property: background-color, opacity, transform; - transition-timing-function: ease-out; - width: 16px; } - .navbar-burger span:nth-child(1) { - top: calc(50% - 6px); } - .navbar-burger span:nth-child(2) { - top: calc(50% - 1px); } - .navbar-burger span:nth-child(3) { - top: calc(50% + 4px); } - .navbar-burger:hover { - background-color: rgba(0, 0, 0, 0.05); } - .navbar-burger.is-active span:nth-child(1) { - transform: translateY(5px) rotate(45deg); } - .navbar-burger.is-active span:nth-child(2) { - opacity: 0; } - .navbar-burger.is-active span:nth-child(3) { - transform: translateY(-5px) rotate(-45deg); } - -.navbar-menu { - display: none; } - -.navbar-item, -.navbar-link { - color: #4a4a4a; - display: block; - line-height: 1.5; - padding: 0.5rem 0.75rem; - position: relative; } - .navbar-item .icon:only-child, - .navbar-link .icon:only-child { - margin-left: -0.25rem; - margin-right: -0.25rem; } - -a.navbar-item, -.navbar-link { - cursor: pointer; } - a.navbar-item:hover, a.navbar-item.is-active, - .navbar-link:hover, - .navbar-link.is-active { - background-color: #fafafa; - color: #33B2E8; } - -.navbar-item { - display: block; - flex-grow: 0; - flex-shrink: 0; } - .navbar-item img { - max-height: 1.75rem; } - .navbar-item.has-dropdown { - padding: 0; } - .navbar-item.is-expanded { - flex-grow: 1; - flex-shrink: 1; } - .navbar-item.is-tab { - border-bottom: 1px solid transparent; - min-height: 3.25rem; - padding-bottom: calc(0.5rem - 1px); } - .navbar-item.is-tab:hover { - background-color: transparent; - border-bottom-color: #33B2E8; } - .navbar-item.is-tab.is-active { - background-color: transparent; - border-bottom-color: #33B2E8; - border-bottom-style: solid; - border-bottom-width: 3px; - color: #33B2E8; - padding-bottom: calc(0.5rem - 3px); } - -.navbar-content { - flex-grow: 1; - flex-shrink: 1; } - -.navbar-link { - padding-right: 2.5em; } - .navbar-link::after { - border-color: #33B2E8; - margin-top: -0.375em; - right: 1.125em; } - -.navbar-dropdown { - font-size: 0.875rem; - padding-bottom: 0.5rem; - padding-top: 0.5rem; } - .navbar-dropdown .navbar-item { - padding-left: 1.5rem; - padding-right: 1.5rem; } - -.navbar-divider { - background-color: whitesmoke; - border: none; - display: none; - height: 2px; - margin: 0.5rem 0; } - -@media screen and (max-width: 1087px) { - .navbar > .container { - display: block; } - .navbar-brand .navbar-item, - .navbar-tabs .navbar-item { - align-items: center; - display: flex; } - .navbar-link::after { - display: none; } - .navbar-menu { - background-color: white; - box-shadow: 0 8px 16px rgba(10, 10, 10, 0.1); - padding: 0.5rem 0; } - .navbar-menu.is-active { - display: block; } - .navbar.is-fixed-bottom-touch, .navbar.is-fixed-top-touch { - left: 0; - position: fixed; - right: 0; - z-index: 30; } - .navbar.is-fixed-bottom-touch { - bottom: 0; } - .navbar.is-fixed-bottom-touch.has-shadow { - box-shadow: 0 -2px 3px rgba(10, 10, 10, 0.1); } - .navbar.is-fixed-top-touch { - top: 0; } - .navbar.is-fixed-top .navbar-menu, .navbar.is-fixed-top-touch .navbar-menu { - -webkit-overflow-scrolling: touch; - max-height: calc(100vh - 3.25rem); - overflow: auto; } - html.has-navbar-fixed-top-touch, - body.has-navbar-fixed-top-touch { - padding-top: 3.25rem; } - html.has-navbar-fixed-bottom-touch, - body.has-navbar-fixed-bottom-touch { - padding-bottom: 3.25rem; } } - -@media screen and (min-width: 1088px) { - .navbar, - .navbar-menu, - .navbar-start, - .navbar-end { - align-items: stretch; - display: flex; } - .navbar { - min-height: 3.25rem; } - .navbar.is-spaced { - padding: 1rem 2rem; } - .navbar.is-spaced .navbar-start, - .navbar.is-spaced .navbar-end { - align-items: center; } - .navbar.is-spaced a.navbar-item, - .navbar.is-spaced .navbar-link { - border-radius: 4px; } - .navbar.is-transparent a.navbar-item:hover, .navbar.is-transparent a.navbar-item.is-active, - .navbar.is-transparent .navbar-link:hover, - .navbar.is-transparent .navbar-link.is-active { - background-color: transparent !important; } - .navbar.is-transparent .navbar-item.has-dropdown.is-active .navbar-link, .navbar.is-transparent .navbar-item.has-dropdown.is-hoverable:hover .navbar-link { - background-color: transparent !important; } - .navbar.is-transparent .navbar-dropdown a.navbar-item:hover { - background-color: whitesmoke; - color: #0a0a0a; } - .navbar.is-transparent .navbar-dropdown a.navbar-item.is-active { - background-color: whitesmoke; - color: #33B2E8; } - .navbar-burger { - display: none; } - .navbar-item, - .navbar-link { - align-items: center; - display: flex; } - .navbar-item { - display: flex; } - .navbar-item.has-dropdown { - align-items: stretch; } - .navbar-item.has-dropdown-up .navbar-link::after { - transform: rotate(135deg) translate(0.25em, -0.25em); } - .navbar-item.has-dropdown-up .navbar-dropdown { - border-bottom: 2px solid #dbdbdb; - border-radius: 6px 6px 0 0; - border-top: none; - bottom: 100%; - box-shadow: 0 -8px 8px rgba(10, 10, 10, 0.1); - top: auto; } - .navbar-item.is-active .navbar-dropdown, .navbar-item.is-hoverable:hover .navbar-dropdown { - display: block; } - .navbar.is-spaced .navbar-item.is-active .navbar-dropdown, .navbar-item.is-active .navbar-dropdown.is-boxed, .navbar.is-spaced .navbar-item.is-hoverable:hover .navbar-dropdown, .navbar-item.is-hoverable:hover .navbar-dropdown.is-boxed { - opacity: 1; - pointer-events: auto; - transform: translateY(0); } - .navbar-menu { - flex-grow: 1; - flex-shrink: 0; } - .navbar-start { - justify-content: flex-start; - margin-right: auto; } - .navbar-end { - justify-content: flex-end; - margin-left: auto; } - .navbar-dropdown { - background-color: white; - border-bottom-left-radius: 6px; - border-bottom-right-radius: 6px; - border-top: 2px solid #dbdbdb; - box-shadow: 0 8px 8px rgba(10, 10, 10, 0.1); - display: none; - font-size: 0.875rem; - left: 0; - min-width: 100%; - position: absolute; - top: 100%; - z-index: 20; } - .navbar-dropdown .navbar-item { - padding: 0.375rem 1rem; - white-space: nowrap; } - .navbar-dropdown a.navbar-item { - padding-right: 3rem; } - .navbar-dropdown a.navbar-item:hover { - background-color: whitesmoke; - color: #0a0a0a; } - .navbar-dropdown a.navbar-item.is-active { - background-color: whitesmoke; - color: #33B2E8; } - .navbar.is-spaced .navbar-dropdown, .navbar-dropdown.is-boxed { - border-radius: 6px; - border-top: none; - box-shadow: 0 8px 8px rgba(10, 10, 10, 0.1), 0 0 0 1px rgba(10, 10, 10, 0.1); - display: block; - opacity: 0; - pointer-events: none; - top: calc(100% + (-4px)); - transform: translateY(-5px); - transition-duration: 86ms; - transition-property: opacity, transform; } - .navbar-dropdown.is-right { - left: auto; - right: 0; } - .navbar-divider { - display: block; } - .navbar > .container .navbar-brand, - .container > .navbar .navbar-brand { - margin-left: -1rem; } - .navbar > .container .navbar-menu, - .container > .navbar .navbar-menu { - margin-right: -1rem; } - .navbar.is-fixed-bottom-desktop, .navbar.is-fixed-top-desktop { - left: 0; - position: fixed; - right: 0; - z-index: 30; } - .navbar.is-fixed-bottom-desktop { - bottom: 0; } - .navbar.is-fixed-bottom-desktop.has-shadow { - box-shadow: 0 -2px 3px rgba(10, 10, 10, 0.1); } - .navbar.is-fixed-top-desktop { - top: 0; } - html.has-navbar-fixed-top-desktop, - body.has-navbar-fixed-top-desktop { - padding-top: 3.25rem; } - html.has-navbar-fixed-bottom-desktop, - body.has-navbar-fixed-bottom-desktop { - padding-bottom: 3.25rem; } - html.has-spaced-navbar-fixed-top, - body.has-spaced-navbar-fixed-top { - padding-top: 5.25rem; } - html.has-spaced-navbar-fixed-bottom, - body.has-spaced-navbar-fixed-bottom { - padding-bottom: 5.25rem; } - a.navbar-item.is-active, - .navbar-link.is-active { - color: #0a0a0a; } - a.navbar-item.is-active:not(:hover), - .navbar-link.is-active:not(:hover) { - background-color: transparent; } - .navbar-item.has-dropdown:hover .navbar-link, .navbar-item.has-dropdown.is-active .navbar-link { - background-color: #fafafa; } } - -.pagination { - font-size: 1rem; - margin: -0.25rem; } - .pagination.is-small { - font-size: 0.75rem; } - .pagination.is-medium { - font-size: 1.25rem; } - .pagination.is-large { - font-size: 1.5rem; } - .pagination.is-rounded .pagination-previous, - .pagination.is-rounded .pagination-next { - padding-left: 1em; - padding-right: 1em; - border-radius: 290486px; } - .pagination.is-rounded .pagination-link { - border-radius: 290486px; } - -.pagination, -.pagination-list { - align-items: center; - display: flex; - justify-content: center; - text-align: center; } - -.pagination-previous, -.pagination-next, -.pagination-link, -.pagination-ellipsis { - font-size: 1em; - padding-left: 0.5em; - padding-right: 0.5em; - justify-content: center; - margin: 0.25rem; - text-align: center; } - -.pagination-previous, -.pagination-next, -.pagination-link { - border-color: #dbdbdb; - color: #363636; - min-width: 2.25em; } - .pagination-previous:hover, - .pagination-next:hover, - .pagination-link:hover { - border-color: #b5b5b5; - color: #363636; } - .pagination-previous:focus, - .pagination-next:focus, - .pagination-link:focus { - border-color: #33B2E8; } - .pagination-previous:active, - .pagination-next:active, - .pagination-link:active { - box-shadow: inset 0 1px 2px rgba(10, 10, 10, 0.2); } - .pagination-previous[disabled], - .pagination-next[disabled], - .pagination-link[disabled] { - background-color: #dbdbdb; - border-color: #dbdbdb; - box-shadow: none; - color: #7a7a7a; - opacity: 0.5; } - -.pagination-previous, -.pagination-next { - padding-left: 0.75em; - padding-right: 0.75em; - white-space: nowrap; } - -.pagination-link.is-current { - background-color: #33B2E8; - border-color: #33B2E8; - color: #fff; } - -.pagination-ellipsis { - color: #b5b5b5; - pointer-events: none; } - -.pagination-list { - flex-wrap: wrap; } - -@media screen and (max-width: 768px) { - .pagination { - flex-wrap: wrap; } - .pagination-previous, - .pagination-next { - flex-grow: 1; - flex-shrink: 1; } - .pagination-list li { - flex-grow: 1; - flex-shrink: 1; } } - -@media screen and (min-width: 769px), print { - .pagination-list { - flex-grow: 1; - flex-shrink: 1; - justify-content: flex-start; - order: 1; } - .pagination-previous { - order: 2; } - .pagination-next { - order: 3; } - .pagination { - justify-content: space-between; } - .pagination.is-centered .pagination-previous { - order: 1; } - .pagination.is-centered .pagination-list { - justify-content: center; - order: 2; } - .pagination.is-centered .pagination-next { - order: 3; } - .pagination.is-right .pagination-previous { - order: 1; } - .pagination.is-right .pagination-next { - order: 2; } - .pagination.is-right .pagination-list { - justify-content: flex-end; - order: 3; } } - -.panel { - font-size: 1rem; } - .panel:not(:last-child) { - margin-bottom: 1.5rem; } - -.panel-heading, -.panel-tabs, -.panel-block { - border-bottom: 1px solid #dbdbdb; - border-left: 1px solid #dbdbdb; - border-right: 1px solid #dbdbdb; } - .panel-heading:first-child, - .panel-tabs:first-child, - .panel-block:first-child { - border-top: 1px solid #dbdbdb; } - -.panel-heading { - background-color: whitesmoke; - border-radius: 4px 4px 0 0; - color: #363636; - font-size: 1.25em; - font-weight: 300; - line-height: 1.25; - padding: 0.5em 0.75em; } - -.panel-tabs { - align-items: flex-end; - display: flex; - font-size: 0.875em; - justify-content: center; } - .panel-tabs a { - border-bottom: 1px solid #dbdbdb; - margin-bottom: -1px; - padding: 0.5em; } - .panel-tabs a.is-active { - border-bottom-color: #4a4a4a; - color: #363636; } - -.panel-list a { - color: #4a4a4a; } - .panel-list a:hover { - color: #33B2E8; } - -.panel-block { - align-items: center; - color: #363636; - display: flex; - justify-content: flex-start; - padding: 0.5em 0.75em; } - .panel-block input[type="checkbox"] { - margin-right: 0.75em; } - .panel-block > .control { - flex-grow: 1; - flex-shrink: 1; - width: 100%; } - .panel-block.is-wrapped { - flex-wrap: wrap; } - .panel-block.is-active { - border-left-color: #33B2E8; - color: #363636; } - .panel-block.is-active .panel-icon { - color: #33B2E8; } - -a.panel-block, -label.panel-block { - cursor: pointer; } - a.panel-block:hover, - label.panel-block:hover { - background-color: whitesmoke; } - -.panel-icon { - display: inline-block; - font-size: 14px; - height: 1em; - line-height: 1em; - text-align: center; - vertical-align: top; - width: 1em; - color: #7a7a7a; - margin-right: 0.75em; } - .panel-icon .fa { - font-size: inherit; - line-height: inherit; } - -.tabs { - -webkit-overflow-scrolling: touch; - align-items: stretch; - display: flex; - font-size: 1rem; - justify-content: space-between; - overflow: hidden; - overflow-x: auto; - white-space: nowrap; } - .tabs a { - align-items: center; - border-bottom-color: #dbdbdb; - border-bottom-style: solid; - border-bottom-width: 1px; - color: #4a4a4a; - display: flex; - justify-content: center; - margin-bottom: -1px; - padding: 0.5em 1em; - vertical-align: top; } - .tabs a:hover { - border-bottom-color: #363636; - color: #363636; } - .tabs li { - display: block; } - .tabs li.is-active a { - border-bottom-color: #33B2E8; - color: #33B2E8; } - .tabs ul { - align-items: center; - border-bottom-color: #dbdbdb; - border-bottom-style: solid; - border-bottom-width: 1px; - display: flex; - flex-grow: 1; - flex-shrink: 0; - justify-content: flex-start; } - .tabs ul.is-left { - padding-right: 0.75em; } - .tabs ul.is-center { - flex: none; - justify-content: center; - padding-left: 0.75em; - padding-right: 0.75em; } - .tabs ul.is-right { - justify-content: flex-end; - padding-left: 0.75em; } - .tabs .icon:first-child { - margin-right: 0.5em; } - .tabs .icon:last-child { - margin-left: 0.5em; } - .tabs.is-centered ul { - justify-content: center; } - .tabs.is-right ul { - justify-content: flex-end; } - .tabs.is-boxed a { - border: 1px solid transparent; - border-radius: 4px 4px 0 0; } - .tabs.is-boxed a:hover { - background-color: whitesmoke; - border-bottom-color: #dbdbdb; } - .tabs.is-boxed li.is-active a { - background-color: white; - border-color: #dbdbdb; - border-bottom-color: transparent !important; } - .tabs.is-fullwidth li { - flex-grow: 1; - flex-shrink: 0; } - .tabs.is-toggle a { - border-color: #dbdbdb; - border-style: solid; - border-width: 1px; - margin-bottom: 0; - position: relative; } - .tabs.is-toggle a:hover { - background-color: whitesmoke; - border-color: #b5b5b5; - z-index: 2; } - .tabs.is-toggle li + li { - margin-left: -1px; } - .tabs.is-toggle li:first-child a { - border-radius: 4px 0 0 4px; } - .tabs.is-toggle li:last-child a { - border-radius: 0 4px 4px 0; } - .tabs.is-toggle li.is-active a { - background-color: #33B2E8; - border-color: #33B2E8; - color: #fff; - z-index: 1; } - .tabs.is-toggle ul { - border-bottom: none; } - .tabs.is-toggle.is-toggle-rounded li:first-child a { - border-bottom-left-radius: 290486px; - border-top-left-radius: 290486px; - padding-left: 1.25em; } - .tabs.is-toggle.is-toggle-rounded li:last-child a { - border-bottom-right-radius: 290486px; - border-top-right-radius: 290486px; - padding-right: 1.25em; } - .tabs.is-small { - font-size: 0.75rem; } - .tabs.is-medium { - font-size: 1.25rem; } - .tabs.is-large { - font-size: 1.5rem; } - -.column { - display: block; - flex-basis: 0; - flex-grow: 1; - flex-shrink: 1; - padding: 0.75rem; } - .columns.is-mobile > .column.is-narrow { - flex: none; } - .columns.is-mobile > .column.is-full { - flex: none; - width: 100%; } - .columns.is-mobile > .column.is-three-quarters { - flex: none; - width: 75%; } - .columns.is-mobile > .column.is-two-thirds { - flex: none; - width: 66.6666%; } - .columns.is-mobile > .column.is-half { - flex: none; - width: 50%; } - .columns.is-mobile > .column.is-one-third { - flex: none; - width: 33.3333%; } - .columns.is-mobile > .column.is-one-quarter { - flex: none; - width: 25%; } - .columns.is-mobile > .column.is-one-fifth { - flex: none; - width: 20%; } - .columns.is-mobile > .column.is-two-fifths { - flex: none; - width: 40%; } - .columns.is-mobile > .column.is-three-fifths { - flex: none; - width: 60%; } - .columns.is-mobile > .column.is-four-fifths { - flex: none; - width: 80%; } - .columns.is-mobile > .column.is-offset-three-quarters { - margin-left: 75%; } - .columns.is-mobile > .column.is-offset-two-thirds { - margin-left: 66.6666%; } - .columns.is-mobile > .column.is-offset-half { - margin-left: 50%; } - .columns.is-mobile > .column.is-offset-one-third { - margin-left: 33.3333%; } - .columns.is-mobile > .column.is-offset-one-quarter { - margin-left: 25%; } - .columns.is-mobile > .column.is-offset-one-fifth { - margin-left: 20%; } - .columns.is-mobile > .column.is-offset-two-fifths { - margin-left: 40%; } - .columns.is-mobile > .column.is-offset-three-fifths { - margin-left: 60%; } - .columns.is-mobile > .column.is-offset-four-fifths { - margin-left: 80%; } - .columns.is-mobile > .column.is-1 { - flex: none; - width: 8.33333%; } - .columns.is-mobile > .column.is-offset-1 { - margin-left: 8.33333%; } - .columns.is-mobile > .column.is-2 { - flex: none; - width: 16.66667%; } - .columns.is-mobile > .column.is-offset-2 { - margin-left: 16.66667%; } - .columns.is-mobile > .column.is-3 { - flex: none; - width: 25%; } - .columns.is-mobile > .column.is-offset-3 { - margin-left: 25%; } - .columns.is-mobile > .column.is-4 { - flex: none; - width: 33.33333%; } - .columns.is-mobile > .column.is-offset-4 { - margin-left: 33.33333%; } - .columns.is-mobile > .column.is-5 { - flex: none; - width: 41.66667%; } - .columns.is-mobile > .column.is-offset-5 { - margin-left: 41.66667%; } - .columns.is-mobile > .column.is-6 { - flex: none; - width: 50%; } - .columns.is-mobile > .column.is-offset-6 { - margin-left: 50%; } - .columns.is-mobile > .column.is-7 { - flex: none; - width: 58.33333%; } - .columns.is-mobile > .column.is-offset-7 { - margin-left: 58.33333%; } - .columns.is-mobile > .column.is-8 { - flex: none; - width: 66.66667%; } - .columns.is-mobile > .column.is-offset-8 { - margin-left: 66.66667%; } - .columns.is-mobile > .column.is-9 { - flex: none; - width: 75%; } - .columns.is-mobile > .column.is-offset-9 { - margin-left: 75%; } - .columns.is-mobile > .column.is-10 { - flex: none; - width: 83.33333%; } - .columns.is-mobile > .column.is-offset-10 { - margin-left: 83.33333%; } - .columns.is-mobile > .column.is-11 { - flex: none; - width: 91.66667%; } - .columns.is-mobile > .column.is-offset-11 { - margin-left: 91.66667%; } - .columns.is-mobile > .column.is-12 { - flex: none; - width: 100%; } - .columns.is-mobile > .column.is-offset-12 { - margin-left: 100%; } - @media screen and (max-width: 768px) { - .column.is-narrow-mobile { - flex: none; } - .column.is-full-mobile { - flex: none; - width: 100%; } - .column.is-three-quarters-mobile { - flex: none; - width: 75%; } - .column.is-two-thirds-mobile { - flex: none; - width: 66.6666%; } - .column.is-half-mobile { - flex: none; - width: 50%; } - .column.is-one-third-mobile { - flex: none; - width: 33.3333%; } - .column.is-one-quarter-mobile { - flex: none; - width: 25%; } - .column.is-one-fifth-mobile { - flex: none; - width: 20%; } - .column.is-two-fifths-mobile { - flex: none; - width: 40%; } - .column.is-three-fifths-mobile { - flex: none; - width: 60%; } - .column.is-four-fifths-mobile { - flex: none; - width: 80%; } - .column.is-offset-three-quarters-mobile { - margin-left: 75%; } - .column.is-offset-two-thirds-mobile { - margin-left: 66.6666%; } - .column.is-offset-half-mobile { - margin-left: 50%; } - .column.is-offset-one-third-mobile { - margin-left: 33.3333%; } - .column.is-offset-one-quarter-mobile { - margin-left: 25%; } - .column.is-offset-one-fifth-mobile { - margin-left: 20%; } - .column.is-offset-two-fifths-mobile { - margin-left: 40%; } - .column.is-offset-three-fifths-mobile { - margin-left: 60%; } - .column.is-offset-four-fifths-mobile { - margin-left: 80%; } - .column.is-1-mobile { - flex: none; - width: 8.33333%; } - .column.is-offset-1-mobile { - margin-left: 8.33333%; } - .column.is-2-mobile { - flex: none; - width: 16.66667%; } - .column.is-offset-2-mobile { - margin-left: 16.66667%; } - .column.is-3-mobile { - flex: none; - width: 25%; } - .column.is-offset-3-mobile { - margin-left: 25%; } - .column.is-4-mobile { - flex: none; - width: 33.33333%; } - .column.is-offset-4-mobile { - margin-left: 33.33333%; } - .column.is-5-mobile { - flex: none; - width: 41.66667%; } - .column.is-offset-5-mobile { - margin-left: 41.66667%; } - .column.is-6-mobile { - flex: none; - width: 50%; } - .column.is-offset-6-mobile { - margin-left: 50%; } - .column.is-7-mobile { - flex: none; - width: 58.33333%; } - .column.is-offset-7-mobile { - margin-left: 58.33333%; } - .column.is-8-mobile { - flex: none; - width: 66.66667%; } - .column.is-offset-8-mobile { - margin-left: 66.66667%; } - .column.is-9-mobile { - flex: none; - width: 75%; } - .column.is-offset-9-mobile { - margin-left: 75%; } - .column.is-10-mobile { - flex: none; - width: 83.33333%; } - .column.is-offset-10-mobile { - margin-left: 83.33333%; } - .column.is-11-mobile { - flex: none; - width: 91.66667%; } - .column.is-offset-11-mobile { - margin-left: 91.66667%; } - .column.is-12-mobile { - flex: none; - width: 100%; } - .column.is-offset-12-mobile { - margin-left: 100%; } } - @media screen and (min-width: 769px), print { - .column.is-narrow, .column.is-narrow-tablet { - flex: none; } - .column.is-full, .column.is-full-tablet { - flex: none; - width: 100%; } - .column.is-three-quarters, .column.is-three-quarters-tablet { - flex: none; - width: 75%; } - .column.is-two-thirds, .column.is-two-thirds-tablet { - flex: none; - width: 66.6666%; } - .column.is-half, .column.is-half-tablet { - flex: none; - width: 50%; } - .column.is-one-third, .column.is-one-third-tablet { - flex: none; - width: 33.3333%; } - .column.is-one-quarter, .column.is-one-quarter-tablet { - flex: none; - width: 25%; } - .column.is-one-fifth, .column.is-one-fifth-tablet { - flex: none; - width: 20%; } - .column.is-two-fifths, .column.is-two-fifths-tablet { - flex: none; - width: 40%; } - .column.is-three-fifths, .column.is-three-fifths-tablet { - flex: none; - width: 60%; } - .column.is-four-fifths, .column.is-four-fifths-tablet { - flex: none; - width: 80%; } - .column.is-offset-three-quarters, .column.is-offset-three-quarters-tablet { - margin-left: 75%; } - .column.is-offset-two-thirds, .column.is-offset-two-thirds-tablet { - margin-left: 66.6666%; } - .column.is-offset-half, .column.is-offset-half-tablet { - margin-left: 50%; } - .column.is-offset-one-third, .column.is-offset-one-third-tablet { - margin-left: 33.3333%; } - .column.is-offset-one-quarter, .column.is-offset-one-quarter-tablet { - margin-left: 25%; } - .column.is-offset-one-fifth, .column.is-offset-one-fifth-tablet { - margin-left: 20%; } - .column.is-offset-two-fifths, .column.is-offset-two-fifths-tablet { - margin-left: 40%; } - .column.is-offset-three-fifths, .column.is-offset-three-fifths-tablet { - margin-left: 60%; } - .column.is-offset-four-fifths, .column.is-offset-four-fifths-tablet { - margin-left: 80%; } - .column.is-1, .column.is-1-tablet { - flex: none; - width: 8.33333%; } - .column.is-offset-1, .column.is-offset-1-tablet { - margin-left: 8.33333%; } - .column.is-2, .column.is-2-tablet { - flex: none; - width: 16.66667%; } - .column.is-offset-2, .column.is-offset-2-tablet { - margin-left: 16.66667%; } - .column.is-3, .column.is-3-tablet { - flex: none; - width: 25%; } - .column.is-offset-3, .column.is-offset-3-tablet { - margin-left: 25%; } - .column.is-4, .column.is-4-tablet { - flex: none; - width: 33.33333%; } - .column.is-offset-4, .column.is-offset-4-tablet { - margin-left: 33.33333%; } - .column.is-5, .column.is-5-tablet { - flex: none; - width: 41.66667%; } - .column.is-offset-5, .column.is-offset-5-tablet { - margin-left: 41.66667%; } - .column.is-6, .column.is-6-tablet { - flex: none; - width: 50%; } - .column.is-offset-6, .column.is-offset-6-tablet { - margin-left: 50%; } - .column.is-7, .column.is-7-tablet { - flex: none; - width: 58.33333%; } - .column.is-offset-7, .column.is-offset-7-tablet { - margin-left: 58.33333%; } - .column.is-8, .column.is-8-tablet { - flex: none; - width: 66.66667%; } - .column.is-offset-8, .column.is-offset-8-tablet { - margin-left: 66.66667%; } - .column.is-9, .column.is-9-tablet { - flex: none; - width: 75%; } - .column.is-offset-9, .column.is-offset-9-tablet { - margin-left: 75%; } - .column.is-10, .column.is-10-tablet { - flex: none; - width: 83.33333%; } - .column.is-offset-10, .column.is-offset-10-tablet { - margin-left: 83.33333%; } - .column.is-11, .column.is-11-tablet { - flex: none; - width: 91.66667%; } - .column.is-offset-11, .column.is-offset-11-tablet { - margin-left: 91.66667%; } - .column.is-12, .column.is-12-tablet { - flex: none; - width: 100%; } - .column.is-offset-12, .column.is-offset-12-tablet { - margin-left: 100%; } } - @media screen and (max-width: 1087px) { - .column.is-narrow-touch { - flex: none; } - .column.is-full-touch { - flex: none; - width: 100%; } - .column.is-three-quarters-touch { - flex: none; - width: 75%; } - .column.is-two-thirds-touch { - flex: none; - width: 66.6666%; } - .column.is-half-touch { - flex: none; - width: 50%; } - .column.is-one-third-touch { - flex: none; - width: 33.3333%; } - .column.is-one-quarter-touch { - flex: none; - width: 25%; } - .column.is-one-fifth-touch { - flex: none; - width: 20%; } - .column.is-two-fifths-touch { - flex: none; - width: 40%; } - .column.is-three-fifths-touch { - flex: none; - width: 60%; } - .column.is-four-fifths-touch { - flex: none; - width: 80%; } - .column.is-offset-three-quarters-touch { - margin-left: 75%; } - .column.is-offset-two-thirds-touch { - margin-left: 66.6666%; } - .column.is-offset-half-touch { - margin-left: 50%; } - .column.is-offset-one-third-touch { - margin-left: 33.3333%; } - .column.is-offset-one-quarter-touch { - margin-left: 25%; } - .column.is-offset-one-fifth-touch { - margin-left: 20%; } - .column.is-offset-two-fifths-touch { - margin-left: 40%; } - .column.is-offset-three-fifths-touch { - margin-left: 60%; } - .column.is-offset-four-fifths-touch { - margin-left: 80%; } - .column.is-1-touch { - flex: none; - width: 8.33333%; } - .column.is-offset-1-touch { - margin-left: 8.33333%; } - .column.is-2-touch { - flex: none; - width: 16.66667%; } - .column.is-offset-2-touch { - margin-left: 16.66667%; } - .column.is-3-touch { - flex: none; - width: 25%; } - .column.is-offset-3-touch { - margin-left: 25%; } - .column.is-4-touch { - flex: none; - width: 33.33333%; } - .column.is-offset-4-touch { - margin-left: 33.33333%; } - .column.is-5-touch { - flex: none; - width: 41.66667%; } - .column.is-offset-5-touch { - margin-left: 41.66667%; } - .column.is-6-touch { - flex: none; - width: 50%; } - .column.is-offset-6-touch { - margin-left: 50%; } - .column.is-7-touch { - flex: none; - width: 58.33333%; } - .column.is-offset-7-touch { - margin-left: 58.33333%; } - .column.is-8-touch { - flex: none; - width: 66.66667%; } - .column.is-offset-8-touch { - margin-left: 66.66667%; } - .column.is-9-touch { - flex: none; - width: 75%; } - .column.is-offset-9-touch { - margin-left: 75%; } - .column.is-10-touch { - flex: none; - width: 83.33333%; } - .column.is-offset-10-touch { - margin-left: 83.33333%; } - .column.is-11-touch { - flex: none; - width: 91.66667%; } - .column.is-offset-11-touch { - margin-left: 91.66667%; } - .column.is-12-touch { - flex: none; - width: 100%; } - .column.is-offset-12-touch { - margin-left: 100%; } } - @media screen and (min-width: 1088px) { - .column.is-narrow-desktop { - flex: none; } - .column.is-full-desktop { - flex: none; - width: 100%; } - .column.is-three-quarters-desktop { - flex: none; - width: 75%; } - .column.is-two-thirds-desktop { - flex: none; - width: 66.6666%; } - .column.is-half-desktop { - flex: none; - width: 50%; } - .column.is-one-third-desktop { - flex: none; - width: 33.3333%; } - .column.is-one-quarter-desktop { - flex: none; - width: 25%; } - .column.is-one-fifth-desktop { - flex: none; - width: 20%; } - .column.is-two-fifths-desktop { - flex: none; - width: 40%; } - .column.is-three-fifths-desktop { - flex: none; - width: 60%; } - .column.is-four-fifths-desktop { - flex: none; - width: 80%; } - .column.is-offset-three-quarters-desktop { - margin-left: 75%; } - .column.is-offset-two-thirds-desktop { - margin-left: 66.6666%; } - .column.is-offset-half-desktop { - margin-left: 50%; } - .column.is-offset-one-third-desktop { - margin-left: 33.3333%; } - .column.is-offset-one-quarter-desktop { - margin-left: 25%; } - .column.is-offset-one-fifth-desktop { - margin-left: 20%; } - .column.is-offset-two-fifths-desktop { - margin-left: 40%; } - .column.is-offset-three-fifths-desktop { - margin-left: 60%; } - .column.is-offset-four-fifths-desktop { - margin-left: 80%; } - .column.is-1-desktop { - flex: none; - width: 8.33333%; } - .column.is-offset-1-desktop { - margin-left: 8.33333%; } - .column.is-2-desktop { - flex: none; - width: 16.66667%; } - .column.is-offset-2-desktop { - margin-left: 16.66667%; } - .column.is-3-desktop { - flex: none; - width: 25%; } - .column.is-offset-3-desktop { - margin-left: 25%; } - .column.is-4-desktop { - flex: none; - width: 33.33333%; } - .column.is-offset-4-desktop { - margin-left: 33.33333%; } - .column.is-5-desktop { - flex: none; - width: 41.66667%; } - .column.is-offset-5-desktop { - margin-left: 41.66667%; } - .column.is-6-desktop { - flex: none; - width: 50%; } - .column.is-offset-6-desktop { - margin-left: 50%; } - .column.is-7-desktop { - flex: none; - width: 58.33333%; } - .column.is-offset-7-desktop { - margin-left: 58.33333%; } - .column.is-8-desktop { - flex: none; - width: 66.66667%; } - .column.is-offset-8-desktop { - margin-left: 66.66667%; } - .column.is-9-desktop { - flex: none; - width: 75%; } - .column.is-offset-9-desktop { - margin-left: 75%; } - .column.is-10-desktop { - flex: none; - width: 83.33333%; } - .column.is-offset-10-desktop { - margin-left: 83.33333%; } - .column.is-11-desktop { - flex: none; - width: 91.66667%; } - .column.is-offset-11-desktop { - margin-left: 91.66667%; } - .column.is-12-desktop { - flex: none; - width: 100%; } - .column.is-offset-12-desktop { - margin-left: 100%; } } - @media screen and (min-width: 1280px) { - .column.is-narrow-widescreen { - flex: none; } - .column.is-full-widescreen { - flex: none; - width: 100%; } - .column.is-three-quarters-widescreen { - flex: none; - width: 75%; } - .column.is-two-thirds-widescreen { - flex: none; - width: 66.6666%; } - .column.is-half-widescreen { - flex: none; - width: 50%; } - .column.is-one-third-widescreen { - flex: none; - width: 33.3333%; } - .column.is-one-quarter-widescreen { - flex: none; - width: 25%; } - .column.is-one-fifth-widescreen { - flex: none; - width: 20%; } - .column.is-two-fifths-widescreen { - flex: none; - width: 40%; } - .column.is-three-fifths-widescreen { - flex: none; - width: 60%; } - .column.is-four-fifths-widescreen { - flex: none; - width: 80%; } - .column.is-offset-three-quarters-widescreen { - margin-left: 75%; } - .column.is-offset-two-thirds-widescreen { - margin-left: 66.6666%; } - .column.is-offset-half-widescreen { - margin-left: 50%; } - .column.is-offset-one-third-widescreen { - margin-left: 33.3333%; } - .column.is-offset-one-quarter-widescreen { - margin-left: 25%; } - .column.is-offset-one-fifth-widescreen { - margin-left: 20%; } - .column.is-offset-two-fifths-widescreen { - margin-left: 40%; } - .column.is-offset-three-fifths-widescreen { - margin-left: 60%; } - .column.is-offset-four-fifths-widescreen { - margin-left: 80%; } - .column.is-1-widescreen { - flex: none; - width: 8.33333%; } - .column.is-offset-1-widescreen { - margin-left: 8.33333%; } - .column.is-2-widescreen { - flex: none; - width: 16.66667%; } - .column.is-offset-2-widescreen { - margin-left: 16.66667%; } - .column.is-3-widescreen { - flex: none; - width: 25%; } - .column.is-offset-3-widescreen { - margin-left: 25%; } - .column.is-4-widescreen { - flex: none; - width: 33.33333%; } - .column.is-offset-4-widescreen { - margin-left: 33.33333%; } - .column.is-5-widescreen { - flex: none; - width: 41.66667%; } - .column.is-offset-5-widescreen { - margin-left: 41.66667%; } - .column.is-6-widescreen { - flex: none; - width: 50%; } - .column.is-offset-6-widescreen { - margin-left: 50%; } - .column.is-7-widescreen { - flex: none; - width: 58.33333%; } - .column.is-offset-7-widescreen { - margin-left: 58.33333%; } - .column.is-8-widescreen { - flex: none; - width: 66.66667%; } - .column.is-offset-8-widescreen { - margin-left: 66.66667%; } - .column.is-9-widescreen { - flex: none; - width: 75%; } - .column.is-offset-9-widescreen { - margin-left: 75%; } - .column.is-10-widescreen { - flex: none; - width: 83.33333%; } - .column.is-offset-10-widescreen { - margin-left: 83.33333%; } - .column.is-11-widescreen { - flex: none; - width: 91.66667%; } - .column.is-offset-11-widescreen { - margin-left: 91.66667%; } - .column.is-12-widescreen { - flex: none; - width: 100%; } - .column.is-offset-12-widescreen { - margin-left: 100%; } } - @media screen and (min-width: 1472px) { - .column.is-narrow-fullhd { - flex: none; } - .column.is-full-fullhd { - flex: none; - width: 100%; } - .column.is-three-quarters-fullhd { - flex: none; - width: 75%; } - .column.is-two-thirds-fullhd { - flex: none; - width: 66.6666%; } - .column.is-half-fullhd { - flex: none; - width: 50%; } - .column.is-one-third-fullhd { - flex: none; - width: 33.3333%; } - .column.is-one-quarter-fullhd { - flex: none; - width: 25%; } - .column.is-one-fifth-fullhd { - flex: none; - width: 20%; } - .column.is-two-fifths-fullhd { - flex: none; - width: 40%; } - .column.is-three-fifths-fullhd { - flex: none; - width: 60%; } - .column.is-four-fifths-fullhd { - flex: none; - width: 80%; } - .column.is-offset-three-quarters-fullhd { - margin-left: 75%; } - .column.is-offset-two-thirds-fullhd { - margin-left: 66.6666%; } - .column.is-offset-half-fullhd { - margin-left: 50%; } - .column.is-offset-one-third-fullhd { - margin-left: 33.3333%; } - .column.is-offset-one-quarter-fullhd { - margin-left: 25%; } - .column.is-offset-one-fifth-fullhd { - margin-left: 20%; } - .column.is-offset-two-fifths-fullhd { - margin-left: 40%; } - .column.is-offset-three-fifths-fullhd { - margin-left: 60%; } - .column.is-offset-four-fifths-fullhd { - margin-left: 80%; } - .column.is-1-fullhd { - flex: none; - width: 8.33333%; } - .column.is-offset-1-fullhd { - margin-left: 8.33333%; } - .column.is-2-fullhd { - flex: none; - width: 16.66667%; } - .column.is-offset-2-fullhd { - margin-left: 16.66667%; } - .column.is-3-fullhd { - flex: none; - width: 25%; } - .column.is-offset-3-fullhd { - margin-left: 25%; } - .column.is-4-fullhd { - flex: none; - width: 33.33333%; } - .column.is-offset-4-fullhd { - margin-left: 33.33333%; } - .column.is-5-fullhd { - flex: none; - width: 41.66667%; } - .column.is-offset-5-fullhd { - margin-left: 41.66667%; } - .column.is-6-fullhd { - flex: none; - width: 50%; } - .column.is-offset-6-fullhd { - margin-left: 50%; } - .column.is-7-fullhd { - flex: none; - width: 58.33333%; } - .column.is-offset-7-fullhd { - margin-left: 58.33333%; } - .column.is-8-fullhd { - flex: none; - width: 66.66667%; } - .column.is-offset-8-fullhd { - margin-left: 66.66667%; } - .column.is-9-fullhd { - flex: none; - width: 75%; } - .column.is-offset-9-fullhd { - margin-left: 75%; } - .column.is-10-fullhd { - flex: none; - width: 83.33333%; } - .column.is-offset-10-fullhd { - margin-left: 83.33333%; } - .column.is-11-fullhd { - flex: none; - width: 91.66667%; } - .column.is-offset-11-fullhd { - margin-left: 91.66667%; } - .column.is-12-fullhd { - flex: none; - width: 100%; } - .column.is-offset-12-fullhd { - margin-left: 100%; } } - -.columns { - margin-left: -0.75rem; - margin-right: -0.75rem; - margin-top: -0.75rem; } - .columns:last-child { - margin-bottom: -0.75rem; } - .columns:not(:last-child) { - margin-bottom: calc(1.5rem - 0.75rem); } - .columns.is-centered { - justify-content: center; } - .columns.is-gapless { - margin-left: 0; - margin-right: 0; - margin-top: 0; } - .columns.is-gapless > .column { - margin: 0; - padding: 0 !important; } - .columns.is-gapless:not(:last-child) { - margin-bottom: 1.5rem; } - .columns.is-gapless:last-child { - margin-bottom: 0; } - .columns.is-mobile { - display: flex; } - .columns.is-multiline { - flex-wrap: wrap; } - .columns.is-vcentered { - align-items: center; } - @media screen and (min-width: 769px), print { - .columns:not(.is-desktop) { - display: flex; } } - @media screen and (min-width: 1088px) { - .columns.is-desktop { - display: flex; } } - -.columns.is-variable { - --columnGap: 0.75rem; - margin-left: calc(-1 * var(--columnGap)); - margin-right: calc(-1 * var(--columnGap)); } - .columns.is-variable .column { - padding-left: var(--columnGap); - padding-right: var(--columnGap); } - .columns.is-variable.is-0 { - --columnGap: 0rem; } - .columns.is-variable.is-1 { - --columnGap: 0.25rem; } - .columns.is-variable.is-2 { - --columnGap: 0.5rem; } - .columns.is-variable.is-3 { - --columnGap: 0.75rem; } - .columns.is-variable.is-4 { - --columnGap: 1rem; } - .columns.is-variable.is-5 { - --columnGap: 1.25rem; } - .columns.is-variable.is-6 { - --columnGap: 1.5rem; } - .columns.is-variable.is-7 { - --columnGap: 1.75rem; } - .columns.is-variable.is-8 { - --columnGap: 2rem; } - -.tile { - align-items: stretch; - display: block; - flex-basis: 0; - flex-grow: 1; - flex-shrink: 1; - min-height: min-content; } - .tile.is-ancestor { - margin-left: -0.75rem; - margin-right: -0.75rem; - margin-top: -0.75rem; } - .tile.is-ancestor:last-child { - margin-bottom: -0.75rem; } - .tile.is-ancestor:not(:last-child) { - margin-bottom: 0.75rem; } - .tile.is-child { - margin: 0 !important; } - .tile.is-parent { - padding: 0.75rem; } - .tile.is-vertical { - flex-direction: column; } - .tile.is-vertical > .tile.is-child:not(:last-child) { - margin-bottom: 1.5rem !important; } - @media screen and (min-width: 769px), print { - .tile:not(.is-child) { - display: flex; } - .tile.is-1 { - flex: none; - width: 8.33333%; } - .tile.is-2 { - flex: none; - width: 16.66667%; } - .tile.is-3 { - flex: none; - width: 25%; } - .tile.is-4 { - flex: none; - width: 33.33333%; } - .tile.is-5 { - flex: none; - width: 41.66667%; } - .tile.is-6 { - flex: none; - width: 50%; } - .tile.is-7 { - flex: none; - width: 58.33333%; } - .tile.is-8 { - flex: none; - width: 66.66667%; } - .tile.is-9 { - flex: none; - width: 75%; } - .tile.is-10 { - flex: none; - width: 83.33333%; } - .tile.is-11 { - flex: none; - width: 91.66667%; } - .tile.is-12 { - flex: none; - width: 100%; } } - -.hero { - align-items: stretch; - display: flex; - flex-direction: column; - justify-content: space-between; } - .hero .navbar { - background: none; } - .hero .tabs ul { - border-bottom: none; } - .hero.is-white { - background-color: white; - color: #0a0a0a; } - .hero.is-white a:not(.button):not(.dropdown-item):not(.tag), - .hero.is-white strong { - color: inherit; } - .hero.is-white .title { - color: #0a0a0a; } - .hero.is-white .subtitle { - color: rgba(10, 10, 10, 0.9); } - .hero.is-white .subtitle a:not(.button), - .hero.is-white .subtitle strong { - color: #0a0a0a; } - @media screen and (max-width: 1087px) { - .hero.is-white .navbar-menu { - background-color: white; } } - .hero.is-white .navbar-item, - .hero.is-white .navbar-link { - color: rgba(10, 10, 10, 0.7); } - .hero.is-white a.navbar-item:hover, .hero.is-white a.navbar-item.is-active, - .hero.is-white .navbar-link:hover, - .hero.is-white .navbar-link.is-active { - background-color: #f2f2f2; - color: #0a0a0a; } - .hero.is-white .tabs a { - color: #0a0a0a; - opacity: 0.9; } - .hero.is-white .tabs a:hover { - opacity: 1; } - .hero.is-white .tabs li.is-active a { - opacity: 1; } - .hero.is-white .tabs.is-boxed a, .hero.is-white .tabs.is-toggle a { - color: #0a0a0a; } - .hero.is-white .tabs.is-boxed a:hover, .hero.is-white .tabs.is-toggle a:hover { - background-color: rgba(10, 10, 10, 0.1); } - .hero.is-white .tabs.is-boxed li.is-active a, .hero.is-white .tabs.is-boxed li.is-active a:hover, .hero.is-white .tabs.is-toggle li.is-active a, .hero.is-white .tabs.is-toggle li.is-active a:hover { - background-color: #0a0a0a; - border-color: #0a0a0a; - color: white; } - .hero.is-white.is-bold { - background-image: linear-gradient(141deg, #e6e6e6 0%, white 71%, white 100%); } - @media screen and (max-width: 768px) { - .hero.is-white.is-bold .navbar-menu { - background-image: linear-gradient(141deg, #e6e6e6 0%, white 71%, white 100%); } } - .hero.is-black { - background-color: #0a0a0a; - color: white; } - .hero.is-black a:not(.button):not(.dropdown-item):not(.tag), - .hero.is-black strong { - color: inherit; } - .hero.is-black .title { - color: white; } - .hero.is-black .subtitle { - color: rgba(255, 255, 255, 0.9); } - .hero.is-black .subtitle a:not(.button), - .hero.is-black .subtitle strong { - color: white; } - @media screen and (max-width: 1087px) { - .hero.is-black .navbar-menu { - background-color: #0a0a0a; } } - .hero.is-black .navbar-item, - .hero.is-black .navbar-link { - color: rgba(255, 255, 255, 0.7); } - .hero.is-black a.navbar-item:hover, .hero.is-black a.navbar-item.is-active, - .hero.is-black .navbar-link:hover, - .hero.is-black .navbar-link.is-active { - background-color: black; - color: white; } - .hero.is-black .tabs a { - color: white; - opacity: 0.9; } - .hero.is-black .tabs a:hover { - opacity: 1; } - .hero.is-black .tabs li.is-active a { - opacity: 1; } - .hero.is-black .tabs.is-boxed a, .hero.is-black .tabs.is-toggle a { - color: white; } - .hero.is-black .tabs.is-boxed a:hover, .hero.is-black .tabs.is-toggle a:hover { - background-color: rgba(10, 10, 10, 0.1); } - .hero.is-black .tabs.is-boxed li.is-active a, .hero.is-black .tabs.is-boxed li.is-active a:hover, .hero.is-black .tabs.is-toggle li.is-active a, .hero.is-black .tabs.is-toggle li.is-active a:hover { - background-color: white; - border-color: white; - color: #0a0a0a; } - .hero.is-black.is-bold { - background-image: linear-gradient(141deg, black 0%, #0a0a0a 71%, #181616 100%); } - @media screen and (max-width: 768px) { - .hero.is-black.is-bold .navbar-menu { - background-image: linear-gradient(141deg, black 0%, #0a0a0a 71%, #181616 100%); } } - .hero.is-light { - background-color: whitesmoke; - color: #363636; } - .hero.is-light a:not(.button):not(.dropdown-item):not(.tag), - .hero.is-light strong { - color: inherit; } - .hero.is-light .title { - color: #363636; } - .hero.is-light .subtitle { - color: rgba(54, 54, 54, 0.9); } - .hero.is-light .subtitle a:not(.button), - .hero.is-light .subtitle strong { - color: #363636; } - @media screen and (max-width: 1087px) { - .hero.is-light .navbar-menu { - background-color: whitesmoke; } } - .hero.is-light .navbar-item, - .hero.is-light .navbar-link { - color: rgba(54, 54, 54, 0.7); } - .hero.is-light a.navbar-item:hover, .hero.is-light a.navbar-item.is-active, - .hero.is-light .navbar-link:hover, - .hero.is-light .navbar-link.is-active { - background-color: #e8e8e8; - color: #363636; } - .hero.is-light .tabs a { - color: #363636; - opacity: 0.9; } - .hero.is-light .tabs a:hover { - opacity: 1; } - .hero.is-light .tabs li.is-active a { - opacity: 1; } - .hero.is-light .tabs.is-boxed a, .hero.is-light .tabs.is-toggle a { - color: #363636; } - .hero.is-light .tabs.is-boxed a:hover, .hero.is-light .tabs.is-toggle a:hover { - background-color: rgba(10, 10, 10, 0.1); } - .hero.is-light .tabs.is-boxed li.is-active a, .hero.is-light .tabs.is-boxed li.is-active a:hover, .hero.is-light .tabs.is-toggle li.is-active a, .hero.is-light .tabs.is-toggle li.is-active a:hover { - background-color: #363636; - border-color: #363636; - color: whitesmoke; } - .hero.is-light.is-bold { - background-image: linear-gradient(141deg, #dfd8d9 0%, whitesmoke 71%, white 100%); } - @media screen and (max-width: 768px) { - .hero.is-light.is-bold .navbar-menu { - background-image: linear-gradient(141deg, #dfd8d9 0%, whitesmoke 71%, white 100%); } } - .hero.is-dark { - background-color: #363636; - color: whitesmoke; } - .hero.is-dark a:not(.button):not(.dropdown-item):not(.tag), - .hero.is-dark strong { - color: inherit; } - .hero.is-dark .title { - color: whitesmoke; } - .hero.is-dark .subtitle { - color: rgba(245, 245, 245, 0.9); } - .hero.is-dark .subtitle a:not(.button), - .hero.is-dark .subtitle strong { - color: whitesmoke; } - @media screen and (max-width: 1087px) { - .hero.is-dark .navbar-menu { - background-color: #363636; } } - .hero.is-dark .navbar-item, - .hero.is-dark .navbar-link { - color: rgba(245, 245, 245, 0.7); } - .hero.is-dark a.navbar-item:hover, .hero.is-dark a.navbar-item.is-active, - .hero.is-dark .navbar-link:hover, - .hero.is-dark .navbar-link.is-active { - background-color: #292929; - color: whitesmoke; } - .hero.is-dark .tabs a { - color: whitesmoke; - opacity: 0.9; } - .hero.is-dark .tabs a:hover { - opacity: 1; } - .hero.is-dark .tabs li.is-active a { - opacity: 1; } - .hero.is-dark .tabs.is-boxed a, .hero.is-dark .tabs.is-toggle a { - color: whitesmoke; } - .hero.is-dark .tabs.is-boxed a:hover, .hero.is-dark .tabs.is-toggle a:hover { - background-color: rgba(10, 10, 10, 0.1); } - .hero.is-dark .tabs.is-boxed li.is-active a, .hero.is-dark .tabs.is-boxed li.is-active a:hover, .hero.is-dark .tabs.is-toggle li.is-active a, .hero.is-dark .tabs.is-toggle li.is-active a:hover { - background-color: whitesmoke; - border-color: whitesmoke; - color: #363636; } - .hero.is-dark.is-bold { - background-image: linear-gradient(141deg, #1f191a 0%, #363636 71%, #46403f 100%); } - @media screen and (max-width: 768px) { - .hero.is-dark.is-bold .navbar-menu { - background-image: linear-gradient(141deg, #1f191a 0%, #363636 71%, #46403f 100%); } } - .hero.is-primary { - background-color: #00d1b2; - color: #fff; } - .hero.is-primary a:not(.button):not(.dropdown-item):not(.tag), - .hero.is-primary strong { - color: inherit; } - .hero.is-primary .title { - color: #fff; } - .hero.is-primary .subtitle { - color: rgba(255, 255, 255, 0.9); } - .hero.is-primary .subtitle a:not(.button), - .hero.is-primary .subtitle strong { - color: #fff; } - @media screen and (max-width: 1087px) { - .hero.is-primary .navbar-menu { - background-color: #00d1b2; } } - .hero.is-primary .navbar-item, - .hero.is-primary .navbar-link { - color: rgba(255, 255, 255, 0.7); } - .hero.is-primary a.navbar-item:hover, .hero.is-primary a.navbar-item.is-active, - .hero.is-primary .navbar-link:hover, - .hero.is-primary .navbar-link.is-active { - background-color: #00b89c; - color: #fff; } - .hero.is-primary .tabs a { - color: #fff; - opacity: 0.9; } - .hero.is-primary .tabs a:hover { - opacity: 1; } - .hero.is-primary .tabs li.is-active a { - opacity: 1; } - .hero.is-primary .tabs.is-boxed a, .hero.is-primary .tabs.is-toggle a { - color: #fff; } - .hero.is-primary .tabs.is-boxed a:hover, .hero.is-primary .tabs.is-toggle a:hover { - background-color: rgba(10, 10, 10, 0.1); } - .hero.is-primary .tabs.is-boxed li.is-active a, .hero.is-primary .tabs.is-boxed li.is-active a:hover, .hero.is-primary .tabs.is-toggle li.is-active a, .hero.is-primary .tabs.is-toggle li.is-active a:hover { - background-color: #fff; - border-color: #fff; - color: #00d1b2; } - .hero.is-primary.is-bold { - background-image: linear-gradient(141deg, #009e6c 0%, #00d1b2 71%, #00e7eb 100%); } - @media screen and (max-width: 768px) { - .hero.is-primary.is-bold .navbar-menu { - background-image: linear-gradient(141deg, #009e6c 0%, #00d1b2 71%, #00e7eb 100%); } } - .hero.is-link { - background-color: #33B2E8; - color: #fff; } - .hero.is-link a:not(.button):not(.dropdown-item):not(.tag), - .hero.is-link strong { - color: inherit; } - .hero.is-link .title { - color: #fff; } - .hero.is-link .subtitle { - color: rgba(255, 255, 255, 0.9); } - .hero.is-link .subtitle a:not(.button), - .hero.is-link .subtitle strong { - color: #fff; } - @media screen and (max-width: 1087px) { - .hero.is-link .navbar-menu { - background-color: #33B2E8; } } - .hero.is-link .navbar-item, - .hero.is-link .navbar-link { - color: rgba(255, 255, 255, 0.7); } - .hero.is-link a.navbar-item:hover, .hero.is-link a.navbar-item.is-active, - .hero.is-link .navbar-link:hover, - .hero.is-link .navbar-link.is-active { - background-color: #1ca9e5; - color: #fff; } - .hero.is-link .tabs a { - color: #fff; - opacity: 0.9; } - .hero.is-link .tabs a:hover { - opacity: 1; } - .hero.is-link .tabs li.is-active a { - opacity: 1; } - .hero.is-link .tabs.is-boxed a, .hero.is-link .tabs.is-toggle a { - color: #fff; } - .hero.is-link .tabs.is-boxed a:hover, .hero.is-link .tabs.is-toggle a:hover { - background-color: rgba(10, 10, 10, 0.1); } - .hero.is-link .tabs.is-boxed li.is-active a, .hero.is-link .tabs.is-boxed li.is-active a:hover, .hero.is-link .tabs.is-toggle li.is-active a, .hero.is-link .tabs.is-toggle li.is-active a:hover { - background-color: #fff; - border-color: #fff; - color: #33B2E8; } - .hero.is-link.is-bold { - background-image: linear-gradient(141deg, #0cc1dc 0%, #33B2E8 71%, #45a0f0 100%); } - @media screen and (max-width: 768px) { - .hero.is-link.is-bold .navbar-menu { - background-image: linear-gradient(141deg, #0cc1dc 0%, #33B2E8 71%, #45a0f0 100%); } } - .hero.is-info { - background-color: #209cee; - color: #fff; } - .hero.is-info a:not(.button):not(.dropdown-item):not(.tag), - .hero.is-info strong { - color: inherit; } - .hero.is-info .title { - color: #fff; } - .hero.is-info .subtitle { - color: rgba(255, 255, 255, 0.9); } - .hero.is-info .subtitle a:not(.button), - .hero.is-info .subtitle strong { - color: #fff; } - @media screen and (max-width: 1087px) { - .hero.is-info .navbar-menu { - background-color: #209cee; } } - .hero.is-info .navbar-item, - .hero.is-info .navbar-link { - color: rgba(255, 255, 255, 0.7); } - .hero.is-info a.navbar-item:hover, .hero.is-info a.navbar-item.is-active, - .hero.is-info .navbar-link:hover, - .hero.is-info .navbar-link.is-active { - background-color: #118fe4; - color: #fff; } - .hero.is-info .tabs a { - color: #fff; - opacity: 0.9; } - .hero.is-info .tabs a:hover { - opacity: 1; } - .hero.is-info .tabs li.is-active a { - opacity: 1; } - .hero.is-info .tabs.is-boxed a, .hero.is-info .tabs.is-toggle a { - color: #fff; } - .hero.is-info .tabs.is-boxed a:hover, .hero.is-info .tabs.is-toggle a:hover { - background-color: rgba(10, 10, 10, 0.1); } - .hero.is-info .tabs.is-boxed li.is-active a, .hero.is-info .tabs.is-boxed li.is-active a:hover, .hero.is-info .tabs.is-toggle li.is-active a, .hero.is-info .tabs.is-toggle li.is-active a:hover { - background-color: #fff; - border-color: #fff; - color: #209cee; } - .hero.is-info.is-bold { - background-image: linear-gradient(141deg, #04a6d7 0%, #209cee 71%, #3287f5 100%); } - @media screen and (max-width: 768px) { - .hero.is-info.is-bold .navbar-menu { - background-image: linear-gradient(141deg, #04a6d7 0%, #209cee 71%, #3287f5 100%); } } - .hero.is-success { - background-color: #23d160; - color: #fff; } - .hero.is-success a:not(.button):not(.dropdown-item):not(.tag), - .hero.is-success strong { - color: inherit; } - .hero.is-success .title { - color: #fff; } - .hero.is-success .subtitle { - color: rgba(255, 255, 255, 0.9); } - .hero.is-success .subtitle a:not(.button), - .hero.is-success .subtitle strong { - color: #fff; } - @media screen and (max-width: 1087px) { - .hero.is-success .navbar-menu { - background-color: #23d160; } } - .hero.is-success .navbar-item, - .hero.is-success .navbar-link { - color: rgba(255, 255, 255, 0.7); } - .hero.is-success a.navbar-item:hover, .hero.is-success a.navbar-item.is-active, - .hero.is-success .navbar-link:hover, - .hero.is-success .navbar-link.is-active { - background-color: #20bc56; - color: #fff; } - .hero.is-success .tabs a { - color: #fff; - opacity: 0.9; } - .hero.is-success .tabs a:hover { - opacity: 1; } - .hero.is-success .tabs li.is-active a { - opacity: 1; } - .hero.is-success .tabs.is-boxed a, .hero.is-success .tabs.is-toggle a { - color: #fff; } - .hero.is-success .tabs.is-boxed a:hover, .hero.is-success .tabs.is-toggle a:hover { - background-color: rgba(10, 10, 10, 0.1); } - .hero.is-success .tabs.is-boxed li.is-active a, .hero.is-success .tabs.is-boxed li.is-active a:hover, .hero.is-success .tabs.is-toggle li.is-active a, .hero.is-success .tabs.is-toggle li.is-active a:hover { - background-color: #fff; - border-color: #fff; - color: #23d160; } - .hero.is-success.is-bold { - background-image: linear-gradient(141deg, #12af2f 0%, #23d160 71%, #2ce28a 100%); } - @media screen and (max-width: 768px) { - .hero.is-success.is-bold .navbar-menu { - background-image: linear-gradient(141deg, #12af2f 0%, #23d160 71%, #2ce28a 100%); } } - .hero.is-warning { - background-color: #ffdd57; - color: rgba(0, 0, 0, 0.7); } - .hero.is-warning a:not(.button):not(.dropdown-item):not(.tag), - .hero.is-warning strong { - color: inherit; } - .hero.is-warning .title { - color: rgba(0, 0, 0, 0.7); } - .hero.is-warning .subtitle { - color: rgba(0, 0, 0, 0.9); } - .hero.is-warning .subtitle a:not(.button), - .hero.is-warning .subtitle strong { - color: rgba(0, 0, 0, 0.7); } - @media screen and (max-width: 1087px) { - .hero.is-warning .navbar-menu { - background-color: #ffdd57; } } - .hero.is-warning .navbar-item, - .hero.is-warning .navbar-link { - color: rgba(0, 0, 0, 0.7); } - .hero.is-warning a.navbar-item:hover, .hero.is-warning a.navbar-item.is-active, - .hero.is-warning .navbar-link:hover, - .hero.is-warning .navbar-link.is-active { - background-color: #ffd83d; - color: rgba(0, 0, 0, 0.7); } - .hero.is-warning .tabs a { - color: rgba(0, 0, 0, 0.7); - opacity: 0.9; } - .hero.is-warning .tabs a:hover { - opacity: 1; } - .hero.is-warning .tabs li.is-active a { - opacity: 1; } - .hero.is-warning .tabs.is-boxed a, .hero.is-warning .tabs.is-toggle a { - color: rgba(0, 0, 0, 0.7); } - .hero.is-warning .tabs.is-boxed a:hover, .hero.is-warning .tabs.is-toggle a:hover { - background-color: rgba(10, 10, 10, 0.1); } - .hero.is-warning .tabs.is-boxed li.is-active a, .hero.is-warning .tabs.is-boxed li.is-active a:hover, .hero.is-warning .tabs.is-toggle li.is-active a, .hero.is-warning .tabs.is-toggle li.is-active a:hover { - background-color: rgba(0, 0, 0, 0.7); - border-color: rgba(0, 0, 0, 0.7); - color: #ffdd57; } - .hero.is-warning.is-bold { - background-image: linear-gradient(141deg, #ffaf24 0%, #ffdd57 71%, #fffa70 100%); } - @media screen and (max-width: 768px) { - .hero.is-warning.is-bold .navbar-menu { - background-image: linear-gradient(141deg, #ffaf24 0%, #ffdd57 71%, #fffa70 100%); } } - .hero.is-danger { - background-color: #ff3860; - color: #fff; } - .hero.is-danger a:not(.button):not(.dropdown-item):not(.tag), - .hero.is-danger strong { - color: inherit; } - .hero.is-danger .title { - color: #fff; } - .hero.is-danger .subtitle { - color: rgba(255, 255, 255, 0.9); } - .hero.is-danger .subtitle a:not(.button), - .hero.is-danger .subtitle strong { - color: #fff; } - @media screen and (max-width: 1087px) { - .hero.is-danger .navbar-menu { - background-color: #ff3860; } } - .hero.is-danger .navbar-item, - .hero.is-danger .navbar-link { - color: rgba(255, 255, 255, 0.7); } - .hero.is-danger a.navbar-item:hover, .hero.is-danger a.navbar-item.is-active, - .hero.is-danger .navbar-link:hover, - .hero.is-danger .navbar-link.is-active { - background-color: #ff1f4b; - color: #fff; } - .hero.is-danger .tabs a { - color: #fff; - opacity: 0.9; } - .hero.is-danger .tabs a:hover { - opacity: 1; } - .hero.is-danger .tabs li.is-active a { - opacity: 1; } - .hero.is-danger .tabs.is-boxed a, .hero.is-danger .tabs.is-toggle a { - color: #fff; } - .hero.is-danger .tabs.is-boxed a:hover, .hero.is-danger .tabs.is-toggle a:hover { - background-color: rgba(10, 10, 10, 0.1); } - .hero.is-danger .tabs.is-boxed li.is-active a, .hero.is-danger .tabs.is-boxed li.is-active a:hover, .hero.is-danger .tabs.is-toggle li.is-active a, .hero.is-danger .tabs.is-toggle li.is-active a:hover { - background-color: #fff; - border-color: #fff; - color: #ff3860; } - .hero.is-danger.is-bold { - background-image: linear-gradient(141deg, #ff0561 0%, #ff3860 71%, #ff5257 100%); } - @media screen and (max-width: 768px) { - .hero.is-danger.is-bold .navbar-menu { - background-image: linear-gradient(141deg, #ff0561 0%, #ff3860 71%, #ff5257 100%); } } - .hero.is-small .hero-body { - padding-bottom: 1.5rem; - padding-top: 1.5rem; } - @media screen and (min-width: 769px), print { - .hero.is-medium .hero-body { - padding-bottom: 9rem; - padding-top: 9rem; } } - @media screen and (min-width: 769px), print { - .hero.is-large .hero-body { - padding-bottom: 18rem; - padding-top: 18rem; } } - .hero.is-halfheight .hero-body, .hero.is-fullheight .hero-body { - align-items: center; - display: flex; } - .hero.is-halfheight .hero-body > .container, .hero.is-fullheight .hero-body > .container { - flex-grow: 1; - flex-shrink: 1; } - .hero.is-halfheight { - min-height: 50vh; } - .hero.is-fullheight { - min-height: 100vh; } - -.hero-video { - overflow: hidden; } - .hero-video video { - left: 50%; - min-height: 100%; - min-width: 100%; - position: absolute; - top: 50%; - transform: translate3d(-50%, -50%, 0); } - .hero-video.is-transparent { - opacity: 0.3; } - @media screen and (max-width: 768px) { - .hero-video { - display: none; } } - -.hero-buttons { - margin-top: 1.5rem; } - @media screen and (max-width: 768px) { - .hero-buttons .button { - display: flex; } - .hero-buttons .button:not(:last-child) { - margin-bottom: 0.75rem; } } - @media screen and (min-width: 769px), print { - .hero-buttons { - display: flex; - justify-content: center; } - .hero-buttons .button:not(:last-child) { - margin-right: 1.5rem; } } - -.hero-head, -.hero-foot { - flex-grow: 0; - flex-shrink: 0; } - -.hero-body { - flex-grow: 1; - flex-shrink: 0; - padding: 3rem 1.5rem; } - -.section { - padding: 3rem 1.5rem; } - @media screen and (min-width: 1088px) { - .section.is-medium { - padding: 9rem 1.5rem; } - .section.is-large { - padding: 18rem 1.5rem; } } - -.footer { - background-color: #fafafa; - padding: 3rem 1.5rem 6rem; } - -.box-link-shadow:hover, .box-link-shadow:focus { - box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1), 0 0 0 1px #33B2E8; } - -.box-link-shadow:active { - box-shadow: inset 0 1px 2px rgba(10, 10, 10, 0.2), 0 0 0 1px #33B2E8; } - -/*! - * Font Awesome Free 5.3.1 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) - */ -.fa, -.fas, -.far, -.fal, -.fab { - -moz-osx-font-smoothing: grayscale; - -webkit-font-smoothing: antialiased; - display: inline-block; - font-style: normal; - font-variant: normal; - text-rendering: auto; - line-height: 1; } - -.fa-lg { - font-size: 1.33333em; - line-height: 0.75em; - vertical-align: -.0667em; } - -.fa-xs { - font-size: .75em; } - -.fa-sm { - font-size: .875em; } - -.fa-1x { - font-size: 1em; } - -.fa-2x { - font-size: 2em; } - -.fa-3x { - font-size: 3em; } - -.fa-4x { - font-size: 4em; } - -.fa-5x { - font-size: 5em; } - -.fa-6x { - font-size: 6em; } - -.fa-7x { - font-size: 7em; } - -.fa-8x { - font-size: 8em; } - -.fa-9x { - font-size: 9em; } - -.fa-10x { - font-size: 10em; } - -.fa-fw { - text-align: center; - width: 1.25em; } - -.fa-ul { - list-style-type: none; - margin-left: 2.5em; - padding-left: 0; } - .fa-ul > li { - position: relative; } - -.fa-li { - left: -2em; - position: absolute; - text-align: center; - width: 2em; - line-height: inherit; } - -.fa-border { - border: solid 0.08em #eee; - border-radius: .1em; - padding: .2em .25em .15em; } - -.fa-pull-left { - float: left; } - -.fa-pull-right { - float: right; } - -.fa.fa-pull-left, -.fas.fa-pull-left, -.far.fa-pull-left, -.fal.fa-pull-left, -.fab.fa-pull-left { - margin-right: .3em; } - -.fa.fa-pull-right, -.fas.fa-pull-right, -.far.fa-pull-right, -.fal.fa-pull-right, -.fab.fa-pull-right { - margin-left: .3em; } - -.fa-spin { - animation: fa-spin 2s infinite linear; } - -.fa-pulse { - animation: fa-spin 1s infinite steps(8); } - -@keyframes fa-spin { - 0% { - transform: rotate(0deg); } - 100% { - transform: rotate(360deg); } } - -.fa-rotate-90 { - -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=1)"; - transform: rotate(90deg); } - -.fa-rotate-180 { - -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=2)"; - transform: rotate(180deg); } - -.fa-rotate-270 { - -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=3)"; - transform: rotate(270deg); } - -.fa-flip-horizontal { - -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)"; - transform: scale(-1, 1); } - -.fa-flip-vertical { - -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)"; - transform: scale(1, -1); } - -.fa-flip-horizontal.fa-flip-vertical { - -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)"; - transform: scale(-1, -1); } - -:root .fa-rotate-90, -:root .fa-rotate-180, -:root .fa-rotate-270, -:root .fa-flip-horizontal, -:root .fa-flip-vertical { - filter: none; } - -.fa-stack { - display: inline-block; - height: 2em; - line-height: 2em; - position: relative; - vertical-align: middle; - width: 2em; } - -.fa-stack-1x, -.fa-stack-2x { - left: 0; - position: absolute; - text-align: center; - width: 100%; } - -.fa-stack-1x { - line-height: inherit; } - -.fa-stack-2x { - font-size: 2em; } - -.fa-inverse { - color: #fff; } - -/* Font Awesome uses the Unicode Private Use Area (PUA) to ensure screen -readers do not read off random characters that represent icons */ -.fa-500px:before { - content: "\f26e"; } - -.fa-accessible-icon:before { - content: "\f368"; } - -.fa-accusoft:before { - content: "\f369"; } - -.fa-ad:before { - content: "\f641"; } - -.fa-address-book:before { - content: "\f2b9"; } - -.fa-address-card:before { - content: "\f2bb"; } - -.fa-adjust:before { - content: "\f042"; } - -.fa-adn:before { - content: "\f170"; } - -.fa-adversal:before { - content: "\f36a"; } - -.fa-affiliatetheme:before { - content: "\f36b"; } - -.fa-air-freshener:before { - content: "\f5d0"; } - -.fa-algolia:before { - content: "\f36c"; } - -.fa-align-center:before { - content: "\f037"; } - -.fa-align-justify:before { - content: "\f039"; } - -.fa-align-left:before { - content: "\f036"; } - -.fa-align-right:before { - content: "\f038"; } - -.fa-alipay:before { - content: "\f642"; } - -.fa-allergies:before { - content: "\f461"; } - -.fa-amazon:before { - content: "\f270"; } - -.fa-amazon-pay:before { - content: "\f42c"; } - -.fa-ambulance:before { - content: "\f0f9"; } - -.fa-american-sign-language-interpreting:before { - content: "\f2a3"; } - -.fa-amilia:before { - content: "\f36d"; } - -.fa-anchor:before { - content: "\f13d"; } - -.fa-android:before { - content: "\f17b"; } - -.fa-angellist:before { - content: "\f209"; } - -.fa-angle-double-down:before { - content: "\f103"; } - -.fa-angle-double-left:before { - content: "\f100"; } - -.fa-angle-double-right:before { - content: "\f101"; } - -.fa-angle-double-up:before { - content: "\f102"; } - -.fa-angle-down:before { - content: "\f107"; } - -.fa-angle-left:before { - content: "\f104"; } - -.fa-angle-right:before { - content: "\f105"; } - -.fa-angle-up:before { - content: "\f106"; } - -.fa-angry:before { - content: "\f556"; } - -.fa-angrycreative:before { - content: "\f36e"; } - -.fa-angular:before { - content: "\f420"; } - -.fa-ankh:before { - content: "\f644"; } - -.fa-app-store:before { - content: "\f36f"; } - -.fa-app-store-ios:before { - content: "\f370"; } - -.fa-apper:before { - content: "\f371"; } - -.fa-apple:before { - content: "\f179"; } - -.fa-apple-alt:before { - content: "\f5d1"; } - -.fa-apple-pay:before { - content: "\f415"; } - -.fa-archive:before { - content: "\f187"; } - -.fa-archway:before { - content: "\f557"; } - -.fa-arrow-alt-circle-down:before { - content: "\f358"; } - -.fa-arrow-alt-circle-left:before { - content: "\f359"; } - -.fa-arrow-alt-circle-right:before { - content: "\f35a"; } - -.fa-arrow-alt-circle-up:before { - content: "\f35b"; } - -.fa-arrow-circle-down:before { - content: "\f0ab"; } - -.fa-arrow-circle-left:before { - content: "\f0a8"; } - -.fa-arrow-circle-right:before { - content: "\f0a9"; } - -.fa-arrow-circle-up:before { - content: "\f0aa"; } - -.fa-arrow-down:before { - content: "\f063"; } - -.fa-arrow-left:before { - content: "\f060"; } - -.fa-arrow-right:before { - content: "\f061"; } - -.fa-arrow-up:before { - content: "\f062"; } - -.fa-arrows-alt:before { - content: "\f0b2"; } - -.fa-arrows-alt-h:before { - content: "\f337"; } - -.fa-arrows-alt-v:before { - content: "\f338"; } - -.fa-assistive-listening-systems:before { - content: "\f2a2"; } - -.fa-asterisk:before { - content: "\f069"; } - -.fa-asymmetrik:before { - content: "\f372"; } - -.fa-at:before { - content: "\f1fa"; } - -.fa-atlas:before { - content: "\f558"; } - -.fa-atom:before { - content: "\f5d2"; } - -.fa-audible:before { - content: "\f373"; } - -.fa-audio-description:before { - content: "\f29e"; } - -.fa-autoprefixer:before { - content: "\f41c"; } - -.fa-avianex:before { - content: "\f374"; } - -.fa-aviato:before { - content: "\f421"; } - -.fa-award:before { - content: "\f559"; } - -.fa-aws:before { - content: "\f375"; } - -.fa-backspace:before { - content: "\f55a"; } - -.fa-backward:before { - content: "\f04a"; } - -.fa-balance-scale:before { - content: "\f24e"; } - -.fa-ban:before { - content: "\f05e"; } - -.fa-band-aid:before { - content: "\f462"; } - -.fa-bandcamp:before { - content: "\f2d5"; } - -.fa-barcode:before { - content: "\f02a"; } - -.fa-bars:before { - content: "\f0c9"; } - -.fa-baseball-ball:before { - content: "\f433"; } - -.fa-basketball-ball:before { - content: "\f434"; } - -.fa-bath:before { - content: "\f2cd"; } - -.fa-battery-empty:before { - content: "\f244"; } - -.fa-battery-full:before { - content: "\f240"; } - -.fa-battery-half:before { - content: "\f242"; } - -.fa-battery-quarter:before { - content: "\f243"; } - -.fa-battery-three-quarters:before { - content: "\f241"; } - -.fa-bed:before { - content: "\f236"; } - -.fa-beer:before { - content: "\f0fc"; } - -.fa-behance:before { - content: "\f1b4"; } - -.fa-behance-square:before { - content: "\f1b5"; } - -.fa-bell:before { - content: "\f0f3"; } - -.fa-bell-slash:before { - content: "\f1f6"; } - -.fa-bezier-curve:before { - content: "\f55b"; } - -.fa-bible:before { - content: "\f647"; } - -.fa-bicycle:before { - content: "\f206"; } - -.fa-bimobject:before { - content: "\f378"; } - -.fa-binoculars:before { - content: "\f1e5"; } - -.fa-birthday-cake:before { - content: "\f1fd"; } - -.fa-bitbucket:before { - content: "\f171"; } - -.fa-bitcoin:before { - content: "\f379"; } - -.fa-bity:before { - content: "\f37a"; } - -.fa-black-tie:before { - content: "\f27e"; } - -.fa-blackberry:before { - content: "\f37b"; } - -.fa-blender:before { - content: "\f517"; } - -.fa-blind:before { - content: "\f29d"; } - -.fa-blogger:before { - content: "\f37c"; } - -.fa-blogger-b:before { - content: "\f37d"; } - -.fa-bluetooth:before { - content: "\f293"; } - -.fa-bluetooth-b:before { - content: "\f294"; } - -.fa-bold:before { - content: "\f032"; } - -.fa-bolt:before { - content: "\f0e7"; } - -.fa-bomb:before { - content: "\f1e2"; } - -.fa-bone:before { - content: "\f5d7"; } - -.fa-bong:before { - content: "\f55c"; } - -.fa-book:before { - content: "\f02d"; } - -.fa-book-open:before { - content: "\f518"; } - -.fa-book-reader:before { - content: "\f5da"; } - -.fa-bookmark:before { - content: "\f02e"; } - -.fa-bowling-ball:before { - content: "\f436"; } - -.fa-box:before { - content: "\f466"; } - -.fa-box-open:before { - content: "\f49e"; } - -.fa-boxes:before { - content: "\f468"; } - -.fa-braille:before { - content: "\f2a1"; } - -.fa-brain:before { - content: "\f5dc"; } - -.fa-briefcase:before { - content: "\f0b1"; } - -.fa-briefcase-medical:before { - content: "\f469"; } - -.fa-broadcast-tower:before { - content: "\f519"; } - -.fa-broom:before { - content: "\f51a"; } - -.fa-brush:before { - content: "\f55d"; } - -.fa-btc:before { - content: "\f15a"; } - -.fa-bug:before { - content: "\f188"; } - -.fa-building:before { - content: "\f1ad"; } - -.fa-bullhorn:before { - content: "\f0a1"; } - -.fa-bullseye:before { - content: "\f140"; } - -.fa-burn:before { - content: "\f46a"; } - -.fa-buromobelexperte:before { - content: "\f37f"; } - -.fa-bus:before { - content: "\f207"; } - -.fa-bus-alt:before { - content: "\f55e"; } - -.fa-business-time:before { - content: "\f64a"; } - -.fa-buysellads:before { - content: "\f20d"; } - -.fa-calculator:before { - content: "\f1ec"; } - -.fa-calendar:before { - content: "\f133"; } - -.fa-calendar-alt:before { - content: "\f073"; } - -.fa-calendar-check:before { - content: "\f274"; } - -.fa-calendar-minus:before { - content: "\f272"; } - -.fa-calendar-plus:before { - content: "\f271"; } - -.fa-calendar-times:before { - content: "\f273"; } - -.fa-camera:before { - content: "\f030"; } - -.fa-camera-retro:before { - content: "\f083"; } - -.fa-cannabis:before { - content: "\f55f"; } - -.fa-capsules:before { - content: "\f46b"; } - -.fa-car:before { - content: "\f1b9"; } - -.fa-car-alt:before { - content: "\f5de"; } - -.fa-car-battery:before { - content: "\f5df"; } - -.fa-car-crash:before { - content: "\f5e1"; } - -.fa-car-side:before { - content: "\f5e4"; } - -.fa-caret-down:before { - content: "\f0d7"; } - -.fa-caret-left:before { - content: "\f0d9"; } - -.fa-caret-right:before { - content: "\f0da"; } - -.fa-caret-square-down:before { - content: "\f150"; } - -.fa-caret-square-left:before { - content: "\f191"; } - -.fa-caret-square-right:before { - content: "\f152"; } - -.fa-caret-square-up:before { - content: "\f151"; } - -.fa-caret-up:before { - content: "\f0d8"; } - -.fa-cart-arrow-down:before { - content: "\f218"; } - -.fa-cart-plus:before { - content: "\f217"; } - -.fa-cc-amazon-pay:before { - content: "\f42d"; } - -.fa-cc-amex:before { - content: "\f1f3"; } - -.fa-cc-apple-pay:before { - content: "\f416"; } - -.fa-cc-diners-club:before { - content: "\f24c"; } - -.fa-cc-discover:before { - content: "\f1f2"; } - -.fa-cc-jcb:before { - content: "\f24b"; } - -.fa-cc-mastercard:before { - content: "\f1f1"; } - -.fa-cc-paypal:before { - content: "\f1f4"; } - -.fa-cc-stripe:before { - content: "\f1f5"; } - -.fa-cc-visa:before { - content: "\f1f0"; } - -.fa-centercode:before { - content: "\f380"; } - -.fa-certificate:before { - content: "\f0a3"; } - -.fa-chalkboard:before { - content: "\f51b"; } - -.fa-chalkboard-teacher:before { - content: "\f51c"; } - -.fa-charging-station:before { - content: "\f5e7"; } - -.fa-chart-area:before { - content: "\f1fe"; } - -.fa-chart-bar:before { - content: "\f080"; } - -.fa-chart-line:before { - content: "\f201"; } - -.fa-chart-pie:before { - content: "\f200"; } - -.fa-check:before { - content: "\f00c"; } - -.fa-check-circle:before { - content: "\f058"; } - -.fa-check-double:before { - content: "\f560"; } - -.fa-check-square:before { - content: "\f14a"; } - -.fa-chess:before { - content: "\f439"; } - -.fa-chess-bishop:before { - content: "\f43a"; } - -.fa-chess-board:before { - content: "\f43c"; } - -.fa-chess-king:before { - content: "\f43f"; } - -.fa-chess-knight:before { - content: "\f441"; } - -.fa-chess-pawn:before { - content: "\f443"; } - -.fa-chess-queen:before { - content: "\f445"; } - -.fa-chess-rook:before { - content: "\f447"; } - -.fa-chevron-circle-down:before { - content: "\f13a"; } - -.fa-chevron-circle-left:before { - content: "\f137"; } - -.fa-chevron-circle-right:before { - content: "\f138"; } - -.fa-chevron-circle-up:before { - content: "\f139"; } - -.fa-chevron-down:before { - content: "\f078"; } - -.fa-chevron-left:before { - content: "\f053"; } - -.fa-chevron-right:before { - content: "\f054"; } - -.fa-chevron-up:before { - content: "\f077"; } - -.fa-child:before { - content: "\f1ae"; } - -.fa-chrome:before { - content: "\f268"; } - -.fa-church:before { - content: "\f51d"; } - -.fa-circle:before { - content: "\f111"; } - -.fa-circle-notch:before { - content: "\f1ce"; } - -.fa-city:before { - content: "\f64f"; } - -.fa-clipboard:before { - content: "\f328"; } - -.fa-clipboard-check:before { - content: "\f46c"; } - -.fa-clipboard-list:before { - content: "\f46d"; } - -.fa-clock:before { - content: "\f017"; } - -.fa-clone:before { - content: "\f24d"; } - -.fa-closed-captioning:before { - content: "\f20a"; } - -.fa-cloud:before { - content: "\f0c2"; } - -.fa-cloud-download-alt:before { - content: "\f381"; } - -.fa-cloud-upload-alt:before { - content: "\f382"; } - -.fa-cloudscale:before { - content: "\f383"; } - -.fa-cloudsmith:before { - content: "\f384"; } - -.fa-cloudversify:before { - content: "\f385"; } - -.fa-cocktail:before { - content: "\f561"; } - -.fa-code:before { - content: "\f121"; } - -.fa-code-branch:before { - content: "\f126"; } - -.fa-codepen:before { - content: "\f1cb"; } - -.fa-codiepie:before { - content: "\f284"; } - -.fa-coffee:before { - content: "\f0f4"; } - -.fa-cog:before { - content: "\f013"; } - -.fa-cogs:before { - content: "\f085"; } - -.fa-coins:before { - content: "\f51e"; } - -.fa-columns:before { - content: "\f0db"; } - -.fa-comment:before { - content: "\f075"; } - -.fa-comment-alt:before { - content: "\f27a"; } - -.fa-comment-dollar:before { - content: "\f651"; } - -.fa-comment-dots:before { - content: "\f4ad"; } - -.fa-comment-slash:before { - content: "\f4b3"; } - -.fa-comments:before { - content: "\f086"; } - -.fa-comments-dollar:before { - content: "\f653"; } - -.fa-compact-disc:before { - content: "\f51f"; } - -.fa-compass:before { - content: "\f14e"; } - -.fa-compress:before { - content: "\f066"; } - -.fa-concierge-bell:before { - content: "\f562"; } - -.fa-connectdevelop:before { - content: "\f20e"; } - -.fa-contao:before { - content: "\f26d"; } - -.fa-cookie:before { - content: "\f563"; } - -.fa-cookie-bite:before { - content: "\f564"; } - -.fa-copy:before { - content: "\f0c5"; } - -.fa-copyright:before { - content: "\f1f9"; } - -.fa-couch:before { - content: "\f4b8"; } - -.fa-cpanel:before { - content: "\f388"; } - -.fa-creative-commons:before { - content: "\f25e"; } - -.fa-creative-commons-by:before { - content: "\f4e7"; } - -.fa-creative-commons-nc:before { - content: "\f4e8"; } - -.fa-creative-commons-nc-eu:before { - content: "\f4e9"; } - -.fa-creative-commons-nc-jp:before { - content: "\f4ea"; } - -.fa-creative-commons-nd:before { - content: "\f4eb"; } - -.fa-creative-commons-pd:before { - content: "\f4ec"; } - -.fa-creative-commons-pd-alt:before { - content: "\f4ed"; } - -.fa-creative-commons-remix:before { - content: "\f4ee"; } - -.fa-creative-commons-sa:before { - content: "\f4ef"; } - -.fa-creative-commons-sampling:before { - content: "\f4f0"; } - -.fa-creative-commons-sampling-plus:before { - content: "\f4f1"; } - -.fa-creative-commons-share:before { - content: "\f4f2"; } - -.fa-credit-card:before { - content: "\f09d"; } - -.fa-crop:before { - content: "\f125"; } - -.fa-crop-alt:before { - content: "\f565"; } - -.fa-cross:before { - content: "\f654"; } - -.fa-crosshairs:before { - content: "\f05b"; } - -.fa-crow:before { - content: "\f520"; } - -.fa-crown:before { - content: "\f521"; } - -.fa-css3:before { - content: "\f13c"; } - -.fa-css3-alt:before { - content: "\f38b"; } - -.fa-cube:before { - content: "\f1b2"; } - -.fa-cubes:before { - content: "\f1b3"; } - -.fa-cut:before { - content: "\f0c4"; } - -.fa-cuttlefish:before { - content: "\f38c"; } - -.fa-d-and-d:before { - content: "\f38d"; } - -.fa-dashcube:before { - content: "\f210"; } - -.fa-database:before { - content: "\f1c0"; } - -.fa-deaf:before { - content: "\f2a4"; } - -.fa-delicious:before { - content: "\f1a5"; } - -.fa-deploydog:before { - content: "\f38e"; } - -.fa-deskpro:before { - content: "\f38f"; } - -.fa-desktop:before { - content: "\f108"; } - -.fa-deviantart:before { - content: "\f1bd"; } - -.fa-dharmachakra:before { - content: "\f655"; } - -.fa-diagnoses:before { - content: "\f470"; } - -.fa-dice:before { - content: "\f522"; } - -.fa-dice-five:before { - content: "\f523"; } - -.fa-dice-four:before { - content: "\f524"; } - -.fa-dice-one:before { - content: "\f525"; } - -.fa-dice-six:before { - content: "\f526"; } - -.fa-dice-three:before { - content: "\f527"; } - -.fa-dice-two:before { - content: "\f528"; } - -.fa-digg:before { - content: "\f1a6"; } - -.fa-digital-ocean:before { - content: "\f391"; } - -.fa-digital-tachograph:before { - content: "\f566"; } - -.fa-directions:before { - content: "\f5eb"; } - -.fa-discord:before { - content: "\f392"; } - -.fa-discourse:before { - content: "\f393"; } - -.fa-divide:before { - content: "\f529"; } - -.fa-dizzy:before { - content: "\f567"; } - -.fa-dna:before { - content: "\f471"; } - -.fa-dochub:before { - content: "\f394"; } - -.fa-docker:before { - content: "\f395"; } - -.fa-dollar-sign:before { - content: "\f155"; } - -.fa-dolly:before { - content: "\f472"; } - -.fa-dolly-flatbed:before { - content: "\f474"; } - -.fa-donate:before { - content: "\f4b9"; } - -.fa-door-closed:before { - content: "\f52a"; } - -.fa-door-open:before { - content: "\f52b"; } - -.fa-dot-circle:before { - content: "\f192"; } - -.fa-dove:before { - content: "\f4ba"; } - -.fa-download:before { - content: "\f019"; } - -.fa-draft2digital:before { - content: "\f396"; } - -.fa-drafting-compass:before { - content: "\f568"; } - -.fa-draw-polygon:before { - content: "\f5ee"; } - -.fa-dribbble:before { - content: "\f17d"; } - -.fa-dribbble-square:before { - content: "\f397"; } - -.fa-dropbox:before { - content: "\f16b"; } - -.fa-drum:before { - content: "\f569"; } - -.fa-drum-steelpan:before { - content: "\f56a"; } - -.fa-drupal:before { - content: "\f1a9"; } - -.fa-dumbbell:before { - content: "\f44b"; } - -.fa-dyalog:before { - content: "\f399"; } - -.fa-earlybirds:before { - content: "\f39a"; } - -.fa-ebay:before { - content: "\f4f4"; } - -.fa-edge:before { - content: "\f282"; } - -.fa-edit:before { - content: "\f044"; } - -.fa-eject:before { - content: "\f052"; } - -.fa-elementor:before { - content: "\f430"; } - -.fa-ellipsis-h:before { - content: "\f141"; } - -.fa-ellipsis-v:before { - content: "\f142"; } - -.fa-ello:before { - content: "\f5f1"; } - -.fa-ember:before { - content: "\f423"; } - -.fa-empire:before { - content: "\f1d1"; } - -.fa-envelope:before { - content: "\f0e0"; } - -.fa-envelope-open:before { - content: "\f2b6"; } - -.fa-envelope-open-text:before { - content: "\f658"; } - -.fa-envelope-square:before { - content: "\f199"; } - -.fa-envira:before { - content: "\f299"; } - -.fa-equals:before { - content: "\f52c"; } - -.fa-eraser:before { - content: "\f12d"; } - -.fa-erlang:before { - content: "\f39d"; } - -.fa-ethereum:before { - content: "\f42e"; } - -.fa-etsy:before { - content: "\f2d7"; } - -.fa-euro-sign:before { - content: "\f153"; } - -.fa-exchange-alt:before { - content: "\f362"; } - -.fa-exclamation:before { - content: "\f12a"; } - -.fa-exclamation-circle:before { - content: "\f06a"; } - -.fa-exclamation-triangle:before { - content: "\f071"; } - -.fa-expand:before { - content: "\f065"; } - -.fa-expand-arrows-alt:before { - content: "\f31e"; } - -.fa-expeditedssl:before { - content: "\f23e"; } - -.fa-external-link-alt:before { - content: "\f35d"; } - -.fa-external-link-square-alt:before { - content: "\f360"; } - -.fa-eye:before { - content: "\f06e"; } - -.fa-eye-dropper:before { - content: "\f1fb"; } - -.fa-eye-slash:before { - content: "\f070"; } - -.fa-facebook:before { - content: "\f09a"; } - -.fa-facebook-f:before { - content: "\f39e"; } - -.fa-facebook-messenger:before { - content: "\f39f"; } - -.fa-facebook-square:before { - content: "\f082"; } - -.fa-fast-backward:before { - content: "\f049"; } - -.fa-fast-forward:before { - content: "\f050"; } - -.fa-fax:before { - content: "\f1ac"; } - -.fa-feather:before { - content: "\f52d"; } - -.fa-feather-alt:before { - content: "\f56b"; } - -.fa-female:before { - content: "\f182"; } - -.fa-fighter-jet:before { - content: "\f0fb"; } - -.fa-file:before { - content: "\f15b"; } - -.fa-file-alt:before { - content: "\f15c"; } - -.fa-file-archive:before { - content: "\f1c6"; } - -.fa-file-audio:before { - content: "\f1c7"; } - -.fa-file-code:before { - content: "\f1c9"; } - -.fa-file-contract:before { - content: "\f56c"; } - -.fa-file-download:before { - content: "\f56d"; } - -.fa-file-excel:before { - content: "\f1c3"; } - -.fa-file-export:before { - content: "\f56e"; } - -.fa-file-image:before { - content: "\f1c5"; } - -.fa-file-import:before { - content: "\f56f"; } - -.fa-file-invoice:before { - content: "\f570"; } - -.fa-file-invoice-dollar:before { - content: "\f571"; } - -.fa-file-medical:before { - content: "\f477"; } - -.fa-file-medical-alt:before { - content: "\f478"; } - -.fa-file-pdf:before { - content: "\f1c1"; } - -.fa-file-powerpoint:before { - content: "\f1c4"; } - -.fa-file-prescription:before { - content: "\f572"; } - -.fa-file-signature:before { - content: "\f573"; } - -.fa-file-upload:before { - content: "\f574"; } - -.fa-file-video:before { - content: "\f1c8"; } - -.fa-file-word:before { - content: "\f1c2"; } - -.fa-fill:before { - content: "\f575"; } - -.fa-fill-drip:before { - content: "\f576"; } - -.fa-film:before { - content: "\f008"; } - -.fa-filter:before { - content: "\f0b0"; } - -.fa-fingerprint:before { - content: "\f577"; } - -.fa-fire:before { - content: "\f06d"; } - -.fa-fire-extinguisher:before { - content: "\f134"; } - -.fa-firefox:before { - content: "\f269"; } - -.fa-first-aid:before { - content: "\f479"; } - -.fa-first-order:before { - content: "\f2b0"; } - -.fa-first-order-alt:before { - content: "\f50a"; } - -.fa-firstdraft:before { - content: "\f3a1"; } - -.fa-fish:before { - content: "\f578"; } - -.fa-flag:before { - content: "\f024"; } - -.fa-flag-checkered:before { - content: "\f11e"; } - -.fa-flask:before { - content: "\f0c3"; } - -.fa-flickr:before { - content: "\f16e"; } - -.fa-flipboard:before { - content: "\f44d"; } - -.fa-flushed:before { - content: "\f579"; } - -.fa-fly:before { - content: "\f417"; } - -.fa-folder:before { - content: "\f07b"; } - -.fa-folder-minus:before { - content: "\f65d"; } - -.fa-folder-open:before { - content: "\f07c"; } - -.fa-folder-plus:before { - content: "\f65e"; } - -.fa-font:before { - content: "\f031"; } - -.fa-font-awesome:before { - content: "\f2b4"; } - -.fa-font-awesome-alt:before { - content: "\f35c"; } - -.fa-font-awesome-flag:before { - content: "\f425"; } - -.fa-font-awesome-logo-full:before { - content: "\f4e6"; } - -.fa-fonticons:before { - content: "\f280"; } - -.fa-fonticons-fi:before { - content: "\f3a2"; } - -.fa-football-ball:before { - content: "\f44e"; } - -.fa-fort-awesome:before { - content: "\f286"; } - -.fa-fort-awesome-alt:before { - content: "\f3a3"; } - -.fa-forumbee:before { - content: "\f211"; } - -.fa-forward:before { - content: "\f04e"; } - -.fa-foursquare:before { - content: "\f180"; } - -.fa-free-code-camp:before { - content: "\f2c5"; } - -.fa-freebsd:before { - content: "\f3a4"; } - -.fa-frog:before { - content: "\f52e"; } - -.fa-frown:before { - content: "\f119"; } - -.fa-frown-open:before { - content: "\f57a"; } - -.fa-fulcrum:before { - content: "\f50b"; } - -.fa-funnel-dollar:before { - content: "\f662"; } - -.fa-futbol:before { - content: "\f1e3"; } - -.fa-galactic-republic:before { - content: "\f50c"; } - -.fa-galactic-senate:before { - content: "\f50d"; } - -.fa-gamepad:before { - content: "\f11b"; } - -.fa-gas-pump:before { - content: "\f52f"; } - -.fa-gavel:before { - content: "\f0e3"; } - -.fa-gem:before { - content: "\f3a5"; } - -.fa-genderless:before { - content: "\f22d"; } - -.fa-get-pocket:before { - content: "\f265"; } - -.fa-gg:before { - content: "\f260"; } - -.fa-gg-circle:before { - content: "\f261"; } - -.fa-gift:before { - content: "\f06b"; } - -.fa-git:before { - content: "\f1d3"; } - -.fa-git-square:before { - content: "\f1d2"; } - -.fa-github:before { - content: "\f09b"; } - -.fa-github-alt:before { - content: "\f113"; } - -.fa-github-square:before { - content: "\f092"; } - -.fa-gitkraken:before { - content: "\f3a6"; } - -.fa-gitlab:before { - content: "\f296"; } - -.fa-gitter:before { - content: "\f426"; } - -.fa-glass-martini:before { - content: "\f000"; } - -.fa-glass-martini-alt:before { - content: "\f57b"; } - -.fa-glasses:before { - content: "\f530"; } - -.fa-glide:before { - content: "\f2a5"; } - -.fa-glide-g:before { - content: "\f2a6"; } - -.fa-globe:before { - content: "\f0ac"; } - -.fa-globe-africa:before { - content: "\f57c"; } - -.fa-globe-americas:before { - content: "\f57d"; } - -.fa-globe-asia:before { - content: "\f57e"; } - -.fa-gofore:before { - content: "\f3a7"; } - -.fa-golf-ball:before { - content: "\f450"; } - -.fa-goodreads:before { - content: "\f3a8"; } - -.fa-goodreads-g:before { - content: "\f3a9"; } - -.fa-google:before { - content: "\f1a0"; } - -.fa-google-drive:before { - content: "\f3aa"; } - -.fa-google-play:before { - content: "\f3ab"; } - -.fa-google-plus:before { - content: "\f2b3"; } - -.fa-google-plus-g:before { - content: "\f0d5"; } - -.fa-google-plus-square:before { - content: "\f0d4"; } - -.fa-google-wallet:before { - content: "\f1ee"; } - -.fa-gopuram:before { - content: "\f664"; } - -.fa-graduation-cap:before { - content: "\f19d"; } - -.fa-gratipay:before { - content: "\f184"; } - -.fa-grav:before { - content: "\f2d6"; } - -.fa-greater-than:before { - content: "\f531"; } - -.fa-greater-than-equal:before { - content: "\f532"; } - -.fa-grimace:before { - content: "\f57f"; } - -.fa-grin:before { - content: "\f580"; } - -.fa-grin-alt:before { - content: "\f581"; } - -.fa-grin-beam:before { - content: "\f582"; } - -.fa-grin-beam-sweat:before { - content: "\f583"; } - -.fa-grin-hearts:before { - content: "\f584"; } - -.fa-grin-squint:before { - content: "\f585"; } - -.fa-grin-squint-tears:before { - content: "\f586"; } - -.fa-grin-stars:before { - content: "\f587"; } - -.fa-grin-tears:before { - content: "\f588"; } - -.fa-grin-tongue:before { - content: "\f589"; } - -.fa-grin-tongue-squint:before { - content: "\f58a"; } - -.fa-grin-tongue-wink:before { - content: "\f58b"; } - -.fa-grin-wink:before { - content: "\f58c"; } - -.fa-grip-horizontal:before { - content: "\f58d"; } - -.fa-grip-vertical:before { - content: "\f58e"; } - -.fa-gripfire:before { - content: "\f3ac"; } - -.fa-grunt:before { - content: "\f3ad"; } - -.fa-gulp:before { - content: "\f3ae"; } - -.fa-h-square:before { - content: "\f0fd"; } - -.fa-hacker-news:before { - content: "\f1d4"; } - -.fa-hacker-news-square:before { - content: "\f3af"; } - -.fa-hackerrank:before { - content: "\f5f7"; } - -.fa-hamsa:before { - content: "\f665"; } - -.fa-hand-holding:before { - content: "\f4bd"; } - -.fa-hand-holding-heart:before { - content: "\f4be"; } - -.fa-hand-holding-usd:before { - content: "\f4c0"; } - -.fa-hand-lizard:before { - content: "\f258"; } - -.fa-hand-paper:before { - content: "\f256"; } - -.fa-hand-peace:before { - content: "\f25b"; } - -.fa-hand-point-down:before { - content: "\f0a7"; } - -.fa-hand-point-left:before { - content: "\f0a5"; } - -.fa-hand-point-right:before { - content: "\f0a4"; } - -.fa-hand-point-up:before { - content: "\f0a6"; } - -.fa-hand-pointer:before { - content: "\f25a"; } - -.fa-hand-rock:before { - content: "\f255"; } - -.fa-hand-scissors:before { - content: "\f257"; } - -.fa-hand-spock:before { - content: "\f259"; } - -.fa-hands:before { - content: "\f4c2"; } - -.fa-hands-helping:before { - content: "\f4c4"; } - -.fa-handshake:before { - content: "\f2b5"; } - -.fa-hashtag:before { - content: "\f292"; } - -.fa-haykal:before { - content: "\f666"; } - -.fa-hdd:before { - content: "\f0a0"; } - -.fa-heading:before { - content: "\f1dc"; } - -.fa-headphones:before { - content: "\f025"; } - -.fa-headphones-alt:before { - content: "\f58f"; } - -.fa-headset:before { - content: "\f590"; } - -.fa-heart:before { - content: "\f004"; } - -.fa-heartbeat:before { - content: "\f21e"; } - -.fa-helicopter:before { - content: "\f533"; } - -.fa-highlighter:before { - content: "\f591"; } - -.fa-hips:before { - content: "\f452"; } - -.fa-hire-a-helper:before { - content: "\f3b0"; } - -.fa-history:before { - content: "\f1da"; } - -.fa-hockey-puck:before { - content: "\f453"; } - -.fa-home:before { - content: "\f015"; } - -.fa-hooli:before { - content: "\f427"; } - -.fa-hornbill:before { - content: "\f592"; } - -.fa-hospital:before { - content: "\f0f8"; } - -.fa-hospital-alt:before { - content: "\f47d"; } - -.fa-hospital-symbol:before { - content: "\f47e"; } - -.fa-hot-tub:before { - content: "\f593"; } - -.fa-hotel:before { - content: "\f594"; } - -.fa-hotjar:before { - content: "\f3b1"; } - -.fa-hourglass:before { - content: "\f254"; } - -.fa-hourglass-end:before { - content: "\f253"; } - -.fa-hourglass-half:before { - content: "\f252"; } - -.fa-hourglass-start:before { - content: "\f251"; } - -.fa-houzz:before { - content: "\f27c"; } - -.fa-html5:before { - content: "\f13b"; } - -.fa-hubspot:before { - content: "\f3b2"; } - -.fa-i-cursor:before { - content: "\f246"; } - -.fa-id-badge:before { - content: "\f2c1"; } - -.fa-id-card:before { - content: "\f2c2"; } - -.fa-id-card-alt:before { - content: "\f47f"; } - -.fa-image:before { - content: "\f03e"; } - -.fa-images:before { - content: "\f302"; } - -.fa-imdb:before { - content: "\f2d8"; } - -.fa-inbox:before { - content: "\f01c"; } - -.fa-indent:before { - content: "\f03c"; } - -.fa-industry:before { - content: "\f275"; } - -.fa-infinity:before { - content: "\f534"; } - -.fa-info:before { - content: "\f129"; } - -.fa-info-circle:before { - content: "\f05a"; } - -.fa-instagram:before { - content: "\f16d"; } - -.fa-internet-explorer:before { - content: "\f26b"; } - -.fa-ioxhost:before { - content: "\f208"; } - -.fa-italic:before { - content: "\f033"; } - -.fa-itunes:before { - content: "\f3b4"; } - -.fa-itunes-note:before { - content: "\f3b5"; } - -.fa-java:before { - content: "\f4e4"; } - -.fa-jedi:before { - content: "\f669"; } - -.fa-jedi-order:before { - content: "\f50e"; } - -.fa-jenkins:before { - content: "\f3b6"; } - -.fa-joget:before { - content: "\f3b7"; } - -.fa-joint:before { - content: "\f595"; } - -.fa-joomla:before { - content: "\f1aa"; } - -.fa-journal-whills:before { - content: "\f66a"; } - -.fa-js:before { - content: "\f3b8"; } - -.fa-js-square:before { - content: "\f3b9"; } - -.fa-jsfiddle:before { - content: "\f1cc"; } - -.fa-kaaba:before { - content: "\f66b"; } - -.fa-kaggle:before { - content: "\f5fa"; } - -.fa-key:before { - content: "\f084"; } - -.fa-keybase:before { - content: "\f4f5"; } - -.fa-keyboard:before { - content: "\f11c"; } - -.fa-keycdn:before { - content: "\f3ba"; } - -.fa-khanda:before { - content: "\f66d"; } - -.fa-kickstarter:before { - content: "\f3bb"; } - -.fa-kickstarter-k:before { - content: "\f3bc"; } - -.fa-kiss:before { - content: "\f596"; } - -.fa-kiss-beam:before { - content: "\f597"; } - -.fa-kiss-wink-heart:before { - content: "\f598"; } - -.fa-kiwi-bird:before { - content: "\f535"; } - -.fa-korvue:before { - content: "\f42f"; } - -.fa-landmark:before { - content: "\f66f"; } - -.fa-language:before { - content: "\f1ab"; } - -.fa-laptop:before { - content: "\f109"; } - -.fa-laptop-code:before { - content: "\f5fc"; } - -.fa-laravel:before { - content: "\f3bd"; } - -.fa-lastfm:before { - content: "\f202"; } - -.fa-lastfm-square:before { - content: "\f203"; } - -.fa-laugh:before { - content: "\f599"; } - -.fa-laugh-beam:before { - content: "\f59a"; } - -.fa-laugh-squint:before { - content: "\f59b"; } - -.fa-laugh-wink:before { - content: "\f59c"; } - -.fa-layer-group:before { - content: "\f5fd"; } - -.fa-leaf:before { - content: "\f06c"; } - -.fa-leanpub:before { - content: "\f212"; } - -.fa-lemon:before { - content: "\f094"; } - -.fa-less:before { - content: "\f41d"; } - -.fa-less-than:before { - content: "\f536"; } - -.fa-less-than-equal:before { - content: "\f537"; } - -.fa-level-down-alt:before { - content: "\f3be"; } - -.fa-level-up-alt:before { - content: "\f3bf"; } - -.fa-life-ring:before { - content: "\f1cd"; } - -.fa-lightbulb:before { - content: "\f0eb"; } - -.fa-line:before { - content: "\f3c0"; } - -.fa-link:before { - content: "\f0c1"; } - -.fa-linkedin:before { - content: "\f08c"; } - -.fa-linkedin-in:before { - content: "\f0e1"; } - -.fa-linode:before { - content: "\f2b8"; } - -.fa-linux:before { - content: "\f17c"; } - -.fa-lira-sign:before { - content: "\f195"; } - -.fa-list:before { - content: "\f03a"; } - -.fa-list-alt:before { - content: "\f022"; } - -.fa-list-ol:before { - content: "\f0cb"; } - -.fa-list-ul:before { - content: "\f0ca"; } - -.fa-location-arrow:before { - content: "\f124"; } - -.fa-lock:before { - content: "\f023"; } - -.fa-lock-open:before { - content: "\f3c1"; } - -.fa-long-arrow-alt-down:before { - content: "\f309"; } - -.fa-long-arrow-alt-left:before { - content: "\f30a"; } - -.fa-long-arrow-alt-right:before { - content: "\f30b"; } - -.fa-long-arrow-alt-up:before { - content: "\f30c"; } - -.fa-low-vision:before { - content: "\f2a8"; } - -.fa-luggage-cart:before { - content: "\f59d"; } - -.fa-lyft:before { - content: "\f3c3"; } - -.fa-magento:before { - content: "\f3c4"; } - -.fa-magic:before { - content: "\f0d0"; } - -.fa-magnet:before { - content: "\f076"; } - -.fa-mail-bulk:before { - content: "\f674"; } - -.fa-mailchimp:before { - content: "\f59e"; } - -.fa-male:before { - content: "\f183"; } - -.fa-mandalorian:before { - content: "\f50f"; } - -.fa-map:before { - content: "\f279"; } - -.fa-map-marked:before { - content: "\f59f"; } - -.fa-map-marked-alt:before { - content: "\f5a0"; } - -.fa-map-marker:before { - content: "\f041"; } - -.fa-map-marker-alt:before { - content: "\f3c5"; } - -.fa-map-pin:before { - content: "\f276"; } - -.fa-map-signs:before { - content: "\f277"; } - -.fa-markdown:before { - content: "\f60f"; } - -.fa-marker:before { - content: "\f5a1"; } - -.fa-mars:before { - content: "\f222"; } - -.fa-mars-double:before { - content: "\f227"; } - -.fa-mars-stroke:before { - content: "\f229"; } - -.fa-mars-stroke-h:before { - content: "\f22b"; } - -.fa-mars-stroke-v:before { - content: "\f22a"; } - -.fa-mastodon:before { - content: "\f4f6"; } - -.fa-maxcdn:before { - content: "\f136"; } - -.fa-medal:before { - content: "\f5a2"; } - -.fa-medapps:before { - content: "\f3c6"; } - -.fa-medium:before { - content: "\f23a"; } - -.fa-medium-m:before { - content: "\f3c7"; } - -.fa-medkit:before { - content: "\f0fa"; } - -.fa-medrt:before { - content: "\f3c8"; } - -.fa-meetup:before { - content: "\f2e0"; } - -.fa-megaport:before { - content: "\f5a3"; } - -.fa-meh:before { - content: "\f11a"; } - -.fa-meh-blank:before { - content: "\f5a4"; } - -.fa-meh-rolling-eyes:before { - content: "\f5a5"; } - -.fa-memory:before { - content: "\f538"; } - -.fa-menorah:before { - content: "\f676"; } - -.fa-mercury:before { - content: "\f223"; } - -.fa-microchip:before { - content: "\f2db"; } - -.fa-microphone:before { - content: "\f130"; } - -.fa-microphone-alt:before { - content: "\f3c9"; } - -.fa-microphone-alt-slash:before { - content: "\f539"; } - -.fa-microphone-slash:before { - content: "\f131"; } - -.fa-microscope:before { - content: "\f610"; } - -.fa-microsoft:before { - content: "\f3ca"; } - -.fa-minus:before { - content: "\f068"; } - -.fa-minus-circle:before { - content: "\f056"; } - -.fa-minus-square:before { - content: "\f146"; } - -.fa-mix:before { - content: "\f3cb"; } - -.fa-mixcloud:before { - content: "\f289"; } - -.fa-mizuni:before { - content: "\f3cc"; } - -.fa-mobile:before { - content: "\f10b"; } - -.fa-mobile-alt:before { - content: "\f3cd"; } - -.fa-modx:before { - content: "\f285"; } - -.fa-monero:before { - content: "\f3d0"; } - -.fa-money-bill:before { - content: "\f0d6"; } - -.fa-money-bill-alt:before { - content: "\f3d1"; } - -.fa-money-bill-wave:before { - content: "\f53a"; } - -.fa-money-bill-wave-alt:before { - content: "\f53b"; } - -.fa-money-check:before { - content: "\f53c"; } - -.fa-money-check-alt:before { - content: "\f53d"; } - -.fa-monument:before { - content: "\f5a6"; } - -.fa-moon:before { - content: "\f186"; } - -.fa-mortar-pestle:before { - content: "\f5a7"; } - -.fa-mosque:before { - content: "\f678"; } - -.fa-motorcycle:before { - content: "\f21c"; } - -.fa-mouse-pointer:before { - content: "\f245"; } - -.fa-music:before { - content: "\f001"; } - -.fa-napster:before { - content: "\f3d2"; } - -.fa-neos:before { - content: "\f612"; } - -.fa-neuter:before { - content: "\f22c"; } - -.fa-newspaper:before { - content: "\f1ea"; } - -.fa-nimblr:before { - content: "\f5a8"; } - -.fa-nintendo-switch:before { - content: "\f418"; } - -.fa-node:before { - content: "\f419"; } - -.fa-node-js:before { - content: "\f3d3"; } - -.fa-not-equal:before { - content: "\f53e"; } - -.fa-notes-medical:before { - content: "\f481"; } - -.fa-npm:before { - content: "\f3d4"; } - -.fa-ns8:before { - content: "\f3d5"; } - -.fa-nutritionix:before { - content: "\f3d6"; } - -.fa-object-group:before { - content: "\f247"; } - -.fa-object-ungroup:before { - content: "\f248"; } - -.fa-odnoklassniki:before { - content: "\f263"; } - -.fa-odnoklassniki-square:before { - content: "\f264"; } - -.fa-oil-can:before { - content: "\f613"; } - -.fa-old-republic:before { - content: "\f510"; } - -.fa-om:before { - content: "\f679"; } - -.fa-opencart:before { - content: "\f23d"; } - -.fa-openid:before { - content: "\f19b"; } - -.fa-opera:before { - content: "\f26a"; } - -.fa-optin-monster:before { - content: "\f23c"; } - -.fa-osi:before { - content: "\f41a"; } - -.fa-outdent:before { - content: "\f03b"; } - -.fa-page4:before { - content: "\f3d7"; } - -.fa-pagelines:before { - content: "\f18c"; } - -.fa-paint-brush:before { - content: "\f1fc"; } - -.fa-paint-roller:before { - content: "\f5aa"; } - -.fa-palette:before { - content: "\f53f"; } - -.fa-palfed:before { - content: "\f3d8"; } - -.fa-pallet:before { - content: "\f482"; } - -.fa-paper-plane:before { - content: "\f1d8"; } - -.fa-paperclip:before { - content: "\f0c6"; } - -.fa-parachute-box:before { - content: "\f4cd"; } - -.fa-paragraph:before { - content: "\f1dd"; } - -.fa-parking:before { - content: "\f540"; } - -.fa-passport:before { - content: "\f5ab"; } - -.fa-pastafarianism:before { - content: "\f67b"; } - -.fa-paste:before { - content: "\f0ea"; } - -.fa-patreon:before { - content: "\f3d9"; } - -.fa-pause:before { - content: "\f04c"; } - -.fa-pause-circle:before { - content: "\f28b"; } - -.fa-paw:before { - content: "\f1b0"; } - -.fa-paypal:before { - content: "\f1ed"; } - -.fa-peace:before { - content: "\f67c"; } - -.fa-pen:before { - content: "\f304"; } - -.fa-pen-alt:before { - content: "\f305"; } - -.fa-pen-fancy:before { - content: "\f5ac"; } - -.fa-pen-nib:before { - content: "\f5ad"; } - -.fa-pen-square:before { - content: "\f14b"; } - -.fa-pencil-alt:before { - content: "\f303"; } - -.fa-pencil-ruler:before { - content: "\f5ae"; } - -.fa-people-carry:before { - content: "\f4ce"; } - -.fa-percent:before { - content: "\f295"; } - -.fa-percentage:before { - content: "\f541"; } - -.fa-periscope:before { - content: "\f3da"; } - -.fa-phabricator:before { - content: "\f3db"; } - -.fa-phoenix-framework:before { - content: "\f3dc"; } - -.fa-phoenix-squadron:before { - content: "\f511"; } - -.fa-phone:before { - content: "\f095"; } - -.fa-phone-slash:before { - content: "\f3dd"; } - -.fa-phone-square:before { - content: "\f098"; } - -.fa-phone-volume:before { - content: "\f2a0"; } - -.fa-php:before { - content: "\f457"; } - -.fa-pied-piper:before { - content: "\f2ae"; } - -.fa-pied-piper-alt:before { - content: "\f1a8"; } - -.fa-pied-piper-hat:before { - content: "\f4e5"; } - -.fa-pied-piper-pp:before { - content: "\f1a7"; } - -.fa-piggy-bank:before { - content: "\f4d3"; } - -.fa-pills:before { - content: "\f484"; } - -.fa-pinterest:before { - content: "\f0d2"; } - -.fa-pinterest-p:before { - content: "\f231"; } - -.fa-pinterest-square:before { - content: "\f0d3"; } - -.fa-place-of-worship:before { - content: "\f67f"; } - -.fa-plane:before { - content: "\f072"; } - -.fa-plane-arrival:before { - content: "\f5af"; } - -.fa-plane-departure:before { - content: "\f5b0"; } - -.fa-play:before { - content: "\f04b"; } - -.fa-play-circle:before { - content: "\f144"; } - -.fa-playstation:before { - content: "\f3df"; } - -.fa-plug:before { - content: "\f1e6"; } - -.fa-plus:before { - content: "\f067"; } - -.fa-plus-circle:before { - content: "\f055"; } - -.fa-plus-square:before { - content: "\f0fe"; } - -.fa-podcast:before { - content: "\f2ce"; } - -.fa-poll:before { - content: "\f681"; } - -.fa-poll-h:before { - content: "\f682"; } - -.fa-poo:before { - content: "\f2fe"; } - -.fa-poop:before { - content: "\f619"; } - -.fa-portrait:before { - content: "\f3e0"; } - -.fa-pound-sign:before { - content: "\f154"; } - -.fa-power-off:before { - content: "\f011"; } - -.fa-pray:before { - content: "\f683"; } - -.fa-praying-hands:before { - content: "\f684"; } - -.fa-prescription:before { - content: "\f5b1"; } - -.fa-prescription-bottle:before { - content: "\f485"; } - -.fa-prescription-bottle-alt:before { - content: "\f486"; } - -.fa-print:before { - content: "\f02f"; } - -.fa-procedures:before { - content: "\f487"; } - -.fa-product-hunt:before { - content: "\f288"; } - -.fa-project-diagram:before { - content: "\f542"; } - -.fa-pushed:before { - content: "\f3e1"; } - -.fa-puzzle-piece:before { - content: "\f12e"; } - -.fa-python:before { - content: "\f3e2"; } - -.fa-qq:before { - content: "\f1d6"; } - -.fa-qrcode:before { - content: "\f029"; } - -.fa-question:before { - content: "\f128"; } - -.fa-question-circle:before { - content: "\f059"; } - -.fa-quidditch:before { - content: "\f458"; } - -.fa-quinscape:before { - content: "\f459"; } - -.fa-quora:before { - content: "\f2c4"; } - -.fa-quote-left:before { - content: "\f10d"; } - -.fa-quote-right:before { - content: "\f10e"; } - -.fa-quran:before { - content: "\f687"; } - -.fa-r-project:before { - content: "\f4f7"; } - -.fa-random:before { - content: "\f074"; } - -.fa-ravelry:before { - content: "\f2d9"; } - -.fa-react:before { - content: "\f41b"; } - -.fa-readme:before { - content: "\f4d5"; } - -.fa-rebel:before { - content: "\f1d0"; } - -.fa-receipt:before { - content: "\f543"; } - -.fa-recycle:before { - content: "\f1b8"; } - -.fa-red-river:before { - content: "\f3e3"; } - -.fa-reddit:before { - content: "\f1a1"; } - -.fa-reddit-alien:before { - content: "\f281"; } - -.fa-reddit-square:before { - content: "\f1a2"; } - -.fa-redo:before { - content: "\f01e"; } - -.fa-redo-alt:before { - content: "\f2f9"; } - -.fa-registered:before { - content: "\f25d"; } - -.fa-rendact:before { - content: "\f3e4"; } - -.fa-renren:before { - content: "\f18b"; } - -.fa-reply:before { - content: "\f3e5"; } - -.fa-reply-all:before { - content: "\f122"; } - -.fa-replyd:before { - content: "\f3e6"; } - -.fa-researchgate:before { - content: "\f4f8"; } - -.fa-resolving:before { - content: "\f3e7"; } - -.fa-retweet:before { - content: "\f079"; } - -.fa-rev:before { - content: "\f5b2"; } - -.fa-ribbon:before { - content: "\f4d6"; } - -.fa-road:before { - content: "\f018"; } - -.fa-robot:before { - content: "\f544"; } - -.fa-rocket:before { - content: "\f135"; } - -.fa-rocketchat:before { - content: "\f3e8"; } - -.fa-rockrms:before { - content: "\f3e9"; } - -.fa-route:before { - content: "\f4d7"; } - -.fa-rss:before { - content: "\f09e"; } - -.fa-rss-square:before { - content: "\f143"; } - -.fa-ruble-sign:before { - content: "\f158"; } - -.fa-ruler:before { - content: "\f545"; } - -.fa-ruler-combined:before { - content: "\f546"; } - -.fa-ruler-horizontal:before { - content: "\f547"; } - -.fa-ruler-vertical:before { - content: "\f548"; } - -.fa-rupee-sign:before { - content: "\f156"; } - -.fa-sad-cry:before { - content: "\f5b3"; } - -.fa-sad-tear:before { - content: "\f5b4"; } - -.fa-safari:before { - content: "\f267"; } - -.fa-sass:before { - content: "\f41e"; } - -.fa-save:before { - content: "\f0c7"; } - -.fa-schlix:before { - content: "\f3ea"; } - -.fa-school:before { - content: "\f549"; } - -.fa-screwdriver:before { - content: "\f54a"; } - -.fa-scribd:before { - content: "\f28a"; } - -.fa-search:before { - content: "\f002"; } - -.fa-search-dollar:before { - content: "\f688"; } - -.fa-search-location:before { - content: "\f689"; } - -.fa-search-minus:before { - content: "\f010"; } - -.fa-search-plus:before { - content: "\f00e"; } - -.fa-searchengin:before { - content: "\f3eb"; } - -.fa-seedling:before { - content: "\f4d8"; } - -.fa-sellcast:before { - content: "\f2da"; } - -.fa-sellsy:before { - content: "\f213"; } - -.fa-server:before { - content: "\f233"; } - -.fa-servicestack:before { - content: "\f3ec"; } - -.fa-shapes:before { - content: "\f61f"; } - -.fa-share:before { - content: "\f064"; } - -.fa-share-alt:before { - content: "\f1e0"; } - -.fa-share-alt-square:before { - content: "\f1e1"; } - -.fa-share-square:before { - content: "\f14d"; } - -.fa-shekel-sign:before { - content: "\f20b"; } - -.fa-shield-alt:before { - content: "\f3ed"; } - -.fa-ship:before { - content: "\f21a"; } - -.fa-shipping-fast:before { - content: "\f48b"; } - -.fa-shirtsinbulk:before { - content: "\f214"; } - -.fa-shoe-prints:before { - content: "\f54b"; } - -.fa-shopping-bag:before { - content: "\f290"; } - -.fa-shopping-basket:before { - content: "\f291"; } - -.fa-shopping-cart:before { - content: "\f07a"; } - -.fa-shopware:before { - content: "\f5b5"; } - -.fa-shower:before { - content: "\f2cc"; } - -.fa-shuttle-van:before { - content: "\f5b6"; } - -.fa-sign:before { - content: "\f4d9"; } - -.fa-sign-in-alt:before { - content: "\f2f6"; } - -.fa-sign-language:before { - content: "\f2a7"; } - -.fa-sign-out-alt:before { - content: "\f2f5"; } - -.fa-signal:before { - content: "\f012"; } - -.fa-signature:before { - content: "\f5b7"; } - -.fa-simplybuilt:before { - content: "\f215"; } - -.fa-sistrix:before { - content: "\f3ee"; } - -.fa-sitemap:before { - content: "\f0e8"; } - -.fa-sith:before { - content: "\f512"; } - -.fa-skull:before { - content: "\f54c"; } - -.fa-skyatlas:before { - content: "\f216"; } - -.fa-skype:before { - content: "\f17e"; } - -.fa-slack:before { - content: "\f198"; } - -.fa-slack-hash:before { - content: "\f3ef"; } - -.fa-sliders-h:before { - content: "\f1de"; } - -.fa-slideshare:before { - content: "\f1e7"; } - -.fa-smile:before { - content: "\f118"; } - -.fa-smile-beam:before { - content: "\f5b8"; } - -.fa-smile-wink:before { - content: "\f4da"; } - -.fa-smoking:before { - content: "\f48d"; } - -.fa-smoking-ban:before { - content: "\f54d"; } - -.fa-snapchat:before { - content: "\f2ab"; } - -.fa-snapchat-ghost:before { - content: "\f2ac"; } - -.fa-snapchat-square:before { - content: "\f2ad"; } - -.fa-snowflake:before { - content: "\f2dc"; } - -.fa-socks:before { - content: "\f696"; } - -.fa-solar-panel:before { - content: "\f5ba"; } - -.fa-sort:before { - content: "\f0dc"; } - -.fa-sort-alpha-down:before { - content: "\f15d"; } - -.fa-sort-alpha-up:before { - content: "\f15e"; } - -.fa-sort-amount-down:before { - content: "\f160"; } - -.fa-sort-amount-up:before { - content: "\f161"; } - -.fa-sort-down:before { - content: "\f0dd"; } - -.fa-sort-numeric-down:before { - content: "\f162"; } - -.fa-sort-numeric-up:before { - content: "\f163"; } - -.fa-sort-up:before { - content: "\f0de"; } - -.fa-soundcloud:before { - content: "\f1be"; } - -.fa-spa:before { - content: "\f5bb"; } - -.fa-space-shuttle:before { - content: "\f197"; } - -.fa-speakap:before { - content: "\f3f3"; } - -.fa-spinner:before { - content: "\f110"; } - -.fa-splotch:before { - content: "\f5bc"; } - -.fa-spotify:before { - content: "\f1bc"; } - -.fa-spray-can:before { - content: "\f5bd"; } - -.fa-square:before { - content: "\f0c8"; } - -.fa-square-full:before { - content: "\f45c"; } - -.fa-square-root-alt:before { - content: "\f698"; } - -.fa-squarespace:before { - content: "\f5be"; } - -.fa-stack-exchange:before { - content: "\f18d"; } - -.fa-stack-overflow:before { - content: "\f16c"; } - -.fa-stamp:before { - content: "\f5bf"; } - -.fa-star:before { - content: "\f005"; } - -.fa-star-and-crescent:before { - content: "\f699"; } - -.fa-star-half:before { - content: "\f089"; } - -.fa-star-half-alt:before { - content: "\f5c0"; } - -.fa-star-of-david:before { - content: "\f69a"; } - -.fa-star-of-life:before { - content: "\f621"; } - -.fa-staylinked:before { - content: "\f3f5"; } - -.fa-steam:before { - content: "\f1b6"; } - -.fa-steam-square:before { - content: "\f1b7"; } - -.fa-steam-symbol:before { - content: "\f3f6"; } - -.fa-step-backward:before { - content: "\f048"; } - -.fa-step-forward:before { - content: "\f051"; } - -.fa-stethoscope:before { - content: "\f0f1"; } - -.fa-sticker-mule:before { - content: "\f3f7"; } - -.fa-sticky-note:before { - content: "\f249"; } - -.fa-stop:before { - content: "\f04d"; } - -.fa-stop-circle:before { - content: "\f28d"; } - -.fa-stopwatch:before { - content: "\f2f2"; } - -.fa-store:before { - content: "\f54e"; } - -.fa-store-alt:before { - content: "\f54f"; } - -.fa-strava:before { - content: "\f428"; } - -.fa-stream:before { - content: "\f550"; } - -.fa-street-view:before { - content: "\f21d"; } - -.fa-strikethrough:before { - content: "\f0cc"; } - -.fa-stripe:before { - content: "\f429"; } - -.fa-stripe-s:before { - content: "\f42a"; } - -.fa-stroopwafel:before { - content: "\f551"; } - -.fa-studiovinari:before { - content: "\f3f8"; } - -.fa-stumbleupon:before { - content: "\f1a4"; } - -.fa-stumbleupon-circle:before { - content: "\f1a3"; } - -.fa-subscript:before { - content: "\f12c"; } - -.fa-subway:before { - content: "\f239"; } - -.fa-suitcase:before { - content: "\f0f2"; } - -.fa-suitcase-rolling:before { - content: "\f5c1"; } - -.fa-sun:before { - content: "\f185"; } - -.fa-superpowers:before { - content: "\f2dd"; } - -.fa-superscript:before { - content: "\f12b"; } - -.fa-supple:before { - content: "\f3f9"; } - -.fa-surprise:before { - content: "\f5c2"; } - -.fa-swatchbook:before { - content: "\f5c3"; } - -.fa-swimmer:before { - content: "\f5c4"; } - -.fa-swimming-pool:before { - content: "\f5c5"; } - -.fa-synagogue:before { - content: "\f69b"; } - -.fa-sync:before { - content: "\f021"; } - -.fa-sync-alt:before { - content: "\f2f1"; } - -.fa-syringe:before { - content: "\f48e"; } - -.fa-table:before { - content: "\f0ce"; } - -.fa-table-tennis:before { - content: "\f45d"; } - -.fa-tablet:before { - content: "\f10a"; } - -.fa-tablet-alt:before { - content: "\f3fa"; } - -.fa-tablets:before { - content: "\f490"; } - -.fa-tachometer-alt:before { - content: "\f3fd"; } - -.fa-tag:before { - content: "\f02b"; } - -.fa-tags:before { - content: "\f02c"; } - -.fa-tape:before { - content: "\f4db"; } - -.fa-tasks:before { - content: "\f0ae"; } - -.fa-taxi:before { - content: "\f1ba"; } - -.fa-teamspeak:before { - content: "\f4f9"; } - -.fa-teeth:before { - content: "\f62e"; } - -.fa-teeth-open:before { - content: "\f62f"; } - -.fa-telegram:before { - content: "\f2c6"; } - -.fa-telegram-plane:before { - content: "\f3fe"; } - -.fa-tencent-weibo:before { - content: "\f1d5"; } - -.fa-terminal:before { - content: "\f120"; } - -.fa-text-height:before { - content: "\f034"; } - -.fa-text-width:before { - content: "\f035"; } - -.fa-th:before { - content: "\f00a"; } - -.fa-th-large:before { - content: "\f009"; } - -.fa-th-list:before { - content: "\f00b"; } - -.fa-the-red-yeti:before { - content: "\f69d"; } - -.fa-theater-masks:before { - content: "\f630"; } - -.fa-themeco:before { - content: "\f5c6"; } - -.fa-themeisle:before { - content: "\f2b2"; } - -.fa-thermometer:before { - content: "\f491"; } - -.fa-thermometer-empty:before { - content: "\f2cb"; } - -.fa-thermometer-full:before { - content: "\f2c7"; } - -.fa-thermometer-half:before { - content: "\f2c9"; } - -.fa-thermometer-quarter:before { - content: "\f2ca"; } - -.fa-thermometer-three-quarters:before { - content: "\f2c8"; } - -.fa-thumbs-down:before { - content: "\f165"; } - -.fa-thumbs-up:before { - content: "\f164"; } - -.fa-thumbtack:before { - content: "\f08d"; } - -.fa-ticket-alt:before { - content: "\f3ff"; } - -.fa-times:before { - content: "\f00d"; } - -.fa-times-circle:before { - content: "\f057"; } - -.fa-tint:before { - content: "\f043"; } - -.fa-tint-slash:before { - content: "\f5c7"; } - -.fa-tired:before { - content: "\f5c8"; } - -.fa-toggle-off:before { - content: "\f204"; } - -.fa-toggle-on:before { - content: "\f205"; } - -.fa-toolbox:before { - content: "\f552"; } - -.fa-tooth:before { - content: "\f5c9"; } - -.fa-torah:before { - content: "\f6a0"; } - -.fa-torii-gate:before { - content: "\f6a1"; } - -.fa-trade-federation:before { - content: "\f513"; } - -.fa-trademark:before { - content: "\f25c"; } - -.fa-traffic-light:before { - content: "\f637"; } - -.fa-train:before { - content: "\f238"; } - -.fa-transgender:before { - content: "\f224"; } - -.fa-transgender-alt:before { - content: "\f225"; } - -.fa-trash:before { - content: "\f1f8"; } - -.fa-trash-alt:before { - content: "\f2ed"; } - -.fa-tree:before { - content: "\f1bb"; } - -.fa-trello:before { - content: "\f181"; } - -.fa-tripadvisor:before { - content: "\f262"; } - -.fa-trophy:before { - content: "\f091"; } - -.fa-truck:before { - content: "\f0d1"; } - -.fa-truck-loading:before { - content: "\f4de"; } - -.fa-truck-monster:before { - content: "\f63b"; } - -.fa-truck-moving:before { - content: "\f4df"; } - -.fa-truck-pickup:before { - content: "\f63c"; } - -.fa-tshirt:before { - content: "\f553"; } - -.fa-tty:before { - content: "\f1e4"; } - -.fa-tumblr:before { - content: "\f173"; } - -.fa-tumblr-square:before { - content: "\f174"; } - -.fa-tv:before { - content: "\f26c"; } - -.fa-twitch:before { - content: "\f1e8"; } - -.fa-twitter:before { - content: "\f099"; } - -.fa-twitter-square:before { - content: "\f081"; } - -.fa-typo3:before { - content: "\f42b"; } - -.fa-uber:before { - content: "\f402"; } - -.fa-uikit:before { - content: "\f403"; } - -.fa-umbrella:before { - content: "\f0e9"; } - -.fa-umbrella-beach:before { - content: "\f5ca"; } - -.fa-underline:before { - content: "\f0cd"; } - -.fa-undo:before { - content: "\f0e2"; } - -.fa-undo-alt:before { - content: "\f2ea"; } - -.fa-uniregistry:before { - content: "\f404"; } - -.fa-universal-access:before { - content: "\f29a"; } - -.fa-university:before { - content: "\f19c"; } - -.fa-unlink:before { - content: "\f127"; } - -.fa-unlock:before { - content: "\f09c"; } - -.fa-unlock-alt:before { - content: "\f13e"; } - -.fa-untappd:before { - content: "\f405"; } - -.fa-upload:before { - content: "\f093"; } - -.fa-usb:before { - content: "\f287"; } - -.fa-user:before { - content: "\f007"; } - -.fa-user-alt:before { - content: "\f406"; } - -.fa-user-alt-slash:before { - content: "\f4fa"; } - -.fa-user-astronaut:before { - content: "\f4fb"; } - -.fa-user-check:before { - content: "\f4fc"; } - -.fa-user-circle:before { - content: "\f2bd"; } - -.fa-user-clock:before { - content: "\f4fd"; } - -.fa-user-cog:before { - content: "\f4fe"; } - -.fa-user-edit:before { - content: "\f4ff"; } - -.fa-user-friends:before { - content: "\f500"; } - -.fa-user-graduate:before { - content: "\f501"; } - -.fa-user-lock:before { - content: "\f502"; } - -.fa-user-md:before { - content: "\f0f0"; } - -.fa-user-minus:before { - content: "\f503"; } - -.fa-user-ninja:before { - content: "\f504"; } - -.fa-user-plus:before { - content: "\f234"; } - -.fa-user-secret:before { - content: "\f21b"; } - -.fa-user-shield:before { - content: "\f505"; } - -.fa-user-slash:before { - content: "\f506"; } - -.fa-user-tag:before { - content: "\f507"; } - -.fa-user-tie:before { - content: "\f508"; } - -.fa-user-times:before { - content: "\f235"; } - -.fa-users:before { - content: "\f0c0"; } - -.fa-users-cog:before { - content: "\f509"; } - -.fa-ussunnah:before { - content: "\f407"; } - -.fa-utensil-spoon:before { - content: "\f2e5"; } - -.fa-utensils:before { - content: "\f2e7"; } - -.fa-vaadin:before { - content: "\f408"; } - -.fa-vector-square:before { - content: "\f5cb"; } - -.fa-venus:before { - content: "\f221"; } - -.fa-venus-double:before { - content: "\f226"; } - -.fa-venus-mars:before { - content: "\f228"; } - -.fa-viacoin:before { - content: "\f237"; } - -.fa-viadeo:before { - content: "\f2a9"; } - -.fa-viadeo-square:before { - content: "\f2aa"; } - -.fa-vial:before { - content: "\f492"; } - -.fa-vials:before { - content: "\f493"; } - -.fa-viber:before { - content: "\f409"; } - -.fa-video:before { - content: "\f03d"; } - -.fa-video-slash:before { - content: "\f4e2"; } - -.fa-vihara:before { - content: "\f6a7"; } - -.fa-vimeo:before { - content: "\f40a"; } - -.fa-vimeo-square:before { - content: "\f194"; } - -.fa-vimeo-v:before { - content: "\f27d"; } - -.fa-vine:before { - content: "\f1ca"; } - -.fa-vk:before { - content: "\f189"; } - -.fa-vnv:before { - content: "\f40b"; } - -.fa-volleyball-ball:before { - content: "\f45f"; } - -.fa-volume-down:before { - content: "\f027"; } - -.fa-volume-off:before { - content: "\f026"; } - -.fa-volume-up:before { - content: "\f028"; } - -.fa-vuejs:before { - content: "\f41f"; } - -.fa-walking:before { - content: "\f554"; } - -.fa-wallet:before { - content: "\f555"; } - -.fa-warehouse:before { - content: "\f494"; } - -.fa-weebly:before { - content: "\f5cc"; } - -.fa-weibo:before { - content: "\f18a"; } - -.fa-weight:before { - content: "\f496"; } - -.fa-weight-hanging:before { - content: "\f5cd"; } - -.fa-weixin:before { - content: "\f1d7"; } - -.fa-whatsapp:before { - content: "\f232"; } - -.fa-whatsapp-square:before { - content: "\f40c"; } - -.fa-wheelchair:before { - content: "\f193"; } - -.fa-whmcs:before { - content: "\f40d"; } - -.fa-wifi:before { - content: "\f1eb"; } - -.fa-wikipedia-w:before { - content: "\f266"; } - -.fa-window-close:before { - content: "\f410"; } - -.fa-window-maximize:before { - content: "\f2d0"; } - -.fa-window-minimize:before { - content: "\f2d1"; } - -.fa-window-restore:before { - content: "\f2d2"; } - -.fa-windows:before { - content: "\f17a"; } - -.fa-wine-glass:before { - content: "\f4e3"; } - -.fa-wine-glass-alt:before { - content: "\f5ce"; } - -.fa-wix:before { - content: "\f5cf"; } - -.fa-wolf-pack-battalion:before { - content: "\f514"; } - -.fa-won-sign:before { - content: "\f159"; } - -.fa-wordpress:before { - content: "\f19a"; } - -.fa-wordpress-simple:before { - content: "\f411"; } - -.fa-wpbeginner:before { - content: "\f297"; } - -.fa-wpexplorer:before { - content: "\f2de"; } - -.fa-wpforms:before { - content: "\f298"; } - -.fa-wrench:before { - content: "\f0ad"; } - -.fa-x-ray:before { - content: "\f497"; } - -.fa-xbox:before { - content: "\f412"; } - -.fa-xing:before { - content: "\f168"; } - -.fa-xing-square:before { - content: "\f169"; } - -.fa-y-combinator:before { - content: "\f23b"; } - -.fa-yahoo:before { - content: "\f19e"; } - -.fa-yandex:before { - content: "\f413"; } - -.fa-yandex-international:before { - content: "\f414"; } - -.fa-yelp:before { - content: "\f1e9"; } - -.fa-yen-sign:before { - content: "\f157"; } - -.fa-yin-yang:before { - content: "\f6ad"; } - -.fa-yoast:before { - content: "\f2b1"; } - -.fa-youtube:before { - content: "\f167"; } - -.fa-youtube-square:before { - content: "\f431"; } - -.fa-zhihu:before { - content: "\f63f"; } - -.sr-only { - border: 0; - clip: rect(0, 0, 0, 0); - height: 1px; - margin: -1px; - overflow: hidden; - padding: 0; - position: absolute; - width: 1px; } - -.sr-only-focusable:active, .sr-only-focusable:focus { - clip: auto; - height: auto; - margin: 0; - overflow: visible; - position: static; - width: auto; } - -/*! - * Font Awesome Free 5.3.1 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) - */ -@font-face { - font-family: 'Font Awesome 5 Free'; - font-style: normal; - font-weight: 400; - src: url("webfonts/fa-regular-400.eot"); - src: url("webfonts/fa-regular-400.eot?#iefix") format("embedded-opentype"), url("webfonts/fa-regular-400.woff2") format("woff2"), url("webfonts/fa-regular-400.woff") format("woff"), url("webfonts/fa-regular-400.ttf") format("truetype"), url("webfonts/fa-regular-400.svg#fontawesome") format("svg"); } - -.far { - font-family: 'Font Awesome 5 Free'; - font-weight: 400; } +/* +Error: File to import not found or unreadable: bulma/sass/utilities/initial-variables. + Load paths: + C:/Program Files/Adobe/Adobe Dreamweaver CC 2019/Configuration/SassFrameworks/bourbon + C:/Program Files/Adobe/Adobe Dreamweaver CC 2019/Configuration/SassFrameworks/neat + C:/Program Files/Adobe/Adobe Dreamweaver CC 2019/Configuration/SassFrameworks/base + on line 1 of C:\Users\jkefel\Documents\Hg\scm-manager\scm-ui\styles\scm.scss + +1: @import "bulma/sass/utilities/initial-variables"; +2: @import "bulma/sass/utilities/functions"; +3: +4: +5: $blue: #33B2E8; +6: $mint: #11dfd0; + +Backtrace: +C:\Users\jkefel\Documents\Hg\scm-manager\scm-ui\styles\scm.scss:1 +C:/Program Files/Adobe/Adobe Dreamweaver CC 2019/ruby/lib/ruby/gems/2.4.0/gems/sass-3.5.3/lib/sass/tree/import_node.rb:67:in `rescue in import' +C:/Program Files/Adobe/Adobe Dreamweaver CC 2019/ruby/lib/ruby/gems/2.4.0/gems/sass-3.5.3/lib/sass/tree/import_node.rb:45:in `import' +C:/Program Files/Adobe/Adobe Dreamweaver CC 2019/ruby/lib/ruby/gems/2.4.0/gems/sass-3.5.3/lib/sass/tree/import_node.rb:28:in `imported_file' +C:/Program Files/Adobe/Adobe Dreamweaver CC 2019/ruby/lib/ruby/gems/2.4.0/gems/sass-3.5.3/lib/sass/tree/import_node.rb:37:in `css_import?' +C:/Program Files/Adobe/Adobe Dreamweaver CC 2019/ruby/lib/ruby/gems/2.4.0/gems/sass-3.5.3/lib/sass/tree/visitors/perform.rb:314:in `visit_import' +C:/Program Files/Adobe/Adobe Dreamweaver CC 2019/ruby/lib/ruby/gems/2.4.0/gems/sass-3.5.3/lib/sass/tree/visitors/base.rb:36:in `visit' +C:/Program Files/Adobe/Adobe Dreamweaver CC 2019/ruby/lib/ruby/gems/2.4.0/gems/sass-3.5.3/lib/sass/tree/visitors/perform.rb:162:in `block in visit' +C:/Program Files/Adobe/Adobe Dreamweaver CC 2019/ruby/lib/ruby/gems/2.4.0/gems/sass-3.5.3/lib/sass/stack.rb:79:in `block in with_base' +C:/Program Files/Adobe/Adobe Dreamweaver CC 2019/ruby/lib/ruby/gems/2.4.0/gems/sass-3.5.3/lib/sass/stack.rb:135:in `with_frame' +C:/Program Files/Adobe/Adobe Dreamweaver CC 2019/ruby/lib/ruby/gems/2.4.0/gems/sass-3.5.3/lib/sass/stack.rb:79:in `with_base' +C:/Program Files/Adobe/Adobe Dreamweaver CC 2019/ruby/lib/ruby/gems/2.4.0/gems/sass-3.5.3/lib/sass/tree/visitors/perform.rb:162:in `visit' +C:/Program Files/Adobe/Adobe Dreamweaver CC 2019/ruby/lib/ruby/gems/2.4.0/gems/sass-3.5.3/lib/sass/tree/visitors/base.rb:52:in `block in visit_children' +C:/Program Files/Adobe/Adobe Dreamweaver CC 2019/ruby/lib/ruby/gems/2.4.0/gems/sass-3.5.3/lib/sass/tree/visitors/base.rb:52:in `map' +C:/Program Files/Adobe/Adobe Dreamweaver CC 2019/ruby/lib/ruby/gems/2.4.0/gems/sass-3.5.3/lib/sass/tree/visitors/base.rb:52:in `visit_children' +C:/Program Files/Adobe/Adobe Dreamweaver CC 2019/ruby/lib/ruby/gems/2.4.0/gems/sass-3.5.3/lib/sass/tree/visitors/perform.rb:171:in `block in visit_children' +C:/Program Files/Adobe/Adobe Dreamweaver CC 2019/ruby/lib/ruby/gems/2.4.0/gems/sass-3.5.3/lib/sass/tree/visitors/perform.rb:183:in `with_environment' +C:/Program Files/Adobe/Adobe Dreamweaver CC 2019/ruby/lib/ruby/gems/2.4.0/gems/sass-3.5.3/lib/sass/tree/visitors/perform.rb:170:in `visit_children' +C:/Program Files/Adobe/Adobe Dreamweaver CC 2019/ruby/lib/ruby/gems/2.4.0/gems/sass-3.5.3/lib/sass/tree/visitors/base.rb:36:in `block in visit' +C:/Program Files/Adobe/Adobe Dreamweaver CC 2019/ruby/lib/ruby/gems/2.4.0/gems/sass-3.5.3/lib/sass/tree/visitors/perform.rb:190:in `visit_root' +C:/Program Files/Adobe/Adobe Dreamweaver CC 2019/ruby/lib/ruby/gems/2.4.0/gems/sass-3.5.3/lib/sass/tree/visitors/base.rb:36:in `visit' +C:/Program Files/Adobe/Adobe Dreamweaver CC 2019/ruby/lib/ruby/gems/2.4.0/gems/sass-3.5.3/lib/sass/tree/visitors/perform.rb:161:in `visit' +C:/Program Files/Adobe/Adobe Dreamweaver CC 2019/ruby/lib/ruby/gems/2.4.0/gems/sass-3.5.3/lib/sass/tree/visitors/perform.rb:10:in `visit' +C:/Program Files/Adobe/Adobe Dreamweaver CC 2019/ruby/lib/ruby/gems/2.4.0/gems/sass-3.5.3/lib/sass/tree/root_node.rb:36:in `css_tree' +C:/Program Files/Adobe/Adobe Dreamweaver CC 2019/ruby/lib/ruby/gems/2.4.0/gems/sass-3.5.3/lib/sass/tree/root_node.rb:20:in `render' +C:/Program Files/Adobe/Adobe Dreamweaver CC 2019/ruby/lib/ruby/gems/2.4.0/gems/sass-3.5.3/lib/sass/engine.rb:290:in `render' +C:/Program Files/Adobe/Adobe Dreamweaver CC 2019/ruby/lib/ruby/gems/2.4.0/gems/sass-3.5.3/lib/sass/exec/sass_scss.rb:400:in `run' +C:/Program Files/Adobe/Adobe Dreamweaver CC 2019/ruby/lib/ruby/gems/2.4.0/gems/sass-3.5.3/lib/sass/exec/sass_scss.rb:63:in `process_result' +C:/Program Files/Adobe/Adobe Dreamweaver CC 2019/ruby/lib/ruby/gems/2.4.0/gems/sass-3.5.3/lib/sass/exec/base.rb:52:in `parse' +C:/Program Files/Adobe/Adobe Dreamweaver CC 2019/ruby/lib/ruby/gems/2.4.0/gems/sass-3.5.3/lib/sass/exec/base.rb:19:in `parse!' +C:/Program Files/Adobe/Adobe Dreamweaver CC 2019/ruby/lib/ruby/gems/2.4.0/gems/sass-3.5.3/bin/sass:13:in `<top (required)>' +C:/Program Files/Adobe/Adobe Dreamweaver CC 2019/ruby/bin/sass:23:in `load' +C:/Program Files/Adobe/Adobe Dreamweaver CC 2019/ruby/bin/sass:23:in `<main>' +*/ +body:before { + white-space: pre; + font-family: monospace; + content: "Error: File to import not found or unreadable: bulma/sass/utilities/initial-variables.\A Load paths:\A C:/Program Files/Adobe/Adobe Dreamweaver CC 2019/Configuration/SassFrameworks/bourbon\A C:/Program Files/Adobe/Adobe Dreamweaver CC 2019/Configuration/SassFrameworks/neat\A C:/Program Files/Adobe/Adobe Dreamweaver CC 2019/Configuration/SassFrameworks/base\A on line 1 of C:\Users\jkefel\Documents\Hg\scm-manager\scm-ui\styles\scm.scss\A \A 1: @import \"bulma/sass/utilities/initial-variables\";\A 2: @import \"bulma/sass/utilities/functions\";\A 3: \A 4: \A 5: $blue: #33B2E8;\A 6: $mint: #11dfd0;"; } diff --git a/scm-ui/styles/scm.scss b/scm-ui/styles/scm.scss index 4d306df6ad..fc5d423fa3 100644 --- a/scm-ui/styles/scm.scss +++ b/scm-ui/styles/scm.scss @@ -1,8 +1,10 @@ @import "bulma/sass/utilities/initial-variables"; @import "bulma/sass/utilities/functions"; +$blue: #33b2e8; +$mint: #11dfd0; -$blue: #33B2E8; +$info: $blue; // $footer-background-color @@ -27,9 +29,36 @@ $blue: #33B2E8; padding: 0 0 0 3.8em !important; } +.is-word-break { + -webkit-hyphens: auto; + -moz-hyphens: auto; + -ms-hyphens: auto; + hyphens: auto; + word-break: break-all; +} + .main { min-height: calc(100vh - 260px); } + +// shown in top section when pageactions set +hr.header-with-actions { + margin-top: -10px; + + @media screen and (max-width: 768px) { + display: none; + } +} +.is-mobile-create-button-spacing { + @media screen and (max-width: 768px) { + border: 2px solid #e9f7fd; + padding: 1em 1em; + margin-top: 0 !important; + width: 100%; + text-align: center !important; + } +} + .footer { height: 50px; } @@ -43,15 +72,342 @@ $blue: #33B2E8; &:hover, &:focus { box-shadow: $box-link-hover-shadow; - } + } &:active { box-shadow: $box-link-active-shadow; } } - @import "@fortawesome/fontawesome-free/scss/fontawesome.scss"; $fa-font-path: "webfonts"; @import "@fortawesome/fontawesome-free/scss/solid.scss"; @import "diff2html/dist/diff2html"; + +// NEW STYLES + +//typography +.subtitle { + color: #666; +} +.has-border-white { + border-color: #fff !important; +} +// buttons +.button { + padding-left: 1.5em; + padding-right: 1.5em; + height: 2.5rem; + + &.is-primary { + background-color: #00d1df; + } + &.is-primary:hover, &.is-primary.is-hovered { + background-color: #00b9c6; + } + &.is-primary:active, &.is-primary.is-active { + background-color: #00a1ac; + } + &.is-primary[disabled] { + background-color: #40dde7; + } +} + +// multiline Columns +.columns.is-multiline { + .column.is-half { + width: calc(50% - 0.75rem); + max-height: 120px; + + &:nth-child(odd) { + margin-right: 1.5rem; + } + + .overlay-half-column { + position: absolute; + height: calc(120px - 1.5rem); + width: calc(50% - 3rem); + } + } + .column.is-full { + .overlay-full-column { + position: absolute; + height: calc(120px - 0.5rem); + width: calc(100% - 1.5rem); + } + } + @media screen and (max-width: 768px) { + .column.is-half { + width: 100%; + + &:nth-child(odd) { + margin-right: 0; + } + + .overlay-half-column{ + position: absolute; + height: calc(120px - 0.5rem); + width: calc(100% - 1.5rem); + } + } + } +} + +.text-box { + width: calc(50% - 0.75rem); + .shorten-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +// tables +.table { + width: 100%; + td { + border-color: #eee; + padding: 1rem; + } +} + +// card tables +.card-table { + border-collapse: separate; + border-spacing: 0px 5px; + + tr { + a { + color: #363636; + } + &:hover { + td { + background-color: whitesmoke; + &:nth-child(4) { + background-color: #e1e1e1; + } + } + a { + color: $blue; + } + } + } + td { + border-bottom: 1px solid whitesmoke; + background-color: #fafafa; + padding: 1em 1.25em; + &:first-child { + border-left: 3px solid $mint; + } + &:nth-child(4) { + background-color: whitesmoke; + } + } + &.is-hoverable tbody tr:not(.is-selected):hover { + background-color: whitesmoke; + } + thead th { + background-color: transparent; + border: none; + } +} + +// panels +.panel { + .panel-heading > .field { + margin-bottom: 0; // replace selector margin + } + .panel-block { + display: block; + } + + .panel-footer { + background-color: whitesmoke; + border-radius: 0 0 4px 4px; + color: #363636; + font-size: 1.25em; + font-weight: 300; + line-height: 1.25; + padding: 0.5em 0.75em; + + border-left: 1px solid #dbdbdb; + border-right: 1px solid #dbdbdb; + + &:last-child { + border-bottom: 1px solid #dbdbdb; + } + } +} + +// forms +form .field:not(.is-grouped) { + margin-bottom: 1rem; +} +.input, +.textarea { + /*background-color: whitesmoke;*/ + border-color: #98d8f3; + box-shadow: none; +} + +// label with help-icon compensation +.label-icon-spacing { + margin-top: 30px; + + @media screen and (max-width: 768px) { + margin-top: 0; + } +} + +// pagination +.pagination-next, +.pagination-link, +.pagination-ellipsis { + padding-left: 1.5em; + padding-right: 1.5em; + height: 2.5rem; +} +.pagination-previous, +.pagination-next { + min-width: 6.75em; +} + +// dark hero colors +.hero.is-dark { + background-color: #002e4b; + background-image: url(../images/scmManagerHero.jpg); + background-size: cover; + background-position: top center; + + .tabs.is-boxed li.is-active a, + .tabs.is-boxed li.is-active a:hover, + .tabs.is-toggle li.is-active a, + .tabs.is-toggle li.is-active a:hover { + background-color: #28b1e8; + border-color: #28b1e8; + color: #fff; + } +} + +// footer +.footer { + background-color: whitesmoke; +} + +// sidebar menu +.aside-background { + bottom: 0; + left: 50%; + position: absolute; + right: 0; + top: 0; + background-color: whitesmoke; +} +.menu { + div { + height: 100%; + /*border: 1px solid #eee;*/ + margin-bottom: 1rem; + } +} + +.menu-label { + color: #fff; + font-size: 1em; + font-weight: 600; + background-color: #bbb; + border-radius: 5px 5px 0 0; + padding: 0.5rem 1rem; + text-transform: none; + + &:last-child, + &:not(:last-child) { + margin-bottom: 0; + } +} +.menu div:first-child .menu-label { + background-color: $blue; +} +.menu-list { + a { + color: #333; + padding: 1rem; + + &.is-active { + color: $blue; + background-color: #fff; + } + } + + > li { + ul { + margin: 0; + border-top: 1px solid #eee; + + li { + border-right: none; + } + li:last-child { + border-bottom: none; + } + } + + > a.is-active:before { + position: relative; + content: " "; + background: $blue; + height: 53px; + width: 2px; + display: block; + left: -17px; + float: left; + top: -16px; + } + + border-radius: 0; + border-top: 1px solid #eee; + border-left: 1px solid #eee; + border-right: 1px solid #eee; + } + > li:first-child { + border-top: none; + } + li:last-child { + border-bottom: 1px solid #eee; + } + div { + margin-bottom: 0; + } +} + +// modal +.modal { + .modal-card-foot { + justify-content: flex-end; // pulled-right + } +} + +.sub-menu li { + line-height: 1; + + a { + padding: 0.75rem 1rem; + } + + a:before { + font-family: "Font Awesome 5 Free"; + font-weight: 900; + -webkit-font-smoothing: antialiased; + display: inline-block; + font-style: normal; + font-variant: normal; + text-rendering: auto; + line-height: 1; + content: "\f105"; + padding-right: 5px; + } + + i { + display: none; + } +} diff --git a/scm-ui/yarn.lock b/scm-ui/yarn.lock index ec5a53aecc..5c656ab243 100644 --- a/scm-ui/yarn.lock +++ b/scm-ui/yarn.lock @@ -513,6 +513,13 @@ "@babel/helper-regex" "^7.0.0" regexpu-core "^4.1.3" +"@babel/polyfill@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/polyfill/-/polyfill-7.0.0.tgz#c8ff65c9ec3be6a1ba10113ebd40e8750fb90bff" + dependencies: + core-js "^2.5.7" + regenerator-runtime "^0.11.1" + "@babel/preset-env@^7.0.0": version "7.1.0" resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.1.0.tgz#e67ea5b0441cfeab1d6f41e9b5c79798800e8d11" @@ -576,6 +583,12 @@ "@babel/plugin-transform-react-jsx-self" "^7.0.0" "@babel/plugin-transform-react-jsx-source" "^7.0.0" +"@babel/runtime@^7.1.2": + version "7.1.5" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.1.5.tgz#4170907641cf1f61508f563ece3725150cc6fe39" + dependencies: + regenerator-runtime "^0.12.0" + "@babel/template@^7.1.0", "@babel/template@^7.1.2": version "7.1.2" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.1.2.tgz#090484a574fef5a2d2d7726a674eceda5c5b5644" @@ -606,6 +619,46 @@ lodash "^4.17.10" to-fast-properties "^2.0.0" +"@emotion/babel-utils@^0.6.4": + version "0.6.10" + resolved "https://registry.yarnpkg.com/@emotion/babel-utils/-/babel-utils-0.6.10.tgz#83dbf3dfa933fae9fc566e54fbb45f14674c6ccc" + dependencies: + "@emotion/hash" "^0.6.6" + "@emotion/memoize" "^0.6.6" + "@emotion/serialize" "^0.9.1" + convert-source-map "^1.5.1" + find-root "^1.1.0" + source-map "^0.7.2" + +"@emotion/hash@^0.6.2", "@emotion/hash@^0.6.6": + version "0.6.6" + resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.6.6.tgz#62266c5f0eac6941fece302abad69f2ee7e25e44" + +"@emotion/memoize@^0.6.1", "@emotion/memoize@^0.6.6": + version "0.6.6" + resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.6.6.tgz#004b98298d04c7ca3b4f50ca2035d4f60d2eed1b" + +"@emotion/serialize@^0.9.1": + version "0.9.1" + resolved "https://registry.yarnpkg.com/@emotion/serialize/-/serialize-0.9.1.tgz#a494982a6920730dba6303eb018220a2b629c145" + dependencies: + "@emotion/hash" "^0.6.6" + "@emotion/memoize" "^0.6.6" + "@emotion/unitless" "^0.6.7" + "@emotion/utils" "^0.8.2" + +"@emotion/stylis@^0.7.0": + version "0.7.1" + resolved "https://registry.yarnpkg.com/@emotion/stylis/-/stylis-0.7.1.tgz#50f63225e712d99e2b2b39c19c70fff023793ca5" + +"@emotion/unitless@^0.6.2", "@emotion/unitless@^0.6.7": + version "0.6.7" + resolved "https://registry.yarnpkg.com/@emotion/unitless/-/unitless-0.6.7.tgz#53e9f1892f725b194d5e6a1684a7b394df592397" + +"@emotion/utils@^0.8.2": + version "0.8.2" + resolved "https://registry.yarnpkg.com/@emotion/utils/-/utils-0.8.2.tgz#576ff7fb1230185b619a75d258cbc98f0867a8dc" + "@fortawesome/fontawesome-free@^5.3.1": version "5.3.1" resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-5.3.1.tgz#5466b8f31c1f493a96754c1426c25796d0633dd9" @@ -645,9 +698,9 @@ version "0.0.2" resolved "https://registry.yarnpkg.com/@scm-manager/eslint-config/-/eslint-config-0.0.2.tgz#94cc8c3fb4f51f870b235893dc134fc6c423ae85" -"@scm-manager/ui-bundler@^0.0.21": - version "0.0.21" - resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.21.tgz#f8b5fa355415cc67b8aaf8744e1701a299dff647" +"@scm-manager/ui-bundler@^0.0.24": + version "0.0.24" + resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.24.tgz#034d5500c79b438c48d8f7ee985be07c4ea46d1e" dependencies: "@babel/core" "^7.0.0" "@babel/plugin-proposal-class-properties" "^7.0.0" @@ -685,9 +738,10 @@ vinyl-source-stream "^2.0.0" watchify "^3.11.0" -"@scm-manager/ui-extensions@^0.1.1": - version "0.1.1" - resolved "https://registry.yarnpkg.com/@scm-manager/ui-extensions/-/ui-extensions-0.1.1.tgz#966e62d89981e92a14adf7e674e646e76de96d45" +"@scm-manager/ui-extensions@^0.1.2": + version "0.1.2" + resolved "https://registry.yarnpkg.com/@scm-manager/ui-extensions/-/ui-extensions-0.1.2.tgz#0689427ca45c8e4e045b5b9dbc89036f1d2c45fc" + integrity sha512-oIkXcc/VWssnK/yjWKC/Wnq5DZ01rArsz76n4X/0DT0hkGNIKmwk/Fdp7OoXiUEb7+aaPjUX1VvDqlTwCNKPmA== dependencies: react "^16.4.2" react-dom "^16.4.2" @@ -1137,6 +1191,23 @@ babel-messages@^6.23.0: dependencies: babel-runtime "^6.22.0" +babel-plugin-emotion@^9.2.11: + version "9.2.11" + resolved "https://registry.yarnpkg.com/babel-plugin-emotion/-/babel-plugin-emotion-9.2.11.tgz#319c005a9ee1d15bb447f59fe504c35fd5807728" + dependencies: + "@babel/helper-module-imports" "^7.0.0" + "@emotion/babel-utils" "^0.6.4" + "@emotion/hash" "^0.6.2" + "@emotion/memoize" "^0.6.1" + "@emotion/stylis" "^0.7.0" + babel-plugin-macros "^2.0.0" + babel-plugin-syntax-jsx "^6.18.0" + convert-source-map "^1.5.0" + find-root "^1.1.0" + mkdirp "^0.5.1" + source-map "^0.5.7" + touch "^2.0.1" + babel-plugin-istanbul@^4.1.6: version "4.1.6" resolved "http://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-4.1.6.tgz#36c59b2192efce81c5b378321b74175add1c9a45" @@ -1150,6 +1221,17 @@ babel-plugin-jest-hoist@^23.2.0: version "23.2.0" resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-23.2.0.tgz#e61fae05a1ca8801aadee57a6d66b8cefaf44167" +babel-plugin-macros@^2.0.0: + version "2.4.2" + resolved "https://registry.yarnpkg.com/babel-plugin-macros/-/babel-plugin-macros-2.4.2.tgz#21b1a2e82e2130403c5ff785cba6548e9b644b28" + dependencies: + cosmiconfig "^5.0.5" + resolve "^1.8.1" + +babel-plugin-syntax-jsx@^6.18.0: + version "6.18.0" + resolved "http://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz#0af32a9a6e13ca7a3fd5069e62d7b0f58d0d8946" + babel-plugin-syntax-object-rest-spread@^6.13.0: version "6.13.0" resolved "http://registry.npmjs.org/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.13.0.tgz#fd6536f2bce13836ffa3a5458c4903a597bb3bf5" @@ -1660,12 +1742,24 @@ cached-path-relative@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/cached-path-relative/-/cached-path-relative-1.0.1.tgz#d09c4b52800aa4c078e2dd81a869aac90d2e54e7" +caller-callsite@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/caller-callsite/-/caller-callsite-2.0.0.tgz#847e0fce0a223750a9a027c54b33731ad3154134" + dependencies: + callsites "^2.0.0" + caller-path@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/caller-path/-/caller-path-0.1.0.tgz#94085ef63581ecd3daa92444a8fe94e82577751f" dependencies: callsites "^0.2.0" +caller-path@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/caller-path/-/caller-path-2.0.0.tgz#468f83044e369ab2010fac5f06ceee15bb2cb1f4" + dependencies: + caller-callsite "^2.0.0" + callsite@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/callsite/-/callsite-1.0.0.tgz#280398e5d664bd74038b6f0905153e6e8af1bc20" @@ -1832,7 +1926,7 @@ class-utils@^0.3.5: isobject "^3.0.0" static-extend "^0.1.1" -classnames@^2.2.5, classnames@^2.2.6: +classnames@^2.2.5: version "2.2.6" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce" @@ -2005,6 +2099,12 @@ concat-stream@^1.6.0, concat-stream@^1.6.1, concat-stream@~1.6.0: readable-stream "^2.2.2" typedarray "^0.0.6" +concat@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/concat/-/concat-1.0.3.tgz#40f3353089d65467695cb1886b45edd637d8cca8" + dependencies: + commander "^2.9.0" + connect-history-api-fallback@^1: version "1.5.0" resolved "https://registry.yarnpkg.com/connect-history-api-fallback/-/connect-history-api-fallback-1.5.0.tgz#b06873934bc5e344fef611a196a6faae0aee015a" @@ -2036,7 +2136,7 @@ contains-path@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/contains-path/-/contains-path-0.1.0.tgz#fe8cf184ff6670b6baef01a9d4861a5cbec4120a" -convert-source-map@1.X, convert-source-map@^1.1.0, convert-source-map@^1.4.0, convert-source-map@^1.5.1: +convert-source-map@1.X, convert-source-map@^1.1.0, convert-source-map@^1.4.0, convert-source-map@^1.5.0, convert-source-map@^1.5.1: version "1.6.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.6.0.tgz#51b537a8c43e0f04dec1993bffcdd504e758ac20" dependencies: @@ -2065,7 +2165,7 @@ copyfiles@^2.0.0: through2 "^2.0.1" yargs "^11.0.0" -core-js@^2.4.0, core-js@^2.5.0: +core-js@^2.4.0, core-js@^2.5.0, core-js@^2.5.7: version "2.5.7" resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.7.tgz#f972608ff0cead68b841a16a932d0b183791814e" @@ -2073,6 +2173,15 @@ core-util-is@1.0.2, core-util-is@~1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" +cosmiconfig@^5.0.5: + version "5.0.7" + resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-5.0.7.tgz#39826b292ee0d78eda137dfa3173bd1c21a43b04" + dependencies: + import-fresh "^2.0.0" + is-directory "^0.3.1" + js-yaml "^3.9.0" + parse-json "^4.0.0" + coveralls@^2.11.3: version "2.13.3" resolved "https://registry.yarnpkg.com/coveralls/-/coveralls-2.13.3.tgz#9ad7c2ae527417f361e8b626483f48ee92dd2bc7" @@ -2090,6 +2199,18 @@ create-ecdh@^4.0.0: bn.js "^4.1.0" elliptic "^6.0.0" +create-emotion@^9.2.12: + version "9.2.12" + resolved "https://registry.yarnpkg.com/create-emotion/-/create-emotion-9.2.12.tgz#0fc8e7f92c4f8bb924b0fef6781f66b1d07cb26f" + dependencies: + "@emotion/hash" "^0.6.2" + "@emotion/memoize" "^0.6.1" + "@emotion/stylis" "^0.7.0" + "@emotion/unitless" "^0.6.2" + csstype "^2.5.2" + stylis "^3.5.0" + stylis-rule-sheet "^0.0.10" + create-hash@^1.1.0, create-hash@^1.1.2: version "1.2.0" resolved "http://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz#889078af11a63756bcfb59bd221996be3a9ef196" @@ -2200,6 +2321,10 @@ cssstyle@^1.0.0: dependencies: cssom "0.3.x" +csstype@^2.5.2: + version "2.5.7" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.5.7.tgz#bf9235d5872141eccfb2d16d82993c6b149179ff" + currently-unhandled@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea" @@ -2425,14 +2550,14 @@ dev-ip@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/dev-ip/-/dev-ip-1.0.1.tgz#a76a3ed1855be7a012bb8ac16cb80f3c00dc28f0" -diff2html@^2.4.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/diff2html/-/diff2html-2.4.0.tgz#de632384eefa5a7f6b0e92eafb1fa25d22dc88ab" +diff2html@^2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/diff2html/-/diff2html-2.5.0.tgz#2d16f1a8f115354733b16b0264a594fa7db98aa2" dependencies: diff "^3.5.0" hogan.js "^3.0.2" - lodash "^4.17.10" - whatwg-fetch "^2.0.4" + lodash "^4.17.11" + whatwg-fetch "^3.0.0" diff@^3.2.0, diff@^3.5.0: version "3.5.0" @@ -2463,6 +2588,12 @@ doctrine@^2.1.0: dependencies: esutils "^2.0.2" +dom-helpers@^3.3.1: + version "3.4.0" + resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.4.0.tgz#e9b369700f959f62ecde5a6babde4bccd9169af8" + dependencies: + "@babel/runtime" "^7.1.2" + dom-serializer@0, dom-serializer@~0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.1.0.tgz#073c697546ce0780ce23be4a28e293e40bc30c82" @@ -2571,6 +2702,13 @@ emoji-regex@^6.5.1: version "6.5.1" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-6.5.1.tgz#9baea929b155565c11ea41c6626eaa65cef992c2" +emotion@^9.1.2: + version "9.2.12" + resolved "https://registry.yarnpkg.com/emotion/-/emotion-9.2.12.tgz#53925aaa005614e65c6e43db8243c843574d1ea9" + dependencies: + babel-plugin-emotion "^9.2.11" + create-emotion "^9.2.12" + encodeurl@~1.0.1, encodeurl@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" @@ -2929,11 +3067,10 @@ event-emitter@^0.3.5: es5-ext "~0.10.14" event-stream@~3.3.0: - version "3.3.6" - resolved "https://registry.yarnpkg.com/event-stream/-/event-stream-3.3.6.tgz#cac1230890e07e73ec9cacd038f60a5b66173eef" + version "3.3.5" + resolved "https://registry.yarnpkg.com/event-stream/-/event-stream-3.3.5.tgz#e5dd8989543630d94c6cf4d657120341fa31636b" dependencies: duplexer "^0.1.1" - flatmap-stream "^0.1.0" from "^0.1.7" map-stream "0.0.7" pause-stream "^0.0.11" @@ -3193,6 +3330,10 @@ find-node-modules@^1.0.4: findup-sync "0.4.2" merge "^1.2.0" +find-root@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/find-root/-/find-root-1.1.0.tgz#abcfc8ba76f708c42a97b3d685b7e9450bfb9ce4" + find-up@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/find-up/-/find-up-1.1.2.tgz#6b2e9822b1a2ce0a60ab64d610eccad53cb24d0f" @@ -3251,10 +3392,6 @@ flat-cache@^1.2.1: graceful-fs "^4.1.2" write "^0.2.1" -flatmap-stream@^0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/flatmap-stream/-/flatmap-stream-0.1.1.tgz#d34f39ef3b9aa5a2fc225016bd3adf28ac5ae6ea" - flow-bin@^0.79.1: version "0.79.1" resolved "https://registry.yarnpkg.com/flow-bin/-/flow-bin-0.79.1.tgz#01c9f427baa6556753fa878c192d42e1ecb764b6" @@ -3468,10 +3605,6 @@ getpass@^0.1.1: dependencies: assert-plus "^1.0.0" -gitdiff-parser@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/gitdiff-parser/-/gitdiff-parser-0.1.2.tgz#26a256e05e9c2d5016b512a96c1dacb40862b92a" - glob-base@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/glob-base/-/glob-base-0.3.0.tgz#dbb164f6221b1c0b1ccf82aea328b497df0ea3c4" @@ -4088,6 +4221,13 @@ immutable@^3: version "3.8.2" resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.8.2.tgz#c2439951455bb39913daf281376f1530e104adf3" +import-fresh@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-2.0.0.tgz#d81355c15612d386c61f9ddd3922d4304822a546" + dependencies: + caller-path "^2.0.0" + resolve-from "^3.0.0" + import-local@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/import-local/-/import-local-1.0.0.tgz#5e4ffdc03f4fe6c009c6729beb29631c2f8227bc" @@ -4289,6 +4429,10 @@ is-descriptor@^1.0.0, is-descriptor@^1.0.2: is-data-descriptor "^1.0.0" kind-of "^6.0.2" +is-directory@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/is-directory/-/is-directory-0.3.1.tgz#61339b6f2475fc772fd9c9d83f5c8575dc154ae1" + is-dotfile@^1.0.0: version "1.0.3" resolved "https://registry.yarnpkg.com/is-dotfile/-/is-dotfile-1.0.3.tgz#a6a2f32ffd2dfb04f5ca25ecd0f6b83cf798a1e1" @@ -4948,7 +5092,7 @@ js-yaml@3.6.1: argparse "^1.0.7" esprima "^2.6.0" -js-yaml@^3.12.0, js-yaml@^3.7.0: +js-yaml@^3.12.0, js-yaml@^3.7.0, js-yaml@^3.9.0: version "3.12.0" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.12.0.tgz#eaed656ec8344f10f527c6bfa1b6e2244de167d1" dependencies: @@ -5334,10 +5478,6 @@ lodash.escape@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/lodash.escape/-/lodash.escape-4.0.1.tgz#c9044690c21e04294beaa517712fded1fa88de98" -lodash.findlastindex@^4.6.0: - version "4.6.0" - resolved "https://registry.yarnpkg.com/lodash.findlastindex/-/lodash.findlastindex-4.6.0.tgz#b8375ac0f02e9b926375cdf8dc3ea814abf9c6ac" - lodash.flattendeep@^4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz#fb030917f86a3134e5bc9bec0d69e0013ddfedb2" @@ -5370,10 +5510,6 @@ lodash.keys@^3.0.0: lodash.isarguments "^3.0.0" lodash.isarray "^3.0.0" -lodash.mapvalues@^4.6.0: - version "4.6.0" - resolved "https://registry.yarnpkg.com/lodash.mapvalues/-/lodash.mapvalues-4.6.0.tgz#1bafa5005de9dd6f4f26668c30ca37230cc9689c" - lodash.memoize@~3.0.3: version "3.0.4" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-3.0.4.tgz#2dcbd2c287cbc0a55cc42328bd0c736150d53e3f" @@ -5411,7 +5547,7 @@ lodash.templatesettings@^3.0.0: lodash._reinterpolate "^3.0.0" lodash.escape "^3.0.0" -lodash@^4.0.0, lodash@^4.13.1, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.4, lodash@^4.17.5, lodash@~4.17.10: +lodash@^4.0.0, lodash@^4.13.1, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.4, lodash@^4.17.5, lodash@~4.17.10: version "4.17.11" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d" @@ -5423,7 +5559,7 @@ log-driver@1.2.5: version "1.2.5" resolved "https://registry.yarnpkg.com/log-driver/-/log-driver-1.2.5.tgz#7ae4ec257302fd790d557cb10c97100d857b0056" -loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1: +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" dependencies: @@ -5534,6 +5670,10 @@ mem@^1.1.0: dependencies: mimic-fn "^1.0.0" +memoize-one@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-4.0.3.tgz#cdfdd942853f1a1b4c71c5336b8c49da0bf0273c" + memoizee@0.4.X: version "0.4.14" resolved "https://registry.yarnpkg.com/memoizee/-/memoizee-0.4.14.tgz#07a00f204699f9a95c2d9e77218271c7cd610d57" @@ -5710,7 +5850,7 @@ mixin-deep@^1.2.0: mkdirp@0.3.0: version "0.3.0" - resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.3.0.tgz#1bbf5ab1ba827af23575143490426455f481fe1e" + resolved "http://registry.npmjs.org/mkdirp/-/mkdirp-0.3.0.tgz#1bbf5ab1ba827af23575143490426455f481fe1e" "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1: version "0.5.1" @@ -5951,7 +6091,7 @@ noms@0.0.0: inherits "^2.0.1" readable-stream "~1.0.31" -nopt@1.0.10: +nopt@1.0.10, nopt@~1.0.10: version "1.0.10" resolved "https://registry.yarnpkg.com/nopt/-/nopt-1.0.10.tgz#6ddd21bd2a31417b92727dd585f8a6f37608ebee" dependencies: @@ -6768,18 +6908,6 @@ rc@^1.2.7: minimist "^1.2.0" strip-json-comments "~2.0.1" -react-diff-view@^1.7.0: - version "1.8.1" - resolved "https://registry.yarnpkg.com/react-diff-view/-/react-diff-view-1.8.1.tgz#0b9b4adcb92de6730d28177d68654dfcc2097f73" - dependencies: - classnames "^2.2.6" - gitdiff-parser "^0.1.2" - leven "^2.1.0" - lodash.escape "^4.0.1" - lodash.findlastindex "^4.6.0" - lodash.mapvalues "^4.6.0" - warning "^4.0.1" - react-dom@^16.4.2: version "16.5.2" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.5.2.tgz#b69ee47aa20bab5327b2b9d7c1fe2a30f2cfa9d7" @@ -6797,6 +6925,12 @@ react-i18next@^7.9.0: html-parse-stringify2 "2.0.1" prop-types "^15.6.0" +react-input-autosize@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/react-input-autosize/-/react-input-autosize-2.2.1.tgz#ec428fa15b1592994fb5f9aa15bb1eb6baf420f8" + dependencies: + prop-types "^15.5.8" + react-is@^16.5.2: version "16.5.2" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.5.2.tgz#e2a7b7c3f5d48062eb769fcb123505eb928722e3" @@ -6811,6 +6945,10 @@ react-jss@^8.6.0: prop-types "^15.6.0" theming "^1.3.0" +react-lifecycles-compat@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" + react-redux@^5.0.7: version "5.0.7" resolved "http://registry.npmjs.org/react-redux/-/react-redux-5.0.7.tgz#0dc1076d9afb4670f993ffaef44b8f8c1155a4c8" @@ -6860,6 +6998,18 @@ react-router@^4.2.0, react-router@^4.3.1: prop-types "^15.6.1" warning "^4.0.1" +react-select@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/react-select/-/react-select-2.1.2.tgz#7a3e4c2b9efcd8c44ae7cf6ebb8b060ef69c513c" + dependencies: + classnames "^2.2.5" + emotion "^9.1.2" + memoize-one "^4.0.0" + prop-types "^15.6.0" + raf "^3.4.0" + react-input-autosize "^2.2.1" + react-transition-group "^2.2.1" + react-syntax-highlighter@^9.0.1: version "9.0.1" resolved "https://registry.yarnpkg.com/react-syntax-highlighter/-/react-syntax-highlighter-9.0.1.tgz#cad91692e1976f68290f24762ac3451b1fec3d26" @@ -6879,6 +7029,15 @@ react-test-renderer@^16.0.0-0, react-test-renderer@^16.4.1: react-is "^16.5.2" schedule "^0.5.0" +react-transition-group@^2.2.1: + version "2.5.0" + resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.5.0.tgz#70bca0e3546102c4dc5cf3f5f57f73447cce6874" + dependencies: + dom-helpers "^3.3.1" + loose-envify "^1.4.0" + prop-types "^15.6.2" + react-lifecycles-compat "^3.0.4" + react@^16.4.2: version "16.5.2" resolved "https://registry.yarnpkg.com/react/-/react-16.5.2.tgz#19f6b444ed139baa45609eee6dc3d318b3895d42" @@ -7056,10 +7215,14 @@ regenerator-runtime@^0.10.5: version "0.10.5" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.10.5.tgz#336c3efc1220adcedda2c9fab67b5a7955a33658" -regenerator-runtime@^0.11.0: +regenerator-runtime@^0.11.0, regenerator-runtime@^0.11.1: version "0.11.1" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9" +regenerator-runtime@^0.12.0: + version "0.12.1" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz#fa1a71544764c036f8c49b13a08b2594c9f8a0de" + regenerator-transform@^0.13.3: version "0.13.3" resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.13.3.tgz#264bd9ff38a8ce24b06e0636496b2c856b57bcbb" @@ -7278,7 +7441,7 @@ resolve@1.1.7: version "1.1.7" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b" -resolve@^1.1.4, resolve@^1.1.6, resolve@^1.1.7, resolve@^1.3.2, resolve@^1.4.0, resolve@^1.5.0, resolve@^1.6.0: +resolve@^1.1.4, resolve@^1.1.6, resolve@^1.1.7, resolve@^1.3.2, resolve@^1.4.0, resolve@^1.5.0, resolve@^1.6.0, resolve@^1.8.1: version "1.8.1" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.8.1.tgz#82f1ec19a423ac1fbd080b0bab06ba36e84a7a26" dependencies: @@ -7675,6 +7838,10 @@ source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" +source-map@^0.7.2: + version "0.7.3" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383" + space-separated-tokens@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/space-separated-tokens/-/space-separated-tokens-1.1.2.tgz#e95ab9d19ae841e200808cd96bc7bd0adbbb3412" @@ -7925,6 +8092,14 @@ strip-json-comments@^2.0.1, strip-json-comments@~2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" +stylis-rule-sheet@^0.0.10: + version "0.0.10" + resolved "https://registry.yarnpkg.com/stylis-rule-sheet/-/stylis-rule-sheet-0.0.10.tgz#44e64a2b076643f4b52e5ff71efc04d8c3c4a430" + +stylis@^3.5.0: + version "3.5.4" + resolved "https://registry.yarnpkg.com/stylis/-/stylis-3.5.4.tgz#f665f25f5e299cf3d64654ab949a57c768b73fbe" + subarg@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/subarg/-/subarg-1.0.0.tgz#f62cf17581e996b48fc965699f54c06ae268b8d2" @@ -8127,6 +8302,12 @@ to-regex@^3.0.1, to-regex@^3.0.2: regex-not "^1.0.2" safe-regex "^1.1.0" +touch@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/touch/-/touch-2.0.2.tgz#ca0b2a3ae3211246a61b16ba9e6cbf1596287164" + dependencies: + nopt "~1.0.10" + tough-cookie@>=2.3.3, tough-cookie@^2.3.4, tough-cookie@~2.4.3: version "2.4.3" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781" @@ -8526,9 +8707,9 @@ whatwg-encoding@^1.0.1, whatwg-encoding@^1.0.3: dependencies: iconv-lite "0.4.24" -whatwg-fetch@^2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-2.0.4.tgz#dde6a5df315f9d39991aa17621853d720b85566f" +whatwg-fetch@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz#fc804e458cc460009b1a2b966bc8817d2578aefb" whatwg-mimetype@^2.1.0: version "2.2.0" diff --git a/scm-webapp/pom.xml b/scm-webapp/pom.xml index 4dfb749690..eb0793fa8d 100644 --- a/scm-webapp/pom.xml +++ b/scm-webapp/pom.xml @@ -18,14 +18,14 @@ <dependencies> <!-- annotation processor --> - + <dependency> <groupId>sonia.scm</groupId> <artifactId>scm-annotation-processor</artifactId> <version>2.0.0-SNAPSHOT</version> <scope>provided</scope> </dependency> - + <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> @@ -69,11 +69,23 @@ <artifactId>shiro-guice</artifactId> <version>${shiro.version}</version> </dependency> - + <dependency> <groupId>io.jsonwebtoken</groupId> - <artifactId>jjwt</artifactId> - <version>0.4</version> + <artifactId>jjwt-impl</artifactId> + <version>${jjwt.version}</version> + </dependency> + + <dependency> + <groupId>io.jsonwebtoken</groupId> + <artifactId>jjwt-api</artifactId> + <version>${jjwt.version}</version> + </dependency> + + <dependency> + <groupId>io.jsonwebtoken</groupId> + <artifactId>jjwt-jackson</artifactId> + <version>${jjwt.version}</version> </dependency> <!-- json --> @@ -89,20 +101,23 @@ <artifactId>jackson-jaxrs-base</artifactId> <version>${jackson.version}</version> </dependency> + <dependency> <groupId>com.fasterxml.jackson.datatype</groupId> <artifactId>jackson-datatype-jdk8</artifactId> <version>${jackson.version}</version> </dependency> + <dependency> <groupId>com.fasterxml.jackson.datatype</groupId> <artifactId>jackson-datatype-jsr310</artifactId> <version>${jackson.version}</version> </dependency> + <dependency> - <groupId>javax</groupId> - <artifactId>javaee-api</artifactId> - <version>7.0</version> + <groupId>com.fasterxml.jackson.jaxrs</groupId> + <artifactId>jackson-jaxrs-json-provider</artifactId> + <version>${jackson.version}</version> </dependency> <!-- rest api --> @@ -136,11 +151,31 @@ <groupId>org.jboss.resteasy</groupId> <artifactId>resteasy-servlet-initializer</artifactId> </dependency> + <dependency> <groupId>org.jboss.resteasy</groupId> <artifactId>resteasy-validator-provider-11</artifactId> <version>${resteasy.version}</version> </dependency> + + <dependency> + <groupId>org.hibernate</groupId> + <artifactId>hibernate-validator</artifactId> + <version>5.3.6.Final</version> + </dependency> + + <dependency> + <groupId>javax.el</groupId> + <artifactId>javax.el-api</artifactId> + <version>2.2.4</version> + </dependency> + + <dependency> + <groupId>org.glassfish.web</groupId> + <artifactId>javax.el</artifactId> + <version>2.2.4</version> + </dependency> + <!-- injection --> <dependency> @@ -150,19 +185,18 @@ </dependency> <!-- event bus --> - + <dependency> <groupId>com.github.legman.support</groupId> <artifactId>shiro</artifactId> <version>${legman.version}</version> </dependency> - + <!-- logging --> <dependency> <groupId>ch.qos.logback</groupId> <artifactId>logback-classic</artifactId> - <version>${logback.version}</version> </dependency> <dependency> @@ -176,26 +210,22 @@ <artifactId>log4j-over-slf4j</artifactId> <version>${slf4j.version}</version> </dependency> - - <!-- only for BeanComparator, replace with own implementation --> - + + <!-- + fix java.lang.NoClassDefFoundError org/w3c/dom/ElementTraversal + --> + <dependency> - <groupId>commons-beanutils</groupId> - <artifactId>commons-beanutils</artifactId> - <version>1.9.2</version> + <groupId>xml-apis</groupId> + <artifactId>xml-apis</artifactId> + <version>1.4.01</version> </dependency> - - <dependency> - <groupId>commons-collections</groupId> - <artifactId>commons-collections</artifactId> - <version>3.2.1</version> - </dependency> - - <!-- - fix installation of httpasswd-plugin + + <!-- + fix installation of httpasswd-plugin https://groups.google.com/d/topic/scmmanager/eN7UtG8TwW8/discussion --> - + <dependency> <groupId>commons-codec</groupId> <artifactId>commons-codec</artifactId> @@ -219,17 +249,9 @@ </exclusion> </exclusions> </dependency> - - <!-- fix version conflict --> - - <dependency> - <groupId>org.apache.httpcomponents</groupId> - <artifactId>httpclient</artifactId> - <version>4.2.6</version> - </dependency> - + <!-- template engine --> - + <dependency> <groupId>com.github.spullara.mustache.java</groupId> <artifactId>compiler</artifactId> @@ -239,20 +261,21 @@ <dependency> <groupId>com.github.sdorra</groupId> <artifactId>web-resources</artifactId> - <version>1.0.2</version> + <version>1.1.0</version> </dependency> <dependency> <groupId>com.github.sdorra</groupId> <artifactId>spotter-core</artifactId> - <version>1.1.0</version> + <version>1.2.1</version> </dependency> <dependency> <groupId>org.apache.tika</groupId> <artifactId>tika-core</artifactId> - <version>1.18</version> + <version>1.20</version> </dependency> + <!-- test scope --> <dependency> @@ -267,7 +290,7 @@ </exclusion> </exclusions> </dependency> - + <dependency> <groupId>org.seleniumhq.selenium</groupId> <artifactId>selenium-java</artifactId> @@ -288,7 +311,7 @@ <version>2.21</version> <scope>test</scope> </dependency> - + <dependency> <groupId>com.sun.jersey</groupId> <artifactId>jersey-client</artifactId> @@ -310,7 +333,7 @@ <artifactId>shiro-unit</artifactId> <scope>test</scope> </dependency> - + <dependency> <groupId>sonia.scm.plugins</groupId> <artifactId>scm-git-plugin</artifactId> @@ -318,14 +341,14 @@ <classifier>tests</classifier> <scope>test</scope> </dependency> - + <dependency> <groupId>sonia.scm.plugins</groupId> <artifactId>scm-git-plugin</artifactId> <version>2.0.0-SNAPSHOT</version> <scope>test</scope> </dependency> - + <dependency> <groupId>sonia.scm.plugins</groupId> <artifactId>scm-hg-plugin</artifactId> @@ -333,14 +356,14 @@ <classifier>tests</classifier> <scope>test</scope> </dependency> - + <dependency> <groupId>sonia.scm.plugins</groupId> <artifactId>scm-hg-plugin</artifactId> <version>2.0.0-SNAPSHOT</version> <scope>test</scope> </dependency> - + <dependency> <groupId>sonia.scm.plugins</groupId> <artifactId>scm-svn-plugin</artifactId> @@ -348,7 +371,7 @@ <classifier>tests</classifier> <scope>test</scope> </dependency> - + <dependency> <groupId>sonia.scm.plugins</groupId> <artifactId>scm-svn-plugin</artifactId> @@ -479,7 +502,7 @@ <filteringDeploymentDescriptors>true</filteringDeploymentDescriptors> </configuration> </plugin> - + <plugin> <groupId>sonia.maven</groupId> <artifactId>change-env</artifactId> @@ -529,8 +552,8 @@ <jettyXml>${project.basedir}/src/main/conf/jetty.xml</jettyXml> <scanIntervalSeconds>0</scanIntervalSeconds> </configuration> - </plugin> - + </plugin> + </plugins> <finalName>scm-webapp</finalName> @@ -540,6 +563,7 @@ <scm.stage>DEVELOPMENT</scm.stage> <scm.home>target/scm-it</scm.home> <environment.profile>default</environment.profile> + <jjwt.version>0.10.5</jjwt.version> <selenium.version>2.53.1</selenium.version> <wagon.version>1.0</wagon.version> <mustache.version>0.8.17</mustache.version> @@ -885,6 +909,11 @@ <artifactId>enunciate-lombok</artifactId> <version>${enunciate.version}</version> </dependency> + <dependency> + <groupId>org.mapstruct</groupId> + <artifactId>mapstruct-processor</artifactId> + <version>${org.mapstruct.version}</version> + </dependency> </dependencies> </plugin> diff --git a/scm-webapp/src/main/java/sonia/scm/ScmServletModule.java b/scm-webapp/src/main/java/sonia/scm/ScmServletModule.java index 90764a7e00..7c7dec47ff 100644 --- a/scm-webapp/src/main/java/sonia/scm/ScmServletModule.java +++ b/scm-webapp/src/main/java/sonia/scm/ScmServletModule.java @@ -79,10 +79,12 @@ import sonia.scm.repository.spi.HookEventFacade; import sonia.scm.repository.xml.XmlRepositoryDAO; import sonia.scm.schedule.QuartzScheduler; import sonia.scm.schedule.Scheduler; +import sonia.scm.security.AccessTokenCookieIssuer; import sonia.scm.security.AuthorizationChangedEventProducer; import sonia.scm.security.CipherHandler; import sonia.scm.security.CipherUtil; import sonia.scm.security.ConfigurableLoginAttemptHandler; +import sonia.scm.security.DefaultAccessTokenCookieIssuer; import sonia.scm.security.DefaultKeyGenerator; import sonia.scm.security.DefaultSecuritySystem; import sonia.scm.security.KeyGenerator; @@ -110,9 +112,7 @@ import sonia.scm.util.ScmConfigurationUtil; import sonia.scm.web.UserAgentParser; import sonia.scm.web.cgi.CGIExecutorFactory; import sonia.scm.web.cgi.DefaultCGIExecutorFactory; -import sonia.scm.web.filter.AuthenticationFilter; import sonia.scm.web.filter.LoggingFilter; -import sonia.scm.web.protocol.HttpProtocolServlet; import sonia.scm.web.security.AdministrationContext; import sonia.scm.web.security.DefaultAdministrationContext; @@ -313,11 +313,10 @@ public class ScmServletModule extends ServletModule bind(TemplateEngineFactory.class); bind(ObjectMapper.class).toProvider(ObjectMapperProvider.class); - filter(HttpProtocolServlet.PATTERN).through(AuthenticationFilter.class); - // bind events // bind(LastModifiedUpdateListener.class); + bind(AccessTokenCookieIssuer.class).to(DefaultAccessTokenCookieIssuer.class); bind(PushStateDispatcher.class).toProvider(PushStateDispatcherProvider.class); } diff --git a/scm-webapp/src/main/java/sonia/scm/WebResourceServlet.java b/scm-webapp/src/main/java/sonia/scm/WebResourceServlet.java index 222800c9e3..0d6f40ce14 100644 --- a/scm-webapp/src/main/java/sonia/scm/WebResourceServlet.java +++ b/scm-webapp/src/main/java/sonia/scm/WebResourceServlet.java @@ -1,5 +1,6 @@ package sonia.scm; +import com.github.sdorra.webresources.CacheControl; import com.github.sdorra.webresources.WebResourceSender; import com.google.common.annotations.VisibleForTesting; import org.slf4j.Logger; @@ -44,7 +45,8 @@ public class WebResourceServlet extends HttpServlet { private final WebResourceSender sender = WebResourceSender.create() .withGZIP() .withGZIPMinLength(512) - .withBufferSize(16384); + .withBufferSize(16384) + .withCacheControl(CacheControl.create().noCache()); private final UberWebResourceLoader webResourceLoader; private final PushStateDispatcher pushStateDispatcher; diff --git a/scm-webapp/src/main/java/sonia/scm/api/FallbackExceptionMapper.java b/scm-webapp/src/main/java/sonia/scm/api/FallbackExceptionMapper.java index feb5341e2d..4815b22bdc 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/FallbackExceptionMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/FallbackExceptionMapper.java @@ -4,10 +4,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.slf4j.MDC; import sonia.scm.api.v2.resources.ErrorDto; -import sonia.scm.api.v2.resources.ExceptionWithContextToErrorDtoMapper; import sonia.scm.web.VndMediaType; -import javax.inject.Inject; import javax.ws.rs.core.Response; import javax.ws.rs.ext.ExceptionMapper; import javax.ws.rs.ext.Provider; @@ -20,16 +18,9 @@ public class FallbackExceptionMapper implements ExceptionMapper<Exception> { private static final String ERROR_CODE = "CmR8GCJb31"; - private final ExceptionWithContextToErrorDtoMapper mapper; - - @Inject - public FallbackExceptionMapper(ExceptionWithContextToErrorDtoMapper mapper) { - this.mapper = mapper; - } - @Override public Response toResponse(Exception exception) { - logger.debug("map {} to status code 500", exception); + logger.warn("mapping unexpected {} to status code 500", exception.getClass().getName(), exception); ErrorDto errorDto = new ErrorDto(); errorDto.setMessage("internal server error"); errorDto.setContext(Collections.emptyList()); diff --git a/scm-webapp/src/main/java/sonia/scm/api/JaxNotFoundExceptionMapper.java b/scm-webapp/src/main/java/sonia/scm/api/JaxNotFoundExceptionMapper.java new file mode 100644 index 0000000000..3283dedf3f --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/JaxNotFoundExceptionMapper.java @@ -0,0 +1,35 @@ +package sonia.scm.api; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; +import sonia.scm.api.v2.resources.ErrorDto; +import sonia.scm.web.VndMediaType; + +import javax.ws.rs.NotFoundException; +import javax.ws.rs.core.Response; +import javax.ws.rs.ext.ExceptionMapper; +import javax.ws.rs.ext.Provider; +import java.util.Collections; + +@Provider +public class JaxNotFoundExceptionMapper implements ExceptionMapper<NotFoundException> { + + private static final Logger logger = LoggerFactory.getLogger(JaxNotFoundExceptionMapper.class); + + private static final String ERROR_CODE = "92RCCCMHO1"; + + @Override + public Response toResponse(NotFoundException exception) { + logger.debug(exception.getMessage()); + ErrorDto errorDto = new ErrorDto(); + errorDto.setMessage("path not found"); + errorDto.setContext(Collections.emptyList()); + errorDto.setErrorCode(ERROR_CODE); + errorDto.setTransactionId(MDC.get("transaction_id")); + return Response.status(Response.Status.NOT_FOUND) + .entity(errorDto) + .type(VndMediaType.ERROR_TYPE) + .build(); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/JsonParseExceptionMapper.java b/scm-webapp/src/main/java/sonia/scm/api/JsonParseExceptionMapper.java new file mode 100644 index 0000000000..dacecb350e --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/JsonParseExceptionMapper.java @@ -0,0 +1,35 @@ +package sonia.scm.api; + +import com.fasterxml.jackson.core.JsonParseException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; +import sonia.scm.api.v2.resources.ErrorDto; +import sonia.scm.web.VndMediaType; + +import javax.ws.rs.core.Response; +import javax.ws.rs.ext.ExceptionMapper; +import javax.ws.rs.ext.Provider; +import java.util.Collections; + +@Provider +public class JsonParseExceptionMapper implements ExceptionMapper<JsonParseException> { + + private static final Logger logger = LoggerFactory.getLogger(JsonParseExceptionMapper.class); + + private static final String ERROR_CODE = "2VRCrvpL71"; + + @Override + public Response toResponse(JsonParseException exception) { + logger.trace("got illegal json: {}", exception.getMessage()); + ErrorDto errorDto = new ErrorDto(); + errorDto.setMessage("illegal json content: " + exception.getMessage()); + errorDto.setContext(Collections.emptyList()); + errorDto.setErrorCode(ERROR_CODE); + errorDto.setTransactionId(MDC.get("transaction_id")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(errorDto) + .type(VndMediaType.ERROR_TYPE) + .build(); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/NotSupportedExceptionMapper.java b/scm-webapp/src/main/java/sonia/scm/api/NotSupportedExceptionMapper.java new file mode 100644 index 0000000000..33e1e368d7 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/NotSupportedExceptionMapper.java @@ -0,0 +1,31 @@ +package sonia.scm.api; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; +import sonia.scm.api.v2.resources.ErrorDto; +import sonia.scm.web.VndMediaType; + +import javax.ws.rs.NotSupportedException; +import javax.ws.rs.core.Response; +import javax.ws.rs.ext.ExceptionMapper; +import javax.ws.rs.ext.Provider; + +@Provider +public class NotSupportedExceptionMapper implements ExceptionMapper<NotSupportedException> { + + private static final Logger LOG = LoggerFactory.getLogger(NotSupportedExceptionMapper.class); + + @Override + public Response toResponse(NotSupportedException exception) { + LOG.debug("illegal media type"); + ErrorDto error = new ErrorDto(); + error.setTransactionId(MDC.get("transaction_id")); + error.setMessage("illegal media type"); + error.setErrorCode("8pRBYDURx1"); + return Response.status(Response.Status.UNSUPPORTED_MEDIA_TYPE) + .entity(error) + .type(VndMediaType.ERROR_TYPE) + .build(); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/rest/BadRequestExceptionMapper.java b/scm-webapp/src/main/java/sonia/scm/api/rest/BadRequestExceptionMapper.java new file mode 100644 index 0000000000..e529bc7c1a --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/rest/BadRequestExceptionMapper.java @@ -0,0 +1,16 @@ +package sonia.scm.api.rest; + +import sonia.scm.BadRequestException; +import sonia.scm.api.v2.resources.ExceptionWithContextToErrorDtoMapper; + +import javax.inject.Inject; +import javax.ws.rs.core.Response; +import javax.ws.rs.ext.Provider; + +@Provider +public class BadRequestExceptionMapper extends ContextualExceptionMapper<BadRequestException> { + @Inject + public BadRequestExceptionMapper(ExceptionWithContextToErrorDtoMapper mapper) { + super(BadRequestException.class, Response.Status.BAD_REQUEST, mapper); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/rest/NotAllowedExceptionMapper.java b/scm-webapp/src/main/java/sonia/scm/api/rest/NotAllowedExceptionMapper.java new file mode 100644 index 0000000000..1d268b6855 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/rest/NotAllowedExceptionMapper.java @@ -0,0 +1,12 @@ +package sonia.scm.api.rest; + +import javax.ws.rs.NotAllowedException; +import javax.ws.rs.core.Response; +import javax.ws.rs.ext.Provider; + +@Provider +public class NotAllowedExceptionMapper extends StatusExceptionMapper<NotAllowedException> { + public NotAllowedExceptionMapper() { + super(NotAllowedException.class, Response.Status.METHOD_NOT_ALLOWED); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/rest/Permission.java b/scm-webapp/src/main/java/sonia/scm/api/rest/Permission.java deleted file mode 100644 index ba707c19c2..0000000000 --- a/scm-webapp/src/main/java/sonia/scm/api/rest/Permission.java +++ /dev/null @@ -1,168 +0,0 @@ -/** - * Copyright (c) 2010, Sebastian Sdorra - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * 3. Neither the name of SCM-Manager; nor the names of its - * contributors may be used to endorse or promote products derived from this - * software without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY - * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON - * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - * - * http://bitbucket.org/sdorra/scm-manager - * - */ - - -package sonia.scm.api.rest; - -//~--- non-JDK imports -------------------------------------------------------- - -import com.google.common.base.MoreObjects; -import com.google.common.base.Objects; - -import javax.xml.bind.annotation.XmlAccessType; -import javax.xml.bind.annotation.XmlAccessorType; -import javax.xml.bind.annotation.XmlRootElement; -import java.io.Serializable; - -//~--- JDK imports ------------------------------------------------------------ - -/** - * - * @author Sebastian Sdorra - */ -@XmlRootElement -@XmlAccessorType(XmlAccessType.FIELD) -public class Permission implements Serializable -{ - - /** Field description */ - private static final long serialVersionUID = 4320217034601679261L; - - //~--- constructors --------------------------------------------------------- - - /** - * Constructs ... - * - */ - public Permission() {} - - /** - * Constructs ... - * - * - * @param id - * @param value - */ - public Permission(String id, String value) - { - this.id = id; - this.value = value; - } - - //~--- methods -------------------------------------------------------------- - - /** - * Method description - * - * - * @param obj - * - * @return - */ - @Override - public boolean equals(Object obj) - { - if (obj == null) - { - return false; - } - - if (getClass() != obj.getClass()) - { - return false; - } - - final Permission other = (Permission) obj; - - return Objects.equal(id, other.id) && Objects.equal(value, other.value); - } - - /** - * Method description - * - * - * @return - */ - @Override - public int hashCode() - { - return Objects.hashCode(id, value); - } - - /** - * Method description - * - * - * @return - */ - @Override - public String toString() - { - //J- - return MoreObjects.toStringHelper(this) - .add("id", id) - .add("value", value) - .toString(); - //J+ - } - - //~--- get methods ---------------------------------------------------------- - - /** - * Method description - * - * - * @return - */ - public String getId() - { - return id; - } - - /** - * Method description - * - * - * @return - */ - public String getValue() - { - return value; - } - - //~--- fields --------------------------------------------------------------- - - /** Field description */ - private String id; - - /** Field description */ - private String value; -} diff --git a/scm-webapp/src/main/java/sonia/scm/api/rest/StatusExceptionMapper.java b/scm-webapp/src/main/java/sonia/scm/api/rest/StatusExceptionMapper.java index 6f4978637e..1590154369 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/rest/StatusExceptionMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/rest/StatusExceptionMapper.java @@ -36,6 +36,7 @@ package sonia.scm.api.rest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import javax.ws.rs.ext.ExceptionMapper; @@ -92,6 +93,7 @@ public class StatusExceptionMapper<E extends Throwable> return Response.status(status) .entity(exception.getMessage()) + .type(MediaType.TEXT_PLAIN_TYPE) .build(); } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/rest/resources/AbstractManagerResource.java b/scm-webapp/src/main/java/sonia/scm/api/rest/resources/AbstractManagerResource.java index f00072cdcb..dfc0bd2a5d 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/rest/resources/AbstractManagerResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/rest/resources/AbstractManagerResource.java @@ -35,7 +35,8 @@ package sonia.scm.api.rest.resources; //~--- non-JDK imports -------------------------------------------------------- -import org.apache.commons.beanutils.BeanComparator; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.net.UrlEscapers; import org.apache.shiro.authz.AuthorizationException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -45,7 +46,7 @@ import sonia.scm.ModelObject; import sonia.scm.PageResult; import sonia.scm.api.rest.RestExceptionResult; import sonia.scm.util.AssertUtil; -import sonia.scm.util.HttpUtil; +import sonia.scm.util.Comparables; import sonia.scm.util.Util; import javax.ws.rs.core.CacheControl; @@ -55,11 +56,7 @@ import javax.ws.rs.core.Request; import javax.ws.rs.core.Response; import javax.ws.rs.core.Response.Status; import javax.ws.rs.core.UriInfo; -import java.beans.BeanInfo; -import java.beans.IntrospectionException; -import java.beans.Introspector; -import java.beans.PropertyDescriptor; -import java.util.Arrays; +import java.net.URI; import java.util.Collection; import java.util.Comparator; import java.util.Date; @@ -139,11 +136,7 @@ public abstract class AbstractManagerResource<T extends ModelObject> { manager.create(item); String id = getId(item); - - id = HttpUtil.encode(id); - response = Response.created( - uriInfo.getAbsolutePath().resolve( - getPathPart().concat("/").concat(id))).build(); + response = Response.created(location(uriInfo, id)).build(); } catch (AuthorizationException ex) { @@ -159,6 +152,12 @@ public abstract class AbstractManagerResource<T extends ModelObject> { return response; } + @VisibleForTesting + URI location(UriInfo uriInfo, String id) { + String escaped = UrlEscapers.urlPathSegmentEscaper().escape(id); + return uriInfo.getAbsolutePath().resolve(getPathPart().concat("/").concat(escaped)); + } + /** * Method description * @@ -247,7 +246,7 @@ public abstract class AbstractManagerResource<T extends ModelObject> { */ public Response get(Request request, String id) { - Response response = null; + Response response; T item = manager.get(id); if (item != null) @@ -506,21 +505,11 @@ public abstract class AbstractManagerResource<T extends ModelObject> { return builder.build(); } - @SuppressWarnings("unchecked") - private Comparator<T> createComparator(String sortBy, boolean desc) - { - checkSortByField(sortBy); - Comparator comparator; - - if (desc) - { - comparator = new BeanReverseComparator(sortBy); + private Comparator<T> createComparator(String sortBy, boolean desc) { + Comparator<T> comparator = Comparables.comparator(type, sortBy); + if (desc) { + comparator = comparator.reversed(); } - else - { - comparator = new BeanComparator(sortBy); - } - return comparator; } @@ -554,21 +543,6 @@ public abstract class AbstractManagerResource<T extends ModelObject> { return items; } - // We have to handle IntrospectionException here, because it's a checked exception - // It shouldn't occur really - so creating a new unchecked exception would be over-engineered here - @SuppressWarnings("squid:S00112") - private void checkSortByField(String sortBy) { - try { - BeanInfo info = Introspector.getBeanInfo(type); - PropertyDescriptor[] pds = info.getPropertyDescriptors(); - if (Arrays.stream(pds).noneMatch(p -> p.getName().equals(sortBy))) { - throw new IllegalArgumentException("sortBy"); - } - } catch (IntrospectionException e) { - throw new RuntimeException("error introspecting model type " + type.getName(), e); - } - } - protected PageResult<T> fetchPage(String sortBy, boolean desc, int pageNumber, int pageSize) { AssertUtil.assertPositive(pageNumber); @@ -604,51 +578,4 @@ public abstract class AbstractManagerResource<T extends ModelObject> { return lastModified; } - - //~--- inner classes -------------------------------------------------------- - - /** - * Class description - * - * - * @version Enter version here..., 11/06/09 - * @author Enter your name here... - */ - private static class BeanReverseComparator extends BeanComparator - { - - /** Field description */ - private static final long serialVersionUID = -8535047820348790009L; - - //~--- constructors ------------------------------------------------------- - - /** - * Constructs ... - * - * - * @param sortby - */ - private BeanReverseComparator(String sortby) - { - super(sortby); - } - - //~--- methods ------------------------------------------------------------ - - /** - * Method description - * - * - * @param o1 - * @param o2 - * - * @return - */ - @Override - @SuppressWarnings("unchecked") - public int compare(Object o1, Object o2) - { - return super.compare(o1, o2) * -1; - } - } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/rest/resources/AbstractPermissionResource.java b/scm-webapp/src/main/java/sonia/scm/api/rest/resources/AbstractPermissionResource.java deleted file mode 100644 index 040eb6b2dc..0000000000 --- a/scm-webapp/src/main/java/sonia/scm/api/rest/resources/AbstractPermissionResource.java +++ /dev/null @@ -1,298 +0,0 @@ -/** - * Copyright (c) 2010, Sebastian Sdorra - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * 3. Neither the name of SCM-Manager; nor the names of its - * contributors may be used to endorse or promote products derived from this - * software without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY - * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON - * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - * - * http://bitbucket.org/sdorra/scm-manager - * - */ - - - -package sonia.scm.api.rest.resources; - -//~--- non-JDK imports -------------------------------------------------------- - -import com.google.common.base.Function; -import com.google.common.base.Predicate; -import com.google.common.collect.Lists; -import com.webcohesion.enunciate.metadata.rs.ResponseCode; -import com.webcohesion.enunciate.metadata.rs.ResponseHeader; -import com.webcohesion.enunciate.metadata.rs.StatusCodes; -import com.webcohesion.enunciate.metadata.rs.TypeHint; - -import sonia.scm.api.rest.Permission; -import sonia.scm.security.AssignedPermission; -import sonia.scm.security.SecuritySystem; -import sonia.scm.security.StoredAssignedPermission; - -//~--- JDK imports ------------------------------------------------------------ - -import java.net.URI; - -import java.util.List; - -import javax.ws.rs.Consumes; -import javax.ws.rs.DELETE; -import javax.ws.rs.GET; -import javax.ws.rs.POST; -import javax.ws.rs.PUT; -import javax.ws.rs.Path; -import javax.ws.rs.PathParam; -import javax.ws.rs.Produces; -import javax.ws.rs.WebApplicationException; -import javax.ws.rs.core.Context; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; -import javax.ws.rs.core.Response.Status; -import javax.ws.rs.core.UriInfo; - -/** - * Abstract base class for global permission resources. - * - * @author Sebastian Sdorra - * @since 1.31 - */ -public abstract class AbstractPermissionResource -{ - - /** - * Constructs a new {@link AbstractPermissionResource}. - * - * - * @param securitySystem security system - * @param name name of the user or group - */ - protected AbstractPermissionResource(SecuritySystem securitySystem, - String name) - { - this.securitySystem = securitySystem; - this.name = name; - } - - //~--- methods -------------------------------------------------------------- - - /** - * Transforms a {@link Permission} to a {@link AssignedPermission}. - * - * - * @param permission permission object to transform - * - * @return transformed {@link AssignedPermission} - */ - protected abstract AssignedPermission transformPermission( - Permission permission); - - //~--- get methods ---------------------------------------------------------- - - /** - * Returns a {@link Predicate} to filter permissions. - * - * - * @return {@link Predicate} to filter permissions - */ - protected abstract Predicate<AssignedPermission> getPredicate(); - - //~--- methods -------------------------------------------------------------- - - /** - * Adds a new permission to the user or group managed by the resource. - * - * @param uriInfo uri informations - * @param permission permission to add - * - * @return web response - */ - @POST - @StatusCodes({ - @ResponseCode(code = 201, condition = "creates", additionalHeaders = { - @ResponseHeader(name = "Location", description = "uri to new create permission") - }), - @ResponseCode(code = 500, condition = "internal server error") - }) - @TypeHint(TypeHint.NO_CONTENT.class) - @Consumes({ MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML }) - public Response add(@Context UriInfo uriInfo, Permission permission) - { - AssignedPermission ap = transformPermission(permission); - StoredAssignedPermission sap = securitySystem.addPermission(ap); - URI uri = uriInfo.getAbsolutePathBuilder().path(sap.getId()).build(); - - return Response.created(uri).build(); - } - - /** - * Deletes a permission from the user or group managed by the resource. - * - * @param id id of the permission - * - * @return web response - */ - @DELETE - @Path("{id}") - @StatusCodes({ - @ResponseCode(code = 204, condition = "success"), - @ResponseCode(code = 400, condition = "bad request, permission id does not belong to the user or group"), - @ResponseCode(code = 404, condition = "not found, no permission with the specified id available"), - @ResponseCode(code = 500, condition = "internal server error") - }) - @TypeHint(TypeHint.NO_CONTENT.class) - public Response delete(@PathParam("id") String id) - { - StoredAssignedPermission sap = getPermission(id); - - securitySystem.deletePermission(sap); - - return Response.noContent().build(); - } - - /** - * Updates the specified permission on the user or group managed by the resource. - * - * @param id id of the permission - * @param permission updated permission - * - * @return web response - */ - @PUT - @Path("{id}") - @StatusCodes({ - @ResponseCode(code = 204, condition = "success"), - @ResponseCode(code = 400, condition = "bad request, permission id does not belong to the user or group"), - @ResponseCode(code = 404, condition = "not found, no permission with the specified id available"), - @ResponseCode(code = 500, condition = "internal server error") - }) - @TypeHint(TypeHint.NO_CONTENT.class) - @Consumes({ MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML }) - public Response update(@PathParam("id") String id, Permission permission) - { - StoredAssignedPermission sap = getPermission(id); - - securitySystem.modifyPermission(new StoredAssignedPermission(sap.getId(), - transformPermission(permission))); - - return Response.noContent().build(); - } - - //~--- get methods ---------------------------------------------------------- - - /** - * Returns the {@link Permission} with the specified id. - * - * @param id id of the {@link Permission} - * - * @return {@link Permission} with the specified id - */ - @GET - @Path("{id}") - @StatusCodes({ - @ResponseCode(code = 204, condition = "success"), - @ResponseCode(code = 400, condition = "bad request, permission id does not belong to the user or group"), - @ResponseCode(code = 404, condition = "not found, no permission with the specified id available"), - @ResponseCode(code = 500, condition = "internal server error") - }) - @Produces({ MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML }) - public Permission get(@PathParam("id") String id) - { - StoredAssignedPermission sap = getPermission(id); - - return new Permission(sap.getId(), sap.getPermission()); - } - - /** - * Returns all permissions of the user or group managed by the resource. - * - * @return all permissions of the user or group - */ - @GET - @StatusCodes({ - @ResponseCode(code = 204, condition = "success"), - @ResponseCode(code = 500, condition = "internal server error") - }) - @Produces({ MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML }) - public List<Permission> getAll() - { - return getPermissions(getPredicate()); - } - - /** - * Returns the {@link StoredAssignedPermission} with the given id. - * - * - * @param id id of the stored permission - * - * @return {@link StoredAssignedPermission} with the given id - */ - private StoredAssignedPermission getPermission(String id) - { - StoredAssignedPermission sap = securitySystem.getPermission(id); - - if (sap == null) - { - throw new WebApplicationException(Status.NOT_FOUND); - } - - if (!getPredicate().apply(sap)) - { - throw new WebApplicationException(Status.BAD_REQUEST); - } - - return sap; - } - - /** - * Returns all permissions which matches the given {@link Predicate}. - * - * - * @param predicate predicate for filtering - * - * @return all permissions which matches the given {@link Predicate} - */ - private List<Permission> getPermissions( - Predicate<AssignedPermission> predicate) - { - List<StoredAssignedPermission> permissions = - securitySystem.getPermissions(predicate); - - return Lists.transform(permissions, - new Function<StoredAssignedPermission, Permission>() - { - - @Override - public Permission apply(StoredAssignedPermission mgp) - { - return new Permission(mgp.getId(), mgp.getPermission()); - } - }); - } - - //~--- fields --------------------------------------------------------------- - - /** name of the user or the group */ - protected String name; - - /** security system */ - private SecuritySystem securitySystem; -} diff --git a/scm-webapp/src/main/java/sonia/scm/api/rest/resources/BrowserStreamingOutput.java b/scm-webapp/src/main/java/sonia/scm/api/rest/resources/BrowserStreamingOutput.java index d2ce744c19..79b5dbc2ae 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/rest/resources/BrowserStreamingOutput.java +++ b/scm-webapp/src/main/java/sonia/scm/api/rest/resources/BrowserStreamingOutput.java @@ -6,8 +6,6 @@ import sonia.scm.repository.api.CatCommandBuilder; import sonia.scm.repository.api.RepositoryService; import sonia.scm.util.IOUtil; -import javax.ws.rs.WebApplicationException; -import javax.ws.rs.core.Response; import javax.ws.rs.core.StreamingOutput; import java.io.IOException; import java.io.OutputStream; diff --git a/scm-webapp/src/main/java/sonia/scm/api/rest/resources/DiffStreamingOutput.java b/scm-webapp/src/main/java/sonia/scm/api/rest/resources/DiffStreamingOutput.java index d177e05a5e..b7f994b967 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/rest/resources/DiffStreamingOutput.java +++ b/scm-webapp/src/main/java/sonia/scm/api/rest/resources/DiffStreamingOutput.java @@ -42,7 +42,6 @@ import sonia.scm.repository.api.RepositoryService; import sonia.scm.util.IOUtil; import javax.ws.rs.WebApplicationException; -import javax.ws.rs.core.Response; import javax.ws.rs.core.StreamingOutput; import java.io.IOException; import java.io.OutputStream; diff --git a/scm-webapp/src/main/java/sonia/scm/api/rest/resources/GroupPermissionResource.java b/scm-webapp/src/main/java/sonia/scm/api/rest/resources/GroupPermissionResource.java deleted file mode 100644 index b591d6a2a3..0000000000 --- a/scm-webapp/src/main/java/sonia/scm/api/rest/resources/GroupPermissionResource.java +++ /dev/null @@ -1,127 +0,0 @@ -/** - * Copyright (c) 2010, Sebastian Sdorra - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * 3. Neither the name of SCM-Manager; nor the names of its - * contributors may be used to endorse or promote products derived from this - * software without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY - * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON - * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - * - * http://bitbucket.org/sdorra/scm-manager - * - */ - - -package sonia.scm.api.rest.resources; - -//~--- non-JDK imports -------------------------------------------------------- - -import com.google.common.base.Predicate; - -import sonia.scm.api.rest.Permission; -import sonia.scm.security.AssignedPermission; -import sonia.scm.security.SecuritySystem; - -/** - * Resource to manage global group permission for a specified group. - * - * @author Sebastian Sdorra - * @since 1.31 - */ -public class GroupPermissionResource extends AbstractPermissionResource -{ - - /** - * Constructs a new group permissions resource - * - * - * @param securitySystem security system - * @param name name of the group - */ - public GroupPermissionResource(SecuritySystem securitySystem, String name) - { - super(securitySystem, name); - } - - //~--- methods -------------------------------------------------------------- - - /** - * {@inheritDoc} - */ - @Override - protected AssignedPermission transformPermission(Permission permission) - { - return new AssignedPermission(name, true, permission.getValue()); - } - - //~--- get methods ---------------------------------------------------------- - - /** - * {@inheritDoc} - */ - @Override - protected Predicate<AssignedPermission> getPredicate() - { - return new GroupPredicate(name); - } - - //~--- inner classes -------------------------------------------------------- - - /** - * Group predicate to filter permissions. - */ - private static class GroupPredicate implements Predicate<AssignedPermission> - { - - /** - * Constructs a new group predicate - * - * - * @param name name of the group - */ - public GroupPredicate(String name) - { - this.name = name; - } - - //~--- methods ------------------------------------------------------------ - - /** - * Returns true if the permission is a group permission and the name is - * equals. - * - * @param input permission - * - * @return true if the permission is a group permission and the name is - * equals - */ - @Override - public boolean apply(AssignedPermission input) - { - return input.isGroupPermission() && input.getName().equals(name); - } - - //~--- fields ------------------------------------------------------------- - - /** name of the group */ - private String name; - } -} diff --git a/scm-webapp/src/main/java/sonia/scm/api/rest/resources/RepositoryImportResource.java b/scm-webapp/src/main/java/sonia/scm/api/rest/resources/RepositoryImportResource.java index 6bf6c8e803..64b20fc10c 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/rest/resources/RepositoryImportResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/rest/resources/RepositoryImportResource.java @@ -46,7 +46,7 @@ import org.apache.shiro.SecurityUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.NotFoundException; -import sonia.scm.NotSupportedFeatureException; +import sonia.scm.FeatureNotSupportedException; import sonia.scm.Type; import sonia.scm.api.rest.RestActionUploadResult; import sonia.scm.api.v2.resources.RepositoryResource; @@ -394,7 +394,7 @@ public class RepositoryImportResource response = Response.ok(result).build(); } - catch (NotSupportedFeatureException ex) + catch (FeatureNotSupportedException ex) { logger .warn( @@ -609,7 +609,7 @@ public class RepositoryImportResource types.add(t); } } - catch (NotSupportedFeatureException ex) + catch (FeatureNotSupportedException ex) { if (logger.isTraceEnabled()) { @@ -711,7 +711,7 @@ public class RepositoryImportResource } } } - catch (NotSupportedFeatureException ex) + catch (FeatureNotSupportedException ex) { throw new WebApplicationException(ex, Response.Status.BAD_REQUEST); } diff --git a/scm-webapp/src/main/java/sonia/scm/api/rest/resources/SecuritySystemResource.java b/scm-webapp/src/main/java/sonia/scm/api/rest/resources/SecuritySystemResource.java deleted file mode 100644 index f9e95232db..0000000000 --- a/scm-webapp/src/main/java/sonia/scm/api/rest/resources/SecuritySystemResource.java +++ /dev/null @@ -1,106 +0,0 @@ -/** - * Copyright (c) 2010, Sebastian Sdorra - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * 3. Neither the name of SCM-Manager; nor the names of its - * contributors may be used to endorse or promote products derived from this - * software without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY - * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON - * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - * - * http://bitbucket.org/sdorra/scm-manager - * - */ - - -package sonia.scm.api.rest.resources; - -//~--- non-JDK imports -------------------------------------------------------- - -import com.google.inject.Inject; - -import org.apache.shiro.SecurityUtils; - -import sonia.scm.security.Role; -import sonia.scm.security.SecuritySystem; - -//~--- JDK imports ------------------------------------------------------------ - -import javax.ws.rs.Path; -import javax.ws.rs.PathParam; - -/** - * Resource for managing system security permissions. - * - * @author Sebastian Sdorra - */ -@Path("security/permission") -public class SecuritySystemResource -{ - - /** - * Constructs ... - * - * - * @param system - */ - @Inject - public SecuritySystemResource(SecuritySystem system) - { - this.system = system; - - // only administrators can use this resource - SecurityUtils.getSubject().checkRole(Role.ADMIN); - } - - //~--- get methods ---------------------------------------------------------- - - /** - * Returns group permission sub resource. - * - * @param group name of group - * - * @return sub resource - */ - @Path("group/{group}") - public GroupPermissionResource getGroupSubResource(@PathParam("group") String group) - { - return new GroupPermissionResource(system, group); - } - - /** - * Returns user permission sub resource. - * - * - * @param user name of user - * - * @return sub resource - */ - @Path("user/{user}") - public UserPermissionResource getUserSubResource(@PathParam("user") String user) - { - return new UserPermissionResource(system, user); - } - - //~--- fields --------------------------------------------------------------- - - /** Field description */ - private final SecuritySystem system; -} diff --git a/scm-webapp/src/main/java/sonia/scm/api/rest/resources/UserPermissionResource.java b/scm-webapp/src/main/java/sonia/scm/api/rest/resources/UserPermissionResource.java deleted file mode 100644 index 5f82fb98eb..0000000000 --- a/scm-webapp/src/main/java/sonia/scm/api/rest/resources/UserPermissionResource.java +++ /dev/null @@ -1,127 +0,0 @@ -/** - * Copyright (c) 2010, Sebastian Sdorra - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * 3. Neither the name of SCM-Manager; nor the names of its - * contributors may be used to endorse or promote products derived from this - * software without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY - * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON - * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - * - * http://bitbucket.org/sdorra/scm-manager - * - */ - - -package sonia.scm.api.rest.resources; - -//~--- non-JDK imports -------------------------------------------------------- - -import com.google.common.base.Predicate; - -import sonia.scm.api.rest.Permission; -import sonia.scm.security.AssignedPermission; -import sonia.scm.security.SecuritySystem; - -/** - * Resource to manage global user permission for a specified user. - * - * @author Sebastian Sdorra - * @since 1.31 - */ -public class UserPermissionResource extends AbstractPermissionResource -{ - - /** - * Constructs a new user permission resource. - * - * - * @param securitySystem security system - * @param name name of the user - */ - public UserPermissionResource(SecuritySystem securitySystem, String name) - { - super(securitySystem, name); - } - - //~--- methods -------------------------------------------------------------- - - /** - * {@inheritDoc} - */ - @Override - protected AssignedPermission transformPermission(Permission permission) - { - return new AssignedPermission(name, permission.getValue()); - } - - //~--- get methods ---------------------------------------------------------- - - /** - * {@inheritDoc} - */ - @Override - protected Predicate<AssignedPermission> getPredicate() - { - return new UserPredicate(name); - } - - //~--- inner classes -------------------------------------------------------- - - /** - * User predicate to filter permissions. - */ - private static class UserPredicate implements Predicate<AssignedPermission> - { - - /** - * Constructs a new user predicate. - * - * - * @param name name of the user - */ - public UserPredicate(String name) - { - this.name = name; - } - - //~--- methods ------------------------------------------------------------ - - /** - * Returns true if the permission is a user permission and the name is - * equals. - * - * @param input permission - * - * @return true if the permission is a user permission and the name is - * equals - */ - @Override - public boolean apply(AssignedPermission input) - { - return !input.isGroupPermission() && input.getName().equals(name); - } - - //~--- fields ------------------------------------------------------------- - - /** name of the user */ - private String name; - } -} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/CacheControlResponseFilter.java b/scm-webapp/src/main/java/sonia/scm/api/v2/CacheControlResponseFilter.java new file mode 100644 index 0000000000..059b48df5a --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/CacheControlResponseFilter.java @@ -0,0 +1,39 @@ +package sonia.scm.api.v2; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.ws.rs.container.ContainerRequestContext; +import javax.ws.rs.container.ContainerResponseContext; +import javax.ws.rs.container.ContainerResponseFilter; +import javax.ws.rs.ext.Provider; + +/** + * Adds the Cache-Control: no-cache header to every api call. But only if non caching headers are set to the response. + * The Cache-Control header should fix stale resources on ie. + */ +@Provider +public class CacheControlResponseFilter implements ContainerResponseFilter { + + private static final Logger LOG = LoggerFactory.getLogger(CacheControlResponseFilter.class); + + @Override + public void filter(ContainerRequestContext requestContext, ContainerResponseContext responseContext) { + if (!isCacheable(responseContext)) { + LOG.trace("add no-cache header to response"); + responseContext.getHeaders().add("Cache-Control", "no-cache"); + } + } + + private boolean isCacheable(ContainerResponseContext responseContext) { + return hasLastModifiedDate(responseContext) || hasEntityTag(responseContext); + } + + private boolean hasEntityTag(ContainerResponseContext responseContext) { + return responseContext.getEntityTag() != null; + } + + private boolean hasLastModifiedDate(ContainerResponseContext responseContext) { + return responseContext.getLastModified() != null; + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/NotSupportedFeatureExceptionMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/NotSupportedFeatureExceptionMapper.java deleted file mode 100644 index 6a48663aa5..0000000000 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/NotSupportedFeatureExceptionMapper.java +++ /dev/null @@ -1,17 +0,0 @@ -package sonia.scm.api.v2; - -import sonia.scm.NotSupportedFeatureException; -import sonia.scm.api.rest.ContextualExceptionMapper; -import sonia.scm.api.v2.resources.ExceptionWithContextToErrorDtoMapper; - -import javax.inject.Inject; -import javax.ws.rs.core.Response; -import javax.ws.rs.ext.Provider; - -@Provider -public class NotSupportedFeatureExceptionMapper extends ContextualExceptionMapper<NotSupportedFeatureException> { - @Inject - public NotSupportedFeatureExceptionMapper(ExceptionWithContextToErrorDtoMapper mapper) { - super(NotSupportedFeatureException.class, Response.Status.BAD_REQUEST, mapper); - } -} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/ValidationExceptionMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/ResteasyValidationExceptionMapper.java similarity index 62% rename from scm-webapp/src/main/java/sonia/scm/api/v2/ValidationExceptionMapper.java rename to scm-webapp/src/main/java/sonia/scm/api/v2/ResteasyValidationExceptionMapper.java index 6fadce8500..63582e10b8 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/ValidationExceptionMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/ResteasyValidationExceptionMapper.java @@ -1,7 +1,7 @@ package sonia.scm.api.v2; import org.jboss.resteasy.api.validation.ResteasyViolationException; -import sonia.scm.api.v2.resources.ViolationExceptionToErrorDtoMapper; +import sonia.scm.api.v2.resources.ResteasyViolationExceptionToErrorDtoMapper; import javax.inject.Inject; import javax.ws.rs.core.MediaType; @@ -10,12 +10,12 @@ import javax.ws.rs.ext.ExceptionMapper; import javax.ws.rs.ext.Provider; @Provider -public class ValidationExceptionMapper implements ExceptionMapper<ResteasyViolationException> { +public class ResteasyValidationExceptionMapper implements ExceptionMapper<ResteasyViolationException> { - private final ViolationExceptionToErrorDtoMapper mapper; + private final ResteasyViolationExceptionToErrorDtoMapper mapper; @Inject - public ValidationExceptionMapper(ViolationExceptionToErrorDtoMapper mapper) { + public ResteasyValidationExceptionMapper(ResteasyViolationExceptionToErrorDtoMapper mapper) { this.mapper = mapper; } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/ScmConstraintValidationExceptionMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/ScmConstraintValidationExceptionMapper.java new file mode 100644 index 0000000000..991aeedaeb --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/ScmConstraintValidationExceptionMapper.java @@ -0,0 +1,30 @@ +package sonia.scm.api.v2; + +import sonia.scm.ScmConstraintViolationException; +import sonia.scm.api.v2.resources.ScmViolationExceptionToErrorDtoMapper; + +import javax.inject.Inject; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import javax.ws.rs.ext.ExceptionMapper; +import javax.ws.rs.ext.Provider; + +@Provider +public class ScmConstraintValidationExceptionMapper implements ExceptionMapper<ScmConstraintViolationException> { + + private final ScmViolationExceptionToErrorDtoMapper mapper; + + @Inject + public ScmConstraintValidationExceptionMapper(ScmViolationExceptionToErrorDtoMapper mapper) { + this.mapper = mapper; + } + + @Override + public Response toResponse(ScmConstraintViolationException exception) { + return Response + .status(Response.Status.BAD_REQUEST) + .type(MediaType.APPLICATION_JSON_TYPE) + .entity(mapper.map(exception)) + .build(); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailableRepositoryPermissionsDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailableRepositoryPermissionsDto.java new file mode 100644 index 0000000000..60203b565b --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailableRepositoryPermissionsDto.java @@ -0,0 +1,31 @@ +package sonia.scm.api.v2.resources; + +import de.otto.edison.hal.HalRepresentation; +import de.otto.edison.hal.Links; +import sonia.scm.security.RepositoryRole; + +import java.util.Collection; + +public class AvailableRepositoryPermissionsDto extends HalRepresentation { + private final Collection<String> availableVerbs; + private final Collection<RepositoryRole> availableRoles; + + public AvailableRepositoryPermissionsDto(Collection<String> availableVerbs, Collection<RepositoryRole> availableRoles) { + this.availableVerbs = availableVerbs; + this.availableRoles = availableRoles; + } + + public Collection<String> getAvailableVerbs() { + return availableVerbs; + } + + public Collection<RepositoryRole> getAvailableRoles() { + return availableRoles; + } + + @Override + @SuppressWarnings("squid:S1185") // We want to have this method available in this package + protected HalRepresentation add(Links links) { + return super.add(links); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchDto.java index f5bdc850ab..343d9c8bc8 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchDto.java @@ -1,5 +1,6 @@ package sonia.scm.api.v2.resources; +import de.otto.edison.hal.Embedded; import de.otto.edison.hal.HalRepresentation; import de.otto.edison.hal.Links; import lombok.Getter; @@ -12,8 +13,7 @@ public class BranchDto extends HalRepresentation { private String name; private String revision; - @Override - protected HalRepresentation add(Links links) { - return super.add(links); + BranchDto(Links links, Embedded embedded) { + super(links, embedded); } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchRootResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchRootResource.java index a0d73ad24e..71b1127ad8 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchRootResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchRootResource.java @@ -26,7 +26,6 @@ import javax.ws.rs.Produces; import javax.ws.rs.QueryParam; import javax.ws.rs.core.Response; import java.io.IOException; -import java.util.List; import static sonia.scm.ContextEntry.ContextBuilder.entity; import static sonia.scm.NotFoundException.notFound; @@ -128,49 +127,6 @@ public class BranchRootResource { } } - @Path("{branch}/diffchangesets/{otherBranchName}") - @GET - @StatusCodes({ - @ResponseCode(code = 200, condition = "success"), - @ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), - @ResponseCode(code = 403, condition = "not authorized, the current user has no privileges to read the changeset"), - @ResponseCode(code = 404, condition = "not found, no changesets available in the repository"), - @ResponseCode(code = 500, condition = "internal server error") - }) - @Produces(VndMediaType.CHANGESET_COLLECTION) - @TypeHint(CollectionDto.class) - public Response changesetDiff(@PathParam("namespace") String namespace, - @PathParam("name") String name, - @PathParam("branch") String branchName, - @PathParam("otherBranchName") String otherBranchName, - @DefaultValue("0") @QueryParam("page") int page, - @DefaultValue("10") @QueryParam("pageSize") int pageSize) throws Exception { - try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) { - List<Branch> allBranches = repositoryService.getBranchesCommand().getBranches().getBranches(); - if (allBranches.stream().noneMatch(branch -> branchName.equals(branch.getName()))) { - throw new NotFoundException("branch", branchName); - } - if (allBranches.stream().noneMatch(branch -> otherBranchName.equals(branch.getName()))) { - throw new NotFoundException("branch", otherBranchName); - } - Repository repository = repositoryService.getRepository(); - RepositoryPermissions.read(repository).check(); - ChangesetPagingResult changesets = new PagedLogCommandBuilder(repositoryService) - .page(page) - .pageSize(pageSize) - .create() - .setBranch(branchName) - .setAncestorChangeset(otherBranchName) - .getChangesets(); - if (changesets != null && changesets.getChangesets() != null) { - PageResult<Changeset> pageResult = new PageResult<>(changesets.getChangesets(), changesets.getTotal()); - return Response.ok(branchChangesetCollectionToDtoMapper.map(page, pageSize, pageResult, repository, branchName)).build(); - } else { - return Response.ok().build(); - } - } - } - /** * Returns the branches for a repository. * diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapper.java index 8167e28f9a..c940b1ffd9 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapper.java @@ -1,11 +1,11 @@ package sonia.scm.api.v2.resources; +import de.otto.edison.hal.Embedded; import de.otto.edison.hal.Links; -import org.mapstruct.AfterMapping; import org.mapstruct.Context; import org.mapstruct.Mapper; import org.mapstruct.Mapping; -import org.mapstruct.MappingTarget; +import org.mapstruct.ObjectFactory; import sonia.scm.repository.Branch; import sonia.scm.repository.NamespaceAndName; @@ -15,7 +15,7 @@ import static de.otto.edison.hal.Link.linkBuilder; import static de.otto.edison.hal.Links.linkingTo; @Mapper -public abstract class BranchToBranchDtoMapper { +public abstract class BranchToBranchDtoMapper extends HalAppenderMapper { @Inject private ResourceLinks resourceLinks; @@ -23,14 +23,17 @@ public abstract class BranchToBranchDtoMapper { @Mapping(target = "attributes", ignore = true) // We do not map HAL attributes public abstract BranchDto map(Branch branch, @Context NamespaceAndName namespaceAndName); - @AfterMapping - void appendLinks(@MappingTarget BranchDto target, @Context NamespaceAndName namespaceAndName) { + @ObjectFactory + BranchDto createDto(@Context NamespaceAndName namespaceAndName, Branch branch) { Links.Builder linksBuilder = linkingTo() - .self(resourceLinks.branch().self(namespaceAndName, target.getName())) - .single(linkBuilder("history", resourceLinks.branch().history(namespaceAndName, target.getName())).build()) - .single(linkBuilder("changesetDiff", resourceLinks.branch().changesetDiff(namespaceAndName, target.getName())).build()) - .single(linkBuilder("changeset", resourceLinks.changeset().changeset(namespaceAndName.getNamespace(), namespaceAndName.getName(), target.getRevision())).build()) - .single(linkBuilder("source", resourceLinks.source().self(namespaceAndName.getNamespace(), namespaceAndName.getName(), target.getRevision())).build()); - target.add(linksBuilder.build()); + .self(resourceLinks.branch().self(namespaceAndName, branch.getName())) + .single(linkBuilder("history", resourceLinks.branch().history(namespaceAndName, branch.getName())).build()) + .single(linkBuilder("changeset", resourceLinks.changeset().changeset(namespaceAndName.getNamespace(), namespaceAndName.getName(), branch.getRevision())).build()) + .single(linkBuilder("source", resourceLinks.source().self(namespaceAndName.getNamespace(), namespaceAndName.getName(), branch.getRevision())).build()); + + Embedded.Builder embeddedBuilder = Embedded.embeddedBuilder(); + applyEnrichers(new EdisonHalAppender(linksBuilder, embeddedBuilder), branch, namespaceAndName); + + return new BranchDto(linksBuilder.build(), embeddedBuilder.build()); } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ChangePasswordNotAllowedExceptionMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ChangePasswordNotAllowedExceptionMapper.java deleted file mode 100644 index 18a6e6e75c..0000000000 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ChangePasswordNotAllowedExceptionMapper.java +++ /dev/null @@ -1,17 +0,0 @@ -package sonia.scm.api.v2.resources; - -import sonia.scm.api.rest.ContextualExceptionMapper; -import sonia.scm.user.ChangePasswordNotAllowedException; -import sonia.scm.user.InvalidPasswordException; - -import javax.inject.Inject; -import javax.ws.rs.core.Response; -import javax.ws.rs.ext.Provider; - -@Provider -public class ChangePasswordNotAllowedExceptionMapper extends ContextualExceptionMapper<ChangePasswordNotAllowedException> { - @Inject - public ChangePasswordNotAllowedExceptionMapper(ExceptionWithContextToErrorDtoMapper mapper) { - super(ChangePasswordNotAllowedException.class, Response.Status.BAD_REQUEST, mapper); - } -} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigDto.java index 4c9620564b..f77823eaac 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigDto.java @@ -23,7 +23,9 @@ public class ConfigDto extends HalRepresentation { private boolean disableGroupingGrid; private String dateFormat; private boolean anonymousAccessEnabled; + @NoBlankStrings private Set<String> adminGroups; + @NoBlankStrings private Set<String> adminUsers; private String baseUrl; private boolean forceBaseUrl; diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigResource.java index bf3a11fb9c..c646dceab4 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigResource.java @@ -9,6 +9,7 @@ import sonia.scm.util.ScmConfigurationUtil; import sonia.scm.web.VndMediaType; import javax.inject.Inject; +import javax.validation.Valid; import javax.ws.rs.Consumes; import javax.ws.rs.GET; import javax.ws.rs.PUT; @@ -71,7 +72,7 @@ public class ConfigResource { @ResponseCode(code = 500, condition = "internal server error") }) @TypeHint(TypeHint.NO_CONTENT.class) - public Response update(ConfigDto configDto) { + public Response update(@Valid ConfigDto configDto) { // This *could* be moved to ScmConfiguration or ScmConfigurationUtil classes. // But to where to check? load() or store()? Leave it for now, SCMv1 legacy that can be cleaned up later. diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ChangesetToChangesetDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DefaultChangesetToChangesetDtoMapper.java similarity index 64% rename from scm-webapp/src/main/java/sonia/scm/api/v2/resources/ChangesetToChangesetDtoMapper.java rename to scm-webapp/src/main/java/sonia/scm/api/v2/resources/DefaultChangesetToChangesetDtoMapper.java index a189dcec97..479a43aef1 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ChangesetToChangesetDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DefaultChangesetToChangesetDtoMapper.java @@ -1,11 +1,11 @@ package sonia.scm.api.v2.resources; +import de.otto.edison.hal.Embedded; import de.otto.edison.hal.Links; -import org.mapstruct.AfterMapping; import org.mapstruct.Context; import org.mapstruct.Mapper; import org.mapstruct.Mapping; -import org.mapstruct.MappingTarget; +import org.mapstruct.ObjectFactory; import sonia.scm.repository.Branch; import sonia.scm.repository.Changeset; import sonia.scm.repository.Repository; @@ -19,11 +19,12 @@ import java.util.List; import java.util.function.Function; import java.util.stream.Collectors; +import static de.otto.edison.hal.Embedded.embeddedBuilder; import static de.otto.edison.hal.Link.link; import static de.otto.edison.hal.Links.linkingTo; @Mapper -public abstract class ChangesetToChangesetDtoMapper implements InstantAttributeMapper { +public abstract class DefaultChangesetToChangesetDtoMapper extends HalAppenderMapper implements InstantAttributeMapper, ChangesetToChangesetDtoMapper{ @Inject private RepositoryServiceFactory serviceFactory; @@ -31,7 +32,6 @@ public abstract class ChangesetToChangesetDtoMapper implements InstantAttributeM @Inject private ResourceLinks resourceLinks; - @Inject private BranchCollectionToDtoMapper branchCollectionToDtoMapper; @@ -46,28 +46,35 @@ public abstract class ChangesetToChangesetDtoMapper implements InstantAttributeM public abstract ChangesetDto map(Changeset changeset, @Context Repository repository); - @AfterMapping - void appendLinks(Changeset source, @MappingTarget ChangesetDto target, @Context Repository repository) { + @ObjectFactory + ChangesetDto createDto(@Context Repository repository, Changeset source) { String namespace = repository.getNamespace(); String name = repository.getName(); + Embedded.Builder embeddedBuilder = embeddedBuilder(); + try (RepositoryService repositoryService = serviceFactory.create(repository)) { if (repositoryService.isSupported(Command.TAGS)) { - target.withEmbedded("tags", tagCollectionToDtoMapper.getTagDtoList(namespace, name, + embeddedBuilder.with("tags", tagCollectionToDtoMapper.getTagDtoList(namespace, name, getListOfObjects(source.getTags(), tagName -> new Tag(tagName, source.getId())))); } if (repositoryService.isSupported(Command.BRANCHES)) { - target.withEmbedded("branches", branchCollectionToDtoMapper.getBranchDtoList(namespace, name, + embeddedBuilder.with("branches", branchCollectionToDtoMapper.getBranchDtoList(namespace, name, getListOfObjects(source.getBranches(), branchName -> new Branch(branchName, source.getId())))); } } - target.withEmbedded("parents", getListOfObjects(source.getParents(), parent -> changesetToParentDtoMapper.map(new Changeset(parent, 0L, null), repository))); + embeddedBuilder.with("parents", getListOfObjects(source.getParents(), parent -> changesetToParentDtoMapper.map(new Changeset(parent, 0L, null), repository))); Links.Builder linksBuilder = linkingTo() - .self(resourceLinks.changeset().self(repository.getNamespace(), repository.getName(), target.getId())) - .single(link("diff", resourceLinks.diff().self(namespace, name, target.getId()))) - .single(link("modifications", resourceLinks.modifications().self(namespace, name, target.getId()))); - target.add(linksBuilder.build()); + .self(resourceLinks.changeset().self(repository.getNamespace(), repository.getName(), source.getId())) + .single(link("diff", resourceLinks.diff().self(namespace, name, source.getId()))) + .single(link("sources", resourceLinks.source().self(namespace, name, source.getId()))) + .single(link("modifications", resourceLinks.modifications().self(namespace, name, source.getId()))); + + + applyEnrichers(new EdisonHalAppender(linksBuilder, embeddedBuilder), source, repository); + + return new ChangesetDto(linksBuilder.build(), embeddedBuilder.build()); } private <T> List<T> getListOfObjects(List<String> list, Function<String, T> mapFunction) { diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DiffRootResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DiffRootResource.java index e236b54005..4d15b773cd 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DiffRootResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DiffRootResource.java @@ -25,7 +25,7 @@ public class DiffRootResource { public static final String HEADER_CONTENT_DISPOSITION = "Content-Disposition"; - private static final String DIFF_FORMAT_VALUES_REGEX = "NATIVE|GIT|UNIFIED"; + static final String DIFF_FORMAT_VALUES_REGEX = "NATIVE|GIT|UNIFIED"; private final RepositoryServiceFactory serviceFactory; diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/EdisonHalAppender.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/EdisonHalAppender.java new file mode 100644 index 0000000000..769de2b705 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/EdisonHalAppender.java @@ -0,0 +1,58 @@ +package sonia.scm.api.v2.resources; + +import de.otto.edison.hal.Embedded; +import de.otto.edison.hal.HalRepresentation; +import de.otto.edison.hal.Link; +import de.otto.edison.hal.Links; + +import java.util.ArrayList; +import java.util.List; + +class EdisonHalAppender implements HalAppender { + + private final Links.Builder linkBuilder; + private final Embedded.Builder embeddedBuilder; + + EdisonHalAppender(Links.Builder linkBuilder, Embedded.Builder embeddedBuilder) { + this.linkBuilder = linkBuilder; + this.embeddedBuilder = embeddedBuilder; + } + + @Override + public void appendLink(String rel, String href) { + linkBuilder.single(Link.link(rel, href)); + } + + @Override + public LinkArrayBuilder linkArrayBuilder(String rel) { + return new EdisonLinkArrayBuilder(linkBuilder, rel); + } + + @Override + public void appendEmbedded(String rel, HalRepresentation embedded) { + embeddedBuilder.with(rel, embedded); + } + + private static class EdisonLinkArrayBuilder implements LinkArrayBuilder { + + private final Links.Builder builder; + private final String rel; + private final List<Link> linkArray = new ArrayList<>(); + + private EdisonLinkArrayBuilder(Links.Builder builder, String rel) { + this.builder = builder; + this.rel = rel; + } + + @Override + public LinkArrayBuilder append(String name, String href) { + linkArray.add(Link.linkBuilder(rel, href).withName(name).build()); + return this; + } + + @Override + public void build() { + builder.array(linkArray); + } + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ErrorDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ErrorDto.java index bd889d5de5..d155fbede6 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ErrorDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ErrorDto.java @@ -5,7 +5,6 @@ import lombok.Getter; import lombok.Setter; import sonia.scm.ContextEntry; -import javax.xml.bind.annotation.XmlElement; import javax.xml.bind.annotation.XmlElementWrapper; import javax.xml.bind.annotation.XmlRootElement; import java.util.List; diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/FileObjectDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/FileObjectDto.java index c183d731c6..0bce564e35 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/FileObjectDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/FileObjectDto.java @@ -1,6 +1,7 @@ package sonia.scm.api.v2.resources; import com.fasterxml.jackson.annotation.JsonInclude; +import de.otto.edison.hal.Embedded; import de.otto.edison.hal.HalRepresentation; import de.otto.edison.hal.Links; import lombok.Getter; @@ -27,10 +28,8 @@ public class FileObjectDto extends HalRepresentation { @JsonInclude(JsonInclude.Include.NON_EMPTY) private String revision; - @Override - @SuppressWarnings("squid:S1185") // We want to have this method available in this package - protected HalRepresentation add(Links links) { - return super.add(links); + public FileObjectDto(Links links, Embedded embedded) { + super(links, embedded); } public void setChildren(List<FileObjectDto> children) { diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/FileObjectToFileObjectDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/FileObjectToFileObjectDtoMapper.java index 7ba8d21c75..608dea9f26 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/FileObjectToFileObjectDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/FileObjectToFileObjectDtoMapper.java @@ -1,24 +1,22 @@ package sonia.scm.api.v2.resources; +import de.otto.edison.hal.Embedded; import de.otto.edison.hal.Links; -import org.mapstruct.AfterMapping; import org.mapstruct.Context; import org.mapstruct.Mapper; import org.mapstruct.Mapping; -import org.mapstruct.MappingTarget; +import org.mapstruct.ObjectFactory; import sonia.scm.repository.FileObject; import sonia.scm.repository.NamespaceAndName; import sonia.scm.repository.SubRepository; import javax.inject.Inject; -import java.util.List; -import java.util.stream.Collectors; - +import static de.otto.edison.hal.Embedded.embeddedBuilder; import static de.otto.edison.hal.Link.link; @Mapper -public abstract class FileObjectToFileObjectDtoMapper implements InstantAttributeMapper { +public abstract class FileObjectToFileObjectDtoMapper extends HalAppenderMapper implements InstantAttributeMapper { @Inject private ResourceLinks resourceLinks; @@ -28,18 +26,21 @@ public abstract class FileObjectToFileObjectDtoMapper implements InstantAttribut abstract SubRepositoryDto mapSubrepository(SubRepository subRepository); - @AfterMapping - void addLinks(FileObject fileObject, @MappingTarget FileObjectDto dto, @Context NamespaceAndName namespaceAndName, @Context String revision) { + @ObjectFactory + FileObjectDto createDto(@Context NamespaceAndName namespaceAndName, @Context String revision, FileObject fileObject) { String path = removeFirstSlash(fileObject.getPath()); Links.Builder links = Links.linkingTo(); - if (dto.isDirectory()) { + if (fileObject.isDirectory()) { links.self(resourceLinks.source().sourceWithPath(namespaceAndName.getNamespace(), namespaceAndName.getName(), revision, path)); } else { links.self(resourceLinks.source().content(namespaceAndName.getNamespace(), namespaceAndName.getName(), revision, path)); links.single(link("history", resourceLinks.fileHistory().self(namespaceAndName.getNamespace(), namespaceAndName.getName(), revision, path))); } - dto.add(links.build()); + Embedded.Builder embeddedBuilder = embeddedBuilder(); + applyEnrichers(new EdisonHalAppender(links, embeddedBuilder), fileObject, namespaceAndName, revision); + + return new FileObjectDto(links.build(), embeddedBuilder.build()); } private String removeFirstSlash(String source) { diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GlobalPermissionResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GlobalPermissionResource.java new file mode 100644 index 0000000000..f9cd015f45 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GlobalPermissionResource.java @@ -0,0 +1,30 @@ +package sonia.scm.api.v2.resources; + +import sonia.scm.security.PermissionAssigner; +import sonia.scm.security.PermissionDescriptor; +import sonia.scm.web.VndMediaType; + +import javax.inject.Inject; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Response; + +@Path("v2/permissions") +public class GlobalPermissionResource { + + private PermissionAssigner permissionAssigner; + + @Inject + public GlobalPermissionResource(PermissionAssigner permissionAssigner) { + this.permissionAssigner = permissionAssigner; + } + + @GET + @Produces(VndMediaType.PERMISSION_COLLECTION) + @Path("") + public Response getAll() { + String[] permissions = permissionAssigner.getAvailablePermissions().stream().map(PermissionDescriptor::getValue).toArray(String[]::new); + return Response.ok(new PermissionListDto(permissions)).build(); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupCollectionResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupCollectionResource.java index 4c111e6707..6c13dc33a5 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupCollectionResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupCollectionResource.java @@ -10,7 +10,6 @@ import sonia.scm.group.GroupManager; import sonia.scm.web.VndMediaType; import javax.inject.Inject; -import javax.inject.Named; import javax.validation.Valid; import javax.ws.rs.Consumes; import javax.ws.rs.DefaultValue; diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupDto.java index 760beab1da..a150570316 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupDto.java @@ -1,12 +1,12 @@ package sonia.scm.api.v2.resources; import com.fasterxml.jackson.annotation.JsonInclude; +import de.otto.edison.hal.Embedded; import de.otto.edison.hal.HalRepresentation; import de.otto.edison.hal.Links; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; -import org.hibernate.validator.constraints.NotEmpty; import javax.validation.constraints.Pattern; import java.time.Instant; @@ -28,13 +28,7 @@ public class GroupDto extends HalRepresentation { private Map<String, String> properties; private List<String> members; - @Override - @SuppressWarnings("squid:S1185") // We want to have this method available in this package - protected HalRepresentation add(Links links) { - return super.add(links); - } - - public HalRepresentation withMembers(List<MemberDto> members) { - return super.withEmbedded("members", members); + GroupDto(Links links, Embedded embedded) { + super(links, embedded); } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupDtoToGroupMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupDtoToGroupMapper.java index be1aca5814..3812f700da 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupDtoToGroupMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupDtoToGroupMapper.java @@ -4,8 +4,6 @@ import org.mapstruct.Mapper; import org.mapstruct.Mapping; import sonia.scm.group.Group; -import java.time.Instant; - @Mapper public abstract class GroupDtoToGroupMapper extends BaseDtoMapper { diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupPermissionResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupPermissionResource.java new file mode 100644 index 0000000000..11934abcb0 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupPermissionResource.java @@ -0,0 +1,79 @@ +package sonia.scm.api.v2.resources; + +import com.webcohesion.enunciate.metadata.rs.ResponseCode; +import com.webcohesion.enunciate.metadata.rs.StatusCodes; +import com.webcohesion.enunciate.metadata.rs.TypeHint; +import sonia.scm.security.PermissionAssigner; +import sonia.scm.security.PermissionDescriptor; +import sonia.scm.web.VndMediaType; + +import javax.inject.Inject; +import javax.ws.rs.Consumes; +import javax.ws.rs.GET; +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Response; +import java.util.Arrays; +import java.util.Collection; +import java.util.stream.Collectors; + +public class GroupPermissionResource { + + private final PermissionAssigner permissionAssigner; + private final PermissionCollectionToDtoMapper permissionCollectionToDtoMapper; + + @Inject + public GroupPermissionResource(PermissionAssigner permissionAssigner, PermissionCollectionToDtoMapper permissionCollectionToDtoMapper) { + this.permissionAssigner = permissionAssigner; + this.permissionCollectionToDtoMapper = permissionCollectionToDtoMapper; + } + + /** + * Returns permissions for a group. + * + * @param id the id/name of the group + */ + @GET + @Path("") + @Produces(VndMediaType.PERMISSION_COLLECTION) + @TypeHint(PermissionListDto.class) + @StatusCodes({ + @ResponseCode(code = 200, condition = "success"), + @ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), + @ResponseCode(code = 403, condition = "not authorized, the current user has no privileges to read the group"), + @ResponseCode(code = 404, condition = "not found, no group with the specified id/name available"), + @ResponseCode(code = 500, condition = "internal server error") + }) + public Response getPermissions(@PathParam("id") String id) { + Collection<PermissionDescriptor> permissions = permissionAssigner.readPermissionsForGroup(id); + return Response.ok(permissionCollectionToDtoMapper.mapForGroup(permissions, id)).build(); + } + + /** + * Sets permissions for a group. Overwrites all existing permissions. + * + * @param id id of the group to be modified + * @param newPermissions New list of permissions for the group + */ + @PUT + @Path("") + @Consumes(VndMediaType.PERMISSION_COLLECTION) + @StatusCodes({ + @ResponseCode(code = 204, condition = "update success"), + @ResponseCode(code = 400, condition = "Invalid body"), + @ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), + @ResponseCode(code = 403, condition = "not authorized, the current group does not have the correct privilege"), + @ResponseCode(code = 404, condition = "not found, no group with the specified id/name available"), + @ResponseCode(code = 500, condition = "internal server error") + }) + @TypeHint(TypeHint.NO_CONTENT.class) + public Response overwritePermissions(@PathParam("id") String id, PermissionListDto newPermissions) { + Collection<PermissionDescriptor> permissionDescriptors = Arrays.stream(newPermissions.getPermissions()) + .map(PermissionDescriptor::new) + .collect(Collectors.toList()); + permissionAssigner.setPermissionsForGroup(id, permissionDescriptors); + return Response.noContent().build(); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupResource.java index 6d0b921d02..cfc1916fc1 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupResource.java @@ -8,7 +8,6 @@ import sonia.scm.group.GroupManager; import sonia.scm.web.VndMediaType; import javax.inject.Inject; -import javax.inject.Named; import javax.validation.Valid; import javax.ws.rs.Consumes; import javax.ws.rs.DELETE; @@ -24,13 +23,15 @@ public class GroupResource { private final GroupToGroupDtoMapper groupToGroupDtoMapper; private final GroupDtoToGroupMapper dtoToGroupMapper; private final IdResourceManagerAdapter<Group, GroupDto> adapter; + private final GroupPermissionResource groupPermissionResource; @Inject public GroupResource(GroupManager manager, GroupToGroupDtoMapper groupToGroupDtoMapper, - GroupDtoToGroupMapper groupDtoToGroupMapper) { + GroupDtoToGroupMapper groupDtoToGroupMapper, GroupPermissionResource groupPermissionResource) { this.groupToGroupDtoMapper = groupToGroupDtoMapper; this.dtoToGroupMapper = groupDtoToGroupMapper; this.adapter = new IdResourceManagerAdapter<>(manager, Group.class); + this.groupPermissionResource = groupPermissionResource; } /** @@ -100,4 +101,9 @@ public class GroupResource { public Response update(@PathParam("id") String name, @Valid GroupDto group) { return adapter.update(name, existing -> dtoToGroupMapper.map(group)); } + + @Path("permissions") + public GroupPermissionResource permissions() { + return groupPermissionResource; + } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupToGroupDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupToGroupDtoMapper.java index d03fc94387..7d5ddae548 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupToGroupDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupToGroupDtoMapper.java @@ -1,16 +1,18 @@ package sonia.scm.api.v2.resources; +import de.otto.edison.hal.Embedded; import de.otto.edison.hal.Links; -import org.mapstruct.AfterMapping; import org.mapstruct.Mapper; -import org.mapstruct.MappingTarget; +import org.mapstruct.ObjectFactory; import sonia.scm.group.Group; import sonia.scm.group.GroupPermissions; +import sonia.scm.security.PermissionPermissions; import javax.inject.Inject; import java.util.List; import java.util.stream.Collectors; +import static de.otto.edison.hal.Embedded.embeddedBuilder; import static de.otto.edison.hal.Link.link; import static de.otto.edison.hal.Links.linkingTo; @@ -22,22 +24,26 @@ public abstract class GroupToGroupDtoMapper extends BaseMapper<Group, GroupDto> @Inject private ResourceLinks resourceLinks; - @AfterMapping - void appendLinks(Group group, @MappingTarget GroupDto target) { - Links.Builder linksBuilder = linkingTo().self(resourceLinks.group().self(target.getName())); + @ObjectFactory + GroupDto createDto(Group group) { + Links.Builder linksBuilder = linkingTo().self(resourceLinks.group().self(group.getName())); if (GroupPermissions.delete(group).isPermitted()) { - linksBuilder.single(link("delete", resourceLinks.group().delete(target.getName()))); + linksBuilder.single(link("delete", resourceLinks.group().delete(group.getName()))); } if (GroupPermissions.modify(group).isPermitted()) { - linksBuilder.single(link("update", resourceLinks.group().update(target.getName()))); + linksBuilder.single(link("update", resourceLinks.group().update(group.getName()))); + } + if (PermissionPermissions.read().isPermitted()) { + linksBuilder.single(link("permissions", resourceLinks.groupPermissions().permissions(group.getName()))); } - target.add(linksBuilder.build()); - } - @AfterMapping - void mapMembers(Group group, @MappingTarget GroupDto target) { + Embedded.Builder embeddedBuilder = embeddedBuilder(); List<MemberDto> memberDtos = group.getMembers().stream().map(this::createMember).collect(Collectors.toList()); - target.withMembers(memberDtos); + embeddedBuilder.with("members", memberDtos); + + applyEnrichers(new EdisonHalAppender(linksBuilder, embeddedBuilder), group); + + return new GroupDto(linksBuilder.build(), embeddedBuilder.build()); } private MemberDto createMember(String name) { diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IncomingChangesetCollectionToDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IncomingChangesetCollectionToDtoMapper.java new file mode 100644 index 0000000000..a2aaabb1a2 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IncomingChangesetCollectionToDtoMapper.java @@ -0,0 +1,29 @@ +package sonia.scm.api.v2.resources; + +import sonia.scm.PageResult; +import sonia.scm.repository.Changeset; +import sonia.scm.repository.Repository; + +import javax.inject.Inject; + +public class IncomingChangesetCollectionToDtoMapper extends ChangesetCollectionToDtoMapper { + + + private final ResourceLinks resourceLinks; + + @Inject + public IncomingChangesetCollectionToDtoMapper(ChangesetToChangesetDtoMapper changesetToChangesetDtoMapper, ResourceLinks resourceLinks) { + super(changesetToChangesetDtoMapper, resourceLinks); + this.resourceLinks = resourceLinks; + } + + public CollectionDto map(int pageNumber, int pageSize, PageResult<Changeset> pageResult, Repository repository, String source, String target) { + return super.map(pageNumber, pageSize, pageResult, repository, () -> createSelfLink(repository, source, target)); + } + + private String createSelfLink(Repository repository, String source, String target) { + return resourceLinks.incoming().changesets(repository.getNamespace(), repository.getName(), source, target); + } + + +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IncomingRootResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IncomingRootResource.java new file mode 100644 index 0000000000..4c43485abd --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IncomingRootResource.java @@ -0,0 +1,153 @@ +package sonia.scm.api.v2.resources; + +import com.google.inject.Inject; +import com.webcohesion.enunciate.metadata.rs.ResponseCode; +import com.webcohesion.enunciate.metadata.rs.StatusCodes; +import com.webcohesion.enunciate.metadata.rs.TypeHint; +import sonia.scm.PageResult; +import sonia.scm.repository.Changeset; +import sonia.scm.repository.ChangesetPagingResult; +import sonia.scm.repository.NamespaceAndName; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryPermissions; +import sonia.scm.repository.api.DiffFormat; +import sonia.scm.repository.api.RepositoryService; +import sonia.scm.repository.api.RepositoryServiceFactory; +import sonia.scm.util.HttpUtil; +import sonia.scm.web.VndMediaType; + +import javax.validation.constraints.Pattern; +import javax.ws.rs.DefaultValue; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.StreamingOutput; +import java.io.IOException; + +import static sonia.scm.api.v2.resources.DiffRootResource.DIFF_FORMAT_VALUES_REGEX; +import static sonia.scm.api.v2.resources.DiffRootResource.HEADER_CONTENT_DISPOSITION; + +public class IncomingRootResource { + + + private final RepositoryServiceFactory serviceFactory; + + private final IncomingChangesetCollectionToDtoMapper mapper; + + + @Inject + public IncomingRootResource(RepositoryServiceFactory serviceFactory, IncomingChangesetCollectionToDtoMapper incomingChangesetCollectionToDtoMapper) { + this.serviceFactory = serviceFactory; + this.mapper = incomingChangesetCollectionToDtoMapper; + } + + /** + * Get the incoming changesets from <code>source</code> to <code>target</code> + * <p> + * Example: + * <p> + * - master + * - | + * - _______________ ° m1 + * - e | + * - | ° m2 + * - ° e1 | + * - ______|_______ | + * - | | b + * - f a | + * - | | ° b1 + * - ° f1 ° a1 | + * - ° b2 + * - + * <p> + * - /incoming/a/master/changesets -> a1 , e1 + * - /incoming/b/master/changesets -> b1 , b2 + * - /incoming/b/f/changesets -> b1 , b2, m2 + * - /incoming/f/b/changesets -> f1 , e1 + * - /incoming/a/b/changesets -> a1 , e1 + * - /incoming/a/b/changesets -> a1 , e1 + * + * @param namespace + * @param name + * @param source can be a changeset id or a branch name + * @param target can be a changeset id or a branch name + * @param page + * @param pageSize + * @return + * @throws Exception + */ + @Path("{source}/{target}/changesets") + @GET + @StatusCodes({ + @ResponseCode(code = 200, condition = "success"), + @ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), + @ResponseCode(code = 403, condition = "not authorized, the current user has no privileges to read the changeset"), + @ResponseCode(code = 404, condition = "not found, no changesets available in the repository"), + @ResponseCode(code = 500, condition = "internal server error") + }) + @Produces(VndMediaType.CHANGESET_COLLECTION) + @TypeHint(CollectionDto.class) + public Response incomingChangesets(@PathParam("namespace") String namespace, + @PathParam("name") String name, + @PathParam("source") String source, + @PathParam("target") String target, + @DefaultValue("0") @QueryParam("page") int page, + @DefaultValue("10") @QueryParam("pageSize") int pageSize) throws IOException { + try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) { + Repository repository = repositoryService.getRepository(); + RepositoryPermissions.read(repository).check(); + ChangesetPagingResult changesets = new PagedLogCommandBuilder(repositoryService) + .page(page) + .pageSize(pageSize) + .create() + .setStartChangeset(source) + .setAncestorChangeset(target) + .getChangesets(); + if (changesets != null && changesets.getChangesets() != null) { + PageResult<Changeset> pageResult = new PageResult<>(changesets.getChangesets(), changesets.getTotal()); + return Response.ok(mapper.map(page, pageSize, pageResult, repository, source, target)).build(); + } else { + return Response.ok().build(); + } + } + } + + + @Path("{source}/{target}/diff") + @GET + @StatusCodes({ + @ResponseCode(code = 200, condition = "success"), + @ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), + @ResponseCode(code = 403, condition = "not authorized, the current user has no privileges to read the changeset"), + @ResponseCode(code = 404, condition = "not found, no changesets available in the repository"), + @ResponseCode(code = 500, condition = "internal server error") + }) + @Produces(VndMediaType.DIFF) + @TypeHint(CollectionDto.class) + public Response incomingDiff(@PathParam("namespace") String namespace, + @PathParam("name") String name, + @PathParam("source") String source, + @PathParam("target") String target, + @Pattern(regexp = DIFF_FORMAT_VALUES_REGEX) @DefaultValue("NATIVE") @QueryParam("format") String format) throws IOException { + + + HttpUtil.checkForCRLFInjection(source); + HttpUtil.checkForCRLFInjection(target); + DiffFormat diffFormat = DiffFormat.valueOf(format); + try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) { + StreamingOutput responseEntry = output -> + repositoryService.getDiffCommand() + .setRevision(source) + .setAncestorChangeset(target) + .setFormat(diffFormat) + .retrieveContent(output); + + return Response.ok(responseEntry) + .header(HEADER_CONTENT_DISPOSITION, HttpUtil.createContentDispositionAttachmentHeader(String.format("%s-%s.diff", name, source))) + .build(); + } + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDto.java index 9346420f58..16f945332d 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDto.java @@ -1,5 +1,6 @@ package sonia.scm.api.v2.resources; +import de.otto.edison.hal.Embedded; import de.otto.edison.hal.HalRepresentation; import de.otto.edison.hal.Links; import lombok.Getter; @@ -9,8 +10,8 @@ public class IndexDto extends HalRepresentation { private final String version; - IndexDto(String version, Links links) { - super(links); + IndexDto(Links links, Embedded embedded, String version) { + super(links, embedded); this.version = version; } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java index da4368d9b9..90445bcdc2 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java @@ -1,20 +1,23 @@ package sonia.scm.api.v2.resources; import com.google.common.collect.Lists; +import de.otto.edison.hal.Embedded; import de.otto.edison.hal.Link; import de.otto.edison.hal.Links; import org.apache.shiro.SecurityUtils; import sonia.scm.SCMContextProvider; import sonia.scm.config.ConfigurationPermissions; import sonia.scm.group.GroupPermissions; +import sonia.scm.security.PermissionPermissions; import sonia.scm.user.UserPermissions; import javax.inject.Inject; import java.util.List; +import static de.otto.edison.hal.Embedded.embeddedBuilder; import static de.otto.edison.hal.Link.link; -public class IndexDtoGenerator { +public class IndexDtoGenerator extends HalAppenderMapper { private final ResourceLinks resourceLinks; private final SCMContextProvider scmContextProvider; @@ -52,10 +55,17 @@ public class IndexDtoGenerator { builder.single(link("config", resourceLinks.config().self())); } builder.single(link("repositories", resourceLinks.repositoryCollection().self())); + if (PermissionPermissions.list().isPermitted()) { + builder.single(link("permissions", resourceLinks.permissions().self())); + } + builder.single(link("availableRepositoryPermissions", resourceLinks.availableRepositoryPermissions().self())); } else { builder.single(link("login", resourceLinks.authentication().jsonLogin())); } - return new IndexDto(scmContextProvider.getVersion(), builder.build()); + Embedded.Builder embeddedBuilder = embeddedBuilder(); + applyEnrichers(new EdisonHalAppender(builder, embeddedBuilder), new Index()); + + return new IndexDto(builder.build(), embeddedBuilder.build(), scmContextProvider.getVersion()); } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InvalidPasswordExceptionMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InvalidPasswordExceptionMapper.java deleted file mode 100644 index 7a1d311a1c..0000000000 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InvalidPasswordExceptionMapper.java +++ /dev/null @@ -1,17 +0,0 @@ -package sonia.scm.api.v2.resources; - -import sonia.scm.api.rest.ContextualExceptionMapper; -import sonia.scm.user.InvalidPasswordException; - -import javax.inject.Inject; -import javax.ws.rs.core.Response; -import javax.ws.rs.ext.Provider; - -@Provider -public class InvalidPasswordExceptionMapper extends ContextualExceptionMapper<InvalidPasswordException> { - - @Inject - public InvalidPasswordExceptionMapper(ExceptionWithContextToErrorDtoMapper mapper) { - super(InvalidPasswordException.class, Response.Status.BAD_REQUEST, mapper); - } -} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/LinkEnricherAutoRegistration.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/LinkEnricherAutoRegistration.java new file mode 100644 index 0000000000..8472eb9fc1 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/LinkEnricherAutoRegistration.java @@ -0,0 +1,45 @@ +package sonia.scm.api.v2.resources; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.plugin.Extension; + +import javax.inject.Inject; +import javax.servlet.ServletContextEvent; +import javax.servlet.ServletContextListener; +import java.util.Set; + +/** + * Registers every {@link HalEnricher} which is annotated with an {@link Enrich} annotation. + */ +@Extension +public class LinkEnricherAutoRegistration implements ServletContextListener { + + private static final Logger LOG = LoggerFactory.getLogger(LinkEnricherAutoRegistration.class); + + private final HalEnricherRegistry registry; + private final Set<HalEnricher> enrichers; + + @Inject + public LinkEnricherAutoRegistration(HalEnricherRegistry registry, Set<HalEnricher> enrichers) { + this.registry = registry; + this.enrichers = enrichers; + } + + @Override + public void contextInitialized(ServletContextEvent sce) { + for (HalEnricher enricher : enrichers) { + Enrich annotation = enricher.getClass().getAnnotation(Enrich.class); + if (annotation != null) { + registry.register(annotation.value(), enricher); + } else { + LOG.warn("found HalEnricher extension {} without Enrich annotation", enricher.getClass()); + } + } + } + + @Override + public void contextDestroyed(ServletContextEvent sce) { + // nothing todo + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java index 35f58cef90..c74f16ad70 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java @@ -8,7 +8,6 @@ public class MapperModule extends AbstractModule { @Override protected void configure() { bind(UserDtoToUserMapper.class).to(Mappers.getMapper(UserDtoToUserMapper.class).getClass()); - bind(MeToUserDtoMapper.class).to(Mappers.getMapper(MeToUserDtoMapper.class).getClass()); bind(UserToUserDtoMapper.class).to(Mappers.getMapper(UserToUserDtoMapper.class).getClass()); bind(UserCollectionToDtoMapper.class); @@ -26,10 +25,10 @@ public class MapperModule extends AbstractModule { bind(RepositoryTypeCollectionToDtoMapper.class); bind(BranchToBranchDtoMapper.class).to(Mappers.getMapper(BranchToBranchDtoMapper.class).getClass()); - bind(PermissionDtoToPermissionMapper.class).to(Mappers.getMapper(PermissionDtoToPermissionMapper.class).getClass()); - bind(PermissionToPermissionDtoMapper.class).to(Mappers.getMapper(PermissionToPermissionDtoMapper.class).getClass()); + bind(RepositoryPermissionDtoToRepositoryPermissionMapper.class).to(Mappers.getMapper(RepositoryPermissionDtoToRepositoryPermissionMapper.class).getClass()); + bind(RepositoryPermissionToRepositoryPermissionDtoMapper.class).to(Mappers.getMapper(RepositoryPermissionToRepositoryPermissionDtoMapper.class).getClass()); - bind(ChangesetToChangesetDtoMapper.class).to(Mappers.getMapper(ChangesetToChangesetDtoMapper.class).getClass()); + bind(ChangesetToChangesetDtoMapper.class).to(Mappers.getMapper(DefaultChangesetToChangesetDtoMapper.class).getClass()); bind(ChangesetToParentDtoMapper.class).to(Mappers.getMapper(ChangesetToParentDtoMapper.class).getClass()); bind(TagToTagDtoMapper.class).to(Mappers.getMapper(TagToTagDtoMapper.class).getClass()); @@ -39,10 +38,14 @@ public class MapperModule extends AbstractModule { bind(ReducedObjectModelToDtoMapper.class).to(Mappers.getMapper(ReducedObjectModelToDtoMapper.class).getClass()); - bind(ViolationExceptionToErrorDtoMapper.class).to(Mappers.getMapper(ViolationExceptionToErrorDtoMapper.class).getClass()); + bind(ResteasyViolationExceptionToErrorDtoMapper.class).to(Mappers.getMapper(ResteasyViolationExceptionToErrorDtoMapper.class).getClass()); + bind(ScmViolationExceptionToErrorDtoMapper.class).to(Mappers.getMapper(ScmViolationExceptionToErrorDtoMapper.class).getClass()); bind(ExceptionWithContextToErrorDtoMapper.class).to(Mappers.getMapper(ExceptionWithContextToErrorDtoMapper.class).getClass()); + bind(MergeResultToDtoMapper.class).to(Mappers.getMapper(MergeResultToDtoMapper.class).getClass()); + // no mapstruct required + bind(MeDtoFactory.class); bind(UIPluginDtoMapper.class); bind(UIPluginDtoCollectionMapper.class); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeDto.java new file mode 100644 index 0000000000..84fbbfe290 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeDto.java @@ -0,0 +1,25 @@ +package sonia.scm.api.v2.resources; + +import de.otto.edison.hal.Embedded; +import de.otto.edison.hal.HalRepresentation; +import de.otto.edison.hal.Links; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +@NoArgsConstructor +public class MeDto extends HalRepresentation { + + private String name; + private String displayName; + private String mail; + private List<String> groups; + + MeDto(Links links, Embedded embedded) { + super(links, embedded); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeDtoFactory.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeDtoFactory.java new file mode 100644 index 0000000000..b5e1998066 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeDtoFactory.java @@ -0,0 +1,80 @@ +package sonia.scm.api.v2.resources; + +import com.google.common.collect.ImmutableList; +import de.otto.edison.hal.Embedded; +import de.otto.edison.hal.Links; +import org.apache.shiro.SecurityUtils; +import org.apache.shiro.subject.PrincipalCollection; +import org.apache.shiro.subject.Subject; +import sonia.scm.group.GroupNames; +import sonia.scm.user.User; +import sonia.scm.user.UserManager; +import sonia.scm.user.UserPermissions; + +import javax.inject.Inject; +import java.util.Collections; + +import static de.otto.edison.hal.Embedded.embeddedBuilder; +import static de.otto.edison.hal.Link.link; +import static de.otto.edison.hal.Links.linkingTo; + +public class MeDtoFactory extends HalAppenderMapper { + + private final ResourceLinks resourceLinks; + private final UserManager userManager; + + @Inject + public MeDtoFactory(ResourceLinks resourceLinks, UserManager userManager) { + this.resourceLinks = resourceLinks; + this.userManager = userManager; + } + + public MeDto create() { + PrincipalCollection principals = getPrincipalCollection(); + User user = principals.oneByType(User.class); + + MeDto dto = createDto(user); + mapUserProperties(user, dto); + mapGroups(principals, dto); + return dto; + } + + private void mapGroups(PrincipalCollection principals, MeDto dto) { + Iterable<String> groups = principals.oneByType(GroupNames.class); + if (groups == null) { + groups = Collections.emptySet(); + } + dto.setGroups(ImmutableList.copyOf(groups)); + } + + private void mapUserProperties(User user, MeDto dto) { + dto.setName(user.getName()); + dto.setDisplayName(user.getDisplayName()); + dto.setMail(user.getMail()); + } + + private PrincipalCollection getPrincipalCollection() { + Subject subject = SecurityUtils.getSubject(); + return subject.getPrincipals(); + } + + + private MeDto createDto(User user) { + Links.Builder linksBuilder = linkingTo().self(resourceLinks.me().self()); + if (UserPermissions.delete(user).isPermitted()) { + linksBuilder.single(link("delete", resourceLinks.me().delete(user.getName()))); + } + if (UserPermissions.modify(user).isPermitted()) { + linksBuilder.single(link("update", resourceLinks.me().update(user.getName()))); + } + if (userManager.isTypeDefault(user) && UserPermissions.changePassword(user).isPermitted()) { + linksBuilder.single(link("password", resourceLinks.me().passwordChange())); + } + + Embedded.Builder embeddedBuilder = embeddedBuilder(); + applyEnrichers(new EdisonHalAppender(linksBuilder, embeddedBuilder), new Me(), user); + + return new MeDto(linksBuilder.build(), embeddedBuilder.build()); + } + +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeResource.java index 20fc35923c..2c2e208893 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeResource.java @@ -3,14 +3,11 @@ package sonia.scm.api.v2.resources; import com.webcohesion.enunciate.metadata.rs.ResponseCode; import com.webcohesion.enunciate.metadata.rs.StatusCodes; import com.webcohesion.enunciate.metadata.rs.TypeHint; -import org.apache.shiro.SecurityUtils; import org.apache.shiro.authc.credential.PasswordService; -import sonia.scm.user.User; import sonia.scm.user.UserManager; import sonia.scm.web.VndMediaType; import javax.inject.Inject; -import javax.inject.Named; import javax.validation.Valid; import javax.ws.rs.Consumes; import javax.ws.rs.GET; @@ -28,20 +25,18 @@ import javax.ws.rs.core.UriInfo; */ @Path(MeResource.ME_PATH_V2) public class MeResource { - public static final String ME_PATH_V2 = "v2/me/"; - private final MeToUserDtoMapper meToUserDtoMapper; + static final String ME_PATH_V2 = "v2/me/"; - private final IdResourceManagerAdapter<User, UserDto> adapter; - private final PasswordService passwordService; + private final MeDtoFactory meDtoFactory; private final UserManager userManager; + private final PasswordService passwordService; @Inject - public MeResource(MeToUserDtoMapper meToUserDtoMapper, UserManager manager, PasswordService passwordService) { - this.meToUserDtoMapper = meToUserDtoMapper; - this.adapter = new IdResourceManagerAdapter<>(manager, User.class); + public MeResource(MeDtoFactory meDtoFactory, UserManager userManager, PasswordService passwordService) { + this.meDtoFactory = meDtoFactory; + this.userManager = userManager; this.passwordService = passwordService; - this.userManager = manager; } /** @@ -49,17 +44,15 @@ public class MeResource { */ @GET @Path("") - @Produces(VndMediaType.USER) - @TypeHint(UserDto.class) + @Produces(VndMediaType.ME) + @TypeHint(MeDto.class) @StatusCodes({ @ResponseCode(code = 200, condition = "success"), @ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), @ResponseCode(code = 500, condition = "internal server error") }) public Response get(@Context Request request, @Context UriInfo uriInfo) { - - String id = (String) SecurityUtils.getSubject().getPrincipals().getPrimaryPrincipal(); - return adapter.get(id, meToUserDtoMapper::map); + return Response.ok(meDtoFactory.create()).build(); } /** @@ -75,7 +68,10 @@ public class MeResource { @TypeHint(TypeHint.NO_CONTENT.class) @Consumes(VndMediaType.PASSWORD_CHANGE) public Response changePassword(@Valid PasswordChangeDto passwordChange) { - userManager.changePasswordForLoggedInUser(passwordService.encryptPassword(passwordChange.getOldPassword()), passwordService.encryptPassword(passwordChange.getNewPassword())); + userManager.changePasswordForLoggedInUser( + passwordService.encryptPassword(passwordChange.getOldPassword()), + passwordService.encryptPassword(passwordChange.getNewPassword()) + ); return Response.noContent().build(); } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeToUserDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeToUserDtoMapper.java deleted file mode 100644 index 2a872eadd9..0000000000 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeToUserDtoMapper.java +++ /dev/null @@ -1,42 +0,0 @@ -package sonia.scm.api.v2.resources; - -import de.otto.edison.hal.Links; -import org.mapstruct.AfterMapping; -import org.mapstruct.Mapper; -import org.mapstruct.MappingTarget; -import sonia.scm.user.User; -import sonia.scm.user.UserManager; -import sonia.scm.user.UserPermissions; - -import javax.inject.Inject; - -import static de.otto.edison.hal.Link.link; -import static de.otto.edison.hal.Links.linkingTo; - -@Mapper -public abstract class MeToUserDtoMapper extends UserToUserDtoMapper{ - - @Inject - private UserManager userManager; - - @Inject - private ResourceLinks resourceLinks; - - - @Override - @AfterMapping - protected void appendLinks(User user, @MappingTarget UserDto target) { - Links.Builder linksBuilder = linkingTo().self(resourceLinks.me().self()); - if (UserPermissions.delete(user).isPermitted()) { - linksBuilder.single(link("delete", resourceLinks.me().delete(target.getName()))); - } - if (UserPermissions.modify(user).isPermitted()) { - linksBuilder.single(link("update", resourceLinks.me().update(target.getName()))); - } - if (userManager.isTypeDefault(user)) { - linksBuilder.single(link("password", resourceLinks.me().passwordChange())); - } - target.add(linksBuilder.build()); - } - -} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeCommandDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeCommandDto.java new file mode 100644 index 0000000000..0661d6a4ef --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeCommandDto.java @@ -0,0 +1,14 @@ +package sonia.scm.api.v2.resources; + +import lombok.Getter; +import lombok.Setter; +import org.hibernate.validator.constraints.NotEmpty; + +@Getter @Setter +public class MergeCommandDto { + + @NotEmpty + private String sourceRevision; + @NotEmpty + private String targetRevision; +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeResource.java new file mode 100644 index 0000000000..63fa2274ec --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeResource.java @@ -0,0 +1,87 @@ +package sonia.scm.api.v2.resources; + +import com.webcohesion.enunciate.metadata.rs.ResponseCode; +import com.webcohesion.enunciate.metadata.rs.StatusCodes; +import lombok.extern.slf4j.Slf4j; +import org.apache.http.HttpStatus; +import sonia.scm.repository.NamespaceAndName; +import sonia.scm.repository.api.MergeCommandBuilder; +import sonia.scm.repository.api.MergeCommandResult; +import sonia.scm.repository.api.MergeDryRunCommandResult; +import sonia.scm.repository.api.RepositoryService; +import sonia.scm.repository.api.RepositoryServiceFactory; +import sonia.scm.web.VndMediaType; + +import javax.inject.Inject; +import javax.validation.Valid; +import javax.ws.rs.Consumes; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Response; + +@Slf4j +public class MergeResource { + + private final RepositoryServiceFactory serviceFactory; + private final MergeResultToDtoMapper mapper; + + @Inject + public MergeResource(RepositoryServiceFactory serviceFactory, MergeResultToDtoMapper mapper) { + this.serviceFactory = serviceFactory; + this.mapper = mapper; + } + + @POST + @Path("") + @Produces(VndMediaType.MERGE_RESULT) + @Consumes(VndMediaType.MERGE_COMMAND) + @StatusCodes({ + @ResponseCode(code = 204, condition = "merge has been executed successfully"), + @ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), + @ResponseCode(code = 403, condition = "not authorized, the current user does not have the privilege to write the repository"), + @ResponseCode(code = 409, condition = "The branches could not be merged automatically due to conflicts (conflicting files will be returned)"), + @ResponseCode(code = 500, condition = "internal server error") + }) + public Response merge(@PathParam("namespace") String namespace, @PathParam("name") String name, @Valid MergeCommandDto mergeCommand) { + NamespaceAndName namespaceAndName = new NamespaceAndName(namespace, name); + log.info("Merge in Repository {}/{} from {} to {}", namespace, name, mergeCommand.getSourceRevision(), mergeCommand.getTargetRevision()); + try (RepositoryService repositoryService = serviceFactory.create(namespaceAndName)) { + MergeCommandResult mergeCommandResult = createMergeCommand(mergeCommand, repositoryService).executeMerge(); + if (mergeCommandResult.isSuccess()) { + return Response.noContent().build(); + } else { + return Response.status(HttpStatus.SC_CONFLICT).entity(mapper.map(mergeCommandResult)).build(); + } + } + } + + @POST + @Path("dry-run/") + @StatusCodes({ + @ResponseCode(code = 204, condition = "merge can be done automatically"), + @ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), + @ResponseCode(code = 409, condition = "The branches can not be merged automatically due to conflicts"), + @ResponseCode(code = 500, condition = "internal server error") + }) + public Response dryRun(@PathParam("namespace") String namespace, @PathParam("name") String name, @Valid MergeCommandDto mergeCommand) { + NamespaceAndName namespaceAndName = new NamespaceAndName(namespace, name); + log.info("Merge in Repository {}/{} from {} to {}", namespace, name, mergeCommand.getSourceRevision(), mergeCommand.getTargetRevision()); + try (RepositoryService repositoryService = serviceFactory.create(namespaceAndName)) { + MergeDryRunCommandResult mergeCommandResult = createMergeCommand(mergeCommand, repositoryService).dryRun(); + if (mergeCommandResult.isMergeable()) { + return Response.noContent().build(); + } else { + return Response.status(HttpStatus.SC_CONFLICT).build(); + } + } + } + + private MergeCommandBuilder createMergeCommand(MergeCommandDto mergeCommand, RepositoryService repositoryService) { + return repositoryService + .getMergeCommand() + .setBranchToMerge(mergeCommand.getSourceRevision()) + .setTargetBranch(mergeCommand.getTargetRevision()); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeResultDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeResultDto.java new file mode 100644 index 0000000000..fa523153cf --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeResultDto.java @@ -0,0 +1,12 @@ +package sonia.scm.api.v2.resources; + +import lombok.Getter; +import lombok.Setter; + +import java.util.Collection; + +@Getter +@Setter +public class MergeResultDto { + private Collection<String> filesWithConflict; +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeResultToDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeResultToDtoMapper.java new file mode 100644 index 0000000000..1dbbe8aacd --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeResultToDtoMapper.java @@ -0,0 +1,9 @@ +package sonia.scm.api.v2.resources; + +import org.mapstruct.Mapper; +import sonia.scm.repository.api.MergeCommandResult; + +@Mapper +public interface MergeResultToDtoMapper { + MergeResultDto map(MergeCommandResult result); +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NoBlankStrings.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NoBlankStrings.java new file mode 100644 index 0000000000..ba5e20ffbd --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NoBlankStrings.java @@ -0,0 +1,26 @@ +package sonia.scm.api.v2.resources; + +import javax.validation.Constraint; +import javax.validation.Payload; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.ANNOTATION_TYPE; +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +@Target({FIELD, METHOD, PARAMETER, ANNOTATION_TYPE}) +@Retention(RUNTIME) +@Constraint(validatedBy = NoBlankStringsValidator.class) +@Documented +public @interface NoBlankStrings { + + String message() default "collection must not contain empty strings"; + + Class<?>[] groups() default {}; + + Class<? extends Payload>[] payload() default {}; +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NoBlankStringsValidator.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NoBlankStringsValidator.java new file mode 100644 index 0000000000..6bae44164e --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/NoBlankStringsValidator.java @@ -0,0 +1,23 @@ +package sonia.scm.api.v2.resources; + +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; +import java.util.Collection; + +public class NoBlankStringsValidator implements ConstraintValidator<NoBlankStrings, Collection> { + + @Override + public void initialize(NoBlankStrings constraintAnnotation) { + } + + @Override + public boolean isValid(Collection object, ConstraintValidatorContext constraintContext) { + if ( object == null || object.isEmpty()) { + return true; + } + return object.stream() + .map(x -> x.toString()) + .map(s -> ((String) s).trim()) + .noneMatch(s -> ((String) s).isEmpty()); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionCollectionToDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionCollectionToDtoMapper.java index 4789915f3d..31269d468e 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionCollectionToDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionCollectionToDtoMapper.java @@ -1,51 +1,47 @@ package sonia.scm.api.v2.resources; -import com.google.inject.Inject; -import de.otto.edison.hal.Embedded; -import de.otto.edison.hal.HalRepresentation; import de.otto.edison.hal.Links; -import sonia.scm.repository.Repository; -import sonia.scm.repository.RepositoryPermissions; +import sonia.scm.security.PermissionDescriptor; +import sonia.scm.security.PermissionPermissions; -import java.util.List; +import javax.inject.Inject; +import java.util.Collection; -import static de.otto.edison.hal.Embedded.embeddedBuilder; import static de.otto.edison.hal.Link.link; import static de.otto.edison.hal.Links.linkingTo; -import static java.util.stream.Collectors.toList; public class PermissionCollectionToDtoMapper { private final ResourceLinks resourceLinks; - private final PermissionToPermissionDtoMapper permissionToPermissionDtoMapper; @Inject - public PermissionCollectionToDtoMapper(PermissionToPermissionDtoMapper permissionToPermissionDtoMapper, ResourceLinks resourceLinks) { + public PermissionCollectionToDtoMapper(ResourceLinks resourceLinks) { this.resourceLinks = resourceLinks; - this.permissionToPermissionDtoMapper = permissionToPermissionDtoMapper; } - public HalRepresentation map(Repository repository) { - List<PermissionDto> permissionDtoList = repository.getPermissions() + public PermissionListDto mapForUser(Collection<PermissionDescriptor> permissions, String userId) { + return map(permissions, userId, resourceLinks.userPermissions()); + } + + public PermissionListDto mapForGroup(Collection<PermissionDescriptor> permissions, String groupId) { + return map(permissions, groupId, resourceLinks.groupPermissions()); + } + + private PermissionListDto map(Collection<PermissionDescriptor> permissions, String id, ResourceLinks.WithPermissionLinks links) { + String[] permissionStrings = permissions .stream() - .map(permission -> permissionToPermissionDtoMapper.map(permission, repository)) - .collect(toList()); - return new HalRepresentation(createLinks(repository), embedDtos(permissionDtoList)); - } + .map(PermissionDescriptor::getValue) + .toArray(String[]::new); + PermissionListDto target = new PermissionListDto(permissionStrings); - private Links createLinks(Repository repository) { - RepositoryPermissions.permissionRead(repository).check(); - Links.Builder linksBuilder = linkingTo() - .with(Links.linkingTo().self(resourceLinks.permission().all(repository.getNamespace(), repository.getName())).build()); - if (RepositoryPermissions.permissionWrite(repository).isPermitted()) { - linksBuilder.single(link("create", resourceLinks.permission().create(repository.getNamespace(), repository.getName()))); + Links.Builder linksBuilder = linkingTo().self(links.permissions(id)); + + if (PermissionPermissions.assign().isPermitted()) { + linksBuilder.single(link("overwrite", links.overwritePermissions(id))); } - return linksBuilder.build(); - } - private Embedded embedDtos(List<PermissionDto> permissionDtoList) { - return embeddedBuilder() - .with("permissions", permissionDtoList) - .build(); + target.add(linksBuilder.build()); + + return target; } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionDtoToPermissionMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionDtoToPermissionMapper.java deleted file mode 100644 index 1e90c23aa7..0000000000 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionDtoToPermissionMapper.java +++ /dev/null @@ -1,21 +0,0 @@ -package sonia.scm.api.v2.resources; - -import org.mapstruct.Mapper; -import org.mapstruct.MappingTarget; -import sonia.scm.repository.Permission; - -@Mapper -public abstract class PermissionDtoToPermissionMapper { - - public abstract Permission map(PermissionDto permissionDto); - - /** - * this method is needed to modify an existing permission object - * - * @param target the target permission - * @param permissionDto the source dto - * @return the mapped target permission object - */ - public abstract void modify(@MappingTarget Permission target, PermissionDto permissionDto); - -} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionListDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionListDto.java new file mode 100644 index 0000000000..23d57f4d8e --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionListDto.java @@ -0,0 +1,23 @@ +package sonia.scm.api.v2.resources; + +import de.otto.edison.hal.HalRepresentation; +import de.otto.edison.hal.Links; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class PermissionListDto extends HalRepresentation { + + private String[] permissions; + + @Override + @SuppressWarnings("squid:S1185") // We want to have this method available in this package + protected HalRepresentation add(Links links) { + return super.add(links); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryCollectionResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryCollectionResource.java index d8d5280456..e1e1260a4d 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryCollectionResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryCollectionResource.java @@ -6,10 +6,9 @@ import com.webcohesion.enunciate.metadata.rs.ResponseHeaders; import com.webcohesion.enunciate.metadata.rs.StatusCodes; import com.webcohesion.enunciate.metadata.rs.TypeHint; import org.apache.shiro.SecurityUtils; -import sonia.scm.repository.Permission; -import sonia.scm.repository.PermissionType; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryManager; +import sonia.scm.repository.RepositoryPermission; import sonia.scm.user.User; import sonia.scm.web.VndMediaType; @@ -100,7 +99,7 @@ public class RepositoryCollectionResource { private Repository createModelObjectFromDto(@Valid RepositoryDto repositoryDto) { Repository repository = dtoToRepositoryMapper.map(repositoryDto, null); - repository.setPermissions(singletonList(new Permission(currentUser(), PermissionType.OWNER))); + repository.setPermissions(singletonList(new RepositoryPermission(currentUser(), singletonList("*"), false))); return repository; } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryDto.java index ddfe432d73..8b48311bba 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryDto.java @@ -1,9 +1,11 @@ package sonia.scm.api.v2.resources; import com.fasterxml.jackson.annotation.JsonInclude; +import de.otto.edison.hal.Embedded; import de.otto.edison.hal.HalRepresentation; import de.otto.edison.hal.Links; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; import org.hibernate.validator.constraints.Email; import org.hibernate.validator.constraints.NotEmpty; @@ -13,7 +15,7 @@ import java.time.Instant; import java.util.List; import java.util.Map; -@Getter @Setter +@Getter @Setter @NoArgsConstructor public class RepositoryDto extends HalRepresentation { @Email @@ -31,9 +33,7 @@ public class RepositoryDto extends HalRepresentation { private String type; protected Map<String, String> properties; - @Override - @SuppressWarnings("squid:S1185") // We want to have this method available in this package - protected HalRepresentation add(Links links) { - return super.add(links); + RepositoryDto(Links links, Embedded embedded) { + super(links, embedded); } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryDtoToRepositoryMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryDtoToRepositoryMapper.java index b7058d2830..b9add14529 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryDtoToRepositoryMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryDtoToRepositoryMapper.java @@ -10,7 +10,6 @@ public abstract class RepositoryDtoToRepositoryMapper extends BaseDtoMapper { @Mapping(target = "id", ignore = true) @Mapping(target = "publicReadable", ignore = true) @Mapping(target = "healthCheckFailures", ignore = true) - @Mapping(target = "permissions", ignore = true) public abstract Repository map(RepositoryDto repositoryDto, @Context String id); @AfterMapping diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionCollectionToDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionCollectionToDtoMapper.java new file mode 100644 index 0000000000..5e678212e8 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionCollectionToDtoMapper.java @@ -0,0 +1,51 @@ +package sonia.scm.api.v2.resources; + +import com.google.inject.Inject; +import de.otto.edison.hal.Embedded; +import de.otto.edison.hal.HalRepresentation; +import de.otto.edison.hal.Links; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryPermissions; + +import java.util.List; + +import static de.otto.edison.hal.Embedded.embeddedBuilder; +import static de.otto.edison.hal.Link.link; +import static de.otto.edison.hal.Links.linkingTo; +import static java.util.stream.Collectors.toList; + +public class RepositoryPermissionCollectionToDtoMapper { + + private final ResourceLinks resourceLinks; + private final RepositoryPermissionToRepositoryPermissionDtoMapper repositoryPermissionToRepositoryPermissionDtoMapper; + + @Inject + public RepositoryPermissionCollectionToDtoMapper(RepositoryPermissionToRepositoryPermissionDtoMapper repositoryPermissionToRepositoryPermissionDtoMapper, ResourceLinks resourceLinks) { + this.resourceLinks = resourceLinks; + this.repositoryPermissionToRepositoryPermissionDtoMapper = repositoryPermissionToRepositoryPermissionDtoMapper; + } + + public HalRepresentation map(Repository repository) { + List<RepositoryPermissionDto> repositoryPermissionDtoList = repository.getPermissions() + .stream() + .map(permission -> repositoryPermissionToRepositoryPermissionDtoMapper.map(permission, repository)) + .collect(toList()); + return new HalRepresentation(createLinks(repository), embedDtos(repositoryPermissionDtoList)); + } + + private Links createLinks(Repository repository) { + RepositoryPermissions.permissionRead(repository).check(); + Links.Builder linksBuilder = linkingTo() + .with(Links.linkingTo().self(resourceLinks.repositoryPermission().all(repository.getNamespace(), repository.getName())).build()); + if (RepositoryPermissions.permissionWrite(repository).isPermitted()) { + linksBuilder.single(link("create", resourceLinks.repositoryPermission().create(repository.getNamespace(), repository.getName()))); + } + return linksBuilder.build(); + } + + private Embedded embedDtos(List<RepositoryPermissionDto> repositoryPermissionDtoList) { + return embeddedBuilder() + .with("permissions", repositoryPermissionDtoList) + .build(); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionDto.java similarity index 60% rename from scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionDto.java rename to scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionDto.java index 82405a6ac2..fe8c2c19b1 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionDto.java @@ -1,44 +1,37 @@ package sonia.scm.api.v2.resources; -import com.fasterxml.jackson.annotation.JsonInclude; import de.otto.edison.hal.HalRepresentation; import de.otto.edison.hal.Links; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import lombok.ToString; +import org.hibernate.validator.constraints.NotEmpty; import javax.validation.constraints.Pattern; +import java.util.Collection; + import static sonia.scm.api.v2.ValidationConstraints.USER_GROUP_PATTERN; @Getter @Setter @ToString @NoArgsConstructor -public class PermissionDto extends HalRepresentation { +public class RepositoryPermissionDto extends HalRepresentation { public static final String GROUP_PREFIX = "@"; @Pattern(regexp = USER_GROUP_PATTERN) private String name; - /** - * the type can be replaced with a dto enum if the mapstruct 1.3.0 is stable - * the mapstruct has a Bug on mapping enums in the 1.2.0-Final Version - * - * see the bug fix: https://github.com/mapstruct/mapstruct/commit/460e87eef6eb71245b387fdb0509c726676a8e19 - * - **/ - @JsonInclude(JsonInclude.Include.NON_NULL) - private String type; - + @NotEmpty + private Collection<String> verbs; private boolean groupPermission = false; - public PermissionDto(String permissionName, boolean groupPermission) { + public RepositoryPermissionDto(String permissionName, boolean groupPermission) { name = permissionName; this.groupPermission = groupPermission; } - @Override @SuppressWarnings("squid:S1185") // We want to have this method available in this package protected HalRepresentation add(Links links) { diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionDtoToRepositoryPermissionMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionDtoToRepositoryPermissionMapper.java new file mode 100644 index 0000000000..8e015e8b60 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionDtoToRepositoryPermissionMapper.java @@ -0,0 +1,22 @@ +package sonia.scm.api.v2.resources; + +import org.mapstruct.CollectionMappingStrategy; +import org.mapstruct.Mapper; +import org.mapstruct.MappingTarget; +import sonia.scm.repository.RepositoryPermission; + +@Mapper(collectionMappingStrategy = CollectionMappingStrategy.TARGET_IMMUTABLE) +public abstract class RepositoryPermissionDtoToRepositoryPermissionMapper { + + public abstract RepositoryPermission map(RepositoryPermissionDto permissionDto); + + /** + * this method is needed to modify an existing permission object + * + * @param target the target permission + * @param repositoryPermissionDto the source dto + * @return the mapped target permission object + */ + public abstract void modify(@MappingTarget RepositoryPermission target, RepositoryPermissionDto repositoryPermissionDto); + +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionResource.java new file mode 100644 index 0000000000..e5734085ca --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionResource.java @@ -0,0 +1,43 @@ +package sonia.scm.api.v2.resources; + +import com.webcohesion.enunciate.metadata.rs.ResponseCode; +import com.webcohesion.enunciate.metadata.rs.StatusCodes; +import de.otto.edison.hal.Links; +import sonia.scm.security.RepositoryPermissionProvider; +import sonia.scm.web.VndMediaType; + +import javax.inject.Inject; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; + +/** + * RESTful Web Service Resource to get available repository types. + */ +@Path(RepositoryPermissionResource.PATH) +public class RepositoryPermissionResource { + + static final String PATH = "v2/repositoryPermissions/"; + + private final RepositoryPermissionProvider repositoryPermissionProvider; + private final ResourceLinks resourceLinks; + + @Inject + public RepositoryPermissionResource(RepositoryPermissionProvider repositoryPermissionProvider, ResourceLinks resourceLinks) { + this.repositoryPermissionProvider = repositoryPermissionProvider; + this.resourceLinks = resourceLinks; + } + + @GET + @Path("") + @StatusCodes({ + @ResponseCode(code = 200, condition = "success"), + @ResponseCode(code = 500, condition = "internal server error") + }) + @Produces(VndMediaType.REPOSITORY_PERMISSION_COLLECTION) + public AvailableRepositoryPermissionsDto get() { + AvailableRepositoryPermissionsDto dto = new AvailableRepositoryPermissionsDto(repositoryPermissionProvider.availableVerbs(), repositoryPermissionProvider.availableRoles()); + dto.add(Links.linkingTo().self(resourceLinks.availableRepositoryPermissions().self()).build()); + return dto; + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionRootResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionRootResource.java similarity index 77% rename from scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionRootResource.java rename to scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionRootResource.java index 127a3f450e..dd66e6e5f2 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionRootResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionRootResource.java @@ -8,14 +8,13 @@ import lombok.extern.slf4j.Slf4j; import sonia.scm.AlreadyExistsException; import sonia.scm.NotFoundException; import sonia.scm.repository.NamespaceAndName; -import sonia.scm.repository.Permission; +import sonia.scm.repository.RepositoryPermission; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryManager; import sonia.scm.repository.RepositoryPermissions; import sonia.scm.web.VndMediaType; import javax.inject.Inject; -import javax.inject.Named; import javax.validation.Valid; import javax.ws.rs.Consumes; import javax.ws.rs.DELETE; @@ -33,29 +32,31 @@ import java.util.function.Predicate; import static sonia.scm.AlreadyExistsException.alreadyExists; import static sonia.scm.ContextEntry.ContextBuilder.entity; import static sonia.scm.NotFoundException.notFound; -import static sonia.scm.api.v2.resources.PermissionDto.GROUP_PREFIX; +import static sonia.scm.api.v2.resources.RepositoryPermissionDto.GROUP_PREFIX; @Slf4j -public class PermissionRootResource { +public class RepositoryPermissionRootResource { - - private PermissionDtoToPermissionMapper dtoToModelMapper; - private PermissionToPermissionDtoMapper modelToDtoMapper; - private PermissionCollectionToDtoMapper permissionCollectionToDtoMapper; + private RepositoryPermissionDtoToRepositoryPermissionMapper dtoToModelMapper; + private RepositoryPermissionToRepositoryPermissionDtoMapper modelToDtoMapper; + private RepositoryPermissionCollectionToDtoMapper repositoryPermissionCollectionToDtoMapper; private ResourceLinks resourceLinks; private final RepositoryManager manager; - @Inject - public PermissionRootResource(PermissionDtoToPermissionMapper dtoToModelMapper, PermissionToPermissionDtoMapper modelToDtoMapper, PermissionCollectionToDtoMapper permissionCollectionToDtoMapper, ResourceLinks resourceLinks, RepositoryManager manager) { + public RepositoryPermissionRootResource( + RepositoryPermissionDtoToRepositoryPermissionMapper dtoToModelMapper, + RepositoryPermissionToRepositoryPermissionDtoMapper modelToDtoMapper, + RepositoryPermissionCollectionToDtoMapper repositoryPermissionCollectionToDtoMapper, + ResourceLinks resourceLinks, + RepositoryManager manager) { this.dtoToModelMapper = dtoToModelMapper; this.modelToDtoMapper = modelToDtoMapper; - this.permissionCollectionToDtoMapper = permissionCollectionToDtoMapper; + this.repositoryPermissionCollectionToDtoMapper = repositoryPermissionCollectionToDtoMapper; this.resourceLinks = resourceLinks; this.manager = manager; } - /** * Adds a new permission to the user or group managed by the repository * @@ -72,9 +73,9 @@ public class PermissionRootResource { @ResponseCode(code = 409, condition = "conflict") }) @TypeHint(TypeHint.NO_CONTENT.class) - @Consumes(VndMediaType.PERMISSION) + @Consumes(VndMediaType.REPOSITORY_PERMISSION) @Path("") - public Response create(@PathParam("namespace") String namespace, @PathParam("name") String name,@Valid PermissionDto permission) { + public Response create(@PathParam("namespace") String namespace, @PathParam("name") String name, @Valid RepositoryPermissionDto permission) { log.info("try to add new permission: {}", permission); Repository repository = load(namespace, name); RepositoryPermissions.permissionWrite(repository).check(); @@ -82,10 +83,9 @@ public class PermissionRootResource { repository.addPermission(dtoToModelMapper.map(permission)); manager.modify(repository); String urlPermissionName = modelToDtoMapper.getUrlPermissionName(permission); - return Response.created(URI.create(resourceLinks.permission().self(namespace, name, urlPermissionName))).build(); + return Response.created(URI.create(resourceLinks.repositoryPermission().self(namespace, name, urlPermissionName))).build(); } - /** * Get the searched permission with permission name related to a repository * @@ -100,8 +100,8 @@ public class PermissionRootResource { @ResponseCode(code = 404, condition = "not found"), @ResponseCode(code = 500, condition = "internal server error") }) - @Produces(VndMediaType.PERMISSION) - @TypeHint(PermissionDto.class) + @Produces(VndMediaType.REPOSITORY_PERMISSION) + @TypeHint(RepositoryPermissionDto.class) @Path("{permission-name}") public Response get(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("permission-name") String permissionName) { Repository repository = load(namespace, name); @@ -112,11 +112,10 @@ public class PermissionRootResource { .filter(filterPermission(permissionName)) .map(permission -> modelToDtoMapper.map(permission, repository)) .findFirst() - .orElseThrow(() -> notFound(entity(Permission.class, namespace).in(Repository.class, namespace + "/" + name))) + .orElseThrow(() -> notFound(entity(RepositoryPermission.class, namespace).in(Repository.class, namespace + "/" + name))) ).build(); } - /** * Get all permissions related to a repository * @@ -131,16 +130,15 @@ public class PermissionRootResource { @ResponseCode(code = 404, condition = "not found"), @ResponseCode(code = 500, condition = "internal server error") }) - @Produces(VndMediaType.PERMISSION) - @TypeHint(PermissionDto.class) + @Produces(VndMediaType.REPOSITORY_PERMISSION) + @TypeHint(RepositoryPermissionDto.class) @Path("") public Response getAll(@PathParam("namespace") String namespace, @PathParam("name") String name) { Repository repository = load(namespace, name); RepositoryPermissions.permissionRead(repository).check(); - return Response.ok(permissionCollectionToDtoMapper.map(repository)).build(); + return Response.ok(repositoryPermissionCollectionToDtoMapper.map(repository)).build(); } - /** * Update a permission to the user or group managed by the repository * ignore the user input for groupPermission and take it from the path parameter (if the group prefix (@) exists it is a group permission) @@ -156,28 +154,29 @@ public class PermissionRootResource { @ResponseCode(code = 500, condition = "internal server error") }) @TypeHint(TypeHint.NO_CONTENT.class) - @Consumes(VndMediaType.PERMISSION) + @Consumes(VndMediaType.REPOSITORY_PERMISSION) @Path("{permission-name}") public Response update(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("permission-name") String permissionName, - @Valid PermissionDto permission) { + @Valid RepositoryPermissionDto permission) { log.info("try to update the permission with name: {}. the modified permission is: {}", permissionName, permission); Repository repository = load(namespace, name); RepositoryPermissions.permissionWrite(repository).check(); String extractedPermissionName = getPermissionName(permissionName); - if (!isPermissionExist(new PermissionDto(extractedPermissionName, isGroupPermission(permissionName)), repository)) { - throw notFound(entity(Permission.class, namespace).in(Repository.class, namespace + "/" + name)); + if (!isPermissionExist(new RepositoryPermissionDto(extractedPermissionName, isGroupPermission(permissionName)), repository)) { + throw notFound(entity(RepositoryPermission.class, namespace).in(Repository.class, namespace + "/" + name)); } permission.setGroupPermission(isGroupPermission(permissionName)); if (!extractedPermissionName.equals(permission.getName())) { checkPermissionAlreadyExists(permission, repository); } - Permission existingPermission = repository.getPermissions() + + RepositoryPermission existingPermission = repository.getPermissions() .stream() .filter(filterPermission(permissionName)) .findFirst() - .orElseThrow(() -> notFound(entity(Permission.class, namespace).in(Repository.class, namespace + "/" + name))); + .orElseThrow(() -> notFound(entity(RepositoryPermission.class, namespace).in(Repository.class, namespace + "/" + name))); dtoToModelMapper.modify(existingPermission, permission); manager.modify(repository); log.info("the permission with name: {} is updated.", permissionName); @@ -209,17 +208,16 @@ public class PermissionRootResource { .stream() .filter(filterPermission(permissionName)) .findFirst() - .ifPresent(repository::removePermission) - ; + .ifPresent(repository::removePermission); manager.modify(repository); log.info("the permission with name: {} is updated.", permissionName); return Response.noContent().build(); } - Predicate<Permission> filterPermission(String permissionName) { - return permission -> getPermissionName(permissionName).equals(permission.getName()) + private Predicate<RepositoryPermission> filterPermission(String name) { + return permission -> getPermissionName(name).equals(permission.getName()) && - permission.isGroupPermission() == isGroupPermission(permissionName); + permission.isGroupPermission() == isGroupPermission(name); } private String getPermissionName(String permissionName) { @@ -232,7 +230,6 @@ public class PermissionRootResource { return permissionName.startsWith(GROUP_PREFIX); } - /** * check if the actual user is permitted to manage the repository permissions * return the repository if the user is permitted @@ -255,13 +252,13 @@ public class PermissionRootResource { * @param repository the repository to be inspected * @throws AlreadyExistsException if the permission already exists in the repository */ - private void checkPermissionAlreadyExists(PermissionDto permission, Repository repository) { + private void checkPermissionAlreadyExists(RepositoryPermissionDto permission, Repository repository) { if (isPermissionExist(permission, repository)) { throw alreadyExists(entity("permission", permission.getName()).in(repository)); } } - private boolean isPermissionExist(PermissionDto permission, Repository repository) { + private boolean isPermissionExist(RepositoryPermissionDto permission, Repository repository) { return repository.getPermissions() .stream() .anyMatch(p -> p.getName().equals(permission.getName()) && p.isGroupPermission() == permission.isGroupPermission()); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionToPermissionDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionToRepositoryPermissionDtoMapper.java similarity index 51% rename from scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionToPermissionDtoMapper.java rename to scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionToRepositoryPermissionDtoMapper.java index d6ab3721cf..9f9971ffce 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionToPermissionDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPermissionToRepositoryPermissionDtoMapper.java @@ -7,7 +7,7 @@ import org.mapstruct.Context; import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.MappingTarget; -import sonia.scm.repository.Permission; +import sonia.scm.repository.RepositoryPermission; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryPermissions; @@ -16,16 +16,16 @@ import java.util.Optional; import static de.otto.edison.hal.Link.link; import static de.otto.edison.hal.Links.linkingTo; -import static sonia.scm.api.v2.resources.PermissionDto.GROUP_PREFIX; +import static sonia.scm.api.v2.resources.RepositoryPermissionDto.GROUP_PREFIX; @Mapper -public abstract class PermissionToPermissionDtoMapper { +public abstract class RepositoryPermissionToRepositoryPermissionDtoMapper { @Inject private ResourceLinks resourceLinks; @Mapping(target = "attributes", ignore = true) // We do not map HAL attributes - public abstract PermissionDto map(Permission permission, @Context Repository repository); + public abstract RepositoryPermissionDto map(RepositoryPermission permission, @Context Repository repository); @BeforeMapping @@ -40,20 +40,20 @@ public abstract class PermissionToPermissionDtoMapper { * @param repository the repository */ @AfterMapping - void appendLinks(@MappingTarget PermissionDto target, @Context Repository repository) { + void appendLinks(@MappingTarget RepositoryPermissionDto target, @Context Repository repository) { String permissionName = getUrlPermissionName(target); Links.Builder linksBuilder = linkingTo() - .self(resourceLinks.permission().self(repository.getNamespace(), repository.getName(), permissionName)); + .self(resourceLinks.repositoryPermission().self(repository.getNamespace(), repository.getName(), permissionName)); if (RepositoryPermissions.permissionWrite(repository).isPermitted()) { - linksBuilder.single(link("update", resourceLinks.permission().update(repository.getNamespace(), repository.getName(), permissionName))); - linksBuilder.single(link("delete", resourceLinks.permission().delete(repository.getNamespace(), repository.getName(), permissionName))); + linksBuilder.single(link("update", resourceLinks.repositoryPermission().update(repository.getNamespace(), repository.getName(), permissionName))); + linksBuilder.single(link("delete", resourceLinks.repositoryPermission().delete(repository.getNamespace(), repository.getName(), permissionName))); } target.add(linksBuilder.build()); } - public String getUrlPermissionName(PermissionDto permissionDto) { - return Optional.of(permissionDto.getName()) - .filter(p -> !permissionDto.isGroupPermission()) - .orElse(GROUP_PREFIX + permissionDto.getName()); + public String getUrlPermissionName(RepositoryPermissionDto repositoryPermissionDto) { + return Optional.of(repositoryPermissionDto.getName()) + .filter(p -> !repositoryPermissionDto.isGroupPermission()) + .orElse(GROUP_PREFIX + repositoryPermissionDto.getName()); } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryResource.java index f65235db0b..e8c303e0f8 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryResource.java @@ -10,7 +10,6 @@ import sonia.scm.repository.RepositoryManager; import sonia.scm.web.VndMediaType; import javax.inject.Inject; -import javax.inject.Named; import javax.inject.Provider; import javax.validation.Valid; import javax.ws.rs.Consumes; @@ -40,10 +39,12 @@ public class RepositoryResource { private final Provider<ChangesetRootResource> changesetRootResource; private final Provider<SourceRootResource> sourceRootResource; private final Provider<ContentResource> contentResource; - private final Provider<PermissionRootResource> permissionRootResource; + private final Provider<RepositoryPermissionRootResource> permissionRootResource; private final Provider<DiffRootResource> diffRootResource; private final Provider<ModificationsRootResource> modificationsRootResource; private final Provider<FileHistoryRootResource> fileHistoryRootResource; + private final Provider<MergeResource> mergeResource; + private final Provider<IncomingRootResource> incomingRootResource; @Inject public RepositoryResource( @@ -53,11 +54,12 @@ public class RepositoryResource { Provider<BranchRootResource> branchRootResource, Provider<ChangesetRootResource> changesetRootResource, Provider<SourceRootResource> sourceRootResource, Provider<ContentResource> contentResource, - Provider<PermissionRootResource> permissionRootResource, + Provider<RepositoryPermissionRootResource> permissionRootResource, Provider<DiffRootResource> diffRootResource, Provider<ModificationsRootResource> modificationsRootResource, - Provider<FileHistoryRootResource> fileHistoryRootResource - ) { + Provider<FileHistoryRootResource> fileHistoryRootResource, + Provider<IncomingRootResource> incomingRootResource, + Provider<MergeResource> mergeResource) { this.dtoToRepositoryMapper = dtoToRepositoryMapper; this.manager = manager; this.repositoryToDtoMapper = repositoryToDtoMapper; @@ -71,6 +73,8 @@ public class RepositoryResource { this.diffRootResource = diffRootResource; this.modificationsRootResource = modificationsRootResource; this.fileHistoryRootResource = fileHistoryRootResource; + this.mergeResource = mergeResource; + this.incomingRootResource = incomingRootResource; } /** @@ -150,7 +154,6 @@ public class RepositoryResource { private Repository processUpdate(RepositoryDto repositoryDto, Repository existing) { Repository changedRepository = dtoToRepositoryMapper.map(repositoryDto, existing.getId()); - changedRepository.setPermissions(existing.getPermissions()); return changedRepository; } @@ -190,12 +193,22 @@ public class RepositoryResource { } @Path("permissions/") - public PermissionRootResource permissions() { + public RepositoryPermissionRootResource permissions() { return permissionRootResource.get(); } - @Path("modifications/") - public ModificationsRootResource modifications() {return modificationsRootResource.get(); } + @Path("modifications/") + public ModificationsRootResource modifications() { + return modificationsRootResource.get(); + } + + @Path("incoming/") + public IncomingRootResource incoming() { + return incomingRootResource.get(); + } + + @Path("merge/") + public MergeResource merge() {return mergeResource.get(); } private Optional<Response> handleNotArchived(Throwable throwable) { if (throwable instanceof RepositoryIsNotArchivedException) { diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java index 29a4107aad..9e680b7e5c 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java @@ -1,11 +1,12 @@ package sonia.scm.api.v2.resources; import com.google.inject.Inject; +import de.otto.edison.hal.Embedded; import de.otto.edison.hal.Link; import de.otto.edison.hal.Links; -import org.mapstruct.AfterMapping; import org.mapstruct.Mapper; -import org.mapstruct.MappingTarget; +import org.mapstruct.ObjectFactory; +import sonia.scm.repository.Feature; import sonia.scm.repository.HealthCheckFailure; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryPermissions; @@ -16,6 +17,7 @@ import sonia.scm.repository.api.ScmProtocol; import java.util.List; +import static de.otto.edison.hal.Embedded.embeddedBuilder; import static de.otto.edison.hal.Link.link; import static de.otto.edison.hal.Links.linkingTo; import static java.util.stream.Collectors.toList; @@ -32,15 +34,17 @@ public abstract class RepositoryToRepositoryDtoMapper extends BaseMapper<Reposit abstract HealthCheckFailureDto toDto(HealthCheckFailure failure); - @AfterMapping - void appendLinks(Repository repository, @MappingTarget RepositoryDto target) { - Links.Builder linksBuilder = linkingTo().self(resourceLinks.repository().self(target.getNamespace(), target.getName())); + @ObjectFactory + RepositoryDto createDto(Repository repository) { + Links.Builder linksBuilder = linkingTo().self(resourceLinks.repository().self(repository.getNamespace(), repository.getName())); if (RepositoryPermissions.delete(repository).isPermitted()) { - linksBuilder.single(link("delete", resourceLinks.repository().delete(target.getNamespace(), target.getName()))); + linksBuilder.single(link("delete", resourceLinks.repository().delete(repository.getNamespace(), repository.getName()))); } if (RepositoryPermissions.modify(repository).isPermitted()) { - linksBuilder.single(link("update", resourceLinks.repository().update(target.getNamespace(), target.getName()))); - linksBuilder.single(link("permissions", resourceLinks.permission().all(target.getNamespace(), target.getName()))); + linksBuilder.single(link("update", resourceLinks.repository().update(repository.getNamespace(), repository.getName()))); + } + if (RepositoryPermissions.permissionRead(repository).isPermitted()) { + linksBuilder.single(link("permissions", resourceLinks.repositoryPermission().all(repository.getNamespace(), repository.getName()))); } try (RepositoryService repositoryService = serviceFactory.create(repository)) { if (RepositoryPermissions.pull(repository).isPermitted()) { @@ -50,15 +54,27 @@ public abstract class RepositoryToRepositoryDtoMapper extends BaseMapper<Reposit linksBuilder.array(protocolLinks); } if (repositoryService.isSupported(Command.TAGS)) { - linksBuilder.single(link("tags", resourceLinks.tag().all(target.getNamespace(), target.getName()))); + linksBuilder.single(link("tags", resourceLinks.tag().all(repository.getNamespace(), repository.getName()))); } if (repositoryService.isSupported(Command.BRANCHES)) { - linksBuilder.single(link("branches", resourceLinks.branchCollection().self(target.getNamespace(), target.getName()))); + linksBuilder.single(link("branches", resourceLinks.branchCollection().self(repository.getNamespace(), repository.getName()))); + } + if (repositoryService.isSupported(Feature.INCOMING_REVISION)) { + linksBuilder.single(link("incomingChangesets", resourceLinks.incoming().changesets(repository.getNamespace(), repository.getName()))); + linksBuilder.single(link("incomingDiff", resourceLinks.incoming().diff(repository.getNamespace(), repository.getName()))); + } + if (repositoryService.isSupported(Command.MERGE)) { + linksBuilder.single(link("merge", resourceLinks.merge().merge(repository.getNamespace(), repository.getName()))); + linksBuilder.single(link("mergeDryRun", resourceLinks.merge().dryRun(repository.getNamespace(), repository.getName()))); } } - linksBuilder.single(link("changesets", resourceLinks.changeset().all(target.getNamespace(), target.getName()))); - linksBuilder.single(link("sources", resourceLinks.source().selfWithoutRevision(target.getNamespace(), target.getName()))); - target.add(linksBuilder.build()); + linksBuilder.single(link("changesets", resourceLinks.changeset().all(repository.getNamespace(), repository.getName()))); + linksBuilder.single(link("sources", resourceLinks.source().selfWithoutRevision(repository.getNamespace(), repository.getName()))); + + Embedded.Builder embeddedBuilder = embeddedBuilder(); + applyEnrichers(new EdisonHalAppender(linksBuilder, embeddedBuilder), repository); + + return new RepositoryDto(linksBuilder.build(), embeddedBuilder.build()); } private Link createProtocolLink(ScmProtocol protocol) { diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java index 970166257a..644b3adc3f 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java @@ -96,6 +96,52 @@ class ResourceLinks { } } + interface WithPermissionLinks { + String permissions(String name); + + String overwritePermissions(String name); + } + + UserPermissionLinks userPermissions() { + return new UserPermissionLinks(scmPathInfoStore.get()); + } + + static class UserPermissionLinks implements WithPermissionLinks { + private final LinkBuilder userPermissionLinkBuilder; + + UserPermissionLinks(ScmPathInfo pathInfo) { + this.userPermissionLinkBuilder = new LinkBuilder(pathInfo, UserRootResource.class, UserResource.class, UserPermissionResource.class); + } + + public String permissions(String name) { + return userPermissionLinkBuilder.method("getUserResource").parameters(name).method("permissions").parameters().method("getPermissions").parameters().href(); + } + + public String overwritePermissions(String name) { + return userPermissionLinkBuilder.method("getUserResource").parameters(name).method("permissions").parameters().method("overwritePermissions").parameters().href(); + } + } + + GroupPermissionLinks groupPermissions() { + return new GroupPermissionLinks(scmPathInfoStore.get()); + } + + static class GroupPermissionLinks implements WithPermissionLinks { + private final LinkBuilder groupPermissionLinkBuilder; + + GroupPermissionLinks(ScmPathInfo pathInfo) { + this.groupPermissionLinkBuilder = new LinkBuilder(pathInfo, GroupRootResource.class, GroupResource.class, GroupPermissionResource.class); + } + + public String permissions(String name) { + return groupPermissionLinkBuilder.method("getGroupResource").parameters(name).method("permissions").parameters().method("getPermissions").parameters().href(); + } + + public String overwritePermissions(String name) { + return groupPermissionLinkBuilder.method("getGroupResource").parameters(name).method("permissions").parameters().method("overwritePermissions").parameters().href(); + } + } + MeLinks me() { return new MeLinks(scmPathInfoStore.get(), this.user()); } @@ -323,8 +369,34 @@ class ResourceLinks { return branchLinkBuilder.method("getRepositoryResource").parameters(namespaceAndName.getNamespace(), namespaceAndName.getName()).method("branches").parameters().method("history").parameters(branch).href(); } - public String changesetDiff(NamespaceAndName namespaceAndName, String branch) { - return branchLinkBuilder.method("getRepositoryResource").parameters(namespaceAndName.getNamespace(), namespaceAndName.getName()).method("branches").parameters().method("changesetDiff").parameters(branch, "").href() + "{otherBranch}"; + } + + public IncomingLinks incoming() { + return new IncomingLinks(scmPathInfoStore.get()); + } + + static class IncomingLinks { + private final LinkBuilder incomingLinkBuilder; + + IncomingLinks(ScmPathInfo pathInfo) { + incomingLinkBuilder = new LinkBuilder(pathInfo, RepositoryRootResource.class, RepositoryResource.class, IncomingRootResource.class); + } + + public String changesets(String namespace, String name) { + return toTemplateParams(incomingLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("incoming").parameters().method("incomingChangesets").parameters("source","target").href()); + } + + public String changesets(String namespace, String name, String source, String target) { + return incomingLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("incoming").parameters().method("incomingChangesets").parameters(source,target).href(); + } + + public String diff(String namespace, String name) { + return toTemplateParams(incomingLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("incoming").parameters().method("incomingDiff").parameters("source", "target").href()); + + } + + public String toTemplateParams(String href) { + return href.replace("source", "{source}").replace("target", "{target}"); } } @@ -433,15 +505,16 @@ class ResourceLinks { } - public PermissionLinks permission() { - return new PermissionLinks(scmPathInfoStore.get()); + + public RepositoryPermissionLinks repositoryPermission() { + return new RepositoryPermissionLinks(scmPathInfoStore.get()); } - static class PermissionLinks { + static class RepositoryPermissionLinks { private final LinkBuilder permissionLinkBuilder; - PermissionLinks(ScmPathInfo pathInfo) { - permissionLinkBuilder = new LinkBuilder(pathInfo, RepositoryRootResource.class, RepositoryResource.class, PermissionRootResource.class); + RepositoryPermissionLinks(ScmPathInfo pathInfo) { + permissionLinkBuilder = new LinkBuilder(pathInfo, RepositoryRootResource.class, RepositoryResource.class, RepositoryPermissionRootResource.class); } String all(String namespace, String name) { @@ -541,4 +614,55 @@ class ResourceLinks { } } + public MergeLinks merge() { + return new MergeLinks(scmPathInfoStore.get()); + } + + static class MergeLinks { + private final LinkBuilder mergeLinkBuilder; + + MergeLinks(ScmPathInfo pathInfo) { + this.mergeLinkBuilder = new LinkBuilder(pathInfo, RepositoryRootResource.class, RepositoryResource.class, MergeResource.class); + } + + String merge(String namespace, String name) { + return mergeLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("merge").parameters().method("merge").parameters().href(); + } + + String dryRun(String namespace, String name) { + return mergeLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("merge").parameters().method("dryRun").parameters().href(); + } + } + + public PermissionsLinks permissions() { + return new PermissionsLinks(scmPathInfoStore.get()); + } + + static class PermissionsLinks { + private final LinkBuilder permissionsLinkBuilder; + + PermissionsLinks(ScmPathInfo scmPathInfo) { + this.permissionsLinkBuilder = new LinkBuilder(scmPathInfo, GlobalPermissionResource.class); + } + + String self() { + return permissionsLinkBuilder.method("getAll").parameters().href(); + } + } + + public AvailableRepositoryPermissionLinks availableRepositoryPermissions() { + return new AvailableRepositoryPermissionLinks(scmPathInfoStore.get()); + } + + static class AvailableRepositoryPermissionLinks { + private final LinkBuilder linkBuilder; + + AvailableRepositoryPermissionLinks(ScmPathInfo scmPathInfo) { + this.linkBuilder = new LinkBuilder(scmPathInfo, RepositoryPermissionResource.class); + } + + String self() { + return linkBuilder.method("get").parameters().href(); + } + } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ViolationExceptionToErrorDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResteasyViolationExceptionToErrorDtoMapper.java similarity index 96% rename from scm-webapp/src/main/java/sonia/scm/api/v2/resources/ViolationExceptionToErrorDtoMapper.java rename to scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResteasyViolationExceptionToErrorDtoMapper.java index e713b031f7..7bab2d4272 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ViolationExceptionToErrorDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResteasyViolationExceptionToErrorDtoMapper.java @@ -12,7 +12,7 @@ import java.util.List; import java.util.stream.Collectors; @Mapper -public abstract class ViolationExceptionToErrorDtoMapper { +public abstract class ResteasyViolationExceptionToErrorDtoMapper { @Mapping(target = "errorCode", ignore = true) @Mapping(target = "transactionId", ignore = true) diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ScmViolationExceptionToErrorDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ScmViolationExceptionToErrorDtoMapper.java new file mode 100644 index 0000000000..3828134cc1 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ScmViolationExceptionToErrorDtoMapper.java @@ -0,0 +1,53 @@ +package sonia.scm.api.v2.resources; + +import org.mapstruct.AfterMapping; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingTarget; +import org.slf4j.MDC; +import sonia.scm.ScmConstraintViolationException; +import sonia.scm.ScmConstraintViolationException.ScmConstraintViolation; + +import java.util.List; +import java.util.stream.Collectors; + +@Mapper +public abstract class ScmViolationExceptionToErrorDtoMapper { + + @Mapping(target = "errorCode", ignore = true) + @Mapping(target = "transactionId", ignore = true) + @Mapping(target = "context", ignore = true) + public abstract ErrorDto map(ScmConstraintViolationException exception); + + @AfterMapping + void setTransactionId(@MappingTarget ErrorDto dto) { + dto.setTransactionId(MDC.get("transaction_id")); + } + + @AfterMapping + void mapViolations(ScmConstraintViolationException exception, @MappingTarget ErrorDto dto) { + List<ErrorDto.ConstraintViolationDto> violations = + exception.getViolations() + .stream() + .map(this::createViolationDto) + .collect(Collectors.toList()); + dto.setViolations(violations); + } + + private ErrorDto.ConstraintViolationDto createViolationDto(ScmConstraintViolation violation) { + ErrorDto.ConstraintViolationDto constraintViolationDto = new ErrorDto.ConstraintViolationDto(); + constraintViolationDto.setMessage(violation.getMessage()); + constraintViolationDto.setPath(violation.getPropertyPath()); + return constraintViolationDto; + } + + @AfterMapping + void setErrorCode(@MappingTarget ErrorDto dto) { + dto.setErrorCode("3zR9vPNIE1"); + } + + @AfterMapping + void setMessage(@MappingTarget ErrorDto dto) { + dto.setMessage("input violates conditions (see violation list)"); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagDto.java index 8af036f5a3..a3d4c5d17e 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagDto.java @@ -1,5 +1,6 @@ package sonia.scm.api.v2.resources; +import de.otto.edison.hal.Embedded; import de.otto.edison.hal.HalRepresentation; import de.otto.edison.hal.Links; import lombok.Getter; @@ -15,10 +16,8 @@ public class TagDto extends HalRepresentation { private String revision; - @Override - @SuppressWarnings("squid:S1185") // We want to have this method available in this package - protected HalRepresentation add(Links links) { - return super.add(links); + TagDto(Links links, Embedded embedded) { + super(links, embedded); } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagToTagDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagToTagDtoMapper.java index ee0488e037..3a7faf3155 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagToTagDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagToTagDtoMapper.java @@ -1,21 +1,22 @@ package sonia.scm.api.v2.resources; +import de.otto.edison.hal.Embedded; import de.otto.edison.hal.Links; -import org.mapstruct.AfterMapping; import org.mapstruct.Context; import org.mapstruct.Mapper; import org.mapstruct.Mapping; -import org.mapstruct.MappingTarget; +import org.mapstruct.ObjectFactory; import sonia.scm.repository.NamespaceAndName; import sonia.scm.repository.Tag; import javax.inject.Inject; +import static de.otto.edison.hal.Embedded.embeddedBuilder; import static de.otto.edison.hal.Link.link; import static de.otto.edison.hal.Links.linkingTo; @Mapper -public abstract class TagToTagDtoMapper { +public abstract class TagToTagDtoMapper extends HalAppenderMapper { @Inject private ResourceLinks resourceLinks; @@ -23,12 +24,16 @@ public abstract class TagToTagDtoMapper { @Mapping(target = "attributes", ignore = true) // We do not map HAL attributes public abstract TagDto map(Tag tag, @Context NamespaceAndName namespaceAndName); - @AfterMapping - void appendLinks(@MappingTarget TagDto target, @Context NamespaceAndName namespaceAndName) { + @ObjectFactory + TagDto createDto(@Context NamespaceAndName namespaceAndName, Tag tag) { Links.Builder linksBuilder = linkingTo() - .self(resourceLinks.tag().self(namespaceAndName.getNamespace(), namespaceAndName.getName(), target.getName())) - .single(link("sources", resourceLinks.source().self(namespaceAndName.getNamespace(), namespaceAndName.getName(), target.getRevision()))) - .single(link("changeset", resourceLinks.changeset().self(namespaceAndName.getNamespace(), namespaceAndName.getName(), target.getRevision()))); - target.add(linksBuilder.build()); + .self(resourceLinks.tag().self(namespaceAndName.getNamespace(), namespaceAndName.getName(), tag.getName())) + .single(link("sources", resourceLinks.source().self(namespaceAndName.getNamespace(), namespaceAndName.getName(), tag.getRevision()))) + .single(link("changeset", resourceLinks.changeset().self(namespaceAndName.getNamespace(), namespaceAndName.getName(), tag.getRevision()))); + + Embedded.Builder embeddedBuilder = embeddedBuilder(); + applyEnrichers(new EdisonHalAppender(linksBuilder, embeddedBuilder), tag, namespaceAndName); + + return new TagDto(linksBuilder.build(), embeddedBuilder.build()); } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserCollectionResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserCollectionResource.java index a4fe9adb94..a7442a2262 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserCollectionResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserCollectionResource.java @@ -11,7 +11,6 @@ import sonia.scm.user.UserManager; import sonia.scm.web.VndMediaType; import javax.inject.Inject; -import javax.inject.Named; import javax.validation.Valid; import javax.ws.rs.Consumes; import javax.ws.rs.DefaultValue; diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserDto.java index 9dc5b850bd..0ee7b2f82c 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserDto.java @@ -1,6 +1,7 @@ package sonia.scm.api.v2.resources; import com.fasterxml.jackson.annotation.JsonInclude; +import de.otto.edison.hal.Embedded; import de.otto.edison.hal.HalRepresentation; import de.otto.edison.hal.Links; import lombok.Getter; @@ -33,9 +34,7 @@ public class UserDto extends HalRepresentation { private String type; private Map<String, String> properties; - @Override - @SuppressWarnings("squid:S1185") // We want to have this method available in this package - protected HalRepresentation add(Links links) { - return super.add(links); + UserDto(Links links, Embedded embedded) { + super(links, embedded); } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserPermissionResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserPermissionResource.java new file mode 100644 index 0000000000..2b02104646 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserPermissionResource.java @@ -0,0 +1,79 @@ +package sonia.scm.api.v2.resources; + +import com.webcohesion.enunciate.metadata.rs.ResponseCode; +import com.webcohesion.enunciate.metadata.rs.StatusCodes; +import com.webcohesion.enunciate.metadata.rs.TypeHint; +import sonia.scm.security.PermissionAssigner; +import sonia.scm.security.PermissionDescriptor; +import sonia.scm.web.VndMediaType; + +import javax.inject.Inject; +import javax.ws.rs.Consumes; +import javax.ws.rs.GET; +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Response; +import java.util.Arrays; +import java.util.Collection; +import java.util.stream.Collectors; + +public class UserPermissionResource { + + private final PermissionAssigner permissionAssigner; + private final PermissionCollectionToDtoMapper permissionCollectionToDtoMapper; + + @Inject + public UserPermissionResource(PermissionAssigner permissionAssigner, PermissionCollectionToDtoMapper permissionCollectionToDtoMapper) { + this.permissionAssigner = permissionAssigner; + this.permissionCollectionToDtoMapper = permissionCollectionToDtoMapper; + } + + /** + * Returns permissions for a user. + * + * @param id the id/name of the user + */ + @GET + @Path("") + @Produces(VndMediaType.PERMISSION_COLLECTION) + @TypeHint(PermissionListDto.class) + @StatusCodes({ + @ResponseCode(code = 200, condition = "success"), + @ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), + @ResponseCode(code = 403, condition = "not authorized, the current user has no privileges to read the user"), + @ResponseCode(code = 404, condition = "not found, no user with the specified id/name available"), + @ResponseCode(code = 500, condition = "internal server error") + }) + public Response getPermissions(@PathParam("id") String id) { + Collection<PermissionDescriptor> permissions = permissionAssigner.readPermissionsForUser(id); + return Response.ok(permissionCollectionToDtoMapper.mapForUser(permissions, id)).build(); + } + + /** + * Sets permissions for a user. Overwrites all existing permissions. + * + * @param id id of the user to be modified + * @param newPermissions New list of permissions for the user + */ + @PUT + @Path("") + @Consumes(VndMediaType.PERMISSION_COLLECTION) + @StatusCodes({ + @ResponseCode(code = 204, condition = "update success"), + @ResponseCode(code = 400, condition = "Invalid body"), + @ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), + @ResponseCode(code = 403, condition = "not authorized, the current user does not have the correct privilege"), + @ResponseCode(code = 404, condition = "not found, no user with the specified id/name available"), + @ResponseCode(code = 500, condition = "internal server error") + }) + @TypeHint(TypeHint.NO_CONTENT.class) + public Response overwritePermissions(@PathParam("id") String id, PermissionListDto newPermissions) { + Collection<PermissionDescriptor> permissionDescriptors = Arrays.stream(newPermissions.getPermissions()) + .map(PermissionDescriptor::new) + .collect(Collectors.toList()); + permissionAssigner.setPermissionsForUser(id, permissionDescriptors); + return Response.noContent().build(); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserResource.java index 0076d057ca..6e90b4e6ec 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserResource.java @@ -9,7 +9,6 @@ import sonia.scm.user.UserManager; import sonia.scm.web.VndMediaType; import javax.inject.Inject; -import javax.inject.Named; import javax.validation.Valid; import javax.ws.rs.Consumes; import javax.ws.rs.DELETE; @@ -28,14 +27,20 @@ public class UserResource { private final IdResourceManagerAdapter<User, UserDto> adapter; private final UserManager userManager; private final PasswordService passwordService; + private final UserPermissionResource userPermissionResource; @Inject - public UserResource(UserDtoToUserMapper dtoToUserMapper, UserToUserDtoMapper userToDtoMapper, UserManager manager, PasswordService passwordService) { + public UserResource( + UserDtoToUserMapper dtoToUserMapper, + UserToUserDtoMapper userToDtoMapper, + UserManager manager, + PasswordService passwordService, UserPermissionResource userPermissionResource) { this.dtoToUserMapper = dtoToUserMapper; this.userToDtoMapper = userToDtoMapper; this.adapter = new IdResourceManagerAdapter<>(manager, User.class); this.userManager = manager; this.passwordService = passwordService; + this.userPermissionResource = userPermissionResource; } /** @@ -132,4 +137,9 @@ public class UserResource { userManager.overwritePassword(name, passwordService.encryptPassword(passwordOverwrite.getNewPassword())); return Response.noContent().build(); } + + @Path("permissions") + public UserPermissionResource permissions() { + return userPermissionResource; + } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserToUserDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserToUserDtoMapper.java index eb49ff5f3d..ac641e3e66 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserToUserDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserToUserDtoMapper.java @@ -1,17 +1,18 @@ package sonia.scm.api.v2.resources; -import com.google.common.annotations.VisibleForTesting; +import de.otto.edison.hal.Embedded; import de.otto.edison.hal.Links; -import org.mapstruct.AfterMapping; import org.mapstruct.Mapper; import org.mapstruct.Mapping; -import org.mapstruct.MappingTarget; +import org.mapstruct.ObjectFactory; +import sonia.scm.security.PermissionPermissions; import sonia.scm.user.User; import sonia.scm.user.UserManager; import sonia.scm.user.UserPermissions; import javax.inject.Inject; +import static de.otto.edison.hal.Embedded.embeddedBuilder; import static de.otto.edison.hal.Link.link; import static de.otto.edison.hal.Links.linkingTo; @@ -31,19 +32,26 @@ public abstract class UserToUserDtoMapper extends BaseMapper<User, UserDto> { @Inject private ResourceLinks resourceLinks; - @AfterMapping - protected void appendLinks(User user, @MappingTarget UserDto target) { - Links.Builder linksBuilder = linkingTo().self(resourceLinks.user().self(target.getName())); + @ObjectFactory + UserDto createDto(User user) { + Links.Builder linksBuilder = linkingTo().self(resourceLinks.user().self(user.getName())); if (UserPermissions.delete(user).isPermitted()) { - linksBuilder.single(link("delete", resourceLinks.user().delete(target.getName()))); + linksBuilder.single(link("delete", resourceLinks.user().delete(user.getName()))); } if (UserPermissions.modify(user).isPermitted()) { - linksBuilder.single(link("update", resourceLinks.user().update(target.getName()))); + linksBuilder.single(link("update", resourceLinks.user().update(user.getName()))); if (userManager.isTypeDefault(user)) { - linksBuilder.single(link("password", resourceLinks.user().passwordChange(target.getName()))); + linksBuilder.single(link("password", resourceLinks.user().passwordChange(user.getName()))); } } - target.add(linksBuilder.build()); + if (PermissionPermissions.read().isPermitted()) { + linksBuilder.single(link("permissions", resourceLinks.userPermissions().permissions(user.getName()))); + } + + Embedded.Builder embeddedBuilder = embeddedBuilder(); + applyEnrichers(new EdisonHalAppender(linksBuilder, embeddedBuilder), user); + + return new UserDto(linksBuilder.build(), embeddedBuilder.build()); } } diff --git a/scm-webapp/src/main/java/sonia/scm/boot/BootstrapContextListener.java b/scm-webapp/src/main/java/sonia/scm/boot/BootstrapContextListener.java index afaa28bfe8..be5a1e7ac2 100644 --- a/scm-webapp/src/main/java/sonia/scm/boot/BootstrapContextListener.java +++ b/scm-webapp/src/main/java/sonia/scm/boot/BootstrapContextListener.java @@ -33,12 +33,9 @@ package sonia.scm.boot; //~--- non-JDK imports -------------------------------------------------------- -import com.github.legman.Subscribe; - import com.google.common.base.Charsets; import com.google.common.collect.ImmutableList; import com.google.common.io.Files; -import com.google.inject.servlet.GuiceFilter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/scm-webapp/src/main/java/sonia/scm/debug/DebugService.java b/scm-webapp/src/main/java/sonia/scm/debug/DebugService.java index 8e2475d802..5fb5925e6c 100644 --- a/scm-webapp/src/main/java/sonia/scm/debug/DebugService.java +++ b/scm-webapp/src/main/java/sonia/scm/debug/DebugService.java @@ -52,7 +52,7 @@ public final class DebugService private final Multimap<NamespaceAndName,DebugHookData> receivedHooks = LinkedListMultimap.create(); /** - * Stores {@link DebugHookData} for the given repository. + * Store {@link DebugHookData} for the given repository. */ void put(NamespaceAndName namespaceAndName, DebugHookData hookData) { diff --git a/scm-webapp/src/main/java/sonia/scm/filter/MDCFilter.java b/scm-webapp/src/main/java/sonia/scm/filter/MDCFilter.java index b77a927a2d..fc52ef4eff 100644 --- a/scm-webapp/src/main/java/sonia/scm/filter/MDCFilter.java +++ b/scm-webapp/src/main/java/sonia/scm/filter/MDCFilter.java @@ -42,7 +42,6 @@ import org.slf4j.MDC; import sonia.scm.SCMContext; import sonia.scm.security.DefaultKeyGenerator; -import sonia.scm.security.KeyGenerator; import sonia.scm.web.filter.HttpFilter; //~--- JDK imports ------------------------------------------------------------ diff --git a/scm-webapp/src/main/java/sonia/scm/filter/PropagatePrincipleFilter.java b/scm-webapp/src/main/java/sonia/scm/filter/PropagatePrincipleFilter.java index 508e804d1f..e7d020ae18 100644 --- a/scm-webapp/src/main/java/sonia/scm/filter/PropagatePrincipleFilter.java +++ b/scm-webapp/src/main/java/sonia/scm/filter/PropagatePrincipleFilter.java @@ -51,8 +51,6 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; -import static sonia.scm.api.v2.resources.ScmPathInfo.REST_API_PATH; - //~--- JDK imports ------------------------------------------------------------ /** diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/ExplodedSmp.java b/scm-webapp/src/main/java/sonia/scm/plugin/ExplodedSmp.java index eb48534ed1..d1fe214f50 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/ExplodedSmp.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/ExplodedSmp.java @@ -34,6 +34,8 @@ package sonia.scm.plugin; //~--- non-JDK imports -------------------------------------------------------- import com.google.common.base.Function; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; //~--- JDK imports ------------------------------------------------------------ @@ -52,17 +54,18 @@ import java.util.Set; public final class ExplodedSmp implements Comparable<ExplodedSmp> { + private static final Logger logger = LoggerFactory.getLogger(ExplodedSmp.class); + /** * Constructs ... * * * @param path - * @param pluginId - * @param dependencies * @param plugin */ ExplodedSmp(Path path, Plugin plugin) { + logger.trace("create exploded scm for plugin {} and dependencies {}", plugin.getInformation().getName(), plugin.getDependencies()); this.path = path; this.plugin = plugin; } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginNode.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginNode.java index 281fb2eab1..e28ccff2ff 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginNode.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginNode.java @@ -175,6 +175,11 @@ public final class PluginNode this.wrapper = wrapper; } + @Override + public String toString() { + return plugin.getPath().toString() + " -> " + children; + } + //~--- fields --------------------------------------------------------------- /** Field description */ diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java index 4e4e97591a..11308789f4 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java @@ -162,34 +162,29 @@ public final class PluginProcessor Set<Path> archives = collect(pluginDirectory, new PluginArchiveFilter()); - if (logger.isDebugEnabled()) - { - logger.debug("extract {} archives", archives.size()); - } + logger.debug("extract {} archives", archives.size()); extract(archives); List<Path> dirs = collectPluginDirectories(pluginDirectory); - if (logger.isDebugEnabled()) - { - logger.debug("process {} directories", dirs.size()); - } + logger.debug("process {} directories: {}", dirs.size(), dirs); List<ExplodedSmp> smps = Lists.transform(dirs, new PathTransformer()); logger.trace("start building plugin tree"); - List<PluginNode> rootNodes = new PluginTree(smps).getRootNodes(); + PluginTree pluginTree = new PluginTree(smps); + + logger.trace("build plugin tree: {}", pluginTree); + + List<PluginNode> rootNodes = pluginTree.getRootNodes(); logger.trace("create plugin wrappers and build classloaders"); Set<PluginWrapper> wrappers = createPluginWrappers(classLoader, rootNodes); - if (logger.isDebugEnabled()) - { - logger.debug("collected {} plugins", wrappers.size()); - } + logger.debug("collected {} plugins", wrappers.size()); return ImmutableSet.copyOf(wrappers); } @@ -208,6 +203,9 @@ public final class PluginProcessor ClassLoader classLoader, PluginNode node) throws IOException { + if (node.getWrapper() != null) { + return; + } ExplodedSmp smp = node.getPlugin(); List<ClassLoader> parents = Lists.newArrayList(); diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginTree.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginTree.java index 9757fa2513..7e57fb3d57 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginTree.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginTree.java @@ -112,14 +112,14 @@ public final class PluginTree } else { - appendNode(rootNodes, dependencies, smp); + appendNode(smp); } } else { //J- throw new PluginConditionFailedException( - condition, + condition, String.format( "could not load plugin %s, the plugin condition does not match", plugin.getInformation().getId() @@ -149,23 +149,20 @@ public final class PluginTree * Method description * * - * @param nodes - * @param dependencies * @param smp */ - private void appendNode(List<PluginNode> nodes, Set<String> dependencies, - ExplodedSmp smp) + private void appendNode(ExplodedSmp smp) { PluginNode child = new PluginNode(smp); - for (String dependency : dependencies) + for (String dependency : smp.getPlugin().getDependencies()) { - if (!appendNode(nodes, child, dependency)) + if (!appendNode(rootNodes, child, dependency)) { //J- throw new PluginNotInstalledException( String.format( - "dependency %s of %s is not installed", + "dependency %s of %s is not installed", dependency, child.getId() ) @@ -188,7 +185,7 @@ public final class PluginTree private boolean appendNode(List<PluginNode> nodes, PluginNode child, String dependency) { - logger.debug("check for {} {}", dependency, child.getId()); + logger.debug("check for {} as dependency of {}", dependency, child.getId()); boolean found = false; @@ -196,29 +193,28 @@ public final class PluginTree { if (node.getId().equals(dependency)) { - logger.debug("add plugin {} as child of {}", child.getId(), - node.getId()); + logger.debug("add plugin {} as child of {}", child.getId(), node.getId()); node.addChild(child); found = true; break; } - else + else if (appendNode(node.getChildren(), child, dependency)) { - if (appendNode(node.getChildren(), child, dependency)) - { - found = true; - - break; - } + found = true; + break; } } return found; } - //~--- fields --------------------------------------------------------------- + @Override + public String toString() { + return "plugin tree: " + rootNodes.toString(); + } +//~--- fields --------------------------------------------------------------- /** Field description */ private final List<PluginNode> rootNodes = Lists.newArrayList(); diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/UberClassLoader.java b/scm-webapp/src/main/java/sonia/scm/plugin/UberClassLoader.java index 6906afc7d4..311cb9e879 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/UberClassLoader.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/UberClassLoader.java @@ -73,43 +73,39 @@ public final class UberClassLoader extends ClassLoader //~--- methods -------------------------------------------------------------- - /** - * Method description - * - * - * @param name - * - * @return - * - * @throws ClassNotFoundException - */ @Override protected Class<?> findClass(String name) throws ClassNotFoundException { Class<?> clazz = getFromCache(name); - if (clazz == null) - { - for (PluginWrapper plugin : plugins) - { - ClassLoader cl = plugin.getClassLoader(); - - // load class could be slow, perhaps we should call - // find class via reflection ??? - clazz = cl.loadClass(name); - - if (clazz != null) - { - cache.put(name, new WeakReference<Class<?>>(clazz)); - - break; - } - } + if (clazz == null) { + clazz = findClassInPlugins(name); + cache.put(name, new WeakReference<>(clazz)); } return clazz; } + private Class<?> findClassInPlugins(String name) throws ClassNotFoundException { + for (PluginWrapper plugin : plugins) { + Class<?> clazz = findClass(plugin.getClassLoader(), name); + if (clazz != null) { + return clazz; + } + } + throw new ClassNotFoundException("could not find class " + name + " in any of the installed plugins"); + } + + private Class<?> findClass(ClassLoader classLoader, String name) { + try { + // load class could be slow, perhaps we should call + // find class via reflection ??? + return classLoader.loadClass(name); + } catch (ClassNotFoundException ex) { + return null; + } + } + /** * Method description * diff --git a/scm-webapp/src/main/java/sonia/scm/repository/DefaultRepositoryManager.java b/scm-webapp/src/main/java/sonia/scm/repository/DefaultRepositoryManager.java index c409a860ec..4fd4682456 100644 --- a/scm-webapp/src/main/java/sonia/scm/repository/DefaultRepositoryManager.java +++ b/scm-webapp/src/main/java/sonia/scm/repository/DefaultRepositoryManager.java @@ -39,7 +39,6 @@ import com.google.inject.Singleton; import org.apache.shiro.concurrent.SubjectAwareExecutorService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import sonia.scm.AlreadyExistsException; import sonia.scm.ConfigurationException; import sonia.scm.HandlerEventType; import sonia.scm.ManagerDaoAdapter; @@ -138,17 +137,18 @@ public class DefaultRepositoryManager extends AbstractRepositoryManager { return managerDaoAdapter.create( repository, RepositoryPermissions::create, + newRepository -> fireEvent(HandlerEventType.BEFORE_CREATE, newRepository), newRepository -> { + fireEvent(HandlerEventType.CREATE, newRepository); if (initRepository) { try { getHandler(newRepository).create(newRepository); - } catch (AlreadyExistsException e) { - throw new InternalRepositoryException(repository, "directory for repository does already exist", e); + } catch (InternalRepositoryException e) { + delete(repository); + throw e; } } - fireEvent(HandlerEventType.BEFORE_CREATE, newRepository); }, - newRepository -> fireEvent(HandlerEventType.CREATE, newRepository), newRepository -> repositoryDAO.contains(newRepository.getNamespaceAndName()) ); } @@ -169,7 +169,7 @@ public class DefaultRepositoryManager extends AbstractRepositoryManager { throw new RepositoryIsNotArchivedException(); } fireEvent(HandlerEventType.BEFORE_DELETE, toDelete); -// getHandler(toDelete).delete(toDelete); + getHandler(toDelete).delete(toDelete); } @Override diff --git a/scm-webapp/src/main/java/sonia/scm/security/AuthorizationChangedEventProducer.java b/scm-webapp/src/main/java/sonia/scm/security/AuthorizationChangedEventProducer.java index cf4c980625..0586db2bb3 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/AuthorizationChangedEventProducer.java +++ b/scm-webapp/src/main/java/sonia/scm/security/AuthorizationChangedEventProducer.java @@ -189,9 +189,9 @@ public class AuthorizationChangedEventProducer { * @param event permission event */ @Subscribe - public void onEvent(StoredAssignedPermissionEvent event) { + public void onEvent(AssignedPermissionEvent event) { if (event.getEventType().isPost()) { - StoredAssignedPermission permission = event.getPermission(); + AssignedPermission permission = event.getPermission(); if (permission.isGroupPermission()) { handleGroupPermissionChange(permission); } else { @@ -200,18 +200,18 @@ public class AuthorizationChangedEventProducer { } } - private void handleGroupPermissionChange(StoredAssignedPermission permission) { + private void handleGroupPermissionChange(AssignedPermission permission) { logger.debug( - "fire authorization changed event, because global group permission {} has changed", - permission.getId() + "fire authorization changed event for group {}, because permission {} has changed", + permission.getName(), permission.getPermission() ); fireEventForEveryUser(); } - private void handleUserPermissionChange(StoredAssignedPermission permission) { + private void handleUserPermissionChange(AssignedPermission permission) { logger.debug( - "fire authorization changed event for user {}, because permission {} has changed", - permission.getName(), permission.getId() + "fire authorization changed event for user {}, because permission {} has changed", + permission.getName(), permission.getPermission() ); fireEventForUser(permission.getName()); } diff --git a/scm-webapp/src/main/java/sonia/scm/security/BearerRealm.java b/scm-webapp/src/main/java/sonia/scm/security/BearerRealm.java index 3b19351641..b237a0a5ff 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/BearerRealm.java +++ b/scm-webapp/src/main/java/sonia/scm/security/BearerRealm.java @@ -31,35 +31,20 @@ package sonia.scm.security; -//~--- non-JDK imports -------------------------------------------------------- - import com.google.common.annotations.VisibleForTesting; - -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.JwtException; -import io.jsonwebtoken.Jwts; - -import org.apache.shiro.authc.AuthenticationException; import org.apache.shiro.authc.AuthenticationInfo; import org.apache.shiro.authc.AuthenticationToken; import org.apache.shiro.authc.credential.AllowAllCredentialsMatcher; import org.apache.shiro.realm.AuthenticatingRealm; - import sonia.scm.group.GroupDAO; import sonia.scm.plugin.Extension; import sonia.scm.user.UserDAO; -import static com.google.common.base.Preconditions.checkArgument; - -import java.util.List; -import java.util.Set; - -//~--- JDK imports ------------------------------------------------------------ - import javax.inject.Inject; import javax.inject.Singleton; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; + +import static com.google.common.base.Preconditions.checkArgument; + /** * Realm for authentication with {@link BearerToken}. @@ -71,34 +56,29 @@ import org.slf4j.LoggerFactory; @Extension public class BearerRealm extends AuthenticatingRealm { - - /** - * the logger for BearerRealm - */ - private static final Logger LOG = LoggerFactory.getLogger(BearerRealm.class); /** realm name */ @VisibleForTesting static final String REALM = "BearerRealm"; - //~--- constructors --------------------------------------------------------- + + /** dao realm helper */ + private final DAORealmHelper helper; + + /** access token resolver **/ + private final AccessTokenResolver tokenResolver; /** * Constructs ... * * @param helperFactory dao realm helper factory - * @param resolver key resolver - * @param validators token claims validators + * @param tokenResolver resolve access token from bearer */ @Inject - public BearerRealm( - DAORealmHelperFactory helperFactory, SecureKeyResolver resolver, Set<TokenClaimsValidator> validators - ) - { + public BearerRealm(DAORealmHelperFactory helperFactory, AccessTokenResolver tokenResolver) { this.helper = helperFactory.create(REALM); - this.resolver = resolver; - this.validators = validators; - + this.tokenResolver = tokenResolver; + setCredentialsMatcher(new AllowAllCredentialsMatcher()); setAuthenticationTokenClass(BearerToken.class); } @@ -106,71 +86,26 @@ public class BearerRealm extends AuthenticatingRealm //~--- methods -------------------------------------------------------------- /** - * Validates the given jwt token and retrieves authentication data from + * Validates the given bearer token and retrieves authentication data from * {@link UserDAO} and {@link GroupDAO}. * * - * @param token jwt token + * @param token bearer token * * @return authentication data from user and group dao */ @Override - protected AuthenticationInfo doGetAuthenticationInfo( AuthenticationToken token) - { - checkArgument(token instanceof BearerToken, "%s is required", - BearerToken.class); + protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) { + checkArgument(token instanceof BearerToken, "%s is required", BearerToken.class); BearerToken bt = (BearerToken) token; - Claims c = checkToken(bt); + AccessToken accessToken = tokenResolver.resolve(bt); - return helper.getAuthenticationInfo(c.getSubject(), bt.getCredentials(), Scopes.fromClaims(c)); + return helper.authenticationInfoBuilder(accessToken.getSubject()) + .withCredentials(bt.getCredentials()) + .withScope(Scopes.fromClaims(accessToken.getClaims())) + .withGroups(accessToken.getGroups()) + .build(); } - /** - * Validates the jwt token. - * - * - * @param token jwt token - * - * @return claim - */ - private Claims checkToken(BearerToken token) - { - Claims claims; - - try - { - //J- - claims = Jwts.parser() - .setSigningKeyResolver(resolver) - .parseClaimsJws(token.getCredentials()) - .getBody(); - //J+ - - // check all registered claims validators - validators.forEach((validator) -> { - if (!validator.validate(claims)) { - LOG.warn("token claims is invalid, marked by validator {}", validator.getClass()); - throw new AuthenticationException("token claims is invalid"); - } - }); - } - catch (JwtException ex) - { - throw new AuthenticationException("signature is invalid", ex); - } - - return claims; - } - - //~--- fields --------------------------------------------------------------- - - /** token claims validators **/ - private final Set<TokenClaimsValidator> validators; - - /** dao realm helper */ - private final DAORealmHelper helper; - - /** secure key resolver */ - private final SecureKeyResolver resolver; } diff --git a/scm-webapp/src/main/java/sonia/scm/security/AccessTokenCookieIssuer.java b/scm-webapp/src/main/java/sonia/scm/security/DefaultAccessTokenCookieIssuer.java similarity index 93% rename from scm-webapp/src/main/java/sonia/scm/security/AccessTokenCookieIssuer.java rename to scm-webapp/src/main/java/sonia/scm/security/DefaultAccessTokenCookieIssuer.java index bb1473dca6..fd3f0e0d6f 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/AccessTokenCookieIssuer.java +++ b/scm-webapp/src/main/java/sonia/scm/security/DefaultAccessTokenCookieIssuer.java @@ -51,12 +51,12 @@ import java.util.concurrent.TimeUnit; * @author Sebastian Sdorra * @since 2.0.0 */ -public final class AccessTokenCookieIssuer { +public final class DefaultAccessTokenCookieIssuer implements AccessTokenCookieIssuer { /** - * the logger for AccessTokenCookieIssuer + * the logger for DefaultAccessTokenCookieIssuer */ - private static final Logger LOG = LoggerFactory.getLogger(AccessTokenCookieIssuer.class); + private static final Logger LOG = LoggerFactory.getLogger(DefaultAccessTokenCookieIssuer.class); private final ScmConfiguration configuration; @@ -66,7 +66,7 @@ public final class AccessTokenCookieIssuer { * @param configuration scm main configuration */ @Inject - public AccessTokenCookieIssuer(ScmConfiguration configuration) { + public DefaultAccessTokenCookieIssuer(ScmConfiguration configuration) { this.configuration = configuration; } diff --git a/scm-webapp/src/main/java/sonia/scm/security/DefaultAuthorizationCollector.java b/scm-webapp/src/main/java/sonia/scm/security/DefaultAuthorizationCollector.java index 8a79293642..9dca7a774e 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/DefaultAuthorizationCollector.java +++ b/scm-webapp/src/main/java/sonia/scm/security/DefaultAuthorizationCollector.java @@ -36,6 +36,7 @@ package sonia.scm.security; //~--- non-JDK imports -------------------------------------------------------- import com.github.legman.Subscribe; +import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Objects; import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableSet; @@ -51,10 +52,11 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.cache.Cache; import sonia.scm.cache.CacheManager; +import sonia.scm.config.ScmConfiguration; import sonia.scm.group.GroupNames; import sonia.scm.group.GroupPermissions; import sonia.scm.plugin.Extension; -import sonia.scm.repository.Permission; +import sonia.scm.repository.RepositoryPermission; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryDAO; import sonia.scm.user.User; @@ -62,7 +64,6 @@ import sonia.scm.user.UserPermissions; import sonia.scm.util.Util; import java.util.Collection; -import java.util.List; import java.util.Set; //~--- JDK imports ------------------------------------------------------------ @@ -76,9 +77,6 @@ import java.util.Set; public class DefaultAuthorizationCollector implements AuthorizationCollector { - // TODO move to util class - private static final String SEPARATOR = System.getProperty("line.separator", "\n"); - /** Field description */ private static final String ADMIN_PERMISSION = "*"; @@ -98,14 +96,16 @@ public class DefaultAuthorizationCollector implements AuthorizationCollector * * * + * @param configuration * @param cacheManager * @param repositoryDAO * @param securitySystem */ @Inject - public DefaultAuthorizationCollector(CacheManager cacheManager, - RepositoryDAO repositoryDAO, SecuritySystem securitySystem) + public DefaultAuthorizationCollector(ScmConfiguration configuration, CacheManager cacheManager, + RepositoryDAO repositoryDAO, SecuritySystem securitySystem) { + this.configuration = configuration; this.cache = cacheManager.getCache(CACHE_NAME); this.repositoryDAO = repositoryDAO; this.securitySystem = securitySystem; @@ -119,8 +119,8 @@ public class DefaultAuthorizationCollector implements AuthorizationCollector * * @return */ - @Override - public AuthorizationInfo collect() + @VisibleForTesting + AuthorizationInfo collect() { AuthorizationInfo authorizationInfo; Subject subject = SecurityUtils.getSubject(); @@ -144,6 +144,7 @@ public class DefaultAuthorizationCollector implements AuthorizationCollector * * @return */ + @Override public AuthorizationInfo collect(PrincipalCollection principals) { Preconditions.checkNotNull(principals, "principals parameter is required"); @@ -175,12 +176,12 @@ public class DefaultAuthorizationCollector implements AuthorizationCollector private void collectGlobalPermissions(Builder<String> builder, final User user, final GroupNames groups) { - List<StoredAssignedPermission> globalPermissions = + Collection<AssignedPermission> globalPermissions = securitySystem.getPermissions((AssignedPermission input) -> isUserPermitted(user, groups, input)); - for (StoredAssignedPermission gp : globalPermissions) + for (AssignedPermission gp : globalPermissions) { - String permission = gp.getPermission(); + String permission = gp.getPermission().getValue(); logger.trace("add permission {} for user {}", permission, user.getName()); builder.add(permission); @@ -199,18 +200,17 @@ public class DefaultAuthorizationCollector implements AuthorizationCollector private void collectRepositoryPermissions(Builder<String> builder, Repository repository, User user, GroupNames groups) { - Collection<Permission> repositoryPermissions - = repository.getPermissions(); + Collection<RepositoryPermission> repositoryPermissions = repository.getPermissions(); if (Util.isNotEmpty(repositoryPermissions)) { boolean hasPermission = false; - for (sonia.scm.repository.Permission permission : repositoryPermissions) + for (RepositoryPermission permission : repositoryPermissions) { hasPermission = isUserPermitted(user, groups, permission); - if (hasPermission) + if (hasPermission && !permission.getVerbs().isEmpty()) { - String perm = permission.getType().getPermissionPrefix().concat(repository.getId()); + String perm = "repository:" + String.join(",", permission.getVerbs()) + ":" + repository.getId(); if (logger.isTraceEnabled()) { logger.trace("add repository permission {} for user {} at repository {}", @@ -239,7 +239,7 @@ public class DefaultAuthorizationCollector implements AuthorizationCollector Set<String> roles; Set<String> permissions; - if (user.isAdmin()) + if (isAdmin(user, groups)) { if (logger.isDebugEnabled()) { @@ -270,6 +270,37 @@ public class DefaultAuthorizationCollector implements AuthorizationCollector return info; } + private boolean isAdmin(User user, GroupNames groups) { + boolean admin = user.isAdmin(); + if (admin) { + logger.debug("user {} is marked as admin, because of the user flag", user.getName()); + return true; + } + if (isUserAdminInConfiguration(user)) { + logger.debug("user {} is marked as admin, because of the admin user configuration", user.getName()); + return true; + } + return isUserAdminInGroupConfiguration(user, groups); + } + + private boolean isUserAdminInGroupConfiguration(User user, GroupNames groups) { + Set<String> adminGroups = configuration.getAdminGroups(); + if (adminGroups != null && groups != null) { + for (String group : groups) { + if (adminGroups.contains(group)) { + logger.debug("user {} is marked as admin, because of the admin group configuration for group {}", user.getName(), group); + return true; + } + } + } + return false; + } + + private boolean isUserAdminInConfiguration(User user) { + Set<String> adminUsers = configuration.getAdminUsers(); + return adminUsers != null && adminUsers.contains(user.getName()); + } + private String getGroupAutocompletePermission() { return GroupPermissions.autocomplete().asShiroString(); } @@ -373,6 +404,8 @@ public class DefaultAuthorizationCollector implements AuthorizationCollector //~--- fields --------------------------------------------------------------- + private final ScmConfiguration configuration; + /** authorization cache */ private final Cache<CacheKey, AuthorizationInfo> cache; diff --git a/scm-webapp/src/main/java/sonia/scm/security/DefaultJwtAccessTokenRefreshStrategy.java b/scm-webapp/src/main/java/sonia/scm/security/DefaultJwtAccessTokenRefreshStrategy.java new file mode 100644 index 0000000000..266a327d44 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/security/DefaultJwtAccessTokenRefreshStrategy.java @@ -0,0 +1,10 @@ +package sonia.scm.security; + +import sonia.scm.plugin.Extension; + +@Extension +public class DefaultJwtAccessTokenRefreshStrategy extends PercentageJwtAccessTokenRefreshStrategy { + public DefaultJwtAccessTokenRefreshStrategy() { + super(0.5F); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/security/DefaultRealm.java b/scm-webapp/src/main/java/sonia/scm/security/DefaultRealm.java index 0cc5db846c..bacbd9b314 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/DefaultRealm.java +++ b/scm-webapp/src/main/java/sonia/scm/security/DefaultRealm.java @@ -42,9 +42,11 @@ import org.apache.shiro.authc.UsernamePasswordToken; import org.apache.shiro.authc.credential.PasswordMatcher; import org.apache.shiro.authc.credential.PasswordService; import org.apache.shiro.authz.AuthorizationInfo; +import org.apache.shiro.authz.SimpleAuthorizationInfo; import org.apache.shiro.realm.AuthorizingRealm; import org.apache.shiro.subject.PrincipalCollection; +import org.apache.shiro.subject.SimplePrincipalCollection; import sonia.scm.group.GroupNames; import sonia.scm.plugin.Extension; @@ -56,6 +58,8 @@ import javax.inject.Singleton; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.Set; + /** * Default authorizing realm. * @@ -85,14 +89,13 @@ public class DefaultRealm extends AuthorizingRealm * * * @param service - * @param collector + * @param authorizationCollectors * @param helperFactory */ @Inject - public DefaultRealm(PasswordService service, - DefaultAuthorizationCollector collector, DAORealmHelperFactory helperFactory) + public DefaultRealm(PasswordService service, Set<AuthorizationCollector> authorizationCollectors, DAORealmHelperFactory helperFactory) { - this.collector = collector; + this.authorizationCollectors = authorizationCollectors; this.helper = helperFactory.create(REALM); PasswordMatcher matcher = new PasswordMatcher(); @@ -133,8 +136,7 @@ public class DefaultRealm extends AuthorizingRealm @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { - AuthorizationInfo info = collector.collect(principals); - + AuthorizationInfo info = collectors(principals); Scope scope = principals.oneByType(Scope.class); if (scope != null && ! scope.isEmpty()) { LOG.trace("filter permissions by scope {}", scope); @@ -144,13 +146,36 @@ public class DefaultRealm extends AuthorizingRealm } return filtered; } else if (LOG.isTraceEnabled()) { - LOG.trace("principal does not contain scope informations, returning all permissions"); + LOG.trace("principal does not contain scope information, returning all permissions"); log(principals, info, null); } return info; } - + + private AuthorizationInfo collectors(PrincipalCollection principals) { + SimpleAuthorizationInfo merged = new SimpleAuthorizationInfo(); + for (AuthorizationCollector collector : authorizationCollectors) { + AuthorizationInfo authorizationInfo = collector.collect(principals); + merge(merged, authorizationInfo); + } + return merged; + } + + private void merge(SimpleAuthorizationInfo merged, AuthorizationInfo authorizationInfo) { + if (authorizationInfo != null) { + if (authorizationInfo.getRoles() != null) { + merged.addRoles(authorizationInfo.getRoles()); + } + if (authorizationInfo.getObjectPermissions() != null) { + merged.addObjectPermissions(authorizationInfo.getObjectPermissions()); + } + if (authorizationInfo.getStringPermissions() != null) { + merged.addStringPermissions(authorizationInfo.getStringPermissions()); + } + } + } + private void log( PrincipalCollection collection, AuthorizationInfo original, AuthorizationInfo filtered ) { StringBuilder buffer = new StringBuilder("authorization summary: "); @@ -190,8 +215,8 @@ public class DefaultRealm extends AuthorizingRealm //~--- fields --------------------------------------------------------------- - /** default authorization collector */ - private final DefaultAuthorizationCollector collector; + /** set of authorization collector */ + private final Set<AuthorizationCollector> authorizationCollectors; /** realm helper */ private final DAORealmHelper helper; diff --git a/scm-webapp/src/main/java/sonia/scm/security/DefaultSecuritySystem.java b/scm-webapp/src/main/java/sonia/scm/security/DefaultSecuritySystem.java index d958dcf41f..0064a46228 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/DefaultSecuritySystem.java +++ b/scm-webapp/src/main/java/sonia/scm/security/DefaultSecuritySystem.java @@ -36,38 +36,22 @@ package sonia.scm.security; //~--- non-JDK imports -------------------------------------------------------- import com.github.legman.Subscribe; - +import com.google.common.base.Objects; import com.google.common.base.Preconditions; -import com.google.common.base.Predicate; import com.google.common.base.Strings; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableList.Builder; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.ImmutableSet.Builder; import com.google.inject.Inject; import com.google.inject.Singleton; - -import org.apache.shiro.SecurityUtils; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; - import sonia.scm.HandlerEventType; import sonia.scm.event.ScmEventBus; import sonia.scm.group.GroupEvent; +import sonia.scm.plugin.PluginLoader; import sonia.scm.store.ConfigurationEntryStore; import sonia.scm.store.ConfigurationEntryStoreFactory; import sonia.scm.user.UserEvent; -import sonia.scm.util.ClassLoaders; - -//~--- JDK imports ------------------------------------------------------------ - -import java.io.IOException; - -import java.net.URL; - -import java.util.Collections; -import java.util.Enumeration; -import java.util.List; -import java.util.Map.Entry; import javax.xml.bind.JAXBContext; import javax.xml.bind.JAXBException; @@ -75,6 +59,19 @@ import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; import javax.xml.bind.annotation.XmlElement; import javax.xml.bind.annotation.XmlRootElement; +import java.io.IOException; +import java.net.URL; +import java.util.Collection; +import java.util.Collections; +import java.util.Enumeration; +import java.util.List; +import java.util.Map.Entry; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import static java.util.Objects.isNull; + +//~--- JDK imports ------------------------------------------------------------ /** * TODO add events @@ -108,10 +105,14 @@ public class DefaultSecuritySystem implements SecuritySystem * @param storeFactory */ @Inject - public DefaultSecuritySystem(ConfigurationEntryStoreFactory storeFactory) + @SuppressWarnings("unchecked") + public DefaultSecuritySystem(ConfigurationEntryStoreFactory storeFactory, PluginLoader pluginLoader) { - store = storeFactory.getStore(AssignedPermission.class, NAME); - readAvailablePermissions(); + store = storeFactory + .withType(AssignedPermission.class) + .withName(NAME) + .build(); + this.availablePermissions = readAvailablePermissions(pluginLoader); } //~--- methods -------------------------------------------------------------- @@ -125,9 +126,9 @@ public class DefaultSecuritySystem implements SecuritySystem * @return */ @Override - public StoredAssignedPermission addPermission(AssignedPermission permission) + public void addPermission(AssignedPermission permission) { - assertIsAdmin(); + assertHasPermission(); validatePermission(permission); String id = store.put(permission); @@ -136,11 +137,9 @@ public class DefaultSecuritySystem implements SecuritySystem //J- ScmEventBus.getInstance().post( - new StoredAssignedPermissionEvent(HandlerEventType.CREATE, sap) + new AssignedPermissionEvent(HandlerEventType.CREATE, permission) ); //J+ - - return sap; } /** @@ -150,33 +149,16 @@ public class DefaultSecuritySystem implements SecuritySystem * @param permission */ @Override - public void deletePermission(StoredAssignedPermission permission) + public void deletePermission(AssignedPermission permission) { - assertIsAdmin(); - store.remove(permission.getId()); - //J- - ScmEventBus.getInstance().post( - new StoredAssignedPermissionEvent(HandlerEventType.CREATE, permission) - ); - //J+ - } - - /** - * Method description - * - * - * @param id - */ - @Override - public void deletePermission(String id) - { - assertIsAdmin(); - - AssignedPermission ap = store.get(id); - - if (ap != null) - { - deletePermission(new StoredAssignedPermission(id, ap)); + assertHasPermission(); + boolean deleted = deletePermissions(sap -> Objects.equal(sap.getName(), permission.getName()) + && Objects.equal(sap.isGroupPermission(), permission.isGroupPermission()) + && Objects.equal(sap.getPermission(), permission.getPermission())); + if (deleted) { + ScmEventBus.getInstance().post( + new AssignedPermissionEvent(HandlerEventType.DELETE, permission) + ); } } @@ -191,16 +173,8 @@ public class DefaultSecuritySystem implements SecuritySystem { if (event.getEventType() == HandlerEventType.DELETE) { - deletePermissions(new Predicate<AssignedPermission>() - { - - @Override - public boolean apply(AssignedPermission p) - { - return !p.isGroupPermission() - && event.getItem().getName().equals(p.getName()); - } - }); + deletePermissions(p -> !p.isGroupPermission() + && event.getItem().getName().equals(p.getName())); } } @@ -215,44 +189,11 @@ public class DefaultSecuritySystem implements SecuritySystem { if (event.getEventType() == HandlerEventType.DELETE) { - deletePermissions(new Predicate<AssignedPermission>() - { - - @Override - public boolean apply(AssignedPermission p) - { - return p.isGroupPermission() - && event.getItem().getName().equals(p.getName()); - } - }); + deletePermissions(p -> p.isGroupPermission() + && event.getItem().getName().equals(p.getName())); } } - /** - * Method description - * - * - * @param permission - */ - @Override - public void modifyPermission(StoredAssignedPermission permission) - { - assertIsAdmin(); - validatePermission(permission); - - synchronized (store) - { - store.remove(permission.getId()); - store.put(permission.getId(), new AssignedPermission(permission)); - } - - //J- - ScmEventBus.getInstance().post( - new StoredAssignedPermissionEvent(HandlerEventType.CREATE, permission) - ); - //J+ - } - //~--- get methods ---------------------------------------------------------- /** @@ -262,49 +203,13 @@ public class DefaultSecuritySystem implements SecuritySystem * @return */ @Override - public List<StoredAssignedPermission> getAllPermissions() + public Collection<PermissionDescriptor> getAvailablePermissions() { - return getPermissions(null); - } - - /** - * Method description - * - * - * @return - */ - @Override - public List<PermissionDescriptor> getAvailablePermissions() - { - assertIsAdmin(); + assertHasPermission(); return availablePermissions; } - /** - * Method description - * - * - * @param id - * - * @return - */ - @Override - public StoredAssignedPermission getPermission(String id) - { - assertIsAdmin(); - - StoredAssignedPermission sap = null; - AssignedPermission ap = store.get(id); - - if (ap != null) - { - sap = new StoredAssignedPermission(id, ap); - } - - return sap; - } - /** * Method description * @@ -314,14 +219,13 @@ public class DefaultSecuritySystem implements SecuritySystem * @return */ @Override - public List<StoredAssignedPermission> getPermissions( - Predicate<AssignedPermission> predicate) + public Collection<AssignedPermission> getPermissions(Predicate<AssignedPermission> predicate) { - Builder<StoredAssignedPermission> permissions = ImmutableList.builder(); + Builder<AssignedPermission> permissions = ImmutableSet.builder(); for (Entry<String, AssignedPermission> e : store.getAll().entrySet()) { - if ((predicate == null) || predicate.apply(e.getValue())) + if ((predicate == null) || predicate.test(e.getValue())) { permissions.add(new StoredAssignedPermission(e.getKey(), e.getValue())); } @@ -336,9 +240,9 @@ public class DefaultSecuritySystem implements SecuritySystem * Method description * */ - private void assertIsAdmin() + private void assertHasPermission() { - SecurityUtils.getSubject().checkRole(Role.ADMIN); + PermissionPermissions.assign().check(); } /** @@ -347,14 +251,15 @@ public class DefaultSecuritySystem implements SecuritySystem * * @param predicate */ - private void deletePermissions(Predicate<AssignedPermission> predicate) + private boolean deletePermissions(Predicate<AssignedPermission> predicate) { - List<StoredAssignedPermission> permissions = getPermissions(predicate); - - for (StoredAssignedPermission permission : permissions) - { - deletePermission(permission); - } + List<Entry<String, AssignedPermission>> toRemove = + store.getAll() + .entrySet() + .stream() + .filter(e -> (predicate == null) || predicate.test(e.getValue())).collect(Collectors.toList()); + toRemove.forEach(e -> store.remove(e.getKey())); + return !toRemove.isEmpty(); } /** @@ -367,7 +272,7 @@ public class DefaultSecuritySystem implements SecuritySystem * @return */ @SuppressWarnings("unchecked") - private List<PermissionDescriptor> parsePermissionDescriptor( + private static List<PermissionDescriptor> parsePermissionDescriptor( JAXBContext context, URL descriptorUrl) { List<PermissionDescriptor> descriptors = Collections.EMPTY_LIST; @@ -395,19 +300,20 @@ public class DefaultSecuritySystem implements SecuritySystem /** * Method description * + * @param pluginLoader */ - private void readAvailablePermissions() + private static ImmutableSet<PermissionDescriptor> readAvailablePermissions(PluginLoader pluginLoader) { - Builder<PermissionDescriptor> builder = ImmutableList.builder(); + ImmutableSet.Builder<PermissionDescriptor> builder = ImmutableSet.builder(); try { JAXBContext context = JAXBContext.newInstance(PermissionDescriptors.class); + // Querying permissions from uberClassLoader returns also the permissions from plugin Enumeration<URL> descirptorEnum = - ClassLoaders.getContextClassLoader( - DefaultSecuritySystem.class).getResources(PERMISSION_DESCRIPTOR); + pluginLoader.getUberClassLoader().getResources(PERMISSION_DESCRIPTOR); while (descirptorEnum.hasMoreElements()) { @@ -428,7 +334,7 @@ public class DefaultSecuritySystem implements SecuritySystem "could not create jaxb context to read permission descriptors", ex); } - availablePermissions = builder.build(); + return builder.build(); } /** @@ -441,7 +347,7 @@ public class DefaultSecuritySystem implements SecuritySystem { Preconditions.checkArgument(!Strings.isNullOrEmpty(perm.getName()), "name is required"); - Preconditions.checkArgument(!Strings.isNullOrEmpty(perm.getPermission()), + Preconditions.checkArgument(!isNull(perm.getPermission()), "permission is required"); } @@ -455,12 +361,6 @@ public class DefaultSecuritySystem implements SecuritySystem private static class PermissionDescriptors { - /** - * Constructs ... - * - */ - public PermissionDescriptors() {} - //~--- get methods -------------------------------------------------------- /** @@ -494,5 +394,5 @@ public class DefaultSecuritySystem implements SecuritySystem private final ConfigurationEntryStore<AssignedPermission> store; /** Field description */ - private List<PermissionDescriptor> availablePermissions; + private final ImmutableSet<PermissionDescriptor> availablePermissions; } diff --git a/scm-webapp/src/main/java/sonia/scm/security/JwtAccessToken.java b/scm-webapp/src/main/java/sonia/scm/security/JwtAccessToken.java index 46f4c68e74..5b895a34fa 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/JwtAccessToken.java +++ b/scm-webapp/src/main/java/sonia/scm/security/JwtAccessToken.java @@ -30,9 +30,16 @@ */ package sonia.scm.security; +import com.google.common.collect.ImmutableSet; import io.jsonwebtoken.Claims; + +import java.util.Collections; import java.util.Date; +import java.util.Map; import java.util.Optional; +import java.util.Set; + +import static java.util.Optional.ofNullable; /** * Jwt implementation of {@link AccessToken}. @@ -41,7 +48,11 @@ import java.util.Optional; * @since 2.0.0 */ public final class JwtAccessToken implements AccessToken { - + + public static final String REFRESHABLE_UNTIL_CLAIM_KEY = "scm-manager.refreshExpiration"; + public static final String PARENT_TOKEN_ID_CLAIM_KEY = "scm-manager.parentTokenId"; + public static final String GROUPS_CLAIM_KEY = "scm-manager.groups"; + private final Claims claims; private final String compact; @@ -75,6 +86,16 @@ public final class JwtAccessToken implements AccessToken { return claims.getExpiration(); } + @Override + public Optional<Date> getRefreshExpiration() { + return ofNullable(claims.get(REFRESHABLE_UNTIL_CLAIM_KEY, Date.class)); + } + + @Override + public Optional<String> getParentKey() { + return ofNullable(claims.get(PARENT_TOKEN_ID_CLAIM_KEY).toString()); + } + @Override public Scope getScope() { return Scopes.fromClaims(claims); @@ -86,9 +107,23 @@ public final class JwtAccessToken implements AccessToken { return Optional.ofNullable(claims.get(key)); } + @Override + @SuppressWarnings("unchecked") + public Set<String> getGroups() { + Iterable<String> groups = claims.get(GROUPS_CLAIM_KEY, Iterable.class); + if (groups != null) { + return ImmutableSet.copyOf(groups); + } + return ImmutableSet.of(); + } + @Override public String compact() { return compact; } - + + @Override + public Map<String, Object> getClaims() { + return Collections.unmodifiableMap(claims); + } } diff --git a/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenBuilder.java b/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenBuilder.java index ece96e2954..61c7631119 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenBuilder.java +++ b/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenBuilder.java @@ -36,8 +36,13 @@ import com.google.common.collect.Maps; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; + +import java.time.Clock; +import java.time.Instant; +import java.util.Collections; import java.util.Date; import java.util.HashMap; +import java.util.HashSet; import java.util.Map; import java.util.Set; import java.util.concurrent.TimeUnit; @@ -45,10 +50,11 @@ import org.apache.shiro.SecurityUtils; import org.apache.shiro.subject.Subject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import sonia.scm.group.GroupNames; /** * Jwt implementation of {@link AccessTokenBuilder}. - * + * * @author Sebastian Sdorra * @since 2.0.0 */ @@ -58,21 +64,28 @@ public final class JwtAccessTokenBuilder implements AccessTokenBuilder { * the logger for JwtAccessTokenBuilder */ private static final Logger LOG = LoggerFactory.getLogger(JwtAccessTokenBuilder.class); - - private final KeyGenerator keyGenerator; - private final SecureKeyResolver keyResolver; - + + private final KeyGenerator keyGenerator; + private final SecureKeyResolver keyResolver; + private final Clock clock; + private String subject; private String issuer; - private long expiresIn = 60l; - private TimeUnit expiresInUnit = TimeUnit.MINUTES; + private long expiresIn = 1; + private TimeUnit expiresInUnit = TimeUnit.HOURS; + private long refreshableFor = 12; + private TimeUnit refreshableForUnit = TimeUnit.HOURS; + private Instant refreshExpiration; + private String parentKeyId; private Scope scope = Scope.empty(); - + private Set<String> groups = new HashSet<>(); + private final Map<String,Object> custom = Maps.newHashMap(); - - JwtAccessTokenBuilder(KeyGenerator keyGenerator, SecureKeyResolver keyResolver) { + + JwtAccessTokenBuilder(KeyGenerator keyGenerator, SecureKeyResolver keyResolver, Clock clock) { this.keyGenerator = keyGenerator; this.keyResolver = keyResolver; + this.clock = clock; } @Override @@ -81,7 +94,7 @@ public final class JwtAccessTokenBuilder implements AccessTokenBuilder { this.subject = subject; return this; } - + @Override public JwtAccessTokenBuilder custom(String key, Object value) { Preconditions.checkArgument(!Strings.isNullOrEmpty(key), "null or empty value not allowed"); @@ -92,11 +105,11 @@ public final class JwtAccessTokenBuilder implements AccessTokenBuilder { @Override public JwtAccessTokenBuilder scope(Scope scope) { - Preconditions.checkArgument(scope != null, "scope can not be null"); + Preconditions.checkArgument(scope != null, "scope cannot be null"); this.scope = scope; return this; } - + @Override public JwtAccessTokenBuilder issuer(String issuer) { Preconditions.checkArgument(!Strings.isNullOrEmpty(issuer), "null or empty value not allowed"); @@ -106,15 +119,43 @@ public final class JwtAccessTokenBuilder implements AccessTokenBuilder { @Override public JwtAccessTokenBuilder expiresIn(long count, TimeUnit unit) { - Preconditions.checkArgument(count > 0, "expires in must be greater than 0"); - Preconditions.checkArgument(unit != null, "unit can not be null"); - + Preconditions.checkArgument(count > 0, "count must be greater than 0"); + Preconditions.checkArgument(unit != null, "unit cannot be null"); + this.expiresIn = count; this.expiresInUnit = unit; - + return this; } - + + @Override + public JwtAccessTokenBuilder refreshableFor(long count, TimeUnit unit) { + Preconditions.checkArgument(count >= 0, "count must be greater or equal to 0"); + Preconditions.checkArgument(unit != null, "unit cannot be null"); + + this.refreshableFor = count; + this.refreshableForUnit = unit; + + return this; + } + + @Override + public JwtAccessTokenBuilder groups(String... groups) { + Collections.addAll(this.groups, groups); + return this; + } + + JwtAccessTokenBuilder refreshExpiration(Instant refreshExpiration) { + this.refreshExpiration = refreshExpiration; + this.refreshableFor = 0; + return this; + } + + public JwtAccessTokenBuilder parentKey(String parentKeyId) { + this.parentKeyId = parentKeyId; + return this; + } + private String getSubject(){ if (subject == null) { Subject currentSubject = SecurityUtils.getSubject(); @@ -130,35 +171,58 @@ public final class JwtAccessTokenBuilder implements AccessTokenBuilder { String id = keyGenerator.createKey(); String sub = getSubject(); - + LOG.trace("create new token {} for user {}", id, subject); SecureKey key = keyResolver.getSecureKey(sub); - + Map<String,Object> customClaims = new HashMap<>(custom); - + // add scope to custom claims Scopes.toClaims(customClaims, scope); - - Date now = new Date(); + + Instant now = clock.instant(); long expiration = expiresInUnit.toMillis(expiresIn); - + Claims claims = Jwts.claims(customClaims) .setSubject(sub) .setId(id) - .setIssuedAt(now) - .setExpiration(new Date(now.getTime() + expiration)); - + .setIssuedAt(Date.from(now)) + .setExpiration(new Date(now.toEpochMilli() + expiration)); + + + if (refreshableFor > 0) { + long refreshExpiration = refreshableForUnit.toMillis(refreshableFor); + claims.put(JwtAccessToken.REFRESHABLE_UNTIL_CLAIM_KEY, new Date(now.toEpochMilli() + refreshExpiration).getTime()); + } else if (refreshExpiration != null) { + claims.put(JwtAccessToken.REFRESHABLE_UNTIL_CLAIM_KEY, Date.from(refreshExpiration)); + } + if (parentKeyId == null) { + claims.put(JwtAccessToken.PARENT_TOKEN_ID_CLAIM_KEY, id); + } else { + claims.put(JwtAccessToken.PARENT_TOKEN_ID_CLAIM_KEY, parentKeyId); + } + if ( issuer != null ) { claims.setIssuer(issuer); } - + + if (!groups.isEmpty()) { + claims.put(JwtAccessToken.GROUPS_CLAIM_KEY, groups); + } else { + Subject currentSubject = SecurityUtils.getSubject(); + GroupNames groupNames = currentSubject.getPrincipals().oneByType(GroupNames.class); + if (groupNames != null && groupNames.isExternal()) { + claims.put(JwtAccessToken.GROUPS_CLAIM_KEY, groupNames.getCollection().toArray(new String[]{})); + } + } + // sign token and create compact version String compact = Jwts.builder() .setClaims(claims) .signWith(SignatureAlgorithm.HS256, key.getBytes()) .compact(); - + return new JwtAccessToken(claims, compact); } - + } diff --git a/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenBuilderFactory.java b/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenBuilderFactory.java index 63c4ea981c..a5704b0e82 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenBuilderFactory.java +++ b/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenBuilderFactory.java @@ -30,6 +30,7 @@ */ package sonia.scm.security; +import java.time.Clock; import java.util.Set; import javax.inject.Inject; import sonia.scm.plugin.Extension; @@ -46,19 +47,25 @@ public final class JwtAccessTokenBuilderFactory implements AccessTokenBuilderFac private final KeyGenerator keyGenerator; private final SecureKeyResolver keyResolver; private final Set<AccessTokenEnricher> enrichers; + private final Clock clock; @Inject public JwtAccessTokenBuilderFactory( - KeyGenerator keyGenerator, SecureKeyResolver keyResolver, Set<AccessTokenEnricher> enrichers - ) { + KeyGenerator keyGenerator, SecureKeyResolver keyResolver, Set<AccessTokenEnricher> enrichers) { + this(keyGenerator, keyResolver, enrichers, Clock.systemDefaultZone()); + } + + JwtAccessTokenBuilderFactory( + KeyGenerator keyGenerator, SecureKeyResolver keyResolver, Set<AccessTokenEnricher> enrichers, Clock clock) { this.keyGenerator = keyGenerator; this.keyResolver = keyResolver; this.enrichers = enrichers; + this.clock = clock; } @Override public JwtAccessTokenBuilder create() { - JwtAccessTokenBuilder builder = new JwtAccessTokenBuilder(keyGenerator, keyResolver); + JwtAccessTokenBuilder builder = new JwtAccessTokenBuilder(keyGenerator, keyResolver, clock); // enrich access token builder enrichers.forEach((enricher) -> { diff --git a/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenRefreshStrategy.java b/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenRefreshStrategy.java new file mode 100644 index 0000000000..9135a0e099 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenRefreshStrategy.java @@ -0,0 +1,8 @@ +package sonia.scm.security; + +import sonia.scm.plugin.ExtensionPoint; + +@ExtensionPoint(multi = false) +public interface JwtAccessTokenRefreshStrategy { + boolean shouldBeRefreshed(JwtAccessToken oldToken); +} diff --git a/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenRefresher.java b/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenRefresher.java new file mode 100644 index 0000000000..6db01c904f --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenRefresher.java @@ -0,0 +1,77 @@ +package sonia.scm.security; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.inject.Inject; +import java.time.Clock; +import java.util.Date; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +public class JwtAccessTokenRefresher { + + private static final Logger log = LoggerFactory.getLogger(JwtAccessTokenRefresher.class); + + private final JwtAccessTokenBuilderFactory builderFactory; + private final JwtAccessTokenRefreshStrategy refreshStrategy; + private final Clock clock; + + @Inject + public JwtAccessTokenRefresher(JwtAccessTokenBuilderFactory builderFactory, JwtAccessTokenRefreshStrategy refreshStrategy) { + this(builderFactory, refreshStrategy, Clock.systemDefaultZone()); + } + + JwtAccessTokenRefresher(JwtAccessTokenBuilderFactory builderFactory, JwtAccessTokenRefreshStrategy refreshStrategy, Clock clock) { + this.builderFactory = builderFactory; + this.refreshStrategy = refreshStrategy; + this.clock = clock; + } + + @SuppressWarnings("squid:S3655") // the refresh expiration cannot be null at the time building the new token, because + // we checked this before in tokenCanBeRefreshed + public Optional<JwtAccessToken> refresh(JwtAccessToken oldToken) { + JwtAccessTokenBuilder builder = builderFactory.create(); + Map<String, Object> claims = oldToken.getClaims(); + claims.forEach(builder::custom); + + if (canBeRefreshed(oldToken) && shouldBeRefreshed(oldToken)) { + Optional<Object> parentTokenId = oldToken.getCustom("scm-manager.parentTokenId"); + if (!parentTokenId.isPresent()) { + log.warn("no parent token id found in token; could not refresh"); + return Optional.empty(); + } + builder.expiresIn(computeOldExpirationInMillis(oldToken), TimeUnit.MILLISECONDS); + builder.parentKey(parentTokenId.get().toString()); + builder.refreshExpiration(oldToken.getRefreshExpiration().get().toInstant()); + return Optional.of(builder.build()); + } else { + return Optional.empty(); + } + } + + private long computeOldExpirationInMillis(JwtAccessToken oldToken) { + return oldToken.getExpiration().getTime() - oldToken.getIssuedAt().getTime(); + } + + private boolean canBeRefreshed(JwtAccessToken oldToken) { + return tokenIsValid(oldToken) && tokenCanBeRefreshed(oldToken); + } + + private boolean shouldBeRefreshed(JwtAccessToken oldToken) { + return refreshStrategy.shouldBeRefreshed(oldToken); + } + + private boolean tokenCanBeRefreshed(JwtAccessToken oldToken) { + return oldToken.getRefreshExpiration().map(this::isAfterNow).orElse(false); + } + + private boolean tokenIsValid(JwtAccessToken oldToken) { + return isAfterNow(oldToken.getExpiration()); + } + + private boolean isAfterNow(Date expiration) { + return expiration.toInstant().isAfter(clock.instant()); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenResolver.java b/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenResolver.java index 72b2a85c95..291364b935 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenResolver.java +++ b/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenResolver.java @@ -55,37 +55,48 @@ public final class JwtAccessTokenResolver implements AccessTokenResolver { private static final Logger LOG = LoggerFactory.getLogger(JwtAccessTokenResolver.class); private final SecureKeyResolver keyResolver; - private final Set<TokenClaimsValidator> validators; + private final Set<AccessTokenValidator> validators; @Inject - public JwtAccessTokenResolver(SecureKeyResolver keyResolver, Set<TokenClaimsValidator> validators) { + public JwtAccessTokenResolver(SecureKeyResolver keyResolver, Set<AccessTokenValidator> validators) { this.keyResolver = keyResolver; this.validators = validators; } @Override public JwtAccessToken resolve(BearerToken bearerToken) { - Claims claims; - try { - // parse and validate - claims = Jwts.parser() + String compact = bearerToken.getCredentials(); + + Claims claims = Jwts.parser() .setSigningKeyResolver(keyResolver) - .parseClaimsJws(bearerToken.getCredentials()) + .parseClaimsJws(compact) .getBody(); - - // check all registered claims validators - validators.forEach((validator) -> { - if (!validator.validate(claims)) { - LOG.warn("token claims is invalid, marked by validator {}", validator.getClass()); - throw new AuthenticationException("token claims is invalid"); - } - }); + + JwtAccessToken token = new JwtAccessToken(claims, compact); + validate(token); + + return token; } catch (JwtException ex) { throw new AuthenticationException("signature is invalid", ex); } - - return new JwtAccessToken(claims, bearerToken.getCredentials()); + } + + + private void validate(AccessToken accessToken) { + validators.forEach(validator -> validate(validator, accessToken)); + } + + private void validate(AccessTokenValidator validator, AccessToken accessToken) { + if (!validator.validate(accessToken)) { + String msg = createValidationFailedMessage(validator, accessToken); + LOG.debug(msg); + throw new AuthenticationException(msg); + } + } + + private String createValidationFailedMessage(AccessTokenValidator validator, AccessToken accessToken) { + return String.format("token %s is invalid, marked by validator %s", accessToken.getId(), validator.getClass()); } } diff --git a/scm-webapp/src/main/java/sonia/scm/security/PercentageJwtAccessTokenRefreshStrategy.java b/scm-webapp/src/main/java/sonia/scm/security/PercentageJwtAccessTokenRefreshStrategy.java new file mode 100644 index 0000000000..c78654c389 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/security/PercentageJwtAccessTokenRefreshStrategy.java @@ -0,0 +1,25 @@ +package sonia.scm.security; + +import java.time.Clock; + +public class PercentageJwtAccessTokenRefreshStrategy implements JwtAccessTokenRefreshStrategy { + + private final Clock clock; + private final float refreshPercentage; + + public PercentageJwtAccessTokenRefreshStrategy(float refreshPercentage) { + this(Clock.systemDefaultZone(), refreshPercentage); + } + + PercentageJwtAccessTokenRefreshStrategy(Clock clock, float refreshPercentage) { + this.clock = clock; + this.refreshPercentage = refreshPercentage; + } + + @Override + public boolean shouldBeRefreshed(JwtAccessToken oldToken) { + long liveSpan = oldToken.getExpiration().getTime() - oldToken.getIssuedAt().getTime(); + long age = clock.instant().toEpochMilli() - oldToken.getIssuedAt().getTime(); + return (float)age/liveSpan > refreshPercentage; + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/security/PermissionAssigner.java b/scm-webapp/src/main/java/sonia/scm/security/PermissionAssigner.java new file mode 100644 index 0000000000..faa25d7817 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/security/PermissionAssigner.java @@ -0,0 +1,85 @@ +package sonia.scm.security; + +import sonia.scm.ContextEntry; +import sonia.scm.NotFoundException; + +import javax.inject.Inject; +import java.util.Collection; +import java.util.List; +import java.util.Set; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +public class PermissionAssigner { + + private final SecuritySystem securitySystem; + + @Inject + public PermissionAssigner(SecuritySystem securitySystem) { + this.securitySystem = securitySystem; + } + + public Collection<PermissionDescriptor> getAvailablePermissions() { + PermissionPermissions.read().check(); + return securitySystem.getAvailablePermissions(); + } + + public Collection<PermissionDescriptor> readPermissionsForUser(String id) { + return readPermissions(filterForUser(id)); + } + + public Collection<PermissionDescriptor> readPermissionsForGroup(String id) { + return readPermissions(filterForGroup(id)); + } + + private Predicate<AssignedPermission> filterForUser(String id) { + return p -> !p.isGroupPermission() && p.getName().equals(id); + } + + private Predicate<AssignedPermission> filterForGroup(String id) { + return p -> p.isGroupPermission() && p.getName().equals(id); + } + + private Set<PermissionDescriptor> readPermissions(Predicate<AssignedPermission> predicate) { + PermissionPermissions.read().check(); + return securitySystem.getPermissions(predicate) + .stream() + .map(AssignedPermission::getPermission) + .collect(Collectors.toSet()); + } + + public void setPermissionsForUser(String id, Collection<PermissionDescriptor> permissions) { + Collection<AssignedPermission> existingPermissions = securitySystem.getPermissions(filterForUser(id)); + adaptPermissions(id, false, permissions, existingPermissions); + } + + public void setPermissionsForGroup(String id, Collection<PermissionDescriptor> permissions) { + Collection<AssignedPermission> existingPermissions = securitySystem.getPermissions(filterForGroup(id)); + adaptPermissions(id, true, permissions, existingPermissions); + } + + private void adaptPermissions(String id, boolean groupPermission, Collection<PermissionDescriptor> permissions, Collection<AssignedPermission> existingPermissions) { + PermissionPermissions.assign().check(); + List<AssignedPermission> toRemove = existingPermissions.stream() + .filter(p -> !permissions.contains(p.getPermission())) + .collect(Collectors.toList()); + toRemove.forEach(securitySystem::deletePermission); + + Collection<PermissionDescriptor> availablePermissions = this.getAvailablePermissions(); + + permissions.stream() + .filter(permissionExists(availablePermissions, existingPermissions)) + .map(p -> new AssignedPermission(id, groupPermission, p)) + .filter(p -> !existingPermissions.contains(p)) + .forEach(securitySystem::addPermission); + } + + private Predicate<PermissionDescriptor> permissionExists(Collection<PermissionDescriptor> availablePermissions, Collection<AssignedPermission> existingPermissions) { + return p -> { + if (!availablePermissions.contains(p) && existingPermissions.stream().map(AssignedPermission::getPermission).noneMatch(e -> e.equals(p))) { + throw NotFoundException.notFound(ContextEntry.ContextBuilder.entity("permission", p.getValue())); + } + return true; + }; + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/security/RepositoryPermissionProvider.java b/scm-webapp/src/main/java/sonia/scm/security/RepositoryPermissionProvider.java new file mode 100644 index 0000000000..070990c6d6 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/security/RepositoryPermissionProvider.java @@ -0,0 +1,147 @@ +package sonia.scm.security; + +import com.google.inject.Inject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.plugin.PluginLoader; + +import javax.xml.bind.JAXBContext; +import javax.xml.bind.JAXBException; +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlElement; +import javax.xml.bind.annotation.XmlRootElement; +import java.io.IOException; +import java.net.URL; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Enumeration; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import static java.util.Collections.unmodifiableCollection; + +public class RepositoryPermissionProvider { + + private static final Logger logger = LoggerFactory.getLogger(RepositoryPermissionProvider.class); + private static final String REPOSITORY_PERMISSION_DESCRIPTOR = "META-INF/scm/repository-permissions.xml"; + private final Collection<String> availableVerbs; + private final Collection<RepositoryRole> availableRoles; + + @Inject + public RepositoryPermissionProvider(PluginLoader pluginLoader) { + AvailableRepositoryPermissions availablePermissions = readAvailablePermissions(pluginLoader); + this.availableVerbs = unmodifiableCollection(new LinkedHashSet<>(availablePermissions.availableVerbs)); + this.availableRoles = unmodifiableCollection(new LinkedHashSet<>(availablePermissions.availableRoles.stream().map(r -> new RepositoryRole(r.name, r.verbs.verbs)).collect(Collectors.toList()))); + } + + public Collection<String> availableVerbs() { + return availableVerbs; + } + + public Collection<RepositoryRole> availableRoles() { + return availableRoles; + } + + private static AvailableRepositoryPermissions readAvailablePermissions(PluginLoader pluginLoader) { + Collection<String> availableVerbs = new ArrayList<>(); + Collection<RoleDescriptor> availableRoles = new ArrayList<>(); + + try { + JAXBContext context = + JAXBContext.newInstance(RepositoryPermissionsRoot.class); + + // Querying permissions from uberClassLoader returns also the permissions from plugin + Enumeration<URL> descriptorEnum = + pluginLoader.getUberClassLoader().getResources(REPOSITORY_PERMISSION_DESCRIPTOR); + + while (descriptorEnum.hasMoreElements()) { + URL descriptorUrl = descriptorEnum.nextElement(); + + logger.debug("read repository permission descriptor from {}", descriptorUrl); + + RepositoryPermissionsRoot repositoryPermissionsRoot = parsePermissionDescriptor(context, descriptorUrl); + availableVerbs.addAll(repositoryPermissionsRoot.verbs.verbs); + mergeRolesInto(availableRoles, repositoryPermissionsRoot.roles.roles); + } + } catch (IOException ex) { + logger.error("could not read permission descriptors", ex); + } catch (JAXBException ex) { + logger.error( + "could not create jaxb context to read permission descriptors", ex); + } + + return new AvailableRepositoryPermissions(availableVerbs, availableRoles); + } + + private static void mergeRolesInto(Collection<RoleDescriptor> targetRoles, List<RoleDescriptor> additionalRoles) { + additionalRoles.forEach(r -> addOrMergeInto(targetRoles, r)); + } + + private static void addOrMergeInto(Collection<RoleDescriptor> targetRoles, RoleDescriptor additionalRole) { + Optional<RoleDescriptor> existingRole = targetRoles + .stream() + .filter(r -> r.name.equals(additionalRole.name)) + .findFirst(); + if (existingRole.isPresent()) { + existingRole.get().verbs.verbs.addAll(additionalRole.verbs.verbs); + } else { + targetRoles.add(additionalRole); + } + } + + private static RepositoryPermissionsRoot parsePermissionDescriptor(JAXBContext context, URL descriptorUrl) { + try { + RepositoryPermissionsRoot descriptorWrapper = + (RepositoryPermissionsRoot) context.createUnmarshaller().unmarshal( + descriptorUrl); + logger.trace("repository permissions from {}: {}", descriptorUrl, descriptorWrapper.verbs.verbs); + logger.trace("repository roles from {}: {}", descriptorUrl, descriptorWrapper.roles.roles); + return descriptorWrapper; + } catch (JAXBException ex) { + logger.error("could not parse permission descriptor", ex); + return new RepositoryPermissionsRoot(); + } + } + + private static class AvailableRepositoryPermissions { + private final Collection<String> availableVerbs; + private final Collection<RoleDescriptor> availableRoles; + + private AvailableRepositoryPermissions(Collection<String> availableVerbs, Collection<RoleDescriptor> availableRoles) { + this.availableVerbs = unmodifiableCollection(availableVerbs); + this.availableRoles = unmodifiableCollection(availableRoles); + } + } + + @XmlRootElement(name = "repository-permissions") + @XmlAccessorType(XmlAccessType.FIELD) + private static class RepositoryPermissionsRoot { + private VerbListDescriptor verbs = new VerbListDescriptor(); + private RoleListDescriptor roles = new RoleListDescriptor(); + } + + @XmlRootElement(name = "verbs") + private static class VerbListDescriptor { + @XmlElement(name = "verb") + private Set<String> verbs = new LinkedHashSet<>(); + } + + @XmlRootElement(name = "roles") + private static class RoleListDescriptor { + @XmlElement(name = "role") + private List<RoleDescriptor> roles = new ArrayList<>(); + } + + @XmlRootElement(name = "role") + @XmlAccessorType(XmlAccessType.FIELD) + public static class RoleDescriptor { + @XmlElement(name = "name") + private String name; + @XmlElement(name = "verbs") + private VerbListDescriptor verbs = new VerbListDescriptor(); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/security/RepositoryRole.java b/scm-webapp/src/main/java/sonia/scm/security/RepositoryRole.java new file mode 100644 index 0000000000..1fab500d79 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/security/RepositoryRole.java @@ -0,0 +1,43 @@ +package sonia.scm.security; + +import java.util.Collection; +import java.util.Collections; +import java.util.Objects; + +public class RepositoryRole { + + private final String name; + private final Collection<String> verbs; + + public RepositoryRole(String name, Collection<String> verbs) { + this.name = name; + this.verbs = verbs; + } + + public String getName() { + return name; + } + + public Collection<String> getVerbs() { + return Collections.unmodifiableCollection(verbs); + } + + public String toString() { + return "Role " + name + " (" + String.join(", ", verbs) + ")"; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof RepositoryRole)) return false; + RepositoryRole that = (RepositoryRole) o; + return name.equals(that.name) + && this.verbs.containsAll(that.verbs) + && this.verbs.size() == that.verbs.size(); + } + + @Override + public int hashCode() { + return Objects.hash(name, verbs.size()); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/security/Scopes.java b/scm-webapp/src/main/java/sonia/scm/security/Scopes.java index d5d6b74021..4286bafe90 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/Scopes.java +++ b/scm-webapp/src/main/java/sonia/scm/security/Scopes.java @@ -47,7 +47,7 @@ import org.apache.shiro.authz.SimpleAuthorizationInfo; import org.apache.shiro.authz.permission.PermissionResolver; /** - * Utile methods for {@link Scope}. + * Util methods for {@link Scope}. * * @author Sebastian Sdorra * @since 2.0.0 diff --git a/scm-webapp/src/main/java/sonia/scm/security/SecureKeyResolver.java b/scm-webapp/src/main/java/sonia/scm/security/SecureKeyResolver.java index c7c594d5e3..f1f29bfe51 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/SecureKeyResolver.java +++ b/scm-webapp/src/main/java/sonia/scm/security/SecureKeyResolver.java @@ -51,6 +51,7 @@ import static com.google.common.base.Preconditions.*; //~--- JDK imports ------------------------------------------------------------ import java.security.SecureRandom; +import java.util.Random; import javax.inject.Inject; import javax.inject.Singleton; @@ -87,9 +88,18 @@ public class SecureKeyResolver extends SigningKeyResolverAdapter * @param storeFactory store factory */ @Inject - public SecureKeyResolver(ConfigurationEntryStoreFactory storeFactory) + @SuppressWarnings("unchecked") + public SecureKeyResolver(ConfigurationEntryStoreFactory storeFactory) { + this(storeFactory, new SecureRandom()); + } + + SecureKeyResolver(ConfigurationEntryStoreFactory storeFactory, Random random) { - this.store = storeFactory.getStore(SecureKey.class, STORE_NAME); + store = storeFactory + .withType(SecureKey.class) + .withName(STORE_NAME) + .build(); + this.random = random; } //~--- methods -------------------------------------------------------------- @@ -108,7 +118,9 @@ public class SecureKeyResolver extends SigningKeyResolverAdapter SecureKey key = store.get(subject); - checkState(key != null, "could not resolve key for subject %s", subject); + if (key == null) { + return getSecureKey(subject).getBytes(); + } return key.getBytes(); } @@ -157,7 +169,7 @@ public class SecureKeyResolver extends SigningKeyResolverAdapter //~--- fields --------------------------------------------------------------- /** secure randon */ - private final SecureRandom random = new SecureRandom(); + private final Random random; /** configuration entry store */ private final ConfigurationEntryStore<SecureKey> store; diff --git a/scm-webapp/src/main/java/sonia/scm/security/XsrfAccessTokenEnricher.java b/scm-webapp/src/main/java/sonia/scm/security/XsrfAccessTokenEnricher.java index 7690c48b30..617950ddea 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/XsrfAccessTokenEnricher.java +++ b/scm-webapp/src/main/java/sonia/scm/security/XsrfAccessTokenEnricher.java @@ -44,7 +44,7 @@ import sonia.scm.util.HttpUtil; /** * Xsrf access token enricher will add an xsrf custom field to the access token. The enricher will only * add the xsrf field, if the authentication request is issued from the web interface and xsrf protection is - * enabled. The xsrf field will be validated on every request by the {@link XsrfTokenClaimsValidator}. Xsrf protection + * enabled. The xsrf field will be validated on every request by the {@link XsrfAccessTokenValidator}. Xsrf protection * can be disabled with {@link ScmConfiguration#setEnabledXsrfProtection(boolean)}. * * @see <a href="https://goo.gl/s67xO3">Issue 793</a> diff --git a/scm-webapp/src/main/java/sonia/scm/security/XsrfTokenClaimsValidator.java b/scm-webapp/src/main/java/sonia/scm/security/XsrfAccessTokenValidator.java similarity index 71% rename from scm-webapp/src/main/java/sonia/scm/security/XsrfTokenClaimsValidator.java rename to scm-webapp/src/main/java/sonia/scm/security/XsrfAccessTokenValidator.java index c71be5706e..437174f57e 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/XsrfTokenClaimsValidator.java +++ b/scm-webapp/src/main/java/sonia/scm/security/XsrfAccessTokenValidator.java @@ -30,30 +30,23 @@ */ package sonia.scm.security; -import com.google.common.base.Strings; -import java.util.Map; +import sonia.scm.plugin.Extension; + import javax.inject.Inject; import javax.inject.Provider; import javax.servlet.http.HttpServletRequest; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import sonia.scm.plugin.Extension; +import java.util.Optional; /** - * Validates xsrf protected token claims. The validator check if the current request contains an xsrf key which is - * equal to the token in the claims. If the claims does not contain a xsrf key, the check is passed by. The xsrf keys - * are added by the {@link XsrfTokenClaimsEnricher}. + * Validates xsrf protected access tokens. The validator check if the current request contains an xsrf key which is + * equal to the one in the access token. If the token does not contain a xsrf key, the check is passed by. The xsrf keys + * are added by the {@link XsrfAccessTokenEnricher}. * * @author Sebastian Sdorra * @since 2.0.0 */ @Extension -public class XsrfTokenClaimsValidator implements TokenClaimsValidator { - - /** - * the logger for XsrfTokenClaimsEnricher - */ - private static final Logger LOG = LoggerFactory.getLogger(XsrfTokenClaimsValidator.class); +public class XsrfAccessTokenValidator implements AccessTokenValidator { private final Provider<HttpServletRequest> requestProvider; @@ -64,16 +57,16 @@ public class XsrfTokenClaimsValidator implements TokenClaimsValidator { * @param requestProvider http request provider */ @Inject - public XsrfTokenClaimsValidator(Provider<HttpServletRequest> requestProvider) { + public XsrfAccessTokenValidator(Provider<HttpServletRequest> requestProvider) { this.requestProvider = requestProvider; } @Override - public boolean validate(Map<String, Object> claims) { - String xsrfClaimValue = (String) claims.get(Xsrf.TOKEN_KEY); - if (!Strings.isNullOrEmpty(xsrfClaimValue)) { + public boolean validate(AccessToken accessToken) { + Optional<String> xsrfClaim = accessToken.getCustom(Xsrf.TOKEN_KEY); + if (xsrfClaim.isPresent()) { String xsrfHeaderValue = requestProvider.get().getHeader(Xsrf.HEADER_KEY); - return xsrfClaimValue.equals(xsrfHeaderValue); + return xsrfClaim.get().equals(xsrfHeaderValue); } return true; } diff --git a/scm-webapp/src/main/java/sonia/scm/web/cgi/DefaultCGIExecutor.java b/scm-webapp/src/main/java/sonia/scm/web/cgi/DefaultCGIExecutor.java index 3eaa684080..b442042480 100644 --- a/scm-webapp/src/main/java/sonia/scm/web/cgi/DefaultCGIExecutor.java +++ b/scm-webapp/src/main/java/sonia/scm/web/cgi/DefaultCGIExecutor.java @@ -35,6 +35,7 @@ package sonia.scm.web.cgi; //~--- non-JDK imports -------------------------------------------------------- +import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Strings; import com.google.common.io.ByteStreams; @@ -139,12 +140,6 @@ public class DefaultCGIExecutor extends AbstractCGIExecutor apendOsEnvironment(env); } - // workaround for mercurial 2.1 - if (isContentLengthWorkaround()) - { - env.set(ENV_CONTENT_LENGTH, Integer.toString(request.getContentLength())); - } - if (workDirectory == null) { workDirectory = command.getParentFile(); @@ -304,26 +299,10 @@ public class DefaultCGIExecutor extends AbstractCGIExecutor String uri = HttpUtil.removeMatrixParameter(request.getRequestURI()); String scriptName = uri.substring(0, uri.length() - pathInfo.length()); String scriptPath = context.getRealPath(scriptName); - int len = request.getContentLength(); EnvList env = new EnvList(); env.set(ENV_AUTH_TYPE, request.getAuthType()); - - /** - * Note CGI spec says CONTENT_LENGTH must be NULL ("") or undefined - * if there is no content, so we cannot put 0 or -1 in as per the - * Servlet API spec. - * - * see org.apache.catalina.servlets.CGIServlet - */ - if (len <= 0) - { - env.set(ENV_CONTENT_LENGTH, ""); - } - else - { - env.set(ENV_CONTENT_LENGTH, Integer.toString(len)); - } + env.set(ENV_CONTENT_LENGTH, createCGIContentLength(request, contentLengthWorkaround)); /** * Decode PATH_INFO @@ -383,6 +362,39 @@ public class DefaultCGIExecutor extends AbstractCGIExecutor return env; } + /** + * Returns the content length as string in the cgi specific format. + * + * CGI spec says CONTENT_LENGTH must be NULL ("") or undefined + * if there is no content, so we cannot put 0 or -1 in as per the + * Servlet API spec. Some CGI applications require a content + * length environment variable, which is not null or empty + * (e.g. mercurial). For those application the disallowEmptyResults + * parameter should be used. + * + * @param disallowEmptyResults {@code true} to return -1 instead of an empty string + * + * @return content length as cgi specific string + */ + @VisibleForTesting + static String createCGIContentLength(HttpServletRequest request, boolean disallowEmptyResults) { + String cgiContentLength = disallowEmptyResults ? "-1" : ""; + + String contentLength = request.getHeader("Content-Length"); + if (!Strings.isNullOrEmpty(contentLength)) { + try { + long len = Long.parseLong(contentLength); + if (len > 0) { + cgiContentLength = String.valueOf(len); + } + } catch (NumberFormatException ex) { + logger.warn("received request with invalid content-length header value: {}", contentLength); + } + } + + return cgiContentLength; + } + /** * Method description * diff --git a/scm-webapp/src/main/java/sonia/scm/web/filter/HttpProtocolServletAuthenticationFilter.java b/scm-webapp/src/main/java/sonia/scm/web/filter/HttpProtocolServletAuthenticationFilter.java new file mode 100644 index 0000000000..e58945a346 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/web/filter/HttpProtocolServletAuthenticationFilter.java @@ -0,0 +1,47 @@ +package sonia.scm.web.filter; + +import sonia.scm.Priority; +import sonia.scm.PushStateDispatcher; +import sonia.scm.config.ScmConfiguration; +import sonia.scm.filter.Filters; +import sonia.scm.filter.WebElement; +import sonia.scm.util.HttpUtil; +import sonia.scm.web.UserAgent; +import sonia.scm.web.UserAgentParser; +import sonia.scm.web.WebTokenGenerator; +import sonia.scm.web.protocol.HttpProtocolServlet; + +import javax.inject.Inject; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Set; + +@Priority(Filters.PRIORITY_AUTHENTICATION) +@WebElement(value = HttpProtocolServlet.PATTERN) +public class HttpProtocolServletAuthenticationFilter extends AuthenticationFilter { + + private final PushStateDispatcher dispatcher; + private final UserAgentParser userAgentParser; + + @Inject + public HttpProtocolServletAuthenticationFilter( + ScmConfiguration configuration, + Set<WebTokenGenerator> tokenGenerators, + PushStateDispatcher dispatcher, + UserAgentParser userAgentParser) { + super(configuration, tokenGenerators); + this.dispatcher = dispatcher; + this.userAgentParser = userAgentParser; + } + + @Override + protected void sendUnauthorizedError(HttpServletRequest request, HttpServletResponse response) throws IOException { + UserAgent userAgent = userAgentParser.parse(request); + if (userAgent.isBrowser()) { + dispatcher.dispatch(request, response, request.getRequestURI()); + } else { + HttpUtil.sendUnauthorized(request, response); + } + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/web/i18n/I18nServlet.java b/scm-webapp/src/main/java/sonia/scm/web/i18n/I18nServlet.java index b074781fec..b98ecd10ba 100644 --- a/scm-webapp/src/main/java/sonia/scm/web/i18n/I18nServlet.java +++ b/scm-webapp/src/main/java/sonia/scm/web/i18n/I18nServlet.java @@ -78,8 +78,10 @@ public class I18nServlet extends HttpServlet { @VisibleForTesting @Override protected void doGet(HttpServletRequest req, HttpServletResponse response) { + response.setCharacterEncoding("UTF-8"); + response.setContentType("application/json"); + response.setHeader("Cache-Control", "no-cache"); try (PrintWriter out = response.getWriter()) { - response.setContentType("application/json"); String path = req.getServletPath(); Function<String, Optional<JsonNode>> jsonFileProvider = usedPath -> Optional.empty(); BiConsumer<String, JsonNode> createdJsonFileConsumer = (usedPath, jsonNode) -> log.debug("A json File is created from the path {}", usedPath); diff --git a/scm-webapp/src/main/java/sonia/scm/web/protocol/HttpProtocolServlet.java b/scm-webapp/src/main/java/sonia/scm/web/protocol/HttpProtocolServlet.java index c6a96e4b9e..250846a8cc 100644 --- a/scm-webapp/src/main/java/sonia/scm/web/protocol/HttpProtocolServlet.java +++ b/scm-webapp/src/main/java/sonia/scm/web/protocol/HttpProtocolServlet.java @@ -4,6 +4,7 @@ import com.google.inject.Inject; import com.google.inject.Singleton; import lombok.extern.slf4j.Slf4j; import org.apache.http.HttpStatus; +import org.apache.shiro.authz.AuthorizationException; import sonia.scm.NotFoundException; import sonia.scm.PushStateDispatcher; import sonia.scm.filter.WebElement; @@ -74,6 +75,9 @@ public class HttpProtocolServlet extends HttpServlet { } catch (NotFoundException e) { log.debug(e.getMessage()); resp.setStatus(HttpStatus.SC_NOT_FOUND); + } catch (AuthorizationException e) { + log.debug(e.getMessage()); + resp.setStatus(HttpStatus.SC_FORBIDDEN); } } } diff --git a/scm-webapp/src/main/java/sonia/scm/web/security/TokenRefreshFilter.java b/scm-webapp/src/main/java/sonia/scm/web/security/TokenRefreshFilter.java new file mode 100644 index 0000000000..6747d40228 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/web/security/TokenRefreshFilter.java @@ -0,0 +1,85 @@ +package sonia.scm.web.security; + +import org.apache.shiro.authc.AuthenticationException; +import org.apache.shiro.authc.AuthenticationToken; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.Priority; +import sonia.scm.filter.Filters; +import sonia.scm.filter.WebElement; +import sonia.scm.security.AccessToken; +import sonia.scm.security.AccessTokenCookieIssuer; +import sonia.scm.security.AccessTokenResolver; +import sonia.scm.security.BearerToken; +import sonia.scm.security.JwtAccessToken; +import sonia.scm.security.JwtAccessTokenRefresher; +import sonia.scm.web.WebTokenGenerator; +import sonia.scm.web.filter.HttpFilter; + +import javax.inject.Inject; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Optional; +import java.util.Set; + +import static java.util.Optional.empty; +import static java.util.Optional.of; + +@Priority(Filters.PRIORITY_POST_AUTHENTICATION) +@WebElement(value = Filters.PATTERN_RESTAPI, + morePatterns = { Filters.PATTERN_DEBUG }) +public class TokenRefreshFilter extends HttpFilter { + + private static final Logger LOG = LoggerFactory.getLogger(TokenRefreshFilter.class); + + private final Set<WebTokenGenerator> tokenGenerators; + private final JwtAccessTokenRefresher refresher; + private final AccessTokenResolver resolver; + private final AccessTokenCookieIssuer issuer; + + @Inject + public TokenRefreshFilter(Set<WebTokenGenerator> tokenGenerators, JwtAccessTokenRefresher refresher, AccessTokenResolver resolver, AccessTokenCookieIssuer issuer) { + this.tokenGenerators = tokenGenerators; + this.refresher = refresher; + this.resolver = resolver; + this.issuer = issuer; + } + + @Override + protected void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { + extractToken(request).ifPresent(token -> examineToken(request, response, token)); + chain.doFilter(request, response); + } + + private Optional<BearerToken> extractToken(HttpServletRequest request) { + for (WebTokenGenerator generator : tokenGenerators) { + AuthenticationToken token = generator.createToken(request); + if (token instanceof BearerToken) { + return of((BearerToken) token); + } + } + return empty(); + } + + private void examineToken(HttpServletRequest request, HttpServletResponse response, BearerToken token) { + AccessToken accessToken; + try { + accessToken = resolver.resolve(token); + } catch (AuthenticationException e) { + LOG.trace("could not resolve token", e); + return; + } + if (accessToken instanceof JwtAccessToken) { + refresher.refresh((JwtAccessToken) accessToken) + .ifPresent(jwtAccessToken -> refreshToken(request, response, jwtAccessToken)); + } + } + + private void refreshToken(HttpServletRequest request, HttpServletResponse response, JwtAccessToken jwtAccessToken) { + LOG.debug("refreshing authentication token"); + issuer.authenticate(request, response, jwtAccessToken); + } +} diff --git a/scm-webapp/src/main/resources/META-INF/scm/permissions.xml b/scm-webapp/src/main/resources/META-INF/scm/permissions.xml index 537ada0bce..0ec51141b4 100644 --- a/scm-webapp/src/main/resources/META-INF/scm/permissions.xml +++ b/scm-webapp/src/main/resources/META-INF/scm/permissions.xml @@ -34,21 +34,31 @@ <permissions> <permission> - <display-name>All Repository (read)</display-name> - <description>Read access to all repositories</description> - <value>repository:*:READ</value> + <value>repository:read,pull:*</value> </permission> - <permission> - <display-name>All Repository (write)</display-name> - <description>Write access to all repositories</description> - <value>repository:*:WRITE</value> + <value>repository:read,pull,push:*</value> </permission> - <permission> - <display-name>All Repository (owner)</display-name> - <description>Owner access to all repositories</description> - <value>repository:*:OWNER</value> + <value>repository:*</value> </permission> - + <permission> + <value>repository:create</value> + </permission> + <permission> + <value>user:*</value> + </permission> + <permission> + <value>group:*</value> + </permission> + <permission> + <value>configuration:list</value> + </permission> + <permission> + <value>configuration:read,write:global</value> + </permission> + <permission> + <value>configuration:read,write:*</value> + </permission> + </permissions> diff --git a/scm-webapp/src/main/resources/META-INF/scm/repository-permissions.xml b/scm-webapp/src/main/resources/META-INF/scm/repository-permissions.xml new file mode 100644 index 0000000000..986aa5cf2d --- /dev/null +++ b/scm-webapp/src/main/resources/META-INF/scm/repository-permissions.xml @@ -0,0 +1,35 @@ +<repository-permissions> + <verbs> + <verb>read</verb> + <verb>modify</verb> + <verb>delete</verb> + <verb>pull</verb> + <verb>push</verb> + <verb>permissionRead</verb> + <verb>permissionWrite</verb> + <verb>*</verb> + </verbs> + <roles> + <role> + <name>READ</name> + <verbs> + <verb>read</verb> + <verb>pull</verb> + </verbs> + </role> + <role> + <name>WRITE</name> + <verbs> + <verb>read</verb> + <verb>pull</verb> + <verb>push</verb> + </verbs> + </role> + <role> + <name>OWNER</name> + <verbs> + <verb>*</verb> + </verbs> + </role> + </roles> +</repository-permissions> diff --git a/scm-webapp/src/main/resources/locales/de/plugins.json b/scm-webapp/src/main/resources/locales/de/plugins.json new file mode 100644 index 0000000000..def3d0b093 --- /dev/null +++ b/scm-webapp/src/main/resources/locales/de/plugins.json @@ -0,0 +1,91 @@ +{ + "permissions": { + "repository": { + "read,pull": { + "*": { + "displayName": "Alle Repositories lesen", + "description": "Darf alle Repositories lesen und klonen." + } + }, + "read,pull,push": { + "*": { + "displayName": "Alle Repositories schreiben", + "description": "Darf alle Repositories lesen, klonen und schreiben." + } + }, + "*": { + "displayName": "Alle Repositories besitzen (Owner)", + "description": "Darf alle Repositories lesen, klonen, schreiben, konfigurieren und löschen." + }, + "create": { + "displayName": "Repositories erstellen", + "description": "Darf Repositories erstellen." + } + }, + "user": { + "*": { + "displayName": "Benutzer administrieren", + "description": "Darf Benutzer administrieren." + } + }, + "group": { + "*": { + "displayName": "Gruppen administrieren", + "description": "Darf Gruppen administrieren." + } + }, + "configuration": { + "list": { + "displayName": "Basis für Administration", + "description": "Voraussetzung für alle anderen Administrationsberechtigungen. Ohne diese Berechtigung wird keine Administrationsansicht gezeigt." + }, + "read,write": { + "global": { + "displayName": "zentrale Konfiguration", + "description": "Darf die Konfiguration des SCM-Manager anpassen" + }, + "*": { + "displayName": "zentrale + Plugin Konfiguration", + "description": "Darf die Konfiguration des SCM-Manager und aller Plugins anpassen" + } + } + }, + "unknown": "Unbekannte Berechtigung" + }, + "verbs": { + "repository": { + "read": { + "displayName": "Lesen", + "description": "Darf das Repository im SCM-Manager sehen." + }, + "modify": { + "displayName": "Modifizieren", + "description": "Darf die Eigenschaften des Repository verändern." + }, + "delete": { + "displayName": "Löschen", + "description": "Darf das Repository löschen." + }, + "pull": { + "displayName": "Pull/Checkout", + "description": "Darf pull/checkout auf das Repository ausführen." + }, + "push": { + "displayName": "Push/Commit", + "description": "Darf push/commit auf das Repository ausführen und damit den Inhalt verändern." + }, + "permissionRead": { + "displayName": "Berechtigungen lesen", + "description": "Darf die Berechtigungen des Repository sehen." + }, + "permissionWrite": { + "displayName": "Berechtigungen modifizieren", + "description": "Darf die Berechtigungen des Repository bearbeiten." + }, + "*": { + "displayName": "Alle Repository Rechte", + "description": "Darf im Repository Kontext alles ausführen. Dies beinhaltet alle Repository Berechtigungen." + } + } + } +} diff --git a/scm-webapp/src/main/resources/locales/en/plugins.json b/scm-webapp/src/main/resources/locales/en/plugins.json new file mode 100644 index 0000000000..bf771def44 --- /dev/null +++ b/scm-webapp/src/main/resources/locales/en/plugins.json @@ -0,0 +1,91 @@ +{ + "permissions": { + "repository": { + "read,pull": { + "*": { + "displayName": "Read all repositories", + "description": "May see and clone all repositories" + } + }, + "read,pull,push": { + "*": { + "displayName": "Write all repositories", + "description": "May see, clone and push to all repositories" + } + }, + "*": { + "displayName": "Own all repositories", + "description": "May see, clone, push to, configure and delete all repositories" + }, + "create": { + "displayName": "Create repositories", + "description": "May create repositories" + } + }, + "user": { + "*": { + "displayName": "Administer users", + "description": "May administer all users" + } + }, + "group": { + "*": { + "displayName": "Administer groups", + "description": "May administer all groups" + } + }, + "configuration": { + "list": { + "displayName": "Basic administration", + "description": "Prerequisite for all other administration permissions. Without this, no configuration will be visible." + }, + "read,write": { + "global": { + "displayName": "Administer core", + "description": "May configure core settings of SCM-Manager" + }, + "*": { + "displayName": "Administer core and plugins", + "description": "May configure settings of SCM-Manager core and all plugins" + } + } + }, + "unknown": "Unknown permission" + }, + "verbs": { + "repository": { + "read": { + "displayName": "read", + "description": "May see the repository inside the SCM-Manager" + }, + "modify": { + "displayName": "modify", + "description": "May modify the properties of the repository" + }, + "delete": { + "displayName": "delete", + "description": "May delete the repository" + }, + "pull": { + "displayName": "pull/checkout", + "description": "May pull/checkout the repository" + }, + "push": { + "displayName": "push/commit", + "description": "May change the content of the repository (push/commit)" + }, + "permissionRead": { + "displayName": "read permissions", + "description": "May see the permissions of the repository" + }, + "permissionWrite": { + "displayName": "modify permissions", + "description": "May modify the permissions of the repository" + }, + "*": { + "displayName": "overall", + "description": "May change everything for the repository (includes all other permissions)" + } + } + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/rest/resources/AbstractManagerResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/rest/resources/AbstractManagerResourceTest.java index 41bcac3c6a..fd9745be83 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/rest/resources/AbstractManagerResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/rest/resources/AbstractManagerResourceTest.java @@ -12,12 +12,16 @@ import sonia.scm.ModelObject; import javax.ws.rs.core.GenericEntity; import javax.ws.rs.core.Request; +import javax.ws.rs.core.UriInfo; +import java.net.URI; +import java.net.URISyntaxException; import java.util.Collection; import java.util.Comparator; import static java.util.Collections.emptyList; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; -import static org.mockito.Matchers.eq; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; @RunWith(MockitoJUnitRunner.class) @@ -25,8 +29,13 @@ public class AbstractManagerResourceTest { @Mock private Manager<Simple> manager; + @Mock private Request request; + + @Mock + private UriInfo uriInfo; + @Captor private ArgumentCaptor<Comparator<Simple>> comparatorCaptor; @@ -59,6 +68,24 @@ public class AbstractManagerResourceTest { abstractManagerResource.getAll(request, 0, 1, "x", true); } + @Test + public void testLocation() throws URISyntaxException { + URI uri = location("special-item"); + assertEquals(new URI("https://scm.scm-manager.org/simple/special-item"), uri); + } + + @Test + public void testLocationWithSpaces() throws URISyntaxException { + URI uri = location("Scm Special Group"); + assertEquals(new URI("https://scm.scm-manager.org/simple/Scm%20Special%20Group"), uri); + } + + private URI location(String id) throws URISyntaxException { + URI base = new URI("https://scm.scm-manager.org/"); + when(uriInfo.getAbsolutePath()).thenReturn(base); + + return abstractManagerResource.location(uriInfo, id); + } private class SimpleManagerResource extends AbstractManagerResource<Simple> { @@ -82,7 +109,7 @@ public class AbstractManagerResourceTest { @Override protected String getPathPart() { - return null; + return "simple"; } } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/CacheControlResponseFilterTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/CacheControlResponseFilterTest.java new file mode 100644 index 0000000000..b0e8c4fdf5 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/CacheControlResponseFilterTest.java @@ -0,0 +1,61 @@ +package sonia.scm.api.v2; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import javax.ws.rs.container.ContainerRequestContext; +import javax.ws.rs.container.ContainerResponseContext; +import javax.ws.rs.core.EntityTag; +import javax.ws.rs.core.MultivaluedMap; +import java.util.Date; + +import static org.mockito.Mockito.*; + +@RunWith(MockitoJUnitRunner.class) +public class CacheControlResponseFilterTest { + + @Mock + private ContainerRequestContext requestContext; + + @Mock + private ContainerResponseContext responseContext; + + @Mock + private MultivaluedMap<String, Object> headers; + + private CacheControlResponseFilter filter = new CacheControlResponseFilter(); + + @Before + public void setUpMocks() { + when(responseContext.getHeaders()).thenReturn(headers); + } + + @Test + public void filterShouldAddCacheControlHeader() { + filter.filter(requestContext, responseContext); + + verify(headers).add("Cache-Control", "no-cache"); + } + + @Test + public void filterShouldNotSetHeaderIfLastModifiedIsNotNull() { + when(responseContext.getLastModified()).thenReturn(new Date()); + + filter.filter(requestContext, responseContext); + + verify(headers, never()).add("Cache-Control", "no-cache"); + } + + @Test + public void filterShouldNotSetHeaderIfEtagIsNotNull() { + when(responseContext.getEntityTag()).thenReturn(new EntityTag("42")); + + filter.filter(requestContext, responseContext); + + verify(headers, never()).add("Cache-Control", "no-cache"); + } + +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/JsonFiltersTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/JsonFiltersTest.java index b60775a73b..8bdbacafc2 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/JsonFiltersTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/JsonFiltersTest.java @@ -3,7 +3,6 @@ package sonia.scm.api.v2; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; -import com.google.common.collect.Lists; import com.google.common.io.Resources; import org.junit.Test; diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AuthenticationResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AuthenticationResourceTest.java index 42428f9f77..1123dc94ce 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AuthenticationResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AuthenticationResourceTest.java @@ -18,6 +18,7 @@ import sonia.scm.security.AccessToken; import sonia.scm.security.AccessTokenBuilder; import sonia.scm.security.AccessTokenBuilderFactory; import sonia.scm.security.AccessTokenCookieIssuer; +import sonia.scm.security.DefaultAccessTokenCookieIssuer; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @@ -46,7 +47,7 @@ public class AuthenticationResourceTest { @Mock private AccessTokenBuilder accessTokenBuilder; - private AccessTokenCookieIssuer cookieIssuer = new AccessTokenCookieIssuer(mock(ScmConfiguration.class)); + private AccessTokenCookieIssuer cookieIssuer = new DefaultAccessTokenCookieIssuer(mock(ScmConfiguration.class)); private static final String AUTH_JSON_TRILLIAN = "{\n" + "\t\"cookie\": true,\n" + diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AutoCompleteResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AutoCompleteResourceTest.java index 2cab41bfbf..d392aefe4e 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AutoCompleteResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AutoCompleteResourceTest.java @@ -66,7 +66,8 @@ public class AutoCompleteResourceTest { ConfigurationStore<Object> storeConfig = mock(ConfigurationStore.class); xmlDB = mock(XmlDatabase.class); when(storeConfig.get()).thenReturn(xmlDB); - when(storeFactory.getStore(any(), any())).thenReturn(storeConfig); + when(storeFactory.getStore(any())).thenReturn(storeConfig); + when(storeFactory.withType(any())).thenCallRealMethod(); XmlUserDAO userDao = new XmlUserDAO(storeFactory); this.userDao = spy(userDao); XmlGroupDAO groupDAO = new XmlGroupDAO(storeFactory); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchRootResourceTest.java index 4994c11b08..9216922e19 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchRootResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchRootResourceTest.java @@ -82,7 +82,7 @@ public class BranchRootResourceTest extends RepositoryTestBase { @InjectMocks - private ChangesetToChangesetDtoMapperImpl changesetToChangesetDtoMapper; + private DefaultChangesetToChangesetDtoMapperImpl changesetToChangesetDtoMapper; private final Subject subject = mock(Subject.class); private final ThreadState subjectThreadState = new SubjectThreadState(subject); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapperTest.java new file mode 100644 index 0000000000..3e64ab95b6 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapperTest.java @@ -0,0 +1,42 @@ +package sonia.scm.api.v2.resources; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.repository.Branch; +import sonia.scm.repository.NamespaceAndName; + +import java.net.URI; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +class BranchToBranchDtoMapperTest { + + private final URI baseUri = URI.create("https://hitchhiker.com"); + + @SuppressWarnings("unused") // Is injected + private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri); + + @InjectMocks + private BranchToBranchDtoMapperImpl mapper; + + @Test + void shouldAppendLinks() { + HalEnricherRegistry registry = new HalEnricherRegistry(); + registry.register(Branch.class, (ctx, appender) -> { + NamespaceAndName namespaceAndName = ctx.oneRequireByType(NamespaceAndName.class); + Branch branch = ctx.oneRequireByType(Branch.class); + + appender.appendLink("ka", "http://" + namespaceAndName.logString() + "/" + branch.getName()); + }); + mapper.setRegistry(registry); + + Branch branch = new Branch("master", "42"); + + BranchDto dto = mapper.map(branch, new NamespaceAndName("hitchhiker", "heart-of-gold")); + assertThat(dto.getLinks().getLinkBy("ka").get().getHref()).isEqualTo("http://hitchhiker/heart-of-gold/master"); + } + +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ChangesetCollectionToDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ChangesetCollectionToDtoMapperTest.java index 69695279e6..7653fbe122 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ChangesetCollectionToDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ChangesetCollectionToDtoMapperTest.java @@ -1,6 +1,5 @@ package sonia.scm.api.v2.resources; -import org.assertj.core.api.Assertions; import org.junit.Test; import sonia.scm.PageResult; import sonia.scm.repository.Changeset; @@ -17,7 +16,7 @@ public class ChangesetCollectionToDtoMapperTest { public static final Repository REPOSITORY = new Repository("", "git", "space", "name"); public static final Changeset CHANGESET = new Changeset(); - private final ChangesetToChangesetDtoMapper changesetToChangesetDtoMapper = mock(ChangesetToChangesetDtoMapper.class); + private final DefaultChangesetToChangesetDtoMapperImpl changesetToChangesetDtoMapper = mock(DefaultChangesetToChangesetDtoMapperImpl.class); private final ChangesetCollectionToDtoMapper changesetCollectionToDtoMapper = new ChangesetCollectionToDtoMapper(changesetToChangesetDtoMapper, ResourceLinksMock.createMock(URI.create("/"))); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ChangesetRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ChangesetRootResourceTest.java index d5c0f91f81..952c8504f6 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ChangesetRootResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ChangesetRootResourceTest.java @@ -65,7 +65,7 @@ public class ChangesetRootResourceTest extends RepositoryTestBase { private ChangesetCollectionToDtoMapper changesetCollectionToDtoMapper; @InjectMocks - private ChangesetToChangesetDtoMapperImpl changesetToChangesetDtoMapper; + private DefaultChangesetToChangesetDtoMapperImpl changesetToChangesetDtoMapper; private ChangesetRootResource changesetRootResource; diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ConfigResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ConfigResourceTest.java index 033824cbea..66689c6ac6 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ConfigResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ConfigResourceTest.java @@ -18,6 +18,7 @@ import sonia.scm.web.VndMediaType; import javax.servlet.http.HttpServletResponse; import java.io.IOException; +import java.io.UnsupportedEncodingException; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; @@ -68,7 +69,7 @@ public class ConfigResourceTest { @Test @SubjectAware(username = "readOnly") - public void shouldGetGlobalConfig() throws URISyntaxException { + public void shouldGetGlobalConfig() throws URISyntaxException, UnsupportedEncodingException { MockHttpRequest request = MockHttpRequest.get("/" + ConfigResource.CONFIG_PATH_V2); MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -92,11 +93,7 @@ public class ConfigResourceTest { @Test @SubjectAware(username = "readWrite") public void shouldUpdateConfig() throws URISyntaxException, IOException { - URL url = Resources.getResource("sonia/scm/api/v2/config-test-update.json"); - byte[] configJson = Resources.toByteArray(url); - MockHttpRequest request = MockHttpRequest.put("/" + ConfigResource.CONFIG_PATH_V2) - .contentType(VndMediaType.CONFIG) - .content(configJson); + MockHttpRequest request = post("sonia/scm/api/v2/config-test-update.json"); MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -113,11 +110,7 @@ public class ConfigResourceTest { @Test @SubjectAware(username = "readOnly") public void shouldNotUpdateConfigWhenNotAuthorized() throws URISyntaxException, IOException { - URL url = Resources.getResource("sonia/scm/api/v2/config-test-update.json"); - byte[] configJson = Resources.toByteArray(url); - MockHttpRequest request = MockHttpRequest.put("/" + ConfigResource.CONFIG_PATH_V2) - .contentType(VndMediaType.CONFIG) - .content(configJson); + MockHttpRequest request = post("sonia/scm/api/v2/config-test-update.json"); MockHttpResponse response = new MockHttpResponse(); thrown.expectMessage("Subject does not have permission [configuration:write:global]"); @@ -125,6 +118,36 @@ public class ConfigResourceTest { dispatcher.invoke(request, response); } + @Test + @SubjectAware(username = "readWrite") + public void shouldFailForEmptyAdminUsers() throws URISyntaxException, IOException { + MockHttpRequest request = post("sonia/scm/api/v2/config-test-empty-admin-user.json"); + + MockHttpResponse response = new MockHttpResponse(); + dispatcher.invoke(request, response); + + assertEquals(HttpServletResponse.SC_BAD_REQUEST, response.getStatus()); + } + + @Test + @SubjectAware(username = "readWrite") + public void shouldFailForEmptyAdminGroups() throws URISyntaxException, IOException { + MockHttpRequest request = post("sonia/scm/api/v2/config-test-empty-admin-group.json"); + + MockHttpResponse response = new MockHttpResponse(); + dispatcher.invoke(request, response); + + assertEquals(HttpServletResponse.SC_BAD_REQUEST, response.getStatus()); + } + + private MockHttpRequest post(String resourceName) throws IOException, URISyntaxException { + URL url = Resources.getResource(resourceName); + byte[] configJson = Resources.toByteArray(url); + return MockHttpRequest.put("/" + ConfigResource.CONFIG_PATH_V2) + .contentType(VndMediaType.CONFIG) + .content(configJson); + } + private static ScmConfiguration createConfiguration() { ScmConfiguration scmConfiguration = new ScmConfiguration(); scmConfiguration.setProxyPassword("heartOfGold"); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/DispatcherMock.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/DispatcherMock.java index 9638a8aa49..fe205e88a1 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/DispatcherMock.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/DispatcherMock.java @@ -4,9 +4,9 @@ import org.jboss.resteasy.core.Dispatcher; import org.jboss.resteasy.mock.MockDispatcherFactory; import sonia.scm.api.rest.AlreadyExistsExceptionMapper; import sonia.scm.api.rest.AuthorizationExceptionMapper; +import sonia.scm.api.rest.BadRequestExceptionMapper; import sonia.scm.api.rest.ConcurrentModificationExceptionMapper; import sonia.scm.api.v2.NotFoundExceptionMapper; -import sonia.scm.api.v2.NotSupportedFeatureExceptionMapper; public class DispatcherMock { public static Dispatcher createDispatcher(Object resource) { @@ -18,9 +18,7 @@ public class DispatcherMock { dispatcher.getProviderFactory().register(new ConcurrentModificationExceptionMapper(mapper)); dispatcher.getProviderFactory().registerProvider(AuthorizationExceptionMapper.class); dispatcher.getProviderFactory().register(new InternalRepositoryExceptionMapper(mapper)); - dispatcher.getProviderFactory().register(new ChangePasswordNotAllowedExceptionMapper(mapper)); - dispatcher.getProviderFactory().register(new InvalidPasswordExceptionMapper(mapper)); - dispatcher.getProviderFactory().register(new NotSupportedFeatureExceptionMapper(mapper)); + dispatcher.getProviderFactory().register(new BadRequestExceptionMapper(mapper)); return dispatcher; } } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/EdisonHalAppenderTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/EdisonHalAppenderTest.java new file mode 100644 index 0000000000..ff149c5dc5 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/EdisonHalAppenderTest.java @@ -0,0 +1,61 @@ +package sonia.scm.api.v2.resources; + +import de.otto.edison.hal.Embedded; +import de.otto.edison.hal.HalRepresentation; +import de.otto.edison.hal.Link; +import de.otto.edison.hal.Links; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static de.otto.edison.hal.Embedded.embeddedBuilder; +import static de.otto.edison.hal.Links.linkingTo; +import static org.assertj.core.api.Assertions.assertThat; + +class EdisonHalAppenderTest { + + private Links.Builder linksBuilder; + private Embedded.Builder embeddedBuilder; + private EdisonHalAppender appender; + + @BeforeEach + void prepare() { + linksBuilder = linkingTo(); + embeddedBuilder = embeddedBuilder(); + appender = new EdisonHalAppender(linksBuilder, embeddedBuilder); + } + + @Test + void shouldAppendOneLink() { + appender.appendLink("self", "https://scm.hitchhiker.com"); + + Links links = linksBuilder.build(); + assertThat(links.getLinkBy("self").get().getHref()).isEqualTo("https://scm.hitchhiker.com"); + } + + @Test + void shouldAppendMultipleLinks() { + appender.linkArrayBuilder("items") + .append("one", "http://one") + .append("two", "http://two") + .build(); + + List<Link> items = linksBuilder.build().getLinksBy("items"); + assertThat(items).hasSize(2); + } + + @Test + void shouldAppendEmbedded() { + HalRepresentation one = new HalRepresentation(); + appender.appendEmbedded("one", one); + + HalRepresentation two = new HalRepresentation(); + appender.appendEmbedded("two", new HalRepresentation()); + + Embedded embedded = embeddedBuilder.build(); + assertThat(embedded.getItemsBy("one")).containsOnly(one); + assertThat(embedded.getItemsBy("two")).containsOnly(two); + } + +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/FileHistoryResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/FileHistoryResourceTest.java index 52c9a434c0..a8b3c15158 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/FileHistoryResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/FileHistoryResourceTest.java @@ -66,7 +66,7 @@ public class FileHistoryResourceTest extends RepositoryTestBase { private FileHistoryCollectionToDtoMapper fileHistoryCollectionToDtoMapper; @InjectMocks - private ChangesetToChangesetDtoMapperImpl changesetToChangesetDtoMapper; + private DefaultChangesetToChangesetDtoMapperImpl changesetToChangesetDtoMapper; private FileHistoryRootResource fileHistoryRootResource; diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/FileObjectToFileObjectDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/FileObjectToFileObjectDtoMapperTest.java index 23b723b748..55058a1684 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/FileObjectToFileObjectDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/FileObjectToFileObjectDtoMapperTest.java @@ -71,6 +71,24 @@ public class FileObjectToFileObjectDtoMapperTest { assertThat(dto.getLinks().getLinkBy("self").get().getHref()).isEqualTo(expectedBaseUri.resolve("namespace/name/content/revision/foo/bar").toString()); } + @Test + public void shouldAppendLinks() { + HalEnricherRegistry registry = new HalEnricherRegistry(); + registry.register(FileObject.class, (ctx, appender) -> { + NamespaceAndName repository = ctx.oneRequireByType(NamespaceAndName.class); + FileObject fo = ctx.oneRequireByType(FileObject.class); + String rev = ctx.oneRequireByType(String.class); + + appender.appendLink("hog", "http://" + repository.logString() + "/" + fo.getName() + "/" + rev); + }); + mapper.setRegistry(registry); + + FileObject fileObject = createFileObject(); + FileObjectDto dto = mapper.map(fileObject, new NamespaceAndName("hitchhiker", "hog"), "42"); + + assertThat(dto.getLinks().getLinkBy("hog").get().getHref()).isEqualTo("http://hitchhiker/hog/foo/42"); + } + private FileObject createDirectoryObject() { FileObject fileObject = createFileObject(); fileObject.setDirectory(true); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/GroupRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/GroupRootResourceTest.java index f28cf49d03..3e2d0f9663 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/GroupRootResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/GroupRootResourceTest.java @@ -18,13 +18,17 @@ import sonia.scm.api.rest.JSONContextResolver; import sonia.scm.api.rest.ObjectMapperProvider; import sonia.scm.group.Group; import sonia.scm.group.GroupManager; +import sonia.scm.security.PermissionAssigner; +import sonia.scm.security.PermissionDescriptor; import sonia.scm.web.VndMediaType; import javax.servlet.http.HttpServletResponse; import java.io.IOException; +import java.io.UnsupportedEncodingException; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; +import java.util.Collection; import java.util.Collections; import static java.util.Collections.singletonList; @@ -54,10 +58,15 @@ public class GroupRootResourceTest { @Mock private GroupManager groupManager; + @Mock + private PermissionAssigner permissionAssigner; @InjectMocks private GroupDtoToGroupMapperImpl dtoToGroupMapper; @InjectMocks private GroupToGroupDtoMapperImpl groupToDtoMapper; + @InjectMocks + private PermissionCollectionToDtoMapper permissionCollectionToDtoMapper; + private ArgumentCaptor<Group> groupCaptor = ArgumentCaptor.forClass(Group.class); @@ -73,7 +82,8 @@ public class GroupRootResourceTest { GroupCollectionToDtoMapper groupCollectionToDtoMapper = new GroupCollectionToDtoMapper(groupToDtoMapper, resourceLinks); GroupCollectionResource groupCollectionResource = new GroupCollectionResource(groupManager, dtoToGroupMapper, groupCollectionToDtoMapper, resourceLinks); - GroupResource groupResource = new GroupResource(groupManager, groupToDtoMapper, dtoToGroupMapper); + GroupPermissionResource groupPermissionResource = new GroupPermissionResource(permissionAssigner, permissionCollectionToDtoMapper); + GroupResource groupResource = new GroupResource(groupManager, groupToDtoMapper, dtoToGroupMapper, groupPermissionResource); GroupRootResource groupRootResource = new GroupRootResource(Providers.of(groupCollectionResource), Providers.of(groupResource)); dispatcher = createDispatcher(groupRootResource); @@ -91,7 +101,7 @@ public class GroupRootResourceTest { } @Test - public void shouldGetGroup() throws URISyntaxException { + public void shouldGetGroup() throws URISyntaxException, UnsupportedEncodingException { Group group = createDummyGroup(); when(groupManager.get("admin")).thenReturn(group); @@ -296,7 +306,7 @@ public class GroupRootResourceTest { } @Test - public void shouldGetAll() throws URISyntaxException { + public void shouldGetAll() throws URISyntaxException, UnsupportedEncodingException { MockHttpRequest request = MockHttpRequest.get("/" + GroupRootResource.GROUPS_PATH_V2); MockHttpResponse response = new MockHttpResponse(); @@ -307,6 +317,48 @@ public class GroupRootResourceTest { assertTrue(response.getContentAsString().contains("\"self\":{\"href\":\"/v2/groups/admin\"}")); } + @Test + public void shouldGetPermissionLink() throws URISyntaxException, UnsupportedEncodingException { + MockHttpRequest request = MockHttpRequest.get("/" + GroupRootResource.GROUPS_PATH_V2 + "admin"); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(HttpServletResponse.SC_OK, response.getStatus()); + + assertTrue(response.getContentAsString().contains("\"permissions\":{")); + } + + @Test + public void shouldGetPermissions() throws URISyntaxException, UnsupportedEncodingException { + when(permissionAssigner.readPermissionsForGroup("admin")).thenReturn(singletonList(new PermissionDescriptor("something:*"))); + MockHttpRequest request = MockHttpRequest.get("/" + GroupRootResource.GROUPS_PATH_V2 + "admin/permissions"); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(HttpServletResponse.SC_OK, response.getStatus()); + + assertTrue(response.getContentAsString().contains("\"permissions\":[\"something:*\"]")); + } + + @Test + public void shouldSetPermissions() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest + .put("/" + GroupRootResource.GROUPS_PATH_V2 + "admin/permissions") + .contentType(VndMediaType.PERMISSION_COLLECTION) + .content("{\"permissions\":[\"other:*\"]}".getBytes()); + MockHttpResponse response = new MockHttpResponse(); + ArgumentCaptor<Collection<PermissionDescriptor>> captor = ArgumentCaptor.forClass(Collection.class); + doNothing().when(permissionAssigner).setPermissionsForGroup(eq("admin"), captor.capture()); + + dispatcher.invoke(request, response); + + assertEquals(HttpServletResponse.SC_NO_CONTENT, response.getStatus()); + + assertEquals("other:*", captor.getValue().iterator().next().getValue()); + } + private Group createDummyGroup() { Group group = new Group(); group.setName("admin"); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/GroupToGroupDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/GroupToGroupDtoMapperTest.java index e519a9c3e5..045124ad91 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/GroupToGroupDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/GroupToGroupDtoMapperTest.java @@ -11,7 +11,6 @@ import org.mockito.InjectMocks; import sonia.scm.group.Group; import java.net.URI; -import java.net.URISyntaxException; import java.util.stream.IntStream; import static java.util.stream.Collectors.toList; @@ -35,7 +34,7 @@ public class GroupToGroupDtoMapperTest { private URI expectedBaseUri; @Before - public void init() throws URISyntaxException { + public void init() { initMocks(this); expectedBaseUri = baseUri.resolve(GroupRootResource.GROUPS_PATH_V2 + "/"); subjectThreadState.bind(); @@ -89,6 +88,21 @@ public class GroupToGroupDtoMapperTest { assertEquals("http://example.com/base/v2/users/user0", actualMember.getLinks().getLinkBy("self").get().getHref()); } + @Test + public void shouldAppendLinks() { + HalEnricherRegistry registry = new HalEnricherRegistry(); + registry.register(Group.class, (ctx, appender) -> { + Group group = ctx.oneRequireByType(Group.class); + appender.appendLink("some", "http://" + group.getName()); + }); + mapper.setRegistry(registry); + + Group group = createDefaultGroup(); + GroupDto dto = mapper.map(group); + + assertEquals("http://abc", dto.getLinks().getLinkBy("some").get().getHref()); + } + private Group createDefaultGroup() { Group group = new Group(); group.setName("abc"); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/HalEnricherAutoRegistrationTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/HalEnricherAutoRegistrationTest.java new file mode 100644 index 0000000000..314dcf11c2 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/HalEnricherAutoRegistrationTest.java @@ -0,0 +1,64 @@ +package sonia.scm.api.v2.resources; + +import com.google.common.collect.ImmutableSet; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.assertj.core.api.Java6Assertions.assertThat; + +class HalEnricherAutoRegistrationTest { + + @Test + void shouldRegisterAllAvailableLinkEnrichers() { + HalEnricher one = new One(); + HalEnricher two = new Two(); + HalEnricher three = new Three(); + HalEnricher four = new Four(); + Set<HalEnricher> enrichers = ImmutableSet.of(one, two, three, four); + + HalEnricherRegistry registry = new HalEnricherRegistry(); + + LinkEnricherAutoRegistration autoRegistration = new LinkEnricherAutoRegistration(registry, enrichers); + autoRegistration.contextInitialized(null); + + assertThat(registry.allByType(String.class)).containsOnly(one, two); + assertThat(registry.allByType(Integer.class)).containsOnly(three); + } + + @Enrich(String.class) + public static class One implements HalEnricher { + + @Override + public void enrich(HalEnricherContext context, HalAppender appender) { + + } + } + + @Enrich(String.class) + public static class Two implements HalEnricher { + + @Override + public void enrich(HalEnricherContext context, HalAppender appender) { + + } + } + + @Enrich(Integer.class) + public static class Three implements HalEnricher { + + @Override + public void enrich(HalEnricherContext context, HalAppender appender) { + + } + } + + public static class Four implements HalEnricher { + + @Override + public void enrich(HalEnricherContext context, HalAppender appender) { + + } + } + +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IncomingRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IncomingRootResourceTest.java new file mode 100644 index 0000000000..b965c2f2c3 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IncomingRootResourceTest.java @@ -0,0 +1,255 @@ +package sonia.scm.api.v2.resources; + + +import com.google.inject.util.Providers; +import lombok.extern.slf4j.Slf4j; +import org.apache.shiro.subject.Subject; +import org.apache.shiro.subject.support.SubjectThreadState; +import org.apache.shiro.util.ThreadContext; +import org.apache.shiro.util.ThreadState; +import org.assertj.core.util.Lists; +import org.jboss.resteasy.core.Dispatcher; +import org.jboss.resteasy.mock.MockHttpRequest; +import org.jboss.resteasy.mock.MockHttpResponse; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import sonia.scm.NotFoundException; +import sonia.scm.repository.Changeset; +import sonia.scm.repository.ChangesetPagingResult; +import sonia.scm.repository.NamespaceAndName; +import sonia.scm.repository.Person; +import sonia.scm.repository.Repository; +import sonia.scm.repository.api.DiffCommandBuilder; +import sonia.scm.repository.api.LogCommandBuilder; +import sonia.scm.repository.api.RepositoryService; +import sonia.scm.repository.api.RepositoryServiceFactory; +import sonia.scm.web.VndMediaType; + +import java.net.URI; +import java.net.URISyntaxException; +import java.time.Instant; +import java.util.Date; +import java.util.List; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.Silent.class) +@Slf4j +public class IncomingRootResourceTest extends RepositoryTestBase { + + + public static final String INCOMING_PATH = "space/repo/incoming/"; + public static final String INCOMING_CHANGESETS_URL = "/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + INCOMING_PATH; + public static final String INCOMING_DIFF_URL = "/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + INCOMING_PATH; + + private Dispatcher dispatcher; + + private final URI baseUri = URI.create("/"); + private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri); + + @Mock + private RepositoryServiceFactory serviceFactory; + + @Mock + private RepositoryService repositoryService; + + @Mock + private LogCommandBuilder logCommandBuilder; + + @Mock + private DiffCommandBuilder diffCommandBuilder; + + + private IncomingChangesetCollectionToDtoMapper incomingChangesetCollectionToDtoMapper; + + @InjectMocks + private DefaultChangesetToChangesetDtoMapperImpl changesetToChangesetDtoMapper; + + private IncomingRootResource incomingRootResource; + + + private final Subject subject = mock(Subject.class); + private final ThreadState subjectThreadState = new SubjectThreadState(subject); + + + @Before + public void prepareEnvironment() { + incomingChangesetCollectionToDtoMapper = new IncomingChangesetCollectionToDtoMapper(changesetToChangesetDtoMapper, resourceLinks); + incomingRootResource = new IncomingRootResource(serviceFactory, incomingChangesetCollectionToDtoMapper); + super.incomingRootResource = Providers.of(incomingRootResource); + dispatcher = DispatcherMock.createDispatcher(getRepositoryRootResource()); + when(serviceFactory.create(new NamespaceAndName("space", "repo"))).thenReturn(repositoryService); + when(serviceFactory.create(any(Repository.class))).thenReturn(repositoryService); + when(repositoryService.getRepository()).thenReturn(new Repository("repoId", "git", "space", "repo")); + when(repositoryService.getLogCommand()).thenReturn(logCommandBuilder); + when(repositoryService.getDiffCommand()).thenReturn(diffCommandBuilder); + dispatcher.getProviderFactory().registerProvider(CRLFInjectionExceptionMapper.class); + subjectThreadState.bind(); + ThreadContext.bind(subject); + when(subject.isPermitted(any(String.class))).thenReturn(true); + } + + @After + public void cleanupContext() { + ThreadContext.unbindSubject(); + } + + @Test + public void shouldGetIncomingChangesets() throws Exception { + String id = "revision_123"; + Instant creationDate = Instant.now(); + String authorName = "name"; + String authorEmail = "em@i.l"; + String commit = "my branch commit"; + ChangesetPagingResult changesetPagingResult = mock(ChangesetPagingResult.class); + List<Changeset> changesetList = Lists.newArrayList(new Changeset(id, Date.from(creationDate).getTime(), new Person(authorName, authorEmail), commit)); + when(changesetPagingResult.getChangesets()).thenReturn(changesetList); + when(changesetPagingResult.getTotal()).thenReturn(1); + when(logCommandBuilder.setPagingStart(0)).thenReturn(logCommandBuilder); + when(logCommandBuilder.setPagingLimit(10)).thenReturn(logCommandBuilder); + when(logCommandBuilder.setStartChangeset(anyString())).thenReturn(logCommandBuilder); + when(logCommandBuilder.setAncestorChangeset(anyString())).thenReturn(logCommandBuilder); + when(logCommandBuilder.getChangesets()).thenReturn(changesetPagingResult); + MockHttpRequest request = MockHttpRequest + .get(INCOMING_CHANGESETS_URL + "src_changeset_id/target_changeset_id/changesets") + .accept(VndMediaType.CHANGESET_COLLECTION); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(200, response.getStatus()); + log.info("Response :{}", response.getContentAsString()); + assertTrue(response.getContentAsString().contains(String.format("\"id\":\"%s\"", id))); + assertTrue(response.getContentAsString().contains(String.format("\"name\":\"%s\"", authorName))); + assertTrue(response.getContentAsString().contains(String.format("\"mail\":\"%s\"", authorEmail))); + assertTrue(response.getContentAsString().contains(String.format("\"description\":\"%s\"", commit))); + } + + @Test + public void shouldGetSinglePageOfIncomingChangesets() throws Exception { + String id = "revision_123"; + Instant creationDate = Instant.now(); + String authorName = "name"; + String authorEmail = "em@i.l"; + String commit = "my branch commit"; + ChangesetPagingResult changesetPagingResult = mock(ChangesetPagingResult.class); + List<Changeset> changesetList = Lists.newArrayList(new Changeset(id, Date.from(creationDate).getTime(), new Person(authorName, authorEmail), commit)); + when(changesetPagingResult.getChangesets()).thenReturn(changesetList); + when(changesetPagingResult.getTotal()).thenReturn(1); + when(logCommandBuilder.setPagingStart(20)).thenReturn(logCommandBuilder); + when(logCommandBuilder.setPagingLimit(10)).thenReturn(logCommandBuilder); + when(logCommandBuilder.setStartChangeset(anyString())).thenReturn(logCommandBuilder); + when(logCommandBuilder.setAncestorChangeset(anyString())).thenReturn(logCommandBuilder); + when(logCommandBuilder.getChangesets()).thenReturn(changesetPagingResult); + MockHttpRequest request = MockHttpRequest + .get(INCOMING_CHANGESETS_URL + "src_changeset_id/target_changeset_id/changesets?page=2") + .accept(VndMediaType.CHANGESET_COLLECTION); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(200, response.getStatus()); + assertTrue(response.getContentAsString().contains(String.format("\"id\":\"%s\"", id))); + assertTrue(response.getContentAsString().contains(String.format("\"name\":\"%s\"", authorName))); + assertTrue(response.getContentAsString().contains(String.format("\"mail\":\"%s\"", authorEmail))); + assertTrue(response.getContentAsString().contains(String.format("\"description\":\"%s\"", commit))); + } + + @Test + public void shouldGetDiffs() throws Exception { + when(diffCommandBuilder.setRevision(anyString())).thenReturn(diffCommandBuilder); + when(diffCommandBuilder.setAncestorChangeset(anyString())).thenReturn(diffCommandBuilder); + when(diffCommandBuilder.setFormat(any())).thenReturn(diffCommandBuilder); + when(diffCommandBuilder.retrieveContent(any())).thenReturn(diffCommandBuilder); + MockHttpRequest request = MockHttpRequest + .get(INCOMING_DIFF_URL + "src_changeset_id/target_changeset_id/diff") + .accept(VndMediaType.DIFF); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()) + .isEqualTo(200); + String expectedHeader = "Content-Disposition"; + String expectedValue = "attachment; filename=\"repo-src_changeset_id.diff\"; filename*=utf-8''repo-src_changeset_id.diff"; + assertThat(response.getOutputHeaders().containsKey(expectedHeader)).isTrue(); + assertThat((String) response.getOutputHeaders().get("Content-Disposition").get(0)) + .contains(expectedValue); + } + + @Test + public void shouldGet404OnMissingRepository() throws URISyntaxException { + when(serviceFactory.create(any(NamespaceAndName.class))).thenThrow(new NotFoundException("Text", "x")); + MockHttpRequest request = MockHttpRequest + .get(INCOMING_DIFF_URL + "src_changeset_id/target_changeset_id/diff") + .accept(VndMediaType.DIFF); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(404, response.getStatus()); + } + + @Test + public void shouldGet404OnMissingRevision() throws Exception { + when(diffCommandBuilder.setRevision(anyString())).thenReturn(diffCommandBuilder); + when(diffCommandBuilder.setAncestorChangeset(anyString())).thenReturn(diffCommandBuilder); + when(diffCommandBuilder.setFormat(any())).thenReturn(diffCommandBuilder); + when(diffCommandBuilder.retrieveContent(any())).thenThrow(new NotFoundException("Text", "x")); + + MockHttpRequest request = MockHttpRequest + .get(INCOMING_DIFF_URL + "src_changeset_id/target_changeset_id/diff") + .accept(VndMediaType.DIFF); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(404, response.getStatus()); + } + + @Test + public void shouldGet400OnCrlfInjection() throws Exception { + when(diffCommandBuilder.setRevision(anyString())).thenReturn(diffCommandBuilder); + when(diffCommandBuilder.setAncestorChangeset(anyString())).thenReturn(diffCommandBuilder); + when(diffCommandBuilder.setFormat(any())).thenReturn(diffCommandBuilder); + when(diffCommandBuilder.retrieveContent(any())).thenThrow(new NotFoundException("Text", "x")); + MockHttpRequest request = MockHttpRequest + .get(INCOMING_DIFF_URL + "ny%0D%0ASet-cookie:%20Tamper=3079675143472450634/ny%0D%0ASet-cookie:%20Tamper=3079675143472450634/diff") + .accept(VndMediaType.DIFF); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(400, response.getStatus()); + assertThat(response.getContentAsString()).contains("parameter contains an illegal character"); + } + + @Test + public void shouldGet400OnUnknownFormat() throws Exception { + when(diffCommandBuilder.setRevision(anyString())).thenReturn(diffCommandBuilder); + when(diffCommandBuilder.setAncestorChangeset(anyString())).thenReturn(diffCommandBuilder); + when(diffCommandBuilder.setFormat(any())).thenReturn(diffCommandBuilder); + when(diffCommandBuilder.retrieveContent(any())).thenThrow(new NotFoundException("Test", "test")); + MockHttpRequest request = MockHttpRequest + .get(INCOMING_DIFF_URL + "src_changeset_id/target_changeset_id/diff?format=Unknown") + .accept(VndMediaType.DIFF); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(400, response.getStatus()); + } + + +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MeDtoFactoryTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MeDtoFactoryTest.java new file mode 100644 index 0000000000..8a00c69229 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MeDtoFactoryTest.java @@ -0,0 +1,185 @@ +package sonia.scm.api.v2.resources; + +import org.apache.shiro.subject.PrincipalCollection; +import org.apache.shiro.subject.Subject; +import org.apache.shiro.util.ThreadContext; +import org.assertj.core.util.Lists; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import sonia.scm.group.GroupNames; +import sonia.scm.user.User; +import sonia.scm.user.UserManager; +import sonia.scm.user.UserTestData; + +import java.net.URI; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class MeDtoFactoryTest { + + private final URI baseUri = URI.create("https://scm.hitchhiker.com/scm/"); + + @Mock + private UserManager userManager; + + @Mock + private Subject subject; + + private MeDtoFactory meDtoFactory; + + @BeforeEach + void setUpContext() { + ThreadContext.bind(subject); + ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri); + meDtoFactory = new MeDtoFactory(resourceLinks, userManager); + } + + @AfterEach + void unbindSubject() { + ThreadContext.unbindSubject(); + } + + @Test + void shouldCreateMeDtoFromUser() { + prepareSubject(UserTestData.createTrillian()); + + MeDto dto = meDtoFactory.create(); + assertThat(dto.getName()).isEqualTo("trillian"); + assertThat(dto.getDisplayName()).isEqualTo("Tricia McMillan"); + assertThat(dto.getMail()).isEqualTo("tricia.mcmillan@hitchhiker.com"); + } + + @Test + void shouldCreateMeDtoWithEmptyGroups() { + prepareSubject(UserTestData.createTrillian()); + MeDto dto = meDtoFactory.create(); + assertThat(dto.getGroups()).isEmpty(); + } + + @Test + void shouldCreateMeDtoWithGroups() { + prepareSubject(UserTestData.createTrillian(), "HeartOfGold", "Puzzle42"); + MeDto dto = meDtoFactory.create(); + assertThat(dto.getGroups()).containsOnly("HeartOfGold", "Puzzle42"); + } + + private void prepareSubject(User user, String... groups) { + PrincipalCollection collection = mock(PrincipalCollection.class); + when(subject.getPrincipals()).thenReturn(collection); + when(collection.oneByType(any(Class.class))).then(ic -> { + Class<?> type = ic.getArgument(0); + if (type.isAssignableFrom(User.class)) { + return user; + } else if (type.isAssignableFrom(GroupNames.class)) { + return new GroupNames(Lists.newArrayList(groups)); + } else { + return null; + } + }); + } + + @Test + void shouldAppendSelfLink() { + prepareSubject(UserTestData.createTrillian()); + + MeDto dto = meDtoFactory.create(); + assertThat(dto.getLinks().getLinkBy("self").get().getHref()).isEqualTo("https://scm.hitchhiker.com/scm/v2/me/"); + } + + @Test + void shouldAppendDeleteLink() { + prepareSubject(UserTestData.createTrillian()); + when(subject.isPermitted("user:delete:trillian")).thenReturn(true); + + MeDto dto = meDtoFactory.create(); + assertThat(dto.getLinks().getLinkBy("delete").get().getHref()).isEqualTo("https://scm.hitchhiker.com/scm/v2/users/trillian"); + } + + @Test + void shouldNotAppendDeleteLink() { + prepareSubject(UserTestData.createTrillian()); + + MeDto dto = meDtoFactory.create(); + assertThat(dto.getLinks().getLinkBy("delete")).isNotPresent(); + } + + @Test + void shouldAppendUpdateLink() { + prepareSubject(UserTestData.createTrillian()); + when(subject.isPermitted("user:modify:trillian")).thenReturn(true); + + MeDto dto = meDtoFactory.create(); + assertThat(dto.getLinks().getLinkBy("update").get().getHref()).isEqualTo("https://scm.hitchhiker.com/scm/v2/users/trillian"); + } + + @Test + void shouldNotAppendUpdateLink() { + prepareSubject(UserTestData.createTrillian()); + + MeDto dto = meDtoFactory.create(); + assertThat(dto.getLinks().getLinkBy("update")).isNotPresent(); + } + + @Test + void shouldGetPasswordLinkOnlyForDefaultUserType() { + User user = UserTestData.createTrillian(); + prepareSubject(user); + + when(subject.isPermitted("user:changePassword:trillian")).thenReturn(true); + when(userManager.isTypeDefault(user)).thenReturn(true); + + MeDto dto = meDtoFactory.create(); + assertThat(dto.getLinks().getLinkBy("password").get().getHref()).isEqualTo("https://scm.hitchhiker.com/scm/v2/me/password"); + } + + @Test + void shouldNotGetPasswordLinkWithoutPermision() { + User user = UserTestData.createTrillian(); + prepareSubject(user); + + when(userManager.isTypeDefault(user)).thenReturn(true); + + MeDto dto = meDtoFactory.create(); + assertThat(dto.getLinks().getLinkBy("password")).isNotPresent(); + } + + @Test + void shouldNotGetPasswordLinkForNonDefaultUsers() { + User user = UserTestData.createTrillian(); + prepareSubject(user); + + when(subject.isPermitted("user:changePassword:trillian")).thenReturn(true); + + MeDto dto = meDtoFactory.create(); + assertThat(dto.getLinks().getLinkBy("password")).isNotPresent(); + } + + @Test + void shouldAppendLinks() { + prepareSubject(UserTestData.createTrillian()); + + HalEnricherRegistry registry = new HalEnricherRegistry(); + meDtoFactory.setRegistry(registry); + + registry.register(Me.class, (ctx, appender) -> { + User user = ctx.oneRequireByType(User.class); + appender.appendLink("profile", "http://hitchhiker.com/users/" + user.getName()); + }); + + MeDto dto = meDtoFactory.create(); + assertThat(dto.getLinks().getLinkBy("profile").get().getHref()).isEqualTo("http://hitchhiker.com/users/trillian"); + } + + +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MeResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MeResourceTest.java index 454e24aa92..cd2a172c1b 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MeResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MeResourceTest.java @@ -2,12 +2,14 @@ package sonia.scm.api.v2.resources; import com.github.sdorra.shiro.ShiroRule; import com.github.sdorra.shiro.SubjectAware; +import org.apache.shiro.SecurityUtils; import org.apache.shiro.authc.credential.PasswordService; +import org.apache.shiro.subject.PrincipalCollection; +import org.apache.shiro.subject.Subject; import org.jboss.resteasy.core.Dispatcher; import org.jboss.resteasy.mock.MockHttpRequest; import org.jboss.resteasy.mock.MockHttpResponse; import org.junit.Before; -import org.junit.Ignore; import org.junit.Rule; import org.junit.Test; import org.mockito.ArgumentCaptor; @@ -19,19 +21,14 @@ import sonia.scm.user.User; import sonia.scm.user.UserManager; import sonia.scm.web.VndMediaType; -import javax.lang.model.util.Types; import javax.servlet.http.HttpServletResponse; +import java.io.UnsupportedEncodingException; import java.net.URI; import java.net.URISyntaxException; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; import static org.mockito.MockitoAnnotations.initMocks; import static sonia.scm.api.v2.resources.DispatcherMock.createDispatcher; @@ -57,7 +54,7 @@ public class MeResourceTest { private UserManager userManager; @InjectMocks - private MeToUserDtoMapperImpl userToDtoMapper; + private MeDtoFactory meDtoFactory; private ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class); @@ -66,7 +63,7 @@ public class MeResourceTest { private User originalUser; @Before - public void prepareEnvironment() throws Exception { + public void prepareEnvironment() { initMocks(this); originalUser = createDummyUser("trillian"); when(userManager.create(userCaptor.capture())).thenAnswer(invocation -> invocation.getArguments()[0]); @@ -74,17 +71,18 @@ public class MeResourceTest { doNothing().when(userManager).delete(userCaptor.capture()); when(userManager.isTypeDefault(userCaptor.capture())).thenCallRealMethod(); when(userManager.getDefaultType()).thenReturn("xml"); - MeResource meResource = new MeResource(userToDtoMapper, userManager, passwordService); + MeResource meResource = new MeResource(meDtoFactory, userManager, passwordService); when(uriInfo.getApiRestUri()).thenReturn(URI.create("/")); when(scmPathInfoStore.get()).thenReturn(uriInfo); dispatcher = createDispatcher(meResource); } @Test - @SubjectAware(username = "trillian", password = "secret") - public void shouldReturnCurrentlyAuthenticatedUser() throws URISyntaxException { + public void shouldReturnCurrentlyAuthenticatedUser() throws URISyntaxException, UnsupportedEncodingException { + applyUserToSubject(originalUser); + MockHttpRequest request = MockHttpRequest.get("/" + MeResource.ME_PATH_V2); - request.accept(VndMediaType.USER); + request.accept(VndMediaType.ME); MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -95,8 +93,17 @@ public class MeResourceTest { assertTrue(response.getContentAsString().contains("\"delete\":{\"href\":\"/v2/users/trillian\"}")); } + private void applyUserToSubject(User user) { + // use spy here to keep applied permissions from ShiroRule + Subject subject = spy(SecurityUtils.getSubject()); + PrincipalCollection collection = mock(PrincipalCollection.class); + when(collection.getPrimaryPrincipal()).thenReturn(user.getName()); + when(subject.getPrincipals()).thenReturn(collection); + when(collection.oneByType(User.class)).thenReturn(user); + shiro.setSubject(subject); + } + @Test - @SubjectAware(username = "trillian", password = "secret") public void shouldEncryptPasswordBeforeChanging() throws Exception { String newPassword = "pwd123"; String encryptedNewPassword = "encrypted123"; @@ -124,7 +131,6 @@ public class MeResourceTest { } @Test - @SubjectAware(username = "trillian", password = "secret") public void shouldGet400OnMissingOldPassword() throws Exception { originalUser.setType("not an xml type"); String newPassword = "pwd123"; @@ -141,7 +147,6 @@ public class MeResourceTest { } @Test - @SubjectAware(username = "trillian", password = "secret") public void shouldGet400OnMissingEmptyPassword() throws Exception { String newPassword = "pwd123"; String oldPassword = ""; @@ -158,7 +163,6 @@ public class MeResourceTest { } @Test - @SubjectAware(username = "trillian", password = "secret") public void shouldMapExceptionFromManager() throws Exception { String newPassword = "pwd123"; String oldPassword = "secret"; diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MeToUserDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MeToUserDtoMapperTest.java deleted file mode 100644 index 4f40098da5..0000000000 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MeToUserDtoMapperTest.java +++ /dev/null @@ -1,135 +0,0 @@ -package sonia.scm.api.v2.resources; - -import org.apache.shiro.subject.Subject; -import org.apache.shiro.subject.support.SubjectThreadState; -import org.apache.shiro.util.ThreadContext; -import org.apache.shiro.util.ThreadState; -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import sonia.scm.user.User; -import sonia.scm.user.UserManager; - -import java.net.URI; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; -import static org.mockito.MockitoAnnotations.initMocks; - -public class MeToUserDtoMapperTest { - - private final URI baseUri = URI.create("http://example.com/base/"); - @SuppressWarnings("unused") // Is injected - private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri); - - @Mock - private UserManager userManager; - - @InjectMocks - private MeToUserDtoMapperImpl mapper; - - private final Subject subject = mock(Subject.class); - private final ThreadState subjectThreadState = new SubjectThreadState(subject); - - private URI expectedBaseUri; - private URI expectedUserBaseUri; - - @Before - public void init() { - initMocks(this); - when(userManager.getDefaultType()).thenReturn("xml"); - expectedBaseUri = baseUri.resolve(MeResource.ME_PATH_V2 + "/"); - expectedUserBaseUri = baseUri.resolve(UserRootResource.USERS_PATH_V2 + "/"); - subjectThreadState.bind(); - ThreadContext.bind(subject); - } - - @After - public void unbindSubject() { - ThreadContext.unbindSubject(); - } - - @Test - public void shouldMapTheUpdateLink() { - User user = createDefaultUser(); - when(subject.isPermitted("user:modify:abc")).thenReturn(true); - - UserDto userDto = mapper.map(user); - assertEquals("expected update link", expectedUserBaseUri.resolve("abc").toString(), userDto.getLinks().getLinkBy("update").get().getHref()); - - when(subject.isPermitted("user:modify:abc")).thenReturn(false); - userDto = mapper.map(user); - assertFalse("expected no update link", userDto.getLinks().getLinkBy("update").isPresent()); - } - - @Test - public void shouldMapTheSelfLink() { - User user = createDefaultUser(); - when(subject.isPermitted("user:modify:abc")).thenReturn(true); - - UserDto userDto = mapper.map(user); - assertEquals("expected self link", expectedBaseUri.toString(), userDto.getLinks().getLinkBy("self").get().getHref()); - - } - - @Test - public void shouldMapTheDeleteLink() { - User user = createDefaultUser(); - when(subject.isPermitted("user:delete:abc")).thenReturn(true); - - UserDto userDto = mapper.map(user); - assertEquals("expected update link", expectedUserBaseUri.resolve("abc").toString(), userDto.getLinks().getLinkBy("delete").get().getHref()); - - when(subject.isPermitted("user:delete:abc")).thenReturn(false); - userDto = mapper.map(user); - assertFalse("expected no delete link", userDto.getLinks().getLinkBy("delete").isPresent()); - } - - @Test - public void shouldGetPasswordLinkOnlyForDefaultUserType() { - User user = createDefaultUser(); - when(subject.isPermitted("user:modify:abc")).thenReturn(true); - when(userManager.isTypeDefault(eq(user))).thenReturn(true); - - UserDto userDto = mapper.map(user); - - assertEquals("expected password link with modify permission", expectedBaseUri.resolve("password").toString(), userDto.getLinks().getLinkBy("password").get().getHref()); - - when(subject.isPermitted("user:modify:abc")).thenReturn(false); - userDto = mapper.map(user); - assertEquals("expected password link on mission modify permission", expectedBaseUri.resolve("password").toString(), userDto.getLinks().getLinkBy("password").get().getHref()); - - when(userManager.isTypeDefault(eq(user))).thenReturn(false); - - userDto = mapper.map(user); - - assertFalse("expected no password link", userDto.getLinks().getLinkBy("password").isPresent()); - } - - - @Test - public void shouldGetEmptyPasswordProperty() { - User user = createDefaultUser(); - user.setPassword("myHighSecurePassword"); - when(subject.isPermitted("user:modify:abc")).thenReturn(true); - - UserDto userDto = mapper.map(user); - - assertThat(userDto.getPassword()).as("hide password for the me resource").isBlank(); - } - - private User createDefaultUser() { - User user = new User(); - user.setName("abc"); - user.setCreationDate(1L); - return user; - } - - -} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MergeResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MergeResourceTest.java new file mode 100644 index 0000000000..d47ff35e5f --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MergeResourceTest.java @@ -0,0 +1,150 @@ +package sonia.scm.api.v2.resources; + +import com.google.common.io.Resources; +import com.google.inject.util.Providers; +import org.jboss.resteasy.core.Dispatcher; +import org.jboss.resteasy.mock.MockHttpRequest; +import org.jboss.resteasy.mock.MockHttpResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.repository.NamespaceAndName; +import sonia.scm.repository.api.MergeCommandBuilder; +import sonia.scm.repository.api.MergeCommandResult; +import sonia.scm.repository.api.MergeDryRunCommandResult; +import sonia.scm.repository.api.RepositoryService; +import sonia.scm.repository.api.RepositoryServiceFactory; +import sonia.scm.repository.spi.MergeCommand; +import sonia.scm.web.VndMediaType; + +import java.net.URL; + +import static java.util.Arrays.asList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class MergeResourceTest extends RepositoryTestBase { + + public static final String MERGE_URL = "/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo/merge/"; + + private Dispatcher dispatcher; + @Mock + private RepositoryServiceFactory serviceFactory; + @Mock + private RepositoryService repositoryService; + @Mock + private MergeCommand mergeCommand; + @InjectMocks + private MergeCommandBuilder mergeCommandBuilder; + private MergeResultToDtoMapperImpl mapper = new MergeResultToDtoMapperImpl(); + + private MergeResource mergeResource; + + @BeforeEach + void init() { + mergeResource = new MergeResource(serviceFactory, mapper); + super.mergeResource = Providers.of(mergeResource); + dispatcher = DispatcherMock.createDispatcher(getRepositoryRootResource()); + } + + @Test + void shouldHandleIllegalInput() throws Exception { + URL url = Resources.getResource("sonia/scm/api/v2/mergeCommand_invalid.json"); + byte[] mergeCommandJson = Resources.toByteArray(url); + + MockHttpRequest request = MockHttpRequest + .post(MERGE_URL + "dry-run/") + .content(mergeCommandJson) + .contentType(VndMediaType.MERGE_COMMAND); + MockHttpResponse response = new MockHttpResponse(); + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(400); + System.out.println(response.getContentAsString()); + } + + @Nested + class ExecutingMergeCommand { + + @BeforeEach + void initRepository() { + when(serviceFactory.create(new NamespaceAndName("space", "repo"))).thenReturn(repositoryService); + when(repositoryService.getMergeCommand()).thenReturn(mergeCommandBuilder); + } + + @Test + void shouldHandleSuccessfulMerge() throws Exception { + when(mergeCommand.merge(any())).thenReturn(MergeCommandResult.success()); + + URL url = Resources.getResource("sonia/scm/api/v2/mergeCommand.json"); + byte[] mergeCommandJson = Resources.toByteArray(url); + + MockHttpRequest request = MockHttpRequest + .post(MERGE_URL) + .content(mergeCommandJson) + .contentType(VndMediaType.MERGE_COMMAND); + MockHttpResponse response = new MockHttpResponse(); + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(204); + } + + @Test + void shouldHandleFailedMerge() throws Exception { + when(mergeCommand.merge(any())).thenReturn(MergeCommandResult.failure(asList("file1", "file2"))); + + URL url = Resources.getResource("sonia/scm/api/v2/mergeCommand.json"); + byte[] mergeCommandJson = Resources.toByteArray(url); + + MockHttpRequest request = MockHttpRequest + .post(MERGE_URL) + .content(mergeCommandJson) + .contentType(VndMediaType.MERGE_COMMAND); + MockHttpResponse response = new MockHttpResponse(); + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(409); + assertThat(response.getContentAsString()).contains("file1", "file2"); + } + + @Test + void shouldHandleSuccessfulDryRun() throws Exception { + when(mergeCommand.dryRun(any())).thenReturn(new MergeDryRunCommandResult(true)); + + URL url = Resources.getResource("sonia/scm/api/v2/mergeCommand.json"); + byte[] mergeCommandJson = Resources.toByteArray(url); + + MockHttpRequest request = MockHttpRequest + .post(MERGE_URL + "dry-run/") + .content(mergeCommandJson) + .contentType(VndMediaType.MERGE_COMMAND); + MockHttpResponse response = new MockHttpResponse(); + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(204); + } + + @Test + void shouldHandleFailedDryRun() throws Exception { + when(mergeCommand.dryRun(any())).thenReturn(new MergeDryRunCommandResult(false)); + + URL url = Resources.getResource("sonia/scm/api/v2/mergeCommand.json"); + byte[] mergeCommandJson = Resources.toByteArray(url); + + MockHttpRequest request = MockHttpRequest + .post(MERGE_URL + "dry-run/") + .content(mergeCommandJson) + .contentType(VndMediaType.MERGE_COMMAND); + MockHttpResponse response = new MockHttpResponse(); + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(409); + } + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/NoBlankStringsValidatorTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/NoBlankStringsValidatorTest.java new file mode 100644 index 0000000000..1929b0bb06 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/NoBlankStringsValidatorTest.java @@ -0,0 +1,28 @@ +package sonia.scm.api.v2.resources; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; + +import static java.util.Collections.emptySet; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class NoBlankStringsValidatorTest { + + @Test + void shouldAcceptNonEmptyElements() { + assertTrue(new NoBlankStringsValidator().isValid(Arrays.asList("not", "empty"), null)); + } + + @Test + void shouldFailForEmptyElements() { + assertFalse(new NoBlankStringsValidator().isValid(Arrays.asList("one", "", "three"), null)); + } + + @Test + void shouldAcceptEmptyList() { + assertTrue(new NoBlankStringsValidator().isValid(emptySet(), null)); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PermissionRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryPermissionRootResourceTest.java similarity index 74% rename from scm-webapp/src/test/java/sonia/scm/api/v2/resources/PermissionRootResourceTest.java rename to scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryPermissionRootResourceTest.java index c008e0b8db..4472acb2c5 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PermissionRootResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryPermissionRootResourceTest.java @@ -1,5 +1,6 @@ package sonia.scm.api.v2.resources; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.github.sdorra.shiro.ShiroRule; import com.github.sdorra.shiro.SubjectAware; @@ -29,13 +30,13 @@ import org.junit.jupiter.api.TestFactory; import org.mockito.InjectMocks; import org.mockito.Mock; import sonia.scm.repository.NamespaceAndName; -import sonia.scm.repository.Permission; -import sonia.scm.repository.PermissionType; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryManager; +import sonia.scm.repository.RepositoryPermission; import sonia.scm.web.VndMediaType; import java.io.IOException; +import java.io.UnsupportedEncodingException; import java.net.URI; import java.net.URISyntaxException; import java.util.ArrayList; @@ -47,18 +48,20 @@ import java.util.stream.Stream; import static de.otto.edison.hal.Link.link; import static de.otto.edison.hal.Links.linkingTo; +import static java.util.Arrays.asList; +import static java.util.Collections.singletonList; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.Assert.assertEquals; import static org.junit.Assert.fail; import static org.junit.jupiter.api.DynamicTest.dynamicTest; -import static org.mockito.Matchers.any; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import static org.mockito.MockitoAnnotations.initMocks; import static sonia.scm.api.v2.resources.DispatcherMock.createDispatcher; -import static sonia.scm.api.v2.resources.PermissionDto.GROUP_PREFIX; +import static sonia.scm.api.v2.resources.RepositoryPermissionDto.GROUP_PREFIX; @Slf4j @SubjectAware( @@ -66,7 +69,7 @@ import static sonia.scm.api.v2.resources.PermissionDto.GROUP_PREFIX; password = "secret", configuration = "classpath:sonia/scm/repository/shiro.ini" ) -public class PermissionRootResourceTest extends RepositoryTestBase { +public class RepositoryPermissionRootResourceTest extends RepositoryTestBase { private static final String REPOSITORY_NAMESPACE = "repo_namespace"; private static final String REPOSITORY_NAME = "repo"; private static final String PERMISSION_WRITE = "repository:permissionWrite:" + REPOSITORY_NAME; @@ -76,15 +79,15 @@ public class PermissionRootResourceTest extends RepositoryTestBase { private static final String PERMISSION_NAME = "perm"; private static final String PATH_OF_ALL_PERMISSIONS = REPOSITORY_NAMESPACE + "/" + REPOSITORY_NAME + "/permissions/"; private static final String PATH_OF_ONE_PERMISSION = PATH_OF_ALL_PERMISSIONS + PERMISSION_NAME; - private static final String PERMISSION_TEST_PAYLOAD = "{ \"name\" : \"permission_name\", \"type\" : \"READ\" }"; - private static final ArrayList<Permission> TEST_PERMISSIONS = Lists + private static final String PERMISSION_TEST_PAYLOAD = "{ \"name\" : \"permission_name\", \"verbs\" : [\"read\",\"pull\"] }"; + private static final ArrayList<RepositoryPermission> TEST_PERMISSIONS = Lists .newArrayList( - new Permission("user_write", PermissionType.WRITE, false), - new Permission("user_read", PermissionType.READ, false), - new Permission("user_owner", PermissionType.OWNER, false), - new Permission("group_read", PermissionType.READ, true), - new Permission("group_write", PermissionType.WRITE, true), - new Permission("group_owner", PermissionType.OWNER, true) + new RepositoryPermission("user_write", asList("read","modify"), false), + new RepositoryPermission("user_read", singletonList("read"), false), + new RepositoryPermission("user_owner", singletonList("*"), false), + new RepositoryPermission("group_read", singletonList("read"), true), + new RepositoryPermission("group_write", asList("read","modify"), true), + new RepositoryPermission("group_owner", singletonList("*"), true) ); private final ExpectedRequest requestGETAllPermissions = new ExpectedRequest() .description("GET all permissions") @@ -121,14 +124,14 @@ public class PermissionRootResourceTest extends RepositoryTestBase { private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri); @InjectMocks - private PermissionToPermissionDtoMapperImpl permissionToPermissionDtoMapper; + private RepositoryPermissionToRepositoryPermissionDtoMapperImpl permissionToPermissionDtoMapper; @InjectMocks - private PermissionDtoToPermissionMapperImpl permissionDtoToPermissionMapper; + private RepositoryPermissionDtoToRepositoryPermissionMapperImpl permissionDtoToPermissionMapper; - private PermissionCollectionToDtoMapper permissionCollectionToDtoMapper; + private RepositoryPermissionCollectionToDtoMapper repositoryPermissionCollectionToDtoMapper; - private PermissionRootResource permissionRootResource; + private RepositoryPermissionRootResource repositoryPermissionRootResource; private final Subject subject = mock(Subject.class); private final ThreadState subjectThreadState = new SubjectThreadState(subject); @@ -137,9 +140,9 @@ public class PermissionRootResourceTest extends RepositoryTestBase { @Before public void prepareEnvironment() { initMocks(this); - permissionCollectionToDtoMapper = new PermissionCollectionToDtoMapper(permissionToPermissionDtoMapper, resourceLinks); - permissionRootResource = new PermissionRootResource(permissionDtoToPermissionMapper, permissionToPermissionDtoMapper, permissionCollectionToDtoMapper, resourceLinks, repositoryManager); - super.permissionRootResource = Providers.of(permissionRootResource); + repositoryPermissionCollectionToDtoMapper = new RepositoryPermissionCollectionToDtoMapper(permissionToPermissionDtoMapper, resourceLinks); + repositoryPermissionRootResource = new RepositoryPermissionRootResource(permissionDtoToPermissionMapper, permissionToPermissionDtoMapper, repositoryPermissionCollectionToDtoMapper, resourceLinks, repositoryManager); + super.permissionRootResource = Providers.of(repositoryPermissionRootResource); dispatcher = createDispatcher(getRepositoryRootResource()); subjectThreadState.bind(); ThreadContext.bind(subject); @@ -207,16 +210,21 @@ public class PermissionRootResourceTest extends RepositoryTestBase { @Test public void shouldGetPermissionByName() throws URISyntaxException { createUserWithRepositoryAndPermissions(TEST_PERMISSIONS, PERMISSION_READ); - Permission expectedPermission = TEST_PERMISSIONS.get(0); + RepositoryPermission expectedPermission = TEST_PERMISSIONS.get(0); assertExpectedRequest(requestGETPermission .expectedResponseStatus(200) .path(PATH_OF_ALL_PERMISSIONS + expectedPermission.getName()) .responseValidator((response) -> { - String body = response.getContentAsString(); + String body = null; + try { + body = response.getContentAsString(); + } catch (UnsupportedEncodingException e) { + e.printStackTrace(); + } ObjectMapper mapper = new ObjectMapper(); try { - PermissionDto actualPermissionDto = mapper.readValue(body, PermissionDto.class); - assertThat(actualPermissionDto) + RepositoryPermissionDto actualRepositoryPermissionDto = mapper.readValue(body, RepositoryPermissionDto.class); + assertThat(actualRepositoryPermissionDto) .as("response payload match permission object model") .isEqualToComparingFieldByFieldRecursively(getExpectedPermissionDto(expectedPermission, PERMISSION_READ)) ; @@ -232,11 +240,11 @@ public class PermissionRootResourceTest extends RepositoryTestBase { public void shouldGet400OnCreatingNewPermissionWithNotAllowedCharacters() throws URISyntaxException { // the @ character at the begin of the name is not allowed createUserWithRepository("user"); - String permissionJson = "{ \"name\": \"@permission\", \"type\": \"OWNER\" }"; + String permissionJson = "{ \"name\": \"@permission\", \"verbs\": [\"*\"] }"; MockHttpRequest request = MockHttpRequest .post("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + PATH_OF_ALL_PERMISSIONS) .content(permissionJson.getBytes()) - .contentType(VndMediaType.PERMISSION); + .contentType(VndMediaType.REPOSITORY_PERMISSION); MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -244,11 +252,11 @@ public class PermissionRootResourceTest extends RepositoryTestBase { assertEquals(400, response.getStatus()); // the whitespace at the begin opf the name is not allowed - permissionJson = "{ \"name\": \" permission\", \"type\": \"OWNER\" }"; + permissionJson = "{ \"name\": \" permission\", \"verbs\": [\"*\"] }"; request = MockHttpRequest .post("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + PATH_OF_ALL_PERMISSIONS) .content(permissionJson.getBytes()) - .contentType(VndMediaType.PERMISSION); + .contentType(VndMediaType.REPOSITORY_PERMISSION); response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -259,26 +267,34 @@ public class PermissionRootResourceTest extends RepositoryTestBase { @Test public void shouldGetCreatedPermissions() throws URISyntaxException { createUserWithRepositoryAndPermissions(TEST_PERMISSIONS, PERMISSION_WRITE); - Permission newPermission = new Permission("new_group_perm", PermissionType.WRITE, true); - ArrayList<Permission> permissions = Lists.newArrayList(TEST_PERMISSIONS); + RepositoryPermission newPermission = new RepositoryPermission("new_group_perm", asList("read", "pull", "push"), true); + ArrayList<RepositoryPermission> permissions = Lists.newArrayList(TEST_PERMISSIONS); permissions.add(newPermission); - ImmutableList<Permission> expectedPermissions = ImmutableList.copyOf(permissions); + ImmutableList<RepositoryPermission> expectedPermissions = ImmutableList.copyOf(permissions); assertExpectedRequest(requestPOSTPermission - .content("{\"name\" : \"" + newPermission.getName() + "\" , \"type\" : \"WRITE\" , \"groupPermission\" : true}") + .content("{\"name\" : \"" + newPermission.getName() + "\" , \"verbs\" : [\"read\",\"pull\",\"push\"], \"groupPermission\" : true}") .expectedResponseStatus(201) - .responseValidator(response -> assertThat(response.getContentAsString()) + .responseValidator(response -> assertThat(getContentAsString(response)) .as("POST response has no body") .isBlank()) ); assertGettingExpectedPermissions(expectedPermissions, PERMISSION_WRITE); } + private String getContentAsString(MockHttpResponse response) { + try { + return response.getContentAsString(); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException("could not get content from response", e); + } + } + @Test public void shouldNotAddExistingPermission() throws URISyntaxException { createUserWithRepositoryAndPermissions(TEST_PERMISSIONS, PERMISSION_WRITE); - Permission newPermission = TEST_PERMISSIONS.get(0); + RepositoryPermission newPermission = TEST_PERMISSIONS.get(0); assertExpectedRequest(requestPOSTPermission - .content("{\"name\" : \"" + newPermission.getName() + "\" , \"type\" : \"WRITE\" , \"groupPermission\" : false}") + .content("{\"name\" : \"" + newPermission.getName() + "\" , \"verbs\" : [\"read\",\"pull\",\"push\"], \"groupPermission\" : false}") .expectedResponseStatus(409) ); } @@ -286,15 +302,15 @@ public class PermissionRootResourceTest extends RepositoryTestBase { @Test public void shouldGetUpdatedPermissions() throws URISyntaxException { createUserWithRepositoryAndPermissions(TEST_PERMISSIONS, PERMISSION_WRITE); - Permission modifiedPermission = TEST_PERMISSIONS.get(0); + RepositoryPermission modifiedPermission = TEST_PERMISSIONS.get(0); // modify the type to owner - modifiedPermission.setType(PermissionType.OWNER); - ImmutableList<Permission> expectedPermissions = ImmutableList.copyOf(TEST_PERMISSIONS); + modifiedPermission.setVerbs(new ArrayList<>(singletonList("*"))); + ImmutableList<RepositoryPermission> expectedPermissions = ImmutableList.copyOf(TEST_PERMISSIONS); assertExpectedRequest(requestPUTPermission - .content("{\"name\" : \"" + modifiedPermission.getName() + "\" , \"type\" : \"OWNER\" , \"groupPermission\" : false}") + .content("{\"name\" : \"" + modifiedPermission.getName() + "\" , \"verbs\" : [\"*\"], \"groupPermission\" : false}") .path(PATH_OF_ALL_PERMISSIONS + modifiedPermission.getName()) .expectedResponseStatus(204) - .responseValidator(response -> assertThat(response.getContentAsString()) + .responseValidator(response -> assertThat(getContentAsString(response)) .as("PUT response has no body") .isBlank()) ); @@ -305,12 +321,12 @@ public class PermissionRootResourceTest extends RepositoryTestBase { @Test public void shouldDeletePermissions() throws URISyntaxException { createUserWithRepositoryAndPermissions(TEST_PERMISSIONS, PERMISSION_OWNER); - Permission deletedPermission = TEST_PERMISSIONS.get(0); - ImmutableList<Permission> expectedPermissions = ImmutableList.copyOf(TEST_PERMISSIONS.subList(1, TEST_PERMISSIONS.size())); + RepositoryPermission deletedPermission = TEST_PERMISSIONS.get(0); + ImmutableList<RepositoryPermission> expectedPermissions = ImmutableList.copyOf(TEST_PERMISSIONS.subList(1, TEST_PERMISSIONS.size())); assertExpectedRequest(requestDELETEPermission .path(PATH_OF_ALL_PERMISSIONS + deletedPermission.getName()) .expectedResponseStatus(204) - .responseValidator(response -> assertThat(response.getContentAsString()) + .responseValidator(response -> assertThat(getContentAsString(response)) .as("DELETE response has no body") .isBlank()) ); @@ -320,12 +336,12 @@ public class PermissionRootResourceTest extends RepositoryTestBase { @Test public void deletingNotExistingPermissionShouldProcess() throws URISyntaxException { createUserWithRepositoryAndPermissions(TEST_PERMISSIONS, PERMISSION_OWNER); - Permission deletedPermission = TEST_PERMISSIONS.get(0); - ImmutableList<Permission> expectedPermissions = ImmutableList.copyOf(TEST_PERMISSIONS.subList(1, TEST_PERMISSIONS.size())); + RepositoryPermission deletedPermission = TEST_PERMISSIONS.get(0); + ImmutableList<RepositoryPermission> expectedPermissions = ImmutableList.copyOf(TEST_PERMISSIONS.subList(1, TEST_PERMISSIONS.size())); assertExpectedRequest(requestDELETEPermission .path(PATH_OF_ALL_PERMISSIONS + deletedPermission.getName()) .expectedResponseStatus(204) - .responseValidator(response -> assertThat(response.getContentAsString()) + .responseValidator(response -> assertThat(getContentAsString(response)) .as("DELETE response has no body") .isBlank()) ); @@ -333,32 +349,35 @@ public class PermissionRootResourceTest extends RepositoryTestBase { assertExpectedRequest(requestDELETEPermission .path(PATH_OF_ALL_PERMISSIONS + deletedPermission.getName()) .expectedResponseStatus(204) - .responseValidator(response -> assertThat(response.getContentAsString()) + .responseValidator(response -> assertThat(getContentAsString(response)) .as("DELETE response has no body") .isBlank()) ); assertGettingExpectedPermissions(expectedPermissions, PERMISSION_READ); } - private void assertGettingExpectedPermissions(ImmutableList<Permission> expectedPermissions, String userPermission) throws URISyntaxException { + private void assertGettingExpectedPermissions(ImmutableList<RepositoryPermission> expectedPermissions, String userPermission) throws URISyntaxException { assertExpectedRequest(requestGETAllPermissions .expectedResponseStatus(200) .responseValidator((response) -> { - String body = response.getContentAsString(); + String body = getContentAsString(response); ObjectMapper mapper = new ObjectMapper(); try { HalRepresentation halRepresentation = mapper.readValue(body, HalRepresentation.class); List<HalRepresentation> actualPermissionDtos = halRepresentation.getEmbedded().getItemsBy("permissions", HalRepresentation.class); - List<PermissionDto> permissionDtoStream = actualPermissionDtos.stream() + List<RepositoryPermissionDto> repositoryPermissionDtoStream = actualPermissionDtos.stream() .map(hal -> { - PermissionDto result = new PermissionDto(); + RepositoryPermissionDto result = new RepositoryPermissionDto(); result.setName(hal.getAttribute("name").asText()); - result.setType(hal.getAttribute("type").asText()); + JsonNode attribute = hal.getAttribute("verbs"); + List<String> verbs = new ArrayList<>(); + attribute.iterator().forEachRemaining(v -> verbs.add(v.asText())); + result.setVerbs(verbs); result.setGroupPermission(hal.getAttribute("groupPermission").asBoolean()); result.add(hal.getLinks()); return result; }).collect(Collectors.toList()); - assertThat(permissionDtoStream) + assertThat(repositoryPermissionDtoStream) .as("response payload match permission object models") .hasSize(expectedPermissions.size()) .usingRecursiveFieldByFieldElementComparator() @@ -371,18 +390,18 @@ public class PermissionRootResourceTest extends RepositoryTestBase { ); } - private PermissionDto[] getExpectedPermissionDtos(ArrayList<Permission> permissions, String userPermission) { + private RepositoryPermissionDto[] getExpectedPermissionDtos(ArrayList<RepositoryPermission> permissions, String userPermission) { return permissions .stream() .map(p -> getExpectedPermissionDto(p, userPermission)) - .toArray(PermissionDto[]::new); + .toArray(RepositoryPermissionDto[]::new); } - private PermissionDto getExpectedPermissionDto(Permission permission, String userPermission) { - PermissionDto result = new PermissionDto(); + private RepositoryPermissionDto getExpectedPermissionDto(RepositoryPermission permission, String userPermission) { + RepositoryPermissionDto result = new RepositoryPermissionDto(); result.setName(permission.getName()); result.setGroupPermission(permission.isGroupPermission()); - result.setType(permission.getType().name()); + result.setVerbs(permission.getVerbs()); String permissionName = Optional.of(permission.getName()) .filter(p -> !permission.isGroupPermission()) .orElse(GROUP_PREFIX + permission.getName()); @@ -411,7 +430,7 @@ public class PermissionRootResourceTest extends RepositoryTestBase { return mockRepository; } - private void createUserWithRepositoryAndPermissions(ArrayList<Permission> permissions, String userPermission) { + private void createUserWithRepositoryAndPermissions(ArrayList<RepositoryPermission> permissions, String userPermission) { createUserWithRepository(userPermission).setPermissions(permissions); } @@ -425,7 +444,7 @@ public class PermissionRootResourceTest extends RepositoryTestBase { HttpRequest request = MockHttpRequest .create(entry.method, "/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + entry.path) .content(entry.content) - .contentType(VndMediaType.PERMISSION); + .contentType(VndMediaType.REPOSITORY_PERMISSION); dispatcher.invoke(request, response); log.info("Test the Request :{}", entry); assertThat(response.getStatus()) diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PermissionToPermissionDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryPermissionToRepositoryPermissionDtoMapperTest.java similarity index 52% rename from scm-webapp/src/test/java/sonia/scm/api/v2/resources/PermissionToPermissionDtoMapperTest.java rename to scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryPermissionToRepositoryPermissionDtoMapperTest.java index 31c2f0ec31..97146e67ff 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PermissionToPermissionDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryPermissionToRepositoryPermissionDtoMapperTest.java @@ -7,19 +7,19 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.InjectMocks; import org.mockito.junit.MockitoJUnitRunner; -import sonia.scm.repository.Permission; -import sonia.scm.repository.PermissionType; +import sonia.scm.repository.RepositoryPermission; import sonia.scm.repository.Repository; import java.net.URI; +import static java.util.Arrays.asList; import static org.assertj.core.api.Assertions.assertThat; @RunWith(MockitoJUnitRunner.Silent.class) @SubjectAware( configuration = "classpath:sonia/scm/repository/shiro.ini" ) -public class PermissionToPermissionDtoMapperTest { +public class RepositoryPermissionToRepositoryPermissionDtoMapperTest { @Rule public ShiroRule shiro = new ShiroRule(); @@ -30,31 +30,31 @@ public class PermissionToPermissionDtoMapperTest { private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri); @InjectMocks - PermissionToPermissionDtoMapperImpl mapper; + RepositoryPermissionToRepositoryPermissionDtoMapperImpl mapper; @Test @SubjectAware(username = "trillian", password = "secret") public void shouldMapGroupPermissionCorrectly() { Repository repository = getDummyRepository(); - Permission permission = new Permission("42", PermissionType.OWNER, true); + RepositoryPermission permission = new RepositoryPermission("42", asList("read","modify","delete"), true); - PermissionDto permissionDto = mapper.map(permission, repository); + RepositoryPermissionDto repositoryPermissionDto = mapper.map(permission, repository); - assertThat(permissionDto.getLinks().getLinkBy("self").isPresent()).isTrue(); - assertThat(permissionDto.getLinks().getLinkBy("self").get().getHref()).contains("@42"); + assertThat(repositoryPermissionDto.getLinks().getLinkBy("self").isPresent()).isTrue(); + assertThat(repositoryPermissionDto.getLinks().getLinkBy("self").get().getHref()).contains("@42"); } @Test @SubjectAware(username = "trillian", password = "secret") public void shouldMapNonGroupPermissionCorrectly() { Repository repository = getDummyRepository(); - Permission permission = new Permission("42", PermissionType.OWNER, false); + RepositoryPermission permission = new RepositoryPermission("42", asList("read","modify","delete"), false); - PermissionDto permissionDto = mapper.map(permission, repository); + RepositoryPermissionDto repositoryPermissionDto = mapper.map(permission, repository); - assertThat(permissionDto.getLinks().getLinkBy("self").isPresent()).isTrue(); - assertThat(permissionDto.getLinks().getLinkBy("self").get().getHref()).contains("42"); - assertThat(permissionDto.getLinks().getLinkBy("self").get().getHref()).doesNotContain("@"); + assertThat(repositoryPermissionDto.getLinks().getLinkBy("self").isPresent()).isTrue(); + assertThat(repositoryPermissionDto.getLinks().getLinkBy("self").get().getHref()).contains("42"); + assertThat(repositoryPermissionDto.getLinks().getLinkBy("self").get().getHref()).doesNotContain("@"); } private Repository getDummyRepository() { diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryRootResourceTest.java index fe403088f2..1f6ed6b3a7 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryRootResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryRootResourceTest.java @@ -6,7 +6,6 @@ import com.google.common.io.Resources; import com.google.inject.util.Providers; import org.apache.shiro.subject.SimplePrincipalCollection; import org.apache.shiro.subject.Subject; -import org.assertj.core.api.Assertions; import org.jboss.resteasy.core.Dispatcher; import org.jboss.resteasy.mock.MockHttpRequest; import org.jboss.resteasy.mock.MockHttpResponse; @@ -18,8 +17,6 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import sonia.scm.PageResult; import sonia.scm.repository.NamespaceAndName; -import sonia.scm.repository.Permission; -import sonia.scm.repository.PermissionType; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryIsNotArchivedException; import sonia.scm.repository.RepositoryManager; @@ -30,6 +27,7 @@ import sonia.scm.web.VndMediaType; import javax.servlet.http.HttpServletResponse; import java.io.IOException; +import java.io.UnsupportedEncodingException; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; @@ -41,15 +39,12 @@ import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND; import static javax.servlet.http.HttpServletResponse.SC_NO_CONTENT; import static javax.servlet.http.HttpServletResponse.SC_OK; import static javax.servlet.http.HttpServletResponse.SC_PRECONDITION_FAILED; -import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; -import static org.mockito.ArgumentCaptor.forClass; -import static org.mockito.Matchers.any; -import static org.mockito.Matchers.anyObject; -import static org.mockito.Matchers.eq; -import static org.mockito.Mockito.doNothing; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyObject; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -126,7 +121,7 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { } @Test - public void shouldFindExistingRepository() throws URISyntaxException { + public void shouldFindExistingRepository() throws URISyntaxException, UnsupportedEncodingException { mockRepository("space", "repo"); MockHttpRequest request = MockHttpRequest.get("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo"); @@ -139,7 +134,7 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { } @Test - public void shouldMapProperties() throws URISyntaxException { + public void shouldMapProperties() throws URISyntaxException, UnsupportedEncodingException { Repository repository = mockRepository("space", "repo"); repository.setProperty("testKey", "testValue"); @@ -152,7 +147,7 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { } @Test - public void shouldGetAll() throws URISyntaxException { + public void shouldGetAll() throws URISyntaxException, UnsupportedEncodingException { PageResult<Repository> singletonPageResult = createSingletonPageResult(mockRepository("space", "repo")); when(repositoryManager.getPage(any(), eq(0), eq(10))).thenReturn(singletonPageResult); @@ -291,36 +286,14 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { dispatcher.invoke(request, response); - Assertions.assertThat(createCaptor.getValue().getPermissions()) + assertThat(createCaptor.getValue().getPermissions()) .hasSize(1) .allSatisfy(p -> { assertThat(p.getName()).isEqualTo("trillian"); - assertThat(p.getType()).isEqualTo(PermissionType.OWNER); + assertThat(p.getVerbs()).containsExactly("*"); }); } - @Test - public void shouldNotOverwriteExistingPermissionsOnUpdate() throws Exception { - Repository existingRepository = mockRepository("space", "repo"); - existingRepository.setPermissions(singletonList(new Permission("user", PermissionType.READ))); - - URL url = Resources.getResource("sonia/scm/api/v2/repository-test-update.json"); - byte[] repository = Resources.toByteArray(url); - - ArgumentCaptor<Repository> modifiedRepositoryCaptor = forClass(Repository.class); - doNothing().when(repositoryManager).modify(modifiedRepositoryCaptor.capture()); - - MockHttpRequest request = MockHttpRequest - .put("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo") - .contentType(VndMediaType.REPOSITORY) - .content(repository); - MockHttpResponse response = new MockHttpResponse(); - - dispatcher.invoke(request, response); - - assertFalse(modifiedRepositoryCaptor.getValue().getPermissions().isEmpty()); - } - @Test public void shouldCreateArrayOfProtocolUrls() throws Exception { mockRepository("space", "repo"); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTestBase.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTestBase.java index 3d3b28ae51..a8901e2d79 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTestBase.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTestBase.java @@ -16,11 +16,13 @@ public abstract class RepositoryTestBase { protected Provider<ChangesetRootResource> changesetRootResource; protected Provider<SourceRootResource> sourceRootResource; protected Provider<ContentResource> contentResource; - protected Provider<PermissionRootResource> permissionRootResource; + protected Provider<RepositoryPermissionRootResource> permissionRootResource; protected Provider<DiffRootResource> diffRootResource; protected Provider<ModificationsRootResource> modificationsRootResource; protected Provider<FileHistoryRootResource> fileHistoryRootResource; protected Provider<RepositoryCollectionResource> repositoryCollectionResource; + protected Provider<IncomingRootResource> incomingRootResource; + protected Provider<MergeResource> mergeResource; RepositoryRootResource getRepositoryRootResource() { @@ -36,7 +38,9 @@ public abstract class RepositoryTestBase { permissionRootResource, diffRootResource, modificationsRootResource, - fileHistoryRootResource)), repositoryCollectionResource); + fileHistoryRootResource, + incomingRootResource, + mergeResource)), repositoryCollectionResource); } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapperTest.java index 2e6048d6b8..4b02508ae8 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapperTest.java @@ -10,8 +10,6 @@ import org.junit.Test; import org.mockito.InjectMocks; import org.mockito.Mock; import sonia.scm.repository.HealthCheckFailure; -import sonia.scm.repository.Permission; -import sonia.scm.repository.PermissionType; import sonia.scm.repository.Repository; import sonia.scm.repository.api.Command; import sonia.scm.repository.api.RepositoryService; @@ -25,7 +23,7 @@ import static java.util.stream.Stream.of; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; -import static org.mockito.Matchers.any; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; import static org.mockito.MockitoAnnotations.initMocks; @@ -211,6 +209,19 @@ public class RepositoryToRepositoryDtoMapperTest { assertTrue(dto.getLinks().getLinksBy("protocol").isEmpty()); } + @Test + public void shouldAppendLinks() { + HalEnricherRegistry registry = new HalEnricherRegistry(); + registry.register(Repository.class, (ctx, appender) -> { + Repository repository = ctx.oneRequireByType(Repository.class); + appender.appendLink("id", "http://" + repository.getId()); + }); + mapper.setRegistry(registry); + + RepositoryDto dto = mapper.map(createTestRepository()); + assertEquals("http://1", dto.getLinks().getLinkBy("id").get().getHref()); + } + private ScmProtocol mockProtocol(String type, String protocol) { return new MockScmProtocol(type, protocol); } @@ -225,7 +236,6 @@ public class RepositoryToRepositoryDtoMapperTest { repository.setId("1"); repository.setCreationDate(System.currentTimeMillis()); repository.setHealthCheckFailures(singletonList(new HealthCheckFailure("1", "summary", "url", "failure"))); - repository.setPermissions(singletonList(new Permission("permission", PermissionType.READ))); return repository; } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java index c2dc685306..8714496e1a 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java @@ -16,17 +16,20 @@ public class ResourceLinksMock { when(resourceLinks.user()).thenReturn(userLinks); when(resourceLinks.me()).thenReturn(new ResourceLinks.MeLinks(uriInfo,userLinks)); when(resourceLinks.userCollection()).thenReturn(new ResourceLinks.UserCollectionLinks(uriInfo)); + when(resourceLinks.userPermissions()).thenReturn(new ResourceLinks.UserPermissionLinks(uriInfo)); when(resourceLinks.autoComplete()).thenReturn(new ResourceLinks.AutoCompleteLinks(uriInfo)); when(resourceLinks.group()).thenReturn(new ResourceLinks.GroupLinks(uriInfo)); when(resourceLinks.groupCollection()).thenReturn(new ResourceLinks.GroupCollectionLinks(uriInfo)); + when(resourceLinks.groupPermissions()).thenReturn(new ResourceLinks.GroupPermissionLinks(uriInfo)); when(resourceLinks.repository()).thenReturn(new ResourceLinks.RepositoryLinks(uriInfo)); + when(resourceLinks.incoming()).thenReturn(new ResourceLinks.IncomingLinks(uriInfo)); when(resourceLinks.repositoryCollection()).thenReturn(new ResourceLinks.RepositoryCollectionLinks(uriInfo)); when(resourceLinks.tag()).thenReturn(new ResourceLinks.TagCollectionLinks(uriInfo)); when(resourceLinks.branchCollection()).thenReturn(new ResourceLinks.BranchCollectionLinks(uriInfo)); when(resourceLinks.changeset()).thenReturn(new ResourceLinks.ChangesetLinks(uriInfo)); when(resourceLinks.fileHistory()).thenReturn(new ResourceLinks.FileHistoryLinks(uriInfo)); when(resourceLinks.source()).thenReturn(new ResourceLinks.SourceLinks(uriInfo)); - when(resourceLinks.permission()).thenReturn(new ResourceLinks.PermissionLinks(uriInfo)); + when(resourceLinks.repositoryPermission()).thenReturn(new ResourceLinks.RepositoryPermissionLinks(uriInfo)); when(resourceLinks.config()).thenReturn(new ResourceLinks.ConfigLinks(uriInfo)); when(resourceLinks.branch()).thenReturn(new ResourceLinks.BranchLinks(uriInfo)); when(resourceLinks.diff()).thenReturn(new ResourceLinks.DiffLinks(uriInfo)); @@ -37,6 +40,9 @@ public class ResourceLinksMock { when(resourceLinks.uiPlugin()).thenReturn(new ResourceLinks.UIPluginLinks(uriInfo)); when(resourceLinks.authentication()).thenReturn(new ResourceLinks.AuthenticationLinks(uriInfo)); when(resourceLinks.index()).thenReturn(new ResourceLinks.IndexLinks(uriInfo)); + when(resourceLinks.merge()).thenReturn(new ResourceLinks.MergeLinks(uriInfo)); + when(resourceLinks.permissions()).thenReturn(new ResourceLinks.PermissionsLinks(uriInfo)); + when(resourceLinks.availableRepositoryPermissions()).thenReturn(new ResourceLinks.AvailableRepositoryPermissionLinks(uriInfo)); return resourceLinks; } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/TagToTagDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/TagToTagDtoMapperTest.java new file mode 100644 index 0000000000..cd3c18de27 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/TagToTagDtoMapperTest.java @@ -0,0 +1,37 @@ +package sonia.scm.api.v2.resources; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.repository.NamespaceAndName; +import sonia.scm.repository.Tag; + +import java.net.URI; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +class TagToTagDtoMapperTest { + + @SuppressWarnings("unused") // Is injected + private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(URI.create("https://hitchhiker.com")); + + @InjectMocks + private TagToTagDtoMapperImpl mapper; + + @Test + void shouldAppendLinks() { + HalEnricherRegistry registry = new HalEnricherRegistry(); + registry.register(Tag.class, (ctx, appender) -> { + NamespaceAndName repository = ctx.oneRequireByType(NamespaceAndName.class); + Tag tag = ctx.oneRequireByType(Tag.class); + appender.appendLink("yo", "http://" + repository.logString() + "/" + tag.getName()); + }); + mapper.setRegistry(registry); + + TagDto dto = mapper.map(new Tag("1.0.0", "42"), new NamespaceAndName("hitchhiker", "hog")); + assertThat(dto.getLinks().getLinkBy("yo").get().getHref()).isEqualTo("http://hitchhiker/hog/1.0.0"); + } + +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UIRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UIRootResourceTest.java index 99a1435923..b2dafc8cfe 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UIRootResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UIRootResourceTest.java @@ -16,6 +16,7 @@ import sonia.scm.plugin.*; import sonia.scm.web.VndMediaType; import javax.servlet.http.HttpServletRequest; +import java.io.UnsupportedEncodingException; import java.net.URI; import java.net.URISyntaxException; import java.util.HashSet; @@ -87,7 +88,7 @@ public class UIRootResourceTest { } @Test - public void shouldReturnPlugin() throws URISyntaxException { + public void shouldReturnPlugin() throws URISyntaxException, UnsupportedEncodingException { mockPlugins(mockPlugin("awesome", "Awesome", createPluginResources("my/awesome.bundle.js"))); MockHttpRequest request = MockHttpRequest.get("/v2/ui/plugins/awesome"); @@ -101,7 +102,7 @@ public class UIRootResourceTest { } @Test - public void shouldReturnPlugins() throws URISyntaxException { + public void shouldReturnPlugins() throws URISyntaxException, UnsupportedEncodingException { mockPlugins( mockPlugin("awesome", "Awesome", createPluginResources("my/awesome.bundle.js")), mockPlugin("special", "Special", createPluginResources("my/special.bundle.js")) @@ -120,7 +121,7 @@ public class UIRootResourceTest { } @Test - public void shouldNotReturnPluginsWithoutResources() throws URISyntaxException { + public void shouldNotReturnPluginsWithoutResources() throws URISyntaxException, UnsupportedEncodingException { mockPlugins( mockPlugin("awesome", "Awesome", createPluginResources("my/awesome.bundle.js")), mockPlugin("special") diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserDtoToUserMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserDtoToUserMapperTest.java index 19f247b3b2..552009b73f 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserDtoToUserMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserDtoToUserMapperTest.java @@ -10,7 +10,6 @@ import sonia.scm.user.User; import java.time.Instant; import static org.junit.Assert.assertEquals; -import static org.mockito.Mockito.when; import static org.mockito.MockitoAnnotations.initMocks; public class UserDtoToUserMapperTest { diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserRootResourceTest.java index 284e7d1d7b..4047dfadd2 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserRootResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserRootResourceTest.java @@ -17,22 +17,26 @@ import org.mockito.Mock; import sonia.scm.ContextEntry; import sonia.scm.NotFoundException; import sonia.scm.PageResult; +import sonia.scm.security.PermissionAssigner; +import sonia.scm.security.PermissionDescriptor; import sonia.scm.user.ChangePasswordNotAllowedException; import sonia.scm.user.User; import sonia.scm.user.UserManager; import sonia.scm.web.VndMediaType; import javax.servlet.http.HttpServletResponse; +import java.io.UnsupportedEncodingException; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; +import java.util.Collection; import static java.util.Collections.singletonList; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; -import static org.mockito.Matchers.any; -import static org.mockito.Matchers.eq; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; @@ -59,16 +63,20 @@ public class UserRootResourceTest { private PasswordService passwordService; @Mock private UserManager userManager; + @Mock + private PermissionAssigner permissionAssigner; @InjectMocks private UserDtoToUserMapperImpl dtoToUserMapper; @InjectMocks private UserToUserDtoMapperImpl userToDtoMapper; + @InjectMocks + private PermissionCollectionToDtoMapper permissionCollectionToDtoMapper; private ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class); private User originalUser; @Before - public void prepareEnvironment() throws Exception { + public void prepareEnvironment() { initMocks(this); originalUser = createDummyUser("Neo"); when(userManager.create(userCaptor.capture())).thenAnswer(invocation -> invocation.getArguments()[0]); @@ -80,7 +88,8 @@ public class UserRootResourceTest { UserCollectionToDtoMapper userCollectionToDtoMapper = new UserCollectionToDtoMapper(userToDtoMapper, resourceLinks); UserCollectionResource userCollectionResource = new UserCollectionResource(userManager, dtoToUserMapper, userCollectionToDtoMapper, resourceLinks, passwordService); - UserResource userResource = new UserResource(dtoToUserMapper, userToDtoMapper, userManager, passwordService); + UserPermissionResource userPermissionResource = new UserPermissionResource(permissionAssigner, permissionCollectionToDtoMapper); + UserResource userResource = new UserResource(dtoToUserMapper, userToDtoMapper, userManager, passwordService, userPermissionResource); UserRootResource userRootResource = new UserRootResource(Providers.of(userCollectionResource), Providers.of(userResource)); @@ -88,7 +97,7 @@ public class UserRootResourceTest { } @Test - public void shouldCreateFullResponseForAdmin() throws URISyntaxException { + public void shouldCreateFullResponseForAdmin() throws URISyntaxException, UnsupportedEncodingException { MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "Neo"); MockHttpResponse response = new MockHttpResponse(); @@ -128,7 +137,7 @@ public class UserRootResourceTest { @Test @SubjectAware(username = "unpriv") - public void shouldCreateLimitedResponseForSimpleUser() throws URISyntaxException { + public void shouldCreateLimitedResponseForSimpleUser() throws URISyntaxException, UnsupportedEncodingException { MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "Neo"); MockHttpResponse response = new MockHttpResponse(); @@ -322,7 +331,7 @@ public class UserRootResourceTest { } @Test - public void shouldCreatePageForOnePageOnly() throws URISyntaxException { + public void shouldCreatePageForOnePageOnly() throws URISyntaxException, UnsupportedEncodingException { PageResult<User> singletonPageResult = createSingletonPageResult(1); when(userManager.getPage(any(), eq(0), eq(10))).thenReturn(singletonPageResult); MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2); @@ -330,8 +339,6 @@ public class UserRootResourceTest { dispatcher.invoke(request, response); - System.out.println(response.getContentAsString()); - assertEquals(HttpServletResponse.SC_OK, response.getStatus()); assertTrue(response.getContentAsString().contains("\"name\":\"Neo\"")); assertTrue(response.getContentAsString().contains("\"self\":{\"href\":\"/v2/users/?page=0")); @@ -340,7 +347,7 @@ public class UserRootResourceTest { } @Test - public void shouldCreatePageForMultiplePages() throws URISyntaxException { + public void shouldCreatePageForMultiplePages() throws URISyntaxException, UnsupportedEncodingException { PageResult<User> singletonPageResult = createSingletonPageResult(3); when(userManager.getPage(any(), eq(1), eq(1))).thenReturn(singletonPageResult); MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "?page=1&pageSize=1"); @@ -348,8 +355,6 @@ public class UserRootResourceTest { dispatcher.invoke(request, response); - System.out.println(response.getContentAsString()); - assertEquals(HttpServletResponse.SC_OK, response.getStatus()); assertTrue(response.getContentAsString().contains("\"name\":\"Neo\"")); assertTrue(response.getContentAsString().contains("\"self\":{\"href\":\"/v2/users/?page=1")); @@ -359,6 +364,48 @@ public class UserRootResourceTest { assertTrue(response.getContentAsString().contains("\"last\":{\"href\":\"/v2/users/?page=2")); } + @Test + public void shouldGetPermissionLink() throws URISyntaxException, UnsupportedEncodingException { + MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "Neo"); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(HttpServletResponse.SC_OK, response.getStatus()); + + assertTrue(response.getContentAsString().contains("\"permissions\":{")); + } + + @Test + public void shouldGetPermissions() throws URISyntaxException, UnsupportedEncodingException { + when(permissionAssigner.readPermissionsForUser("Neo")).thenReturn(singletonList(new PermissionDescriptor("something:*"))); + MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "Neo/permissions"); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(HttpServletResponse.SC_OK, response.getStatus()); + + assertTrue(response.getContentAsString().contains("\"permissions\":[\"something:*\"]")); + } + + @Test + public void shouldSetPermissions() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest + .put("/" + UserRootResource.USERS_PATH_V2 + "Neo/permissions") + .contentType(VndMediaType.PERMISSION_COLLECTION) + .content("{\"permissions\":[\"other:*\"]}".getBytes()); + MockHttpResponse response = new MockHttpResponse(); + ArgumentCaptor<Collection<PermissionDescriptor>> captor = ArgumentCaptor.forClass(Collection.class); + doNothing().when(permissionAssigner).setPermissionsForUser(eq("Neo"), captor.capture()); + + dispatcher.invoke(request, response); + + assertEquals(HttpServletResponse.SC_NO_CONTENT, response.getStatus()); + + assertEquals("other:*", captor.getValue().iterator().next().getValue()); + } + private PageResult<User> createSingletonPageResult(int overallCount) { return new PageResult<>(singletonList(createDummyUser("Neo")), overallCount); } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserToUserDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserToUserDtoMapperTest.java index dfff933f19..ae1d75dddf 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserToUserDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserToUserDtoMapperTest.java @@ -11,6 +11,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import sonia.scm.user.User; import sonia.scm.user.UserManager; +import sonia.scm.user.UserTestData; import java.net.URI; import java.time.Instant; @@ -149,4 +150,17 @@ public class UserToUserDtoMapperTest { assertEquals(expectedCreationDate, userDto.getCreationDate()); assertEquals(expectedModificationDate, userDto.getLastModified()); } + + @Test + public void shouldAppendLink() { + User trillian = UserTestData.createTrillian(); + + HalEnricherRegistry registry = new HalEnricherRegistry(); + registry.register(User.class, (ctx, appender) -> appender.appendLink("sample", "http://" + ctx.oneByType(User.class).get().getName())); + mapper.setRegistry(registry); + + UserDto userDto = mapper.map(trillian); + + assertEquals("http://trillian", userDto.getLinks().getLinkBy("sample").get().getHref()); + } } diff --git a/scm-webapp/src/test/java/sonia/scm/boot/RestartServletTest.java b/scm-webapp/src/test/java/sonia/scm/boot/RestartServletTest.java index b8b538c82b..eac4a12340 100644 --- a/scm-webapp/src/test/java/sonia/scm/boot/RestartServletTest.java +++ b/scm-webapp/src/test/java/sonia/scm/boot/RestartServletTest.java @@ -2,7 +2,6 @@ package sonia.scm.boot; import com.github.legman.Subscribe; import com.google.common.base.Charsets; -import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mock; diff --git a/scm-webapp/src/test/java/sonia/scm/boot/ServletContextCleanerTest.java b/scm-webapp/src/test/java/sonia/scm/boot/ServletContextCleanerTest.java index a26cf3b215..c9d8c594b4 100644 --- a/scm-webapp/src/test/java/sonia/scm/boot/ServletContextCleanerTest.java +++ b/scm-webapp/src/test/java/sonia/scm/boot/ServletContextCleanerTest.java @@ -13,7 +13,6 @@ import java.util.Enumeration; import java.util.Set; import java.util.Vector; -import static org.junit.Assert.*; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; diff --git a/scm-webapp/src/test/java/sonia/scm/it/GitLfsITCase.java b/scm-webapp/src/test/java/sonia/scm/it/GitLfsITCase.java index c55a33c39a..a367d171a1 100644 --- a/scm-webapp/src/test/java/sonia/scm/it/GitLfsITCase.java +++ b/scm-webapp/src/test/java/sonia/scm/it/GitLfsITCase.java @@ -50,7 +50,6 @@ import sonia.scm.api.rest.ObjectMapperProvider; import sonia.scm.api.v2.resources.RepositoryDto; import sonia.scm.api.v2.resources.UserDto; import sonia.scm.api.v2.resources.UserToUserDtoMapperImpl; -import sonia.scm.repository.PermissionType; import sonia.scm.user.User; import sonia.scm.user.UserTestData; import sonia.scm.util.HttpUtil; @@ -117,10 +116,6 @@ public class GitLfsITCase { @Test public void testLfsAPIWithOwnerPermissions() throws IOException { - uploadAndDownloadAsUser(PermissionType.OWNER); - } - - private void uploadAndDownloadAsUser(PermissionType permissionType) throws IOException { User trillian = UserTestData.createTrillian(); trillian.setPassword("secret123"); createUser(trillian); @@ -129,8 +124,8 @@ public class GitLfsITCase { String permissionsUrl = repository.getLinks().getLinkBy("permissions").get().getHref(); IntegrationTestUtil.createResource(adminClient, URI.create(permissionsUrl)) .accept("*/*") - .type(VndMediaType.PERMISSION) - .post(ClientResponse.class, "{\"name\": \""+ trillian.getId() +"\", \"type\":\"WRITE\"}"); + .type(VndMediaType.REPOSITORY_PERMISSION) + .post(ClientResponse.class, "{\"name\": \""+ trillian.getId() +"\", \"verbs\":[\"*\"]}"); ScmClient client = new ScmClient(trillian.getId(), "secret123"); @@ -140,16 +135,14 @@ public class GitLfsITCase { } } - @Test - public void testLfsAPIWithWritePermissions() throws IOException { - uploadAndDownloadAsUser(PermissionType.WRITE); - } - private void createUser(User user) { - UserDto dto = new UserToUserDtoMapperImpl(){ - @Override - protected void appendLinks(User user, UserDto target) {} - }.map(user); + UserDto dto = new UserDto(); + dto.setName(user.getName()); + dto.setMail(user.getMail()); + dto.setDisplayName(user.getDisplayName()); + dto.setType(user.getType()); + dto.setActive(user.isActive()); + dto.setAdmin(user.isAdmin()); dto.setPassword(user.getPassword()); createResource(adminClient, "users") .accept("*/*") @@ -175,8 +168,8 @@ public class GitLfsITCase { String permissionsUrl = repository.getLinks().getLinkBy("permissions").get().getHref(); IntegrationTestUtil.createResource(adminClient, URI.create(permissionsUrl)) .accept("*/*") - .type(VndMediaType.PERMISSION) - .post(ClientResponse.class, "{\"name\": \""+ trillian.getId() +"\", \"type\":\"READ\"}"); + .type(VndMediaType.REPOSITORY_PERMISSION) + .post(ClientResponse.class, "{\"name\": \""+ trillian.getId() +"\", \"verbs\":[\"read\"]}"); ScmClient client = new ScmClient(trillian.getId(), "secret123"); uploadAndDownload(client); @@ -196,8 +189,8 @@ public class GitLfsITCase { String permissionsUrl = repository.getLinks().getLinkBy("permissions").get().getHref(); IntegrationTestUtil.createResource(adminClient, URI.create(permissionsUrl)) .accept("*/*") - .type(VndMediaType.PERMISSION) - .post(ClientResponse.class, "{\"name\": \""+ trillian.getId() +"\", \"type\":\"READ\"}"); + .type(VndMediaType.REPOSITORY_PERMISSION) + .post(ClientResponse.class, "{\"name\": \""+ trillian.getId() +"\", \"verbs\":[\"read\",\"pull\"]}"); // upload data as admin String data = UUID.randomUUID().toString(); diff --git a/scm-webapp/src/test/java/sonia/scm/net/ahc/DefaultAdvancedHttpResponseTest.java b/scm-webapp/src/test/java/sonia/scm/net/ahc/DefaultAdvancedHttpResponseTest.java index a60c884b64..399f20cd3f 100644 --- a/scm-webapp/src/test/java/sonia/scm/net/ahc/DefaultAdvancedHttpResponseTest.java +++ b/scm-webapp/src/test/java/sonia/scm/net/ahc/DefaultAdvancedHttpResponseTest.java @@ -41,6 +41,7 @@ import com.google.common.collect.Maps; import com.google.common.collect.Multimap; import com.google.common.io.ByteSource; +import org.hamcrest.Matchers; import org.junit.Test; import org.junit.runner.RunWith; @@ -49,8 +50,6 @@ import org.mockito.junit.MockitoJUnitRunner; import sonia.scm.config.ScmConfiguration; -import static org.hamcrest.Matchers.*; - import static org.junit.Assert.*; import static org.mockito.Mockito.*; @@ -136,7 +135,7 @@ public class DefaultAdvancedHttpResponseTest connection, 200, "OK"); Multimap<String, String> headers = response.getHeaders(); - assertThat(headers.get("Test"), contains("One", "Two")); + assertThat(headers.get("Test"), Matchers.contains("One", "Two")); assertTrue(headers.get("Test-2").isEmpty()); } @@ -144,8 +143,7 @@ public class DefaultAdvancedHttpResponseTest /** Field description */ private final DefaultAdvancedHttpClient client = - new DefaultAdvancedHttpClient(new ScmConfiguration(), - new HashSet<ContentTransformer>(), new SSLContextProvider()); + new DefaultAdvancedHttpClient(new ScmConfiguration(), new HashSet<>(), new SSLContextProvider()); /** Field description */ @Mock diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/MultiParentClassLoaderTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/MultiParentClassLoaderTest.java index ae65f5c1ae..df31977de1 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/MultiParentClassLoaderTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/MultiParentClassLoaderTest.java @@ -29,9 +29,6 @@ package sonia.scm.plugin; -import com.google.common.base.Enums; -import com.google.common.collect.Iterables; -import com.google.common.collect.Iterators; import java.io.IOException; import java.net.URL; import java.util.Arrays; diff --git a/scm-webapp/src/test/java/sonia/scm/repository/DefaultRepositoryManagerPerfTest.java b/scm-webapp/src/test/java/sonia/scm/repository/DefaultRepositoryManagerPerfTest.java index 9d03fa02ca..f3ff9fd0f9 100644 --- a/scm-webapp/src/test/java/sonia/scm/repository/DefaultRepositoryManagerPerfTest.java +++ b/scm-webapp/src/test/java/sonia/scm/repository/DefaultRepositoryManagerPerfTest.java @@ -111,7 +111,6 @@ public class DefaultRepositoryManagerPerfTest { public void setUpObjectUnderTest(){ when(repositoryHandler.getType()).thenReturn(new RepositoryType(REPOSITORY_TYPE, REPOSITORY_TYPE, Sets.newHashSet())); Set<RepositoryHandler> handlerSet = ImmutableSet.of(repositoryHandler); - RepositoryMatcher repositoryMatcher = new RepositoryMatcher(Collections.<RepositoryPathMatcher>emptySet()); NamespaceStrategy namespaceStrategy = mock(NamespaceStrategy.class); repositoryManager = new DefaultRepositoryManager( configuration, @@ -138,7 +137,7 @@ public class DefaultRepositoryManagerPerfTest { /** * Start performance test and ensure that the timeout is not reached. */ - @Test(timeout = 6000l) + @Test(timeout = 6000L) public void perfTestGetAll(){ SecurityUtils.getSubject().login(new UsernamePasswordToken("trillian", "secret")); @@ -155,7 +154,7 @@ public class DefaultRepositoryManagerPerfTest { } private long calculateAverage(List<Long> times) { - Long sum = 0l; + Long sum = 0L; if(!times.isEmpty()) { for (Long time : times) { sum += time; @@ -183,9 +182,8 @@ private long calculateAverage(List<Long> times) { } private Repository createTestRepository(int number) { - Repository repository = new Repository(keyGenerator.createKey(), REPOSITORY_TYPE, "namespace", "repo-" + number); - repository.addPermission(new Permission("trillian", PermissionType.READ)); - return repository; + return new Repository(keyGenerator.createKey(), REPOSITORY_TYPE, "namespace", "repo-" + number); + } static class DummyRealm extends AuthorizingRealm { @@ -207,7 +205,7 @@ private long calculateAverage(List<Long> times) { @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { - return authzCollector.collect(); + return authzCollector.collect(principals); } } diff --git a/scm-webapp/src/test/java/sonia/scm/repository/DefaultRepositoryManagerTest.java b/scm-webapp/src/test/java/sonia/scm/repository/DefaultRepositoryManagerTest.java index 2b5aeb7f53..416babfab0 100644 --- a/scm-webapp/src/test/java/sonia/scm/repository/DefaultRepositoryManagerTest.java +++ b/scm-webapp/src/test/java/sonia/scm/repository/DefaultRepositoryManagerTest.java @@ -39,6 +39,7 @@ import com.google.common.collect.ImmutableSet; import com.google.common.collect.Sets; import org.apache.shiro.authz.UnauthorizedException; import org.apache.shiro.util.ThreadContext; +import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; @@ -49,8 +50,10 @@ import sonia.scm.HandlerEventType; import sonia.scm.Manager; import sonia.scm.ManagerTestBase; import sonia.scm.NotFoundException; +import sonia.scm.SCMContext; import sonia.scm.config.ScmConfiguration; import sonia.scm.event.ScmEventBus; +import sonia.scm.io.DefaultFileSystem; import sonia.scm.repository.api.HookContext; import sonia.scm.repository.api.HookContextFactory; import sonia.scm.repository.api.HookFeature; @@ -66,20 +69,9 @@ import java.util.HashSet; import java.util.Set; import java.util.Stack; -import static org.hamcrest.Matchers.containsInAnyOrder; -import static org.hamcrest.Matchers.hasProperty; -import static org.hamcrest.Matchers.is; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNotSame; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertThat; -import static org.junit.Assert.assertTrue; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.when; +import static org.hamcrest.Matchers.*; +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; //~--- JDK imports ------------------------------------------------------------ @@ -109,6 +101,11 @@ public class DefaultRepositoryManagerTest extends ManagerTestBase<Repository> { private String mockedNamespace = "default_namespace"; + @Before + public void initContext() { + ((TempSCMContextProvider)SCMContext.getContext()).setBaseDirectory(temp); + } + @Test public void testCreate() { Repository heartOfGold = createTestRepository(); @@ -419,23 +416,26 @@ public class DefaultRepositoryManagerTest extends ManagerTestBase<Repository> { } private DefaultRepositoryManager createRepositoryManager(boolean archiveEnabled, KeyGenerator keyGenerator) { + DefaultFileSystem fileSystem = new DefaultFileSystem(); Set<RepositoryHandler> handlerSet = new HashSet<>(); - ConfigurationStoreFactory factory = new JAXBConfigurationStoreFactory(contextProvider); - handlerSet.add(new DummyRepositoryHandler(factory)); - handlerSet.add(new DummyRepositoryHandler(factory) { + InitialRepositoryLocationResolver initialRepositoryLocationResolver = new InitialRepositoryLocationResolver(); + XmlRepositoryDAO repositoryDAO = new XmlRepositoryDAO(contextProvider, initialRepositoryLocationResolver, fileSystem); + RepositoryLocationResolver repositoryLocationResolver = new RepositoryLocationResolver(contextProvider, repositoryDAO, initialRepositoryLocationResolver); + ConfigurationStoreFactory factory = new JAXBConfigurationStoreFactory(contextProvider, repositoryLocationResolver); + handlerSet.add(new DummyRepositoryHandler(factory, repositoryLocationResolver)); + handlerSet.add(new DummyRepositoryHandler(factory, repositoryLocationResolver) { @Override public RepositoryType getType() { return new RepositoryType("hg", "Mercurial", Sets.newHashSet()); } }); - handlerSet.add(new DummyRepositoryHandler(factory) { + handlerSet.add(new DummyRepositoryHandler(factory, repositoryLocationResolver) { @Override public RepositoryType getType() { return new RepositoryType("git", "Git", Sets.newHashSet()); } }); - XmlRepositoryDAO repositoryDAO = new XmlRepositoryDAO(factory); this.configuration = new ScmConfiguration(); diff --git a/scm-webapp/src/test/java/sonia/scm/schedule/QuartzTaskTest.java b/scm-webapp/src/test/java/sonia/scm/schedule/QuartzTaskTest.java index efaeb702fe..baf4c659cc 100644 --- a/scm-webapp/src/test/java/sonia/scm/schedule/QuartzTaskTest.java +++ b/scm-webapp/src/test/java/sonia/scm/schedule/QuartzTaskTest.java @@ -32,12 +32,11 @@ package sonia.scm.schedule; import org.junit.Test; -import static org.junit.Assert.*; + import static org.mockito.Mockito.*; -import static org.hamcrest.Matchers.*; + import org.junit.Before; import org.junit.runner.RunWith; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; import org.quartz.JobKey; diff --git a/scm-webapp/src/test/java/sonia/scm/security/AuthorizationChangedEventProducerTest.java b/scm-webapp/src/test/java/sonia/scm/security/AuthorizationChangedEventProducerTest.java index b45e0d72e9..59b6951025 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/AuthorizationChangedEventProducerTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/AuthorizationChangedEventProducerTest.java @@ -32,23 +32,30 @@ package sonia.scm.security; import com.google.common.collect.Lists; -import org.junit.Test; -import static org.junit.Assert.*; import org.junit.Before; +import org.junit.Test; import sonia.scm.HandlerEventType; import sonia.scm.group.Group; import sonia.scm.group.GroupEvent; import sonia.scm.group.GroupModificationEvent; -import sonia.scm.repository.PermissionType; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryEvent; import sonia.scm.repository.RepositoryModificationEvent; +import sonia.scm.repository.RepositoryPermission; import sonia.scm.repository.RepositoryTestData; import sonia.scm.user.User; import sonia.scm.user.UserEvent; import sonia.scm.user.UserModificationEvent; import sonia.scm.user.UserTestData; +import static java.util.Arrays.asList; +import static java.util.Collections.singletonList; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + /** * Unit tests for {@link AuthorizationChangedEventProducer}. * @@ -86,7 +93,12 @@ public class AuthorizationChangedEventProducerTest { assertTrue(producer.event.isEveryUserAffected()); assertEquals(username, producer.event.getNameOfAffectedUser()); } - + + private void assertGlobalEventIsFired(){ + assertNotNull(producer.event); + assertFalse(producer.event.isEveryUserAffected()); + } + /** * Tests {@link AuthorizationChangedEventProducer#onEvent(sonia.scm.user.UserEvent)} with modified user. */ @@ -126,11 +138,6 @@ public class AuthorizationChangedEventProducerTest { assertGlobalEventIsFired(); } - private void assertGlobalEventIsFired(){ - assertNotNull(producer.event); - assertFalse(producer.event.isEveryUserAffected()); - } - /** * Tests {@link AuthorizationChangedEventProducer#onEvent(sonia.scm.group.GroupEvent)} with modified groups. */ @@ -173,40 +180,49 @@ public class AuthorizationChangedEventProducerTest { { Repository repositoryModified = RepositoryTestData.createHeartOfGold(); repositoryModified.setName("test123"); - repositoryModified.setPermissions(Lists.newArrayList(new sonia.scm.repository.Permission("test"))); - + repositoryModified.setPermissions(Lists.newArrayList(new RepositoryPermission("test", singletonList("read"), false))); + Repository repository = RepositoryTestData.createHeartOfGold(); - repository.setPermissions(Lists.newArrayList(new sonia.scm.repository.Permission("test"))); - + repository.setPermissions(Lists.newArrayList(new RepositoryPermission("test", singletonList("read"), false))); + producer.onEvent(new RepositoryModificationEvent(HandlerEventType.BEFORE_CREATE, repositoryModified, repository)); assertEventIsNotFired(); - + producer.onEvent(new RepositoryModificationEvent(HandlerEventType.CREATE, repositoryModified, repository)); assertEventIsNotFired(); - - repositoryModified.setPermissions(Lists.newArrayList(new sonia.scm.repository.Permission("test"))); + + repositoryModified.setPermissions(Lists.newArrayList(new RepositoryPermission("test", singletonList("read"), false))); producer.onEvent(new RepositoryModificationEvent(HandlerEventType.CREATE, repositoryModified, repository)); assertEventIsNotFired(); - - repositoryModified.setPermissions(Lists.newArrayList(new sonia.scm.repository.Permission("test123"))); + + repositoryModified.setPermissions(Lists.newArrayList(new RepositoryPermission("test123", singletonList("read"), false))); producer.onEvent(new RepositoryModificationEvent(HandlerEventType.CREATE, repositoryModified, repository)); assertGlobalEventIsFired(); - + resetStoredEvent(); repositoryModified.setPermissions( - Lists.newArrayList(new sonia.scm.repository.Permission("test", PermissionType.READ, true)) + Lists.newArrayList(new RepositoryPermission("test", singletonList("read"), true)) ); producer.onEvent(new RepositoryModificationEvent(HandlerEventType.CREATE, repositoryModified, repository)); assertGlobalEventIsFired(); - + resetStoredEvent(); - + repositoryModified.setPermissions( - Lists.newArrayList(new sonia.scm.repository.Permission("test", PermissionType.WRITE)) + Lists.newArrayList(new RepositoryPermission("test", asList("read", "write"), false)) ); producer.onEvent(new RepositoryModificationEvent(HandlerEventType.CREATE, repositoryModified, repository)); assertGlobalEventIsFired(); + + resetStoredEvent(); + repository.setPermissions(Lists.newArrayList(new RepositoryPermission("test", asList("read", "write"), false))); + + repositoryModified.setPermissions( + Lists.newArrayList(new RepositoryPermission("test", asList("write", "read"), false)) + ); + producer.onEvent(new RepositoryModificationEvent(HandlerEventType.CREATE, repositoryModified, repository)); + assertEventIsNotFired(); } private void resetStoredEvent(){ @@ -214,7 +230,7 @@ public class AuthorizationChangedEventProducerTest { } /** - * Tests {@link AuthorizationChangedEventProducer#onEvent(sonia.scm.security.StoredAssignedPermissionEvent)}. + * Tests {@link AuthorizationChangedEventProducer#onEvent(AssignedPermissionEvent)}. */ @Test public void testOnStoredAssignedPermissionEvent() @@ -222,10 +238,10 @@ public class AuthorizationChangedEventProducerTest { StoredAssignedPermission groupPermission = new StoredAssignedPermission( "123", new AssignedPermission("_authenticated", true, "repository:read:*") ); - producer.onEvent(new StoredAssignedPermissionEvent(HandlerEventType.BEFORE_CREATE, groupPermission)); + producer.onEvent(new AssignedPermissionEvent(HandlerEventType.BEFORE_CREATE, groupPermission)); assertEventIsNotFired(); - producer.onEvent(new StoredAssignedPermissionEvent(HandlerEventType.CREATE, groupPermission)); + producer.onEvent(new AssignedPermissionEvent(HandlerEventType.CREATE, groupPermission)); assertGlobalEventIsFired(); resetStoredEvent(); @@ -233,12 +249,12 @@ public class AuthorizationChangedEventProducerTest { StoredAssignedPermission userPermission = new StoredAssignedPermission( "123", new AssignedPermission("trillian", false, "repository:read:*") ); - producer.onEvent(new StoredAssignedPermissionEvent(HandlerEventType.BEFORE_CREATE, userPermission)); + producer.onEvent(new AssignedPermissionEvent(HandlerEventType.BEFORE_CREATE, userPermission)); assertEventIsNotFired(); resetStoredEvent(); - producer.onEvent(new StoredAssignedPermissionEvent(HandlerEventType.CREATE, userPermission)); + producer.onEvent(new AssignedPermissionEvent(HandlerEventType.CREATE, userPermission)); assertUserEventIsFired("trillian"); } @@ -253,4 +269,4 @@ public class AuthorizationChangedEventProducerTest { } -} \ No newline at end of file +} diff --git a/scm-webapp/src/test/java/sonia/scm/security/BearerRealmTest.java b/scm-webapp/src/test/java/sonia/scm/security/BearerRealmTest.java index e6061e61a1..c2d75358fd 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/BearerRealmTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/BearerRealmTest.java @@ -29,47 +29,25 @@ * */ - - package sonia.scm.security; -//~--- non-JDK imports -------------------------------------------------------- - -import com.google.common.collect.ImmutableList; -import com.google.common.collect.Sets; -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.JwsHeader; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.SignatureAlgorithm; -import org.apache.shiro.authc.AuthenticationException; +import com.google.common.collect.ImmutableSet; import org.apache.shiro.authc.AuthenticationInfo; import org.apache.shiro.authc.UsernamePasswordToken; -import org.apache.shiro.subject.PrincipalCollection; -import org.hamcrest.Matchers; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.ExpectedException; -import org.junit.runner.RunWith; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.Mockito; -import org.mockito.junit.MockitoJUnitRunner; -import sonia.scm.group.GroupDAO; -import sonia.scm.user.User; -import sonia.scm.user.UserDAO; -import sonia.scm.user.UserTestData; +import org.mockito.junit.jupiter.MockitoExtension; -import javax.crypto.spec.SecretKeySpec; -import java.security.SecureRandom; -import java.util.Date; +import java.util.HashMap; import java.util.Set; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertThat; -import static org.junit.Assert.assertTrue; -import static org.mockito.Mockito.any; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; /** @@ -77,232 +55,57 @@ import static org.mockito.Mockito.when; * * @author Sebastian Sdorra */ -@SuppressWarnings("unchecked") -@RunWith(MockitoJUnitRunner.class) -public class BearerRealmTest -{ - - @Rule - public ExpectedException expectedException = ExpectedException.none(); +@ExtendWith(MockitoExtension.class) +class BearerRealmTest { - /** - * Method description - * - */ - @Test - public void testDoGetAuthenticationInfo() - { - SecureKey key = createSecureKey(); + @Mock + private DAORealmHelperFactory realmHelperFactory; - User marvin = UserTestData.createMarvin(); + @Mock + private DAORealmHelper realmHelper; - when(userDAO.get(marvin.getName())).thenReturn(marvin); + @Mock + private DAORealmHelper.AuthenticationInfoBuilder builder; - resolveKey(key); - - String compact = createCompactToken(marvin.getName(), key); - - BearerToken token = BearerToken.valueOf(compact); - AuthenticationInfo info = realm.doGetAuthenticationInfo(token); - - assertNotNull(info); - - PrincipalCollection principals = info.getPrincipals(); - - assertEquals(marvin.getName(), principals.getPrimaryPrincipal()); - assertEquals(marvin, principals.oneByType(User.class)); - assertNotNull(principals.oneByType(Scope.class)); - assertTrue(principals.oneByType(Scope.class).isEmpty()); - } - - /** - * Test {@link BearerRealm#doGetAuthenticationInfo(AuthenticationToken)} with scope. - * - */ - @Test - public void testDoGetAuthenticationInfoWithScope() - { - SecureKey key = createSecureKey(); - - User marvin = UserTestData.createMarvin(); - - when(userDAO.get(marvin.getName())).thenReturn(marvin); - - resolveKey(key); - - String compact = createCompactToken( - marvin.getName(), - key, - new Date(System.currentTimeMillis() + 60000), - Scope.valueOf("repo:*", "user:*") - ); - - AuthenticationInfo info = realm.doGetAuthenticationInfo(BearerToken.valueOf(compact)); - Scope scope = info.getPrincipals().oneByType(Scope.class); - assertThat(scope, Matchers.containsInAnyOrder("repo:*", "user:*")); - } - - /** - * Test {@link BearerRealm#doGetAuthenticationInfo(AuthenticationToken)} with a failed - * claims validation. - */ - @Test - public void testDoGetAuthenticationInfoWithInvalidClaims() - { - SecureKey key = createSecureKey(); - User marvin = UserTestData.createMarvin(); - - resolveKey(key); - - String compact = createCompactToken(marvin.getName(), key); - - // treat claims as invalid - when(validator.validate(Mockito.anyMap())).thenReturn(false); - - // expect exception - expectedException.expect(AuthenticationException.class); - expectedException.expectMessage(Matchers.containsString("claims")); - - // kick authentication - realm.doGetAuthenticationInfo(BearerToken.valueOf(compact)); - } - - /** - * Method description - * - */ - @Test(expected = AuthenticationException.class) - public void testDoGetAuthenticationInfoWithExpiredToken() - { - User trillian = UserTestData.createTrillian(); - - SecureKey key = createSecureKey(); - - resolveKey(key); - - Date exp = new Date(System.currentTimeMillis() - 600l); - String compact = createCompactToken(trillian.getName(), key, exp, Scope.empty()); - - realm.doGetAuthenticationInfo(BearerToken.valueOf(compact)); - } - - /** - * Method description - * - */ - @Test(expected = AuthenticationException.class) - public void testDoGetAuthenticationInfoWithInvalidSignature() - { - resolveKey(createSecureKey()); - - User trillian = UserTestData.createTrillian(); - String compact = createCompactToken(trillian.getName(), createSecureKey()); - - realm.doGetAuthenticationInfo(BearerToken.valueOf(compact)); - } - - /** - * Method description - * - */ - @Test(expected = AuthenticationException.class) - public void testDoGetAuthenticationInfoWithoutSignature() - { - String compact = Jwts.builder().setSubject("test").compact(); - - realm.doGetAuthenticationInfo(BearerToken.valueOf(compact)); - } - - /** - * Method description - * - */ - @Test(expected = IllegalArgumentException.class) - public void testDoGetAuthenticationInfoWrongToken() - { - realm.doGetAuthenticationInfo(new UsernamePasswordToken("test", "test")); - } - - //~--- set methods ---------------------------------------------------------- - - /** - * Method description - * - */ - @Before - public void setUp() - { - when(validator.validate(Mockito.anyMap())).thenReturn(true); - Set<TokenClaimsValidator> validators = Sets.newHashSet(validator); - realm = new BearerRealm(helperFactory, keyResolver, validators); - } - - //~--- methods -------------------------------------------------------------- - -private String createCompactToken(String subject, SecureKey key) { - return createCompactToken(subject, key, Scope.empty()); - } - - private String createCompactToken(String subject, SecureKey key, Scope scope) { - return createCompactToken(subject, key, new Date(System.currentTimeMillis() + 60000), scope); - } - - private String createCompactToken(String subject, SecureKey key, Date exp, Scope scope) { - return Jwts.builder() - .claim(Scopes.CLAIMS_KEY, ImmutableList.copyOf(scope)) - .setSubject(subject) - .setExpiration(exp) - .signWith(SignatureAlgorithm.HS256, key.getBytes()) - .compact(); - } - - private SecureKey createSecureKey() { - byte[] bytes = new byte[32]; - random.nextBytes(bytes); - return new SecureKey(bytes, System.currentTimeMillis()); - } - - private void resolveKey(SecureKey key) { - when( - keyResolver.resolveSigningKey( - any(JwsHeader.class), - any(Claims.class) - ) - ) - .thenReturn( - new SecretKeySpec( - key.getBytes(), - SignatureAlgorithm.HS256.getValue() - ) - ); - } - - //~--- fields --------------------------------------------------------------- - - /** Field description */ - private final SecureRandom random = new SecureRandom(); + @Mock + private AccessTokenResolver accessTokenResolver; @InjectMocks - private DAORealmHelperFactory helperFactory; - - @Mock - private LoginAttemptHandler loginAttemptHandler; - - @Mock - private TokenClaimsValidator validator; - - /** Field description */ - @Mock - private GroupDAO groupDAO; - - /** Field description */ - @Mock - private SecureKeyResolver keyResolver; - - /** Field description */ private BearerRealm realm; - /** Field description */ @Mock - private UserDAO userDAO; + private AuthenticationInfo authenticationInfo; + + @BeforeEach + void prepareObjectUnderTest() { + when(realmHelperFactory.create(BearerRealm.REALM)).thenReturn(realmHelper); + realm = new BearerRealm(realmHelperFactory, accessTokenResolver); + } + + @Test + void shouldDoGetAuthentication() { + BearerToken bearerToken = BearerToken.valueOf("__bearer__"); + AccessToken accessToken = mock(AccessToken.class); + + Set<String> groups = ImmutableSet.of("HeartOfGold", "Puzzle42"); + + when(accessToken.getSubject()).thenReturn("trillian"); + when(accessToken.getGroups()).thenReturn(groups); + when(accessToken.getClaims()).thenReturn(new HashMap<>()); + when(accessTokenResolver.resolve(bearerToken)).thenReturn(accessToken); + + when(realmHelper.authenticationInfoBuilder("trillian")).thenReturn(builder); + when(builder.withGroups(groups)).thenReturn(builder); + when(builder.withCredentials("__bearer__")).thenReturn(builder); + when(builder.withScope(any(Scope.class))).thenReturn(builder); + when(builder.build()).thenReturn(authenticationInfo); + + AuthenticationInfo result = realm.doGetAuthenticationInfo(bearerToken); + assertThat(result).isSameAs(authenticationInfo); + } + + @Test + void shouldThrowIllegalArgumentExceptionForWrongTypeOfToken() { + assertThrows(IllegalArgumentException.class, () -> realm.doGetAuthenticationInfo(new UsernamePasswordToken())); + } } diff --git a/scm-webapp/src/test/java/sonia/scm/security/AccessTokenCookieIssuerTest.java b/scm-webapp/src/test/java/sonia/scm/security/DefaultAccessTokenCookieIssuerTest.java similarity index 93% rename from scm-webapp/src/test/java/sonia/scm/security/AccessTokenCookieIssuerTest.java rename to scm-webapp/src/test/java/sonia/scm/security/DefaultAccessTokenCookieIssuerTest.java index 03cf174226..9c80cfc67b 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/AccessTokenCookieIssuerTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/DefaultAccessTokenCookieIssuerTest.java @@ -20,11 +20,11 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @RunWith(MockitoJUnitRunner.class) -public class AccessTokenCookieIssuerTest { +public class DefaultAccessTokenCookieIssuerTest { private ScmConfiguration configuration; - private AccessTokenCookieIssuer issuer; + private DefaultAccessTokenCookieIssuer issuer; @Mock private HttpServletRequest request; @@ -41,7 +41,7 @@ public class AccessTokenCookieIssuerTest { @Before public void setUp() { configuration = new ScmConfiguration(); - issuer = new AccessTokenCookieIssuer(configuration); + issuer = new DefaultAccessTokenCookieIssuer(configuration); } @Test diff --git a/scm-webapp/src/test/java/sonia/scm/security/DefaultAuthorizationCollectorTest.java b/scm-webapp/src/test/java/sonia/scm/security/DefaultAuthorizationCollectorTest.java index 2f455469dd..e9345c9599 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/DefaultAuthorizationCollectorTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/DefaultAuthorizationCollectorTest.java @@ -33,7 +33,7 @@ package sonia.scm.security; import com.github.sdorra.shiro.ShiroRule; import com.github.sdorra.shiro.SubjectAware; -import com.google.common.base.Predicate; +import com.google.common.collect.ImmutableSet; import com.google.common.collect.Lists; import org.apache.shiro.authz.AuthorizationInfo; import org.apache.shiro.authz.SimpleAuthorizationInfo; @@ -49,14 +49,16 @@ import org.mockito.Mockito; import org.mockito.junit.MockitoJUnitRunner; import sonia.scm.cache.Cache; import sonia.scm.cache.CacheManager; +import sonia.scm.config.ScmConfiguration; import sonia.scm.group.GroupNames; -import sonia.scm.repository.PermissionType; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryDAO; +import sonia.scm.repository.RepositoryPermission; import sonia.scm.repository.RepositoryTestData; import sonia.scm.user.User; import sonia.scm.user.UserTestData; +import static java.util.Arrays.asList; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.nullValue; @@ -76,6 +78,8 @@ import static org.mockito.Mockito.when; @RunWith(MockitoJUnitRunner.class) public class DefaultAuthorizationCollectorTest { + private ScmConfiguration configuration; + @Mock private Cache cache; @@ -99,8 +103,38 @@ public class DefaultAuthorizationCollectorTest { @Before public void setUp(){ when(cacheManager.getCache(Mockito.any(String.class))).thenReturn(cache); + configuration = new ScmConfiguration(); + collector = new DefaultAuthorizationCollector(configuration, cacheManager, repositoryDAO, securitySystem); + } - collector = new DefaultAuthorizationCollector(cacheManager, repositoryDAO, securitySystem); + @Test + @SubjectAware( + configuration = "classpath:sonia/scm/shiro-001.ini" + ) + public void shouldGetAdminPrivilegedByConfiguration() { + configuration.setAdminUsers(ImmutableSet.of("trillian")); + authenticate(UserTestData.createTrillian(), "main"); + + AuthorizationInfo authInfo = collector.collect(); + assertIsAdmin(authInfo); + } + + private void assertIsAdmin(AuthorizationInfo authInfo) { + assertThat(authInfo.getRoles(), Matchers.containsInAnyOrder(Role.USER, Role.ADMIN)); + assertThat(authInfo.getObjectPermissions(), nullValue()); + assertThat(authInfo.getStringPermissions(), Matchers.contains("*")); + } + + @Test + @SubjectAware( + configuration = "classpath:sonia/scm/shiro-001.ini" + ) + public void shouldGetAdminPrivilegedByGroupConfiguration() { + configuration.setAdminGroups(ImmutableSet.of("heartOfGold")); + authenticate(UserTestData.createTrillian(), "heartOfGold"); + + AuthorizationInfo authInfo = collector.collect(); + assertIsAdmin(authInfo); } /** @@ -142,7 +176,7 @@ public class DefaultAuthorizationCollectorTest { public void testCollectWithCache() { authenticate(UserTestData.createTrillian(), "main"); - AuthorizationInfo authInfo = collector.collect(); + collector.collect(); verify(cache).put(any(), any()); } @@ -176,9 +210,7 @@ public class DefaultAuthorizationCollectorTest { authenticate(trillian, "main"); AuthorizationInfo authInfo = collector.collect(); - assertThat(authInfo.getRoles(), Matchers.containsInAnyOrder(Role.USER, Role.ADMIN)); - assertThat(authInfo.getObjectPermissions(), nullValue()); - assertThat(authInfo.getStringPermissions(), Matchers.contains("*")); + assertIsAdmin(authInfo); } /** @@ -193,10 +225,10 @@ public class DefaultAuthorizationCollectorTest { authenticate(UserTestData.createTrillian(), group); Repository heartOfGold = RepositoryTestData.createHeartOfGold(); heartOfGold.setId("one"); - heartOfGold.setPermissions(Lists.newArrayList(new sonia.scm.repository.Permission("trillian"))); + heartOfGold.setPermissions(Lists.newArrayList(new RepositoryPermission("trillian", asList("read", "pull"), false))); Repository puzzle42 = RepositoryTestData.create42Puzzle(); puzzle42.setId("two"); - sonia.scm.repository.Permission permission = new sonia.scm.repository.Permission(group, PermissionType.WRITE, true); + RepositoryPermission permission = new RepositoryPermission(group, asList("read", "pull", "push"), true); puzzle42.setPermissions(Lists.newArrayList(permission)); when(repositoryDAO.getAll()).thenReturn(Lists.newArrayList(heartOfGold, puzzle42)); @@ -219,7 +251,7 @@ public class DefaultAuthorizationCollectorTest { StoredAssignedPermission p1 = new StoredAssignedPermission("one", new AssignedPermission("one", "one:one")); StoredAssignedPermission p2 = new StoredAssignedPermission("two", new AssignedPermission("two", "two:two")); - when(securitySystem.getPermissions(Mockito.any(Predicate.class))).thenReturn(Lists.newArrayList(p1, p2)); + when(securitySystem.getPermissions(any())).thenReturn(Lists.newArrayList(p1, p2)); // execute and assert AuthorizationInfo authInfo = collector.collect(); @@ -238,7 +270,7 @@ public class DefaultAuthorizationCollectorTest { } /** - * Tests {@link AuthorizationCollector#invalidateCache(sonia.scm.security.AuthorizationChangedEvent)}. + * Tests {@link DefaultAuthorizationCollector#invalidateCache(sonia.scm.security.AuthorizationChangedEvent)}. */ @Test public void testInvalidateCache() { @@ -246,7 +278,7 @@ public class DefaultAuthorizationCollectorTest { verify(cache).clear(); collector.invalidateCache(AuthorizationChangedEvent.createForUser("dent")); - verify(cache).removeAll(Mockito.any(Predicate.class)); + verify(cache).removeAll(any()); } } diff --git a/scm-webapp/src/test/java/sonia/scm/security/DefaultRealmTest.java b/scm-webapp/src/test/java/sonia/scm/security/DefaultRealmTest.java index 02b1a6ed1b..b6fea9e897 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/DefaultRealmTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/DefaultRealmTest.java @@ -71,7 +71,11 @@ import static org.mockito.Mockito.*; //~--- JDK imports ------------------------------------------------------------ +import java.util.Collections; +import java.util.HashSet; import java.util.List; +import java.util.Set; + import org.apache.shiro.authz.AuthorizationInfo; import org.apache.shiro.authz.Permission; import org.apache.shiro.authz.SimpleAuthorizationInfo; @@ -132,6 +136,36 @@ public class DefaultRealmTest assertThat(realmsAutz.getStringPermissions(), Matchers.contains("repository:*")); } + @Test + public void testGetAuthorizationInfoWithMultipleAuthorizationCollectors(){ + SimplePrincipalCollection col = new SimplePrincipalCollection(); + col.add(Scope.empty(), DefaultRealm.REALM); + + SimpleAuthorizationInfo collectedFromDefault = new SimpleAuthorizationInfo(); + collectedFromDefault.addStringPermission("repository:*"); + when(collector.collect(col)).thenReturn(collectedFromDefault); + + SimpleAuthorizationInfo collectedFromSecond = new SimpleAuthorizationInfo(); + collectedFromSecond.addStringPermission("user:*"); + collectedFromSecond.addRole("awesome"); + + AuthorizationCollector secondCollector = principalCollection -> collectedFromSecond; + authorizationCollectors.add(secondCollector); + + SimpleAuthorizationInfo collectedFromThird = new SimpleAuthorizationInfo(); + Permission permission = p -> false; + collectedFromThird.addObjectPermission(permission); + collectedFromThird.addRole("awesome"); + + AuthorizationCollector thirdCollector = principalCollection -> collectedFromThird; + authorizationCollectors.add(thirdCollector); + + AuthorizationInfo realmsAuthz = realm.doGetAuthorizationInfo(col); + assertThat(realmsAuthz.getObjectPermissions(), contains(permission)); + assertThat(realmsAuthz.getStringPermissions(), containsInAnyOrder("repository:*", "user:*")); + assertThat(realmsAuthz.getRoles(), Matchers.contains("awesome")); + } + /** * Tests {@link DefaultRealm#doGetAuthorizationInfo(PrincipalCollection)} with empty scope. */ @@ -284,7 +318,11 @@ public class DefaultRealmTest // use a small number of iterations for faster test execution hashService.setHashIterations(512); service.setHashService(hashService); - realm = new DefaultRealm(service, collector, helperFactory); + + authorizationCollectors = new HashSet<>(); + authorizationCollectors.add(collector); + + realm = new DefaultRealm(service, authorizationCollectors, helperFactory); // set permission resolver realm.setPermissionResolver(new WildcardPermissionResolver()); @@ -358,6 +396,8 @@ public class DefaultRealmTest @Mock private DefaultAuthorizationCollector collector; + private Set<AuthorizationCollector> authorizationCollectors; + @Mock private LoginAttemptHandler loginAttemptHandler; diff --git a/scm-webapp/src/test/java/sonia/scm/security/DefaultSecuritySystemTest.java b/scm-webapp/src/test/java/sonia/scm/security/DefaultSecuritySystemTest.java index 95ad64b349..457bb96ae4 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/DefaultSecuritySystemTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/DefaultSecuritySystemTest.java @@ -32,28 +32,28 @@ package sonia.scm.security; -//~--- non-JDK imports -------------------------------------------------------- - -import com.google.common.base.Predicate; - +import com.google.common.base.Objects; import org.apache.shiro.authz.UnauthorizedException; import org.apache.shiro.mgt.DefaultSecurityManager; import org.apache.shiro.realm.SimpleAccountRealm; - import org.junit.Before; import org.junit.Test; - +import org.mockito.InjectMocks; +import org.mockito.MockitoAnnotations; import sonia.scm.AbstractTestBase; +import sonia.scm.plugin.PluginLoader; import sonia.scm.store.JAXBConfigurationEntryStoreFactory; +import sonia.scm.util.ClassLoaders; import sonia.scm.util.MockUtil; -import static org.hamcrest.Matchers.*; +import java.util.Collection; -import static org.junit.Assert.*; - -//~--- JDK imports ------------------------------------------------------------ - -import java.util.List; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; /** * @@ -62,6 +62,12 @@ import java.util.List; public class DefaultSecuritySystemTest extends AbstractTestBase { + private JAXBConfigurationEntryStoreFactory jaxbConfigurationEntryStoreFactory; + private PluginLoader pluginLoader; + @InjectMocks + private DefaultSecuritySystem securitySystem; + + /** * Method description * @@ -69,13 +75,12 @@ public class DefaultSecuritySystemTest extends AbstractTestBase @Before public void createSecuritySystem() { - JAXBConfigurationEntryStoreFactory factory = - new JAXBConfigurationEntryStoreFactory(new UUIDKeyGenerator(), - contextProvider); + jaxbConfigurationEntryStoreFactory = + spy(new JAXBConfigurationEntryStoreFactory(contextProvider , repositoryLocationResolver, new UUIDKeyGenerator() ) {}); + pluginLoader = mock(PluginLoader.class); + when(pluginLoader.getUberClassLoader()).thenReturn(ClassLoaders.getContextClassLoader(DefaultSecuritySystem.class)); - securitySystem = new DefaultSecuritySystem(factory); - - // ScmEventBus.getInstance().register(listener); + MockitoAnnotations.initMocks(this); } /** @@ -87,11 +92,10 @@ public class DefaultSecuritySystemTest extends AbstractTestBase { setAdminSubject(); - StoredAssignedPermission sap = createPermission("trillian", false, - "repository:*:READ"); + AssignedPermission sap = createPermission("trillian", false, "repository:*:READ"); assertEquals("trillian", sap.getName()); - assertEquals("repository:*:READ", sap.getPermission()); + assertEquals("repository:*:READ", sap.getPermission().getValue()); assertEquals(false, sap.isGroupPermission()); } @@ -104,10 +108,10 @@ public class DefaultSecuritySystemTest extends AbstractTestBase { setAdminSubject(); - List<PermissionDescriptor> list = securitySystem.getAvailablePermissions(); + Collection<PermissionDescriptor> list = securitySystem.getAvailablePermissions(); assertNotNull(list); - assertThat(list.size(), greaterThan(0)); + assertThat(list).isNotEmpty(); } /** @@ -119,12 +123,12 @@ public class DefaultSecuritySystemTest extends AbstractTestBase { setAdminSubject(); - StoredAssignedPermission sap = createPermission("trillian", false, + AssignedPermission sap = createPermission("trillian", false, "repository:*:READ"); securitySystem.deletePermission(sap); - assertNull(securitySystem.getPermission(sap.getId())); + assertThat(securitySystem.getPermissions(p -> p.getName().equals("trillian"))).isEmpty(); } /** @@ -136,17 +140,17 @@ public class DefaultSecuritySystemTest extends AbstractTestBase { setAdminSubject(); - StoredAssignedPermission trillian = createPermission("trillian", false, + AssignedPermission trillian = createPermission("trillian", false, "repository:*:READ"); - StoredAssignedPermission dent = createPermission("dent", false, + AssignedPermission dent = createPermission("dent", false, "repository:*:READ"); - StoredAssignedPermission marvin = createPermission("marvin", false, + AssignedPermission marvin = createPermission("marvin", false, "repository:*:READ"); - List<StoredAssignedPermission> all = securitySystem.getAllPermissions(); + Collection<AssignedPermission> all = securitySystem.getPermissions(p -> true); assertEquals(3, all.size()); - assertThat(all, containsInAnyOrder(trillian, dent, marvin)); + assertThat(all).contains(trillian, dent, marvin); } /** @@ -158,13 +162,12 @@ public class DefaultSecuritySystemTest extends AbstractTestBase { setAdminSubject(); - StoredAssignedPermission sap = createPermission("trillian", false, + AssignedPermission sap = createPermission("trillian", false, "repository:*:READ"); - StoredAssignedPermission other = securitySystem.getPermission(sap.getId()); + Collection<AssignedPermission> other = securitySystem.getPermissions(p -> p.getName().equals("trillian")); - assertEquals(sap.getId(), other.getId()); - assertEquals(sap, other); + assertThat(other).containsExactly(sap); } /** @@ -176,49 +179,19 @@ public class DefaultSecuritySystemTest extends AbstractTestBase { setAdminSubject(); - StoredAssignedPermission trillian = createPermission("trillian", false, + AssignedPermission trillian = createPermission("trillian", false, "repository:*:READ"); - StoredAssignedPermission dent = createPermission("dent", false, + AssignedPermission dent = createPermission("dent", false, "repository:*:READ"); createPermission("hitchhiker", true, "repository:*:READ"); - List<StoredAssignedPermission> filtered = - securitySystem.getPermissions(new Predicate<AssignedPermission>() - { + Collection<AssignedPermission> filtered = + securitySystem.getPermissions(p -> !p.isGroupPermission()); - @Override - public boolean apply(AssignedPermission input) - { - return !input.isGroupPermission(); - } - }); - - assertEquals(2, filtered.size()); - assertThat(filtered, containsInAnyOrder(trillian, dent)); - } - - /** - * Method description - * - */ - @Test - public void testModifyPermission() - { - setAdminSubject(); - - StoredAssignedPermission sap = createPermission("trillian", false, - "repository:*:READ"); - StoredAssignedPermission modified = - new StoredAssignedPermission(sap.getId(), - new AssignedPermission("trillian", "repository:*:WRITE")); - - securitySystem.modifyPermission(modified); - - sap = securitySystem.getPermission(modified.getId()); - - assertEquals(modified.getId(), sap.getId()); - assertEquals(modified, sap); + assertThat(filtered) + .hasSize(2) + .contains(trillian, dent); } /** @@ -241,46 +214,13 @@ public class DefaultSecuritySystemTest extends AbstractTestBase { setAdminSubject(); - StoredAssignedPermission sap = createPermission("trillian", false, + AssignedPermission sap = createPermission("trillian", false, "repository:*:READ"); setUserSubject(); securitySystem.deletePermission(sap); } - /** - * Method description - * - */ - @Test(expected = UnauthorizedException.class) - public void testUnauthorizedGetPermission() - { - setAdminSubject(); - - StoredAssignedPermission sap = createPermission("trillian", false, - "repository:*:READ"); - - setUserSubject(); - securitySystem.getPermission(sap.getId()); - } - - /** - * Method description - * - */ - @Test(expected = UnauthorizedException.class) - public void testUnauthorizedModifyPermission() - { - setAdminSubject(); - - StoredAssignedPermission sap = createPermission("trillian", false, - "repository:*:READ"); - - setUserSubject(); - - securitySystem.modifyPermission(sap); - } - /** * Method description * @@ -291,17 +231,16 @@ public class DefaultSecuritySystemTest extends AbstractTestBase * * @return */ - private StoredAssignedPermission createPermission(String name, + private AssignedPermission createPermission(String name, boolean groupPermission, String value) { AssignedPermission ap = new AssignedPermission(name, groupPermission, value); - StoredAssignedPermission sap = securitySystem.addPermission(ap); + securitySystem.addPermission(ap); - assertNotNull(sap); - assertNotNull(sap.getId()); - - return sap; + return securitySystem.getPermissions(permission -> Objects.equal(name, permission.getName()) + && Objects.equal(groupPermission, permission.isGroupPermission()) + && Objects.equal(value, permission.getPermission().getValue())).stream().findAny().orElseThrow(() -> new AssertionError("created permission not found")); } //~--- set methods ---------------------------------------------------------- @@ -326,9 +265,4 @@ public class DefaultSecuritySystemTest extends AbstractTestBase setSubject(MockUtil.createUserSubject(sm)); } - - //~--- fields --------------------------------------------------------------- - - /** Field description */ - private DefaultSecuritySystem securitySystem; } diff --git a/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenBuilderTest.java b/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenBuilderTest.java index 6dda005019..2928a6c7e6 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenBuilderTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenBuilderTest.java @@ -36,26 +36,34 @@ import com.github.sdorra.shiro.SubjectAware; import com.google.common.collect.Sets; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; +import org.apache.shiro.SecurityUtils; +import org.apache.shiro.subject.PrincipalCollection; +import org.apache.shiro.subject.Subject; import org.apache.shiro.util.ThreadContext; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mock; +import org.mockito.invocation.InvocationOnMock; import org.mockito.junit.MockitoJUnitRunner; +import sonia.scm.group.GroupNames; -import java.util.Random; +import java.util.Arrays; import java.util.Set; import java.util.concurrent.TimeUnit; -import static org.hamcrest.Matchers.isEmptyOrNullString; -import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.*; +import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertThat; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.when; +import static sonia.scm.security.SecureKeyTestUtil.createSecureKey; /** * Unit test for {@link JwtAccessTokenBuilder}. @@ -63,6 +71,11 @@ import static org.mockito.Mockito.when; * @author Sebastian Sdorra */ @RunWith(MockitoJUnitRunner.class) +@SubjectAware( + configuration = "classpath:sonia/scm/shiro-001.ini", + username = "trillian", + password = "secret" +) public class JwtAccessTokenBuilderTest { { @@ -97,11 +110,6 @@ public class JwtAccessTokenBuilderTest { * Tests {@link JwtAccessTokenBuilder#build()} with subject from shiro context. */ @Test - @SubjectAware( - configuration = "classpath:sonia/scm/shiro-001.ini", - username = "trillian", - password = "secret" - ) public void testBuildWithoutSubject() { JwtAccessToken token = factory.create().build(); assertEquals("trillian", token.getSubject()); @@ -135,6 +143,7 @@ public class JwtAccessTokenBuilderTest { .issuer("https://www.scm-manager.org") .expiresIn(5, TimeUnit.SECONDS) .custom("a", "b") + .groups("one", "two", "three") .scope(Scope.valueOf("repo:*")) .build(); @@ -150,7 +159,33 @@ public class JwtAccessTokenBuilderTest { .getBody(); assertClaims(new JwtAccessToken(claims, compact)); } - + + @Test + public void testWithExternalGroups() { + applyExternalGroupsToSubject(true, "external"); + JwtAccessToken token = factory.create().subject("dent").build(); + assertArrayEquals(new String[]{"external"}, token.getCustom(JwtAccessToken.GROUPS_CLAIM_KEY).map(x -> (String[]) x).get()); + } + + @Test + public void testWithInternalGroups() { + applyExternalGroupsToSubject(false, "external"); + JwtAccessToken token = factory.create().subject("dent").build(); + assertFalse(token.getCustom(JwtAccessToken.GROUPS_CLAIM_KEY).isPresent()); + } + + private void applyExternalGroupsToSubject(boolean external, String... groups) { + Subject subject = spy(SecurityUtils.getSubject()); + when(subject.getPrincipals()).thenAnswer(invocation -> enrichWithGroups(invocation, groups, external)); + shiro.setSubject(subject); + } + + private Object enrichWithGroups(InvocationOnMock invocation, String[] groups, boolean external) throws Throwable { + PrincipalCollection principals = (PrincipalCollection) spy(invocation.callRealMethod()); + when(principals.oneByType(GroupNames.class)).thenReturn(new GroupNames(Arrays.asList(groups), external)); + return principals; + } + private void assertClaims(JwtAccessToken token){ assertThat(token.getId(), not(isEmptyOrNullString())); assertNotNull( token.getIssuedAt() ); @@ -161,12 +196,6 @@ public class JwtAccessTokenBuilderTest { assertEquals(token.getIssuer().get(), "https://www.scm-manager.org"); assertEquals("b", token.getCustom("a").get()); assertEquals("[\"repo:*\"]", token.getScope().toString()); + assertThat(token.getGroups(), containsInAnyOrder("one", "two", "three")); } - - private SecureKey createSecureKey() { - byte[] bytes = new byte[32]; - new Random().nextBytes(bytes); - return new SecureKey(bytes, System.currentTimeMillis()); - } - } diff --git a/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenRefresherTest.java b/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenRefresherTest.java new file mode 100644 index 0000000000..774677cde3 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenRefresherTest.java @@ -0,0 +1,152 @@ +package sonia.scm.security; + +import com.github.sdorra.shiro.ShiroRule; +import com.github.sdorra.shiro.SubjectAware; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import java.sql.Date; +import java.time.Clock; +import java.time.Instant; +import java.util.Collections; +import java.util.Optional; + +import static java.time.Duration.ofMinutes; +import static java.time.temporal.ChronoUnit.SECONDS; +import static java.util.concurrent.TimeUnit.MINUTES; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static sonia.scm.security.SecureKeyTestUtil.createSecureKey; + +@SubjectAware( + username = "user", + password = "secret", + configuration = "classpath:sonia/scm/repository/shiro.ini" +) +@RunWith(MockitoJUnitRunner.class) +public class JwtAccessTokenRefresherTest { + + private static final Instant NOW = Instant.now().truncatedTo(SECONDS); + private static final Instant TOKEN_CREATION = NOW.minus(ofMinutes(1)); + + @Rule + public ShiroRule shiro = new ShiroRule(); + + @Mock + private SecureKeyResolver keyResolver; + @Mock + private JwtAccessTokenRefreshStrategy refreshStrategy; + @Mock + private Clock refreshClock; + + private KeyGenerator keyGenerator = () -> "key"; + + private JwtAccessTokenRefresher refresher; + private JwtAccessTokenBuilder tokenBuilder; + + @Before + public void initKeyResolver() { + when(keyResolver.getSecureKey(any())).thenReturn(createSecureKey()); + + Clock creationClock = mock(Clock.class); + when(creationClock.instant()).thenReturn(TOKEN_CREATION); + tokenBuilder = new JwtAccessTokenBuilderFactory(keyGenerator, keyResolver, Collections.emptySet(), creationClock).create(); + + JwtAccessTokenBuilderFactory refreshBuilderFactory = new JwtAccessTokenBuilderFactory(keyGenerator, keyResolver, Collections.emptySet(), refreshClock); + refresher = new JwtAccessTokenRefresher(refreshBuilderFactory, refreshStrategy, refreshClock); + when(refreshClock.instant()).thenReturn(NOW); + when(refreshStrategy.shouldBeRefreshed(any())).thenReturn(true); + + // set default expiration values + tokenBuilder + .expiresIn(5, MINUTES) + .refreshableFor(10, MINUTES); + } + + @Test + public void shouldNotRefreshTokenWithDisabledRefresh() { + JwtAccessToken oldToken = tokenBuilder + .refreshableFor(0, MINUTES) + .build(); + + Optional<JwtAccessToken> refreshedToken = refresher.refresh(oldToken); + + assertThat(refreshedToken).isEmpty(); + } + + @Test + public void shouldNotRefreshTokenWhenTokenExpired() { + Instant afterNormalExpiration = NOW.plus(ofMinutes(6)); + when(refreshClock.instant()).thenReturn(afterNormalExpiration); + JwtAccessToken oldToken = tokenBuilder.build(); + + Optional<JwtAccessToken> refreshedToken = refresher.refresh(oldToken); + + assertThat(refreshedToken).isEmpty(); + } + + @Test + public void shouldNotRefreshTokenWhenRefreshExpired() { + Instant afterRefreshExpiration = Instant.now().plus(ofMinutes(2)); + when(refreshClock.instant()).thenReturn(afterRefreshExpiration); + JwtAccessToken oldToken = tokenBuilder + .refreshableFor(1, MINUTES) + .build(); + + Optional<JwtAccessToken> refreshedToken = refresher.refresh(oldToken); + + assertThat(refreshedToken).isEmpty(); + } + + @Test + public void shouldNotRefreshTokenWhenStrategyDoesNotSaySo() { + JwtAccessToken oldToken = tokenBuilder.build(); + when(refreshStrategy.shouldBeRefreshed(oldToken)).thenReturn(false); + + Optional<JwtAccessToken> refreshedToken = refresher.refresh(oldToken); + + assertThat(refreshedToken).isEmpty(); + } + + @Test + public void shouldRefreshTokenWithParentId() { + JwtAccessToken oldToken = tokenBuilder.build(); + when(refreshStrategy.shouldBeRefreshed(oldToken)).thenReturn(true); + + Optional<JwtAccessToken> refreshedTokenResult = refresher.refresh(oldToken); + + assertThat(refreshedTokenResult).isNotEmpty(); + JwtAccessToken refreshedToken = refreshedTokenResult.get(); + assertThat(refreshedToken.getParentKey()).get().isEqualTo("key"); + } + + @Test + public void shouldRefreshTokenWithSameExpiration() { + JwtAccessToken oldToken = tokenBuilder.build(); + when(refreshStrategy.shouldBeRefreshed(oldToken)).thenReturn(true); + + Optional<JwtAccessToken> refreshedTokenResult = refresher.refresh(oldToken); + + assertThat(refreshedTokenResult).isNotEmpty(); + JwtAccessToken refreshedToken = refreshedTokenResult.get(); + assertThat(refreshedToken.getExpiration()).isEqualTo(Date.from(NOW.plus(ofMinutes(5)))); + } + + @Test + public void shouldRefreshTokenWithSameRefreshExpiration() { + JwtAccessToken oldToken = tokenBuilder.build(); + when(refreshStrategy.shouldBeRefreshed(oldToken)).thenReturn(true); + + Optional<JwtAccessToken> refreshedTokenResult = refresher.refresh(oldToken); + + assertThat(refreshedTokenResult).isNotEmpty(); + JwtAccessToken refreshedToken = refreshedTokenResult.get(); + assertThat(refreshedToken.getRefreshExpiration()).get().isEqualTo(Date.from(TOKEN_CREATION.plus(ofMinutes(10)))); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenResolverTest.java b/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenResolverTest.java index 689fc4bb35..a1f1e36c9c 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenResolverTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenResolverTest.java @@ -40,24 +40,27 @@ import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.SignatureException; import io.jsonwebtoken.UnsupportedJwtException; -import java.security.SecureRandom; -import java.util.Date; -import java.util.Set; -import javax.crypto.spec.SecretKeySpec; import org.apache.shiro.authc.AuthenticationException; import org.hamcrest.Matchers; -import org.junit.Test; -import static org.junit.Assert.*; -import static org.hamcrest.Matchers.*; import org.junit.Before; import org.junit.Rule; +import org.junit.Test; import org.junit.rules.ExpectedException; import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.Mockito; -import static org.mockito.Mockito.*; import org.mockito.junit.MockitoJUnitRunner; +import javax.crypto.spec.SecretKeySpec; +import java.util.Date; +import java.util.Set; + +import static org.hamcrest.Matchers.instanceOf; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThat; +import static org.mockito.Mockito.when; +import static sonia.scm.security.SecureKeyTestUtil.createSecureKey; + /** * Unit tests for {@link JwtAccessTokenResolver}. * @@ -68,14 +71,12 @@ public class JwtAccessTokenResolverTest { @Rule public ExpectedException expectedException = ExpectedException.none(); - - private final SecureRandom random = new SecureRandom(); - + @Mock private SecureKeyResolver keyResolver; @Mock - private TokenClaimsValidator validator; + private AccessTokenValidator validator; private JwtAccessTokenResolver resolver; @@ -84,8 +85,8 @@ public class JwtAccessTokenResolverTest { */ @Before public void prepareObjectUnderTest() { - Set<TokenClaimsValidator> validators = Sets.newHashSet(validator); - when(validator.validate(anyMap())).thenReturn(true); + Set<AccessTokenValidator> validators = Sets.newHashSet(validator); + when(validator.validate(Mockito.any(AccessToken.class))).thenReturn(true); resolver = new JwtAccessTokenResolver(keyResolver, validators); } @@ -113,11 +114,11 @@ public class JwtAccessTokenResolverTest { String compact = createCompactToken("marvin", secureKey); // prepare mock - when(validator.validate(anyMap())).thenReturn(false); + when(validator.validate(Mockito.any(AccessToken.class))).thenReturn(false); // expect exception expectedException.expect(AuthenticationException.class); - expectedException.expectMessage(Matchers.containsString("claims")); + expectedException.expectMessage(Matchers.containsString("token")); BearerToken bearer = BearerToken.valueOf(compact); resolver.resolve(bearer); @@ -214,12 +215,6 @@ public class JwtAccessTokenResolverTest { .compact(); } - private SecureKey createSecureKey() { - byte[] bytes = new byte[32]; - random.nextBytes(bytes); - return new SecureKey(bytes, System.currentTimeMillis()); - } - private void resolveKey(SecureKey key) { when( keyResolver.resolveSigningKey( @@ -230,7 +225,7 @@ public class JwtAccessTokenResolverTest { .thenReturn( new SecretKeySpec( key.getBytes(), - SignatureAlgorithm.HS256.getValue() + SignatureAlgorithm.HS256.getJcaName() ) ); } diff --git a/scm-webapp/src/test/java/sonia/scm/security/PercentageJwtAccessTokenRefreshStrategyTest.java b/scm-webapp/src/test/java/sonia/scm/security/PercentageJwtAccessTokenRefreshStrategyTest.java new file mode 100644 index 0000000000..122c1b5381 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/security/PercentageJwtAccessTokenRefreshStrategyTest.java @@ -0,0 +1,67 @@ +package sonia.scm.security; + +import com.github.sdorra.shiro.ShiroRule; +import com.github.sdorra.shiro.SubjectAware; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +import java.time.Clock; +import java.time.Instant; +import java.util.Collections; + +import static java.time.temporal.ChronoUnit.MINUTES; +import static java.time.temporal.ChronoUnit.SECONDS; +import static java.util.concurrent.TimeUnit.HOURS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static sonia.scm.security.SecureKeyTestUtil.createSecureKey; + +@SubjectAware( + username = "user", + password = "secret", + configuration = "classpath:sonia/scm/repository/shiro.ini" +) +public class PercentageJwtAccessTokenRefreshStrategyTest { + + private static final Instant TOKEN_CREATION = Instant.now().truncatedTo(SECONDS); + + @Rule + public ShiroRule shiro = new ShiroRule(); + + private KeyGenerator keyGenerator = () -> "key"; + + private Clock refreshClock = mock(Clock.class); + + private JwtAccessTokenBuilder tokenBuilder; + private PercentageJwtAccessTokenRefreshStrategy refreshStrategy; + + @Before + public void initToken() { + SecureKeyResolver keyResolver = mock(SecureKeyResolver.class); + when(keyResolver.getSecureKey(any())).thenReturn(createSecureKey()); + + Clock creationClock = mock(Clock.class); + when(creationClock.instant()).thenReturn(TOKEN_CREATION); + + tokenBuilder = new JwtAccessTokenBuilderFactory(keyGenerator, keyResolver, Collections.emptySet(), creationClock).create(); + tokenBuilder.expiresIn(1, HOURS); + tokenBuilder.refreshableFor(1, HOURS); + + refreshStrategy = new PercentageJwtAccessTokenRefreshStrategy(refreshClock, 0.5F); + } + + @Test + public void shouldNotRefreshWhenTokenIsYoung() { + when(refreshClock.instant()).thenReturn(TOKEN_CREATION.plus(29, MINUTES)); + assertThat(refreshStrategy.shouldBeRefreshed(tokenBuilder.build())).isFalse(); + } + + @Test + public void shouldRefreshWhenTokenIsOld() { + when(refreshClock.instant()).thenReturn(TOKEN_CREATION.plus(31, MINUTES)); + assertThat(refreshStrategy.shouldBeRefreshed(tokenBuilder.build())).isTrue(); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/security/PermissionAssignerTest.java b/scm-webapp/src/test/java/sonia/scm/security/PermissionAssignerTest.java new file mode 100644 index 0000000000..366c16f6b8 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/security/PermissionAssignerTest.java @@ -0,0 +1,112 @@ +package sonia.scm.security; + +import com.github.sdorra.shiro.ShiroRule; +import com.github.sdorra.shiro.SubjectAware; +import org.apache.shiro.authz.UnauthorizedException; +import org.assertj.core.api.Assertions; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import sonia.scm.NotFoundException; +import sonia.scm.plugin.PluginLoader; +import sonia.scm.store.InMemoryConfigurationEntryStoreFactory; +import sonia.scm.util.ClassLoaders; + +import java.util.Arrays; +import java.util.Collection; +import java.util.stream.Collectors; + +import static java.util.Arrays.asList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@SubjectAware(configuration = "classpath:sonia/scm/shiro-001.ini", username = "dent", password = "secret") +public class PermissionAssignerTest { + + @Rule + public ShiroRule shiroRule = new ShiroRule(); + + @Rule + public ExpectedException expectedException = ExpectedException.none(); + + private DefaultSecuritySystem securitySystem; + private PermissionAssigner permissionAssigner; + + @Before + public void init() { + PluginLoader pluginLoader = mock(PluginLoader.class); + when(pluginLoader.getUberClassLoader()).thenReturn(ClassLoaders.getContextClassLoader(DefaultSecuritySystem.class)); + + securitySystem = new DefaultSecuritySystem(new InMemoryConfigurationEntryStoreFactory(), pluginLoader) { + @Override + public Collection<PermissionDescriptor> getAvailablePermissions() { + return Arrays.stream(new String[]{"perm:read:1", "perm:read:2", "perm:read:3", "perm:read:4"}) + .map(PermissionDescriptor::new) + .collect(Collectors.toList()); + } + }; + + try { + securitySystem.addPermission(new AssignedPermission("1", "perm:read:1")); + securitySystem.addPermission(new AssignedPermission("1", "perm:read:2")); + securitySystem.addPermission(new AssignedPermission("2", "perm:read:2")); + securitySystem.addPermission(new AssignedPermission("1", true, "perm:read:2")); + } catch (UnauthorizedException e) { + // ignore for tests with limited privileges + } + permissionAssigner = new PermissionAssigner(securitySystem); + } + + @Test + public void shouldFindUserPermissions() { + Collection<PermissionDescriptor> permissionDescriptors = permissionAssigner.readPermissionsForUser("1"); + + Assertions.assertThat(permissionDescriptors).hasSize(2); + } + + @Test + public void shouldFindGroupPermissions() { + Collection<PermissionDescriptor> permissionDescriptors = permissionAssigner.readPermissionsForUser("1"); + + Assertions.assertThat(permissionDescriptors).hasSize(2); + } + + @Test + @SubjectAware(username = "trillian", password = "secret") + public void shouldNotReadUserPermissionsForUnprivilegedUser() { + expectedException.expect(UnauthorizedException.class); + + permissionAssigner.readPermissionsForUser("1"); + } + + @Test + public void shouldOverwriteUserPermissions() { + permissionAssigner.setPermissionsForUser("2", asList(new PermissionDescriptor("perm:read:3"), new PermissionDescriptor("perm:read:4"))); + + Collection<PermissionDescriptor> permissionDescriptors = permissionAssigner.readPermissionsForUser("2"); + + Assertions.assertThat(permissionDescriptors).hasSize(2); + } + + @Test + @SubjectAware(username = "trillian", password = "secret") + public void shouldNotOverwriteUserPermissionsForUnprivilegedUser() { + expectedException.expect(UnauthorizedException.class); + + permissionAssigner.setPermissionsForUser("2", asList(new PermissionDescriptor("perm:read:3"), new PermissionDescriptor("perm:read:4"))); + } + + @Test + public void shouldFailForNotExistingPermissions() { + expectedException.expect(NotFoundException.class); + permissionAssigner.setPermissionsForUser("2", asList(new PermissionDescriptor("perm:read:4"), new PermissionDescriptor("perm:read:5"))); + } + + @Test + public void shouldAcceptNotExistingPermissionsWhenTheyWereAssignedBefore() { + securitySystem.addPermission(new AssignedPermission("2", "perm:read:5")); + + permissionAssigner.setPermissionsForUser("2", asList(new PermissionDescriptor("perm:read:5"))); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/security/RepositoryPermissionProviderTest.java b/scm-webapp/src/test/java/sonia/scm/security/RepositoryPermissionProviderTest.java new file mode 100644 index 0000000000..8a8d85fdb2 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/security/RepositoryPermissionProviderTest.java @@ -0,0 +1,72 @@ +package sonia.scm.security; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import sonia.scm.plugin.PluginLoader; +import sonia.scm.repository.RepositoryPermissions; +import sonia.scm.util.ClassLoaders; + +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.Collection; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.fail; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class RepositoryPermissionProviderTest { + + private RepositoryPermissionProvider repositoryPermissionProvider; + private String[] allVerbsFromRepositoryClass; + + + @BeforeEach + void init() { + PluginLoader pluginLoader = mock(PluginLoader.class); + when(pluginLoader.getUberClassLoader()).thenReturn(ClassLoaders.getContextClassLoader(DefaultSecuritySystem.class)); + repositoryPermissionProvider = new RepositoryPermissionProvider(pluginLoader); + allVerbsFromRepositoryClass = Arrays.stream(RepositoryPermissions.class.getDeclaredFields()) + .filter(field -> field.getName().startsWith("ACTION_")) + .filter(field -> !field.getName().equals("ACTION_HEALTHCHECK")) + .map(this::getString) + .filter(verb -> !"create".equals(verb)) + .toArray(String[]::new); + } + + @Test + void shouldReadAvailableRoles() { + assertThat(repositoryPermissionProvider.availableRoles()).isNotEmpty(); + assertThat(repositoryPermissionProvider.availableRoles()).allSatisfy(this::containsOnlyAvailableVerbs); + } + + private void containsOnlyAvailableVerbs(RepositoryRole role) { + assertThat(role.getVerbs()).isSubsetOf(repositoryPermissionProvider.availableVerbs()); + } + + @Test + void shouldReadAvailableVerbsFromRepository() { + assertThat(repositoryPermissionProvider.availableVerbs()).contains(allVerbsFromRepositoryClass); + } + + @Test + void shouldMergeRepositoryRoles() { + Collection<String> verbsInMergedRole = repositoryPermissionProvider + .availableRoles() + .stream() + .filter(r -> "READ".equals(r.getName())) + .findFirst() + .get() + .getVerbs(); + assertThat(verbsInMergedRole).contains("read", "pull", "test"); + } + + private String getString(Field field) { + try { + return (String) field.get(null); + } catch (IllegalAccessException e) { + fail(e); + return null; + } + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/security/SecureKeyResolverTest.java b/scm-webapp/src/test/java/sonia/scm/security/SecureKeyResolverTest.java index 8d708c4677..f59991f2cc 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/SecureKeyResolverTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/SecureKeyResolverTest.java @@ -36,20 +36,25 @@ package sonia.scm.security; //~--- non-JDK imports -------------------------------------------------------- import io.jsonwebtoken.Jwts; - import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; - import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; - import sonia.scm.store.ConfigurationEntryStore; import sonia.scm.store.ConfigurationEntryStoreFactory; -import static org.junit.Assert.*; +import java.util.Random; -import static org.mockito.Mockito.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.argThat; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; /** * @@ -97,10 +102,11 @@ public class SecureKeyResolverTest * Method description * */ - @Test(expected = IllegalStateException.class) + @Test public void testResolveSigningKeyBytesWithoutKey() { - resolver.resolveSigningKeyBytes(null, Jwts.claims().setSubject("test")); + byte[] bytes = resolver.resolveSigningKeyBytes(null, Jwts.claims().setSubject("test")); + assertThat(bytes[0]).isEqualTo((byte) 42); } /** @@ -122,12 +128,17 @@ public class SecureKeyResolverTest @Before public void setUp() { - ConfigurationEntryStoreFactory factory = - mock(ConfigurationEntryStoreFactory.class); + ConfigurationEntryStoreFactory factory = mock(ConfigurationEntryStoreFactory.class); - when(factory.getStore(SecureKey.class, - SecureKeyResolver.STORE_NAME)).thenReturn(store); - resolver = new SecureKeyResolver(factory); + when(factory.withType(any())).thenCallRealMethod(); + when(factory.<SecureKey>getStore(argThat(storeParameters -> { + assertThat(storeParameters.getName()).isEqualTo(SecureKeyResolver.STORE_NAME); + assertThat(storeParameters.getType()).isEqualTo(SecureKey.class); + return true; + }))).thenReturn(store); + Random random = mock(Random.class); + doAnswer(invocation -> ((byte[]) invocation.getArguments()[0])[0] = 42).when(random).nextBytes(any()); + resolver = new SecureKeyResolver(factory, random); } //~--- fields --------------------------------------------------------------- diff --git a/scm-webapp/src/test/java/sonia/scm/security/SecureKeyTestUtil.java b/scm-webapp/src/test/java/sonia/scm/security/SecureKeyTestUtil.java new file mode 100644 index 0000000000..3b9c95fd17 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/security/SecureKeyTestUtil.java @@ -0,0 +1,11 @@ +package sonia.scm.security; + +import java.security.SecureRandom; + +public class SecureKeyTestUtil { + public static SecureKey createSecureKey() { + byte[] bytes = new byte[32]; + new SecureRandom().nextBytes(bytes); + return new SecureKey(bytes, System.currentTimeMillis()); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/security/SecurityRequestFilterTest.java b/scm-webapp/src/test/java/sonia/scm/security/SecurityRequestFilterTest.java index 663198dff2..399d97a1bf 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/SecurityRequestFilterTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/SecurityRequestFilterTest.java @@ -3,6 +3,7 @@ package sonia.scm.security; import com.github.sdorra.shiro.ShiroRule; import com.github.sdorra.shiro.SubjectAware; import org.apache.shiro.authc.AuthenticationException; +import org.apache.shiro.util.ThreadContext; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; @@ -29,6 +30,10 @@ public class SecurityRequestFilterTest { @InjectMocks private SecurityRequestFilter securityRequestFilter; + { + ThreadContext.unbindSubject(); + } + @Test public void shouldAllowUnauthenticatedAccessForAnnotatedMethod() throws NoSuchMethodException { when(resourceInfo.getResourceMethod()).thenReturn(SecurityTestClass.class.getMethod("anonymousAccessAllowed")); diff --git a/scm-webapp/src/test/java/sonia/scm/security/XsrfTokenClaimsValidatorTest.java b/scm-webapp/src/test/java/sonia/scm/security/XsrfAccessTokenValidatorTest.java similarity index 68% rename from scm-webapp/src/test/java/sonia/scm/security/XsrfTokenClaimsValidatorTest.java rename to scm-webapp/src/test/java/sonia/scm/security/XsrfAccessTokenValidatorTest.java index dbebb2c0cf..a89744cd6e 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/XsrfTokenClaimsValidatorTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/XsrfAccessTokenValidatorTest.java @@ -31,88 +31,90 @@ package sonia.scm.security; -import com.google.common.collect.Maps; -import java.util.Map; -import javax.servlet.http.HttpServletRequest; -import org.junit.Test; -import static org.junit.Assert.*; import org.junit.Before; +import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mock; -import static org.mockito.Mockito.*; import org.mockito.junit.MockitoJUnitRunner; +import javax.servlet.http.HttpServletRequest; +import java.util.Optional; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.when; + /** - * Tests {@link XsrfTokenClaimsValidator}. + * Tests {@link XsrfAccessTokenValidator}. * * @author Sebastian Sdorra */ @RunWith(MockitoJUnitRunner.class) -public class XsrfTokenClaimsValidatorTest { +public class XsrfAccessTokenValidatorTest { @Mock private HttpServletRequest request; - private XsrfTokenClaimsValidator validator; + @Mock + private AccessToken accessToken; + + private XsrfAccessTokenValidator validator; /** * Prepare object under test. */ @Before public void prepareObjectUnderTest() { - validator = new XsrfTokenClaimsValidator(() -> request); + validator = new XsrfAccessTokenValidator(() -> request); } /** - * Tests {@link XsrfTokenClaimsValidator#validate(java.util.Map)}. + * Tests {@link XsrfAccessTokenValidator#validate(AccessToken)}. */ @Test public void testValidate() { // prepare - Map<String, Object> claims = Maps.newHashMap(); - claims.put(Xsrf.TOKEN_KEY, "abc"); + when(accessToken.getCustom(Xsrf.TOKEN_KEY)).thenReturn(Optional.of("abc")); when(request.getHeader(Xsrf.HEADER_KEY)).thenReturn("abc"); // execute and assert - assertTrue(validator.validate(claims)); + assertTrue(validator.validate(accessToken)); } /** - * Tests {@link XsrfTokenClaimsValidator#validate(java.util.Map)} with wrong header. + * Tests {@link XsrfAccessTokenValidator#validate(AccessToken)} with wrong header. */ @Test public void testValidateWithWrongHeader() { // prepare - Map<String, Object> claims = Maps.newHashMap(); - claims.put(Xsrf.TOKEN_KEY, "abc"); + when(accessToken.getCustom(Xsrf.TOKEN_KEY)).thenReturn(Optional.of("abc")); when(request.getHeader(Xsrf.HEADER_KEY)).thenReturn("123"); // execute and assert - assertFalse(validator.validate(claims)); + assertFalse(validator.validate(accessToken)); } /** - * Tests {@link XsrfTokenClaimsValidator#validate(java.util.Map)} without header. + * Tests {@link XsrfAccessTokenValidator#validate(AccessToken)} without header. */ @Test public void testValidateWithoutHeader() { // prepare - Map<String, Object> claims = Maps.newHashMap(); - claims.put(Xsrf.TOKEN_KEY, "abc"); + when(accessToken.getCustom(Xsrf.TOKEN_KEY)).thenReturn(Optional.of("abc")); // execute and assert - assertFalse(validator.validate(claims)); + assertFalse(validator.validate(accessToken)); } /** - * Tests {@link XsrfTokenClaimsValidator#validate(java.util.Map)} without claims key. + * Tests {@link XsrfAccessTokenValidator#validate(AccessToken)} without claims key. */ @Test public void testValidateWithoutClaimsKey() { // prepare - Map<String, Object> claims = Maps.newHashMap(); + when(accessToken.getCustom(Xsrf.TOKEN_KEY)).thenReturn(Optional.empty()); // execute and assert - assertTrue(validator.validate(claims)); + assertTrue(validator.validate(accessToken)); } } diff --git a/scm-webapp/src/test/java/sonia/scm/user/DefaultUserManagerTest.java b/scm-webapp/src/test/java/sonia/scm/user/DefaultUserManagerTest.java index 1614bf790a..ab31d751fd 100644 --- a/scm-webapp/src/test/java/sonia/scm/user/DefaultUserManagerTest.java +++ b/scm-webapp/src/test/java/sonia/scm/user/DefaultUserManagerTest.java @@ -72,7 +72,7 @@ public class DefaultUserManagerTest extends UserManagerTestBase public ShiroRule shiro = new ShiroRule(); - private UserDAO userDAO = mock(UserDAO.class); + private UserDAO userDAO ; private User trillian; /** @@ -182,6 +182,6 @@ public class DefaultUserManagerTest extends UserManagerTestBase //~--- methods -------------------------------------------------------------- private XmlUserDAO createXmlUserDAO() { - return new XmlUserDAO(new JAXBConfigurationStoreFactory(contextProvider)); + return new XmlUserDAO(new JAXBConfigurationStoreFactory(contextProvider, locationResolver)); } } diff --git a/scm-webapp/src/test/java/sonia/scm/web/cgi/DefaultCGIExecutorTest.java b/scm-webapp/src/test/java/sonia/scm/web/cgi/DefaultCGIExecutorTest.java new file mode 100644 index 0000000000..5f95a171d2 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/web/cgi/DefaultCGIExecutorTest.java @@ -0,0 +1,54 @@ +package sonia.scm.web.cgi; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import javax.servlet.http.HttpServletRequest; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link DefaultCGIExecutor}. + */ +@RunWith(MockitoJUnitRunner.class) +public class DefaultCGIExecutorTest { + + @Mock + private HttpServletRequest request; + + @Test + public void testCreateCGIContentLength() { + when(request.getHeader("Content-Length")).thenReturn("42"); + assertEquals("42", DefaultCGIExecutor.createCGIContentLength(request, false)); + assertEquals("42", DefaultCGIExecutor.createCGIContentLength(request, true)); + } + + @Test + public void testCreateCGIContentLengthWithZeroLength() { + when(request.getHeader("Content-Length")).thenReturn("0"); + assertEquals("", DefaultCGIExecutor.createCGIContentLength(request, false)); + assertEquals("-1", DefaultCGIExecutor.createCGIContentLength(request, true)); + } + + @Test + public void testCreateCGIContentLengthWithoutContentLengthHeader() { + assertEquals("", DefaultCGIExecutor.createCGIContentLength(request, false)); + assertEquals("-1", DefaultCGIExecutor.createCGIContentLength(request, true)); + } + + @Test + public void testCreateCGIContentLengthWithLengthThatExeedsInteger() { + when(request.getHeader("Content-Length")).thenReturn("6314297259"); + assertEquals("6314297259", DefaultCGIExecutor.createCGIContentLength(request, false)); + } + + @Test + public void testCreateCGIContentLengthWithNonNumberHeader() { + when(request.getHeader("Content-Length")).thenReturn("abc"); + assertEquals("", DefaultCGIExecutor.createCGIContentLength(request, false)); + } + +} diff --git a/scm-webapp/src/test/java/sonia/scm/web/i18n/I18nServletTest.java b/scm-webapp/src/test/java/sonia/scm/web/i18n/I18nServletTest.java index a912f738e2..468efe3ecd 100644 --- a/scm-webapp/src/test/java/sonia/scm/web/i18n/I18nServletTest.java +++ b/scm-webapp/src/test/java/sonia/scm/web/i18n/I18nServletTest.java @@ -2,8 +2,6 @@ package sonia.scm.web.i18n; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.github.sdorra.shiro.ShiroRule; -import com.github.sdorra.shiro.SubjectAware; import com.google.common.base.Charsets; import com.google.common.io.Files; import org.apache.commons.lang3.StringUtils; @@ -37,17 +35,11 @@ import java.util.Enumeration; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; @RunWith(MockitoJUnitRunner.Silent.class) -@SubjectAware(configuration = "classpath:sonia/scm/shiro-001.ini") public class I18nServletTest { - @Rule - public ShiroRule shiro = new ShiroRule(); - private static final String GIT_PLUGIN_JSON = json( "{", "'scm-git-plugin': {", @@ -88,15 +80,15 @@ public class I18nServletTest { public TemporaryFolder temporaryFolder = new TemporaryFolder(); @Mock - PluginLoader pluginLoader; + private PluginLoader pluginLoader; @Mock - CacheManager cacheManager; + private CacheManager cacheManager; @Mock - ClassLoader classLoader; + private ClassLoader classLoader; - I18nServlet servlet; + private I18nServlet servlet; @Mock private Cache cache; @@ -106,9 +98,9 @@ public class I18nServletTest { @SuppressWarnings("unchecked") public void init() throws IOException { resources = Collections.enumeration(Lists.newArrayList( - createFileFromString(SVN_PLUGIN_JSON).toURL(), - createFileFromString(GIT_PLUGIN_JSON).toURL(), - createFileFromString(HG_PLUGIN_JSON).toURL() + createFileFromString(SVN_PLUGIN_JSON).toURI().toURL(), + createFileFromString(GIT_PLUGIN_JSON).toURI().toURL(), + createFileFromString(HG_PLUGIN_JSON).toURI().toURL() )); when(pluginLoader.getUberClassLoader()).thenReturn(classLoader); when(cacheManager.getCache(I18nServlet.CACHE_NAME)).thenReturn(cache); @@ -194,6 +186,8 @@ public class I18nServletTest { assertJson(json); verify(cache).get(path); verify(cache).put(eq(path), any()); + + verifyHeaders(response); } @Test @@ -221,6 +215,8 @@ public class I18nServletTest { verify(cache, never()).put(eq(path), any()); verify(cache).get(path); assertJson(json); + + verifyHeaders(response); } @Test @@ -234,11 +230,17 @@ public class I18nServletTest { assertJson(jsonNodeOptional.orElse(null)); } + private void verifyHeaders(HttpServletResponse response) { + verify(response).setCharacterEncoding("UTF-8"); + verify(response).setContentType("application/json"); + verify(response).setHeader("Cache-Control", "no-cache"); + } + public void assertJson(JsonNode actual) throws IOException { assertJson(actual.toString()); } - public void assertJson(String actual) throws IOException { + private void assertJson(String actual) throws IOException { assertThat(actual) .isNotEmpty() .contains(StringUtils.deleteWhitespace(GIT_PLUGIN_JSON.substring(1, GIT_PLUGIN_JSON.length() - 1))) @@ -246,7 +248,7 @@ public class I18nServletTest { .contains(StringUtils.deleteWhitespace(SVN_PLUGIN_JSON.substring(1, SVN_PLUGIN_JSON.length() - 1))); } - public File createFileFromString(String json) throws IOException { + private File createFileFromString(String json) throws IOException { File file = temporaryFolder.newFile(); Files.write(json.getBytes(Charsets.UTF_8), file); return file; diff --git a/scm-webapp/src/test/java/sonia/scm/web/security/TokenRefreshFilterTest.java b/scm-webapp/src/test/java/sonia/scm/web/security/TokenRefreshFilterTest.java new file mode 100644 index 0000000000..945d8cf0d2 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/web/security/TokenRefreshFilterTest.java @@ -0,0 +1,107 @@ +package sonia.scm.web.security; + +import org.apache.shiro.authc.AuthenticationToken; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.security.AccessTokenCookieIssuer; +import sonia.scm.security.AccessTokenResolver; +import sonia.scm.security.BearerToken; +import sonia.scm.security.JwtAccessToken; +import sonia.scm.security.JwtAccessTokenRefresher; +import sonia.scm.web.WebTokenGenerator; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Set; + +import static java.util.Collections.singleton; +import static java.util.Optional.of; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith({MockitoExtension.class}) +class TokenRefreshFilterTest { + + @Mock + private Set<WebTokenGenerator> tokenGenerators; + @Mock + private WebTokenGenerator tokenGenerator; + @Mock + private JwtAccessTokenRefresher refresher; + @Mock + private AccessTokenResolver resolver; + @Mock + private AccessTokenCookieIssuer issuer; + + @InjectMocks + private TokenRefreshFilter filter; + + @Mock + private HttpServletRequest request; + @Mock + private HttpServletResponse response; + @Mock + private FilterChain filterChain; + + @BeforeEach + void initGenerators() { + when(tokenGenerators.iterator()).thenReturn(singleton(tokenGenerator).iterator()); + } + + @Test + void shouldContinueChain() throws IOException, ServletException { + filter.doFilter(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + verify(issuer, never()).authenticate(any(), any(), any()); + } + + @Test + void shouldNotRefreshNonBearerToken() throws IOException, ServletException { + AuthenticationToken token = mock(AuthenticationToken.class); + when(tokenGenerator.createToken(request)).thenReturn(token); + + filter.doFilter(request, response, filterChain); + + verify(issuer, never()).authenticate(any(), any(), any()); + verify(filterChain).doFilter(request, response); + } + + @Test + void shouldNotRefreshNonJwtToken() throws IOException, ServletException { + BearerToken token = mock(BearerToken.class); + JwtAccessToken jwtToken = mock(JwtAccessToken.class); + when(tokenGenerator.createToken(request)).thenReturn(token); + when(resolver.resolve(token)).thenReturn(jwtToken); + + filter.doFilter(request, response, filterChain); + + verify(issuer, never()).authenticate(any(), any(), any()); + verify(filterChain).doFilter(request, response); + } + + @Test + void shouldRefreshIfRefreshable() throws IOException, ServletException { + BearerToken token = mock(BearerToken.class); + JwtAccessToken jwtToken = mock(JwtAccessToken.class); + JwtAccessToken newJwtToken = mock(JwtAccessToken.class); + when(tokenGenerator.createToken(request)).thenReturn(token); + when(resolver.resolve(token)).thenReturn(jwtToken); + when(refresher.refresh(jwtToken)).thenReturn(of(newJwtToken)); + + filter.doFilter(request, response, filterChain); + + verify(issuer).authenticate(request, response, newJwtToken); + verify(filterChain).doFilter(request, response); + } +} diff --git a/scm-webapp/src/test/resources/META-INF/scm/repository-permissions.xml b/scm-webapp/src/test/resources/META-INF/scm/repository-permissions.xml new file mode 100644 index 0000000000..7da12934e5 --- /dev/null +++ b/scm-webapp/src/test/resources/META-INF/scm/repository-permissions.xml @@ -0,0 +1,13 @@ +<repository-permissions> + <verbs> + <verb>test</verb> + </verbs> + <roles> + <role> + <name>READ</name> + <verbs> + <verb>test</verb> + </verbs> + </role> + </roles> +</repository-permissions> diff --git a/scm-webapp/src/test/resources/sonia/scm/api/v2/config-test-empty-admin-group.json b/scm-webapp/src/test/resources/sonia/scm/api/v2/config-test-empty-admin-group.json new file mode 100644 index 0000000000..f665c29ee7 --- /dev/null +++ b/scm-webapp/src/test/resources/sonia/scm/api/v2/config-test-empty-admin-group.json @@ -0,0 +1,3 @@ +{ + "adminGroups": [""] +} diff --git a/scm-webapp/src/test/resources/sonia/scm/api/v2/config-test-empty-admin-user.json b/scm-webapp/src/test/resources/sonia/scm/api/v2/config-test-empty-admin-user.json new file mode 100644 index 0000000000..61efcb1609 --- /dev/null +++ b/scm-webapp/src/test/resources/sonia/scm/api/v2/config-test-empty-admin-user.json @@ -0,0 +1,3 @@ +{ + "adminUsers": [""] +} diff --git a/scm-webapp/src/test/resources/sonia/scm/api/v2/mergeCommand.json b/scm-webapp/src/test/resources/sonia/scm/api/v2/mergeCommand.json new file mode 100644 index 0000000000..dde0b6a413 --- /dev/null +++ b/scm-webapp/src/test/resources/sonia/scm/api/v2/mergeCommand.json @@ -0,0 +1,4 @@ +{ + "sourceRevision": "source", + "targetRevision": "target" +} diff --git a/scm-webapp/src/test/resources/sonia/scm/api/v2/mergeCommand_invalid.json b/scm-webapp/src/test/resources/sonia/scm/api/v2/mergeCommand_invalid.json new file mode 100644 index 0000000000..b2d1e5ab3f --- /dev/null +++ b/scm-webapp/src/test/resources/sonia/scm/api/v2/mergeCommand_invalid.json @@ -0,0 +1,4 @@ +{ + "sourceRevision": "", + "targetRevision": "target" +} diff --git a/scm-webapp/src/test/resources/sonia/scm/repository/shiro.ini b/scm-webapp/src/test/resources/sonia/scm/repository/shiro.ini index 9a39a2d46c..500325faf3 100644 --- a/scm-webapp/src/test/resources/sonia/scm/repository/shiro.ini +++ b/scm-webapp/src/test/resources/sonia/scm/repository/shiro.ini @@ -4,6 +4,7 @@ dent = secret, creator, heartOfGold, puzzle42 unpriv = secret crato = secret, creator community = secret, oss +user = secret, user [roles] admin = * @@ -11,3 +12,4 @@ creator = repository:create heartOfGold = "repository:read,modify,delete:hof" puzzle42 = "repository:read,write:p42" oss = "repository:pull" +user = *