mirror of
https://github.com/photoprism/photoprism.git
synced 2025-12-12 00:34:13 +01:00
This adds standard OAuth2 client credentials and bearer token support as well as scope-based authorization checks for REST API clients. Note that this initial implementation should not be used in production and that the access token limit has not been implemented yet. Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
14
go.mod
14
go.mod
@@ -57,7 +57,7 @@ require (
|
||||
|
||||
require github.com/olekukonko/tablewriter v0.0.5
|
||||
|
||||
require github.com/google/uuid v1.4.0
|
||||
require github.com/google/uuid v1.5.0
|
||||
|
||||
require (
|
||||
github.com/chzyer/readline v1.5.1 // indirect
|
||||
@@ -73,6 +73,11 @@ require (
|
||||
|
||||
require github.com/go-ldap/ldap/v3 v3.4.6
|
||||
|
||||
require (
|
||||
github.com/prometheus/client_golang v1.17.0
|
||||
github.com/prometheus/common v0.45.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
@@ -97,16 +102,13 @@ require (
|
||||
github.com/leodido/go-urn v1.2.4 // indirect
|
||||
github.com/mandykoh/go-parallel v0.1.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
|
||||
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_golang v1.17.0 // indirect
|
||||
github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 // indirect
|
||||
github.com/prometheus/common v0.44.0 // indirect
|
||||
github.com/prometheus/procfs v0.11.1 // indirect
|
||||
github.com/rivo/uniseg v0.2.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.10.0 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
@@ -124,7 +126,7 @@ require (
|
||||
github.com/abema/go-mp4 v1.1.1
|
||||
github.com/bytedance/sonic v1.10.0 // indirect
|
||||
github.com/go-errors/errors v1.5.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.15.3 // indirect
|
||||
github.com/go-playground/validator/v10 v10.16.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
|
||||
github.com/sunfish-shogi/bufseekio v0.1.0
|
||||
golang.org/x/arch v0.5.0 // indirect
|
||||
|
||||
108
go.sum
108
go.sum
@@ -611,6 +611,14 @@ github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm
|
||||
github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19/go.mod h1:T13YZdzov6OU0A1+RfKZiZN9ca6VeKdBdyDV+BY97Tk=
|
||||
github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw=
|
||||
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM=
|
||||
github.com/alecthomas/kingpin/v2 v2.3.1/go.mod h1:oYL5vtsvEHZGHxU7DMp32Dvx+qL+ptGn6lWaot2vCNE=
|
||||
github.com/alecthomas/kingpin/v2 v2.3.2/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE=
|
||||
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
|
||||
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
|
||||
github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 h1:Kk6a4nehpJ3UuJRqlA3JxYxBZEqCeOmATOvrbT4p9RA=
|
||||
github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
@@ -621,6 +629,8 @@ github.com/apache/arrow/go/v11 v11.0.0/go.mod h1:Eg5OsL5H+e299f7u5ssuXsuHQVEGC4x
|
||||
github.com/apache/thrift v0.16.0/go.mod h1:PHK3hniurgQaNMZYaCLEqXKsYK8upmhPbmdP2FXSqgU=
|
||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=
|
||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
|
||||
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
|
||||
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
@@ -633,8 +643,10 @@ github.com/bytedance/sonic v1.10.0/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf5
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw=
|
||||
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
|
||||
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
|
||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
@@ -771,11 +783,20 @@ github.com/go-fonts/stix v0.1.0/go.mod h1:w/c1f0ldAUlJmLBvlbkvVXLAD+tAMqobIIQpmn
|
||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
|
||||
github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
|
||||
github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
|
||||
github.com/go-latex/latex v0.0.0-20210118124228-b3d85cf34e07/go.mod h1:CO1AlKB2CSIqUrmQPqA0gdRIlnLEY0gK5JGjh37zN5U=
|
||||
github.com/go-latex/latex v0.0.0-20210823091927-c0d11ff05a81/go.mod h1:SX0U8uGpxhq9o2S/CELCSUxEWWAuoCUcVCQWv7G2OCk=
|
||||
github.com/go-latex/latex v0.0.0-20230307184459-12ec69307ad9/go.mod h1:gWuR/CrFDDeVRFQwHPvsv9soJVB/iqymhuZQuJ3a9OM=
|
||||
github.com/go-ldap/ldap/v3 v3.4.6 h1:ert95MdbiG7aWo/oPYp9btL3KJlMPKnP58r09rI8T+A=
|
||||
github.com/go-ldap/ldap/v3 v3.4.6/go.mod h1:IGMQANNtxpsOzj7uUAMjpGBaOVTC4DYyIy8VsTdxmtc=
|
||||
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
|
||||
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
|
||||
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
|
||||
github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
|
||||
github.com/go-pdf/fpdf v0.5.0/go.mod h1:HzcnA+A23uwogo0tp9yU+l3V+KXhiESpt1PMayhOh5M=
|
||||
github.com/go-pdf/fpdf v0.6.0/go.mod h1:HzcnA+A23uwogo0tp9yU+l3V+KXhiESpt1PMayhOh5M=
|
||||
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
@@ -789,10 +810,11 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=
|
||||
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||
github.com/go-playground/validator/v10 v10.15.3 h1:S+sSpunYjNPDuXkWbK+x+bA7iXiW296KG4dL3X7xUZo=
|
||||
github.com/go-playground/validator/v10 v10.15.3/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||
github.com/go-playground/validator/v10 v10.16.0 h1:x+plE831WK4vaKHO/jpgUGsvLKIqRRkz6M78GuJAfGE=
|
||||
github.com/go-playground/validator/v10 v10.16.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||
github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
|
||||
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/go-xmlfmt/xmlfmt v0.0.0-20191208150333-d5b6f63a941b/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
|
||||
github.com/go-xmlfmt/xmlfmt v1.1.2 h1:Nea7b4icn8s57fTx1M5AI4qQT5HEM3rVUO8MuE6g80U=
|
||||
github.com/go-xmlfmt/xmlfmt v1.1.2/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
|
||||
@@ -801,6 +823,7 @@ github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGF
|
||||
github.com/goccy/go-json v0.9.11/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY=
|
||||
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||
@@ -898,8 +921,8 @@ github.com/google/s2a-go v0.1.4/go.mod h1:Ej+mSEMGRnqRzjc7VtF+jdBwYG5fuJfiZ8ELkj
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
|
||||
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
|
||||
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.0.0-20220520183353-fd19c99a87aa/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.1.0/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.2.0/go.mod h1:8C0jb7/mgJe/9KK8Lm7X9ctZC2t60YyIpYEI16jx0Qg=
|
||||
@@ -952,10 +975,16 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.0.1 h1:HjfetcXq097iXP0uoPCdnM4Efp5/9MsM0/M+XOTeR3M=
|
||||
github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
|
||||
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
|
||||
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
||||
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
|
||||
github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
|
||||
github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
|
||||
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 h1:iQTw/8FWTuc7uiaSepXwyf3o52HaUYcV+Tu66S3F5GA=
|
||||
@@ -971,11 +1000,15 @@ github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8t
|
||||
github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc=
|
||||
github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
@@ -1017,17 +1050,24 @@ github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71
|
||||
github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/mattn/go-sqlite3 v2.0.1+incompatible h1:xQ15muvnzGBHpIpdrNi1DA5x0+TcBZzsIDwmw9uTHzw=
|
||||
github.com/mattn/go-sqlite3 v2.0.1+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
|
||||
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
|
||||
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
|
||||
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY=
|
||||
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
|
||||
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
@@ -1048,21 +1088,46 @@ github.com/phpdave11/gofpdi v1.0.12/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk
|
||||
github.com/phpdave11/gofpdi v1.0.13/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
|
||||
github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
|
||||
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
|
||||
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
|
||||
github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
|
||||
github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY=
|
||||
github.com/prometheus/client_golang v1.14.0/go.mod h1:8vpkKitgIVNcqrRBWh1C4TIUQgYNtG/XQE4E/Zae36Y=
|
||||
github.com/prometheus/client_golang v1.15.1/go.mod h1:e9yaBhRPU2pPNsZwE+JdQl0KEt1N9XgF6zxWmaC0xOk=
|
||||
github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q=
|
||||
github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY=
|
||||
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
|
||||
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=
|
||||
github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU=
|
||||
github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 h1:v7DLqVdK4VrYkVD5diGdl4sxJurKJEMnODWRJlxV9oM=
|
||||
github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU=
|
||||
github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY=
|
||||
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
|
||||
github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
|
||||
github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc=
|
||||
github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
|
||||
github.com/prometheus/common v0.37.0/go.mod h1:phzohg0JFMnBEFGxTDbfu3QyL5GI8gTQJFhYO5B3mfA=
|
||||
github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc=
|
||||
github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY=
|
||||
github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM=
|
||||
github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY=
|
||||
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
|
||||
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
|
||||
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
|
||||
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
|
||||
github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
|
||||
github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4=
|
||||
github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY=
|
||||
github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwaUuI=
|
||||
github.com/prometheus/procfs v0.11.1/go.mod h1:eesXgaPo1q7lBpVMoMy0ZOFTth9hBn4W/y0/p/ScXhY=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
@@ -1073,8 +1138,8 @@ github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6L
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo=
|
||||
github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
|
||||
@@ -1087,6 +1152,9 @@ github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0
|
||||
github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
|
||||
github.com/sevlyar/go-daemon v0.1.6 h1:EUh1MDjEM4BI109Jign0EaknA2izkOyi0LV3ro3QQGs=
|
||||
github.com/sevlyar/go-daemon v0.1.6/go.mod h1:6dJpPatBT9eUwM5VCw9Bt6CdX9Tk6UWvhW3MebLDRKE=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
|
||||
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
@@ -1095,6 +1163,7 @@ github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY52
|
||||
github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
|
||||
github.com/spf13/afero v1.9.2/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
@@ -1134,6 +1203,8 @@ github.com/ulule/deepcopier v0.0.0-20200430083143-45decc6639b6 h1:TtyC78WMafNW8Q
|
||||
github.com/ulule/deepcopier v0.0.0-20200430083143-45decc6639b6/go.mod h1:h8272+G2omSmi30fBXiZDMkmHuOgonplfKIKjQWzlfs=
|
||||
github.com/urfave/cli v1.22.14 h1:ebbhrRiGK2i4naQJr+1Xj92HXZCrK7MsyTS/ob3HnAk=
|
||||
github.com/urfave/cli v1.22.14/go.mod h1:X0eDS6pD6Exaclxm99NJ3FiCDRED7vIHpx2mDOHLvkA=
|
||||
github.com/xhit/go-str2duration v1.2.0/go.mod h1:3cPSlfZlUHVlneIVfePFWcJZsuwf+P1v2SRTV4cUmp4=
|
||||
github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU=
|
||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
@@ -1165,6 +1236,7 @@ golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUu
|
||||
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.5.0 h1:jpGode6huXQxcskEIpOCvrU+tzo81b6+oFLUYXWtH/Y=
|
||||
golang.org/x/arch v0.5.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
@@ -1256,6 +1328,7 @@ golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
@@ -1263,6 +1336,7 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
|
||||
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
@@ -1291,6 +1365,7 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
|
||||
golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
@@ -1316,6 +1391,7 @@ golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
|
||||
@@ -1350,6 +1426,7 @@ golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I
|
||||
golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw=
|
||||
golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4=
|
||||
golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE=
|
||||
golang.org/x/oauth2 v0.12.0/go.mod h1:A74bZ3aGXgCY0qaIC9Ahg6Lglin4AMAco8cIv9baba4=
|
||||
golang.org/x/oauth2 v0.13.0 h1:jDDenyj+WgFtmV3zYVoi8aE2BwtXFLWOA67ZfNWftiY=
|
||||
golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn0=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -1369,13 +1446,17 @@ golang.org/x/sync v0.0.0-20220819030929-7fc1605a5dde/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
|
||||
golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -1385,6 +1466,7 @@ golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -1398,6 +1480,8 @@ golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -1406,6 +1490,7 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210304124612-50617c2ba197/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -1417,6 +1502,7 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -1430,6 +1516,7 @@ golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220207234003-57398862261d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -1458,6 +1545,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
|
||||
@@ -1859,8 +1948,10 @@ google.golang.org/protobuf v1.29.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqw
|
||||
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
@@ -1871,8 +1962,11 @@ gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI
|
||||
gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
|
||||
gopkg.in/src-d/go-billy.v4 v4.3.2 h1:0SQA1pRztfTFx2miS8sA97XvooFeNOmvUenF4o0EcVg=
|
||||
gopkg.in/src-d/go-billy.v4 v4.3.2/go.mod h1:nDjArDMp+XMs1aFAESLRjfGSgfvoYN0hDfzEk0GjC98=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
|
||||
@@ -1,11 +1,51 @@
|
||||
package acl
|
||||
|
||||
// Predefined grants to simplify configuration.
|
||||
// Standard grants provided to simplify configuration.
|
||||
var (
|
||||
GrantFullAccess = Grant{FullAccess: true, AccessAll: true, AccessLibrary: true, ActionCreate: true, ActionUpdate: true, ActionDelete: true, ActionDownload: true, ActionShare: true, ActionRate: true, ActionReact: true, ActionManage: true, ActionSubscribe: true}
|
||||
GrantSearchShared = Grant{AccessShared: true, ActionSearch: true, ActionView: true, ActionDownload: true}
|
||||
GrantSubscribeAll = Grant{AccessAll: true, ActionSubscribe: true}
|
||||
GrantSubscribeOwn = Grant{AccessOwn: true, ActionSubscribe: true}
|
||||
GrantFullAccess = Grant{
|
||||
FullAccess: true,
|
||||
AccessAll: true,
|
||||
AccessOwn: true,
|
||||
AccessShared: true,
|
||||
AccessLibrary: true,
|
||||
ActionCreate: true,
|
||||
ActionUpdate: true,
|
||||
ActionDelete: true,
|
||||
ActionDownload: true,
|
||||
ActionShare: true,
|
||||
ActionRate: true,
|
||||
ActionReact: true,
|
||||
ActionManage: true,
|
||||
ActionSubscribe: true,
|
||||
}
|
||||
GrantSubscribeAll = Grant{
|
||||
AccessAll: true,
|
||||
ActionSubscribe: true,
|
||||
}
|
||||
GrantSubscribeOwn = Grant{
|
||||
AccessOwn: true,
|
||||
ActionSubscribe: true,
|
||||
}
|
||||
GrantViewAll = Grant{
|
||||
AccessAll: true,
|
||||
ActionView: true,
|
||||
}
|
||||
GrantViewOwn = Grant{
|
||||
AccessOwn: true,
|
||||
ActionView: true,
|
||||
}
|
||||
GrantViewShared = Grant{
|
||||
AccessShared: true,
|
||||
ActionView: true,
|
||||
ActionDownload: true,
|
||||
}
|
||||
GrantSearchShared = Grant{
|
||||
AccessShared: true,
|
||||
ActionSearch: true,
|
||||
ActionView: true,
|
||||
ActionDownload: true,
|
||||
}
|
||||
GrantNone = Grant{}
|
||||
)
|
||||
|
||||
// Grant represents permissions granted or denied.
|
||||
@@ -21,3 +61,10 @@ func (grant Grant) Allow(perm Permission) bool {
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// GrantDefaults defines default grants for all supported roles.
|
||||
var GrantDefaults = Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
RoleVisitor: GrantViewShared,
|
||||
RoleClient: GrantFullAccess,
|
||||
}
|
||||
|
||||
@@ -4,26 +4,28 @@ import "strings"
|
||||
|
||||
// Resources that Roles can be granted Permission.
|
||||
const (
|
||||
ResourceDefault Resource = "default"
|
||||
ResourcePhotos Resource = "photos"
|
||||
ResourceFavorites Resource = "favorites"
|
||||
ResourceAlbums Resource = "albums"
|
||||
ResourcePeople Resource = "people"
|
||||
ResourceMoments Resource = "moments"
|
||||
ResourceCalendar Resource = "calendar"
|
||||
ResourcePlaces Resource = "places"
|
||||
ResourceLabels Resource = "labels"
|
||||
ResourceLogs Resource = "logs"
|
||||
ResourceConfig Resource = "config"
|
||||
ResourceSettings Resource = "settings"
|
||||
ResourcePassword Resource = "password"
|
||||
ResourceUsers Resource = "users"
|
||||
ResourceServices Resource = "services"
|
||||
ResourceFiles Resource = "files"
|
||||
ResourceFolders Resource = "folders"
|
||||
ResourceShares Resource = "shares"
|
||||
ResourcePhotos Resource = "photos"
|
||||
ResourceVideos Resource = "videos"
|
||||
ResourceFavorites Resource = "favorites"
|
||||
ResourceAlbums Resource = "albums"
|
||||
ResourceMoments Resource = "moments"
|
||||
ResourceCalendar Resource = "calendar"
|
||||
ResourcePeople Resource = "people"
|
||||
ResourcePlaces Resource = "places"
|
||||
ResourceLabels Resource = "labels"
|
||||
ResourceConfig Resource = "config"
|
||||
ResourceSettings Resource = "settings"
|
||||
ResourcePassword Resource = "password"
|
||||
ResourceServices Resource = "services"
|
||||
ResourceUsers Resource = "users"
|
||||
ResourceLogs Resource = "logs"
|
||||
ResourceWebDAV Resource = "webdav"
|
||||
ResourceMetrics Resource = "metrics"
|
||||
ResourceFeedback Resource = "feedback"
|
||||
ResourceDefault Resource = "default"
|
||||
)
|
||||
|
||||
// Resource represents a resource for which roles can be granted Permission.
|
||||
|
||||
@@ -3,71 +3,82 @@ package acl
|
||||
// Resources specifies granted permissions by Resource and Role.
|
||||
var Resources = ACL{
|
||||
ResourceFiles: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
},
|
||||
ResourcePhotos: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
RoleVisitor: Grant{AccessShared: true, ActionView: true, ActionDownload: true},
|
||||
},
|
||||
ResourceVideos: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
RoleVisitor: Grant{AccessShared: true, ActionView: true, ActionDownload: true},
|
||||
},
|
||||
ResourceAlbums: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
RoleVisitor: GrantSearchShared,
|
||||
RoleAdmin: GrantFullAccess,
|
||||
RoleClient: GrantFullAccess,
|
||||
},
|
||||
ResourceFolders: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
RoleVisitor: GrantSearchShared,
|
||||
RoleClient: GrantFullAccess,
|
||||
},
|
||||
ResourcePlaces: Roles{
|
||||
ResourceShares: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
},
|
||||
ResourcePhotos: GrantDefaults,
|
||||
ResourceVideos: GrantDefaults,
|
||||
ResourceFavorites: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
RoleClient: GrantFullAccess,
|
||||
},
|
||||
ResourceAlbums: GrantDefaults,
|
||||
ResourceMoments: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
RoleVisitor: Grant{AccessShared: true, ActionView: true, ActionDownload: true},
|
||||
RoleVisitor: GrantSearchShared,
|
||||
RoleClient: GrantFullAccess,
|
||||
},
|
||||
ResourceCalendar: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
RoleVisitor: GrantSearchShared,
|
||||
},
|
||||
ResourceMoments: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
RoleVisitor: GrantSearchShared,
|
||||
RoleClient: GrantFullAccess,
|
||||
},
|
||||
ResourcePeople: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
RoleAdmin: GrantFullAccess,
|
||||
RoleClient: GrantFullAccess,
|
||||
},
|
||||
ResourceFavorites: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
ResourcePlaces: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
RoleVisitor: Grant{AccessShared: true, ActionView: true, ActionDownload: true},
|
||||
RoleClient: GrantFullAccess,
|
||||
},
|
||||
ResourceLabels: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
RoleAdmin: GrantFullAccess,
|
||||
RoleClient: GrantFullAccess,
|
||||
},
|
||||
ResourceLogs: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
ResourceConfig: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
RoleClient: GrantViewOwn,
|
||||
RoleDefault: GrantViewOwn,
|
||||
},
|
||||
ResourceSettings: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
RoleVisitor: Grant{AccessOwn: true, ActionView: true},
|
||||
},
|
||||
ResourceFeedback: Roles{
|
||||
ResourceServices: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
},
|
||||
ResourcePassword: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
},
|
||||
ResourceShares: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
},
|
||||
ResourceServices: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
},
|
||||
ResourceUsers: Roles{
|
||||
RoleAdmin: Grant{AccessAll: true, AccessOwn: true, ActionView: true, ActionCreate: true, ActionUpdate: true, ActionDelete: true, ActionSubscribe: true},
|
||||
},
|
||||
ResourceConfig: Roles{
|
||||
ResourceLogs: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
RoleClient: GrantFullAccess,
|
||||
},
|
||||
ResourceWebDAV: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
RoleClient: GrantFullAccess,
|
||||
},
|
||||
ResourceMetrics: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
RoleClient: GrantViewAll,
|
||||
},
|
||||
ResourceFeedback: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
},
|
||||
ResourceDefault: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
RoleAdmin: GrantFullAccess,
|
||||
RoleClient: GrantNone,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ const (
|
||||
RoleDefault Role = "default"
|
||||
RoleAdmin Role = "admin"
|
||||
RoleVisitor Role = "visitor"
|
||||
RoleClient Role = "client"
|
||||
RoleUnknown Role = ""
|
||||
)
|
||||
|
||||
|
||||
@@ -2,9 +2,11 @@ package api
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/acl"
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/internal/event"
|
||||
"github.com/photoprism/photoprism/pkg/authn"
|
||||
)
|
||||
|
||||
// Auth checks if the user has permission to access the specified resource and returns the session if so.
|
||||
@@ -14,27 +16,63 @@ func Auth(c *gin.Context, resource acl.Resource, grant acl.Permission) *entity.S
|
||||
|
||||
// AuthAny checks if at least one permission allows access and returns the session in this case.
|
||||
func AuthAny(c *gin.Context, resource acl.Resource, grants acl.Permissions) (s *entity.Session) {
|
||||
// Get client IP address and session ID, if any.
|
||||
// Get the client IP and session ID from the request headers.
|
||||
ip := ClientIP(c)
|
||||
sessId := SessionID(c)
|
||||
sid := SessionID(c)
|
||||
|
||||
// Find client session.
|
||||
if s = Session(sessId); s == nil {
|
||||
event.AuditWarn([]string{ip, "unauthenticated", "%s %s as unknown user", "denied"}, grants.String(), string(resource))
|
||||
// Find active session to perform authorization check or deny if no session was found.
|
||||
if s = Session(sid); s == nil {
|
||||
event.AuditWarn([]string{ip, "unauthenticated", "%s %s", "denied"}, grants.String(), string(resource))
|
||||
return entity.SessionStatusUnauthorized()
|
||||
} else {
|
||||
s.SetClientIP(ip)
|
||||
}
|
||||
|
||||
// Check authorization.
|
||||
if s.User() == nil {
|
||||
event.AuditWarn([]string{ip, "session %s", "%s %s as unknown user", "denied"}, s.RefID, grants.String(), string(resource))
|
||||
// If the request is from a client application, check its authorization based
|
||||
// on the allowed scope, the ACL, and the user account it belongs to (if any).
|
||||
if s.Provider() == authn.ProviderClient {
|
||||
// Check ACL resource name against the permitted scope.
|
||||
if !s.HasScope(resource.String()) {
|
||||
event.AuditErr([]string{ip, "client %s", "session %s", "access %s", "denied"}, s.AuthID, s.RefID, string(resource))
|
||||
return s
|
||||
}
|
||||
|
||||
// Perform an authorization check based on the ACL defaults for client applications.
|
||||
if acl.Resources.DenyAll(resource, acl.RoleClient, grants) {
|
||||
event.AuditErr([]string{ip, "client %s", "session %s", "%s %s", "denied"}, s.AuthID, s.RefID, grants.String(), string(resource))
|
||||
return entity.SessionStatusForbidden()
|
||||
}
|
||||
|
||||
// Additionally check the user authorization if the client belongs to a user account.
|
||||
if s.NoUser() {
|
||||
// Allow access based on the ACL defaults for client applications.
|
||||
event.AuditInfo([]string{ip, "client %s", "session %s", "%s %s", "granted"}, s.AuthID, s.RefID, grants.String(), string(resource))
|
||||
} else if u := s.User(); !u.IsDisabled() && !u.IsUnknown() && u.IsRegistered() {
|
||||
if acl.Resources.DenyAll(resource, u.AclRole(), grants) {
|
||||
event.AuditErr([]string{ip, "client %s", "session %s", "%s %s as %s", "denied"}, s.AuthID, s.RefID, grants.String(), string(resource), u.String())
|
||||
return entity.SessionStatusForbidden()
|
||||
}
|
||||
|
||||
// Allow access based on the user role.
|
||||
event.AuditInfo([]string{ip, "client %s", "session %s", "%s %s as %s", "granted"}, s.AuthID, s.RefID, grants.String(), string(resource), u.String())
|
||||
} else {
|
||||
// Deny access if it is not a regular user account or the account has been disabled.
|
||||
event.AuditErr([]string{ip, "client %s", "session %s", "%s %s as unauthorized user", "denied"}, s.AuthID, s.RefID, grants.String(), string(resource))
|
||||
return entity.SessionStatusForbidden()
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// Otherwise, perform a regular ACL authorization check based on the user role.
|
||||
if u := s.User(); u.IsUnknown() || u.IsDisabled() {
|
||||
event.AuditWarn([]string{ip, "session %s", "%s %s as unauthorized user", "denied"}, s.RefID, grants.String(), string(resource))
|
||||
return entity.SessionStatusUnauthorized()
|
||||
} else if acl.Resources.DenyAll(resource, s.User().AclRole(), grants) {
|
||||
event.AuditErr([]string{ip, "session %s", "%s %s as %s", "denied"}, s.RefID, grants.String(), string(resource), s.User().AclRole().String())
|
||||
} else if acl.Resources.DenyAll(resource, u.AclRole(), grants) {
|
||||
event.AuditErr([]string{ip, "session %s", "%s %s as %s", "denied"}, s.RefID, grants.String(), string(resource), u.AclRole().String())
|
||||
return entity.SessionStatusForbidden()
|
||||
} else {
|
||||
event.AuditInfo([]string{ip, "session %s", "%s %s as %s", "granted"}, s.RefID, grants.String(), string(resource), s.User().AclRole().String())
|
||||
event.AuditInfo([]string{ip, "session %s", "%s %s as %s", "granted"}, s.RefID, grants.String(), string(resource), u.AclRole().String())
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
81
internal/api/auth_header.go
Normal file
81
internal/api/auth_header.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/photoprism/photoprism/internal/server/header"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
)
|
||||
|
||||
// SessionID returns the session ID from the request context,
|
||||
// or an empty string if there is none.
|
||||
func SessionID(c *gin.Context) string {
|
||||
// Default is an empty string if no context or ID is set.
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// First check the X-Session-ID header for an existing ID.
|
||||
if id := clean.ID(c.GetHeader(header.SessionID)); id != "" {
|
||||
return id
|
||||
}
|
||||
|
||||
// Otherwise, return the bearer token, if any.
|
||||
return BearerToken(c)
|
||||
}
|
||||
|
||||
// BearerToken returns the value of the bearer token header, or an empty string if there is none.
|
||||
func BearerToken(c *gin.Context) string {
|
||||
if authType, bearerToken := Authorization(c); authType == "Bearer" && bearerToken != "" {
|
||||
return bearerToken
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// Authorization returns the authentication type and token from the authorization request header,
|
||||
// or an empty string if there is none.
|
||||
func Authorization(c *gin.Context) (authType, authToken string) {
|
||||
if s := c.GetHeader(header.Authorization); s == "" {
|
||||
// Ignore.
|
||||
} else if t := strings.Split(s, " "); len(t) != 2 {
|
||||
// Ignore.
|
||||
} else {
|
||||
return clean.ID(t[0]), clean.ID(t[1])
|
||||
}
|
||||
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// BasicAuth checks the basic authorization header for credentials and returns them if found.
|
||||
//
|
||||
// Note that OAuth 2.0 defines basic authentication differently than RFC 7617, however, this
|
||||
// does not matter as long as only alphanumeric characters are used for client id and secret:
|
||||
// https://www.scottbrady91.com/oauth/client-authentication#:~:text=OAuth%20Basic%20Authentication
|
||||
func BasicAuth(c *gin.Context) (username, password, cacheKey string) {
|
||||
authType, authToken := Authorization(c)
|
||||
|
||||
if authType != "Basic" || authToken == "" {
|
||||
return "", "", ""
|
||||
}
|
||||
|
||||
auth, err := base64.StdEncoding.DecodeString(authToken)
|
||||
|
||||
if err != nil {
|
||||
return "", "", ""
|
||||
}
|
||||
|
||||
credentials := strings.SplitN(string(auth), ":", 2)
|
||||
|
||||
if len(credentials) != 2 {
|
||||
return "", "", ""
|
||||
}
|
||||
|
||||
cacheKey = fmt.Sprintf("%x", sha1.Sum([]byte(authToken)))
|
||||
|
||||
return credentials[0], credentials[1], cacheKey
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/form"
|
||||
"github.com/photoprism/photoprism/internal/session"
|
||||
"github.com/photoprism/photoprism/internal/server/header"
|
||||
)
|
||||
|
||||
// AuthenticateAdmin Register session routes and returns valid SessionId.
|
||||
@@ -27,7 +27,7 @@ func AuthenticateUser(app *gin.Engine, router *gin.RouterGroup, name string, pas
|
||||
Password: password,
|
||||
}))
|
||||
|
||||
sessId = r.Header().Get(session.Header)
|
||||
sessId = r.Header().Get(header.SessionID)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -37,7 +37,7 @@ func AuthenticatedRequest(r http.Handler, method, path, sess string) *httptest.R
|
||||
req, _ := http.NewRequest(method, path, nil)
|
||||
|
||||
if sess != "" {
|
||||
req.Header.Add(session.Header, sess)
|
||||
req.Header.Add(header.SessionID, sess)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -52,7 +52,7 @@ func AuthenticatedRequestWithBody(r http.Handler, method, path, body string, ses
|
||||
req, _ := http.NewRequest(method, path, reader)
|
||||
|
||||
if sess != "" {
|
||||
req.Header.Add(session.Header, sess)
|
||||
req.Header.Add(header.SessionID, sess)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/internal/get"
|
||||
"github.com/photoprism/photoprism/internal/session"
|
||||
"github.com/photoprism/photoprism/internal/server/header"
|
||||
)
|
||||
|
||||
// AddCountHeader adds the actual result count to the response.
|
||||
@@ -33,7 +33,7 @@ func AddDownloadHeader(c *gin.Context, fileName string) {
|
||||
|
||||
// AddSessionHeader adds a session id header to the response.
|
||||
func AddSessionHeader(c *gin.Context, id string) {
|
||||
c.Header(session.Header, id)
|
||||
c.Header(header.SessionID, id)
|
||||
}
|
||||
|
||||
// AddContentTypeHeader adds a content type header to the response.
|
||||
|
||||
@@ -5,18 +5,29 @@ import (
|
||||
"runtime"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"github.com/prometheus/client_golang/prometheus/collectors"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"github.com/prometheus/common/expfmt"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/acl"
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/get"
|
||||
)
|
||||
|
||||
// GetMetrics provides a prometheus-compatible metrics endpoint for monitoring.
|
||||
//
|
||||
// GET /api/v1/metrics
|
||||
func GetMetrics(router *gin.RouterGroup) {
|
||||
router.GET("/metrics", func(c *gin.Context) {
|
||||
s := Auth(c, acl.ResourceMetrics, acl.AccessAll)
|
||||
|
||||
// Abort if permission was not granted.
|
||||
if s.Abort(c) {
|
||||
return
|
||||
}
|
||||
|
||||
conf := get.Config()
|
||||
counts := conf.ClientPublic().Count
|
||||
|
||||
@@ -47,7 +58,7 @@ func GetMetrics(router *gin.RouterGroup) {
|
||||
})
|
||||
}
|
||||
|
||||
// Register metrics that exposes various statistics for this instance.
|
||||
// registerCountMetrics registers metrics that can be monitored with the /api/v1/metrics endpoint.=
|
||||
func registerCountMetrics(factory promauto.Factory, counts config.ClientCounts) {
|
||||
metric := factory.NewGaugeVec(
|
||||
prometheus.GaugeOpts{
|
||||
@@ -66,7 +77,7 @@ func registerCountMetrics(factory promauto.Factory, counts config.ClientCounts)
|
||||
metric.With(prometheus.Labels{"stat": "files"}).Set(float64(counts.Files))
|
||||
}
|
||||
|
||||
// Register a metric that exposes build information for this instance.
|
||||
// registerBuildInfoMetric registers a metric that provides build information.
|
||||
func registerBuildInfoMetric(factory promauto.Factory, conf config.ClientConfig) {
|
||||
factory.NewGaugeVec(
|
||||
prometheus.GaugeOpts{
|
||||
|
||||
@@ -2,8 +2,8 @@ package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
104
internal/api/oauth.go
Normal file
104
internal/api/oauth.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/photoprism/photoprism/pkg/authn"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/internal/event"
|
||||
"github.com/photoprism/photoprism/internal/form"
|
||||
"github.com/photoprism/photoprism/internal/get"
|
||||
"github.com/photoprism/photoprism/internal/i18n"
|
||||
"github.com/photoprism/photoprism/internal/server/limiter"
|
||||
)
|
||||
|
||||
// CreateOauthToken creates a new access token and returns it as JSON
|
||||
// if the client's credentials have been successfully validated.
|
||||
//
|
||||
// POST /api/v1/oauth/token
|
||||
func CreateOauthToken(router *gin.RouterGroup) {
|
||||
router.POST("/oauth/token", func(c *gin.Context) {
|
||||
// client_id, client_secret
|
||||
var err error
|
||||
var f form.ClientCredentials
|
||||
|
||||
// Get client IP address for logs and rate limiting checks.
|
||||
clientIP := ClientIP(c)
|
||||
|
||||
// Allow authentication with basic auth and form values.
|
||||
if clientId, clientSecret, _ := BasicAuth(c); clientId != "" && clientSecret != "" {
|
||||
f.ClientID = clientId
|
||||
f.ClientSecret = clientSecret
|
||||
} else if err = c.Bind(&f); err != nil {
|
||||
event.AuditWarn([]string{clientIP, "oauth", "%s"}, err)
|
||||
AbortBadRequest(c)
|
||||
return
|
||||
}
|
||||
|
||||
// Check the credentials for completeness and the correct format.
|
||||
if err = f.Validate(); err != nil {
|
||||
event.AuditWarn([]string{clientIP, "oauth", "%s"}, err)
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
|
||||
return
|
||||
}
|
||||
|
||||
// Check limit for failed auth requests (max. 10 per minute).
|
||||
if limiter.Login.Reject(clientIP) {
|
||||
limiter.AbortJSON(c)
|
||||
return
|
||||
}
|
||||
|
||||
// Find the client that has the ID specified in the authentication request.
|
||||
client := entity.FindClient(f.ClientID)
|
||||
|
||||
// Abort if the client ID or secret are invalid.
|
||||
if client == nil {
|
||||
event.AuditWarn([]string{clientIP, "client %s", "create access token", "invalid client id"}, f.ClientID)
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
|
||||
limiter.Login.Reserve(clientIP)
|
||||
return
|
||||
} else if !client.AuthEnabled {
|
||||
event.AuditWarn([]string{clientIP, "client %s", "create access token", "authentication disabled"}, f.ClientID)
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
|
||||
return
|
||||
} else if client.AuthMethod != authn.MethodOAuth2.String() {
|
||||
event.AuditWarn([]string{clientIP, "client %s", "create access token", "%s authentication not supported"}, f.ClientID, client.AuthMethod)
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
|
||||
return
|
||||
} else if client.WrongSecret(f.ClientSecret) {
|
||||
event.AuditWarn([]string{clientIP, "client %s", "create access token", "invalid client secret"}, f.ClientID)
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
|
||||
limiter.Login.Reserve(clientIP)
|
||||
return
|
||||
}
|
||||
|
||||
// Create new client session.
|
||||
sess := client.NewSession(c)
|
||||
|
||||
// TODO: Enforce limit for maximum number of access tokens.
|
||||
|
||||
// Try to log in and save session if successful.
|
||||
if sess, err = get.Session().Save(sess); err != nil {
|
||||
event.AuditErr([]string{clientIP, "client %s", "create access token", "%s"}, f.ClientID, err)
|
||||
c.AbortWithStatusJSON(sess.HttpStatus(), gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
|
||||
return
|
||||
} else if sess == nil {
|
||||
event.AuditErr([]string{clientIP, "client %s", "create access token", "failed unexpectedly"}, f.ClientID)
|
||||
c.AbortWithStatusJSON(sess.HttpStatus(), gin.H{"error": i18n.Msg(i18n.ErrUnexpected)})
|
||||
return
|
||||
} else {
|
||||
event.AuditInfo([]string{clientIP, "client %s", "session %s", "access token created"}, f.ClientID, sess.RefID)
|
||||
}
|
||||
|
||||
// Return access token.
|
||||
data := gin.H{
|
||||
"access_token": sess.ID,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": sess.ExpiresIn(),
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, data)
|
||||
})
|
||||
}
|
||||
38
internal/api/oauth_test.go
Normal file
38
internal/api/oauth_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCreateOauthToken(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
app, router, _ := NewApiTest()
|
||||
CreateOauthToken(router)
|
||||
|
||||
var method = "POST"
|
||||
var path = "/api/v1/oauth/token"
|
||||
|
||||
data := url.Values{
|
||||
"grant_type": {"client_credentials"},
|
||||
"client_id": {"cs5cpu17n6gj2qo5"},
|
||||
"client_secret": {"xcCbOrw6I0vcoXzhnOmXhjpVSyFq0l0e"},
|
||||
"scope": {"metrics"},
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest(method, path, strings.NewReader(data.Encode()))
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, req)
|
||||
|
||||
t.Logf("Header: %s", w.Header())
|
||||
t.Logf("BODY: %s", w.Body.String())
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
})
|
||||
}
|
||||
@@ -1,25 +1,10 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/internal/get"
|
||||
"github.com/photoprism/photoprism/internal/session"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
)
|
||||
|
||||
// SessionID returns the session ID from the request context.
|
||||
func SessionID(c *gin.Context) (sessId string) {
|
||||
if c == nil {
|
||||
// Should never happen.
|
||||
return ""
|
||||
}
|
||||
|
||||
// Get the authentication token from the HTTP headers.
|
||||
return clean.ID(c.GetHeader(session.Header))
|
||||
}
|
||||
|
||||
// Session finds the client session for the given ID or returns nil otherwise.
|
||||
func Session(id string) *entity.Session {
|
||||
// Skip authentication if app is running in public mode.
|
||||
|
||||
@@ -78,7 +78,7 @@ func ZipCreate(router *gin.RouterGroup) {
|
||||
// Configure file names.
|
||||
dlName := DownloadName(c)
|
||||
zipPath := path.Join(conf.TempPath(), "zip")
|
||||
zipToken := rnd.GenerateToken(8)
|
||||
zipToken := rnd.Base36(8)
|
||||
zipBaseName := fmt.Sprintf("photoprism-download-%s-%s.zip", time.Now().Format("20060102-150405"), zipToken)
|
||||
zipFileName := path.Join(zipPath, zipBaseName)
|
||||
|
||||
|
||||
99
internal/commands/clients.go
Normal file
99
internal/commands/clients.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"github.com/photoprism/photoprism/pkg/authn"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
// Usage hints for the client management subcommands.
|
||||
const (
|
||||
ClientNameUsage = "arbitrary name to help identify the `CLIENT` application"
|
||||
ClientUserName = "a `USERNAME` is only required if the client belongs to a specific account"
|
||||
ClientAuthMethod = "supported authentication `METHOD` for the client application"
|
||||
ClientAuthScope = "authorization `SCOPE` of the client e.g. \"metrics\" (\"*\" to allow all scopes)"
|
||||
ClientAuthExpires = "access token expiration time in `SECONDS`, after which a new token must be created"
|
||||
ClientAuthTokens = "maximum `NUMBER` of access tokens the client can create (-1 to disable the limit)"
|
||||
ClientRegenerateSecret = "generate a new client secret and display it"
|
||||
ClientDisable = "deactivate authentication with this client"
|
||||
ClientEnable = "re-enable client authentication"
|
||||
)
|
||||
|
||||
// ClientsCommand configures the client application subcommands.
|
||||
var ClientsCommand = cli.Command{
|
||||
Name: "clients",
|
||||
Usage: "API authentication subcommands",
|
||||
Subcommands: []cli.Command{
|
||||
ClientsListCommand,
|
||||
ClientsAddCommand,
|
||||
ClientsShowCommand,
|
||||
ClientsModCommand,
|
||||
ClientsRemoveCommand,
|
||||
ClientsResetCommand,
|
||||
},
|
||||
}
|
||||
|
||||
// ClientAddFlags specifies the "photoprism client add" command flags.
|
||||
var ClientAddFlags = []cli.Flag{
|
||||
cli.StringFlag{
|
||||
Name: "name, n",
|
||||
Usage: ClientNameUsage,
|
||||
},
|
||||
cli.StringFlag{
|
||||
Name: "user, u",
|
||||
Usage: ClientUserName,
|
||||
},
|
||||
cli.StringFlag{
|
||||
Name: "method, m",
|
||||
Usage: ClientAuthMethod,
|
||||
Value: authn.MethodOAuth2.String(),
|
||||
},
|
||||
cli.StringFlag{
|
||||
Name: "scope, s",
|
||||
Usage: ClientAuthScope,
|
||||
},
|
||||
cli.Int64Flag{
|
||||
Name: "expires, e",
|
||||
Usage: ClientAuthExpires,
|
||||
},
|
||||
cli.Int64Flag{
|
||||
Name: "tokens, t",
|
||||
Usage: ClientAuthTokens,
|
||||
},
|
||||
}
|
||||
|
||||
// ClientModFlags specifies the "photoprism client mod" command flags.
|
||||
var ClientModFlags = []cli.Flag{
|
||||
cli.StringFlag{
|
||||
Name: "name, n",
|
||||
Usage: ClientNameUsage,
|
||||
},
|
||||
cli.StringFlag{
|
||||
Name: "method, m",
|
||||
Usage: ClientAuthMethod,
|
||||
Value: authn.MethodOAuth2.String(),
|
||||
},
|
||||
cli.StringFlag{
|
||||
Name: "scope, s",
|
||||
Usage: ClientAuthScope,
|
||||
},
|
||||
cli.Int64Flag{
|
||||
Name: "expires, e",
|
||||
Usage: ClientAuthExpires,
|
||||
},
|
||||
cli.Int64Flag{
|
||||
Name: "tokens, t",
|
||||
Usage: ClientAuthTokens,
|
||||
},
|
||||
cli.BoolFlag{
|
||||
Name: "regenerate-secret, r",
|
||||
Usage: ClientRegenerateSecret,
|
||||
},
|
||||
cli.BoolFlag{
|
||||
Name: "disable",
|
||||
Usage: ClientDisable,
|
||||
},
|
||||
cli.BoolFlag{
|
||||
Name: "enable",
|
||||
Usage: ClientEnable,
|
||||
},
|
||||
}
|
||||
138
internal/commands/clients_add.go
Normal file
138
internal/commands/clients_add.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/manifoldco/promptui"
|
||||
"github.com/urfave/cli"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/internal/form"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/list"
|
||||
"github.com/photoprism/photoprism/pkg/report"
|
||||
)
|
||||
|
||||
// ClientsAddCommand configures the command name, flags, and action.
|
||||
var ClientsAddCommand = cli.Command{
|
||||
Name: "add",
|
||||
Usage: "Registers a new client application",
|
||||
Flags: ClientAddFlags,
|
||||
Action: clientsAddAction,
|
||||
}
|
||||
|
||||
// clientsAddAction registers a new client application.
|
||||
func clientsAddAction(ctx *cli.Context) error {
|
||||
return CallWithDependencies(ctx, func(conf *config.Config) error {
|
||||
conf.MigrateDb(false, nil)
|
||||
|
||||
frm := form.NewClientFromCli(ctx)
|
||||
|
||||
interactive := true
|
||||
|
||||
if frm.ClientName != "" && frm.AuthScope != "" {
|
||||
log.Debugf("client will be added in non-interactive mode")
|
||||
interactive = false
|
||||
}
|
||||
|
||||
if interactive && frm.ClientName == "" {
|
||||
prompt := promptui.Prompt{
|
||||
Label: "Client Name",
|
||||
}
|
||||
|
||||
res, err := prompt.Run()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
frm.ClientName = clean.Name(res)
|
||||
}
|
||||
|
||||
// Set a default client name if no specific name has been provided.
|
||||
if frm.ClientName == "" {
|
||||
frm.ClientName = time.Now().UTC().Format(time.DateTime)
|
||||
}
|
||||
|
||||
if interactive && frm.AuthScope == "" {
|
||||
prompt := promptui.Prompt{
|
||||
Label: "Authorization Scope",
|
||||
}
|
||||
|
||||
res, err := prompt.Run()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
frm.AuthScope = clean.Scope(res)
|
||||
}
|
||||
|
||||
// Set a default client name if no specific name has been provided.
|
||||
if frm.AuthScope == "" {
|
||||
frm.AuthScope = list.All
|
||||
}
|
||||
|
||||
client, addErr := entity.AddClient(frm)
|
||||
|
||||
if addErr != nil {
|
||||
return fmt.Errorf("failed to add client: %s", addErr)
|
||||
} else {
|
||||
log.Infof("successfully registered new client %s", clean.LogQuote(client.ClientName))
|
||||
|
||||
// Display client details.
|
||||
cols := []string{"Client ID", "Client Name", "Authentication", "Scope", "User", "Enabled", "Access Token Expires", "Created At"}
|
||||
rows := make([][]string, 1)
|
||||
|
||||
var userName string
|
||||
if client.UserUID == "" {
|
||||
userName = report.NotAssigned
|
||||
} else if client.UserName != "" {
|
||||
userName = client.UserName
|
||||
} else {
|
||||
userName = client.UserUID
|
||||
}
|
||||
|
||||
var authExpires string
|
||||
if client.AuthExpires > 0 {
|
||||
authExpires = client.Expires().String()
|
||||
} else {
|
||||
authExpires = report.Never
|
||||
}
|
||||
|
||||
if client.AuthTokens > 0 {
|
||||
authExpires = fmt.Sprintf("%s, max %d tokens", authExpires, client.AuthTokens)
|
||||
}
|
||||
|
||||
rows[0] = []string{
|
||||
client.UID(),
|
||||
client.ClientName,
|
||||
client.AuthMethod,
|
||||
client.AuthScope,
|
||||
userName,
|
||||
report.Bool(client.AuthEnabled, report.Yes, report.No),
|
||||
authExpires,
|
||||
client.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
|
||||
if result, err := report.RenderFormat(rows, cols, report.CliFormat(ctx)); err == nil {
|
||||
fmt.Printf("\n%s", result)
|
||||
}
|
||||
}
|
||||
|
||||
if secret, err := client.NewSecret(); err != nil {
|
||||
// Failed to create client secret.
|
||||
return fmt.Errorf("failed to create client secret: %s", err)
|
||||
} else {
|
||||
// Show client authentication credentials.
|
||||
fmt.Printf("\nTHE FOLLOWING RANDOMLY GENERATED CLIENT ID AND SECRET ARE REQUIRED FOR AUTHENTICATION:\n")
|
||||
result := report.Credentials("Client ID", client.ClientUID, "Client Secret", secret)
|
||||
fmt.Printf("\n%s", result)
|
||||
fmt.Printf("\nPLEASE WRITE THE CREDENTIALS DOWN AND KEEP THEM IN A SAFE PLACE, AS THE SECRET CANNOT BE DISPLAYED AGAIN.\n\n")
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
85
internal/commands/clients_list.go
Normal file
85
internal/commands/clients_list.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/dustin/go-humanize/english"
|
||||
"github.com/urfave/cli"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/query"
|
||||
"github.com/photoprism/photoprism/pkg/report"
|
||||
)
|
||||
|
||||
// ClientsListCommand configures the command name, flags, and action.
|
||||
var ClientsListCommand = cli.Command{
|
||||
Name: "ls",
|
||||
Usage: "Lists registered client applications",
|
||||
ArgsUsage: "[search]",
|
||||
Flags: append(report.CliFlags, countFlag),
|
||||
Action: clientsListAction,
|
||||
}
|
||||
|
||||
// clientsListAction lists registered client applications
|
||||
func clientsListAction(ctx *cli.Context) error {
|
||||
return CallWithDependencies(ctx, func(conf *config.Config) error {
|
||||
cols := []string{"Client ID", "Client Name", "Authentication", "Scope", "User", "Enabled", "Access Token Expires", "Created At"}
|
||||
|
||||
// Fetch clients from database.
|
||||
clients, err := query.Clients(ctx.Int("n"), 0, "", ctx.Args().First())
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows := make([][]string, len(clients))
|
||||
|
||||
if len(clients) == 0 {
|
||||
log.Warnf("no clients registered")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Show log message.
|
||||
log.Infof("found %s", english.Plural(len(clients), "client", "clients"))
|
||||
|
||||
// Display report.
|
||||
for i, client := range clients {
|
||||
var userName string
|
||||
if client.UserUID == "" {
|
||||
userName = report.NotAssigned
|
||||
} else if client.UserName != "" {
|
||||
userName = client.UserName
|
||||
} else {
|
||||
userName = client.UserUID
|
||||
}
|
||||
|
||||
var authExpires string
|
||||
if client.AuthExpires > 0 {
|
||||
authExpires = client.Expires().String()
|
||||
} else {
|
||||
authExpires = report.Never
|
||||
}
|
||||
|
||||
if client.AuthTokens > 0 {
|
||||
authExpires = fmt.Sprintf("%s, max %d tokens", authExpires, client.AuthTokens)
|
||||
}
|
||||
|
||||
rows[i] = []string{
|
||||
client.UID(),
|
||||
client.ClientName,
|
||||
client.AuthMethod,
|
||||
client.AuthScope,
|
||||
userName,
|
||||
report.Bool(client.AuthEnabled, report.Yes, report.No),
|
||||
authExpires,
|
||||
client.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
}
|
||||
|
||||
result, err := report.RenderFormat(rows, cols, report.CliFormat(ctx))
|
||||
|
||||
fmt.Printf("\n%s\n", result)
|
||||
|
||||
return err
|
||||
})
|
||||
}
|
||||
111
internal/commands/clients_mod.go
Normal file
111
internal/commands/clients_mod.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/urfave/cli"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/pkg/authn"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/report"
|
||||
)
|
||||
|
||||
// ClientsModCommand configures the command name, flags, and action.
|
||||
var ClientsModCommand = cli.Command{
|
||||
Name: "mod",
|
||||
Usage: "Updates client application settings",
|
||||
ArgsUsage: "[id]",
|
||||
Flags: ClientModFlags,
|
||||
Action: clientsModAction,
|
||||
}
|
||||
|
||||
// clientsModAction updates client application settings.
|
||||
func clientsModAction(ctx *cli.Context) error {
|
||||
return CallWithDependencies(ctx, func(conf *config.Config) error {
|
||||
conf.MigrateDb(false, nil)
|
||||
|
||||
id := clean.UID(ctx.Args().First())
|
||||
|
||||
// Name or UID provided?
|
||||
if id == "" {
|
||||
log.Infof("no valid client id specified")
|
||||
return cli.ShowSubcommandHelp(ctx)
|
||||
}
|
||||
|
||||
// Find client record.
|
||||
var client *entity.Client
|
||||
|
||||
client = entity.FindClient(id)
|
||||
|
||||
if client == nil {
|
||||
return fmt.Errorf("client %s not found", clean.LogQuote(id))
|
||||
}
|
||||
|
||||
if name := clean.Name(ctx.String("name")); name != "" {
|
||||
client.ClientName = name
|
||||
}
|
||||
|
||||
if ctx.IsSet("method") {
|
||||
client.AuthMethod = authn.Method(ctx.String("method")).String()
|
||||
}
|
||||
|
||||
if ctx.IsSet("scope") {
|
||||
client.SetScope(ctx.String("scope"))
|
||||
}
|
||||
|
||||
if expires := ctx.Int64("expires"); expires != 0 {
|
||||
if expires > entity.UnixMonth {
|
||||
client.AuthExpires = entity.UnixMonth
|
||||
} else if expires > 0 {
|
||||
client.AuthExpires = expires
|
||||
} else if expires <= 0 {
|
||||
client.AuthExpires = entity.UnixHour
|
||||
}
|
||||
}
|
||||
|
||||
if tokens := ctx.Int64("tokens"); tokens != 0 {
|
||||
if tokens > 2147483647 {
|
||||
client.AuthTokens = 2147483647
|
||||
} else if tokens > 0 {
|
||||
client.AuthTokens = tokens
|
||||
} else if tokens < 0 {
|
||||
client.AuthTokens = -1
|
||||
}
|
||||
}
|
||||
|
||||
if ctx.IsSet("disable") && ctx.Bool("disable") {
|
||||
client.AuthEnabled = false
|
||||
log.Infof("disabled client authentication")
|
||||
} else if ctx.IsSet("enable") && ctx.Bool("enable") {
|
||||
client.AuthEnabled = true
|
||||
log.Warnf("enabled client authentication")
|
||||
}
|
||||
|
||||
// Save changes.
|
||||
if err := client.Validate(); err != nil {
|
||||
return fmt.Errorf("invalid client settings: %s", err)
|
||||
} else if err = client.Save(); err != nil {
|
||||
return fmt.Errorf("failed to update client settings: %s", err)
|
||||
} else {
|
||||
log.Infof("client %s has been updated", clean.LogQuote(client.ClientName))
|
||||
}
|
||||
|
||||
// Regenerate and display secret, if requested.
|
||||
if ctx.IsSet("regenerate-secret") && ctx.Bool("regenerate-secret") {
|
||||
if secret, err := client.NewSecret(); err != nil {
|
||||
// Failed to create client secret.
|
||||
return fmt.Errorf("failed to create client secret: %s", err)
|
||||
} else {
|
||||
// Show client authentication credentials.
|
||||
fmt.Printf("\nTHE FOLLOWING RANDOMLY GENERATED CLIENT ID AND SECRET ARE REQUIRED FOR AUTHENTICATION:\n")
|
||||
result := report.Credentials("Client ID", client.ClientUID, "Client Secret", secret)
|
||||
fmt.Printf("\n%s", result)
|
||||
fmt.Printf("\nPLEASE WRITE THE CREDENTIALS DOWN AND KEEP THEM IN A SAFE PLACE, AS THE SECRET CANNOT BE DISPLAYED AGAIN.\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
72
internal/commands/clients_remove.go
Normal file
72
internal/commands/clients_remove.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/manifoldco/promptui"
|
||||
"github.com/urfave/cli"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
)
|
||||
|
||||
// ClientsRemoveCommand configures the command name, flags, and action.
|
||||
var ClientsRemoveCommand = cli.Command{
|
||||
Name: "rm",
|
||||
Usage: "Deletes a registered client application",
|
||||
ArgsUsage: "[id]",
|
||||
Flags: []cli.Flag{
|
||||
cli.BoolFlag{
|
||||
Name: "force, f",
|
||||
Usage: "don't ask for confirmation",
|
||||
},
|
||||
},
|
||||
Action: clientsRemoveAction,
|
||||
}
|
||||
|
||||
// clientsRemoveAction deletes a registered client application
|
||||
func clientsRemoveAction(ctx *cli.Context) error {
|
||||
return CallWithDependencies(ctx, func(conf *config.Config) error {
|
||||
conf.MigrateDb(false, nil)
|
||||
|
||||
id := clean.UID(ctx.Args().First())
|
||||
|
||||
// Name or UID provided?
|
||||
if id == "" {
|
||||
log.Infof("no valid client id specified")
|
||||
return cli.ShowSubcommandHelp(ctx)
|
||||
}
|
||||
|
||||
// Find client record.
|
||||
var m *entity.Client
|
||||
|
||||
m = entity.FindClient(id)
|
||||
|
||||
if m == nil {
|
||||
return fmt.Errorf("client %s not found", clean.LogQuote(id))
|
||||
} else if m.Deleted() {
|
||||
return fmt.Errorf("client %s has already been deleted", clean.LogQuote(id))
|
||||
}
|
||||
|
||||
if !ctx.Bool("force") {
|
||||
actionPrompt := promptui.Prompt{
|
||||
Label: fmt.Sprintf("Delete client %s?", m.UID()),
|
||||
IsConfirm: true,
|
||||
}
|
||||
|
||||
if _, err := actionPrompt.Run(); err != nil {
|
||||
log.Infof("client %s was not deleted", m.UID())
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := m.Delete(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Infof("client %s has been deleted", m.UID())
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
69
internal/commands/clients_reset.go
Normal file
69
internal/commands/clients_reset.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/manifoldco/promptui"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
)
|
||||
|
||||
// ClientsResetCommand configures the command name, flags, and action.
|
||||
var ClientsResetCommand = cli.Command{
|
||||
Name: "reset",
|
||||
Usage: "Removes all registered client applications",
|
||||
Flags: []cli.Flag{
|
||||
cli.BoolFlag{
|
||||
Name: "trace, t",
|
||||
Usage: "show trace logs for debugging",
|
||||
},
|
||||
cli.BoolFlag{
|
||||
Name: "yes, y",
|
||||
Usage: "assume \"yes\" and run non-interactively",
|
||||
},
|
||||
},
|
||||
Action: clientsResetAction,
|
||||
}
|
||||
|
||||
// clientsResetAction removes all registered client applications.
|
||||
func clientsResetAction(ctx *cli.Context) error {
|
||||
return CallWithDependencies(ctx, func(conf *config.Config) error {
|
||||
confirmed := ctx.Bool("yes")
|
||||
|
||||
// Show prompt?
|
||||
if !confirmed {
|
||||
actionPrompt := promptui.Prompt{
|
||||
Label: fmt.Sprintf("Reset the client database to a clean state?"),
|
||||
IsConfirm: true,
|
||||
}
|
||||
|
||||
if _, err := actionPrompt.Run(); err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if ctx.Bool("trace") {
|
||||
log.SetLevel(logrus.TraceLevel)
|
||||
log.Infoln("reset: enabled trace mode")
|
||||
}
|
||||
|
||||
db := conf.Db()
|
||||
|
||||
// Drop existing auth_clients table.
|
||||
if err := db.DropTableIfExists(entity.Client{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Re-create auth_clients.
|
||||
if err := db.CreateTable(entity.Client{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Infof("the client database has been recreated and is now in a clean state")
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
55
internal/commands/clients_show.go
Normal file
55
internal/commands/clients_show.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/urfave/cli"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/report"
|
||||
)
|
||||
|
||||
// ClientsShowCommand configures the command name, flags, and action.
|
||||
var ClientsShowCommand = cli.Command{
|
||||
Name: "show",
|
||||
Usage: "Shows client configuration details",
|
||||
ArgsUsage: "[id]",
|
||||
Flags: report.CliFlags,
|
||||
Action: clientsShowAction,
|
||||
}
|
||||
|
||||
// clientsShowAction displays the current client application settings
|
||||
func clientsShowAction(ctx *cli.Context) error {
|
||||
return CallWithDependencies(ctx, func(conf *config.Config) error {
|
||||
id := clean.UID(ctx.Args().First())
|
||||
|
||||
// Name or UID provided?
|
||||
if id == "" {
|
||||
return cli.ShowSubcommandHelp(ctx)
|
||||
}
|
||||
|
||||
// Find client record.
|
||||
var m *entity.Client
|
||||
|
||||
m = entity.FindClient(id)
|
||||
|
||||
if m == nil {
|
||||
return fmt.Errorf("client %s not found", clean.LogQuote(id))
|
||||
}
|
||||
|
||||
// Get client information.
|
||||
rows, cols := m.Report(true)
|
||||
|
||||
// Sort values by name.
|
||||
report.Sort(rows)
|
||||
|
||||
// Show client information.
|
||||
result, err := report.RenderFormat(rows, cols, report.CliFormat(ctx))
|
||||
|
||||
fmt.Printf("\n%s\n", result)
|
||||
|
||||
return err
|
||||
})
|
||||
}
|
||||
@@ -63,12 +63,20 @@ var PhotoPrism = []cli.Command{
|
||||
ResetCommand,
|
||||
PasswdCommand,
|
||||
UsersCommand,
|
||||
ClientsCommand,
|
||||
ShowCommand,
|
||||
VersionCommand,
|
||||
ShowConfigCommand,
|
||||
ConnectCommand,
|
||||
}
|
||||
|
||||
// countFlag represents a CLI flag to limit the number of report rows.
|
||||
var countFlag = cli.UintFlag{
|
||||
Name: "n",
|
||||
Usage: "`LIMIT` number of results",
|
||||
Value: 100,
|
||||
}
|
||||
|
||||
// childAlreadyRunning tests if a .pid file at filePath is a running process.
|
||||
// it returns the pid value and the running status (true or false).
|
||||
func childAlreadyRunning(filePath string) (pid int, running bool) {
|
||||
|
||||
@@ -64,7 +64,7 @@ func passwdAction(ctx *cli.Context) error {
|
||||
|
||||
if m == nil {
|
||||
return fmt.Errorf("user %s not found", clean.LogQuote(id))
|
||||
} else if m.Deleted() {
|
||||
} else if m.IsDeleted() {
|
||||
return fmt.Errorf("user %s has been deleted", clean.LogQuote(id))
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
// UsersAddCommand configures the command name, flags, and action.
|
||||
var UsersAddCommand = cli.Command{
|
||||
Name: "add",
|
||||
Usage: "Adds a new user account",
|
||||
Usage: "Creates a new user account",
|
||||
ArgsUsage: "[username]",
|
||||
Flags: UserFlags,
|
||||
Action: usersAddAction,
|
||||
@@ -55,7 +55,7 @@ func usersAddAction(ctx *cli.Context) error {
|
||||
if frm.UserName == "" {
|
||||
return fmt.Errorf("username is required")
|
||||
} else if m := entity.FindUserByName(frm.UserName); m != nil {
|
||||
if !m.Deleted() {
|
||||
if !m.IsDeleted() {
|
||||
return fmt.Errorf("user already exists")
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
// UsersLegacyCommand configures the command name, flags, and action.
|
||||
var UsersLegacyCommand = cli.Command{
|
||||
Name: "legacy",
|
||||
Usage: "Displays legacy user accounts",
|
||||
Usage: "Lists legacy user accounts",
|
||||
ArgsUsage: "[search]",
|
||||
Flags: report.CliFlags,
|
||||
Action: usersLegacyAction,
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
// UsersListCommand configures the command name, flags, and action.
|
||||
var UsersListCommand = cli.Command{
|
||||
Name: "ls",
|
||||
Usage: "Displays existing user accounts",
|
||||
Usage: "Lists registered user accounts",
|
||||
Flags: report.CliFlags,
|
||||
Action: usersListAction,
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
// UsersModCommand configures the command name, flags, and action.
|
||||
var UsersModCommand = cli.Command{
|
||||
Name: "mod",
|
||||
Usage: "Modifies an existing user account",
|
||||
Usage: "Changes user account settings",
|
||||
ArgsUsage: "[username]",
|
||||
Flags: UserFlags,
|
||||
Action: usersModAction,
|
||||
@@ -47,7 +47,7 @@ func usersModAction(ctx *cli.Context) error {
|
||||
}
|
||||
|
||||
// Check if account exists but is deleted.
|
||||
if m.Deleted() {
|
||||
if m.IsDeleted() {
|
||||
prompt := promptui.Prompt{
|
||||
Label: fmt.Sprintf("Restore user %s?", m.String()),
|
||||
IsConfirm: true,
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
// UsersRemoveCommand configures the command name, flags, and action.
|
||||
var UsersRemoveCommand = cli.Command{
|
||||
Name: "rm",
|
||||
Usage: "Removes a user account",
|
||||
Usage: "Deletes a registered user account",
|
||||
ArgsUsage: "[username]",
|
||||
Flags: []cli.Flag{
|
||||
cli.BoolFlag{
|
||||
@@ -49,7 +49,7 @@ func usersRemoveAction(ctx *cli.Context) error {
|
||||
|
||||
if m == nil {
|
||||
return fmt.Errorf("user %s not found", clean.LogQuote(id))
|
||||
} else if m.Deleted() {
|
||||
} else if m.IsDeleted() {
|
||||
return fmt.Errorf("user %s has already been deleted", clean.LogQuote(id))
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
// UsersResetCommand configures the command name, flags, and action.
|
||||
var UsersResetCommand = cli.Command{
|
||||
Name: "reset",
|
||||
Usage: "Resets the user database to a clean state",
|
||||
Usage: "Removes all registered user accounts",
|
||||
Flags: []cli.Flag{
|
||||
cli.BoolFlag{
|
||||
Name: "trace, t",
|
||||
|
||||
@@ -3,20 +3,19 @@ package commands
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
|
||||
"github.com/urfave/cli"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/report"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
)
|
||||
|
||||
// UsersShowCommand configures the command name, flags, and action.
|
||||
var UsersShowCommand = cli.Command{
|
||||
Name: "show",
|
||||
Usage: "Shows user account information",
|
||||
Usage: "Shows detailed account information",
|
||||
ArgsUsage: "[username]",
|
||||
Flags: report.CliFlags,
|
||||
Action: usersShowAction,
|
||||
|
||||
@@ -113,7 +113,7 @@ func NewConfig(ctx *cli.Context) *Config {
|
||||
c := &Config{
|
||||
cliCtx: ctx,
|
||||
options: NewOptions(ctx),
|
||||
token: rnd.GenerateToken(8),
|
||||
token: rnd.Base36(8),
|
||||
env: os.Getenv("DOCKER_ENV"),
|
||||
start: start,
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ func (c *Config) DownloadToken() string {
|
||||
if c.Public() {
|
||||
return entity.TokenPublic
|
||||
} else if c.options.DownloadToken == "" {
|
||||
c.options.DownloadToken = rnd.GenerateToken(8)
|
||||
c.options.DownloadToken = rnd.Base36(8)
|
||||
}
|
||||
|
||||
return c.options.DownloadToken
|
||||
|
||||
@@ -191,7 +191,7 @@ func TestConfig_CreateDirectories(t *testing.T) {
|
||||
|
||||
c := &Config{
|
||||
options: NewTestOptions("config"),
|
||||
token: rnd.GenerateToken(8),
|
||||
token: rnd.Base36(8),
|
||||
}
|
||||
|
||||
if err := c.CreateDirectories(); err != nil {
|
||||
@@ -211,7 +211,7 @@ func TestConfig_CreateDirectories2(t *testing.T) {
|
||||
defer testConfigMutex.Unlock()
|
||||
c := &Config{
|
||||
options: NewTestOptions(),
|
||||
token: rnd.GenerateToken(8),
|
||||
token: rnd.Base36(8),
|
||||
}
|
||||
c.options.AssetsPath = ""
|
||||
|
||||
@@ -235,7 +235,7 @@ func TestConfig_CreateDirectories2(t *testing.T) {
|
||||
defer testConfigMutex.Unlock()
|
||||
c := &Config{
|
||||
options: NewTestOptions(),
|
||||
token: rnd.GenerateToken(8),
|
||||
token: rnd.Base36(8),
|
||||
}
|
||||
|
||||
c.options.StoragePath = "/-*&^%$#@!`~"
|
||||
@@ -252,7 +252,7 @@ func TestConfig_CreateDirectories2(t *testing.T) {
|
||||
defer testConfigMutex.Unlock()
|
||||
c := &Config{
|
||||
options: NewTestOptions(),
|
||||
token: rnd.GenerateToken(8),
|
||||
token: rnd.Base36(8),
|
||||
}
|
||||
c.options.OriginalsPath = ""
|
||||
|
||||
@@ -277,7 +277,7 @@ func TestConfig_CreateDirectories2(t *testing.T) {
|
||||
defer testConfigMutex.Unlock()
|
||||
c := &Config{
|
||||
options: NewTestOptions(),
|
||||
token: rnd.GenerateToken(8),
|
||||
token: rnd.Base36(8),
|
||||
}
|
||||
c.options.ImportPath = ""
|
||||
|
||||
@@ -302,7 +302,7 @@ func TestConfig_CreateDirectories2(t *testing.T) {
|
||||
defer testConfigMutex.Unlock()
|
||||
c := &Config{
|
||||
options: NewTestOptions(),
|
||||
token: rnd.GenerateToken(8),
|
||||
token: rnd.Base36(8),
|
||||
}
|
||||
|
||||
c.options.SidecarPath = "/-*&^%$#@!`~"
|
||||
@@ -319,7 +319,7 @@ func TestConfig_CreateDirectories2(t *testing.T) {
|
||||
defer testConfigMutex.Unlock()
|
||||
c := &Config{
|
||||
options: NewTestOptions(),
|
||||
token: rnd.GenerateToken(8),
|
||||
token: rnd.Base36(8),
|
||||
}
|
||||
|
||||
c.options.CachePath = "/-*&^%$#@!`~"
|
||||
@@ -336,7 +336,7 @@ func TestConfig_CreateDirectories2(t *testing.T) {
|
||||
defer testConfigMutex.Unlock()
|
||||
c := &Config{
|
||||
options: NewTestOptions(),
|
||||
token: rnd.GenerateToken(8),
|
||||
token: rnd.Base36(8),
|
||||
}
|
||||
|
||||
c.options.ConfigPath = "/-*&^%$#@!`~"
|
||||
@@ -353,7 +353,7 @@ func TestConfig_CreateDirectories2(t *testing.T) {
|
||||
defer testConfigMutex.Unlock()
|
||||
c := &Config{
|
||||
options: NewTestOptions(),
|
||||
token: rnd.GenerateToken(8),
|
||||
token: rnd.Base36(8),
|
||||
}
|
||||
|
||||
c.options.TempPath = "/-*&^%$#@!`~"
|
||||
|
||||
@@ -160,7 +160,7 @@ func NewTestConfig(pkg string) *Config {
|
||||
c := &Config{
|
||||
cliCtx: CliTestContext(),
|
||||
options: NewTestOptions(pkg),
|
||||
token: rnd.GenerateToken(8),
|
||||
token: rnd.Base36(8),
|
||||
}
|
||||
|
||||
s := customize.NewSettings(c.DefaultTheme(), c.DefaultLocale())
|
||||
|
||||
370
internal/entity/auth_client.go
Normal file
370
internal/entity/auth_client.go
Normal file
@@ -0,0 +1,370 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jinzhu/gorm"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/event"
|
||||
"github.com/photoprism/photoprism/internal/form"
|
||||
"github.com/photoprism/photoprism/pkg/authn"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
)
|
||||
|
||||
// ClientUID is the unique ID prefix.
|
||||
const (
|
||||
ClientUID = byte('c')
|
||||
)
|
||||
|
||||
// Clients represents a list of client applications.
|
||||
type Clients []Client
|
||||
|
||||
// Client represents a client application.
|
||||
type Client struct {
|
||||
ClientUID string `gorm:"type:VARBINARY(42);primary_key;auto_increment:false;" json:"-" yaml:"ClientUID"`
|
||||
UserUID string `gorm:"type:VARBINARY(42);index;default:'';" json:"UserUID" yaml:"UserUID,omitempty"`
|
||||
UserName string `gorm:"size:64;index;" json:"UserName" yaml:"UserName,omitempty"`
|
||||
user *User `gorm:"-"`
|
||||
ClientName string `gorm:"size:200;" json:"ClientName" yaml:"ClientName,omitempty"`
|
||||
ClientType string `gorm:"type:VARBINARY(16)" json:"ClientType" yaml:"ClientType,omitempty"`
|
||||
ClientURL string `gorm:"type:VARBINARY(255);default:'';column:client_url;" json:"ClientURL" yaml:"ClientURL,omitempty"`
|
||||
CallbackURL string `gorm:"type:VARBINARY(255);default:'';column:callback_url;" json:"CallbackURL" yaml:"CallbackURL,omitempty"`
|
||||
AuthMethod string `gorm:"type:VARBINARY(128);default:'';" json:"AuthMethod" yaml:"AuthMethod,omitempty"`
|
||||
AuthScope string `gorm:"size:1024;default:'';" json:"AuthScope" yaml:"AuthScope,omitempty"`
|
||||
AuthExpires int64 `json:"AuthExpires" yaml:"AuthExpires,omitempty"`
|
||||
AuthTokens int64 `json:"AuthTokens" yaml:"AuthTokens,omitempty"` // TODO: Enforce limit for number of tokens.
|
||||
AuthEnabled bool `json:"AuthEnabled" yaml:"AuthEnabled,omitempty"`
|
||||
LastActive int64 `json:"LastActive" yaml:"LastActive,omitempty"`
|
||||
CreatedAt time.Time `json:"CreatedAt" yaml:"-"`
|
||||
UpdatedAt time.Time `json:"UpdatedAt" yaml:"-"`
|
||||
DeletedAt *time.Time `sql:"index" json:"DeletedAt,omitempty" yaml:"-"`
|
||||
}
|
||||
|
||||
// TableName returns the entity table name.
|
||||
func (Client) TableName() string {
|
||||
return "auth_clients"
|
||||
}
|
||||
|
||||
// NewClient returns a new client application instance.
|
||||
func NewClient() *Client {
|
||||
return &Client{
|
||||
UserUID: "",
|
||||
ClientName: "",
|
||||
ClientType: authn.ClientConfidential,
|
||||
ClientURL: "",
|
||||
CallbackURL: "",
|
||||
AuthMethod: authn.MethodOAuth2.String(),
|
||||
AuthScope: "",
|
||||
AuthExpires: UnixHour,
|
||||
AuthTokens: 5,
|
||||
AuthEnabled: true,
|
||||
LastActive: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// BeforeCreate creates a random UID if needed before inserting a new row to the database.
|
||||
func (m *Client) BeforeCreate(scope *gorm.Scope) error {
|
||||
if rnd.IsUID(m.ClientUID, ClientUID) {
|
||||
return nil
|
||||
}
|
||||
|
||||
m.ClientUID = rnd.GenerateUID(ClientUID)
|
||||
|
||||
return scope.SetColumn("ClientUID", m.ClientUID)
|
||||
}
|
||||
|
||||
// FindClient returns the matching client or nil if it was not found.
|
||||
func FindClient(uid string) *Client {
|
||||
if rnd.InvalidUID(uid, ClientUID) {
|
||||
return nil
|
||||
}
|
||||
|
||||
m := &Client{}
|
||||
|
||||
// Find matching record.
|
||||
if err := UnscopedDb().First(m, "client_uid = ?", uid).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// UID returns the client uid string.
|
||||
func (m *Client) UID() string {
|
||||
return m.ClientUID
|
||||
}
|
||||
|
||||
// HasUID tests if the entity has a valid uid.
|
||||
func (m *Client) HasUID() bool {
|
||||
return rnd.IsUID(m.ClientUID, ClientUID)
|
||||
}
|
||||
|
||||
// User returns the related user account, if any.
|
||||
func (m *Client) User() *User {
|
||||
if m.user != nil {
|
||||
return m.user
|
||||
} else if m.UserUID == "" {
|
||||
return &User{}
|
||||
}
|
||||
|
||||
if u := FindUserByUID(m.UserUID); u != nil {
|
||||
m.user = u
|
||||
return m.user
|
||||
}
|
||||
|
||||
return &User{}
|
||||
}
|
||||
|
||||
// SetUser updates the related user account.
|
||||
func (m *Client) SetUser(u *User) *Client {
|
||||
if u == nil {
|
||||
return m
|
||||
}
|
||||
|
||||
// Update user references.
|
||||
m.user = u
|
||||
m.UserUID = u.UserUID
|
||||
m.UserName = u.UserName
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// Create new entity in the database.
|
||||
func (m *Client) Create() error {
|
||||
return Db().Create(m).Error
|
||||
}
|
||||
|
||||
// Save updates the record in the database or inserts a new record if it does not already exist.
|
||||
func (m *Client) Save() error {
|
||||
return Db().Save(m).Error
|
||||
}
|
||||
|
||||
// Delete marks the entity as deleted.
|
||||
func (m *Client) Delete() (err error) {
|
||||
if m.ClientUID == "" {
|
||||
return fmt.Errorf("client uid is missing")
|
||||
}
|
||||
|
||||
if err = UnscopedDb().Delete(Session{}, "auth_id = ?", m.ClientUID).Error; err != nil {
|
||||
event.AuditErr([]string{"client %s", "delete", "failed to remove sessions", "%s"}, m.ClientUID, err)
|
||||
}
|
||||
|
||||
err = Db().Delete(m).Error
|
||||
|
||||
FlushSessionCache()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Deleted checks if the client has been deleted.
|
||||
func (m *Client) Deleted() bool {
|
||||
if m.DeletedAt == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return !m.DeletedAt.IsZero()
|
||||
}
|
||||
|
||||
// Updates multiple properties in the database.
|
||||
func (m *Client) Updates(values interface{}) error {
|
||||
return UnscopedDb().Model(m).Updates(values).Error
|
||||
}
|
||||
|
||||
// NewSecret sets a new secret stored as hash.
|
||||
func (m *Client) NewSecret() (s string, err error) {
|
||||
if !m.HasUID() {
|
||||
return "", fmt.Errorf("invalid client uid")
|
||||
}
|
||||
|
||||
s = rnd.Base62(32)
|
||||
|
||||
pw := NewPassword(m.ClientUID, s, false)
|
||||
|
||||
if err = pw.Save(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// HasSecret checks if the given client secret is correct.
|
||||
func (m *Client) HasSecret(s string) bool {
|
||||
return !m.WrongSecret(s)
|
||||
}
|
||||
|
||||
// WrongSecret checks if the given client secret is incorrect.
|
||||
func (m *Client) WrongSecret(s string) bool {
|
||||
if !m.HasUID() {
|
||||
return true
|
||||
}
|
||||
|
||||
// Empty secret?
|
||||
if s == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Fetch secret.
|
||||
pw := FindPassword(m.ClientUID)
|
||||
|
||||
// Found?
|
||||
if pw == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
// Invalid?
|
||||
if pw.IsWrong(s) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Method returns the client authentication method.
|
||||
func (m *Client) Method() authn.MethodType {
|
||||
return authn.Method(m.AuthMethod)
|
||||
}
|
||||
|
||||
// Scope returns the client authorization scope.
|
||||
func (m *Client) Scope() string {
|
||||
return clean.Scope(m.AuthScope)
|
||||
}
|
||||
|
||||
// SetScope sets the client authorization scope.
|
||||
func (m *Client) SetScope(s string) *Client {
|
||||
m.AuthScope = clean.Scope(s)
|
||||
return m
|
||||
}
|
||||
|
||||
// UpdateLastActive sets the last activity of the client to now.
|
||||
func (m *Client) UpdateLastActive() *Client {
|
||||
if !m.HasUID() {
|
||||
return m
|
||||
}
|
||||
|
||||
m.LastActive = UnixTime()
|
||||
|
||||
if err := Db().Model(m).UpdateColumn("LastActive", m.LastActive).Error; err != nil {
|
||||
log.Debugf("client: failed to update %s timestamp (%s)", m.ClientUID, err)
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// NewSession creates a new client session.
|
||||
func (m *Client) NewSession(c *gin.Context) *Session {
|
||||
// Update activity timestamp.
|
||||
m.UpdateLastActive()
|
||||
|
||||
// Create, initialize, and return new session.
|
||||
sess := NewSession(m.AuthExpires, 0).SetContext(c)
|
||||
sess.AuthID = m.UID()
|
||||
sess.AuthProvider = authn.ProviderClient.String()
|
||||
sess.AuthMethod = m.Method().String()
|
||||
sess.AuthScope = m.Scope()
|
||||
sess.SetUser(m.User())
|
||||
|
||||
return sess
|
||||
}
|
||||
|
||||
// Expires returns the auth expiration duration.
|
||||
func (m *Client) Expires() time.Duration {
|
||||
return time.Duration(m.AuthExpires) * time.Second
|
||||
}
|
||||
|
||||
// Report returns the entity values as rows.
|
||||
func (m *Client) Report(skipEmpty bool) (rows [][]string, cols []string) {
|
||||
cols = []string{"Name", "Value"}
|
||||
|
||||
// Extract model values.
|
||||
values, _, err := ModelValues(m, "ClientUID")
|
||||
|
||||
// Ok?
|
||||
if err != nil {
|
||||
return rows, cols
|
||||
}
|
||||
|
||||
rows = make([][]string, 0, len(values))
|
||||
|
||||
for k, v := range values {
|
||||
s := fmt.Sprintf("%#v", v)
|
||||
|
||||
// Skip empty values?
|
||||
if !skipEmpty || s != "" {
|
||||
rows = append(rows, []string{k, s})
|
||||
}
|
||||
}
|
||||
|
||||
return rows, cols
|
||||
}
|
||||
|
||||
// SetFormValues sets the values specified in the form.
|
||||
func (m *Client) SetFormValues(frm form.Client) *Client {
|
||||
if frm.UserUID == "" && frm.UserName == "" {
|
||||
// Ignore.
|
||||
} else if u := FindUser(User{UserUID: frm.UserUID, UserName: frm.UserName}); u != nil {
|
||||
m.SetUser(u)
|
||||
}
|
||||
|
||||
if frm.ClientName != "" {
|
||||
m.ClientName = frm.Name()
|
||||
}
|
||||
|
||||
if frm.AuthMethod != "" {
|
||||
m.AuthMethod = frm.Method().String()
|
||||
}
|
||||
|
||||
if frm.AuthScope != "" {
|
||||
m.SetScope(frm.AuthScope)
|
||||
}
|
||||
|
||||
if frm.AuthExpires > UnixMonth {
|
||||
m.AuthExpires = UnixMonth
|
||||
} else if frm.AuthExpires > 0 {
|
||||
m.AuthExpires = frm.AuthExpires
|
||||
} else if m.AuthExpires <= 0 {
|
||||
m.AuthExpires = UnixHour
|
||||
}
|
||||
|
||||
if frm.AuthTokens > 2147483647 {
|
||||
m.AuthTokens = 2147483647
|
||||
} else if frm.AuthTokens > 0 {
|
||||
m.AuthTokens = frm.AuthTokens
|
||||
} else if m.AuthTokens < 0 {
|
||||
m.AuthTokens = -1
|
||||
}
|
||||
|
||||
if frm.AuthEnabled {
|
||||
m.AuthEnabled = true
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// Validate checks the client application properties before saving them.
|
||||
func (m *Client) Validate() (err error) {
|
||||
// Empty client name?
|
||||
if m.ClientName == "" {
|
||||
return errors.New("client name must not be empty")
|
||||
}
|
||||
|
||||
// Empty client type?
|
||||
if m.ClientType == "" {
|
||||
return errors.New("client type must not be empty")
|
||||
}
|
||||
|
||||
// Empty authorization method?
|
||||
if m.AuthMethod == "" {
|
||||
return errors.New("authorization method must not be empty")
|
||||
}
|
||||
|
||||
// Empty authorization scope?
|
||||
if m.AuthScope == "" {
|
||||
return errors.New("authorization scope must not be empty")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
18
internal/entity/auth_client_add.go
Normal file
18
internal/entity/auth_client_add.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"github.com/photoprism/photoprism/internal/form"
|
||||
)
|
||||
|
||||
// AddClient creates a new client and returns it if successful.
|
||||
func AddClient(frm form.Client) (client *Client, err error) {
|
||||
client = NewClient().SetFormValues(frm)
|
||||
|
||||
if err = client.Validate(); err != nil {
|
||||
return client, err
|
||||
} else if err = client.Create(); err != nil {
|
||||
return client, err
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
79
internal/entity/auth_client_fixtures.go
Normal file
79
internal/entity/auth_client_fixtures.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package entity
|
||||
|
||||
import "github.com/photoprism/photoprism/pkg/authn"
|
||||
|
||||
type ClientMap map[string]Client
|
||||
|
||||
func (m ClientMap) Get(name string) Client {
|
||||
if result, ok := m[name]; ok {
|
||||
return result
|
||||
}
|
||||
|
||||
return Client{}
|
||||
}
|
||||
|
||||
func (m ClientMap) Pointer(name string) *Client {
|
||||
if result, ok := m[name]; ok {
|
||||
return &result
|
||||
}
|
||||
|
||||
return &Client{}
|
||||
}
|
||||
|
||||
var ClientFixtures = ClientMap{
|
||||
"alice": {
|
||||
ClientUID: "cs5gfen1bgxz7s9i",
|
||||
UserUID: UserFixtures.Pointer("alice").UserUID,
|
||||
UserName: UserFixtures.Pointer("alice").UserName,
|
||||
user: UserFixtures.Pointer("alice"),
|
||||
ClientName: "Alice",
|
||||
ClientType: authn.ClientConfidential,
|
||||
ClientURL: "",
|
||||
CallbackURL: "",
|
||||
AuthMethod: authn.MethodOAuth2.String(),
|
||||
AuthScope: "*",
|
||||
AuthExpires: UnixDay,
|
||||
AuthTokens: -1,
|
||||
AuthEnabled: true,
|
||||
LastActive: 0,
|
||||
},
|
||||
"bob": {
|
||||
ClientUID: "cs5gfsvbd7ejzn8m",
|
||||
UserUID: UserFixtures.Pointer("bob").UserUID,
|
||||
UserName: UserFixtures.Pointer("bob").UserName,
|
||||
user: UserFixtures.Pointer("bob"),
|
||||
ClientName: "Bob",
|
||||
ClientType: authn.ClientWebDAV,
|
||||
ClientURL: "",
|
||||
CallbackURL: "",
|
||||
AuthMethod: authn.MethodBasic.String(),
|
||||
AuthScope: "webdav files photos",
|
||||
AuthExpires: 0,
|
||||
AuthTokens: -1,
|
||||
AuthEnabled: false,
|
||||
LastActive: 0,
|
||||
},
|
||||
"metrics": {
|
||||
ClientUID: "cs5cpu17n6gj2qo5",
|
||||
UserUID: "",
|
||||
UserName: "",
|
||||
user: nil,
|
||||
ClientName: "Monitoring",
|
||||
ClientType: authn.ClientConfidential,
|
||||
ClientURL: "",
|
||||
CallbackURL: "",
|
||||
AuthMethod: authn.MethodOAuth2.String(),
|
||||
AuthScope: "metrics",
|
||||
AuthExpires: UnixHour,
|
||||
AuthTokens: 2,
|
||||
AuthEnabled: true,
|
||||
LastActive: 0,
|
||||
},
|
||||
}
|
||||
|
||||
// CreateClientFixtures inserts known entities into the database for testing.
|
||||
func CreateClientFixtures() {
|
||||
for _, entity := range ClientFixtures {
|
||||
Db().Create(&entity)
|
||||
}
|
||||
}
|
||||
102
internal/entity/auth_client_test.go
Normal file
102
internal/entity/auth_client_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
m := NewClient()
|
||||
assert.Equal(t, "", m.AuthScope)
|
||||
assert.Equal(t, m.AuthScope, m.Scope())
|
||||
m.SetScope(" metrics WEBdav!")
|
||||
assert.Equal(t, "metrics webdav", m.AuthScope)
|
||||
assert.Equal(t, m.AuthScope, m.Scope())
|
||||
}
|
||||
|
||||
func TestFindClient(t *testing.T) {
|
||||
t.Run("Alice", func(t *testing.T) {
|
||||
expected := ClientFixtures.Get("alice")
|
||||
|
||||
m := FindClient("cs5gfen1bgxz7s9i")
|
||||
|
||||
if m == nil {
|
||||
t.Fatal("result should not be nil")
|
||||
}
|
||||
|
||||
assert.Equal(t, m.UserUID, UserFixtures.Get("alice").UserUID)
|
||||
assert.Equal(t, expected.ClientUID, m.UID())
|
||||
assert.NotEmpty(t, m.CreatedAt)
|
||||
assert.NotEmpty(t, m.UpdatedAt)
|
||||
})
|
||||
t.Run("Bob", func(t *testing.T) {
|
||||
expected := ClientFixtures.Get("bob")
|
||||
|
||||
m := FindClient("cs5gfsvbd7ejzn8m")
|
||||
|
||||
if m == nil {
|
||||
t.Fatal("result should not be nil")
|
||||
}
|
||||
|
||||
assert.Equal(t, m.UserUID, UserFixtures.Get("bob").UserUID)
|
||||
assert.Equal(t, expected.ClientUID, m.UID())
|
||||
assert.NotEmpty(t, m.CreatedAt)
|
||||
assert.NotEmpty(t, m.UpdatedAt)
|
||||
})
|
||||
t.Run("Metrics", func(t *testing.T) {
|
||||
expected := ClientFixtures.Get("metrics")
|
||||
|
||||
m := FindClient("cs5cpu17n6gj2qo5")
|
||||
|
||||
if m == nil {
|
||||
t.Fatal("result should not be nil")
|
||||
}
|
||||
|
||||
assert.Empty(t, m.UserUID)
|
||||
assert.Equal(t, expected.ClientUID, m.UID())
|
||||
assert.NotEmpty(t, m.CreatedAt)
|
||||
assert.NotEmpty(t, m.UpdatedAt)
|
||||
})
|
||||
}
|
||||
|
||||
func TestClient_HasPassword(t *testing.T) {
|
||||
t.Run("Alice", func(t *testing.T) {
|
||||
expected := ClientFixtures.Get("alice")
|
||||
|
||||
m := FindClient("cs5gfen1bgxz7s9i")
|
||||
|
||||
if m == nil {
|
||||
t.Fatal("result should not be nil")
|
||||
}
|
||||
|
||||
assert.Equal(t, expected.ClientUID, m.UID())
|
||||
assert.False(t, m.HasSecret("xcCbOrw6I0vcoXzhnOmXhjpVSyFq0l0e"))
|
||||
assert.False(t, m.HasSecret("aaCbOrw6I0vcoXzhnOmXhjpVSyFq0l0e"))
|
||||
assert.False(t, m.HasSecret(""))
|
||||
assert.True(t, m.WrongSecret("xcCbOrw6I0vcoXzhnOmXhjpVSyFq0l0e"))
|
||||
assert.True(t, m.WrongSecret("aaCbOrw6I0vcoXzhnOmXhjpVSyFq0l0e"))
|
||||
assert.True(t, m.WrongSecret(""))
|
||||
assert.NotEmpty(t, m.CreatedAt)
|
||||
assert.NotEmpty(t, m.UpdatedAt)
|
||||
})
|
||||
t.Run("Metrics", func(t *testing.T) {
|
||||
expected := ClientFixtures.Get("metrics")
|
||||
|
||||
m := FindClient("cs5cpu17n6gj2qo5")
|
||||
|
||||
if m == nil {
|
||||
t.Fatal("result should not be nil")
|
||||
}
|
||||
|
||||
assert.Equal(t, expected.ClientUID, m.UID())
|
||||
assert.True(t, m.HasSecret("xcCbOrw6I0vcoXzhnOmXhjpVSyFq0l0e"))
|
||||
assert.False(t, m.HasSecret("aaCbOrw6I0vcoXzhnOmXhjpVSyFq0l0e"))
|
||||
assert.False(t, m.HasSecret(""))
|
||||
assert.False(t, m.WrongSecret("xcCbOrw6I0vcoXzhnOmXhjpVSyFq0l0e"))
|
||||
assert.True(t, m.WrongSecret("aaCbOrw6I0vcoXzhnOmXhjpVSyFq0l0e"))
|
||||
assert.True(t, m.WrongSecret(""))
|
||||
assert.NotEmpty(t, m.CreatedAt)
|
||||
assert.NotEmpty(t, m.UpdatedAt)
|
||||
})
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/photoprism/photoprism/internal/i18n"
|
||||
"github.com/photoprism/photoprism/pkg/authn"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/list"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
"github.com/photoprism/photoprism/pkg/txt"
|
||||
)
|
||||
@@ -37,7 +38,7 @@ type Session struct {
|
||||
AuthProvider string `gorm:"type:VARBINARY(128);default:'';" json:"AuthProvider" yaml:"AuthProvider,omitempty"`
|
||||
AuthMethod string `gorm:"type:VARBINARY(128);default:'';" json:"AuthMethod" yaml:"AuthMethod,omitempty"`
|
||||
AuthDomain string `gorm:"type:VARBINARY(255);default:'';" json:"AuthDomain" yaml:"AuthDomain,omitempty"`
|
||||
AuthID string `gorm:"type:VARBINARY(128);index;default:'';" json:"AuthID" yaml:"AuthID,omitempty"`
|
||||
AuthID string `gorm:"type:VARBINARY(255);index;default:'';" json:"AuthID" yaml:"AuthID,omitempty"`
|
||||
AuthScope string `gorm:"size:1024;default:'';" json:"AuthScope" yaml:"AuthScope,omitempty"`
|
||||
LastActive int64 `json:"LastActive" yaml:"LastActive,omitempty"`
|
||||
SessExpires int64 `gorm:"index" json:"Expires" yaml:"Expires,omitempty"`
|
||||
@@ -64,7 +65,7 @@ func (Session) TableName() string {
|
||||
}
|
||||
|
||||
// NewSession creates a new session using the maxAge and timeout in seconds.
|
||||
func NewSession(maxAge, timeout int64) (m *Session) {
|
||||
func NewSession(lifetime, timeout int64) (m *Session) {
|
||||
created := TimeStamp()
|
||||
|
||||
m = &Session{
|
||||
@@ -74,8 +75,8 @@ func NewSession(maxAge, timeout int64) (m *Session) {
|
||||
UpdatedAt: created,
|
||||
}
|
||||
|
||||
if maxAge > 0 {
|
||||
m.SessExpires = created.Unix() + maxAge
|
||||
if lifetime > 0 {
|
||||
m.SessExpires = created.Unix() + lifetime
|
||||
}
|
||||
|
||||
if timeout > 0 {
|
||||
@@ -230,6 +231,8 @@ func (m *Session) BeforeCreate(scope *gorm.Scope) error {
|
||||
func (m *Session) User() *User {
|
||||
if m.user != nil {
|
||||
return m.user
|
||||
} else if m.UserUID == "" {
|
||||
return &User{}
|
||||
}
|
||||
|
||||
if u := FindUserByUID(m.UserUID); u != nil {
|
||||
@@ -453,6 +456,25 @@ func (m *Session) HasShares() bool {
|
||||
}
|
||||
}
|
||||
|
||||
// NoUser checks if this session has no specific user assigned.
|
||||
func (m *Session) NoUser() bool {
|
||||
return !m.HasUser()
|
||||
}
|
||||
|
||||
// HasUser checks if a user account is assigned to the session.
|
||||
func (m *Session) HasUser() bool {
|
||||
return m.UserUID != ""
|
||||
}
|
||||
|
||||
// HasRegisteredUser checks if the session belongs to a registered user.
|
||||
func (m *Session) HasRegisteredUser() bool {
|
||||
if !m.HasUser() {
|
||||
return false
|
||||
}
|
||||
|
||||
return m.User().IsRegistered()
|
||||
}
|
||||
|
||||
// HasShare if the session includes the specified share
|
||||
func (m *Session) HasShare(uid string) bool {
|
||||
if user := m.User(); user.IsRegistered() {
|
||||
@@ -495,6 +517,15 @@ func (m *Session) ExpiresAt() time.Time {
|
||||
return time.Unix(m.SessExpires, 0)
|
||||
}
|
||||
|
||||
// ExpiresIn returns the expiration time in seconds.
|
||||
func (m *Session) ExpiresIn() int64 {
|
||||
if m.SessExpires <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
return m.SessExpires - UnixTime()
|
||||
}
|
||||
|
||||
// TimeoutAt returns the time at which the session will expire due to inactivity.
|
||||
func (m *Session) TimeoutAt() time.Time {
|
||||
if m.SessTimeout <= 0 || m.LastActive <= 0 {
|
||||
@@ -548,6 +579,9 @@ func (m *Session) Invalid() bool {
|
||||
|
||||
// Valid checks whether the session belongs to a registered user or a visitor with shares.
|
||||
func (m *Session) Valid() bool {
|
||||
if m.AuthMethod == authn.MethodOAuth2.String() {
|
||||
return true
|
||||
}
|
||||
return m.User().IsRegistered() || m.IsVisitor() && m.HasShares()
|
||||
}
|
||||
|
||||
@@ -624,3 +658,13 @@ func (m *Session) HttpStatus() int {
|
||||
|
||||
return http.StatusUnauthorized
|
||||
}
|
||||
|
||||
// Scope returns the client IP address, or "unknown" if it is unknown.
|
||||
func (m *Session) Scope() string {
|
||||
return clean.Scope(m.AuthScope)
|
||||
}
|
||||
|
||||
// HasScope returns the client IP address, or "unknown" if it is unknown.
|
||||
func (m *Session) HasScope(scope string) bool {
|
||||
return !list.ParseAttr(m.Scope()).Contains(scope)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ var CheckTokens = true
|
||||
|
||||
// GenerateToken returns a random string token.
|
||||
func GenerateToken() string {
|
||||
return rnd.GenerateToken(8)
|
||||
return rnd.Base36(8)
|
||||
}
|
||||
|
||||
// InvalidDownloadToken checks if the token is unknown.
|
||||
|
||||
@@ -301,8 +301,8 @@ func (m *User) Delete() (err error) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Deleted checks if the user account has been deleted.
|
||||
func (m *User) Deleted() bool {
|
||||
// IsDeleted checks if the user account has been deleted.
|
||||
func (m *User) IsDeleted() bool {
|
||||
if m.DeletedAt == nil {
|
||||
return false
|
||||
}
|
||||
@@ -361,8 +361,8 @@ func (m *User) BeforeCreate(scope *gorm.Scope) error {
|
||||
return scope.SetColumn("UserUID", m.UserUID)
|
||||
}
|
||||
|
||||
// Expired checks if the user account has expired.
|
||||
func (m *User) Expired() bool {
|
||||
// IsExpired checks if the user account has expired.
|
||||
func (m *User) IsExpired() bool {
|
||||
if m.ExpiresAt == nil {
|
||||
return false
|
||||
}
|
||||
@@ -370,16 +370,20 @@ func (m *User) Expired() bool {
|
||||
return m.ExpiresAt.Before(time.Now())
|
||||
}
|
||||
|
||||
// Disabled checks if the user account has been deleted or has expired.
|
||||
func (m *User) Disabled() bool {
|
||||
return m.Deleted() || m.Expired() && !m.SuperAdmin
|
||||
// IsDisabled checks if the user account has been deleted or has expired.
|
||||
func (m *User) IsDisabled() bool {
|
||||
if m == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
return m.IsDeleted() || m.IsExpired() && !m.SuperAdmin
|
||||
}
|
||||
|
||||
// UpdateLoginTime updates the login timestamp and returns it if successful.
|
||||
func (m *User) UpdateLoginTime() *time.Time {
|
||||
if m == nil {
|
||||
return nil
|
||||
} else if m.Deleted() {
|
||||
} else if m.IsDeleted() {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -398,40 +402,43 @@ func (m *User) UpdateLoginTime() *time.Time {
|
||||
func (m *User) CanLogIn() bool {
|
||||
if m == nil {
|
||||
return false
|
||||
} else if m.Deleted() || m.HasProvider(authn.ProviderNone) {
|
||||
} else if m.IsDeleted() || m.HasProvider(authn.ProviderNone) {
|
||||
return false
|
||||
} else if !m.CanLogin && !m.SuperAdmin || m.ID <= 0 || m.UserName == "" {
|
||||
return false
|
||||
} else if role := m.AclRole(); m.Disabled() || role == acl.RoleUnknown {
|
||||
} else if m.IsDisabled() || m.IsUnknown() || !m.IsRegistered() {
|
||||
return false
|
||||
} else {
|
||||
return acl.Resources.Allow(acl.ResourceConfig, role, acl.AccessOwn)
|
||||
return acl.Resources.Allow(acl.ResourceConfig, m.AclRole(), acl.AccessOwn)
|
||||
}
|
||||
}
|
||||
|
||||
// CanUseWebDAV checks whether the user is allowed to use WebDAV to synchronize files.
|
||||
func (m *User) CanUseWebDAV() bool {
|
||||
if m == nil {
|
||||
// Abort check if user is nil for any reason.
|
||||
return false
|
||||
} else if m.Deleted() || m.HasProvider(authn.ProviderNone) {
|
||||
return false
|
||||
} else if role := m.AclRole(); m.Disabled() || !m.WebDAV || m.ID <= 0 || m.UserName == "" || role == acl.RoleUnknown {
|
||||
} else if !m.WebDAV || m.ID <= 0 || m.IsDisabled() || m.IsUnknown() || !m.IsRegistered() || m.HasProvider(authn.ProviderNone) {
|
||||
// Deny WebDAV access if WebDAV is disabled, the user does not have a
|
||||
// regular, registered account, or the account has been deactivated.
|
||||
return false
|
||||
} else {
|
||||
return acl.Resources.Allow(acl.ResourcePhotos, role, acl.ActionUpload)
|
||||
// Check if the ACL allows downloading files via WebDAV based on the user role.
|
||||
return acl.Resources.Allow(acl.ResourceWebDAV, m.AclRole(), acl.ActionDownload)
|
||||
}
|
||||
}
|
||||
|
||||
// CanUpload checks if the user is allowed to upload files.
|
||||
func (m *User) CanUpload() bool {
|
||||
if m == nil {
|
||||
// Abort check if user is nil for any reason.
|
||||
return false
|
||||
} else if m.Deleted() || m.HasProvider(authn.ProviderNone) {
|
||||
return false
|
||||
} else if role := m.AclRole(); m.Disabled() || role == acl.RoleUnknown {
|
||||
} else if m.IsDisabled() || m.HasProvider(authn.ProviderNone) || m.IsUnknown() {
|
||||
// Deny uploading if the user is unknown or the account has been deactivated.
|
||||
return false
|
||||
} else {
|
||||
return acl.Resources.Allow(acl.ResourcePhotos, role, acl.ActionUpload)
|
||||
// Check if the ACL allows uploading photos based on the user role.
|
||||
return acl.Resources.Allow(acl.ResourcePhotos, m.AclRole(), acl.ActionUpload)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -630,8 +637,8 @@ func (m *User) SetRole(role string) *User {
|
||||
}
|
||||
|
||||
// HasRole checks the user role specified as string.
|
||||
func (m *User) HasRole(role string) bool {
|
||||
return m.AclRole().String() == acl.ValidRoles[clean.Role(role)].String()
|
||||
func (m *User) HasRole(role acl.Role) bool {
|
||||
return m.AclRole() == role
|
||||
}
|
||||
|
||||
// AclRole returns the user role for ACL permission checks.
|
||||
@@ -686,13 +693,14 @@ func (m *User) Attr() string {
|
||||
return clean.Attr(m.UserAttr)
|
||||
}
|
||||
|
||||
// IsRegistered checks if the user is registered e.g. has a username.
|
||||
// IsRegistered checks if this user has a registered account with a valid ID, username, and role.
|
||||
func (m *User) IsRegistered() bool {
|
||||
if m == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return m.UserName != "" && rnd.IsUID(m.UserUID, UserUID) && !m.IsVisitor()
|
||||
// Registered users must have an ID, a UID, a username and a known role, except visitor.
|
||||
return m.ID > 0 && m.UserName != "" && rnd.IsUID(m.UserUID, UserUID) && !m.IsVisitor()
|
||||
}
|
||||
|
||||
// NotRegistered checks if the user is not registered with an own account.
|
||||
@@ -743,7 +751,11 @@ func (m *User) HasSharedAccessOnly(resource acl.Resource) bool {
|
||||
|
||||
// IsUnknown checks if the user is unknown.
|
||||
func (m *User) IsUnknown() bool {
|
||||
return !rnd.IsUID(m.UserUID, UserUID) || m.ID == UnknownUser.ID || m.UserUID == UnknownUser.UserUID
|
||||
if m == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
return !rnd.IsUID(m.UserUID, UserUID) || m.ID == UnknownUser.ID || m.UserUID == UnknownUser.UserUID || m.HasRole(acl.RoleUnknown)
|
||||
}
|
||||
|
||||
// DeleteSessions deletes all active user sessions except those passed as argument.
|
||||
|
||||
@@ -588,59 +588,82 @@ func TestUser_String(t *testing.T) {
|
||||
|
||||
func TestUser_Admin(t *testing.T) {
|
||||
t.Run("SuperAdmin", func(t *testing.T) {
|
||||
p := User{UserUID: "u000000000000008", UserName: "Hanna", DisplayName: "", SuperAdmin: true}
|
||||
p := User{ID: 8, UserUID: "u000000000000008", UserName: "Hanna", DisplayName: "", SuperAdmin: true}
|
||||
assert.True(t, p.IsAdmin())
|
||||
})
|
||||
t.Run("RoleAdmin", func(t *testing.T) {
|
||||
p := User{UserUID: "u000000000000008", UserName: "Hanna", DisplayName: "", UserRole: acl.RoleAdmin.String()}
|
||||
p := User{ID: 8, UserUID: "u000000000000008", UserName: "Hanna", DisplayName: "", UserRole: acl.RoleAdmin.String()}
|
||||
assert.True(t, p.IsAdmin())
|
||||
})
|
||||
t.Run("NoID", func(t *testing.T) {
|
||||
p := User{UserUID: "u000000000000008", UserName: "Hanna", DisplayName: "", UserRole: acl.RoleAdmin.String()}
|
||||
assert.False(t, p.IsAdmin())
|
||||
})
|
||||
t.Run("False", func(t *testing.T) {
|
||||
p := User{UserUID: "u000000000000008", UserName: "Hanna", DisplayName: "", SuperAdmin: false, UserRole: ""}
|
||||
p := User{ID: 8, UserUID: "u000000000000008", UserName: "Hanna", DisplayName: "", SuperAdmin: false, UserRole: ""}
|
||||
assert.False(t, p.IsAdmin())
|
||||
})
|
||||
}
|
||||
|
||||
func TestUser_Anonymous(t *testing.T) {
|
||||
t.Run("True", func(t *testing.T) {
|
||||
p := User{UserUID: "", UserName: "Hanna", DisplayName: ""}
|
||||
func TestUser_IsUnknown(t *testing.T) {
|
||||
t.Run("ID", func(t *testing.T) {
|
||||
p := User{ID: UnknownUser.ID, UserUID: "u000000000000008", UserName: "", DisplayName: "", SuperAdmin: false, UserRole: acl.RoleAdmin.String()}
|
||||
assert.True(t, p.IsUnknown())
|
||||
})
|
||||
t.Run("False", func(t *testing.T) {
|
||||
p := User{UserUID: "u000000000000008", UserName: "Hanna", DisplayName: "", SuperAdmin: true}
|
||||
t.Run("UID", func(t *testing.T) {
|
||||
p := User{ID: 123, UserUID: "", UserName: "Hanna", DisplayName: "", SuperAdmin: false, UserRole: acl.RoleAdmin.String()}
|
||||
assert.True(t, p.IsUnknown())
|
||||
})
|
||||
t.Run("Name", func(t *testing.T) {
|
||||
p := User{ID: 123, UserUID: "u000000000000008", UserName: "", DisplayName: "", SuperAdmin: false, UserRole: acl.RoleAdmin.String()}
|
||||
assert.False(t, p.IsUnknown())
|
||||
})
|
||||
t.Run("Role", func(t *testing.T) {
|
||||
p := User{ID: 123, UserUID: "u000000000000008", UserName: "Hanna", DisplayName: "", SuperAdmin: false, UserRole: ""}
|
||||
assert.True(t, p.IsUnknown())
|
||||
})
|
||||
t.Run("Admin", func(t *testing.T) {
|
||||
p := User{ID: 123, UserUID: "u000000000000008", UserName: "Hanna", DisplayName: "", SuperAdmin: false, UserRole: acl.RoleAdmin.String()}
|
||||
assert.False(t, p.IsUnknown())
|
||||
})
|
||||
t.Run("SuperAdmin", func(t *testing.T) {
|
||||
p := User{ID: 123, UserUID: "u000000000000008", UserName: "Hanna", DisplayName: "", SuperAdmin: true, UserRole: ""}
|
||||
assert.False(t, p.IsUnknown())
|
||||
})
|
||||
}
|
||||
|
||||
func TestUser_Guest(t *testing.T) {
|
||||
func TestUser_IsVisitor(t *testing.T) {
|
||||
t.Run("True", func(t *testing.T) {
|
||||
p := User{UserUID: "", UserName: "Hanna", DisplayName: "", UserRole: acl.RoleVisitor.String()}
|
||||
p := User{UserUID: "u000000000000008", UserName: "Hanna", DisplayName: "", UserRole: acl.RoleVisitor.String()}
|
||||
assert.True(t, p.IsVisitor())
|
||||
})
|
||||
t.Run("False", func(t *testing.T) {
|
||||
p := User{UserUID: "", UserName: "Hanna", DisplayName: ""}
|
||||
t.Run("Unknown", func(t *testing.T) {
|
||||
p := User{UserUID: "u000000000000008", UserName: "Hanna", DisplayName: ""}
|
||||
assert.False(t, p.IsVisitor())
|
||||
})
|
||||
t.Run("Admin", func(t *testing.T) {
|
||||
p := User{UserUID: "u000000000000008", UserName: "Hanna", DisplayName: "", UserRole: acl.RoleAdmin.String()}
|
||||
assert.False(t, p.IsVisitor())
|
||||
})
|
||||
}
|
||||
|
||||
func TestUser_SetPassword(t *testing.T) {
|
||||
t.Run("Ok", func(t *testing.T) {
|
||||
p := User{UserUID: "u000000000000008", UserName: "Hanna", DisplayName: ""}
|
||||
p := User{ID: 8, UserUID: "u000000000000008", UserName: "Hanna", DisplayName: "", UserRole: acl.RoleAdmin.String()}
|
||||
if err := p.SetPassword("insecure"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
t.Run("NotRegistered", func(t *testing.T) {
|
||||
p := User{UserUID: "", UserName: "Hanna", DisplayName: ""}
|
||||
p := User{ID: 0, UserUID: "", UserName: "Hanna", DisplayName: "", UserRole: acl.RoleAdmin.String()}
|
||||
assert.Error(t, p.SetPassword("insecure"))
|
||||
|
||||
})
|
||||
t.Run("PasswordTooShort", func(t *testing.T) {
|
||||
p := User{UserUID: "u000000000000008", UserName: "Hanna", DisplayName: ""}
|
||||
p := User{ID: 8, UserUID: "u000000000000008", UserName: "Hanna", DisplayName: "", UserRole: acl.RoleAdmin.String()}
|
||||
assert.Error(t, p.SetPassword("cat"))
|
||||
})
|
||||
t.Run("PasswordTooLong", func(t *testing.T) {
|
||||
p := User{UserUID: "u000000000000008", UserName: "Hanna", DisplayName: ""}
|
||||
p := User{ID: 8, UserUID: "u000000000000008", UserName: "Hanna", DisplayName: "", UserRole: acl.RoleAdmin.String()}
|
||||
assert.Error(t, p.SetPassword("hfnoehurhgfoeuro7584othgiyruifh85hglhiryhgbbyeirygbubgirgtheuogfugfkhsbdgiyerbgeuigbdtiyrgehbik"))
|
||||
})
|
||||
}
|
||||
@@ -657,10 +680,10 @@ func TestUser_InitLogin(t *testing.T) {
|
||||
}
|
||||
})
|
||||
t.Run("AlreadyExists", func(t *testing.T) {
|
||||
p := User{UserUID: "u000000000000010", UserName: "Hans", DisplayName: ""}
|
||||
p := User{ID: 10, UserUID: "u000000000000010", UserName: "Hans", DisplayName: "", UserRole: acl.RoleAdmin.String()}
|
||||
|
||||
if err := p.Save(); err != nil {
|
||||
t.Logf("cannot user %s: ", err)
|
||||
t.Logf("failed to create user: %s", err)
|
||||
}
|
||||
|
||||
if err := p.SetPassword("insecure"); err != nil {
|
||||
@@ -691,25 +714,31 @@ func TestUser_InitLogin(t *testing.T) {
|
||||
|
||||
func TestUser_AclRole(t *testing.T) {
|
||||
t.Run("SuperAdmin", func(t *testing.T) {
|
||||
p := User{UserUID: "u000000000000008", UserName: "Hanna", DisplayName: "", SuperAdmin: true, UserRole: ""}
|
||||
p := User{ID: 8, UserUID: "u000000000000008", UserName: "Hanna", DisplayName: "", SuperAdmin: true, UserRole: ""}
|
||||
assert.Equal(t, acl.RoleAdmin, p.AclRole())
|
||||
assert.True(t, p.IsAdmin())
|
||||
assert.False(t, p.IsVisitor())
|
||||
})
|
||||
t.Run("RoleAdmin", func(t *testing.T) {
|
||||
p := User{UserUID: "u000000000000008", UserName: "Hanna", DisplayName: "", SuperAdmin: false, UserRole: acl.RoleAdmin.String()}
|
||||
p := User{ID: 8, UserUID: "u000000000000008", UserName: "Hanna", DisplayName: "", SuperAdmin: false, UserRole: acl.RoleAdmin.String()}
|
||||
assert.Equal(t, acl.RoleAdmin, p.AclRole())
|
||||
assert.True(t, p.IsAdmin())
|
||||
assert.False(t, p.IsVisitor())
|
||||
})
|
||||
t.Run("NoName", func(t *testing.T) {
|
||||
p := User{UserUID: "u000000000000008", UserName: "", DisplayName: "", UserRole: acl.RoleAdmin.String()}
|
||||
p := User{ID: 8, UserUID: "u000000000000008", UserName: "", DisplayName: "", UserRole: acl.RoleAdmin.String()}
|
||||
assert.Equal(t, acl.RoleVisitor, p.AclRole())
|
||||
assert.False(t, p.IsAdmin())
|
||||
assert.True(t, p.IsVisitor())
|
||||
})
|
||||
t.Run("NoID", func(t *testing.T) {
|
||||
p := User{ID: 0, UserUID: "u000000000000008", UserName: "Hanna", DisplayName: "", SuperAdmin: false, UserRole: acl.RoleAdmin.String()}
|
||||
assert.Equal(t, acl.RoleAdmin, p.AclRole())
|
||||
assert.False(t, p.IsAdmin())
|
||||
assert.False(t, p.IsVisitor())
|
||||
})
|
||||
t.Run("Unauthorized", func(t *testing.T) {
|
||||
p := User{UserUID: "u000000000000008", UserName: "Hanna", DisplayName: ""}
|
||||
p := User{ID: 8, UserUID: "u000000000000008", UserName: "Hanna", DisplayName: ""}
|
||||
assert.Equal(t, acl.RoleUnknown, p.AclRole())
|
||||
assert.False(t, p.IsAdmin())
|
||||
assert.False(t, p.IsVisitor())
|
||||
@@ -931,27 +960,27 @@ func TestDeleteUser(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUser_Deleted(t *testing.T) {
|
||||
assert.False(t, UserFixtures.Pointer("alice").Deleted())
|
||||
assert.True(t, UserFixtures.Pointer("deleted").Deleted())
|
||||
assert.False(t, UserFixtures.Pointer("alice").IsDeleted())
|
||||
assert.True(t, UserFixtures.Pointer("deleted").IsDeleted())
|
||||
}
|
||||
|
||||
func TestUser_Expired(t *testing.T) {
|
||||
t.Run("False", func(t *testing.T) {
|
||||
assert.False(t, UserFixtures.Pointer("alice").Expired())
|
||||
assert.False(t, UserFixtures.Pointer("deleted").Expired())
|
||||
assert.False(t, UserFixtures.Pointer("alice").IsExpired())
|
||||
assert.False(t, UserFixtures.Pointer("deleted").IsExpired())
|
||||
})
|
||||
t.Run("True", func(t *testing.T) {
|
||||
u := NewUser()
|
||||
var expired = time.Date(2020, 3, 6, 2, 6, 51, 0, time.UTC)
|
||||
u.ExpiresAt = &expired
|
||||
|
||||
assert.True(t, u.Expired())
|
||||
assert.True(t, u.IsExpired())
|
||||
})
|
||||
}
|
||||
|
||||
func TestUser_Disabled(t *testing.T) {
|
||||
assert.False(t, UserFixtures.Pointer("alice").Disabled())
|
||||
assert.True(t, UserFixtures.Pointer("deleted").Disabled())
|
||||
assert.False(t, UserFixtures.Pointer("alice").IsDisabled())
|
||||
assert.True(t, UserFixtures.Pointer("deleted").IsDisabled())
|
||||
}
|
||||
|
||||
func TestUser_UpdateLoginTime(t *testing.T) {
|
||||
|
||||
@@ -22,6 +22,7 @@ var Entities = Tables{
|
||||
UserDetails{}.TableName(): &UserDetails{},
|
||||
UserSettings{}.TableName(): &UserSettings{},
|
||||
Session{}.TableName(): &Session{},
|
||||
Client{}.TableName(): &Client{},
|
||||
Service{}.TableName(): &Service{},
|
||||
Folder{}.TableName(): &Folder{},
|
||||
Duplicate{}.TableName(): &Duplicate{},
|
||||
|
||||
@@ -7,8 +7,11 @@ import (
|
||||
// Day specified as time.Duration to improve readability.
|
||||
const Day = time.Hour * 24
|
||||
|
||||
// UnixMinute is one minute in UnixTime.
|
||||
const UnixMinute int64 = 60
|
||||
|
||||
// UnixHour is one hour in UnixTime.
|
||||
const UnixHour int64 = 3600
|
||||
const UnixHour = UnixMinute * 60
|
||||
|
||||
// UnixDay is one day in UnixTime.
|
||||
const UnixDay = UnixHour * 24
|
||||
@@ -16,6 +19,12 @@ const UnixDay = UnixHour * 24
|
||||
// UnixWeek is one week in UnixTime.
|
||||
const UnixWeek = UnixDay * 7
|
||||
|
||||
// UnixMonth is about one month in UnixTime.
|
||||
const UnixMonth = UnixDay * 31
|
||||
|
||||
// UnixYear is about one year in UnixTime.
|
||||
const UnixYear = UnixDay * 365
|
||||
|
||||
// UTC returns the current Coordinated Universal Time (UTC).
|
||||
func UTC() time.Time {
|
||||
return time.Now().UTC()
|
||||
|
||||
@@ -29,6 +29,7 @@ func CreateTestFixtures() {
|
||||
CreateFaceFixtures()
|
||||
CreateUserFixtures()
|
||||
CreateSessionFixtures()
|
||||
CreateClientFixtures()
|
||||
CreateReactionFixtures()
|
||||
CreatePasswordFixtures()
|
||||
CreateUserShareFixtures()
|
||||
|
||||
@@ -69,7 +69,7 @@ func NewUserLink(shareUid, userUid string) Link {
|
||||
result := Link{
|
||||
LinkUID: rnd.GenerateUID(LinkUID),
|
||||
ShareUID: shareUid,
|
||||
LinkToken: rnd.GenerateToken(10),
|
||||
LinkToken: rnd.Base36(10),
|
||||
CreatedBy: userUid,
|
||||
CreatedAt: now,
|
||||
ModifiedAt: now,
|
||||
|
||||
@@ -19,10 +19,11 @@ func (m PasswordMap) Pointer(name string) *Password {
|
||||
}
|
||||
|
||||
var PasswordFixtures = PasswordMap{
|
||||
"alice": NewPassword("uqxetse3cy5eo9z2", "Alice123!", false),
|
||||
"bob": NewPassword("uqxc08w3d0ej2283", "Bobbob123!", false),
|
||||
"friend": NewPassword("uqxqg7i1kperxvu7", "!Friend321", false),
|
||||
"fowler": NewPassword("urinotv3d6jedvlm", "PleaseChange$42", false),
|
||||
"alice": NewPassword("uqxetse3cy5eo9z2", "Alice123!", false),
|
||||
"bob": NewPassword("uqxc08w3d0ej2283", "Bobbob123!", false),
|
||||
"friend": NewPassword("uqxqg7i1kperxvu7", "!Friend321", false),
|
||||
"fowler": NewPassword("urinotv3d6jedvlm", "PleaseChange$42", false),
|
||||
"metrics": NewPassword("cs5cpu17n6gj2qo5", "xcCbOrw6I0vcoXzhnOmXhjpVSyFq0l0e", false),
|
||||
}
|
||||
|
||||
// CreatePasswordFixtures inserts known entities into the database for testing.
|
||||
|
||||
76
internal/form/client.go
Normal file
76
internal/form/client.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package form
|
||||
|
||||
import (
|
||||
"github.com/urfave/cli"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/authn"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
)
|
||||
|
||||
// Client represents client application settings.
|
||||
type Client struct {
|
||||
UserUID string `json:"UserUID,omitempty" yaml:"UserUID,omitempty"`
|
||||
UserName string `gorm:"size:64;index;" json:"UserName" yaml:"UserName,omitempty"`
|
||||
ClientName string `json:"ClientName,omitempty" yaml:"ClientName,omitempty"`
|
||||
AuthMethod string `json:"AuthMethod,omitempty" yaml:"AuthMethod,omitempty"`
|
||||
AuthScope string `json:"AuthScope,omitempty" yaml:"AuthScope,omitempty"`
|
||||
AuthExpires int64 `json:"AuthExpires,omitempty" yaml:"AuthExpires,omitempty"`
|
||||
AuthTokens int64 `json:"AuthTokens,omitempty" yaml:"AuthTokens,omitempty"`
|
||||
AuthEnabled bool `json:"AuthEnabled,omitempty" yaml:"AuthEnabled,omitempty"`
|
||||
}
|
||||
|
||||
// NewClient creates new client application settings.
|
||||
func NewClient() Client {
|
||||
return Client{
|
||||
UserUID: "",
|
||||
UserName: "",
|
||||
ClientName: "",
|
||||
AuthMethod: authn.MethodOAuth2.String(),
|
||||
AuthScope: "",
|
||||
AuthExpires: 3600,
|
||||
AuthTokens: 5,
|
||||
AuthEnabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
// NewClientFromCli creates a new form with values from a CLI context.
|
||||
func NewClientFromCli(ctx *cli.Context) Client {
|
||||
f := NewClient()
|
||||
|
||||
f.ClientName = clean.Name(ctx.String("name"))
|
||||
f.AuthScope = clean.Scope(ctx.String("scope"))
|
||||
|
||||
if method := clean.Scope(ctx.String("method")); authn.MethodOAuth2.Equal(method) {
|
||||
f.AuthMethod = authn.MethodOAuth2.String()
|
||||
} else if authn.MethodBasic.Equal(method) {
|
||||
f.AuthMethod = authn.MethodBasic.String()
|
||||
}
|
||||
|
||||
if authn.MethodOAuth2.NotEqual(f.AuthMethod) {
|
||||
f.AuthScope = "webdav"
|
||||
}
|
||||
|
||||
if user := ctx.String("user"); rnd.IsUID(user, 'u') {
|
||||
f.UserUID = user
|
||||
} else if user != "" {
|
||||
f.UserName = user
|
||||
}
|
||||
|
||||
return f
|
||||
}
|
||||
|
||||
// Name returns the sanitized client name.
|
||||
func (f *Client) Name() string {
|
||||
return clean.Name(f.ClientName)
|
||||
}
|
||||
|
||||
// Method returns the sanitized auth method name.
|
||||
func (f *Client) Method() authn.MethodType {
|
||||
return authn.Method(f.AuthMethod)
|
||||
}
|
||||
|
||||
// Scope returns the client scopes as sanitized string.
|
||||
func (f Client) Scope() string {
|
||||
return clean.Scope(f.AuthScope)
|
||||
}
|
||||
39
internal/form/client_credentials.go
Normal file
39
internal/form/client_credentials.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package form
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
)
|
||||
|
||||
// ClientCredentials represents client authentication request credentials.
|
||||
type ClientCredentials struct {
|
||||
ClientID string `form:"client_id" json:"client_id,omitempty"`
|
||||
ClientSecret string `form:"client_secret" json:" client_secret,omitempty"`
|
||||
AuthScope string `form:"scope" json:"scope,omitempty"`
|
||||
}
|
||||
|
||||
// Validate checks the grant type and credentials.
|
||||
func (f ClientCredentials) Validate() error {
|
||||
// Check client ID.
|
||||
if f.ClientID == "" {
|
||||
return fmt.Errorf("missing client id")
|
||||
} else if rnd.InvalidUID(f.ClientID, 'c') {
|
||||
return fmt.Errorf("invalid client id")
|
||||
}
|
||||
|
||||
// Check client secret.
|
||||
if f.ClientSecret == "" {
|
||||
return fmt.Errorf("missing client secret")
|
||||
} else if !rnd.IsAlnum(f.ClientSecret) {
|
||||
return fmt.Errorf("invalid client secret")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Scope returns the client scopes as sanitized string.
|
||||
func (f ClientCredentials) Scope() string {
|
||||
return clean.Scope(f.AuthScope)
|
||||
}
|
||||
17
internal/form/client_test.go
Normal file
17
internal/form/client_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package form
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/authn"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
t.Run("Defaults", func(t *testing.T) {
|
||||
client := NewClient()
|
||||
assert.Equal(t, authn.MethodOAuth2, client.Method())
|
||||
assert.Equal(t, "", client.Scope())
|
||||
})
|
||||
}
|
||||
@@ -30,7 +30,7 @@ func TestIndexRelated(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
testToken := rnd.GenerateToken(8)
|
||||
testToken := rnd.Base36(8)
|
||||
testPath := filepath.Join(conf.OriginalsPath(), testToken)
|
||||
|
||||
for _, f := range testRelated.Files {
|
||||
@@ -91,7 +91,7 @@ func TestIndexRelated(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
testToken := rnd.GenerateToken(8)
|
||||
testToken := rnd.Base36(8)
|
||||
testPath := filepath.Join(conf.OriginalsPath(), testToken)
|
||||
|
||||
for _, f := range testRelated.Files {
|
||||
|
||||
42
internal/query/clients.go
Normal file
42
internal/query/clients.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
)
|
||||
|
||||
// Clients finds clients and returns them.
|
||||
func Clients(limit, offset int, sortOrder, search string) (result entity.Clients, err error) {
|
||||
result = entity.Clients{}
|
||||
stmt := Db()
|
||||
|
||||
search = strings.TrimSpace(search)
|
||||
|
||||
if search == "all" {
|
||||
// Don't filter.
|
||||
} else if rnd.IsUID(search, entity.ClientUID) {
|
||||
stmt = stmt.Where("client_uid = ?", search)
|
||||
} else if rnd.IsUID(search, entity.UserUID) {
|
||||
stmt = stmt.Where("user_uid = ?", search)
|
||||
} else if search != "" {
|
||||
stmt = stmt.Where("client_name LIKE ? OR user_name LIKE ?", search+"%", search+"%")
|
||||
}
|
||||
|
||||
if sortOrder == "" {
|
||||
sortOrder = "user_name, client_name, created_at"
|
||||
}
|
||||
|
||||
if limit > 0 {
|
||||
stmt = stmt.Limit(limit)
|
||||
|
||||
if offset > 0 {
|
||||
stmt = stmt.Offset(offset)
|
||||
}
|
||||
}
|
||||
|
||||
err = stmt.Order(sortOrder).Find(&result).Error
|
||||
|
||||
return result, err
|
||||
}
|
||||
@@ -1,13 +1,9 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -24,63 +20,68 @@ import (
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
)
|
||||
|
||||
// Authentication cache with an expiration time of 5 minutes.
|
||||
// To improve performance, we use a basic auth cache
|
||||
// with an expiration time of about 5 minutes.
|
||||
var basicAuthExpiration = 5 * time.Minute
|
||||
var basicAuthCache = gc.New(basicAuthExpiration, basicAuthExpiration)
|
||||
var basicAuthMutex = sync.Mutex{}
|
||||
var BasicAuthRealm = "Basic realm=\"WebDAV Authorization Required\""
|
||||
|
||||
// GetAuthUser returns the authenticated user if found, nil otherwise.
|
||||
func GetAuthUser(key string) *entity.User {
|
||||
user, valid := basicAuthCache.Get(key)
|
||||
// WebDAVAuth checks authentication and authentication
|
||||
// before WebDAV requests are processed.
|
||||
func WebDAVAuth(conf *config.Config) gin.HandlerFunc {
|
||||
// Helper function that extracts the login information from the request headers.
|
||||
var basicAuth = func(c *gin.Context) (username, password, cacheKey string, authorized bool) {
|
||||
// Extract credentials from the HTTP request headers.
|
||||
username, password, cacheKey = api.BasicAuth(c)
|
||||
|
||||
if valid && user != nil {
|
||||
return user.(*entity.User)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BasicAuth implements an HTTP request handler that adds basic authentication.
|
||||
func BasicAuth(conf *config.Config) gin.HandlerFunc {
|
||||
var validate = func(c *gin.Context) (name, password, key string, valid bool) {
|
||||
name, password, key = GetCredentials(c)
|
||||
|
||||
if name == "" || password == "" {
|
||||
return name, password, "", false
|
||||
// Fail if the username or password is empty, as
|
||||
// this is not allowed under any circumstances.
|
||||
if username == "" || password == "" || cacheKey == "" {
|
||||
return "", "", "", false
|
||||
}
|
||||
|
||||
key = fmt.Sprintf("%x", sha1.Sum([]byte(key)))
|
||||
|
||||
if user := GetAuthUser(key); user != nil {
|
||||
c.Set(gin.AuthUserKey, user)
|
||||
return name, password, key, true
|
||||
// To improve performance, check the cache for already authorized users.
|
||||
if user, found := basicAuthCache.Get(cacheKey); found && user != nil {
|
||||
// Add cached user information to the request context.
|
||||
c.Set(gin.AuthUserKey, user.(*entity.User))
|
||||
// Credentials have already been authorized within the configured
|
||||
// expiration time of the basic auth cache (about 5 minutes).
|
||||
return username, password, cacheKey, true
|
||||
} else {
|
||||
// Credentials found, but not pre-authorized. If successful, the
|
||||
// authorization will be cached for the next request.
|
||||
return username, password, cacheKey, false
|
||||
}
|
||||
|
||||
return name, password, key, false
|
||||
}
|
||||
|
||||
// Authentication handler that is called before WebDAV requests are processed.
|
||||
return func(c *gin.Context) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
|
||||
name, password, key, ok := validate(c)
|
||||
// Get basic authentication credentials.
|
||||
username, password, cacheKey, authorized := basicAuth(c)
|
||||
|
||||
if ok {
|
||||
// Already authenticated.
|
||||
// Allow requests from already authorized users to be processed.
|
||||
if authorized {
|
||||
return
|
||||
} else if key == "" {
|
||||
// Incomplete credentials.
|
||||
}
|
||||
|
||||
// Re-request authentication if credentials are missing or incomplete.
|
||||
if cacheKey == "" {
|
||||
c.Header("WWW-Authenticate", BasicAuthRealm)
|
||||
c.AbortWithStatus(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Get client IP address.
|
||||
// Get the client IP address from the request headers
|
||||
// for use in logs and to enforce request rate limits.
|
||||
clientIp := api.ClientIP(c)
|
||||
|
||||
// Check limit for failed auth requests (max. 10 per minute).
|
||||
// Check the authentication request rate to block the client after
|
||||
// too many failed attempts (10/req per minute by default).
|
||||
if limiter.Login.Reject(clientIp) {
|
||||
limiter.Abort(c)
|
||||
return
|
||||
@@ -91,7 +92,7 @@ func BasicAuth(conf *config.Config) gin.HandlerFunc {
|
||||
|
||||
// User credentials.
|
||||
f := form.Login{
|
||||
UserName: name,
|
||||
UserName: username,
|
||||
Password: password,
|
||||
}
|
||||
|
||||
@@ -99,31 +100,31 @@ func BasicAuth(conf *config.Config) gin.HandlerFunc {
|
||||
if user, _, err := entity.Auth(f, nil, c); err != nil {
|
||||
message := err.Error()
|
||||
limiter.Login.Reserve(clientIp)
|
||||
event.AuditErr([]string{clientIp, "webdav login as %s", message}, clean.LogQuote(name))
|
||||
event.LoginError(clientIp, "webdav", name, api.UserAgent(c), message)
|
||||
event.AuditErr([]string{clientIp, "webdav login as %s", message}, clean.LogQuote(username))
|
||||
event.LoginError(clientIp, "webdav", username, api.UserAgent(c), message)
|
||||
} else if user == nil {
|
||||
message := "account not found"
|
||||
limiter.Login.Reserve(clientIp)
|
||||
event.AuditErr([]string{clientIp, "webdav login as %s", message}, clean.LogQuote(name))
|
||||
event.LoginError(clientIp, "webdav", name, api.UserAgent(c), message)
|
||||
event.AuditErr([]string{clientIp, "webdav login as %s", message}, clean.LogQuote(username))
|
||||
event.LoginError(clientIp, "webdav", username, api.UserAgent(c), message)
|
||||
} else if !user.CanUseWebDAV() {
|
||||
// Sync disabled for this account.
|
||||
message := "sync disabled"
|
||||
|
||||
event.AuditWarn([]string{clientIp, "webdav login as %s", message}, clean.LogQuote(name))
|
||||
event.LoginError(clientIp, "webdav", name, api.UserAgent(c), message)
|
||||
event.AuditWarn([]string{clientIp, "webdav login as %s", message}, clean.LogQuote(username))
|
||||
event.LoginError(clientIp, "webdav", username, api.UserAgent(c), message)
|
||||
} else if err = os.MkdirAll(filepath.Join(conf.OriginalsPath(), user.GetUploadPath()), fs.ModeDir); err != nil {
|
||||
message := "failed to create user upload path"
|
||||
|
||||
event.AuditWarn([]string{clientIp, "webdav login as %s", message}, clean.LogQuote(name))
|
||||
event.LoginError(clientIp, "webdav", name, api.UserAgent(c), message)
|
||||
event.AuditWarn([]string{clientIp, "webdav login as %s", message}, clean.LogQuote(username))
|
||||
event.LoginError(clientIp, "webdav", username, api.UserAgent(c), message)
|
||||
} else {
|
||||
// Successfully authenticated.
|
||||
event.AuditInfo([]string{clientIp, "webdav login as %s", "succeeded"}, clean.LogQuote(name))
|
||||
event.LoginInfo(clientIp, "webdav", name, api.UserAgent(c))
|
||||
event.AuditInfo([]string{clientIp, "webdav login as %s", "succeeded"}, clean.LogQuote(username))
|
||||
event.LoginInfo(clientIp, "webdav", username, api.UserAgent(c))
|
||||
|
||||
// Cache successful authentication.
|
||||
basicAuthCache.SetDefault(key, user)
|
||||
basicAuthCache.SetDefault(cacheKey, user)
|
||||
c.Set(gin.AuthUserKey, user)
|
||||
return
|
||||
}
|
||||
@@ -133,28 +134,3 @@ func BasicAuth(conf *config.Config) gin.HandlerFunc {
|
||||
c.AbortWithStatus(http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
// GetCredentials parses the "Authorization" header into username and password.
|
||||
func GetCredentials(c *gin.Context) (name, password, raw string) {
|
||||
data := c.GetHeader("Authorization")
|
||||
|
||||
if !strings.HasPrefix(data, "Basic ") {
|
||||
return "", "", data
|
||||
}
|
||||
|
||||
data = strings.TrimPrefix(data, "Basic ")
|
||||
|
||||
auth, err := base64.StdEncoding.DecodeString(data)
|
||||
|
||||
if err != nil {
|
||||
return "", "", data
|
||||
}
|
||||
|
||||
credentials := strings.SplitN(string(auth), ":", 2)
|
||||
|
||||
if len(credentials) != 2 {
|
||||
return "", "", data
|
||||
}
|
||||
|
||||
return credentials[0], credentials[1], data
|
||||
}
|
||||
|
||||
6
internal/server/header/auth.go
Normal file
6
internal/server/header/auth.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package header
|
||||
|
||||
const (
|
||||
SessionID = "X-Session-ID"
|
||||
Authorization = "Authorization" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization
|
||||
)
|
||||
@@ -32,6 +32,7 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
|
||||
api.CreateSession(APIv1)
|
||||
api.GetSession(APIv1)
|
||||
api.DeleteSession(APIv1)
|
||||
api.CreateOauthToken(APIv1)
|
||||
|
||||
// Server Config.
|
||||
api.GetConfigOptions(APIv1)
|
||||
|
||||
@@ -31,11 +31,11 @@ func registerWebDAVRoutes(router *gin.Engine, conf *config.Config) {
|
||||
info = ""
|
||||
}
|
||||
|
||||
WebDAV(conf.OriginalsPath(), router.Group(conf.BaseUri(WebDAVOriginals), BasicAuth(conf)), conf)
|
||||
WebDAV(conf.OriginalsPath(), router.Group(conf.BaseUri(WebDAVOriginals), WebDAVAuth(conf)), conf)
|
||||
log.Infof("webdav: shared %s/%s", conf.BaseUri(WebDAVOriginals), info)
|
||||
|
||||
if conf.ImportPath() != "" {
|
||||
WebDAV(conf.ImportPath(), router.Group(conf.BaseUri(WebDAVImport), BasicAuth(conf)), conf)
|
||||
WebDAV(conf.ImportPath(), router.Group(conf.BaseUri(WebDAVImport), WebDAVAuth(conf)), conf)
|
||||
log.Infof("webdav: shared %s/%s", conf.BaseUri(WebDAVImport), info)
|
||||
}
|
||||
}
|
||||
|
||||
8
pkg/authn/client.go
Normal file
8
pkg/authn/client.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package authn
|
||||
|
||||
// API client types.
|
||||
const (
|
||||
ClientConfidential = "confidential"
|
||||
ClientWebDAV = "webdav"
|
||||
ClientUnknown = ""
|
||||
)
|
||||
68
pkg/authn/methods.go
Normal file
68
pkg/authn/methods.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package authn
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/txt"
|
||||
)
|
||||
|
||||
// MethodType represents an authentication method.
|
||||
type MethodType string
|
||||
|
||||
// Authentication methods.
|
||||
const (
|
||||
MethodDefault MethodType = "default"
|
||||
MethodOAuth2 MethodType = "oauth2"
|
||||
MethodBasic MethodType = "basic"
|
||||
MethodUnknown MethodType = ""
|
||||
)
|
||||
|
||||
// IsDefault checks if this is the default method.
|
||||
func (t MethodType) IsDefault() bool {
|
||||
return t.String() == MethodDefault.String()
|
||||
}
|
||||
|
||||
// String returns the provider identifier as a string.
|
||||
func (t MethodType) String() string {
|
||||
switch t {
|
||||
case "":
|
||||
return string(MethodDefault)
|
||||
case "oauth":
|
||||
return string(MethodOAuth2)
|
||||
default:
|
||||
return string(t)
|
||||
}
|
||||
}
|
||||
|
||||
// Equal checks if the type matches.
|
||||
func (t MethodType) Equal(s string) bool {
|
||||
return strings.EqualFold(s, t.String())
|
||||
}
|
||||
|
||||
// NotEqual checks if the type is different.
|
||||
func (t MethodType) NotEqual(s string) bool {
|
||||
return !t.Equal(s)
|
||||
}
|
||||
|
||||
// Pretty returns the provider identifier in an easy-to-read format.
|
||||
func (t MethodType) Pretty() string {
|
||||
switch t {
|
||||
case MethodOAuth2:
|
||||
return "OAuth2"
|
||||
default:
|
||||
return txt.UpperFirst(t.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Method casts a string to a normalized method type.
|
||||
func Method(s string) MethodType {
|
||||
switch s {
|
||||
case "", "-", "null", "nil", "0", "false":
|
||||
return MethodDefault
|
||||
case "oauth2", "oauth":
|
||||
return MethodOAuth2
|
||||
default:
|
||||
return MethodType(clean.TypeLower(s))
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package authn
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/list"
|
||||
"github.com/photoprism/photoprism/pkg/txt"
|
||||
@@ -12,6 +14,7 @@ type ProviderType string
|
||||
// Authentication providers.
|
||||
const (
|
||||
ProviderDefault ProviderType = "default"
|
||||
ProviderClient ProviderType = "client"
|
||||
ProviderLocal ProviderType = "local"
|
||||
ProviderLDAP ProviderType = "ldap"
|
||||
ProviderLink ProviderType = "link"
|
||||
@@ -58,6 +61,16 @@ func (t ProviderType) String() string {
|
||||
}
|
||||
}
|
||||
|
||||
// Equal checks if the type matches.
|
||||
func (t ProviderType) Equal(s string) bool {
|
||||
return strings.EqualFold(s, t.String())
|
||||
}
|
||||
|
||||
// NotEqual checks if the type is different.
|
||||
func (t ProviderType) NotEqual(s string) bool {
|
||||
return !t.Equal(s)
|
||||
}
|
||||
|
||||
// Pretty returns the provider identifier in an easy-to-read format.
|
||||
func (t ProviderType) Pretty() string {
|
||||
switch t {
|
||||
|
||||
16
pkg/clean/scope.go
Normal file
16
pkg/clean/scope.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package clean
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/list"
|
||||
)
|
||||
|
||||
// Scope sanitizes a string that contains authentication scope identifiers.
|
||||
func Scope(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return list.ParseAttr(strings.ToLower(s)).String()
|
||||
}
|
||||
22
pkg/clean/scope_test.go
Normal file
22
pkg/clean/scope_test.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package clean
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestScope(t *testing.T) {
|
||||
t.Run("Empty", func(t *testing.T) {
|
||||
q := Scope("")
|
||||
assert.Equal(t, "", q)
|
||||
})
|
||||
t.Run("Sanitized", func(t *testing.T) {
|
||||
q := Scope(" foo:BAR webdav openid metrics !")
|
||||
assert.Equal(t, "foo:bar metrics openid webdav", q)
|
||||
})
|
||||
t.Run("All", func(t *testing.T) {
|
||||
q := Scope("*")
|
||||
assert.Equal(t, "*", q)
|
||||
})
|
||||
}
|
||||
@@ -27,6 +27,9 @@ func Key(s string) string {
|
||||
switch r {
|
||||
case '.', '@', '-', '+', '_', '#':
|
||||
return r
|
||||
case '*':
|
||||
i++
|
||||
return r
|
||||
}
|
||||
if r >= '0' && r <= '9' {
|
||||
return r
|
||||
@@ -52,7 +55,7 @@ func Value(s string) string {
|
||||
return -1
|
||||
}
|
||||
switch r {
|
||||
case '(', ')', '<', '>', '\'', '"':
|
||||
case '(', ')', '<', '>', '\'', '"', '*':
|
||||
return r
|
||||
}
|
||||
return r
|
||||
|
||||
@@ -67,7 +67,7 @@ func TestFlag_String(t *testing.T) {
|
||||
assert.Equal(t, "feature:string", ParseKeyValue("feature : string").String())
|
||||
})
|
||||
t.Run("WhitespacePadding", func(t *testing.T) {
|
||||
assert.Equal(t, "featureq62:String!!#$^&*(", ParseKeyValue(" ^&^&(&*&)feature!q62:String!!#$^&*( ").String())
|
||||
assert.Equal(t, "*featureq62:String!!#$^&*(", ParseKeyValue(" ^&^&(&*&)feature!q62:String!!#$^&*( ").String())
|
||||
})
|
||||
t.Run("SpecialChars", func(t *testing.T) {
|
||||
assert.Equal(t, "feature:String!!#$^&*(", ParseKeyValue(" feature:String!!#$^&*( ").String())
|
||||
|
||||
@@ -68,3 +68,36 @@ func (list Attr) Sort() {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Contains tests if the list contains the attribute provided as string.
|
||||
func (list Attr) Contains(s string) bool {
|
||||
if len(list) == 0 || s == "" {
|
||||
return false
|
||||
} else if s == All {
|
||||
return true
|
||||
}
|
||||
|
||||
attr := ParseKeyValue(s)
|
||||
|
||||
// Abort if attribute is invalid.
|
||||
if attr.Key == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Find matches.
|
||||
if attr.Value == "" || attr.Value == All {
|
||||
for i := range list {
|
||||
if strings.EqualFold(attr.Key, list[i].Key) || list[i].Key == All {
|
||||
return true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for i := range list {
|
||||
if strings.EqualFold(attr.Key, list[i].Key) && (attr.Value == list[i].Value || list[i].Value == All) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
package report
|
||||
|
||||
const (
|
||||
Enabled = "Enabled"
|
||||
Disabled = ""
|
||||
Yes = "Yes"
|
||||
No = ""
|
||||
)
|
||||
|
||||
// Bool returns t or f, depending on the value of b.
|
||||
func Bool(value bool, yes, no string) string {
|
||||
if value {
|
||||
|
||||
10
pkg/report/const.go
Normal file
10
pkg/report/const.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package report
|
||||
|
||||
const (
|
||||
Enabled = "Enabled"
|
||||
Disabled = "Disabled"
|
||||
Yes = "Yes"
|
||||
No = "No"
|
||||
NotAssigned = "N/A"
|
||||
Never = "Never"
|
||||
)
|
||||
38
pkg/report/credentials.go
Normal file
38
pkg/report/credentials.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
||||
"github.com/olekukonko/tablewriter"
|
||||
)
|
||||
|
||||
// Credentials returns a text-formatted table with credentials.
|
||||
func Credentials(idName, idValue, secretName, secretValue string) string {
|
||||
buf := &bytes.Buffer{}
|
||||
|
||||
// Set borders.
|
||||
borders := tablewriter.Border{
|
||||
Left: true,
|
||||
Right: true,
|
||||
Top: true,
|
||||
Bottom: true,
|
||||
}
|
||||
|
||||
// Set values.
|
||||
rows := make([][]string, 2)
|
||||
rows[0] = []string{idName, secretName}
|
||||
rows[1] = []string{idValue, secretValue}
|
||||
|
||||
// Render table.
|
||||
table := tablewriter.NewWriter(buf)
|
||||
|
||||
table.SetRowLine(true)
|
||||
table.SetAutoWrapText(false)
|
||||
table.SetHeader(nil)
|
||||
table.SetBorders(borders)
|
||||
table.SetCenterSeparator("|")
|
||||
table.AppendBulk(rows)
|
||||
table.Render()
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package rnd
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"math/big"
|
||||
)
|
||||
|
||||
const CharsetBase36 = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
const CharsetBase62 = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
// Base36 generates a random token containing lowercase letters and numbers.
|
||||
func Base36(length int) string {
|
||||
return Charset(length, CharsetBase36)
|
||||
}
|
||||
|
||||
// Base62 generates a random token containing upper and lower case letters as well as numbers.
|
||||
func Base62(length int) string {
|
||||
return Charset(length, CharsetBase62)
|
||||
}
|
||||
|
||||
// Charset generates a random token with the specified length and charset.
|
||||
func Charset(length int, charset string) string {
|
||||
max := big.NewInt(int64(len(charset)))
|
||||
|
||||
b := make([]byte, length)
|
||||
|
||||
for i := range b {
|
||||
if r, err := rand.Int(rand.Reader, max); err == nil {
|
||||
b[i] = charset[r.Int64()]
|
||||
}
|
||||
}
|
||||
|
||||
return string(b)
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package rnd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestBase36(t *testing.T) {
|
||||
token := Base36(10)
|
||||
t.Logf("Base36 Token: %s", token)
|
||||
assert.NotEmpty(t, token)
|
||||
assert.True(t, IsRefID(token))
|
||||
assert.False(t, InvalidRefID(token))
|
||||
assert.Equal(t, 10, len(token))
|
||||
|
||||
for n := 0; n < 10; n++ {
|
||||
token = Base36(10)
|
||||
t.Logf("Base36 %d: %s", n, token)
|
||||
assert.NotEmpty(t, token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBase62(t *testing.T) {
|
||||
token := Base62(23)
|
||||
t.Logf("Base62 Token: %s", token)
|
||||
assert.NotEmpty(t, token)
|
||||
assert.False(t, IsRefID(token))
|
||||
assert.True(t, InvalidRefID(token))
|
||||
assert.Equal(t, 23, len(token))
|
||||
|
||||
for n := 0; n < 10; n++ {
|
||||
token = Base62(10)
|
||||
t.Logf("Base62 %d: %s", n, token)
|
||||
assert.NotEmpty(t, token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharset(t *testing.T) {
|
||||
token := Charset(23, CharsetBase62)
|
||||
t.Logf("Charset Token: %s", token)
|
||||
assert.NotEmpty(t, token)
|
||||
assert.False(t, IsRefID(token))
|
||||
assert.True(t, InvalidRefID(token))
|
||||
assert.Equal(t, 23, len(token))
|
||||
}
|
||||
@@ -10,9 +10,9 @@ import (
|
||||
func CrcToken() string {
|
||||
token := make([]byte, 0, 14)
|
||||
|
||||
token = append(token, []byte(GenerateToken(4))...)
|
||||
token = append(token, []byte(Base36(4))...)
|
||||
token = append(token, '-')
|
||||
token = append(token, []byte(GenerateToken(4))...)
|
||||
token = append(token, []byte(Base36(4))...)
|
||||
|
||||
checksum := crc32.ChecksumIEEE(token)
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
|
||||
func TestGeneratePasscode(t *testing.T) {
|
||||
for n := 0; n < 10; n++ {
|
||||
code := GeneratePasscode()
|
||||
t.Logf("Passcode %d: %s", n, code)
|
||||
assert.Equal(t, 19, len(code))
|
||||
s := GeneratePasscode()
|
||||
t.Logf("Passcode %d: %s", n, s)
|
||||
assert.Equal(t, 19, len(s))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,31 +2,38 @@ package rnd
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"math/big"
|
||||
)
|
||||
|
||||
// GenerateToken returns a random token with length of up to 10 characters.
|
||||
func GenerateToken(size uint) string {
|
||||
if size > 10 || size < 1 {
|
||||
panic(fmt.Sprintf("size out of range: %d", size))
|
||||
}
|
||||
const CharsetBase36 = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
const CharsetBase62 = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
result := make([]byte, 0, 14)
|
||||
b := make([]byte, 8)
|
||||
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
randomInt := binary.BigEndian.Uint64(b)
|
||||
|
||||
result = append(result, strconv.FormatUint(randomInt, 36)...)
|
||||
|
||||
for i := len(result); i < cap(result); i++ {
|
||||
result = append(result, byte(123-(cap(result)-i)))
|
||||
}
|
||||
|
||||
return string(result[:size])
|
||||
// Base36 generates a random token containing lowercase letters and numbers.
|
||||
func Base36(length int) string {
|
||||
return Charset(length, CharsetBase36)
|
||||
}
|
||||
|
||||
// Base62 generates a random token containing upper and lower case letters as well as numbers.
|
||||
func Base62(length int) string {
|
||||
return Charset(length, CharsetBase62)
|
||||
}
|
||||
|
||||
// Charset generates a random token with the specified length and charset.
|
||||
func Charset(length int, charset string) string {
|
||||
if length < 1 {
|
||||
return ""
|
||||
} else if length > 4096 {
|
||||
length = 4096
|
||||
}
|
||||
|
||||
m := big.NewInt(int64(len(charset)))
|
||||
b := make([]byte, length)
|
||||
|
||||
for i := range b {
|
||||
if r, err := rand.Int(rand.Reader, m); err == nil {
|
||||
b[i] = charset[r.Int64()]
|
||||
}
|
||||
}
|
||||
|
||||
return string(b)
|
||||
}
|
||||
|
||||
@@ -6,32 +6,94 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestBase36(t *testing.T) {
|
||||
t.Run("10", func(t *testing.T) {
|
||||
s := Base36(10)
|
||||
t.Logf("Base36 (10 chars): %s", s)
|
||||
assert.NotEmpty(t, s)
|
||||
assert.True(t, IsRefID(s))
|
||||
assert.False(t, InvalidRefID(s))
|
||||
assert.Equal(t, 10, len(s))
|
||||
|
||||
for n := 0; n < 10; n++ {
|
||||
s = Base36(10)
|
||||
t.Logf("Base36 %d: %s", n, s)
|
||||
assert.NotEmpty(t, s)
|
||||
}
|
||||
})
|
||||
t.Run("23", func(t *testing.T) {
|
||||
s := Base36(23)
|
||||
t.Logf("Base36 (23 chars): %s", s)
|
||||
assert.NotEmpty(t, s)
|
||||
assert.False(t, IsRefID(s))
|
||||
assert.True(t, InvalidRefID(s))
|
||||
assert.Equal(t, 23, len(s))
|
||||
})
|
||||
}
|
||||
|
||||
func TestBase62(t *testing.T) {
|
||||
t.Run("10", func(t *testing.T) {
|
||||
for n := 0; n < 10; n++ {
|
||||
s := Base62(10)
|
||||
t.Logf("Base62 %d: %s", n, s)
|
||||
assert.NotEmpty(t, s)
|
||||
}
|
||||
})
|
||||
t.Run("23", func(t *testing.T) {
|
||||
s := Base62(23)
|
||||
t.Logf("Base62 (23 chars): %s", s)
|
||||
assert.NotEmpty(t, s)
|
||||
assert.False(t, IsRefID(s))
|
||||
assert.True(t, InvalidRefID(s))
|
||||
assert.Equal(t, 23, len(s))
|
||||
})
|
||||
t.Run("32", func(t *testing.T) {
|
||||
for n := 0; n < 10; n++ {
|
||||
s := Base62(32)
|
||||
t.Logf("Base62 (32 chars) %d: %s", n, s)
|
||||
assert.NotEmpty(t, s)
|
||||
assert.False(t, IsRefID(s))
|
||||
assert.True(t, InvalidRefID(s))
|
||||
assert.Equal(t, 32, len(s))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCharset(t *testing.T) {
|
||||
s := Charset(23, CharsetBase62)
|
||||
t.Logf("CharsetBase62 (23 chars): %s", s)
|
||||
assert.NotEmpty(t, s)
|
||||
assert.False(t, IsRefID(s))
|
||||
assert.True(t, InvalidRefID(s))
|
||||
assert.Equal(t, 23, len(s))
|
||||
}
|
||||
|
||||
func TestRandomToken(t *testing.T) {
|
||||
t.Run("Size4", func(t *testing.T) {
|
||||
token := GenerateToken(4)
|
||||
assert.NotEmpty(t, token)
|
||||
s := Base36(4)
|
||||
assert.NotEmpty(t, s)
|
||||
})
|
||||
t.Run("Size8", func(t *testing.T) {
|
||||
token := GenerateToken(9)
|
||||
assert.NotEmpty(t, token)
|
||||
s := Base36(9)
|
||||
assert.NotEmpty(t, s)
|
||||
})
|
||||
t.Run("Log", func(t *testing.T) {
|
||||
for n := 0; n < 10; n++ {
|
||||
token := GenerateToken(8)
|
||||
t.Logf("%d: %s", n, token)
|
||||
assert.NotEmpty(t, token)
|
||||
s := Base36(8)
|
||||
t.Logf("%d: %s", n, s)
|
||||
assert.NotEmpty(t, s)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkGenerateToken4(b *testing.B) {
|
||||
for n := 0; n < b.N; n++ {
|
||||
GenerateToken(4)
|
||||
Base36(4)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGenerateToken3(b *testing.B) {
|
||||
for n := 0; n < b.N; n++ {
|
||||
GenerateToken(3)
|
||||
Base36(3)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ func GenerateUID(prefix byte) string {
|
||||
result := make([]byte, 0, 16)
|
||||
result = append(result, prefix)
|
||||
result = append(result, strconv.FormatInt(time.Now().UTC().Unix(), 36)[0:6]...)
|
||||
result = append(result, GenerateToken(9)...)
|
||||
result = append(result, Base36(9)...)
|
||||
|
||||
return string(result)
|
||||
}
|
||||
|
||||
@@ -22,8 +22,9 @@ func TestIsUID(t *testing.T) {
|
||||
prefix := byte('x')
|
||||
|
||||
for n := 0; n < 10; n++ {
|
||||
id := GenerateUID(prefix)
|
||||
assert.True(t, IsUID(id, prefix))
|
||||
s := GenerateUID(prefix)
|
||||
t.Logf("UID %d: %s", n, s)
|
||||
assert.True(t, IsUID(s, prefix))
|
||||
}
|
||||
|
||||
assert.True(t, IsUID("lt9k3pw1wowuy3c2", 'l'))
|
||||
@@ -69,9 +70,10 @@ func TestIsAlnum(t *testing.T) {
|
||||
|
||||
func TestGenerateUID(t *testing.T) {
|
||||
for n := 0; n < 5; n++ {
|
||||
uid := GenerateUID('x')
|
||||
t.Logf("id: %s", uid)
|
||||
uid := GenerateUID('c')
|
||||
t.Logf("UID %d: %s", n, uid)
|
||||
assert.Equal(t, len(uid), 16)
|
||||
assert.True(t, IsUID(uid, 'c'))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
|
||||
func TestUUID(t *testing.T) {
|
||||
for n := 0; n < 5; n++ {
|
||||
uuid := UUID()
|
||||
t.Logf("token: %s", uuid)
|
||||
assert.Equal(t, 36, len(uuid))
|
||||
s := UUID()
|
||||
t.Logf("UUID %d: %s", n, s)
|
||||
assert.Equal(t, 36, len(s))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user