Compare commits

...

66 Commits

Author SHA1 Message Date
renovate 9c4c265767 Update alpine Docker tag to v3.12 (#573)
Update alpine Docker tag to v3.12

Reviewed-on: vikunja/api#573
2020-05-29 21:44:01 +00:00
kolaente a525787ab7
Update and fix staticcheck 2020-05-29 22:15:21 +02:00
kolaente aae1bc3cab
Load the list when setting a background 2020-05-29 22:12:16 +02:00
kolaente 42ddee8d6f
Add logging if downloading an image from unsplash fails 2020-05-29 22:11:49 +02:00
renovate 54b18b3c59 Update module lib/pq to v1.6.0 (#572)
Update module lib/pq to v1.6.0

Reviewed-on: vikunja/api#572
2020-05-29 17:47:28 +00:00
kolaente 5a04f1ecf4
Add option to disable totp for everyone 2020-05-29 17:15:59 +02:00
kolaente a0fb8bd32d
Add unsplash image proxy for images and thumbnails 2020-05-29 15:33:46 +02:00
konrad c685250c96 Add test mail command (#571)
Add test mail command

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/api#571
2020-05-29 13:10:06 +00:00
renovate 9ea1104764 Update module stretchr/testify to v1.6.0 (#570)
Update module stretchr/testify to v1.6.0

Reviewed-on: vikunja/api#570
2020-05-29 09:37:34 +00:00
kolaente 2ef2c7523d
Also return unsplash info when searching 2020-05-27 22:12:12 +02:00
konrad e5e30d0915 List Backgrounds (#568)
Return the updated list when setting a list background

Add swagger docs for unsplash methods

Add unsplash info to search results

Fix misspell

Fix lint

Add rights check for setting and getting backgrounds

Show unsplash information when loading a single list

Make application id for pingbacks configurable

Remove old backgrounds when setting a new one

Return 404 if the list does not have a background

Implement getting list backgrounds

Implement actually setting a photo from unsplash as list background

go mod tidy

Add migration for background file id

Roughly implement setting a list background from unsplash

Implement saving a background

Add migration for unsplash photo table

Add unsplash search

Fix parsing page param

Fix parsing page param

Fix background config

Add unsplash wrapper library

Add enabled background providers to info endpoint

Add config options for backgrounds

Add unsplash background provider

Add routing handler for backgrounds

Add basic background provider interface

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/api#568
2020-05-26 20:07:55 +00:00
kolaente c37b776f7a
Add docker setup guide from start to finish 2020-05-24 13:50:17 +02:00
kolaente e9bc3246ce
Add todoist migrator to available migrators in info endpoint if it is enabled 2020-05-24 12:51:38 +02:00
konrad e89e6d47d4 Todoist Migration (#566)
Add swagger docs + fix lint

Add parsing logic + fix fixtures

Fix test init

Add logging to creating labels and debug logs

Add creating labels when migrating

Finish test fixtures

Started adding fixtures for testing

Add method and test structures to convert todoist to vikunja

Add basic structure to migrate everything

Add all structs for todoist api

Add docs for config options

Add routes for todoist migrator

Add api token exchange

Add basic structure for todoist migration

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/api#566
2020-05-23 20:50:54 +00:00
kolaente 292c815000
Add restarting commands to all example docker compose files 2020-05-23 19:34:44 +02:00
kolaente f0b1de5ec6
Use the db logger instance for logging migration related stuff 2020-05-23 18:33:11 +02:00
kolaente 4204af255c
Add ability to run the docker container with configurable user and group ids 2020-05-22 21:18:11 +02:00
renovate b0948a37d4 Update module go-redis/redis/v7 to v7.3.0 (#565)
Update module go-redis/redis/v7 to v7.3.0

Reviewed-on: vikunja/api#565
2020-05-21 08:56:39 +00:00
kolaente cc47d11792
"fix" gocyclo check 2020-05-19 17:41:37 +02:00
kolaente b6b82e6f54
Merge branch 'release/0.13' 2020-05-19 17:28:32 +02:00
kolaente 16ed5fd982
Release preparations 2020-05-19 17:27:37 +02:00
kolaente 24aa68f090
Don't return all tasks when a user has no lists 2020-05-19 17:23:49 +02:00
kolaente 98ef052967
Don't return all tasks when a user has no lists 2020-05-19 17:22:10 +02:00
kolaente 8a01b20e1e
Fix setting a list identifier to empty 2020-05-16 13:13:11 +02:00
kolaente 5a86f44fc3
Generate a random list identifier based on the list title 2020-05-16 12:58:37 +02:00
konrad fe43173b6c Ensure consistent naming of title fields (#528)
Remove task text and namespace name in migration

Fix lint

Add migration for namespace title

Fix renaming namespace name to title

Rename namespace name field to title

Drop text column at the end of the migration

Add migration for task text to title

Rename task text to title

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/api#528
2020-05-16 10:17:44 +00:00
konrad 587ce92dc9 Improve getting all namespaces performance (#526)
Improve getting all namespaces performance

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/api#526
2020-05-15 15:10:22 +00:00
konrad 8758fb6ac5 Fix case-insensitive task search for postgresql (#524)
"Fix" gocyclo

Fix case-insensitive task search for postgresql

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/api#524
2020-05-15 14:12:20 +00:00
kolaente 92e5e2db6a
Make the _unix suffix optional when sorting tasks 2020-05-15 16:07:36 +02:00
renovate 851f0d6c08 Update src.techknowlogick.com/xgo commit hash to 209a5cf (#523)
Update src.techknowlogick.com/xgo commit hash to 209a5cf

Reviewed-on: vikunja/api#523
2020-05-15 13:28:38 +00:00
kolaente 333444b4e1
Improve metrics performance 2020-05-15 14:42:32 +02:00
konrad 28a5c1f7b2 Remove dependencies on build step to speed up test pipeline (#521)
Make all steps depend on fetch tags to let them execute in parallel

Cleanup

Remove dependency from the build step everywhere

Add generation step for tests

Remove dependencies on build step to speed up test pipeline

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/api#521
2020-05-15 12:06:12 +00:00
renovate 4533ac6b28 Update module spf13/cobra to v1 (#511)
Update module spf13/cobra to v1

Reviewed-on: vikunja/api#511
2020-05-14 19:09:06 +00:00
kolaente 91a3b7aba2
0.13 release preperations 2020-05-12 22:11:46 +02:00
kolaente 4137d4aed2
Fix creating new things with a link share auth 2020-05-12 15:51:48 +02:00
kolaente cb095d70df
Add explanation to docs about cors 2020-05-12 11:10:07 +02:00
kolaente e43e601912
Add traefik 2 example configuration 2020-05-11 22:02:15 +02:00
kolaente 55cd40d175
Fix team rights not updating for namespace rights 2020-05-11 18:39:30 +02:00
renovate 55cd74efca Update module go-testfixtures/testfixtures/v3 to v3.2.0 (#505)
Update module go-testfixtures/testfixtures/v3 to v3.2.0

Reviewed-on: vikunja/api#505
2020-05-11 10:10:15 +00:00
renovate a9d0079bf3 Update golang.org/x/crypto commit hash to 06a226f (#504)
Update golang.org/x/crypto commit hash to 06a226f

Reviewed-on: vikunja/api#504
2020-05-11 09:02:23 +00:00
konrad 8ac158cdb4 Task Filter Fixes (#495)
Fix gocyclo

Fix misspell

Error codes docs

Filter fixes

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/api#495
2020-05-09 15:48:56 +00:00
renovate def2362682 Update module spf13/viper to v1.7.0 (#494)
Update module spf13/viper to v1.7.0

Reviewed-on: vikunja/api#494
2020-05-09 13:44:17 +00:00
kolaente 18f6e31b54
Fix parsing array style comparators by query param 2020-05-09 15:08:58 +02:00
kolaente 7e1d0a81bf
Change totp secret datatype from varchar to text 2020-05-09 14:45:57 +02:00
renovate f30e405229 Update module lib/pq to v1.5.2 (#491)
Update module lib/pq to v1.5.2

Reviewed-on: vikunja/api#491
2020-05-08 13:23:26 +00:00
kolaente 563fe16bd4
Add checking and logging when trying to put a task into a nonexisting bucket 2020-05-07 10:20:10 +02:00
kolaente a30a358b73
Update theme 2020-05-06 21:03:15 +02:00
kolaente 497499e221
Fix reference to reverse proxies in docs 2020-05-06 21:02:58 +02:00
kolaente 1fdc51078e
Add docs for changing frontend url 2020-05-06 20:44:48 +02:00
renovate 60f343a926 Update module lib/pq to v1.5.1 (#485)
Update module lib/pq to v1.5.1

Reviewed-on: vikunja/api#485
2020-05-05 08:59:08 +00:00
konrad fd3ccd6130 Fix total label count when getting all labels (#477)
Fix total label count when getting all labels

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/api#477
2020-05-03 21:28:36 +00:00
kolaente a97ce76f1f
Fix bucket ID being reset with no need to do so 2020-05-03 22:25:48 +02:00
kolaente 83cf6e191c
Fix not moving its bucket when moving a task between lists 2020-05-03 21:33:59 +02:00
kolaente f0ac6e89be
Remove setting task bucket to 0 2020-05-03 21:24:28 +02:00
renovate 23950c0602 Update module lib/pq to v1.5.0 (#476)
Update module lib/pq to v1.5.0

Reviewed-on: vikunja/api#476
2020-05-03 18:14:57 +00:00
renovate 19a05a7c2c Update golang.org/x/crypto commit hash to 4b2356b (#475)
Update golang.org/x/crypto commit hash to 4b2356b

Reviewed-on: vikunja/api#475
2020-05-01 10:06:00 +00:00
renovate 299e856736 Update module prometheus/client_golang to v1.6.0 (#463)
Update module prometheus/client_golang to v1.6.0

Reviewed-on: vikunja/api#463
2020-04-28 18:50:58 +00:00
renovate 6d95dc16c2 Update golang.org/x/crypto commit hash to 729f1e8 (#458)
Update golang.org/x/crypto commit hash to 729f1e8

Reviewed-on: vikunja/api#458
2020-04-27 18:56:31 +00:00
kolaente fa28ddc2f7
Fix pagination count for task collection 2020-04-27 19:28:19 +02:00
kolaente 56dbb564ea
Fix link share creation & creating admin link shares without admin rights 2020-04-27 12:51:09 +02:00
renovate 711124f5c0 Update module go-testfixtures/testfixtures/v3 to v3.1.2 (#457)
Update module go-testfixtures/testfixtures/v3 to v3.1.2

Reviewed-on: vikunja/api#457
2020-04-27 09:10:08 +00:00
kolaente 5606f987fa
Add creating a new first bucket when creating a new list 2020-04-26 15:51:59 +02:00
kolaente f0cb3b5e36
Fix removing the last bucket 2020-04-26 00:57:47 +02:00
konrad 231dc3913f Add real buckets for tasks which don't have one (#446)
Add docs for error code

Add moving new tasks into the default bucket when none was provided

Add moving tasks in default bucket when deleting one

Fix tests again

Add test for removing a bucket

Fix tests

Prevent removing the last bucket

Remove the empty pseudo bucket

Add migration to create a new bucket for each list (and put all tasks in it

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/api#446
2020-04-25 20:32:02 +00:00
renovate 2b1fc441e6 Update golang.org/x/crypto commit hash to 4bdfaf4 (#438)
Update golang.org/x/crypto commit hash to 4bdfaf4

Reviewed-on: vikunja/api#438
2020-04-24 15:23:22 +00:00
konrad e433289832 Task Position (#412)
Fix misspell

Fix sorting tasks with null values

Fix sorting by priority for postgres

Merge branch 'master' into feature/position

Add community link

Update golang.org/x/crypto commit hash to 44a6062 (#429)

Update golang.org/x/crypto commit hash to 44a6062

Reviewed-on: vikunja/api#429

Update module lib/pq to v1.4.0 (#428)

Update module lib/pq to v1.4.0

Reviewed-on: vikunja/api#428

Fix updating position

Add ordering tasks in buckets by position

Make task sort by string

Merge branch 'master' into feature/position

Update golang.org/x/crypto commit hash to 3c4aac8 (#419)

Update golang.org/x/crypto commit hash to 3c4aac8

Reviewed-on: vikunja/api#419

Merge branch 'master' into feature/position

Fix moving tasks back into the empty (ID: 0) bucket

Add adding a default position when creating new tasks

Update golang.org/x/crypto commit hash to a76a400 (#411)

Update golang.org/x/crypto commit hash to a76a400

Reviewed-on: vikunja/api#411

Remove unused code

Fix tests

Add migration for position attribute

Add position attribute

Co-authored-by: kolaente <k@knt.li>
Co-authored-by: renovate <renovatebot@kolaente.de>
Reviewed-on: vikunja/api#412
2020-04-24 15:23:03 +00:00
614 changed files with 88476 additions and 25547 deletions

View File

@ -67,8 +67,9 @@ steps:
image: vikunja/golang-build:latest
pull: true
commands:
- make generate
- make test
depends_on: [ build ]
depends_on: [ fetch-tags ]
when:
event: [ push, tag, pull_request ]
@ -79,8 +80,9 @@ steps:
VIKUNJA_TESTS_USE_CONFIG: 1
VIKUNJA_DATABASE_TYPE: sqlite
commands:
- make generate
- make test
depends_on: [ build ]
depends_on: [ fetch-tags ]
when:
event: [ push, tag, pull_request ]
@ -95,8 +97,9 @@ steps:
VIKUNJA_DATABASE_PASSWORD: vikunjatest
VIKUNJA_DATABASE_DATABASE: vikunjatest
commands:
- make generate
- make test
depends_on: [ build ]
depends_on: [ fetch-tags ]
when:
event: [ push, tag, pull_request ]
@ -112,8 +115,9 @@ steps:
VIKUNJA_DATABASE_DATABASE: vikunjatest
VIKUNJA_DATABASE_SSLMODE: disable
commands:
- make generate
- make test
depends_on: [ build ]
depends_on: [ fetch-tags ]
when:
event: [ push, tag, pull_request ]
@ -121,8 +125,9 @@ steps:
image: vikunja/golang-build:latest
pull: true
commands:
- make generate
- make integration-test
depends_on: [ build ]
depends_on: [ fetch-tags ]
when:
event: [ push, tag, pull_request ]
@ -133,8 +138,9 @@ steps:
VIKUNJA_TESTS_USE_CONFIG: 1
VIKUNJA_DATABASE_TYPE: sqlite
commands:
- make generate
- make integration-test
depends_on: [ build ]
depends_on: [ fetch-tags ]
when:
event: [ push, tag, pull_request ]
@ -149,8 +155,9 @@ steps:
VIKUNJA_DATABASE_PASSWORD: vikunjatest
VIKUNJA_DATABASE_DATABASE: vikunjatest
commands:
- make generate
- make integration-test
depends_on: [ build ]
depends_on: [ fetch-tags ]
when:
event: [ push, tag, pull_request ]
@ -166,8 +173,9 @@ steps:
VIKUNJA_DATABASE_DATABASE: vikunjatest
VIKUNJA_DATABASE_SSLMODE: disable
commands:
- make generate
- make integration-test
depends_on: [ build ]
depends_on: [ fetch-tags ]
when:
event: [ push, tag, pull_request ]

View File

@ -7,6 +7,105 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
All releases can be found on https://code.vikunja.io/api/releases.
## [0.13.1] - 2020-05-19
### Fixed
* Don't get all tasks if a user has no lists
## [0.13] - 2020-05-12
#### Added
* Add 2fa for authentification (#383)
* Add categories to error docs
* Add changing email for users
* Add community link
* Add configuration options for log level
* Add creating a new first bucket when creating a new list
* Add docs for changing frontend url
* Add endpoint to disable totp auth
* Add endpoint to get the current users totp status
* Add explanation to docs about cors
* Add github token for renovate (#164)
* Add gosec static analysis
* Add moving tasks between lists (#389)
* Add real buckets for tasks which don't have one (#446)
* Add traefik 2 example configuration
* Configure Renovate (#159)
* Kanban (#393)
* Task filters (#243)
* Task Position (#412)
#### Fixed
* Add checking and logging when trying to put a task into a nonexisting bucket
* Fix bucket ID being reset with no need to do so
* Fix creating new things with a link share auth
* Fix dependencies
* Fix gosec in drone
* Fix link share creation & creating admin link shares without admin rights
* Fix moving tasks back into the empty (ID: 0) bucket
* Fix moving tasks in buckets
* Fix not moving its bucket when moving a task between lists
* Fix pagination count for task collection
* Fix parsing array style comparators by query param
* Fix reference to reverse proxies in docs
* Fix removing the last bucket
* Fix replace statements for tail
* Fix team rights not updating for namespace rights
* Fix tests after renaming json fields to snake_case
* Fix total label count when getting all labels (#477)
* Remove setting task bucket to 0
* Task Filter Fixes (#495)
#### Changed
* Change all json fields to snake_case
* Change totp secret datatype from varchar to text
* Update alpine Docker tag to v3.11 (#160)
* Update docs theme
* Update github.com/c2h5oh/datasize commit hash to 28bbd47 (#212)
* Update github.com/gordonklaus/ineffassign commit hash to 7953dde (#233)
* Update github.com/jgautheron/goconst commit hash to cda7ea3 (#228)
* Update github.com/shurcooL/httpfs commit hash to 8d4bc4b (#229)
* Update golang.org/x/crypto commit hash to 056763e (#222)
* Update golang.org/x/crypto commit hash to 06a226f (#504)
* Update golang.org/x/crypto commit hash to 0848c95 (#371)
* Update golang.org/x/crypto commit hash to 3c4aac8 (#419)
* Update golang.org/x/crypto commit hash to 44a6062 (#429)
* Update golang.org/x/crypto commit hash to 4b2356b (#475)
* Update golang.org/x/crypto commit hash to 4bdfaf4 (#438)
* Update golang.org/x/crypto commit hash to 729f1e8 (#458)
* Update golang.org/x/crypto commit hash to a76a400 (#411)
* Update golang.org/x/lint commit hash to 738671d (#223)
* Update module go-redis/redis to v6.15.7 (#234)
* Update module go-redis/redis to v6.15.7 (#290)
* Update module go-redis/redis to v7 (#277)
* Update module go-redis/redis to v7 (#309)
* Update module go-testfixtures/testfixtures/v3 to v3.1.2 (#457)
* Update module go-testfixtures/testfixtures/v3 to v3.2.0 (#505)
* Update module imdario/mergo to v0.3.9 (#238)
* Update module labstack/echo/v4 to v4.1.16 (#241)
* Update module lib/pq to v1.4.0 (#428)
* Update module lib/pq to v1.5.0 (#476)
* Update module lib/pq to v1.5.1 (#485)
* Update module lib/pq to v1.5.2 (#491)
* Update module olekukonko/tablewriter to v0.0.4 (#240)
* Update module prometheus/client_golang to v0.9.4 (#245)
* Update module prometheus/client_golang to v1
* Update module prometheus/client_golang to v1.6.0 (#463)
* Update module spf13/cobra to v0.0.7 (#271)
* Update module spf13/viper to v1.6.2 (#272)
* Update module spf13/viper to v1.6.3 (#291)
* Update module spf13/viper to v1.7.0 (#494)
* Update module stretchr/testify to v1.5.1 (#274)
* Update Renovate Configuration (#161)
* Update src.techknowlogick.com/xgo commit hash to bb0faa3 (#279)
* Update src.techknowlogick.com/xgo commit hash to c43d4c4 (#224)
* Update xorm redis cacher to use the xorm logger instead of a special seperate one
* Update xorm to v1 (#323)
## [0.12] - 2020-04-04
#### Added

View File

@ -23,15 +23,22 @@ RUN if [ -n "${VIKUNJA_VERSION}" ]; then git checkout "${VIKUNJA_VERSION}"; fi \
# The actual image
# Note: I wanted to use the scratch image here, but unfortunatly the go-sqlite bindings require cgo and
# because of this, the container would not start when I compiled the image without cgo.
FROM alpine:3.11
FROM alpine:3.12
LABEL maintainer="maintainers@vikunja.io"
WORKDIR /app/vikunja/
COPY --from=build-env /go/src/code.vikunja.io/api/vikunja .
RUN adduser -S -D vikunja -h /app/vikunja -H \
&& chown vikunja -R /app/vikunja
ENV VIKUNJA_SERVICE_ROOTPATH=/app/vikunja/
# Dynamic permission changing stuff
ENV PUID 1000
ENV PGID 1000
RUN apk --no-cache add shadow && \
addgroup -g ${PGID} vikunja && \
adduser -s /bin/sh -D -G vikunja -u ${PUID} vikunja -h /app/vikunja -H && \
chown vikunja -R /app/vikunja
COPY run.sh /run.sh
# Fix time zone settings not working
RUN apk --no-cache add tzdata
@ -40,6 +47,5 @@ RUN mkdir /app/vikunja/files && \
chown -R vikunja /app/vikunja/files
VOLUME /app/vikunja/files
USER vikunja
CMD ["/app/vikunja/vikunja"]
CMD ["/run.sh"]
EXPOSE 3456

View File

@ -219,7 +219,7 @@ gocyclo-check:
go get -u github.com/fzipp/gocyclo; \
go install $(GOFLAGS) github.com/fzipp/gocyclo; \
fi
for S in $(GOFILES); do gocyclo -over 24 $$S || exit 1; done;
for S in $(GOFILES); do gocyclo -over 29 $$S || exit 1; done;
.PHONY: static-check
static-check:

View File

@ -2,7 +2,7 @@
[![Build Status](https://drone1.kolaente.de/api/badges/vikunja/api/status.svg)](https://drone1.kolaente.de/vikunja/api)
[![License: GPL v3](https://img.shields.io/badge/License-GPL%20v3-blue.svg)](LICENSE)
[![Download](https://img.shields.io/badge/download-v0.12-brightgreen.svg)](https://dl.vikunja.io)
[![Download](https://img.shields.io/badge/download-v0.13.1-brightgreen.svg)](https://dl.vikunja.io)
[![Docker Pulls](https://img.shields.io/docker/pulls/vikunja/api.svg)](https://hub.docker.com/r/vikunja/api/)
[![Swagger Docs](https://img.shields.io/badge/swagger-docs-brightgreen.svg)](https://try.vikunja.io/api/v1/docs)
[![Go Report Card](https://goreportcard.com/badge/git.kolaente.de/vikunja/api)](https://goreportcard.com/report/git.kolaente.de/vikunja/api)

View File

@ -30,6 +30,8 @@ service:
timezone: GMT
# Whether task comments should be enabled or not
enabletaskcomments: true
# Whether totp is enabled. In most cases you want to leave that enabled.
enabletotp: true
database:
# Database type to use. Supported types are mysql, postgres and sqlite.
@ -74,6 +76,8 @@ redis:
cors:
# Whether to enable or disable cors headers.
# Note: If you want to put the frontend and the api on seperate domains or ports, you will need to enable this.
# Otherwise the frontend won't be able to make requests to the api through the browser.
enable: true
# A list of origins which may access the api.
origins:
@ -154,6 +158,20 @@ migration:
# with the code obtained from the wunderlist api.
# Note that the vikunja frontend expects this to be /migrate/wunderlist
redirecturl:
todoist:
# Wheter to enable the todoist migrator or not
enable: false
# The client id, required for making requests to the wunderlist api
# You need to register your vikunja instance at https://developer.todoist.com/appconsole.html to get this
clientid:
# The client secret, also required for making requests to the todoist api
clientsecret:
# The url where clients are redirected after they authorized Vikunja to access their todoist items.
# This needs to match the url you entered when registering your Vikunja instance at todoist.
# This is usually the frontend url where the frontend then makes a request to /migration/todoist/migrate
# with the code obtained from the todoist api.
# Note that the vikunja frontend expects this to be /migrate/todoist
redirecturl:
avatar:
# Switch between avatar providers. Possible values are gravatar and default.
@ -162,3 +180,19 @@ avatar:
provider: gravatar
# When using gravatar, this is the duration in seconds until a cached gravatar user avatar expires
gravatarexpiration: 3600
backgrounds:
# Whether to enable backgrounds for lists at all.
enabled: false
providers:
unsplash:
# Whether to enable setting backgrounds from unsplash as list backgrounds
enabled: false
# You need to create an application for your installation at https://unsplash.com/oauth/applications/new
# and set the access token below.
accesstoken:
# The unsplash application id is only used for pingback and required as per their api guidelines.
# You can find the Application ID in the dashboard for your API application. It should be a numeric ID.
# It will only show in the UI if your application has been approved for Enterprise usage, therefore if
# youre in Demo mode, you can also find the ID in the URL at the end: https://unsplash.com/oauth/applications/:application_id
applicationid:

View File

@ -73,6 +73,8 @@ service:
timezone: GMT
# Whether task comments should be enabled or not
enabletaskcomments: true
# Whether totp is enabled. In most cases you want to leave that enabled.
enabletotp: true
database:
# Database type to use. Supported types are mysql, postgres and sqlite.
@ -117,6 +119,8 @@ redis:
cors:
# Whether to enable or disable cors headers.
# Note: If you want to put the frontend and the api on seperate domains or ports, you will need to enable this.
# Otherwise the frontend won't be able to make requests to the api through the browser.
enable: true
# A list of origins which may access the api.
origins:
@ -197,6 +201,20 @@ migration:
# with the code obtained from the wunderlist api.
# Note that the vikunja frontend expects this to be /migrate/wunderlist
redirecturl:
todoist:
# Wheter to enable the todoist migrator or not
enable: false
# The client id, required for making requests to the wunderlist api
# You need to register your vikunja instance at https://developer.todoist.com/appconsole.html to get this
clientid:
# The client secret, also required for making requests to the todoist api
clientsecret:
# The url where clients are redirected after they authorized Vikunja to access their todoist items.
# This needs to match the url you entered when registering your Vikunja instance at todoist.
# This is usually the frontend url where the frontend then makes a request to /migration/todoist/migrate
# with the code obtained from the todoist api.
# Note that the vikunja frontend expects this to be /migrate/todoist
redirecturl:
avatar:
# Switch between avatar providers. Possible values are gravatar and default.
@ -205,4 +223,20 @@ avatar:
provider: gravatar
# When using gravatar, this is the duration in seconds until a cached gravatar user avatar expires
gravatarexpiration: 3600
backgrounds:
# Whether to enable backgrounds for lists at all.
enabled: false
providers:
unsplash:
# Whether to enable setting backgrounds from unsplash as list backgrounds
enabled: false
# You need to create an application for your installation at https://unsplash.com/oauth/applications/new
# and set the access token below.
accesstoken:
# The unsplash application id is only used for pingback and required as per their api guidelines.
# You can find the Application ID in the dashboard for your API application. It should be a numeric ID.
# It will only show in the UI if your application has been approved for Enterprise usage, therefore if
# youre in Demo mode, you can also find the ID in the URL at the end: https://unsplash.com/oauth/applications/:application_id
applicationid:
{{< /highlight >}}

View File

@ -0,0 +1,221 @@
---
date: "2020-05-24:00:00+02:00"
title: "Docker Walkthrough"
draft: false
type: "doc"
menu:
sidebar:
parent: "setup"
---
# Setup with docker from start to finish
This tutorial assumes basic knowledge of docker.
It is aimed at beginners and should get you up and running quickly.
We'll use [docker compose](https://docs.docker.com/compose/) to make handling the bunch of containers easier.
> If you have any issues setting up vikunja, please don't hesitate to reach out to us via [matrix](https://riot.im/app/#/room/!dCRiCiLaCCFVNlDnYs:matrix.org?via=matrix.org), the [community forum](https://community.vikunja.io/) or even [email](mailto:hello@vikunja.io).
## Preparations (optional)
Create a directory for the project where all data and the compose file will live in.
## Create all necessary files
Create a `docker-compose.yml` file with the following contents in your directory:
{{< highlight yaml >}}
version: '3'
services:
db:
image: mariadb:10
environment:
MYSQL_ROOT_PASSWORD: supersecret
MYSQL_DATABASE: vikunja
volumes:
- ./db:/var/lib/mysql
restart: unless-stopped
api:
image: vikunja/api
environment:
VIKUNJA_DATABASE_HOST: db
VIKUNJA_DATABASE_PASSWORD: supersecret
VIKUNJA_DATABASE_TYPE: mysql
VIKUNJA_DATABASE_USER: root
VIKUNJA_DATABASE_DATABASE: vikunja
volumes:
- ./files:/app/vikunja/files
depends_on:
- db
restart: unless-stopped
frontend:
image: vikunja/frontend
restart: unless-stopped
proxy:
image: nginx
ports:
- 80:80
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- api
- frontend
restart: unless-stopped
{{< /highlight >}}
This defines four services, each with their own container:
* An api service which runs the vikunja api. Most of the core logic lives here.
* The frontend which will make vikunja actually usable for most people.
* A database container which will store all lists, tasks, etc. We're using mariadb here, but you're free to use mysql or postgres if you want.
* A proxy service which makes the frontend and api available on the same port, redirecting all requests to `/api` to the api container.
If you already have a proxy on your host, you may want to check out the [reverse proxy examples]() to use that.
By default, it uses port 80 on the host.
To change to something different, you'll need to change the `ports` section in the service definition.
The number before the colon is the host port - This is where you can reach vikunja from the outside once all is up and running.
For the proxy service we'll need another bit of configuration.
Create an `nginx.conf` in your directory (next to the `docker-compose.yml` file) and add the following contents to it:
{{< highlight conf >}}
server {
listen 80;
location / {
proxy_pass http://frontend:80;
}
location /api/ {
proxy_pass http://api:3456;
}
}
{{< /highlight >}}
This is a simple proxy configuration which will forward all requests to `/api/` to the api container and everything else to the frontend.
**Note:** Even if you want to make your installation available under a different port, you don't need to change anything in this configuration.
## Run it
Run `sudo docker-compose up` in your directory and take a look at the output you get.
When first started, Vikunja will set up the database and run all migrations etc.
Once it is ready, you should see a message like this one in your console:
```
api_1 | 2020-05-24T11:15:37.560386009Z: INFO ▶ cmd/func1 025 Vikunja version 0.13.1+19-e9bc3246ce, built at Sun, 24 May 2020 11:10:36 +0000
api_1 | ⇨ http server started on [::]:3456
```
This indicates all setup has been successful.
If you get any errors, see below:
### Troubleshooting
Vikunja might not run on the first try.
There are a few potential issues that could be causing this.
#### No connection to the database
Indicated by an error message like this one from the api container:
```
2020/05/23 15:37:59 Config File "config" Not Found in "[/app/vikunja /etc/vikunja /app/vikunja/.config/vikunja]"
2020/05/23 15:37:59 Using default config.
2020-05-23T15:37:59.974435725Z: CRITICAL ▶ migration/Migrate 002 Migration failed: dial tcp 172.19.0.2:3306: connect: connection refused
```
Especially when using mysql, this can happen on first start, because the mysql database container will take a few seconds to start.
Vikunja does not know the container is not ready, therefore it will just try to connect to the db, fail since it is not ready and exit.
If you're using the docker compose example from above, you may notice the `restart: unless-stopped` option at the api service.
This tells docker to restart the api container if it exits, unless you explicitly stop it.
Therefore, it should "magically fix itself" by automatically restarting the container.
After a few seconds (or minutes) you should see a log message like this one from the mariadb container:
```
2020-05-24 11:42:15 0 [Note] mysqld: ready for connections.
Version: '10.4.12-MariaDB-1:10.4.12+maria~bionic' socket: '/var/run/mysqld/mysqld.sock' port: 3306 mariadb.org binary distribution
```
The next restart of Vikunja should be successful.
If not, there might be a different error or a bug with Vikunja, please reach out to us in that case.
(If you have an idea about how we could improve this, we'd like to hear it!)
#### "Not a directory"
If you get an error like this one:
```
ERROR: for vikunja_proxy_1 Cannot start service proxy: OCI runtime create failed: container_linux.go:349: starting container process caused "process_linux.go:449: container init caused \"rootfs_linux.go:58: mounting \\\"vikunja/nginx.conf\\\" to rootfs \\\"/var/lib/docker/overlay2/9c8b8f9419c29dad0d1233fbb0a3c36cf403dabd7a55d6f0a47b0c1dd6029994/merged\\\" at \\\"/var/lib/docker/overlay2/9c8b8f9419c29dad0d1233fbb0a3c36cf403dabd7a55d6f0a47b0c1dd6029994/merged/etc/nginx/conf.d/default.conf\\\" caused \\\"not a directory\\\"\"": unknown: Are you trying to mount a directory onto a file (or vice-versa)? Check if the specified host path exists and is the expected type
```
this means docker tried to mount a directory from the host to a file in the container.
This can happen if you did not create the `nginx.conf` file.
Because there is a volume mount for it in the `docker-compose.yml`, Docker will create a folder because non exists, assuming you want to mount a folder into the container.
To fix this, create the file and restart the containers again.
#### Migration failed: commands out of sync
If you get an error like this one:
```
2020/05/23 15:53:38 Config File "config" Not Found in "[/app/vikunja /etc/vikunja /app/vikunja/.config/vikunja]"
2020/05/23 15:53:38 Using default config.
2020-05-23T15:53:38.762747276Z: CRITICAL ▶ migration/Migrate 002 Migration failed: commands out of sync. Did you run multiple statements at once?
```
This is a mysql issue.
Currently, we don't have a better solution than to completely wipe the database files and start over.
To do this, first stop everything by running `sudo docker-compose down`, then remove the `db/` folder in your current folder with `sudo rm -rf db` and start the whole stack again with `sudo docker-compose up -d`.
## Try it
Head over to `http://<host-ip or url>/api/v1/info` in a browser.
You should see something like this:
{{< highlight json >}}
{
"version": "0.13.1+19-e9bc3246ce",
"frontend_url": "http://localhost:8080/",
"motd": "test",
"link_sharing_enabled": true,
"max_file_size": "20MB",
"registration_enabled": true,
"available_migrators": [
"wunderlist",
"todoist"
],
"task_attachments_enabled": true
}
{{< /highlight >}}
This shows you can reach the api through the api proxy.
Now head over to `http://<host-ip or url>/` which should show the login mask.
## Make it persistent
Currently, Vikunja runs in foreground in your terminal.
For a real-world scenario this is not the best way.
Back in your terminal, stop the stack by pressing `CTRL-C` on your keyboard.
Then run `sudo docker-compose up -d` in your again.
The `-d` flag at the end of the command will tell docker to run the containers in the background.
If you need to check the logs after that, you can run `sudo docker-compose logs`.
Vikunja does not have any default users, you'll need to register and account.
After that, you can use it.
## Tear it all down
If you want to completely stop all containers run `sudo docker-compose down` in your terminal.
## Improve this guide
We'll happily accept suggestions and improvements for this guide.
Please [reach out to us](https://vikunja.io/contact/) if you have any.

View File

@ -15,11 +15,36 @@ It uses an nginx container or traefik on the host to proxy backend and frontend
For all available configuration options, see [configuration]({{< ref "config.md">}}).
## Example with traefik
### Redis
This example assumes [traefik](https://traefik.io) in version 1 installed and configured to [use docker as a configuration provider](https://docs.traefik.io/v1.7/configuration/backends/docker/).
To use redis, you'll need to add this to the config examples below:
### Without redis
{{< highlight yaml >}}
version: '3'
services:
api:
image: vikunja/api
environment:
VIKUNJA_REDIS_ENABLED: 1
VIKUNJA_REDIS_HOST: 'redis:6379'
VIKUNJA_CACHE_ENABLED: 1
VIKUNJA_CACHE_TYPE: redis
volumes:
- ./files:/app/vikunja/files
redis:
image: redis
{{< /highlight >}}
## Example with traefik 2
This example assumes [traefik](https://traefik.io) version 2 installed and configured to [use docker as a configuration provider](https://docs.traefik.io/providers/docker/).
We also make a few assumtions here which you'll most likely need to adjust for your traefik setup:
* Your domain is `vikunja.example.com`
* The entrypoint you want to make vikunja available from is called `https`
* The tls cert resolver is called `acme`
{{< highlight yaml >}}
version: '3'
@ -42,19 +67,17 @@ services:
- db
restart: unless-stopped
labels:
- "traefik.docker.network=web"
- "traefik.enable=true"
- "traefik.frontend.rule=Host:vikunja.example.com;PathPrefix:/api/v1"
- "traefik.port=3456"
- "traefik.protocol=http"
- "traefik.http.routers.vikunja-api.rule=Host(`vikunja.example.com`) && PathPrefix(`/api/v1`)"
- "traefik.http.routers.vikunja-api.entrypoints=https"
- "traefik.http.routers.vikunja-api.tls.certResolver=acme"
frontend:
image: vikunja/frontend
labels:
- "traefik.docker.network=web"
- "traefik.enable=true"
- "traefik.frontend.rule=Host:vikunja.example.com;PathPrefix:/"
- "traefik.port=80"
- "traefik.protocol=http"
- "traefik.http.routers.vikunja-frontend.rule=Host(`vikunja.example.com`)"
- "traefik.http.routers.vikunja-frontend.entrypoints=https"
- "traefik.http.routers.vikunja-frontend.tls.certResolver=acme"
networks:
- web
- default
@ -76,7 +99,9 @@ networks:
external: true
{{< /highlight >}}
### With redis
## Example with traefik 1
This example assumes [traefik](https://traefik.io) in version 1 installed and configured to [use docker as a configuration provider](https://docs.traefik.io/v1.7/configuration/backends/docker/).
{{< highlight yaml >}}
version: '3'
@ -90,10 +115,6 @@ services:
VIKUNJA_DATABASE_TYPE: mysql
VIKUNJA_DATABASE_USER: vikunja
VIKUNJA_DATABASE_DATABASE: vikunja
VIKUNJA_REDIS_ENABLED: 1
VIKUNJA_REDIS_HOST: 'redis:6379'
VIKUNJA_CACHE_ENABLED: 1
VIKUNJA_CACHE_TYPE: redis
volumes:
- ./files:/app/vikunja/files
networks:
@ -101,7 +122,6 @@ services:
- default
depends_on:
- db
- redis
restart: unless-stopped
labels:
- "traefik.docker.network=web"
@ -132,8 +152,6 @@ services:
- ./db:/var/lib/mysql
restart: unless-stopped
command: --max-connections=1000
redis:
image: redis
networks:
web:
@ -159,7 +177,7 @@ server {
}
{{< /highlight >}}
### Without redis
`docker-compose.yml` config:
{{< highlight yaml >}}
version: '3'
@ -172,6 +190,7 @@ services:
MYSQL_DATABASE: vikunja
volumes:
- ./db:/var/lib/mysql
restart: unless-stopped
api:
image: vikunja/api
environment:
@ -184,8 +203,10 @@ services:
- ./files:/app/vikunja/files
depends_on:
- db
restart: unless-stopped
frontend:
image: vikunja/frontend
restart: unless-stopped
proxy:
image: nginx
ports:
@ -195,49 +216,5 @@ services:
depends_on:
- api
- frontend
restart: unless-stopped
{{< /highlight >}}
### With redis
{{< highlight yaml >}}
version: '3'
services:
db:
image: mariadb:10
environment:
MYSQL_ROOT_PASSWORD: supersecret
MYSQL_DATABASE: vikunja
volumes:
- ./db:/var/lib/mysql
redis:
image: redis
api:
image: vikunja/api
environment:
VIKUNJA_DATABASE_HOST: db
VIKUNJA_DATABASE_PASSWORD: supersecret
VIKUNJA_DATABASE_TYPE: mysql
VIKUNJA_DATABASE_USER: root
VIKUNJA_DATABASE_DATABASE: vikunja
VIKUNJA_REDIS_ENABLED: 1
VIKUNJA_REDIS_HOST: 'redis:6379'
VIKUNJA_CACHE_ENABLED: 1
VIKUNJA_CACHE_TYPE: redis
volumes:
- ./files:/app/vikunja/files
depends_on:
- db
- redis
frontend:
image: vikunja/frontend
proxy:
image: nginx
ports:
- 80:80
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- api
- frontend
{{< /highlight >}}

View File

@ -106,7 +106,7 @@ docker run -p 3456:3456 vikunja/api
{{< /highlight >}}
to run with a standard configuration.
This will expose
This will expose vikunja on port `3456` on the host running the container.
You can mount a local configuration like so:
@ -117,6 +117,18 @@ docker run -p 3456:3456 -v /path/to/config/on/host.yml:/app/vikunja/config.yml:r
Though it is recommended to use eviroment variables or `.env` files to configure Vikunja in docker.
See [config]({{< ref "config.md">}}) for a list of available configuration options.
### Files volume
By default the container stores all files uploaded and used through vikunja inside of `/app/vikunja/files` which is created as a docker volume.
You should mount the volume somewhere to the host to permanently store the files and don't loose them if the container restarts.
### Setting user and group id of the user running vikunja
You can set the user and group id of the user running vikunja with the `PUID` and `PGID` evironment variables.
This follows the pattern used by [the linuxserver.io](https://docs.linuxserver.io/general/understanding-puid-and-pgid) docker images.
This is useful to solve general permission problems when host-mounting volumes such as the volume used for task attachments.
### Docker compose
To run the backend with a mariadb database you can use this example [docker-compose](https://docs.docker.com/compose/) file:
@ -132,13 +144,15 @@ services:
VIKUNJA_DATABASE_TYPE: mysql
VIKUNJA_DATABASE_USER: root
VIKUNJA_SERVICE_JWTSECRET: <generated secret>
volumes:
- ./files:/app/vikunja/files
db:
image: mariadb:10
environment:
MYSQL_ROOT_PASSWORD: supersecret
MYSQL_DATABASE: vikunja
volumes:
- ./db:/var/lib/mysql
- ./db:/var/lib/mysql
{{< /highlight >}}
See [full docker example]({{< ref "full-docker-example.md">}}) for more varations of this config.

View File

@ -17,6 +17,20 @@ Unzip them and store them somewhere your server can access them.
You also need to configure a rewrite condition to internally redirect all requests to `index.html` which handles all urls.
## API URL configuration
By default, the frontend assumes it can reach the api at `/api/v1` relative to the frontend url.
This means that if you make the frontend available at, say `https://vikunja.example.com`, it tries to reach the api
at `https://vikunja.example.com/api/v1`.
In this scenario it is not possible for the frontend and the api to live on seperate servers or even just seperate
ports on the same server with [the use of a reverse proxy]({{< ref "reverse-proxies.md">}}).
To make configurations like this possible, the api url can be set in the `index.html` file of the frontend releases.
Just open the file with a text editor - there are comments which will explain how to set the url.
**Note:** This needs to be done again after every update.
(If you have a good idea for a better solution than this, we'd love to [hear it](https://vikunja.io/contact/))
## Docker
The docker image is based on nginx and just contains all nessecary files for the frontend.
@ -31,6 +45,16 @@ which will run the docker image and expose port 80 on the host.
See [full docker example]({{< ref "full-docker-example.md">}}) for more varations of this config.
### Setting user and group id of the user running vikunja
You can set the user and group id of the user running vikunja with the `PUID` and `PGID` evironment variables.
This follows the pattern used by [the linuxserver.io](https://docs.linuxserver.io/general/understanding-puid-and-pgid) docker images.
### API URL configuration in docker
When running the frontend with docker, it is possible to set the environment variable `$VIKUNJA_API_URL` to the api url.
It is therefore not needed to change the url manually inside the docker container.
## NGINX
Below are two example configurations which you can put in your `nginx.conf`:

View File

@ -77,6 +77,7 @@ This document describes the different errors Vikunja can return.
| 4015 | 404 | The task comment does not exist. |
| 4016 | 403 | Invalid task field. |
| 4017 | 403 | Invalid task filter comparator. |
| 4018 | 403 | Invalid task filter concatinator. |
### Namespace
@ -128,3 +129,4 @@ This document describes the different errors Vikunja can return.
|-----------|------------------|-------------|
| 10001 | 404 | The bucket does not exist. |
| 10002 | 400 | The bucket does not belong to that list. |
| 10003 | 412 | You cannot remove the last bucket on a list. |

2
docs/themes/vikunja vendored

@ -1 +1 @@
Subproject commit cbc6a98e902516fcb0c8644283e17ebf5e74a2f3
Subproject commit 88977f461bc7cb0671cdbfd64c91cf0f13419865

23
go.mod
View File

@ -33,10 +33,9 @@ require (
github.com/fzipp/gocyclo v0.0.0-20150627053110-6acd4345c835
github.com/go-openapi/jsonreference v0.19.3 // indirect
github.com/go-openapi/spec v0.19.4 // indirect
github.com/go-redis/redis/v7 v7.2.0
github.com/go-redis/redis/v7 v7.3.0
github.com/go-sql-driver/mysql v1.5.0
github.com/go-testfixtures/testfixtures/v3 v3.1.1
github.com/golang/protobuf v1.3.5 // indirect
github.com/go-testfixtures/testfixtures/v3 v3.2.0
github.com/gordonklaus/ineffassign v0.0.0-20200309095847-7953dde2c7bf
github.com/iancoleman/strcase v0.0.0-20191112232945-16388991a334
github.com/imdario/mergo v0.3.9
@ -45,10 +44,9 @@ require (
github.com/labstack/echo/v4 v4.1.16
github.com/labstack/gommon v0.3.0
github.com/laurent22/ical-go v0.1.1-0.20181107184520-7e5d6ade8eef
github.com/lib/pq v1.4.0
github.com/lib/pq v1.6.0
github.com/mailru/easyjson v0.7.0 // indirect
github.com/mattn/go-sqlite3 v2.0.3+incompatible
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
github.com/olekukonko/tablewriter v0.0.4
github.com/onsi/ginkgo v1.12.0 // indirect
@ -56,29 +54,28 @@ require (
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
github.com/pelletier/go-toml v1.4.0 // indirect
github.com/pquerna/otp v1.2.0
github.com/prometheus/client_golang v1.5.1
github.com/prometheus/client_golang v1.6.0
github.com/samedi/caldav-go v3.0.0+incompatible
github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749
github.com/shurcooL/vfsgen v0.0.0-20181202132449-6a9ea43bcacd
github.com/spf13/afero v1.2.2
github.com/spf13/cobra v0.0.7
github.com/spf13/cobra v1.0.0
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/viper v1.6.3
github.com/stretchr/testify v1.5.1
github.com/spf13/viper v1.7.0
github.com/stretchr/testify v1.6.0
github.com/swaggo/swag v1.6.3
github.com/ulule/limiter/v3 v3.5.0
github.com/urfave/cli v1.22.2 // indirect
golang.org/x/crypto v0.0.0-20200422194213-44a606286825
golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37
golang.org/x/lint v0.0.0-20200302205851-738671d3881b
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e // indirect
golang.org/x/sys v0.0.0-20200409092240-59c9f1ba88fa // indirect
golang.org/x/tools v0.0.0-20200410194907-79a7a3126eef // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
gopkg.in/d4l3k/messagediff.v1 v1.2.1
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a
src.techknowlogick.com/xgo v0.0.0-20200408234745-bb0faa361273
honnef.co/go/tools v0.0.1-2020.1.4
src.techknowlogick.com/xgo v0.0.0-20200514233805-209a5cf70012
src.techknowlogick.com/xormigrate v1.2.0
xorm.io/builder v0.3.7
xorm.io/core v0.7.3

216
go.sum
View File

@ -9,8 +9,19 @@ cloud.google.com/go v0.34.0 h1:eOI3/cP2VTU6uZLDYAoic+eyzzB9YyGmJ7eIjl8rOPg=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.37.4 h1:glPeL3BQJsbF6aIIYfZizMwc5LTYz250bDMjttbBGAU=
cloud.google.com/go v0.37.4/go.mod h1:NHPJ89PdicEuT9hdPXMROBD91xc5uRDxsMtSB16k7hw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
code.vikunja.io/web v0.0.0-20200208214421-c90649369427 h1:6ps5r0OxZNRdmCavh1k/xMwftN27hHauo+EtdTGxLug=
code.vikunja.io/web v0.0.0-20200208214421-c90649369427/go.mod h1:cuP1/ieGWAZzgQGw+QPt6Y5F0fVb/8Ol5NV4QSezGdo=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGqTOXqu2aRi/XEQxDCBwM8yJtE6s=
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU=
gitea.com/xorm/tests v0.7.0 h1:pFcaxTGGAWw3rDuVfhBdyr+mX1uzdTtncyAKxkCQ/IE=
@ -19,6 +30,7 @@ gitea.com/xorm/xorm-redis-cache v0.2.0 h1:qglRHt6/7vJmDeld6j+n10M9PmruAh+Le2lgNr
gitea.com/xorm/xorm-redis-cache v0.2.0/go.mod h1:juYdjkmIKvLbPkdfBVKGVJ2daFQIJAgKsn4mL4ZK8Zk=
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
@ -35,8 +47,13 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafo
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/alexbrainman/sspi v0.0.0-20180613141037-e580b900e9f5 h1:P5U+E4x5OkVEKQDklVPmzs71WM56RTTRqV4OrDC//Y4=
github.com/alexbrainman/sspi v0.0.0-20180613141037-e580b900e9f5/go.mod h1:976q2ETgjT2snVCf2ZaBnyBbVoPERGjUz+0sofzEfro=
github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a h1:idn718Q4B6AGu/h5Sxe66HYVdqdGu2l9Iebqhi/AEoA=
github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY=
github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs=
@ -47,6 +64,8 @@ github.com/beorn7/perks v1.0.0 h1:HWo1m869IqiPhD389kmkxeTalrjNbbJTC8LXupb+sl0=
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/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/c2h5oh/datasize v0.0.0-20200112174442-28bbd4740fee h1:BnPxIde0gjtTnc9Er7cxvBk8DHLWhEux0SxayC8dP6I=
@ -62,6 +81,7 @@ github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMe
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd/v22 v22.0.0/go.mod h1:xO0FLkIi5MaZafQlIrOotqXZ90ih+1atmu1JpKERPPk=
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
@ -86,6 +106,7 @@ github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8
github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
@ -103,6 +124,7 @@ github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm
github.com/gin-gonic/gin v1.3.0/go.mod h1:7cKuhb5qV2ggCFctp2fJQ+ErvciLZrIeoOSOm6mUr7Y=
github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM=
github.com/gin-gonic/gin v1.6.2/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
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-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
@ -133,13 +155,15 @@ github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+
github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
github.com/go-redis/redis/v7 v7.2.0 h1:CrCexy/jYWZjW0AyVoHlcJUeZN19VWlbepTh1Vq6dJs=
github.com/go-redis/redis/v7 v7.2.0/go.mod h1:JDNMw23GTyLNC4GZu9njt15ctBQVn7xjRfnwdHj/Dcg=
github.com/go-redis/redis/v7 v7.3.0 h1:3oHqd0W7f/VLKBxeYTEpqdMUsmMectngjM9OtoRoIgg=
github.com/go-redis/redis/v7 v7.3.0/go.mod h1:JDNMw23GTyLNC4GZu9njt15ctBQVn7xjRfnwdHj/Dcg=
github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
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-testfixtures/testfixtures/v3 v3.1.1 h1:SBIfzULODQQ7JV6AD931MvAz8pnkk6QCfMIcoOBDaXQ=
github.com/go-testfixtures/testfixtures/v3 v3.1.1/go.mod h1:RZctY24ixituGC73XlAV1gkCwYMVwiSwPm26MNlQIhE=
github.com/go-testfixtures/testfixtures/v3 v3.2.0 h1:FGAW3z5UzmrZGjR/dZp1u3Tbld0SDmirLO4RrR5++7Q=
github.com/go-testfixtures/testfixtures/v3 v3.2.0/go.mod h1:RZctY24ixituGC73XlAV1gkCwYMVwiSwPm26MNlQIhE=
github.com/godbus/dbus/v5 v5.0.3/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
@ -151,14 +175,19 @@ github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfU
github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.5 h1:F768QJ1E9tib+q5Sc8MkdJi1RxLTbRcTf8LJV56aRls=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0 h1:oOuy+ugB+P/kBdUnG5QaMXSIyJ1q38wWSojYCb3z5VQ=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db h1:woRePGFeVFfLKN/pOkfl+p/TAqKOfFu+7KPlMVpok/w=
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
@ -167,26 +196,53 @@ github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Z
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gordonklaus/ineffassign v0.0.0-20200309095847-7953dde2c7bf h1:vc7Dmrk4JwS0ZPS6WZvWlwDflgDTA26jItmbSj83nug=
github.com/gordonklaus/ineffassign v0.0.0-20200309095847-7953dde2c7bf/go.mod h1:cuNKsD1zp2v6XfE/orVX2QE1LC+i254ceGcVeDT3pTU=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.0/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q=
github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.2 h1:cfejS+Tpcp13yd5nYHWDI6qVCny6wyX2Mt5SGur2IGE=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
github.com/iancoleman/strcase v0.0.0-20191112232945-16388991a334 h1:VHgatEHNcBFEB7inlalqfNqw65aNkM1lGX2yt3NmbS8=
github.com/iancoleman/strcase v0.0.0-20191112232945-16388991a334/go.mod h1:SK73tn/9oHe+/Y0h39VT4UCxmurVJkR5NA7kMEAOgSE=
github.com/imdario/mergo v0.3.9 h1:UauaLniWCFHWd+Jp9oCEkTBj8VO/9DKg3PV3VCNMDIg=
@ -196,6 +252,18 @@ github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANyt
github.com/jackc/fake v0.0.0-20150926172116-812a484cc733 h1:vr3AYkKovP8uR8AvSGGUK1IDqRa5lAAvEkZG1LKaCRc=
github.com/jackc/fake v0.0.0-20150926172116-812a484cc733/go.mod h1:WrMFNQdiFJ80sQsxDoMokWK1W5TQtxBFNpzWTD84ibQ=
github.com/jackc/pgx v3.6.0+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.0.0 h1:J7uCkflzTEhUZ64xqKnkDxq3kzc96ajM1Gli5ktUem8=
github.com/jcmturner/gofork v1.0.0/go.mod h1:MK8+TM0La+2rjBD4jE12Kj1pCCxK7d2LK/UM3ncEo0o=
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.2.0 h1:lzPl/30ZLkTveYsYZPKMcgXc8MbnE6RsTd4F9KgiLtk=
github.com/jcmturner/gokrb5/v8 v8.2.0/go.mod h1:T1hnNppQsBtxW0tCHMHTkAt8n/sABdzZgZdoFrZaZNM=
github.com/jcmturner/rpc/v2 v2.0.2 h1:gMB4IwRXYsWw4Bc6o/az2HJgFUA1ffSh90i26ZJ6Xl0=
github.com/jcmturner/rpc/v2 v2.0.2/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jeffbean/tail v1.0.1 h1:mRuCwa9iq5kH1SBFAIWWFsg+NEi5+VVEf2E2ORVkKp8=
github.com/jeffbean/tail v1.0.1/go.mod h1:+MhJ+VPZMpv8Ui6WRzpJFuWFKxBCZgVOo5HAmlw1sFc=
github.com/jgautheron/goconst v0.0.0-20200227150835-cda7ea3bf591 h1:x/BpEhm6aL26o4TLtcU0loJ7B3+69jielrGc70V7Yb4=
@ -244,8 +312,10 @@ github.com/lib/pq v1.0.0 h1:X5PMW56eZitiTeO7tKzZxFCSpbFZJtkMMooicw2us9A=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.3.0 h1:/qkRGz8zljWiDcFvgpwUpwIAPu3r07TDvs3Rws+o/pU=
github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.4.0 h1:TmtCFbH+Aw0AixwyttznSMQDgbR5Yed/Gg6S8Funrhc=
github.com/lib/pq v1.4.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.5.2 h1:yTSXVswvWUOQ3k1sd7vJfDrbSl8lKuscqFJRqjC0ifw=
github.com/lib/pq v1.5.2/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.6.0 h1:I5DPxhYJChW9KYc66se+oKFFQX6VuQrKiprsX6ivRZc=
github.com/lib/pq v1.6.0/go.mod h1:4vXEAYvW1fRQ2/FhZ78H73A60MHw1geSm145z2mdY1g=
github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4=
@ -256,10 +326,12 @@ github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.0 h1:aizVhC/NAAcKWb+5QsU1iNOZb4Yws5UO2I+aIprQITM=
github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.6 h1:6Su7aK7lXmJ/U79bYtBjLNaha4Fs1Rg9plHpcH+vvnE=
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.7 h1:UvyT9uN+3r7yLEYSlJsbQGdsaB/a0DlgWP3pql6iwOc=
@ -279,15 +351,20 @@ github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJK
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
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/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
@ -310,6 +387,7 @@ github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoT
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88=
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw=
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pelletier/go-toml v1.4.0 h1:u3Z1r+oOXJIkxqw34zVhyPgjBsm6X2wn21NWs/HfSeg=
@ -323,6 +401,7 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/pquerna/otp v1.2.0 h1:/A3+Jn+cagqayeR3iHs/L62m5ue7710D35zl1zJ1kok=
github.com/pquerna/otp v1.2.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
@ -330,8 +409,8 @@ github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829 h1:D+Ci
github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs=
github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.5.1 h1:bdHYieyGlH+6OLEk2YQha8THib30KP0/yD0YH9m6xcA=
github.com/prometheus/client_golang v1.5.1/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=
github.com/prometheus/client_golang v1.6.0 h1:YVPodQOcK15POxhgARIvnDRVpLcuK8mglnMrWfyrw6A=
github.com/prometheus/client_golang v1.6.0/go.mod h1:ZLOG9ck3JLRdB5MgO8f+lLTe83AXG6ro35rLTxvnIl4=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910 h1:idejC8f05m9MGOsuEi1ATq9shN03HrxNkD/luQvxCv8=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f h1:BVwpUVJDADN2ufcGik7W992pyps0wZ888b/y9GXcLTU=
@ -354,13 +433,16 @@ github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R
github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.0.2 h1:6LJUbpNm42llc4HRCuvApCSWB/WfhuNo9K98Q9sNGfs=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.0.8 h1:+fpWZdT24pJBiqJdAwYBjPSk+5YmQzYNPYzQsdzLkt8=
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
github.com/prometheus/procfs v0.0.11 h1:DhHlBtkHWPYi8O2y31JkK0TF+DGM+51OopZjH/Ia5qI=
github.com/prometheus/procfs v0.0.11/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/shopspring/decimal v0.0.0-20191009025716-f1972eb1d1f5/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749 h1:bUGsEnyNbVPw06Bs80sCeARAlK8lhwqGyi6UT8ymuGk=
github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg=
@ -382,8 +464,8 @@ github.com/spf13/afero v1.2.2 h1:5jhuqJyZCZf2JRofRvN/nIFgIWNzPa3/Vz8mYylgbWc=
github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v0.0.7 h1:FfTH+vuMXOas8jmfb5/M7dzEYx7LpcLb7a0LPe34uOU=
github.com/spf13/cobra v0.0.7/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE=
github.com/spf13/cobra v1.0.0 h1:6m/oheQuQ13N9ks4hubMG6BnvwOeaJrqSPLahSnczz8=
github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE=
github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
@ -393,8 +475,8 @@ github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnIn
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE=
github.com/spf13/viper v1.6.3 h1:pDDu1OyEDTKzpJwdq4TiuLyMsUgRa/BT5cn5O62NoHs=
github.com/spf13/viper v1.6.3/go.mod h1:jUMtyi0/lB5yZH/FjyGAoH7IMNrIhlBf6pXZmbMDvzw=
github.com/spf13/viper v1.7.0 h1:xVKxvI7ouOI5I+U9s2eeiUfMaWBVoXA3AWskkrqK0VM=
github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg=
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.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
@ -406,6 +488,8 @@ github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJy
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.0 h1:jlIyCplCJFULU/01vCkhKuTyc3OorI3bJFuw6obfgho=
github.com/stretchr/testify v1.6.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
github.com/swaggo/files v0.0.0-20190704085106-630677cd5c14/go.mod h1:gxQT6pBGRuIGunNf/+tSOB5OHvguWi8Tbt82WOkf35E=
@ -444,35 +528,46 @@ github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wK
go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
go.etcd.io/bbolt v1.3.4/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/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=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190621222207-cc06ce4a13d4 h1:ydJNl0ENAG67pFbB+9tfhiL2pYqLhfoaZFw/cjLhY4A=
golang.org/x/crypto v0.0.0-20190621222207-cc06ce4a13d4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200117160349-530e935923ad/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200221231518-2aa609cf4a9d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200406173513-056763e48d71 h1:DOmugCavvUtnUD114C1Wh+UgTgQZ4pMLzXxi1pSt+/Y=
golang.org/x/crypto v0.0.0-20200406173513-056763e48d71/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200414173820-0848c9571904 h1:bXoxMPcSLOq08zI3/c5dEBT6lE4eh+jOh886GHrn6V8=
golang.org/x/crypto v0.0.0-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200420192832-a76a400e3025 h1:U821GPBRQKVHpr6Os5UDBgF2FveIjlZYbz+e2n5wmmQ=
golang.org/x/crypto v0.0.0-20200420192832-a76a400e3025/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200420201142-3c4aac89819a h1:y6sBfNd1b9Wy08a6K1Z1DZc4aXABUN5TKjkYhz7UKmo=
golang.org/x/crypto v0.0.0-20200420201142-3c4aac89819a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200422194213-44a606286825 h1:dSChiwOTvzwbHFTMq2l6uRardHH7/E6SqEkqccinS/o=
golang.org/x/crypto v0.0.0-20200422194213-44a606286825/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200311171314-f7b00557c8c4/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37 h1:cg5LA/zNPRzIXIWSCxQW10Rvpy94aQh3LT/ShoCpkHw=
golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b h1:Wh+f8QHJXR411sJR8/vRBTZ7YapZaRvUcLFFJhusH0k=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0 h1:KU7oHjnv3XNWfa5COkzUifxZmxp1TyI7ImMXqFxLwvQ=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
@ -481,13 +576,16 @@ golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73r
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181005035420-146acd28ed58 h1:otZG8yDCO4LVps5+9bxOeNiCvgmOyt96J3roHTYs7oE=
golang.org/x/net v0.0.0-20181005035420-146acd28ed58/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/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-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/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-20190125091013-d26f9f9a57f3/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=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c h1:uOCk1iQW6Vc18bnC13MfzScl+wdKBmM9Y9kU7Z83/lw=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
@ -499,12 +597,15 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwL
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b h1:0mm1VjtFUOIlE1SbDlwjYaDxZVDP2S5ou6y0gSgXHu8=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e h1:3G+cUijn7XD+S4eJFddp53Pv7+slrESplyjG25HgL+k=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 h1:SVwTIAaPC2U/AvvLNZ2a7OVsmBpC8L5BlwK1whH3hm0=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f h1:Bl/8QSvNqXvPGPGXa2z5xUTmV7VDcZyvRZ+QQXkXTZQ=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -512,22 +613,28 @@ golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
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-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/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-20181228144115-9a3f9b0469bb/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-20190222072716-a9d3bda3a223/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-20190602015325-4c4f7f33c9ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190609082536-301114b31cce/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190610200419-93c9922d18ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb h1:fgwFCsaw9buMuxNd6+DQfAuSFqbNiQZpcgJQAgJsK6k=
golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -535,14 +642,14 @@ golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20191010194322-b09406accb47/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191010194322-b09406accb47/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/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=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae h1:/WDfKMnPU+m5M4xB+6x4kaepxRw6jWvR5iDRdvjHgy8=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200409092240-59c9f1ba88fa h1:mQTN3ECqfsViCNBgq+A40vdwhkGykrrQlYe3mPj6BoU=
golang.org/x/sys v0.0.0-20200409092240-59c9f1ba88fa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f h1:gWF768j/LaZugp8dyS4UwsslYCYz9XgFxvlgsn0n9H8=
golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@ -556,15 +663,26 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606050223-4d9ae51c2468/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190608022120-eacb66d2a7c3/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190611222205-d73e1c7e250b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190614205625-5aca471b1d59/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628034336-212fb13d595e h1:ZlQjfVdpDxeqxRfmO30CdqWWzTvgRCj0MxaUVfxEG1k=
golang.org/x/tools v0.0.0-20190628034336-212fb13d595e/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7 h1:EBZoQjiKKPaLbPrbpssUfuHtwM6KV/vb4U85g/cigFY=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200410194907-79a7a3126eef h1:RHORRhs540cYZYrzgU2CPUyykkwZM78hGdzocOo9P8A=
@ -574,18 +692,41 @@ golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.3.0 h1:FBSsiFRMz3LBeXIomRnVzrQwSDj4ibvcRexLG0LZGQk=
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.0 h1:Tfd7cKwKbFRsI8RMAD3oqqw7JPFRrvFlOsfbgVkjOOw=
google.golang.org/appengine v1.6.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1 h1:QzqyMA1tlu6CgqCDUtU9V+ZKhLFT2dkJuANu5QaxI3I=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190404172233-64821d5d2107/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0 h1:qdOKuR/EIArgaWNjetjgTzgVTAZ+S/WXVrq9HW9zimw=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
@ -598,12 +739,18 @@ gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8X
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/d4l3k/messagediff.v1 v1.2.1 h1:70AthpjunwzUiarMHyED52mj9UwtAnE89l1Gmrt3EU0=
gopkg.in/d4l3k/messagediff.v1 v1.2.1/go.mod h1:EUzikiKadqXWcD1AzJLagx0j/BeeWGtn++04Xniyg44=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y=
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
gopkg.in/ini.v1 v1.51.0 h1:AQvPpx3LzTDM0AjnIRlVFwFFGC+npRopjZxLJj6gdno=
gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/jcmturner/aescts.v1 v1.0.1/go.mod h1:nsR8qBOg+OucoIW+WMhB3GspUQXq9XorLnQb9XtvcOo=
gopkg.in/jcmturner/dnsutils.v1 v1.0.1/go.mod h1:m3v+5svpVOhtFAP/wSz+yzh4Mc0Fg7eRhxkJMWSIz9Q=
gopkg.in/jcmturner/goidentity.v3 v3.0.0/go.mod h1:oG2kH0IvSYNIu80dVAyu/yoefjq1mNfM5bm88whjWx4=
gopkg.in/jcmturner/gokrb5.v7 v7.5.0/go.mod h1:l8VISx+WGYp+Fp7KRbsiUuXTTOnxIc3Tuvyavf11/WM=
gopkg.in/jcmturner/rpc.v1 v1.1.0/go.mod h1:YIdkC4XfD6GXbzje11McwsDuOlZQSb9W4vfLvuNnlv8=
gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
@ -618,13 +765,20 @@ gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo=
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a h1:LJwr7TCTghdatWv40WobzlKXc9c4s8oGa7QKJUtHhWA=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
src.techknowlogick.com/xgo v0.0.0-20200408234745-bb0faa361273 h1:dE6ry9rVwDn3soD4wPCXqEG60AZTuhniZzHdnj3c+74=
src.techknowlogick.com/xgo v0.0.0-20200408234745-bb0faa361273/go.mod h1:31CE1YKtDOrKTk9PSnjTpe6YbO6W/0LTYZ1VskL09oU=
honnef.co/go/tools v0.0.1-2019.2.3 h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.4 h1:UoveltGrhghAA7ePc+e+QYDHXrBps2PqFZiHkGR/xK8=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
src.techknowlogick.com/xgo v0.0.0-20200514233805-209a5cf70012 h1:k1/qGRpsaGka4IHT2UIUdPqM6+oo3O7+8S3ACN2kJZQ=
src.techknowlogick.com/xgo v0.0.0-20200514233805-209a5cf70012/go.mod h1:31CE1YKtDOrKTk9PSnjTpe6YbO6W/0LTYZ1VskL09oU=
src.techknowlogick.com/xormigrate v1.2.0 h1:bq9JaI48bxB+OddMghicjmV7sGmBUogJq4HmTN0DOcw=
src.techknowlogick.com/xormigrate v1.2.0/go.mod h1:7so27LAfBRqAxbma5jKYeL4ykVG1Jhsv9ncSq1KBCs4=
xorm.io/builder v0.3.7 h1:2pETdKRK+2QG4mLX4oODHEhn5Z8j1m8sXa7jfu+/SZI=

42
pkg/cmd/testmail.go Normal file
View File

@ -0,0 +1,42 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2020 Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package cmd
import (
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/mail"
"github.com/spf13/cobra"
)
func init() {
rootCmd.AddCommand(testmailCmd)
}
var testmailCmd = &cobra.Command{
Use: "testmail [email]",
Short: "Send a test mail using the configured smtp connection",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
log.Info("Sending testmail...")
email := args[0]
if err := mail.SendTestMail(email); err != nil {
log.Errorf("Error sending test mail: %s", err.Error())
return
}
log.Info("Testmail successfully sent.")
},
}

View File

@ -48,6 +48,7 @@ const (
ServiceEnableTaskAttachments Key = `service.enabletaskattachments`
ServiceTimeZone Key = `service.timezone`
ServiceEnableTaskComments Key = `service.enabletaskcomments`
ServiceEnableTotp Key = `service.enabletotp`
DatabaseType Key = `database.type`
DatabaseHost Key = `database.host`
@ -101,6 +102,10 @@ const (
MigrationWunderlistClientID Key = `migration.wunderlist.clientid`
MigrationWunderlistClientSecret Key = `migration.wunderlist.clientsecret`
MigrationWunderlistRedirectURL Key = `migration.wunderlist.redirecturl`
MigrationTodoistEnable Key = `migration.todoist.enable`
MigrationTodoistClientID Key = `migration.todoist.clientid`
MigrationTodoistClientSecret Key = `migration.todoist.clientsecret`
MigrationTodoistRedirectURL Key = `migration.todoist.redirecturl`
CorsEnable Key = `cors.enable`
CorsOrigins Key = `cors.origins`
@ -108,6 +113,11 @@ const (
AvatarProvider Key = `avatar.provider`
AvatarGravaterExpiration Key = `avatar.gravatarexpiration`
BackgroundsEnabled Key = `backgrounds.enabled`
BackgroundsUnsplashEnabled Key = `backgrounds.providers.unsplash.enabled`
BackgroundsUnsplashAccessToken Key = `backgrounds.providers.unsplash.accesstoken`
BackgroundsUnsplashApplicationID Key = `backgrounds.providers.unsplash.applicationid`
)
// GetString returns a string config value
@ -179,6 +189,7 @@ func InitDefaultConfig() {
ServiceEnableTaskAttachments.setDefault(true)
ServiceTimeZone.setDefault("GMT")
ServiceEnableTaskComments.setDefault(true)
ServiceEnableTotp.setDefault(true)
// Database
DatabaseType.setDefault("sqlite")
@ -235,9 +246,13 @@ func InitDefaultConfig() {
CorsMaxAge.setDefault(0)
// Migration
MigrationWunderlistEnable.setDefault(false)
MigrationTodoistEnable.setDefault(false)
// Avatar
AvatarProvider.setDefault("gravatar")
AvatarGravaterExpiration.setDefault(3600)
// List Backgrounds
BackgroundsEnabled.setDefault(false)
BackgroundsUnsplashEnabled.setDefault(false)
}
// InitConfig initializes the config, sets defaults etc.

View File

@ -101,3 +101,107 @@
created_by_id: 1
created: 1587244432
updated: 1587244432
- id: 18
title: testbucket18
list_id: 5
created_by_id: 1
created: 1587244432
updated: 1587244432
- id: 19
title: testbucket19
list_id: 21
created_by_id: 1
created: 1587244432
updated: 1587244432
- id: 20
title: testbucket20
list_id: 22
created_by_id: 1
created: 1587244432
updated: 1587244432
- id: 21
title: testbucket21
list_id: 3
created_by_id: 1
created: 1587244432
updated: 1587244432
# Duplicate buckets to make deletion of one of them possible
- id: 22
title: testbucket22
list_id: 6
created_by_id: 1
created: 1587244432
updated: 1587244432
- id: 23
title: testbucket23
list_id: 7
created_by_id: 1
created: 1587244432
updated: 1587244432
- id: 24
title: testbucket24
list_id: 8
created_by_id: 1
created: 1587244432
updated: 1587244432
- id: 25
title: testbucket25
list_id: 9
created_by_id: 1
created: 1587244432
updated: 1587244432
- id: 26
title: testbucket26
list_id: 10
created_by_id: 1
created: 1587244432
updated: 1587244432
- id: 27
title: testbucket27
list_id: 11
created_by_id: 1
created: 1587244432
updated: 1587244432
- id: 28
title: testbucket28
list_id: 12
created_by_id: 1
created: 1587244432
updated: 1587244432
- id: 29
title: testbucket29
list_id: 13
created_by_id: 1
created: 1587244432
updated: 1587244432
- id: 30
title: testbucket30
list_id: 14
created_by_id: 1
created: 1587244432
updated: 1587244432
- id: 31
title: testbucket31
list_id: 15
created_by_id: 1
created: 1587244432
updated: 1587244432
- id: 32
title: testbucket32
list_id: 16
created_by_id: 1
created: 1587244432
updated: 1587244432
- id: 33
title: testbucket33
list_id: 17
created_by_id: 1
created: 1587244432
updated: 1587244432
# This bucket is the last one in its list
- id: 34
title: testbucket34
list_id: 18
created_by_id: 1
created: 1587244432
updated: 1587244432

View File

@ -1,83 +1,83 @@
- id: 1
name: testnamespace
title: testnamespace
description: Lorem Ipsum
owner_id: 1
updated: 0
created: 0
- id: 2
name: testnamespace2
title: testnamespace2
description: Lorem Ipsum
owner_id: 2
updated: 0
created: 0
- id: 3
name: testnamespace3
title: testnamespace3
description: Lorem Ipsum
owner_id: 3
updated: 0
created: 0
- id: 6
name: testnamespace6
title: testnamespace6
description: Lorem Ipsum
owner_id: 6
updated: 0
created: 0
- id: 7
name: testnamespace7
title: testnamespace7
description: Lorem Ipsum
owner_id: 6
updated: 0
created: 0
- id: 8
name: testnamespace8
title: testnamespace8
description: Lorem Ipsum
owner_id: 6
updated: 0
created: 0
- id: 9
name: testnamespace9
title: testnamespace9
description: Lorem Ipsum
owner_id: 6
updated: 0
created: 0
- id: 10
name: testnamespace10
title: testnamespace10
description: Lorem Ipsum
owner_id: 6
updated: 0
created: 0
- id: 11
name: testnamespace11
title: testnamespace11
description: Lorem Ipsum
owner_id: 6
updated: 0
created: 0
- id: 12
name: testnamespace12
title: testnamespace12
description: Lorem Ipsum
owner_id: 6
updated: 0
created: 0
- id: 13
name: testnamespace13
title: testnamespace13
description: Lorem Ipsum
owner_id: 7
updated: 0
created: 0
- id: 14
name: testnamespace14
title: testnamespace14
description: Lorem Ipsum
owner_id: 7
updated: 0
created: 0
- id: 15
name: testnamespace15
title: testnamespace15
description: Lorem Ipsum
owner_id: 13
updated: 0
created: 0
- id: 16
name: Archived testnamespace16
title: Archived testnamespace16
owner_id: 1
is_archived: 1
updated: 0

View File

@ -1,5 +1,5 @@
- id: 1
text: 'task #1'
title: 'task #1'
description: 'Lorem Ipsum'
done: false
created_by_id: 1
@ -9,7 +9,7 @@
updated: 1543626724
bucket_id: 1
- id: 2
text: 'task #2 done'
title: 'task #2 done'
done: true
created_by_id: 1
list_id: 1
@ -18,7 +18,7 @@
updated: 1543626724
bucket_id: 1
- id: 3
text: 'task #3 high prio'
title: 'task #3 high prio'
done: false
created_by_id: 1
list_id: 1
@ -28,7 +28,7 @@
priority: 100
bucket_id: 2
- id: 4
text: 'task #4 low prio'
title: 'task #4 low prio'
done: false
created_by_id: 1
list_id: 1
@ -38,7 +38,7 @@
priority: 1
bucket_id: 2
- id: 5
text: 'task #5 higher due date'
title: 'task #5 higher due date'
done: false
created_by_id: 1
list_id: 1
@ -48,7 +48,7 @@
due_date_unix: 1543636724
bucket_id: 2
- id: 6
text: 'task #6 lower due date'
title: 'task #6 lower due date'
done: false
created_by_id: 1
list_id: 1
@ -58,7 +58,7 @@
due_date_unix: 1543616724
bucket_id: 3
- id: 7
text: 'task #7 with start date'
title: 'task #7 with start date'
done: false
created_by_id: 1
list_id: 1
@ -68,7 +68,7 @@
start_date_unix: 1544600000
bucket_id: 3
- id: 8
text: 'task #8 with end date'
title: 'task #8 with end date'
done: false
created_by_id: 1
list_id: 1
@ -78,7 +78,7 @@
end_date_unix: 1544700000
bucket_id: 3
- id: 9
text: 'task #9 with start and end date'
title: 'task #9 with start and end date'
done: false
created_by_id: 1
list_id: 1
@ -87,224 +87,252 @@
updated: 1543626724
start_date_unix: 1544600000
end_date_unix: 1544700000
bucket_id: 1
- id: 10
text: 'task #10 basic'
title: 'task #10 basic'
done: false
created_by_id: 1
list_id: 1
index: 10
bucket_id: 1
created: 1543626724
updated: 1543626724
- id: 11
text: 'task #11 basic'
title: 'task #11 basic'
done: false
created_by_id: 1
list_id: 1
index: 11
bucket_id: 1
created: 1543626724
updated: 1543626724
- id: 12
text: 'task #12 basic'
title: 'task #12 basic'
done: false
created_by_id: 1
list_id: 1
index: 12
bucket_id: 1
created: 1543626724
updated: 1543626724
- id: 13
text: 'task #13 basic other list'
title: 'task #13 basic other list'
done: false
created_by_id: 1
list_id: 2
index: 1
bucket_id: 4
created: 1543626724
updated: 1543626724
- id: 14
text: 'task #14 basic other list'
title: 'task #14 basic other list'
done: false
created_by_id: 5
list_id: 5
index: 1
bucket_id: 18
created: 1543626724
updated: 1543626724
- id: 15
text: 'task #15'
title: 'task #15'
done: false
created_by_id: 6
list_id: 6
index: 1
bucket_id: 6
created: 1543626724
updated: 1543626724
- id: 16
text: 'task #16'
title: 'task #16'
done: false
created_by_id: 6
list_id: 7
index: 1
bucket_id: 7
created: 1543626724
updated: 1543626724
- id: 17
text: 'task #17'
title: 'task #17'
done: false
created_by_id: 6
list_id: 8
index: 1
bucket_id: 8
created: 1543626724
updated: 1543626724
- id: 18
text: 'task #18'
title: 'task #18'
done: false
created_by_id: 6
list_id: 9
index: 1
bucket_id: 9
created: 1543626724
updated: 1543626724
- id: 19
text: 'task #19'
title: 'task #19'
done: false
created_by_id: 6
list_id: 10
index: 1
bucket_id: 10
created: 1543626724
updated: 1543626724
- id: 20
text: 'task #20'
title: 'task #20'
done: false
created_by_id: 6
list_id: 11
index: 1
bucket_id: 11
created: 1543626724
updated: 1543626724
- id: 21
text: 'task #21'
title: 'task #21'
done: false
created_by_id: 6
list_id: 12
index: 1
bucket_id: 12
created: 1543626724
updated: 1543626724
- id: 22
text: 'task #22'
title: 'task #22'
done: false
created_by_id: 6
list_id: 13
index: 1
bucket_id: 13
created: 1543626724
updated: 1543626724
- id: 23
text: 'task #23'
title: 'task #23'
done: false
created_by_id: 6
list_id: 14
index: 1
bucket_id: 14
created: 1543626724
updated: 1543626724
- id: 24
text: 'task #24'
title: 'task #24'
done: false
created_by_id: 6
list_id: 15
index: 1
bucket_id: 15
created: 1543626724
updated: 1543626724
- id: 25
text: 'task #25'
title: 'task #25'
done: false
created_by_id: 6
list_id: 16
index: 1
bucket_id: 16
created: 1543626724
updated: 1543626724
- id: 26
text: 'task #26'
title: 'task #26'
done: false
created_by_id: 6
list_id: 17
index: 1
bucket_id: 17
created: 1543626724
updated: 1543626724
- id: 27
text: 'task #27 with reminders'
title: 'task #27 with reminders'
done: false
created_by_id: 1
list_id: 1
index: 12
bucket_id: 1
created: 1543626724
updated: 1543626724
- id: 28
text: 'task #28 with repeat after'
title: 'task #28 with repeat after'
done: false
created_by_id: 1
repeat_after: 3600
list_id: 1
index: 13
bucket_id: 1
created: 1543626724
updated: 1543626724
- id: 29
text: 'task #29 with parent task (1)'
title: 'task #29 with parent task (1)'
done: false
created_by_id: 1
list_id: 1
index: 14
bucket_id: 1
created: 1543626724
updated: 1543626724
- id: 30
text: 'task #30 with assignees'
title: 'task #30 with assignees'
done: false
created_by_id: 1
list_id: 1
index: 15
bucket_id: 1
created: 1543626724
updated: 1543626724
- id: 31
text: 'task #31 with color'
title: 'task #31 with color'
done: false
created_by_id: 1
list_id: 1
index: 16
hex_color: f0f0f0
bucket_id: 1
created: 1543626724
updated: 1543626724
- id: 32
text: 'task #32'
title: 'task #32'
done: false
created_by_id: 1
list_id: 3
index: 1
bucket_id: 21
created: 1543626724
updated: 1543626724
- id: 33
text: 'task #33 with percent done'
title: 'task #33 with percent done'
done: false
created_by_id: 1
list_id: 1
index: 17
percent_done: 0.5
bucket_id: 1
created: 1543626724
updated: 1543626724
# This task is forbidden for user1
- id: 34
text: 'task #34'
title: 'task #34'
done: false
created_by_id: 13
list_id: 20
index: 20
bucket_id: 5
created: 1543626724
updated: 1543626724
- id: 35
text: 'task #35'
title: 'task #35'
done: false
created_by_id: 1
list_id: 21
index: 1
bucket_id: 19
created: 1543626724
updated: 1543626724
- id: 36
text: 'task #36'
title: 'task #36'
done: false
created_by_id: 1
list_id: 22
index: 1
bucket_id: 20
created: 1543626724
updated: 1543626724

View File

@ -50,3 +50,20 @@ func IsErrFileIsTooLarge(err error) bool {
_, ok := err.(ErrFileIsTooLarge)
return ok
}
// ErrFileIsNotUnsplashFile defines an error where a file is not downloaded from unsplash.
// Used in cases whenever unsplash information about a file is requested, but the file was not downloaded from unsplash.
type ErrFileIsNotUnsplashFile struct {
FileID int64
}
// Error is the error implementation of ErrFileIsNotUnsplashFile
func (err ErrFileIsNotUnsplashFile) Error() string {
return fmt.Sprintf("file was not downloaded from unsplash [FileID: %d]", err.FileID)
}
//IsErrFileIsNotUnsplashFile checks if an error is ErrFileIsNotUnsplashFile
func IsErrFileIsNotUnsplashFile(err error) bool {
_, ok := err.(ErrFileIsNotUnsplashFile)
return ok
}

View File

@ -107,12 +107,12 @@ func TestArchived(t *testing.T) {
t.Run("namespace", func(t *testing.T) {
t.Run("not editable", func(t *testing.T) {
_, err := testNamespaceHandler.testUpdateWithUser(nil, map[string]string{"namespace": "16"}, `{"name":"TestIpsum","is_archived":true}`)
_, err := testNamespaceHandler.testUpdateWithUser(nil, map[string]string{"namespace": "16"}, `{"title":"TestIpsum","is_archived":true}`)
assert.Error(t, err)
assertHandlerErrorCode(t, err, models.ErrCodeNamespaceIsArchived)
})
t.Run("unarchivable", func(t *testing.T) {
rec, err := testNamespaceHandler.testUpdateWithUser(nil, map[string]string{"namespace": "16"}, `{"name":"TestIpsum","is_archived":false}`)
rec, err := testNamespaceHandler.testUpdateWithUser(nil, map[string]string{"namespace": "16"}, `{"title":"TestIpsum","is_archived":false}`)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"is_archived":false`)
})
@ -124,12 +124,12 @@ func TestArchived(t *testing.T) {
t.Run("should not appear in the list", func(t *testing.T) {
rec, err := testNamespaceHandler.testReadAllWithUser(nil, nil)
assert.NoError(t, err)
assert.NotContains(t, rec.Body.String(), `"name":"Archived testnamespace16"`)
assert.NotContains(t, rec.Body.String(), `"title":"Archived testnamespace16"`)
})
t.Run("should appear in the list if explicitly requested", func(t *testing.T) {
rec, err := testNamespaceHandler.testReadAllWithUser(url.Values{"is_archived": []string{"true"}}, nil)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"name":"Archived testnamespace16"`)
assert.Contains(t, rec.Body.String(), `"title":"Archived testnamespace16"`)
})
})
@ -138,7 +138,7 @@ func TestArchived(t *testing.T) {
taskTests := func(taskID string, errCode int, t *testing.T) {
t.Run("task", func(t *testing.T) {
t.Run("edit task", func(t *testing.T) {
_, err := testTaskHandler.testUpdateWithUser(nil, map[string]string{"listtask": taskID}, `{"text":"TestIpsum"}`)
_, err := testTaskHandler.testUpdateWithUser(nil, map[string]string{"listtask": taskID}, `{"title":"TestIpsum"}`)
assert.Error(t, err)
assertHandlerErrorCode(t, err, errCode)
})
@ -202,7 +202,7 @@ func TestArchived(t *testing.T) {
assertHandlerErrorCode(t, err, models.ErrCodeNamespaceIsArchived)
})
t.Run("no new tasks", func(t *testing.T) {
_, err := testTaskHandler.testCreateWithUser(nil, map[string]string{"list": "21"}, `{"text":"Lorem"}`)
_, err := testTaskHandler.testCreateWithUser(nil, map[string]string{"list": "21"}, `{"title":"Lorem"}`)
assert.Error(t, err)
assertHandlerErrorCode(t, err, models.ErrCodeNamespaceIsArchived)
})
@ -222,7 +222,7 @@ func TestArchived(t *testing.T) {
assertHandlerErrorCode(t, err, models.ErrCodeListIsArchived)
})
t.Run("no new tasks", func(t *testing.T) {
_, err := testTaskHandler.testCreateWithUser(nil, map[string]string{"list": "22"}, `{"text":"Lorem"}`)
_, err := testTaskHandler.testCreateWithUser(nil, map[string]string{"list": "22"}, `{"title":"Lorem"}`)
assert.Error(t, err)
assertHandlerErrorCode(t, err, models.ErrCodeListIsArchived)
})

View File

@ -133,7 +133,7 @@ func TestBucket(t *testing.T) {
})
t.Run("Delete", func(t *testing.T) {
t.Run("Normal", func(t *testing.T) {
rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"bucket": "1"})
rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"list": "1", "bucket": "1"})
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"message":"Successfully deleted."`)
})
@ -145,70 +145,70 @@ func TestBucket(t *testing.T) {
t.Run("Rights check", func(t *testing.T) {
t.Run("Forbidden", func(t *testing.T) {
// Owned by user13
_, err := testHandler.testDeleteWithUser(nil, map[string]string{"bucket": "5"})
_, err := testHandler.testDeleteWithUser(nil, map[string]string{"list": "20", "bucket": "5"})
assert.Error(t, err)
assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
})
t.Run("Shared Via Team readonly", func(t *testing.T) {
_, err := testHandler.testDeleteWithUser(nil, map[string]string{"bucket": "6"})
_, err := testHandler.testDeleteWithUser(nil, map[string]string{"list": "6", "bucket": "6"})
assert.Error(t, err)
assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
})
t.Run("Shared Via Team write", func(t *testing.T) {
rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"bucket": "7"})
rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"list": "7", "bucket": "7"})
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"message":"Successfully deleted."`)
})
t.Run("Shared Via Team admin", func(t *testing.T) {
rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"bucket": "8"})
rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"list": "8", "bucket": "8"})
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"message":"Successfully deleted."`)
})
t.Run("Shared Via User readonly", func(t *testing.T) {
_, err := testHandler.testDeleteWithUser(nil, map[string]string{"bucket": "9"})
_, err := testHandler.testDeleteWithUser(nil, map[string]string{"list": "9", "bucket": "9"})
assert.Error(t, err)
assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
})
t.Run("Shared Via User write", func(t *testing.T) {
rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"bucket": "10"})
rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"list": "10", "bucket": "10"})
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"message":"Successfully deleted."`)
})
t.Run("Shared Via User admin", func(t *testing.T) {
rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"bucket": "11"})
rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"list": "11", "bucket": "11"})
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"message":"Successfully deleted."`)
})
t.Run("Shared Via NamespaceTeam readonly", func(t *testing.T) {
_, err := testHandler.testDeleteWithUser(nil, map[string]string{"bucket": "12"})
_, err := testHandler.testDeleteWithUser(nil, map[string]string{"list": "12", "bucket": "12"})
assert.Error(t, err)
assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
})
t.Run("Shared Via NamespaceTeam write", func(t *testing.T) {
rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"bucket": "13"})
rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"list": "13", "bucket": "13"})
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"message":"Successfully deleted."`)
})
t.Run("Shared Via NamespaceTeam admin", func(t *testing.T) {
rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"bucket": "14"})
rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"list": "14", "bucket": "14"})
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"message":"Successfully deleted."`)
})
t.Run("Shared Via NamespaceUser readonly", func(t *testing.T) {
_, err := testHandler.testDeleteWithUser(nil, map[string]string{"bucket": "15"})
_, err := testHandler.testDeleteWithUser(nil, map[string]string{"list": "15", "bucket": "15"})
assert.Error(t, err)
assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
})
t.Run("Shared Via NamespaceUser write", func(t *testing.T) {
rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"bucket": "16"})
rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"list": "16", "bucket": "16"})
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"message":"Successfully deleted."`)
})
t.Run("Shared Via NamespaceUser admin", func(t *testing.T) {
rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"bucket": "17"})
rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"list": "17", "bucket": "17"})
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"message":"Successfully deleted."`)
})

View File

@ -54,6 +54,84 @@ func TestLinkSharing(t *testing.T) {
SharedByID: 1,
}
t.Run("New Link Share", func(t *testing.T) {
testHandler := webHandlerTest{
user: &testuser1,
strFunc: func() handler.CObject {
return &models.LinkSharing{}
},
t: t,
}
t.Run("Forbidden", func(t *testing.T) {
t.Run("read only", func(t *testing.T) {
_, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "20"}, `{"right":0}`)
assert.Error(t, err)
assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
})
t.Run("write", func(t *testing.T) {
_, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "20"}, `{"right":1}`)
assert.Error(t, err)
assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
})
t.Run("admin", func(t *testing.T) {
_, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "20"}, `{"right":2}`)
assert.Error(t, err)
assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
})
})
t.Run("Read only access", func(t *testing.T) {
t.Run("read only", func(t *testing.T) {
_, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "9"}, `{"right":0}`)
assert.Error(t, err)
assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
})
t.Run("write", func(t *testing.T) {
_, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "9"}, `{"right":1}`)
assert.Error(t, err)
assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
})
t.Run("admin", func(t *testing.T) {
_, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "9"}, `{"right":2}`)
assert.Error(t, err)
assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
})
})
t.Run("Write access", func(t *testing.T) {
t.Run("read only", func(t *testing.T) {
req, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "10"}, `{"right":0}`)
assert.NoError(t, err)
assert.Contains(t, req.Body.String(), `"hash":`)
})
t.Run("write", func(t *testing.T) {
req, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "10"}, `{"right":1}`)
assert.NoError(t, err)
assert.Contains(t, req.Body.String(), `"hash":`)
})
t.Run("admin", func(t *testing.T) {
_, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "10"}, `{"right":2}`)
assert.Error(t, err)
assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
})
})
t.Run("Admin access", func(t *testing.T) {
t.Run("read only", func(t *testing.T) {
req, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "11"}, `{"right":0}`)
assert.NoError(t, err)
assert.Contains(t, req.Body.String(), `"hash":`)
})
t.Run("write", func(t *testing.T) {
req, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "11"}, `{"right":1}`)
assert.NoError(t, err)
assert.Contains(t, req.Body.String(), `"hash":`)
})
t.Run("admin", func(t *testing.T) {
req, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "11"}, `{"right":2}`)
assert.NoError(t, err)
assert.Contains(t, req.Body.String(), `"hash":`)
})
})
})
t.Run("Lists", func(t *testing.T) {
testHandlerListReadOnly := webHandlerTest{
linkShare: linkshareRead,
@ -507,36 +585,36 @@ func TestLinkSharing(t *testing.T) {
})
t.Run("Create", func(t *testing.T) {
t.Run("Shared readonly", func(t *testing.T) {
_, err := testHandlerTaskReadOnly.testCreateWithLinkShare(nil, map[string]string{"list": "1"}, `{"text":"Lorem Ipsum"}`)
_, err := testHandlerTaskReadOnly.testCreateWithLinkShare(nil, map[string]string{"list": "1"}, `{"title":"Lorem Ipsum"}`)
assert.Error(t, err)
assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
})
t.Run("Shared write", func(t *testing.T) {
rec, err := testHandlerTaskWrite.testCreateWithLinkShare(nil, map[string]string{"list": "2"}, `{"text":"Lorem Ipsum"}`)
rec, err := testHandlerTaskWrite.testCreateWithLinkShare(nil, map[string]string{"list": "2"}, `{"title":"Lorem Ipsum"}`)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"text":"Lorem Ipsum"`)
assert.Contains(t, rec.Body.String(), `"title":"Lorem Ipsum"`)
})
t.Run("Shared admin", func(t *testing.T) {
rec, err := testHandlerTaskAdmin.testCreateWithLinkShare(nil, map[string]string{"list": "3"}, `{"text":"Lorem Ipsum"}`)
rec, err := testHandlerTaskAdmin.testCreateWithLinkShare(nil, map[string]string{"list": "3"}, `{"title":"Lorem Ipsum"}`)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"text":"Lorem Ipsum"`)
assert.Contains(t, rec.Body.String(), `"title":"Lorem Ipsum"`)
})
})
t.Run("Update", func(t *testing.T) {
t.Run("Shared readonly", func(t *testing.T) {
_, err := testHandlerTaskReadOnly.testUpdateWithLinkShare(nil, map[string]string{"listtask": "1"}, `{"text":"Lorem Ipsum"}`)
_, err := testHandlerTaskReadOnly.testUpdateWithLinkShare(nil, map[string]string{"listtask": "1"}, `{"title":"Lorem Ipsum"}`)
assert.Error(t, err)
assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
})
t.Run("Shared write", func(t *testing.T) {
rec, err := testHandlerTaskWrite.testUpdateWithLinkShare(nil, map[string]string{"listtask": "13"}, `{"text":"Lorem Ipsum"}`)
rec, err := testHandlerTaskWrite.testUpdateWithLinkShare(nil, map[string]string{"listtask": "13"}, `{"title":"Lorem Ipsum"}`)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"text":"Lorem Ipsum"`)
assert.Contains(t, rec.Body.String(), `"title":"Lorem Ipsum"`)
})
t.Run("Shared admin", func(t *testing.T) {
rec, err := testHandlerTaskAdmin.testUpdateWithLinkShare(nil, map[string]string{"listtask": "32"}, `{"text":"Lorem Ipsum"}`)
rec, err := testHandlerTaskAdmin.testUpdateWithLinkShare(nil, map[string]string{"listtask": "32"}, `{"title":"Lorem Ipsum"}`)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"text":"Lorem Ipsum"`)
assert.Contains(t, rec.Body.String(), `"title":"Lorem Ipsum"`)
})
})
@ -769,34 +847,34 @@ func TestLinkSharing(t *testing.T) {
})
t.Run("Create", func(t *testing.T) {
t.Run("Shared readonly", func(t *testing.T) {
_, err := testHandlerNamespaceReadOnly.testCreateWithLinkShare(nil, nil, `{"name":"LoremIpsum"}`)
_, err := testHandlerNamespaceReadOnly.testCreateWithLinkShare(nil, nil, `{"title":"LoremIpsum"}`)
assert.Error(t, err)
assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
})
t.Run("Shared write", func(t *testing.T) {
_, err := testHandlerNamespaceWrite.testCreateWithLinkShare(nil, nil, `{"name":"LoremIpsum"}`)
_, err := testHandlerNamespaceWrite.testCreateWithLinkShare(nil, nil, `{"title":"LoremIpsum"}`)
assert.Error(t, err)
assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
})
t.Run("Shared admin", func(t *testing.T) {
_, err := testHandlerNamespaceAdmin.testCreateWithLinkShare(nil, nil, `{"name":"LoremIpsum"}`)
_, err := testHandlerNamespaceAdmin.testCreateWithLinkShare(nil, nil, `{"title":"LoremIpsum"}`)
assert.Error(t, err)
assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
})
})
t.Run("Update", func(t *testing.T) {
t.Run("Shared readonly", func(t *testing.T) {
_, err := testHandlerNamespaceReadOnly.testUpdateWithLinkShare(nil, map[string]string{"namespace": "1"}, `{"name":"LoremIpsum"}`)
_, err := testHandlerNamespaceReadOnly.testUpdateWithLinkShare(nil, map[string]string{"namespace": "1"}, `{"title":"LoremIpsum"}`)
assert.Error(t, err)
assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
})
t.Run("Shared write", func(t *testing.T) {
_, err := testHandlerNamespaceWrite.testUpdateWithLinkShare(nil, map[string]string{"namespace": "2"}, `{"name":"LoremIpsum"}`)
_, err := testHandlerNamespaceWrite.testUpdateWithLinkShare(nil, map[string]string{"namespace": "2"}, `{"title":"LoremIpsum"}`)
assert.Error(t, err)
assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
})
t.Run("Shared admin", func(t *testing.T) {
_, err := testHandlerNamespaceAdmin.testUpdateWithLinkShare(nil, map[string]string{"namespace": "3"}, `{"name":"LoremIpsum"}`)
_, err := testHandlerNamespaceAdmin.testUpdateWithLinkShare(nil, map[string]string{"namespace": "3"}, `{"title":"LoremIpsum"}`)
assert.Error(t, err)
assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
})

View File

@ -89,39 +89,73 @@ func TestTaskCollection(t *testing.T) {
assert.NotContains(t, rec.Body.String(), `task #13`)
assert.NotContains(t, rec.Body.String(), `task #14`)
})
t.Run("Search case insensitive", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(url.Values{"s": []string{"tASk #6"}}, urlParams)
assert.NoError(t, err)
assert.NotContains(t, rec.Body.String(), `task #1`)
assert.NotContains(t, rec.Body.String(), `task #2`)
assert.NotContains(t, rec.Body.String(), `task #3`)
assert.NotContains(t, rec.Body.String(), `task #4`)
assert.NotContains(t, rec.Body.String(), `task #5`)
assert.Contains(t, rec.Body.String(), `task #6`)
assert.NotContains(t, rec.Body.String(), `task #7`)
assert.NotContains(t, rec.Body.String(), `task #8`)
assert.NotContains(t, rec.Body.String(), `task #9`)
assert.NotContains(t, rec.Body.String(), `task #10`)
assert.NotContains(t, rec.Body.String(), `task #11`)
assert.NotContains(t, rec.Body.String(), `task #12`)
assert.NotContains(t, rec.Body.String(), `task #13`)
assert.NotContains(t, rec.Body.String(), `task #14`)
})
t.Run("Sort Order", func(t *testing.T) {
// TODO: Add more cases
// should equal priority asc
t.Run("by priority", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"priority"}}, urlParams)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `{"id":33,"text":"task #33 with percent done","description":"","done":false,"done_at":null,"due_date":null,"reminder_dates":null,"list_id":1,"repeat_after":0,"priority":0,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"created_by":{"id":1,"username":"user1","created":null,"updated":null}},{"id":4,"text":"task #4 low prio","description":"","done":false,"done_at":null,"due_date":null,"reminder_dates":null,"list_id":1,"repeat_after":0,"priority":1,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-4","index":4,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"created_by":{"id":1,"username":"user1","created":null,"updated":null}},{"id":3,"text":"task #3 high prio","description":"","done":false,"done_at":null,"due_date":null,"reminder_dates":null,"list_id":1,"repeat_after":0,"priority":100,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-3","index":3,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"created_by":{"id":1,"username":"user1","created":null,"updated":null}}]`)
assert.Contains(t, rec.Body.String(), `{"id":33,"title":"task #33 with percent done","description":"","done":false,"done_at":null,"due_date":null,"reminder_dates":null,"list_id":1,"repeat_after":0,"priority":0,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":0,"created_by":{"id":1,"username":"user1","created":null,"updated":null}},{"id":4,"title":"task #4 low prio","description":"","done":false,"done_at":null,"due_date":null,"reminder_dates":null,"list_id":1,"repeat_after":0,"priority":1,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-4","index":4,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":null,"updated":null}},{"id":3,"title":"task #3 high prio","description":"","done":false,"done_at":null,"due_date":null,"reminder_dates":null,"list_id":1,"repeat_after":0,"priority":100,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-3","index":3,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":null,"updated":null}}]`)
})
t.Run("by priority desc", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"priority"}, "order_by": []string{"desc"}}, urlParams)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `[{"id":3,"text":"task #3 high prio","description":"","done":false,"done_at":null,"due_date":null,"reminder_dates":null,"list_id":1,"repeat_after":0,"priority":100,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-3","index":3,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"created_by":{"id":1,"username":"user1","created":null,"updated":null}},{"id":4,"text":"task #4 low prio","description":"","done":false,"done_at":null,"due_date":null,"reminder_dates":null,"list_id":1,"repeat_after":0,"priority":1`)
assert.Contains(t, rec.Body.String(), `[{"id":3,"title":"task #3 high prio","description":"","done":false,"done_at":null,"due_date":null,"reminder_dates":null,"list_id":1,"repeat_after":0,"priority":100,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-3","index":3,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":null,"updated":null}},{"id":4,"title":"task #4 low prio","description":"","done":false,"done_at":null,"due_date":null,"reminder_dates":null,"list_id":1,"repeat_after":0,"priority":1`)
})
t.Run("by priority asc", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"priority"}, "order_by": []string{"asc"}}, urlParams)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `{"id":33,"text":"task #33 with percent done","description":"","done":false,"done_at":null,"due_date":null,"reminder_dates":null,"list_id":1,"repeat_after":0,"priority":0,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"created_by":{"id":1,"username":"user1","created":null,"updated":null}},{"id":4,"text":"task #4 low prio","description":"","done":false,"done_at":null,"due_date":null,"reminder_dates":null,"list_id":1,"repeat_after":0,"priority":1,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-4","index":4,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"created_by":{"id":1,"username":"user1","created":null,"updated":null}},{"id":3,"text":"task #3 high prio","description":"","done":false,"done_at":null,"due_date":null,"reminder_dates":null,"list_id":1,"repeat_after":0,"priority":100,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-3","index":3,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"created_by":{"id":1,"username":"user1","created":null,"updated":null}}]`)
assert.Contains(t, rec.Body.String(), `{"id":33,"title":"task #33 with percent done","description":"","done":false,"done_at":null,"due_date":null,"reminder_dates":null,"list_id":1,"repeat_after":0,"priority":0,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":0,"created_by":{"id":1,"username":"user1","created":null,"updated":null}},{"id":4,"title":"task #4 low prio","description":"","done":false,"done_at":null,"due_date":null,"reminder_dates":null,"list_id":1,"repeat_after":0,"priority":1,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-4","index":4,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":null,"updated":null}},{"id":3,"title":"task #3 high prio","description":"","done":false,"done_at":null,"due_date":null,"reminder_dates":null,"list_id":1,"repeat_after":0,"priority":100,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-3","index":3,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":null,"updated":null}}]`)
})
// should equal duedate asc
t.Run("by due_date", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date_unix"}}, urlParams)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `{"id":6,"text":"task #6 lower due date","description":"","done":false,"done_at":null,"due_date":"2018-11-30T22:25:24Z","reminder_dates":null,"list_id":1,"repeat_after":0,"priority":0,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"created_by":{"id":1,"username":"user1","created":null,"updated":null}},{"id":5,"text":"task #5 higher due date","description":"","done":false,"done_at":null,"due_date":"2018-12-01T03:58:44Z","reminder_dates":null,"list_id":1,"repeat_after":0,"priority":0,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"created_by":{"id":1,"username":"user1","created":null,"updated":null}}]`)
assert.Contains(t, rec.Body.String(), `{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":null,"due_date":"2018-11-30T22:25:24Z","reminder_dates":null,"list_id":1,"repeat_after":0,"priority":0,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"position":0,"created_by":{"id":1,"username":"user1","created":null,"updated":null}},{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":null,"due_date":"2018-12-01T03:58:44Z","reminder_dates":null,"list_id":1,"repeat_after":0,"priority":0,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":null,"updated":null}}]`)
})
t.Run("by duedate desc", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date_unix"}, "order_by": []string{"desc"}}, urlParams)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `[{"id":5,"text":"task #5 higher due date","description":"","done":false,"done_at":null,"due_date":"2018-12-01T03:58:44Z","reminder_dates":null,"list_id":1,"repeat_after":0,"priority":0,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"created_by":{"id":1,"username":"user1","created":null,"updated":null}},{"id":6,"text":"task #6 lower due date`)
assert.Contains(t, rec.Body.String(), `[{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":null,"due_date":"2018-12-01T03:58:44Z","reminder_dates":null,"list_id":1,"repeat_after":0,"priority":0,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":null,"updated":null}},{"id":6,"title":"task #6 lower due date`)
})
// Due date without unix suffix
t.Run("by duedate asc without _unix suffix", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date"}, "order_by": []string{"asc"}}, urlParams)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":null,"due_date":"2018-11-30T22:25:24Z","reminder_dates":null,"list_id":1,"repeat_after":0,"priority":0,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"position":0,"created_by":{"id":1,"username":"user1","created":null,"updated":null}},{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":null,"due_date":"2018-12-01T03:58:44Z","reminder_dates":null,"list_id":1,"repeat_after":0,"priority":0,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":null,"updated":null}}]`)
})
t.Run("by due_date without _unix suffix", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date"}}, urlParams)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":null,"due_date":"2018-11-30T22:25:24Z","reminder_dates":null,"list_id":1,"repeat_after":0,"priority":0,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"position":0,"created_by":{"id":1,"username":"user1","created":null,"updated":null}},{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":null,"due_date":"2018-12-01T03:58:44Z","reminder_dates":null,"list_id":1,"repeat_after":0,"priority":0,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":null,"updated":null}}]`)
})
t.Run("by duedate desc without _unix suffix", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date"}, "order_by": []string{"desc"}}, urlParams)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `[{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":null,"due_date":"2018-12-01T03:58:44Z","reminder_dates":null,"list_id":1,"repeat_after":0,"priority":0,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":null,"updated":null}},{"id":6,"title":"task #6 lower due date`)
})
t.Run("by duedate asc", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date_unix"}, "order_by": []string{"asc"}}, urlParams)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `{"id":6,"text":"task #6 lower due date","description":"","done":false,"done_at":null,"due_date":"2018-11-30T22:25:24Z","reminder_dates":null,"list_id":1,"repeat_after":0,"priority":0,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"created_by":{"id":1,"username":"user1","created":null,"updated":null}},{"id":5,"text":"task #5 higher due date","description":"","done":false,"done_at":null,"due_date":"2018-12-01T03:58:44Z","reminder_dates":null,"list_id":1,"repeat_after":0,"priority":0,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"created_by":{"id":1,"username":"user1","created":null,"updated":null}}]`)
assert.Contains(t, rec.Body.String(), `{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":null,"due_date":"2018-11-30T22:25:24Z","reminder_dates":null,"list_id":1,"repeat_after":0,"priority":0,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"position":0,"created_by":{"id":1,"username":"user1","created":null,"updated":null}},{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":null,"due_date":"2018-12-01T03:58:44Z","reminder_dates":null,"list_id":1,"repeat_after":0,"priority":0,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":null,"updated":null}}]`)
})
t.Run("invalid sort parameter", func(t *testing.T) {
_, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"loremipsum"}}, urlParams)
@ -137,10 +171,10 @@ func TestTaskCollection(t *testing.T) {
// Invalid parameter should not sort at all
rec, err := testHandler.testReadAllWithUser(url.Values{"sort": []string{"loremipsum"}}, urlParams)
assert.NoError(t, err)
assert.NotContains(t, rec.Body.String(), `[{"id":3,"text":"task #3 high prio","description":"","done":false,"due_date":0,"reminder_dates":null,"repeat_after":0,"priority":100,"start_date":0,"end_date":0,"assignees":null,"labels":null,"hex_color":"","created":1543626724,"updated":1543626724,"created_by":{"id":0,"username":"","email":"","created":0,"updated":0}},{"id":4,"text":"task #4 low prio","description":"","done":false,"due_date":0,"reminder_dates":null,"repeat_after":0,"priority":1`)
assert.NotContains(t, rec.Body.String(), `{"id":4,"text":"task #4 low prio","description":"","done":false,"due_date":0,"reminder_dates":null,"repeat_after":0,"priority":1,"start_date":0,"end_date":0,"assignees":null,"labels":null,"hex_color":"","created":1543626724,"updated":1543626724,"created_by":{"id":0,"username":"","email":"","created":0,"updated":0}},{"id":3,"text":"task #3 high prio","description":"","done":false,"due_date":0,"reminder_dates":null,"repeat_after":0,"priority":100,"start_date":0,"end_date":0,"assignees":null,"labels":null,"created":1543626724,"updated":1543626724,"created_by":{"id":0,"username":"","email":"","created":0,"updated":0}}]`)
assert.NotContains(t, rec.Body.String(), `[{"id":5,"text":"task #5 higher due date","description":"","done":false,"due_date":1543636724,"reminder_dates":null,"repeat_after":0,"priority":0,"start_date":0,"end_date":0,"assignees":null,"labels":null,"hex_color":"","created":1543626724,"updated":1543626724,"created_by":{"id":0,"username":"","email":"","created":0,"updated":0}},{"id":6,"text":"task #6 lower due date"`)
assert.NotContains(t, rec.Body.String(), `{"id":6,"text":"task #6 lower due date","description":"","done":false,"due_date":1543616724,"reminder_dates":null,"repeat_after":0,"priority":0,"start_date":0,"end_date":0,"assignees":null,"labels":null,"hex_color":"","created":1543626724,"updated":1543626724,"created_by":{"id":0,"username":"","email":"","created":0,"updated":0}},{"id":5,"text":"task #5 higher due date","description":"","done":false,"due_date":1543636724,"reminder_dates":null,"repeat_after":0,"priority":0,"start_date":0,"end_date":0,"assignees":null,"labels":null,"created":1543626724,"updated":1543626724,"created_by":{"id":0,"username":"","email":"","created":0,"updated":0}}]`)
assert.NotContains(t, rec.Body.String(), `[{"id":3,"title":"task #3 high prio","description":"","done":false,"due_date":0,"reminder_dates":null,"repeat_after":0,"priority":100,"start_date":0,"end_date":0,"assignees":null,"labels":null,"hex_color":"","created":1543626724,"updated":1543626724,"created_by":{"id":0,"username":"","email":"","created":0,"updated":0}},{"id":4,"title":"task #4 low prio","description":"","done":false,"due_date":0,"reminder_dates":null,"repeat_after":0,"priority":1`)
assert.NotContains(t, rec.Body.String(), `{"id":4,"title":"task #4 low prio","description":"","done":false,"due_date":0,"reminder_dates":null,"repeat_after":0,"priority":1,"start_date":0,"end_date":0,"assignees":null,"labels":null,"hex_color":"","created":1543626724,"updated":1543626724,"created_by":{"id":0,"username":"","email":"","created":0,"updated":0}},{"id":3,"title":"task #3 high prio","description":"","done":false,"due_date":0,"reminder_dates":null,"repeat_after":0,"priority":100,"start_date":0,"end_date":0,"assignees":null,"labels":null,"created":1543626724,"updated":1543626724,"created_by":{"id":0,"username":"","email":"","created":0,"updated":0}}]`)
assert.NotContains(t, rec.Body.String(), `[{"id":5,"title":"task #5 higher due date","description":"","done":false,"due_date":1543636724,"reminder_dates":null,"repeat_after":0,"priority":0,"start_date":0,"end_date":0,"assignees":null,"labels":null,"hex_color":"","created":1543626724,"updated":1543626724,"created_by":{"id":0,"username":"","email":"","created":0,"updated":0}},{"id":6,"title":"task #6 lower due date"`)
assert.NotContains(t, rec.Body.String(), `{"id":6,"title":"task #6 lower due date","description":"","done":false,"due_date":1543616724,"reminder_dates":null,"repeat_after":0,"priority":0,"start_date":0,"end_date":0,"assignees":null,"labels":null,"hex_color":"","created":1543626724,"updated":1543626724,"created_by":{"id":0,"username":"","email":"","created":0,"updated":0}},{"id":5,"title":"task #5 higher due date","description":"","done":false,"due_date":1543636724,"reminder_dates":null,"repeat_after":0,"priority":0,"start_date":0,"end_date":0,"assignees":null,"labels":null,"created":1543626724,"updated":1543626724,"created_by":{"id":0,"username":"","email":"","created":0,"updated":0}}]`)
})
})
t.Run("Date range", func(t *testing.T) {
@ -207,7 +241,7 @@ func TestTaskCollection(t *testing.T) {
// If no start date but an end date is specified, this should be null
// since we don't have any tasks in the fixtures with an end date >
// the current date.
assert.Equal(t, "null\n", rec.Body.String())
assert.Equal(t, "[]\n", rec.Body.String())
})
})
})
@ -270,42 +304,42 @@ func TestTaskCollection(t *testing.T) {
t.Run("by priority", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"priority"}}, nil)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `{"id":33,"text":"task #33 with percent done","description":"","done":false,"done_at":null,"due_date":null,"reminder_dates":null,"list_id":1,"repeat_after":0,"priority":0,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"created_by":{"id":1,"username":"user1","created":null,"updated":null}},{"id":4,"text":"task #4 low prio","description":"","done":false,"done_at":null,"due_date":null,"reminder_dates":null,"list_id":1,"repeat_after":0,"priority":1,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-4","index":4,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"created_by":{"id":1,"username":"user1","created":null,"updated":null}},{"id":3,"text":"task #3 high prio","description":"","done":false,"done_at":null,"due_date":null,"reminder_dates":null,"list_id":1,"repeat_after":0,"priority":100,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-3","index":3,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"created_by":{"id":1,"username":"user1","created":null,"updated":null}}]`)
assert.Contains(t, rec.Body.String(), `{"id":33,"title":"task #33 with percent done","description":"","done":false,"done_at":null,"due_date":null,"reminder_dates":null,"list_id":1,"repeat_after":0,"priority":0,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":0,"created_by":{"id":1,"username":"user1","created":null,"updated":null}},{"id":4,"title":"task #4 low prio","description":"","done":false,"done_at":null,"due_date":null,"reminder_dates":null,"list_id":1,"repeat_after":0,"priority":1,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-4","index":4,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":null,"updated":null}},{"id":3,"title":"task #3 high prio","description":"","done":false,"done_at":null,"due_date":null,"reminder_dates":null,"list_id":1,"repeat_after":0,"priority":100,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-3","index":3,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":null,"updated":null}}]`)
})
t.Run("by priority desc", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"priority"}, "order_by": []string{"desc"}}, nil)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `[{"id":3,"text":"task #3 high prio","description":"","done":false,"done_at":null,"due_date":null,"reminder_dates":null,"list_id":1,"repeat_after":0,"priority":100,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-3","index":3,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"created_by":{"id":1,"username":"user1","created":null,"updated":null}},{"id":4,"text":"task #4 low prio","description":"","done":false,"done_at":null,"due_date":null,"reminder_dates":null,"list_id":1,"repeat_after":0,"priority":1`)
assert.Contains(t, rec.Body.String(), `[{"id":3,"title":"task #3 high prio","description":"","done":false,"done_at":null,"due_date":null,"reminder_dates":null,"list_id":1,"repeat_after":0,"priority":100,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-3","index":3,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":null,"updated":null}},{"id":4,"title":"task #4 low prio","description":"","done":false,"done_at":null,"due_date":null,"reminder_dates":null,"list_id":1,"repeat_after":0,"priority":1`)
})
t.Run("by priority asc", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"priority"}, "order_by": []string{"asc"}}, nil)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `{"id":33,"text":"task #33 with percent done","description":"","done":false,"done_at":null,"due_date":null,"reminder_dates":null,"list_id":1,"repeat_after":0,"priority":0,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"created_by":{"id":1,"username":"user1","created":null,"updated":null}},{"id":4,"text":"task #4 low prio","description":"","done":false,"done_at":null,"due_date":null,"reminder_dates":null,"list_id":1,"repeat_after":0,"priority":1,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-4","index":4,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"created_by":{"id":1,"username":"user1","created":null,"updated":null}},{"id":3,"text":"task #3 high prio","description":"","done":false,"done_at":null,"due_date":null,"reminder_dates":null,"list_id":1,"repeat_after":0,"priority":100,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-3","index":3,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"created_by":{"id":1,"username":"user1","created":null,"updated":null}}]`)
assert.Contains(t, rec.Body.String(), `{"id":33,"title":"task #33 with percent done","description":"","done":false,"done_at":null,"due_date":null,"reminder_dates":null,"list_id":1,"repeat_after":0,"priority":0,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":0,"created_by":{"id":1,"username":"user1","created":null,"updated":null}},{"id":4,"title":"task #4 low prio","description":"","done":false,"done_at":null,"due_date":null,"reminder_dates":null,"list_id":1,"repeat_after":0,"priority":1,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-4","index":4,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":null,"updated":null}},{"id":3,"title":"task #3 high prio","description":"","done":false,"done_at":null,"due_date":null,"reminder_dates":null,"list_id":1,"repeat_after":0,"priority":100,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-3","index":3,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":null,"updated":null}}]`)
})
// should equal duedate asc
t.Run("by due_date", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date_unix"}}, nil)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `{"id":6,"text":"task #6 lower due date","description":"","done":false,"done_at":null,"due_date":"2018-11-30T22:25:24Z","reminder_dates":null,"list_id":1,"repeat_after":0,"priority":0,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"created_by":{"id":1,"username":"user1","created":null,"updated":null}},{"id":5,"text":"task #5 higher due date","description":"","done":false,"done_at":null,"due_date":"2018-12-01T03:58:44Z","reminder_dates":null,"list_id":1,"repeat_after":0,"priority":0,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"created_by":{"id":1,"username":"user1","created":null,"updated":null}}]`)
assert.Contains(t, rec.Body.String(), `{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":null,"due_date":"2018-11-30T22:25:24Z","reminder_dates":null,"list_id":1,"repeat_after":0,"priority":0,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"position":0,"created_by":{"id":1,"username":"user1","created":null,"updated":null}},{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":null,"due_date":"2018-12-01T03:58:44Z","reminder_dates":null,"list_id":1,"repeat_after":0,"priority":0,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":null,"updated":null}}]`)
})
t.Run("by duedate desc", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date_unix"}, "order_by": []string{"desc"}}, nil)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `[{"id":5,"text":"task #5 higher due date","description":"","done":false,"done_at":null,"due_date":"2018-12-01T03:58:44Z","reminder_dates":null,"list_id":1,"repeat_after":0,"priority":0,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"created_by":{"id":1,"username":"user1","created":null,"updated":null}},{"id":6,"text":"task #6 lower due date`)
assert.Contains(t, rec.Body.String(), `[{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":null,"due_date":"2018-12-01T03:58:44Z","reminder_dates":null,"list_id":1,"repeat_after":0,"priority":0,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":null,"updated":null}},{"id":6,"title":"task #6 lower due date`)
})
t.Run("by duedate asc", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date_unix"}, "order_by": []string{"asc"}}, nil)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `{"id":6,"text":"task #6 lower due date","description":"","done":false,"done_at":null,"due_date":"2018-11-30T22:25:24Z","reminder_dates":null,"list_id":1,"repeat_after":0,"priority":0,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"created_by":{"id":1,"username":"user1","created":null,"updated":null}},{"id":5,"text":"task #5 higher due date","description":"","done":false,"done_at":null,"due_date":"2018-12-01T03:58:44Z","reminder_dates":null,"list_id":1,"repeat_after":0,"priority":0,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"created_by":{"id":1,"username":"user1","created":null,"updated":null}}]`)
assert.Contains(t, rec.Body.String(), `{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":null,"due_date":"2018-11-30T22:25:24Z","reminder_dates":null,"list_id":1,"repeat_after":0,"priority":0,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"position":0,"created_by":{"id":1,"username":"user1","created":null,"updated":null}},{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":null,"due_date":"2018-12-01T03:58:44Z","reminder_dates":null,"list_id":1,"repeat_after":0,"priority":0,"start_date":null,"end_date":null,"assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"created_by":{"id":1,"username":"user1","created":null,"updated":null}}]`)
})
t.Run("invalid parameter", func(t *testing.T) {
// Invalid parameter should not sort at all
rec, err := testHandler.testReadAllWithUser(url.Values{"sort": []string{"loremipsum"}}, nil)
assert.NoError(t, err)
assert.NotContains(t, rec.Body.String(), `[{"id":3,"text":"task #3 high prio","description":"","done":false,"due_date":0,"reminder_dates":null,"repeat_after":0,"priority":100,"start_date":0,"end_date":0,"assignees":null,"labels":null,"hex_color":"","created":1543626724,"updated":1543626724,"created_by":{"id":0,"username":"","email":"","created":0,"updated":0}},{"id":4,"text":"task #4 low prio","description":"","done":false,"due_date":0,"reminder_dates":null,"repeat_after":0,"priority":1`)
assert.NotContains(t, rec.Body.String(), `{"id":4,"text":"task #4 low prio","description":"","done":false,"due_date":0,"reminder_dates":null,"repeat_after":0,"priority":1,"start_date":0,"end_date":0,"assignees":null,"labels":null,"hex_color":"","created":1543626724,"updated":1543626724,"created_by":{"id":0,"username":"","email":"","created":0,"updated":0}},{"id":3,"text":"task #3 high prio","description":"","done":false,"due_date":0,"reminder_dates":null,"repeat_after":0,"priority":100,"start_date":0,"end_date":0,"assignees":null,"labels":null,"created":1543626724,"updated":1543626724,"created_by":{"id":0,"username":"","email":"","created":0,"updated":0}}]`)
assert.NotContains(t, rec.Body.String(), `[{"id":5,"text":"task #5 higher due date","description":"","done":false,"due_date":1543636724,"reminder_dates":null,"repeat_after":0,"priority":0,"start_date":0,"end_date":0,"assignees":null,"labels":null,"hex_color":"","created":1543626724,"updated":1543626724,"created_by":{"id":0,"username":"","email":"","created":0,"updated":0}},{"id":6,"text":"task #6 lower due date"`)
assert.NotContains(t, rec.Body.String(), `{"id":6,"text":"task #6 lower due date","description":"","done":false,"due_date":1543616724,"reminder_dates":null,"repeat_after":0,"priority":0,"start_date":0,"end_date":0,"assignees":null,"labels":null,"hex_color":"","created":1543626724,"updated":1543626724,"created_by":{"id":0,"username":"","email":"","created":0,"updated":0}},{"id":5,"text":"task #5 higher due date","description":"","done":false,"due_date":1543636724,"reminder_dates":null,"repeat_after":0,"priority":0,"start_date":0,"end_date":0,"assignees":null,"labels":null,"created":1543626724,"updated":1543626724,"created_by":{"id":0,"username":"","email":"","created":0,"updated":0}}]`)
assert.NotContains(t, rec.Body.String(), `[{"id":3,"title":"task #3 high prio","description":"","done":false,"due_date":0,"reminder_dates":null,"repeat_after":0,"priority":100,"start_date":0,"end_date":0,"assignees":null,"labels":null,"hex_color":"","created":1543626724,"updated":1543626724,"created_by":{"id":0,"username":"","email":"","created":0,"updated":0}},{"id":4,"title":"task #4 low prio","description":"","done":false,"due_date":0,"reminder_dates":null,"repeat_after":0,"priority":1`)
assert.NotContains(t, rec.Body.String(), `{"id":4,"title":"task #4 low prio","description":"","done":false,"due_date":0,"reminder_dates":null,"repeat_after":0,"priority":1,"start_date":0,"end_date":0,"assignees":null,"labels":null,"hex_color":"","created":1543626724,"updated":1543626724,"created_by":{"id":0,"username":"","email":"","created":0,"updated":0}},{"id":3,"title":"task #3 high prio","description":"","done":false,"due_date":0,"reminder_dates":null,"repeat_after":0,"priority":100,"start_date":0,"end_date":0,"assignees":null,"labels":null,"created":1543626724,"updated":1543626724,"created_by":{"id":0,"username":"","email":"","created":0,"updated":0}}]`)
assert.NotContains(t, rec.Body.String(), `[{"id":5,"title":"task #5 higher due date","description":"","done":false,"due_date":1543636724,"reminder_dates":null,"repeat_after":0,"priority":0,"start_date":0,"end_date":0,"assignees":null,"labels":null,"hex_color":"","created":1543626724,"updated":1543626724,"created_by":{"id":0,"username":"","email":"","created":0,"updated":0}},{"id":6,"title":"task #6 lower due date"`)
assert.NotContains(t, rec.Body.String(), `{"id":6,"title":"task #6 lower due date","description":"","done":false,"due_date":1543616724,"reminder_dates":null,"repeat_after":0,"priority":0,"start_date":0,"end_date":0,"assignees":null,"labels":null,"hex_color":"","created":1543626724,"updated":1543626724,"created_by":{"id":0,"username":"","email":"","created":0,"updated":0}},{"id":5,"title":"task #5 higher due date","description":"","done":false,"due_date":1543636724,"reminder_dates":null,"repeat_after":0,"priority":0,"start_date":0,"end_date":0,"assignees":null,"labels":null,"created":1543626724,"updated":1543626724,"created_by":{"id":0,"username":"","email":"","created":0,"updated":0}}]`)
})
})
t.Run("Date range", func(t *testing.T) {
@ -372,7 +406,7 @@ func TestTaskCollection(t *testing.T) {
// If no start date but an end date is specified, this should be null
// since we don't have any tasks in the fixtures with an end date >
// the current date.
assert.Equal(t, "null\n", rec.Body.String())
assert.Equal(t, "[]\n", rec.Body.String())
})
})
})

View File

@ -36,11 +36,11 @@ func TestTask(t *testing.T) {
// ^TestTask$/^Update$/^Update_task_items$/^Removing_Assignees_null$
t.Run("Update", func(t *testing.T) {
t.Run("Update task items", func(t *testing.T) {
t.Run("Text", func(t *testing.T) {
rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"listtask": "1"}, `{"text":"Lorem Ipsum"}`)
t.Run("Title", func(t *testing.T) {
rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"listtask": "1"}, `{"title":"Lorem Ipsum"}`)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"text":"Lorem Ipsum"`)
assert.NotContains(t, rec.Body.String(), `"text":"task #1"`)
assert.Contains(t, rec.Body.String(), `"title":"Lorem Ipsum"`)
assert.NotContains(t, rec.Body.String(), `"title":"task #1"`)
})
t.Run("Description", func(t *testing.T) {
rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"listtask": "1"}, `{"description":"Dolor sit amet"}`)
@ -195,78 +195,78 @@ func TestTask(t *testing.T) {
})
t.Run("Nonexisting", func(t *testing.T) {
_, err := testHandler.testUpdateWithUser(nil, map[string]string{"listtask": "99999"}, `{"text":"Lorem Ipsum"}`)
_, err := testHandler.testUpdateWithUser(nil, map[string]string{"listtask": "99999"}, `{"title":"Lorem Ipsum"}`)
assert.Error(t, err)
assertHandlerErrorCode(t, err, models.ErrCodeTaskDoesNotExist)
})
t.Run("Rights check", func(t *testing.T) {
t.Run("Forbidden", func(t *testing.T) {
_, err := testHandler.testUpdateWithUser(nil, map[string]string{"listtask": "14"}, `{"text":"Lorem Ipsum"}`)
_, err := testHandler.testUpdateWithUser(nil, map[string]string{"listtask": "14"}, `{"title":"Lorem Ipsum"}`)
assert.Error(t, err)
assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
})
t.Run("Shared Via Team readonly", func(t *testing.T) {
_, err := testHandler.testUpdateWithUser(nil, map[string]string{"listtask": "15"}, `{"text":"Lorem Ipsum"}`)
_, err := testHandler.testUpdateWithUser(nil, map[string]string{"listtask": "15"}, `{"title":"Lorem Ipsum"}`)
assert.Error(t, err)
assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
})
t.Run("Shared Via Team write", func(t *testing.T) {
rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"listtask": "16"}, `{"text":"Lorem Ipsum"}`)
rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"listtask": "16"}, `{"title":"Lorem Ipsum"}`)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"text":"Lorem Ipsum"`)
assert.Contains(t, rec.Body.String(), `"title":"Lorem Ipsum"`)
})
t.Run("Shared Via Team admin", func(t *testing.T) {
rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"listtask": "17"}, `{"text":"Lorem Ipsum"}`)
rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"listtask": "17"}, `{"title":"Lorem Ipsum"}`)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"text":"Lorem Ipsum"`)
assert.Contains(t, rec.Body.String(), `"title":"Lorem Ipsum"`)
})
t.Run("Shared Via User readonly", func(t *testing.T) {
_, err := testHandler.testUpdateWithUser(nil, map[string]string{"listtask": "18"}, `{"text":"Lorem Ipsum"}`)
_, err := testHandler.testUpdateWithUser(nil, map[string]string{"listtask": "18"}, `{"title":"Lorem Ipsum"}`)
assert.Error(t, err)
assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
})
t.Run("Shared Via User write", func(t *testing.T) {
rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"listtask": "19"}, `{"text":"Lorem Ipsum"}`)
rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"listtask": "19"}, `{"title":"Lorem Ipsum"}`)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"text":"Lorem Ipsum"`)
assert.Contains(t, rec.Body.String(), `"title":"Lorem Ipsum"`)
})
t.Run("Shared Via User admin", func(t *testing.T) {
rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"listtask": "20"}, `{"text":"Lorem Ipsum"}`)
rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"listtask": "20"}, `{"title":"Lorem Ipsum"}`)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"text":"Lorem Ipsum"`)
assert.Contains(t, rec.Body.String(), `"title":"Lorem Ipsum"`)
})
t.Run("Shared Via NamespaceTeam readonly", func(t *testing.T) {
_, err := testHandler.testUpdateWithUser(nil, map[string]string{"listtask": "21"}, `{"text":"Lorem Ipsum"}`)
_, err := testHandler.testUpdateWithUser(nil, map[string]string{"listtask": "21"}, `{"title":"Lorem Ipsum"}`)
assert.Error(t, err)
assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
})
t.Run("Shared Via NamespaceTeam write", func(t *testing.T) {
rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"listtask": "22"}, `{"text":"Lorem Ipsum"}`)
rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"listtask": "22"}, `{"title":"Lorem Ipsum"}`)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"text":"Lorem Ipsum"`)
assert.Contains(t, rec.Body.String(), `"title":"Lorem Ipsum"`)
})
t.Run("Shared Via NamespaceTeam admin", func(t *testing.T) {
rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"listtask": "23"}, `{"text":"Lorem Ipsum"}`)
rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"listtask": "23"}, `{"title":"Lorem Ipsum"}`)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"text":"Lorem Ipsum"`)
assert.Contains(t, rec.Body.String(), `"title":"Lorem Ipsum"`)
})
t.Run("Shared Via NamespaceUser readonly", func(t *testing.T) {
_, err := testHandler.testUpdateWithUser(nil, map[string]string{"listtask": "24"}, `{"text":"Lorem Ipsum"}`)
_, err := testHandler.testUpdateWithUser(nil, map[string]string{"listtask": "24"}, `{"title":"Lorem Ipsum"}`)
assert.Error(t, err)
assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
})
t.Run("Shared Via NamespaceUser write", func(t *testing.T) {
rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"listtask": "25"}, `{"text":"Lorem Ipsum"}`)
rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"listtask": "25"}, `{"title":"Lorem Ipsum"}`)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"text":"Lorem Ipsum"`)
assert.Contains(t, rec.Body.String(), `"title":"Lorem Ipsum"`)
})
t.Run("Shared Via NamespaceUser admin", func(t *testing.T) {
rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"listtask": "26"}, `{"text":"Lorem Ipsum"}`)
rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"listtask": "26"}, `{"title":"Lorem Ipsum"}`)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"text":"Lorem Ipsum"`)
assert.Contains(t, rec.Body.String(), `"title":"Lorem Ipsum"`)
})
})
t.Run("Move to other list", func(t *testing.T) {
@ -390,100 +390,100 @@ func TestTask(t *testing.T) {
})
t.Run("Create", func(t *testing.T) {
t.Run("Normal", func(t *testing.T) {
rec, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "1"}, `{"text":"Lorem Ipsum"}`)
rec, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "1"}, `{"title":"Lorem Ipsum"}`)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"text":"Lorem Ipsum"`)
assert.Contains(t, rec.Body.String(), `"title":"Lorem Ipsum"`)
})
t.Run("Nonexisting", func(t *testing.T) {
_, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "9999"}, `{"text":"Lorem Ipsum"}`)
_, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "9999"}, `{"title":"Lorem Ipsum"}`)
assert.Error(t, err)
assertHandlerErrorCode(t, err, models.ErrCodeListDoesNotExist)
})
t.Run("Rights check", func(t *testing.T) {
t.Run("Forbidden", func(t *testing.T) {
// Owned by user13
_, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "20"}, `{"text":"Lorem Ipsum"}`)
_, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "20"}, `{"title":"Lorem Ipsum"}`)
assert.Error(t, err)
assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
})
t.Run("Shared Via Team readonly", func(t *testing.T) {
_, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "6"}, `{"text":"Lorem Ipsum"}`)
_, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "6"}, `{"title":"Lorem Ipsum"}`)
assert.Error(t, err)
assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
})
t.Run("Shared Via Team write", func(t *testing.T) {
rec, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "7"}, `{"text":"Lorem Ipsum"}`)
rec, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "7"}, `{"title":"Lorem Ipsum"}`)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"text":"Lorem Ipsum"`)
assert.Contains(t, rec.Body.String(), `"title":"Lorem Ipsum"`)
})
t.Run("Shared Via Team admin", func(t *testing.T) {
rec, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "8"}, `{"text":"Lorem Ipsum"}`)
rec, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "8"}, `{"title":"Lorem Ipsum"}`)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"text":"Lorem Ipsum"`)
assert.Contains(t, rec.Body.String(), `"title":"Lorem Ipsum"`)
})
t.Run("Shared Via User readonly", func(t *testing.T) {
_, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "9"}, `{"text":"Lorem Ipsum"}`)
_, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "9"}, `{"title":"Lorem Ipsum"}`)
assert.Error(t, err)
assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
})
t.Run("Shared Via User write", func(t *testing.T) {
rec, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "10"}, `{"text":"Lorem Ipsum"}`)
rec, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "10"}, `{"title":"Lorem Ipsum"}`)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"text":"Lorem Ipsum"`)
assert.Contains(t, rec.Body.String(), `"title":"Lorem Ipsum"`)
})
t.Run("Shared Via User admin", func(t *testing.T) {
rec, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "11"}, `{"text":"Lorem Ipsum"}`)
rec, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "11"}, `{"title":"Lorem Ipsum"}`)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"text":"Lorem Ipsum"`)
assert.Contains(t, rec.Body.String(), `"title":"Lorem Ipsum"`)
})
t.Run("Shared Via NamespaceTeam readonly", func(t *testing.T) {
_, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "12"}, `{"text":"Lorem Ipsum"}`)
_, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "12"}, `{"title":"Lorem Ipsum"}`)
assert.Error(t, err)
assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
})
t.Run("Shared Via NamespaceTeam write", func(t *testing.T) {
rec, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "13"}, `{"text":"Lorem Ipsum"}`)
rec, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "13"}, `{"title":"Lorem Ipsum"}`)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"text":"Lorem Ipsum"`)
assert.Contains(t, rec.Body.String(), `"title":"Lorem Ipsum"`)
})
t.Run("Shared Via NamespaceTeam admin", func(t *testing.T) {
rec, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "14"}, `{"text":"Lorem Ipsum"}`)
rec, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "14"}, `{"title":"Lorem Ipsum"}`)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"text":"Lorem Ipsum"`)
assert.Contains(t, rec.Body.String(), `"title":"Lorem Ipsum"`)
})
t.Run("Shared Via NamespaceUser readonly", func(t *testing.T) {
_, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "15"}, `{"text":"Lorem Ipsum"}`)
_, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "15"}, `{"title":"Lorem Ipsum"}`)
assert.Error(t, err)
assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
})
t.Run("Shared Via NamespaceUser write", func(t *testing.T) {
rec, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "16"}, `{"text":"Lorem Ipsum"}`)
rec, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "16"}, `{"title":"Lorem Ipsum"}`)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"text":"Lorem Ipsum"`)
assert.Contains(t, rec.Body.String(), `"title":"Lorem Ipsum"`)
})
t.Run("Shared Via NamespaceUser admin", func(t *testing.T) {
rec, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "17"}, `{"text":"Lorem Ipsum"}`)
rec, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "17"}, `{"title":"Lorem Ipsum"}`)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"text":"Lorem Ipsum"`)
assert.Contains(t, rec.Body.String(), `"title":"Lorem Ipsum"`)
})
})
t.Run("Bucket", func(t *testing.T) {
t.Run("Normal", func(t *testing.T) {
rec, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "1"}, `{"text":"Lorem Ipsum","bucket_id":2}`)
rec, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "1"}, `{"title":"Lorem Ipsum","bucket_id":2}`)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"bucket_id":2`)
assert.NotContains(t, rec.Body.String(), `"bucket_id":1`)
})
t.Run("Different List", func(t *testing.T) {
_, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "1"}, `{"text":"Lorem Ipsum","bucket_id":4}`)
_, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "1"}, `{"title":"Lorem Ipsum","bucket_id":4}`)
assert.Error(t, err)
assertHandlerErrorCode(t, err, models.ErrCodeBucketDoesNotBelongToList)
})
t.Run("Nonexisting Bucket", func(t *testing.T) {
_, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "1"}, `{"text":"Lorem Ipsum","bucket_id":9999}`)
_, err := testHandler.testCreateWithUser(nil, map[string]string{"list": "1"}, `{"title":"Lorem Ipsum","bucket_id":9999}`)
assert.Error(t, err)
assertHandlerErrorCode(t, err, models.ErrCodeBucketDoesNotExist)
})

View File

@ -27,6 +27,16 @@ import (
// Queue is the mail queue
var Queue chan *gomail.Message
func getDialer() *gomail.Dialer {
d := gomail.NewDialer(config.MailerHost.GetString(), config.MailerPort.GetInt(), config.MailerUsername.GetString(), config.MailerPassword.GetString())
// #nosec
d.TLSConfig = &tls.Config{
InsecureSkipVerify: config.MailerSkipTLSVerify.GetBool(),
ServerName: config.MailerHost.GetString(),
}
return d
}
// StartMailDaemon starts the mail daemon
func StartMailDaemon() {
Queue = make(chan *gomail.Message, config.MailerQueuelength.GetInt())
@ -41,9 +51,7 @@ func StartMailDaemon() {
}
go func() {
d := gomail.NewDialer(config.MailerHost.GetString(), config.MailerPort.GetInt(), config.MailerUsername.GetString(), config.MailerPassword.GetString())
// #nosec
d.TLSConfig = &tls.Config{InsecureSkipVerify: config.MailerSkipTLSVerify.GetBool()}
d := getDialer()
var s gomail.SendCloser
var err error

View File

@ -53,6 +53,30 @@ type header struct {
Content string
}
// SendTestMail sends a test mail to a receipient.
// It works without a queue.
func SendTestMail(to string) error {
if config.MailerHost.GetString() == "" {
log.Warning("Mailer seems to be not configured! Please see the config docs for more details.")
return nil
}
d := getDialer()
s, err := d.Dial()
if err != nil {
return err
}
defer s.Close()
m := gomail.NewMessage()
m.SetHeader("From", config.MailerFromEmail.GetString())
m.SetHeader("To", to)
m.SetHeader("Subject", "Test from Vikunja")
m.SetBody("text/plain", "This is a test mail! If you got this, Vikunja is correctly set up to send emails.")
return gomail.Send(s, m)
}
// SendMail puts a mail in the queue
func SendMail(opts *Opts) {
m := gomail.NewMessage()

View File

@ -19,6 +19,7 @@ package metrics
import (
"bytes"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/web"
"encoding/gob"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
@ -37,7 +38,15 @@ type ActiveUser struct {
LastSeen time.Time
}
// ActiveUsersMap is the type used to save active users
type ActiveUsersMap map[int64]*ActiveUser
// activeUsers holds a map with all active users
var activeUsers ActiveUsersMap
func init() {
activeUsers = make(ActiveUsersMap)
promauto.NewGaugeFunc(prometheus.GaugeOpts{
Name: "vikunja_active_users",
Help: "The currently active users on this node",
@ -57,8 +66,17 @@ func init() {
})
}
// SetUserActive sets a user as active and pushes it to redis
func SetUserActive(a web.Auth) (err error) {
activeUsers[a.GetID()] = &ActiveUser{
UserID: a.GetID(),
LastSeen: time.Now(),
}
return PushActiveUsers()
}
// GetActiveUsers returns the active users from redis
func GetActiveUsers() (users []*ActiveUser, err error) {
func GetActiveUsers() (users ActiveUsersMap, err error) {
activeUsersR, err := r.Get(ActiveUsersKey).Bytes()
if err != nil {
@ -80,11 +98,11 @@ func GetActiveUsers() (users []*ActiveUser, err error) {
return
}
// SetActiveUsers sets the active users from redis
func SetActiveUsers(users []*ActiveUser) (err error) {
// PushActiveUsers pushed the content of the activeUsers map to redis
func PushActiveUsers() (err error) {
var b bytes.Buffer
e := gob.NewEncoder(&b)
if err := e.Encode(users); err != nil {
if err := e.Encode(activeUsers); err != nil {
return err
}

View File

@ -48,6 +48,11 @@ const (
func InitMetrics() {
r = red.GetRedis()
// init active users, sometimes we'll have garbage from previous runs in redis instead
if err := PushActiveUsers(); err != nil {
log.Fatalf("Could not set initial count for active users, error was %s", err)
}
// Register total list count metric
promauto.NewGaugeFunc(prometheus.GaugeOpts{
Name: "vikunja_list_count",

View File

@ -0,0 +1,65 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2020 Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package migration
import (
"code.vikunja.io/api/pkg/models"
"math"
"src.techknowlogick.com/xormigrate"
"xorm.io/xorm"
)
type task20200420215928 struct {
Position float64 `xorm:"double null" json:"position"`
}
func (s task20200420215928) TableName() string {
return "tasks"
}
func init() {
migrations = append(migrations, &xormigrate.Migration{
ID: "20200420215928",
Description: "Add position property to task",
Migrate: func(tx *xorm.Engine) error {
err := tx.Sync2(task20200420215928{})
if err != nil {
return err
}
// Create a position according to their id -> gives a starting position
tasks := []*models.Task{}
err = tx.Find(&tasks)
if err != nil {
return err
}
for _, task := range tasks {
task.Position = float64(task.ID) * math.Pow(2, 16)
_, err = tx.Where("id = ?", task.ID).Update(task)
if err != nil {
return err
}
}
return nil
},
Rollback: func(tx *xorm.Engine) error {
return tx.DropTables(task20200420215928{})
},
})
}

View File

@ -0,0 +1,75 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2020 Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package migration
import (
"code.vikunja.io/api/pkg/models"
"src.techknowlogick.com/xormigrate"
"xorm.io/xorm"
)
func init() {
migrations = append(migrations, &xormigrate.Migration{
ID: "20200425182634",
Description: "Create one bucket for each list",
Migrate: func(tx *xorm.Engine) (err error) {
lists := []*models.List{}
err = tx.Find(&lists)
if err != nil {
return
}
tasks := []*models.Task{}
err = tx.Find(&tasks)
if err != nil {
return
}
// This map contains all buckets with their list ids as key
buckets := make(map[int64]*models.Bucket, len(lists))
for _, l := range lists {
buckets[l.ID] = &models.Bucket{
ListID: l.ID,
Title: "New Bucket",
// The bucket creator is just the same as the list's one
CreatedByID: l.OwnerID,
}
_, err = tx.Insert(buckets[l.ID])
if err != nil {
return
}
for _, t := range tasks {
if t.ListID != l.ID {
continue
}
t.BucketID = buckets[l.ID].ID
_, err = tx.Where("id = ?", t.ID).Update(t)
if err != nil {
return
}
}
}
return
},
Rollback: func(tx *xorm.Engine) error {
return nil
},
})
}

View File

@ -0,0 +1,43 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2020 Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package migration
import (
"src.techknowlogick.com/xormigrate"
"xorm.io/xorm"
)
type totp20200509103709 struct {
Secret string `xorm:"text not null" json:"secret"`
}
func (t totp20200509103709) TableName() string {
return "totp"
}
func init() {
migrations = append(migrations, &xormigrate.Migration{
ID: "20200509103709",
Description: "Fix totp secret data type",
Migrate: func(tx *xorm.Engine) error {
return tx.Sync2(totp20200509103709{})
},
Rollback: func(tx *xorm.Engine) error {
return tx.DropTables(totp20200509103709{})
},
})
}

View File

@ -0,0 +1,120 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2020 Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package migration
import (
"code.vikunja.io/api/pkg/config"
"src.techknowlogick.com/xormigrate"
"xorm.io/xorm"
)
type task20200515172220 struct {
ID int64 `xorm:"int(11) autoincr not null unique pk"`
Title string `xorm:"varchar(250) null"`
Text string `xorm:"varchar(250) not null"`
}
func (t *task20200515172220) TableName() string {
return "tasks"
}
// We can't do the migration if the title column is not null but has a default value of null,
// so we initialize it as null and change it after migrating.
type task20200515172221 struct {
Title string `xorm:"varchar(250) not null"`
}
func (t *task20200515172221) TableName() string {
return "tasks"
}
func init() {
migrations = append(migrations, &xormigrate.Migration{
ID: "20200515172220",
Description: "Change task text to title",
Migrate: func(tx *xorm.Engine) error {
err := tx.Sync2(task20200515172220{})
if err != nil {
return err
}
tasks := []*task20200515172220{}
err = tx.Find(&tasks)
if err != nil {
return err
}
for _, task := range tasks {
task.Title = task.Text
_, err = tx.Where("id = ?", task.ID).Update(task)
if err != nil {
return err
}
}
err = tx.Sync2(task20200515172221{})
if err != nil {
return err
}
// sqlite is not able to drop columns. To have inserting new tasks still work, we drop the column manually.
if config.DatabaseType.GetString() == "sqlite" {
_, err = tx.Exec(`
create table tasks_dg_tmp
(
id INTEGER not null
primary key autoincrement,
description TEXT,
done INTEGER,
done_at_unix INTEGER,
due_date_unix INTEGER,
created_by_id INTEGER not null,
list_id INTEGER not null,
repeat_after INTEGER,
priority INTEGER,
start_date_unix INTEGER,
end_date_unix INTEGER,
hex_color TEXT,
percent_done REAL,
"index" INTEGER default 0 not null,
uid TEXT,
created INTEGER not null,
updated INTEGER not null,
bucket_id INTEGER,
position REAL,
title TEXT
);
insert into tasks_dg_tmp(id, description, done, done_at_unix, due_date_unix, created_by_id, list_id, repeat_after, priority, start_date_unix, end_date_unix, hex_color, percent_done, "index", uid, created, updated, bucket_id, position, title) select id, description, done, done_at_unix, due_date_unix, created_by_id, list_id, repeat_after, priority, start_date_unix, end_date_unix, hex_color, percent_done, "index", uid, created, updated, bucket_id, position, title from tasks;
drop table tasks;
alter table tasks_dg_tmp rename to tasks;
create unique index tasks_id_uindex
on tasks (id);
`)
return err
}
return dropTableColum(tx, "tasks", "text")
},
Rollback: func(tx *xorm.Engine) error {
return nil
},
})
}

View File

@ -0,0 +1,108 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2020 Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package migration
import (
"code.vikunja.io/api/pkg/config"
"src.techknowlogick.com/xormigrate"
"xorm.io/xorm"
)
type namespace20200515195546 struct {
ID int64 `xorm:"int(11) autoincr not null unique pk"`
Title string `xorm:"varchar(250) null"`
Name string `xorm:"varchar(250) not null"`
}
func (t *namespace20200515195546) TableName() string {
return "namespaces"
}
// We can't do the migration if the title column is not null but has a default value of null,
// so we initialize it as null and change it after migrating.
type namespace20200515195547 struct {
Title string `xorm:"varchar(250) not null"`
}
func (t *namespace20200515195547) TableName() string {
return "namespaces"
}
func init() {
migrations = append(migrations, &xormigrate.Migration{
ID: "20200515195546",
Description: "Change namespace name to title",
Migrate: func(tx *xorm.Engine) error {
err := tx.Sync2(namespace20200515195546{})
if err != nil {
return err
}
namespaces := []*namespace20200515195546{}
err = tx.Find(&namespaces)
if err != nil {
return err
}
for _, n := range namespaces {
n.Title = n.Name
_, err = tx.Where("id = ?", n.ID).Update(n)
if err != nil {
return err
}
}
err = tx.Sync2(namespace20200515195547{})
if err != nil {
return err
}
// sqlite is not able to drop columns. To have inserting new namespaces still work, we drop the column manually.
if config.DatabaseType.GetString() == "sqlite" {
_, err = tx.Exec(`
create table namespaces_dg_tmp
(
id INTEGER not null
primary key autoincrement,
description TEXT,
owner_id INTEGER not null,
created INTEGER not null,
updated INTEGER not null,
is_archived INTEGER default 0 not null,
hex_color TEXT,
title TEXT
);
insert into namespaces_dg_tmp(id, description, owner_id, created, updated, is_archived, hex_color, title) select id, description, owner_id, created, updated, is_archived, hex_color, title from namespaces;
drop table namespaces;
alter table namespaces_dg_tmp rename to namespaces;
create unique index UQE_namespaces_id
on namespaces (id);
`)
return err
}
return dropTableColum(tx, "namespaces", "name")
},
Rollback: func(tx *xorm.Engine) error {
return nil
},
})
}

View File

@ -0,0 +1,57 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2020 Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package migration
import (
"code.vikunja.io/api/pkg/models"
"src.techknowlogick.com/xormigrate"
"xorm.io/xorm"
)
func init() {
migrations = append(migrations, &xormigrate.Migration{
ID: "20200516123847",
Description: "Generate a list identifier for each list",
Migrate: func(tx *xorm.Engine) error {
lists := []*models.List{}
err := tx.Find(&lists)
if err != nil {
return err
}
for _, l := range lists {
if l.Identifier != "" {
continue
}
err := models.GenerateListIdentifier(l, tx)
if err != nil {
return err
}
_, err = tx.Where("id = ?", l.ID).Update(l)
if err != nil {
return err
}
}
return nil
},
Rollback: func(tx *xorm.Engine) error {
return nil
},
})
}

View File

@ -0,0 +1,47 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2020 Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package migration
import (
"src.techknowlogick.com/xormigrate"
"xorm.io/xorm"
)
type unsplashPhoto20200524221534 struct {
ID int64 `xorm:"autoincr not null unique pk"`
FileID int64 `xorm:"not null"`
UnsplashID string `xorm:"varchar(50)"`
Author string `xorm:"text"`
AuthorName string `xorm:"text"`
}
func (u unsplashPhoto20200524221534) TableName() string {
return "unsplash_photos"
}
func init() {
migrations = append(migrations, &xormigrate.Migration{
ID: "20200524221534",
Description: "Create unsplash photo table",
Migrate: func(tx *xorm.Engine) error {
return tx.Sync2(unsplashPhoto20200524221534{})
},
Rollback: func(tx *xorm.Engine) error {
return tx.DropTables(unsplashPhoto20200524221534{})
},
})
}

View File

@ -0,0 +1,43 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2020 Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package migration
import (
"src.techknowlogick.com/xormigrate"
"xorm.io/xorm"
)
type list20200524224611 struct {
BackgroundFileID int64 `xorm:"null" json:"-"`
}
func (s list20200524224611) TableName() string {
return "list"
}
func init() {
migrations = append(migrations, &xormigrate.Migration{
ID: "20200524224611",
Description: "Add background file id property to list",
Migrate: func(tx *xorm.Engine) error {
return tx.Sync2(list20200524224611{})
},
Rollback: func(tx *xorm.Engine) error {
return tx.DropTables(list20200524224611{})
},
})
}

View File

@ -54,7 +54,8 @@ func initMigration(x *xorm.Engine) *xormigrate.Xormigrate {
})
m := xormigrate.New(x, migrations)
m.NewLogger(log.GetLogWriter("database"))
logger := log.NewXormLogger("")
m.SetLogger(logger)
m.InitSchema(initSchema)
return m
}

View File

@ -110,7 +110,7 @@ func (bt *BulkTask) Update() (err error) {
}
_, err = sess.ID(oldtask.ID).
Cols("text",
Cols("title",
"description",
"done",
"due_date_unix",

View File

@ -24,7 +24,7 @@ func TestBulkTask_Update(t *testing.T) {
fields: fields{
IDs: []int64{10, 11, 12},
Task: Task{
Text: "bulkupdated",
Title: "bulkupdated",
},
User: &user.User{ID: 1},
},
@ -34,7 +34,7 @@ func TestBulkTask_Update(t *testing.T) {
fields: fields{
IDs: []int64{10, 11, 12, 13},
Task: Task{
Text: "bulkupdated",
Title: "bulkupdated",
},
User: &user.User{ID: 1},
},
@ -45,7 +45,7 @@ func TestBulkTask_Update(t *testing.T) {
fields: fields{
IDs: []int64{},
Task: Task{
Text: "bulkupdated",
Title: "bulkupdated",
},
User: &user.User{ID: 1},
},

View File

@ -35,7 +35,7 @@ func IsErrGenericForbidden(err error) bool {
}
func (err ErrGenericForbidden) Error() string {
return fmt.Sprintf("Forbidden")
return "Forbidden"
}
// ErrorCodeGenericForbidden holds the unique world-error code of this error
@ -60,7 +60,7 @@ func IsErrIDCannotBeZero(err error) bool {
}
func (err ErrIDCannotBeZero) Error() string {
return fmt.Sprintf("ID cannot be empty or 0")
return "ID cannot be empty or 0"
}
// ErrCodeIDCannotBeZero holds the unique world-error code of this error
@ -169,7 +169,7 @@ func IsErrListTitleCannotBeEmpty(err error) bool {
}
func (err ErrListTitleCannotBeEmpty) Error() string {
return fmt.Sprintf("List title cannot be empty.")
return "List title cannot be empty."
}
// ErrCodeListTitleCannotBeEmpty holds the unique world-error code of this error
@ -193,7 +193,7 @@ func IsErrListShareDoesNotExist(err error) bool {
}
func (err ErrListShareDoesNotExist) Error() string {
return fmt.Sprintf("List share does not exist.")
return "List share does not exist."
}
// ErrCodeListShareDoesNotExist holds the unique world-error code of this error
@ -216,7 +216,7 @@ func IsErrListIdentifierIsNotUnique(err error) bool {
}
func (err ErrListIdentifierIsNotUnique) Error() string {
return fmt.Sprintf("List identifier is not unique.")
return "List identifier is not unique."
}
// ErrCodeListIdentifierIsNotUnique holds the unique world-error code of this error
@ -268,7 +268,7 @@ func IsErrTaskCannotBeEmpty(err error) bool {
}
func (err ErrTaskCannotBeEmpty) Error() string {
return fmt.Sprintf("List task text cannot be empty.")
return "List task title cannot be empty."
}
// ErrCodeTaskCannotBeEmpty holds the unique world-error code of this error
@ -276,7 +276,7 @@ const ErrCodeTaskCannotBeEmpty = 4001
// HTTPError holds the http error description
func (err ErrTaskCannotBeEmpty) HTTPError() web.HTTPError {
return web.HTTPError{HTTPCode: http.StatusBadRequest, Code: ErrCodeTaskCannotBeEmpty, Message: "You must provide at least a list task text."}
return web.HTTPError{HTTPCode: http.StatusBadRequest, Code: ErrCodeTaskCannotBeEmpty, Message: "You must provide at least a list task title."}
}
// ErrTaskDoesNotExist represents a "ErrListDoesNotExist" kind of error. Used if the list does not exist.
@ -336,7 +336,7 @@ func IsErrBulkTasksNeedAtLeastOne(err error) bool {
}
func (err ErrBulkTasksNeedAtLeastOne) Error() string {
return fmt.Sprintf("Need at least one task when bulk editing tasks")
return "Need at least one task when bulk editing tasks"
}
// ErrCodeBulkTasksNeedAtLeastOne holds the unique world-error code of this error
@ -573,7 +573,7 @@ func (err ErrTaskAttachmentIsTooLarge) HTTPError() web.HTTPError {
// ErrInvalidSortParam represents an error where the provided sort param is invalid
type ErrInvalidSortParam struct {
SortBy sortProperty
SortBy string
}
// IsErrInvalidSortParam checks if an error is ErrInvalidSortParam.
@ -707,6 +707,33 @@ func (err ErrInvalidTaskFilterComparator) HTTPError() web.HTTPError {
}
}
// ErrInvalidTaskFilterConcatinator represents an error where the provided task field is invalid
type ErrInvalidTaskFilterConcatinator struct {
Concatinator taskFilterConcatinator
}
// IsErrInvalidTaskFilterConcatinator checks if an error is ErrInvalidTaskFilterConcatinator.
func IsErrInvalidTaskFilterConcatinator(err error) bool {
_, ok := err.(ErrInvalidTaskFilterConcatinator)
return ok
}
func (err ErrInvalidTaskFilterConcatinator) Error() string {
return fmt.Sprintf("Task filter concatinator is invalid [Concatinator: %s]", err.Concatinator)
}
// ErrCodeInvalidTaskFilterConcatinator holds the unique world-error code of this error
const ErrCodeInvalidTaskFilterConcatinator = 4018
// HTTPError holds the http error description
func (err ErrInvalidTaskFilterConcatinator) HTTPError() web.HTTPError {
return web.HTTPError{
HTTPCode: http.StatusBadRequest,
Code: ErrCodeInvalidTaskFilterConcatinator,
Message: fmt.Sprintf("The task filter concatinator '%s' is invalid.", err.Concatinator),
}
}
// =================
// Namespace errors
// =================
@ -1251,3 +1278,31 @@ func (err ErrBucketDoesNotBelongToList) HTTPError() web.HTTPError {
Message: "This bucket does not belong to that list.",
}
}
// ErrCannotRemoveLastBucket represents an error where a kanban bucket is the last on a list and thus cannot be removed.
type ErrCannotRemoveLastBucket struct {
BucketID int64
ListID int64
}
// IsErrCannotRemoveLastBucket checks if an error is ErrCannotRemoveLastBucket.
func IsErrCannotRemoveLastBucket(err error) bool {
_, ok := err.(ErrCannotRemoveLastBucket)
return ok
}
func (err ErrCannotRemoveLastBucket) Error() string {
return fmt.Sprintf("Cannot remove last bucket of list [BucketID: %d, ListID: %d]", err.BucketID, err.ListID)
}
// ErrCodeCannotRemoveLastBucket holds the unique world-error code of this error
const ErrCodeCannotRemoveLastBucket = 10003
// HTTPError holds the http error description
func (err ErrCannotRemoveLastBucket) HTTPError() web.HTTPError {
return web.HTTPError{
HTTPCode: http.StatusPreconditionFailed,
Code: ErrCodeCannotRemoveLastBucket,
Message: "You cannot remove the last bucket on this list.",
}
}

View File

@ -17,10 +17,10 @@
package models
import (
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/timeutil"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/web"
"time"
)
// Bucket represents a kanban bucket
@ -64,6 +64,15 @@ func getBucketByID(id int64) (b *Bucket, err error) {
return
}
func getDefaultBucket(listID int64) (bucket *Bucket, err error) {
bucket = &Bucket{}
_, err = x.
Where("list_id = ?", listID).
OrderBy("id asc").
Get(bucket)
return
}
// ReadAll returns all buckets with their tasks for a certain list
// @Summary Get all kanban buckets of a list
// @Description Returns all kanban buckets with belong to a list including their tasks.
@ -81,23 +90,7 @@ func (b *Bucket) ReadAll(auth web.Auth, search string, page int, perPage int) (r
// I'll probably just don't do it and instead make individual tasks archivable.
// Get all buckets for this list
buckets := []*Bucket{
{
// This is the default bucket for all tasks which are not associated to a bucket.
ID: 0,
Title: "Not associated to a bucket",
ListID: b.ListID,
Created: timeutil.FromTime(time.Now()),
Updated: timeutil.FromTime(time.Now()),
CreatedByID: auth.GetID(),
},
}
buckets[0].CreatedBy, err = user.GetFromAuth(auth)
if err != nil {
return
}
buckets := []*Bucket{}
err = x.Where("list_id = ?", b.ListID).Find(&buckets)
if err != nil {
return
@ -123,7 +116,15 @@ func (b *Bucket) ReadAll(auth web.Auth, search string, page int, perPage int) (r
}
// Get all tasks for this list
tasks, _, _, err := getTasksForLists([]*List{{ID: b.ListID}}, &taskOptions{})
opts := &taskOptions{
sortby: []*sortParam{
{
sortBy: taskPropertyPosition,
orderBy: orderAscending,
},
},
}
tasks, _, _, err := getTasksForLists([]*List{{ID: b.ListID}}, opts)
if err != nil {
return
}
@ -132,6 +133,11 @@ func (b *Bucket) ReadAll(auth web.Auth, search string, page int, perPage int) (r
// All tasks which are not associated to any bucket will have bucket id 0 which is the nil value for int64
// Since we created a bucked with that id at the beginning, all tasks should be in there.
for _, task := range tasks {
// Check if the bucket exists in the map to prevent nil pointer panics
if _, exists := bucketMap[task.BucketID]; !exists {
log.Debugf("Tried to put task %d into bucket %d which does not exist in list %d", task.ID, task.BucketID, b.ListID)
continue
}
bucketMap[task.BucketID].Tasks = append(bucketMap[task.BucketID].Tasks, task)
}
@ -181,7 +187,7 @@ func (b *Bucket) Update() (err error) {
// Delete removes a bucket, but no tasks
// @Summary Deletes an existing bucket
// @Description Deletes an existing kanban bucket and dissociates all of its task. It does not delete any tasks.
// @Description Deletes an existing kanban bucket and dissociates all of its task. It does not delete any tasks. You cannot delete the last bucket on a list.
// @tags task
// @Accept json
// @Produce json
@ -193,13 +199,34 @@ func (b *Bucket) Update() (err error) {
// @Failure 500 {object} models.Message "Internal error"
// @Router /lists/{listID}/buckets/{bucketID} [delete]
func (b *Bucket) Delete() (err error) {
// Remove all associations of tasks to that bucket
_, err = x.Where("bucket_id = ?", b.ID).Cols("bucket_id").Update(&Task{BucketID: 0})
// Prevent removing the last bucket
total, err := x.Where("list_id = ?", b.ListID).Count(&Bucket{})
if err != nil {
return
}
if total <= 1 {
return ErrCannotRemoveLastBucket{
BucketID: b.ID,
ListID: b.ListID,
}
}
// Remove the bucket itself
_, err = x.Where("id = ?", b.ID).Delete(&Bucket{})
if err != nil {
return
}
// Get the default bucket
defaultBucket, err := getDefaultBucket(b.ListID)
if err != nil {
return
}
// Remove all associations of tasks to that bucket
_, err = x.Where("bucket_id = ?", b.ID).Cols("bucket_id").Update(&Task{BucketID: defaultBucket.ID})
if err != nil {
return
}
return
}

View File

@ -38,30 +38,55 @@ func TestBucket_ReadAll(t *testing.T) {
assert.Equal(t, testuser.ID, buckets[0].CreatedBy.ID)
assert.Equal(t, testuser.ID, buckets[1].CreatedBy.ID)
assert.Equal(t, testuser.ID, buckets[2].CreatedBy.ID)
assert.Equal(t, testuser.ID, buckets[3].CreatedBy.ID)
// Assert our three test buckets + one for all tasks without a bucket
assert.Len(t, buckets, 4)
// Assert our three test buckets
assert.Len(t, buckets, 3)
// Assert all tasks are in the right bucket
assert.Len(t, buckets[0].Tasks, 10)
assert.Len(t, buckets[1].Tasks, 2)
assert.Len(t, buckets[0].Tasks, 12)
assert.Len(t, buckets[1].Tasks, 3)
assert.Len(t, buckets[2].Tasks, 3)
assert.Len(t, buckets[3].Tasks, 3)
// Assert we have bucket 0, 1, 2, 3 but not 4 (that belongs to a different list)
assert.Equal(t, int64(1), buckets[1].ID)
assert.Equal(t, int64(2), buckets[2].ID)
assert.Equal(t, int64(3), buckets[3].ID)
assert.Equal(t, int64(1), buckets[0].ID)
assert.Equal(t, int64(2), buckets[1].ID)
assert.Equal(t, int64(3), buckets[2].ID)
// Kinda assert all tasks are in the right buckets
assert.Equal(t, int64(0), buckets[0].Tasks[0].BucketID)
assert.Equal(t, int64(1), buckets[1].Tasks[0].BucketID)
assert.Equal(t, int64(1), buckets[1].Tasks[1].BucketID)
assert.Equal(t, int64(2), buckets[2].Tasks[0].BucketID)
assert.Equal(t, int64(2), buckets[2].Tasks[1].BucketID)
assert.Equal(t, int64(2), buckets[2].Tasks[2].BucketID)
assert.Equal(t, int64(3), buckets[3].Tasks[0].BucketID)
assert.Equal(t, int64(3), buckets[3].Tasks[1].BucketID)
assert.Equal(t, int64(3), buckets[3].Tasks[2].BucketID)
assert.Equal(t, int64(1), buckets[0].Tasks[0].BucketID)
assert.Equal(t, int64(1), buckets[0].Tasks[1].BucketID)
assert.Equal(t, int64(2), buckets[1].Tasks[0].BucketID)
assert.Equal(t, int64(2), buckets[1].Tasks[1].BucketID)
assert.Equal(t, int64(2), buckets[1].Tasks[2].BucketID)
assert.Equal(t, int64(3), buckets[2].Tasks[0].BucketID)
assert.Equal(t, int64(3), buckets[2].Tasks[1].BucketID)
assert.Equal(t, int64(3), buckets[2].Tasks[2].BucketID)
}
func TestBucket_Delete(t *testing.T) {
t.Run("normal", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
b := &Bucket{
ID: 2, // The second bucket only has 3 tasks
ListID: 1,
}
err := b.Delete()
assert.NoError(t, err)
// Assert all tasks have been moved to bucket 1 as that one is the first
tasks := []*Task{}
err = x.Where("bucket_id = ?", 1).Find(&tasks)
assert.NoError(t, err)
assert.Len(t, tasks, 15)
})
t.Run("last bucket in list", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
b := &Bucket{
ID: 34,
ListID: 18,
}
err := b.Delete()
assert.Error(t, err)
assert.True(t, IsErrCannotRemoveLastBucket(err))
})
}

View File

@ -209,15 +209,11 @@ func getLabelsByTaskIDs(opts *LabelByTaskIDsOptions) (ls []*labelWithTaskID, res
}
// Get the total number of entries
condCount := builder.And(builder.In("label_task.task_id", opts.TaskIDs), builder.NotNull{"label_task.label_id"})
if opts.GetUnusedLabels {
condCount = builder.Or(cond, builder.Eq{"labels.created_by_id": opts.User.ID})
}
totalEntries, err = x.Table("labels").
Select("count(DISTINCT labels.id)").
Join("LEFT", "label_task", "label_task.label_id = labels.id").
Where(condCount).
Where(cond).
And("labels.title LIKE ?", "%"+opts.Search+"%").
GroupBy(groupBy).
Count(&Label{})
if err != nil {
return nil, 0, 0, err

View File

@ -99,10 +99,16 @@ func GetLinkShareFromClaims(claims jwt.MapClaims) (share *LinkSharing, err error
// @Failure 500 {object} models.Message "Internal error"
// @Router /lists/{list}/shares [put]
func (share *LinkSharing) Create(a web.Auth) (err error) {
err = share.Right.isValid()
if err != nil {
return
}
share.SharedByID = a.GetID()
share.Hash = utils.MakeRandomString(40)
_, err = x.Insert(share)
share.SharedBy, _ = a.(*user.User)
share.SharedBy, _ = user.GetFromAuth(a)
return
}

View File

@ -53,9 +53,16 @@ func (share *LinkSharing) canDoLinkShare(a web.Auth) (bool, error) {
return false, nil
}
l, err := GetListSimplByTaskID(share.ListID)
l := &List{ID: share.ListID}
err := l.GetSimpleByID()
if err != nil {
return false, err
}
// Check if the user is admin when the link right is admin
if share.Right == RightAdmin {
return l.IsAdmin(a)
}
return l.CanWrite(a)
}

View File

@ -17,11 +17,14 @@
package models
import (
"code.vikunja.io/api/pkg/files"
"code.vikunja.io/api/pkg/metrics"
"code.vikunja.io/api/pkg/timeutil"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/web"
"strings"
"xorm.io/builder"
"xorm.io/xorm"
)
// List represents a list of tasks
@ -49,6 +52,11 @@ type List struct {
// Whether or not a list is archived.
IsArchived bool `xorm:"not null default false" json:"is_archived" query:"is_archived"`
// The id of the file this list has set as background
BackgroundFileID int64 `xorm:"null" json:"-"`
// Holds extra information about the background set since some background providers require attribution or similar. If not null, the background can be accessed at /lists/{listID}/background
BackgroundInformation interface{} `xorm:"-" json:"background_information"`
// A timestamp when this list was created. You cannot change this value.
Created timeutil.TimeStamp `xorm:"created not null" json:"created"`
// A timestamp when this list was last updated. You cannot change this value.
@ -164,6 +172,16 @@ func (l *List) ReadOne() (err error) {
l.IsArchived = true
}
}
// Get any background information if there is one set
if l.BackgroundFileID != 0 {
// Currently unsplash only
l.BackgroundInformation, err = GetUnsplashPhotoByFileID(l.BackgroundFileID)
if err != nil && !files.IsErrFileIsNotUnsplashFile(err) {
return
}
}
return nil
}
@ -351,6 +369,43 @@ func (l *List) CheckIsArchived() (err error) {
return nil
}
// GenerateListIdentifier generates a unique random list identifier based on the list title.
// If it is not able to find one, the list identifier will be empty.
func GenerateListIdentifier(l *List, sess *xorm.Engine) (err error) {
// The general idea here is to take the title and slice it into pieces, until we found a unique piece.
var exists = true
titleSlug := strings.Replace(strings.ToUpper(l.Title), " ", "", -1)
// We can save at most 10 characters in the db, so we need to ensure it has at most 10 characters
if len(titleSlug) > 10 {
titleSlug = titleSlug[0:9]
}
var i = 0
for exists {
// Prevent endless looping
if i == len(titleSlug) {
break
}
// Take a random part of the title slug, starting at the beginning
l.Identifier = titleSlug[i:]
exists, err = sess.
Where("identifier = ?", l.Identifier).
And("id != ?", l.ID).
Exist(&List{})
if err != nil {
return
}
i++
}
return nil
}
// CreateOrUpdateList updates a list or creates it if it doesn't exist
func CreateOrUpdateList(list *List) (err error) {
@ -376,6 +431,14 @@ func CreateOrUpdateList(list *List) (err error) {
}
}
// Generate a random list identifier base on the list title
if list.ID == 0 && list.Identifier == "" {
err = GenerateListIdentifier(list, x)
if err != nil {
return
}
}
if list.ID == 0 {
_, err = x.Insert(list)
metrics.UpdateCount(1, metrics.ListCountKey)
@ -384,13 +447,11 @@ func CreateOrUpdateList(list *List) (err error) {
colsToUpdate := []string{
"title",
"is_archived",
"identifier",
}
if list.Description != "" {
colsToUpdate = append(colsToUpdate, "description")
}
if list.Identifier != "" {
colsToUpdate = append(colsToUpdate, "identifier")
}
if list.HexColor != "" {
colsToUpdate = append(colsToUpdate, "hex_color")
}
@ -477,7 +538,17 @@ func (l *List) Create(a web.Auth) (err error) {
l.Owner = doer
l.ID = 0 // Otherwise only the first time a new list would be created
return CreateOrUpdateList(l)
err = CreateOrUpdateList(l)
if err != nil {
return
}
// Create a new first bucket for this list
b := &Bucket{
ListID: l.ID,
Title: "New Bucket",
}
return b.Create(a)
}
// Delete implements the delete method of CRUDable
@ -505,3 +576,16 @@ func (l *List) Delete() (err error) {
_, err = x.Where("list_id = ?", l.ID).Delete(&Task{})
return
}
// SetListBackground sets a background file as list background in the db
func SetListBackground(listID int64, background *files.File) (err error) {
l := &List{
ID: listID,
BackgroundFileID: background.ID,
}
_, err = x.
Where("id = ?", l.ID).
Cols("background_file_id").
Update(l)
return
}

View File

@ -235,11 +235,11 @@ func (lu *ListUser) Update() (err error) {
}
// Check if the user exists
user, err := user.GetUserByUsername(lu.Username)
u, err := user.GetUserByUsername(lu.Username)
if err != nil {
return err
}
lu.UserID = user.ID
lu.UserID = u.ID
_, err = x.
Where("list_id = ? AND user_id = ?", lu.ListID, lu.UserID).

View File

@ -31,7 +31,7 @@ type Namespace struct {
// The unique, numeric id of this namespace.
ID int64 `xorm:"int(11) autoincr not null unique pk" json:"id" param:"namespace"`
// The name of this namespace.
Name string `xorm:"varchar(250) not null" json:"name" valid:"required,runelength(5|250)" minLength:"5" maxLength:"250"`
Title string `xorm:"varchar(250) not null" json:"title" valid:"required,runelength(5|250)" minLength:"5" maxLength:"250"`
// The description of the namespace
Description string `xorm:"longtext null" json:"description"`
OwnerID int64 `xorm:"int(11) not null INDEX" json:"-"`
@ -57,7 +57,7 @@ type Namespace struct {
// PseudoNamespace is a pseudo namespace used to hold shared lists
var PseudoNamespace = Namespace{
ID: -1,
Name: "Shared Lists",
Title: "Shared Lists",
Description: "Lists of other users shared with you via teams or directly.",
Created: timeutil.FromTime(time.Now()),
Updated: timeutil.FromTime(time.Now()),
@ -201,7 +201,7 @@ func (n *Namespace) ReadAll(a web.Auth, search string, page int, perPage int) (r
Or("namespaces.owner_id = ?", doer.ID).
Or("users_namespace.user_id = ?", doer.ID).
GroupBy("namespaces.id").
Where("namespaces.name LIKE ?", "%"+search+"%").
Where("namespaces.title LIKE ?", "%"+search+"%").
Where(isArchivedCond)
if limit > 0 {
query = query.Limit(limit, start)
@ -211,30 +211,19 @@ func (n *Namespace) ReadAll(a web.Auth, search string, page int, perPage int) (r
return all, 0, 0, err
}
// Get all users
users := []*user.User{}
err = x.Select("users.*").
Table("namespaces").
Join("LEFT", "team_namespaces", "namespaces.id = team_namespaces.namespace_id").
Join("LEFT", "team_members", "team_members.team_id = team_namespaces.team_id").
Join("LEFT", "users_namespace", "users_namespace.namespace_id = namespaces.id").
Join("INNER", "users", "users.id = namespaces.owner_id").
Where("team_members.user_id = ?", doer.ID).
Or("namespaces.owner_id = ?", doer.ID).
Or("users_namespace.user_id = ?", doer.ID).
And("namespaces.is_archived = ?", n.IsArchived).
Where(isArchivedCond).
GroupBy("users.id").
Find(&users)
if err != nil {
return all, 0, 0, err
}
// Make a list of namespace ids
var namespaceids []int64
var userIDs []int64
for _, nsp := range all {
namespaceids = append(namespaceids, nsp.ID)
userIDs = append(userIDs, nsp.OwnerID)
}
// Get all owners
userMap := make(map[int64]*user.User)
err = x.In("id", userIDs).Find(&userMap)
if err != nil {
return all, 0, 0, err
}
// Get all lists
@ -287,24 +276,19 @@ func (n *Namespace) ReadAll(a web.Auth, search string, page int, perPage int) (r
return nil, 0, 0, err
}
nMap := make(map[int64]*NamespaceWithLists, len(all))
// Put objects in our namespace list
// TODO: Refactor this to use maps for better efficiency
for i, n := range all {
for _, n := range all {
// Users
for _, u := range users {
if n.OwnerID == u.ID {
all[i].Owner = u
break
}
}
n.Owner = userMap[n.OwnerID]
// List infos
for _, l := range lists {
if n.ID == l.NamespaceID {
all[i].Lists = append(all[i].Lists, l)
}
}
nMap[n.ID] = n
}
for _, list := range lists {
nMap[list.NamespaceID].Lists = append(nMap[list.NamespaceID].Lists, list)
}
numberOfTotalItems, err = x.
@ -317,7 +301,7 @@ func (n *Namespace) ReadAll(a web.Auth, search string, page int, perPage int) (r
Or("users_namespace.user_id = ?", doer.ID).
And("namespaces.is_archived = false").
GroupBy("namespaces.id").
Where("namespaces.name LIKE ?", "%"+search+"%").
Where("namespaces.title LIKE ?", "%"+search+"%").
Count(&NamespaceWithLists{})
if err != nil {
return all, 0, 0, err
@ -341,7 +325,7 @@ func (n *Namespace) ReadAll(a web.Auth, search string, page int, perPage int) (r
// @Router /namespaces [put]
func (n *Namespace) Create(a web.Auth) (err error) {
// Check if we have at least a name
if n.Name == "" {
if n.Title == "" {
return ErrNamespaceNameCannotBeEmpty{NamespaceID: 0, UserID: a.GetID()}
}
n.ID = 0 // This would otherwise prevent the creation of new lists after one was created
@ -434,7 +418,7 @@ func (n *Namespace) Delete() (err error) {
// @Router /namespace/{id} [post]
func (n *Namespace) Update() (err error) {
// Check if we have at least a name
if n.Name == "" {
if n.Title == "" {
return ErrNamespaceNameCannotBeEmpty{NamespaceID: n.ID}
}

View File

@ -220,7 +220,7 @@ func (tn *TeamNamespace) Update() (err error) {
}
_, err = x.
Where("namespace_id = ? AND team_id = ?", tn.TeamID, tn.TeamID).
Where("namespace_id = ? AND team_id = ?", tn.NamespaceID, tn.TeamID).
Cols("right").
Update(tn)
return

View File

@ -32,7 +32,7 @@ func TestNamespace_Create(t *testing.T) {
// Dummy namespace
dummynamespace := Namespace{
Name: "Test",
Title: "Test",
Description: "Lorem Ipsum",
}
@ -52,7 +52,7 @@ func TestNamespace_Create(t *testing.T) {
assert.True(t, allowed)
err = dummynamespace.ReadOne()
assert.NoError(t, err)
assert.Equal(t, dummynamespace.Name, "Test")
assert.Equal(t, dummynamespace.Title, "Test")
// Try creating one without a name
n2 := Namespace{}
@ -92,13 +92,13 @@ func TestNamespace_Create(t *testing.T) {
assert.True(t, user.IsErrUserDoesNotExist(err))
// Try updating without a name
dummynamespace.Name = ""
dummynamespace.Title = ""
err = dummynamespace.Update()
assert.Error(t, err)
assert.True(t, IsErrNamespaceNameCannotBeEmpty(err))
// Try updating a nonexistant one
n := Namespace{ID: 284729, Name: "Lorem"}
n := Namespace{ID: 284729, Title: "Lorem"}
err = n.Update()
assert.Error(t, err)
assert.True(t, IsErrNamespaceDoesNotExist(err))

View File

@ -43,6 +43,8 @@ type TaskCollection struct {
// The comparator for field and value
FilterComparator []string `query:"filter_comparator"`
FilterComparatorArr []string `query:"filter_comparator[]"`
// The way all filter conditions are concatenated together, can be either "and" or "or".,
FilterConcat string `query:"filter_concat"`
web.CRUDable `xorm:"-" json:"-"`
web.Rights `xorm:"-" json:"-"`
@ -52,7 +54,7 @@ func validateTaskField(fieldName string) error {
switch fieldName {
case
taskPropertyID,
taskPropertyText,
taskPropertyTitle,
taskPropertyDescription,
taskPropertyDone,
taskPropertyDoneAtUnix,
@ -67,7 +69,8 @@ func validateTaskField(fieldName string) error {
taskPropertyPercentDone,
taskPropertyUID,
taskPropertyCreated,
taskPropertyUpdated:
taskPropertyUpdated,
taskPropertyPosition:
return nil
}
return ErrInvalidTaskField{TaskField: fieldName}
@ -84,11 +87,12 @@ func validateTaskField(fieldName string) error {
// @Param page query int false "The page number. Used for pagination. If not provided, the first page of results is returned."
// @Param per_page query int false "The maximum number of items per page. Note this parameter is limited by the configured maximum of items per page."
// @Param s query string false "Search tasks by task text."
// @Param sort_by query string false "The sorting parameter. You can pass this multiple times to get the tasks ordered by multiple different parametes, along with `order_by`. Possible values to sort by are `id`, `text`, `description`, `done`, `done_at_unix`, `due_date_unix`, `created_by_id`, `list_id`, `repeat_after`, `priority`, `start_date_unix`, `end_date_unix`, `hex_color`, `percent_done`, `uid`, `created`, `updated`. Default is `id`."
// @Param sort_by query string false "The sorting parameter. You can pass this multiple times to get the tasks ordered by multiple different parametes, along with `order_by`. Possible values to sort by are `id`, `title`, `description`, `done`, `done_at_unix`, `due_date_unix`, `created_by_id`, `list_id`, `repeat_after`, `priority`, `start_date_unix`, `end_date_unix`, `hex_color`, `percent_done`, `uid`, `created`, `updated`. Default is `id`."
// @Param order_by query string false "The ordering parameter. Possible values to order by are `asc` or `desc`. Default is `asc`."
// @Param filter_by query string false "The name of the field to filter by. Accepts an array for multiple filters which will be chanied together, all supplied filter must match."
// @Param filter_value query string false "The value to filter for."
// @Param filter_comparator query string false "The comparator to use for a filter. Available values are `equals`, `greater`, `greater_equals`, `less` and `less_equals`. Defaults to `equals`"
// @Param filter_concat query string false "The concatinator to use for filters. Available values are `and` or `or`. Defaults to `or`."
// @Security JWTKeyAuth
// @Success 200 {array} models.Task "The tasks"
// @Failure 500 {object} models.Message "Internal error"
@ -106,7 +110,7 @@ func (tf *TaskCollection) ReadAll(a web.Auth, search string, page int, perPage i
var sort = make([]*sortParam, 0, len(tf.SortBy))
for i, s := range tf.SortBy {
param := &sortParam{
sortBy: sortProperty(s),
sortBy: s,
orderBy: orderAscending,
}
// This checks if tf.OrderBy has an entry with the same index as the current entry from tf.SortBy
@ -114,6 +118,20 @@ func (tf *TaskCollection) ReadAll(a web.Auth, search string, page int, perPage i
if len(tf.OrderBy) > i {
param.orderBy = getSortOrderFromString(tf.OrderBy[i])
}
// Special case for pseudo date fields
// FIXME: This is really dirty, to fix this properly the db fields should be renamed
switch param.sortBy {
case "done_at":
param.sortBy = taskPropertyDoneAtUnix
case "due_date":
param.sortBy = taskPropertyDueDateUnix
case "start_date":
param.sortBy = taskPropertyStartDateUnix
case "end_date":
param.sortBy = taskPropertyEndDateUnix
}
// Param validation
if err := param.validate(); err != nil {
return nil, 0, 0, err
@ -122,10 +140,11 @@ func (tf *TaskCollection) ReadAll(a web.Auth, search string, page int, perPage i
}
taskopts := &taskOptions{
search: search,
page: page,
perPage: perPage,
sortby: sort,
search: search,
page: page,
perPage: perPage,
sortby: sort,
filterConcat: taskFilterConcatinator(tf.FilterConcat),
}
taskopts.filters, err = getTaskFiltersByCollections(tf)

View File

@ -53,7 +53,13 @@ func getTaskFiltersByCollections(c *TaskCollection) (filters []*taskFilter, err
}
if len(c.FilterComparatorArr) > 0 {
c.FilterValue = append(c.FilterValue, c.FilterComparatorArr...)
c.FilterComparator = append(c.FilterComparator, c.FilterComparatorArr...)
}
if c.FilterConcat != "" && c.FilterConcat != filterConcatAnd && c.FilterConcat != filterConcatOr {
return nil, ErrInvalidTaskFilterConcatinator{
Concatinator: taskFilterConcatinator(c.FilterConcat),
}
}
filters = make([]*taskFilter, 0, len(c.FilterBy))
@ -84,7 +90,7 @@ func getTaskFiltersByCollections(c *TaskCollection) (filters []*taskFilter, err
}
// Special case for pseudo date fields
// FIXME: This is really dirty, to fix this the db fields should be renamed
// FIXME: This is really dirty, to fix this properly the db fields should be renamed
if filter.field+"_unix" == taskPropertyDoneAtUnix ||
filter.field+"_unix" == taskPropertyDueDateUnix ||
filter.field+"_unix" == taskPropertyStartDateUnix ||

View File

@ -16,27 +16,18 @@
package models
import (
"code.vikunja.io/api/pkg/timeutil"
"fmt"
"reflect"
"sort"
)
type (
sortParam struct {
sortBy sortProperty
sortBy string
orderBy sortOrder // asc or desc
}
sortProperty string
sortOrder string
)
const (
taskPropertyID string = "id"
taskPropertyText string = "text"
taskPropertyTitle string = "title"
taskPropertyDescription string = "description"
taskPropertyDone string = "done"
taskPropertyDoneAtUnix string = "done_at_unix"
@ -52,12 +43,9 @@ const (
taskPropertyUID string = "uid"
taskPropertyCreated string = "created"
taskPropertyUpdated string = "updated"
taskPropertyPosition string = "position"
)
func (p sortProperty) String() string {
return string(p)
}
const (
orderInvalid sortOrder = "invalid"
orderAscending sortOrder = "asc"
@ -82,139 +70,5 @@ func (sp *sortParam) validate() error {
if sp.orderBy != orderDescending && sp.orderBy != orderAscending {
return ErrInvalidSortOrder{OrderBy: sp.orderBy}
}
return validateTaskField(string(sp.sortBy))
}
type taskComparator func(lhs, rhs *Task) int64
func mustMakeComparator(fieldName string) taskComparator {
field, ok := reflect.TypeOf(&Task{}).Elem().FieldByName(fieldName)
if !ok {
panic(fmt.Sprintf("Field '%s' has not been found on Task", fieldName))
}
extractProp := func(task *Task) interface{} {
return reflect.ValueOf(task).Elem().FieldByIndex(field.Index).Interface()
}
// Special case for handling TimeStamp types
if field.Type.Name() == "TimeStamp" {
return func(lhs, rhs *Task) int64 {
return int64(extractProp(lhs).(timeutil.TimeStamp)) - int64(extractProp(rhs).(timeutil.TimeStamp))
}
}
switch field.Type.Kind() {
case reflect.Int64:
return func(lhs, rhs *Task) int64 {
return extractProp(lhs).(int64) - extractProp(rhs).(int64)
}
case reflect.Float64:
return func(lhs, rhs *Task) int64 {
floatLHS, floatRHS := extractProp(lhs).(float64), extractProp(rhs).(float64)
if floatLHS > floatRHS {
return 1
} else if floatLHS < floatRHS {
return -1
}
return 0
}
case reflect.String:
return func(lhs, rhs *Task) int64 {
strLHS, strRHS := extractProp(lhs).(string), extractProp(rhs).(string)
if strLHS > strRHS {
return 1
} else if strLHS < strRHS {
return -1
}
return 0
}
case reflect.Bool:
return func(lhs, rhs *Task) int64 {
boolLHS, boolRHS := extractProp(lhs).(bool), extractProp(rhs).(bool)
if !boolLHS && boolRHS {
return -1
} else if boolLHS && !boolRHS {
return 1
}
return 0
}
default:
panic(fmt.Sprintf("Unsupported type for sorting: %s", field.Type.Name()))
}
}
// This is a map of properties that can be sorted by
// and their appropriate comparator function.
// The comparator function sorts in ascending mode.
var propertyComparators = map[string]taskComparator{
taskPropertyID: mustMakeComparator("ID"),
taskPropertyText: mustMakeComparator("Text"),
taskPropertyDescription: mustMakeComparator("Description"),
taskPropertyDone: mustMakeComparator("Done"),
taskPropertyDoneAtUnix: mustMakeComparator("DoneAt"),
taskPropertyDueDateUnix: mustMakeComparator("DueDate"),
taskPropertyCreatedByID: mustMakeComparator("CreatedByID"),
taskPropertyListID: mustMakeComparator("ListID"),
taskPropertyRepeatAfter: mustMakeComparator("RepeatAfter"),
taskPropertyPriority: mustMakeComparator("Priority"),
taskPropertyStartDateUnix: mustMakeComparator("StartDate"),
taskPropertyEndDateUnix: mustMakeComparator("EndDate"),
taskPropertyHexColor: mustMakeComparator("HexColor"),
taskPropertyPercentDone: mustMakeComparator("PercentDone"),
taskPropertyUID: mustMakeComparator("UID"),
taskPropertyCreated: mustMakeComparator("Created"),
taskPropertyUpdated: mustMakeComparator("Updated"),
}
// Creates a taskComparator that sorts by the first comparator and falls back to
// the second one (and so on...) if the properties were equal.
func combineComparators(comparators ...taskComparator) taskComparator {
return func(lhs, rhs *Task) int64 {
for _, compare := range comparators {
res := compare(lhs, rhs)
if res != 0 {
return res
}
}
return 0
}
}
func sortTasks(tasks []*Task, by []*sortParam) {
// Always sort at least by id asc so we have a consistent order of items every time
// If we would not do this, we would get a different order for items with the same content every time
// the slice is sorted. To circumvent this, we always order at least by ID.
if len(by) == 0 ||
(len(by) > 0 && by[len(by)-1].sortBy != sortProperty(taskPropertyID)) { // Don't sort by ID last if the id parameter is already passed as the last parameter.
by = append(by, &sortParam{sortBy: sortProperty(taskPropertyID), orderBy: orderAscending})
}
comparators := make([]taskComparator, 0, len(by))
for _, param := range by {
comparator, ok := propertyComparators[string(param.sortBy)]
if !ok {
panic("No suitable comparator for sortBy found! Param was " + param.sortBy)
}
// This is a descending sort, so we need to negate the comparator (i.e. switch the inputs).
if param.orderBy == orderDescending {
oldComparator := comparator
comparator = func(lhs, rhs *Task) int64 {
return oldComparator(lhs, rhs) * -1
}
}
comparators = append(comparators, comparator)
}
combinedComparator := combineComparators(comparators...)
sort.Slice(tasks, func(i, j int) bool {
lhs, rhs := tasks[i], tasks[j]
res := combinedComparator(lhs, rhs)
return res <= 0
})
return validateTaskField(sp.sortBy)
}

View File

@ -17,10 +17,7 @@
package models
import (
"github.com/mohae/deepcopy"
"github.com/stretchr/testify/assert"
"math/rand"
"reflect"
"testing"
)
@ -46,7 +43,7 @@ func TestSortParamValidation(t *testing.T) {
t.Run("Test valid sort by", func(t *testing.T) {
for _, test := range []string{
taskPropertyID,
taskPropertyText,
taskPropertyTitle,
taskPropertyDescription,
taskPropertyDone,
taskPropertyDoneAtUnix,
@ -62,11 +59,12 @@ func TestSortParamValidation(t *testing.T) {
taskPropertyUID,
taskPropertyCreated,
taskPropertyUpdated,
taskPropertyPosition,
} {
t.Run(test, func(t *testing.T) {
s := &sortParam{
orderBy: orderAscending,
sortBy: sortProperty(test),
sortBy: test,
}
err := s.validate()
assert.NoError(t, err)
@ -92,774 +90,3 @@ func TestSortParamValidation(t *testing.T) {
assert.True(t, IsErrInvalidTaskField(err))
})
}
var (
task1 = &Task{
ID: 1,
Text: "aaa",
Description: "Lorem Ipsum",
Done: true,
DoneAt: 1543626000,
ListID: 1,
UID: "JywtBPCESImlyKugvaZWrxmXAFAWXFISMeXYImEh",
Created: 1543626724,
Updated: 1543626724,
}
task2 = &Task{
ID: 2,
Text: "bbb",
Description: "Arem Ipsum",
Done: true,
DoneAt: 1543626724,
CreatedByID: 1,
ListID: 2,
PercentDone: 0.3,
StartDate: 1543626724,
Created: 1553626724,
Updated: 1553626724,
}
task3 = &Task{
ID: 3,
Text: "ccc",
DueDate: 1583626724,
Priority: 100,
ListID: 3,
HexColor: "000000",
PercentDone: 0.1,
Updated: 1555555555,
}
task4 = &Task{
ID: 4,
Text: "ddd",
Priority: 1,
StartDate: 1643626724,
ListID: 1,
}
task5 = &Task{
ID: 5,
Text: "eef",
Priority: 50,
UID: "shggzCHQWLhGNMNsOGOCOjcVkInOYjTAnORqTkdL",
DueDate: 1543636724,
Updated: 1565555555,
}
task6 = &Task{
ID: 6,
Text: "eef",
DueDate: 1543616724,
RepeatAfter: 6400,
CreatedByID: 2,
HexColor: "ffffff",
}
task7 = &Task{
ID: 7,
Text: "mmmn",
Description: "Zoremis",
StartDate: 1544600000,
EndDate: 1584600000,
UID: "tyzCZuLMSKhwclJOsDyDcUdyVAPBDOPHNTBOLTcW",
}
task8 = &Task{
ID: 8,
Text: "b123",
EndDate: 1544700000,
}
task9 = &Task{
ID: 9,
Done: true,
DoneAt: 1573626724,
Text: "a123",
RepeatAfter: 86000,
StartDate: 1544600000,
EndDate: 1544700000,
}
task10 = &Task{
ID: 10,
Text: "zzz",
Priority: 10,
PercentDone: 1,
}
)
type taskSortTestCase struct {
name string
wantAsc []*Task
wantDesc []*Task
sortProperty string
}
var taskSortTestCases = []taskSortTestCase{
{
name: "id",
sortProperty: taskPropertyID,
wantAsc: []*Task{
task1,
task2,
task3,
task4,
task5,
task6,
task7,
task8,
task9,
task10,
},
wantDesc: []*Task{
task10,
task9,
task8,
task7,
task6,
task5,
task4,
task3,
task2,
task1,
},
},
{
name: "text",
sortProperty: taskPropertyText,
wantAsc: []*Task{
task9,
task1,
task8,
task2,
task3,
task4,
task5,
task6,
task7,
task10,
},
wantDesc: []*Task{
task10,
task7,
task5,
task6,
task4,
task3,
task2,
task8,
task1,
task9,
},
},
{
name: "description",
sortProperty: taskPropertyDescription,
wantAsc: []*Task{
task3,
task4,
task5,
task6,
task8,
task9,
task10,
task2,
task1,
task7,
},
wantDesc: []*Task{
task7,
task1,
task2,
task3,
task4,
task5,
task6,
task8,
task9,
task10,
},
},
{
name: "done",
sortProperty: taskPropertyDone,
wantAsc: []*Task{
// These are not
task3,
task4,
task5,
task6,
task7,
task8,
task10,
// These are done
task1,
task2,
task9,
},
wantDesc: []*Task{
// These are done
task1,
task2,
task9,
// These are not
task3,
task4,
task5,
task6,
task7,
task8,
task10,
},
},
{
name: "done at",
sortProperty: taskPropertyDoneAtUnix,
wantAsc: []*Task{
task3,
task4,
task5,
task6,
task7,
task8,
task10,
task1,
task2,
task9,
},
wantDesc: []*Task{
task9,
task2,
task1,
task3,
task4,
task5,
task6,
task7,
task8,
task10,
},
},
{
name: "due date",
sortProperty: taskPropertyDueDateUnix,
wantAsc: []*Task{
task1,
task2,
task4,
task7,
task8,
task9,
task10,
task6,
task5,
task3,
},
wantDesc: []*Task{
task3,
task5,
task6,
task1,
task2,
task4,
task7,
task8,
task9,
task10,
},
},
{
name: "created by id",
sortProperty: taskPropertyCreatedByID,
wantAsc: []*Task{
task1,
task3,
task4,
task5,
task7,
task8,
task9,
task10,
task2,
task6,
},
wantDesc: []*Task{
task6,
task2,
task1,
task3,
task4,
task5,
task7,
task8,
task9,
task10,
},
},
{
name: "list id",
sortProperty: taskPropertyListID,
wantAsc: []*Task{
task5,
task6,
task7,
task8,
task9,
task10,
task1,
task4,
task2,
task3,
},
wantDesc: []*Task{
task3,
task2,
task1,
task4,
task5,
task6,
task7,
task8,
task9,
task10,
},
},
{
name: "repeat after",
sortProperty: taskPropertyRepeatAfter,
wantAsc: []*Task{
task1,
task2,
task3,
task4,
task5,
task7,
task8,
task10,
task6,
task9,
},
wantDesc: []*Task{
task9,
task6,
task1,
task2,
task3,
task4,
task5,
task7,
task8,
task10,
},
},
{
name: "priority",
sortProperty: taskPropertyPriority,
wantAsc: []*Task{
task1,
task2,
task6,
task7,
task8,
task9,
task4,
task10,
task5,
task3,
},
wantDesc: []*Task{
task3,
task5,
task10,
task4,
task1,
task2,
task6,
task7,
task8,
task9,
},
},
{
name: "start date",
sortProperty: taskPropertyStartDateUnix,
wantAsc: []*Task{
task1,
task3,
task5,
task6,
task8,
task10,
task2,
task7,
task9,
task4,
},
wantDesc: []*Task{
task4,
task7,
task9,
task2,
task1,
task3,
task5,
task6,
task8,
task10,
},
},
{
name: "end date",
sortProperty: taskPropertyEndDateUnix,
wantAsc: []*Task{
task1,
task2,
task3,
task4,
task5,
task6,
task10,
task8,
task9,
task7,
},
wantDesc: []*Task{
task7,
task8,
task9,
task1,
task2,
task3,
task4,
task5,
task6,
task10,
},
},
{
name: "hex color",
sortProperty: taskPropertyHexColor,
wantAsc: []*Task{
task1,
task2,
task4,
task5,
task7,
task8,
task9,
task10,
task3,
task6,
},
wantDesc: []*Task{
task6,
task3,
task1,
task2,
task4,
task5,
task7,
task8,
task9,
task10,
},
},
{
name: "percent done",
sortProperty: taskPropertyPercentDone,
wantAsc: []*Task{
task1,
task4,
task5,
task6,
task7,
task8,
task9,
task3,
task2,
task10,
},
wantDesc: []*Task{
task10,
task2,
task3,
task1,
task4,
task5,
task6,
task7,
task8,
task9,
},
},
{
name: "uid",
sortProperty: taskPropertyUID,
wantAsc: []*Task{
task2,
task3,
task4,
task6,
task8,
task9,
task10,
task1,
task5,
task7,
},
wantDesc: []*Task{
task7,
task5,
task1,
task2,
task3,
task4,
task6,
task8,
task9,
task10,
},
},
{
name: "created",
sortProperty: taskPropertyCreated,
wantAsc: []*Task{
task3,
task4,
task5,
task6,
task7,
task8,
task9,
task10,
task1,
task2,
},
wantDesc: []*Task{
task2,
task1,
task3,
task4,
task5,
task6,
task7,
task8,
task9,
task10,
},
},
{
name: "updated",
sortProperty: taskPropertyUpdated,
wantAsc: []*Task{
task4,
task6,
task7,
task8,
task9,
task10,
task1,
task2,
task3,
task5,
},
wantDesc: []*Task{
task5,
task3,
task2,
task1,
task4,
task6,
task7,
task8,
task9,
task10,
},
},
}
func TestTaskSort(t *testing.T) {
assertTestSliceMatch := func(t *testing.T, got, want []*Task) {
if !reflect.DeepEqual(got, want) {
t.Error("Slices do not match in order")
t.Error("Got\t| Want")
for in, task := range got {
fail := ""
if task.ID != want[in].ID {
fail = "wrong"
}
t.Errorf("\t%d\t| %d \t%s", task.ID, want[in].ID, fail)
}
}
}
for _, testCase := range taskSortTestCases {
t.Run(testCase.name, func(t *testing.T) {
t.Run("asc default", func(t *testing.T) {
by := []*sortParam{
{
sortBy: sortProperty(testCase.sortProperty),
},
}
got := deepcopy.Copy(testCase.wantAsc).([]*Task)
// Destroy wanted order to obtain some slice we can sort
rand.Shuffle(len(got), func(i, j int) {
got[i], got[j] = got[j], got[i]
})
sortTasks(got, by)
assertTestSliceMatch(t, got, testCase.wantAsc)
})
t.Run("asc", func(t *testing.T) {
by := []*sortParam{
{
sortBy: sortProperty(testCase.sortProperty),
orderBy: orderAscending,
},
}
got := deepcopy.Copy(testCase.wantAsc).([]*Task)
// Destroy wanted order to obtain some slice we can sort
rand.Shuffle(len(got), func(i, j int) {
got[i], got[j] = got[j], got[i]
})
sortTasks(got, by)
assertTestSliceMatch(t, got, testCase.wantAsc)
})
t.Run("desc", func(t *testing.T) {
by := []*sortParam{
{
sortBy: sortProperty(testCase.sortProperty),
orderBy: orderDescending,
},
}
got := deepcopy.Copy(testCase.wantDesc).([]*Task)
// Destroy wanted order to obtain some slice we can sort
rand.Shuffle(len(got), func(i, j int) {
got[i], got[j] = got[j], got[i]
})
sortTasks(got, by)
assertTestSliceMatch(t, got, testCase.wantDesc)
})
})
}
// Other cases
t.Run("Order by Done Ascending and ID Descending", func(t *testing.T) {
want := []*Task{
// Not done
task10,
task8,
task7,
task6,
task5,
task4,
task3,
// Done
task9,
task2,
task1,
}
sortParams := []*sortParam{
{
sortBy: sortProperty(taskPropertyDone),
orderBy: orderAscending,
},
{
sortBy: sortProperty(taskPropertyID),
orderBy: orderDescending,
},
}
got := deepcopy.Copy(want).([]*Task)
// Destroy wanted order to obtain some slice we can sort
rand.Shuffle(len(got), func(i, j int) {
got[i], got[j] = got[j], got[i]
})
sortTasks(got, sortParams)
assertTestSliceMatch(t, got, want)
})
t.Run("Order by Done Ascending and Text Descending", func(t *testing.T) {
want := []*Task{
// Not done
task10,
task7,
task5,
task6,
task4,
task3,
task8,
// Done
task2,
task1,
task9,
}
sortParams := []*sortParam{
{
sortBy: sortProperty(taskPropertyDone),
orderBy: orderAscending,
},
{
sortBy: sortProperty(taskPropertyText),
orderBy: orderDescending,
},
}
got := deepcopy.Copy(want).([]*Task)
// Destroy wanted order to obtain some slice we can sort
rand.Shuffle(len(got), func(i, j int) {
got[i], got[j] = got[j], got[i]
})
sortTasks(got, sortParams)
assertTestSliceMatch(t, got, want)
})
t.Run("Order by Done Descending and Text Ascending", func(t *testing.T) {
want := []*Task{
// Done
task9,
task1,
task2,
// Not done
task8,
task3,
task4,
task5,
task6,
task7,
task10,
}
sortParams := []*sortParam{
{
sortBy: sortProperty(taskPropertyDone),
orderBy: orderDescending,
},
{
sortBy: sortProperty(taskPropertyText),
orderBy: orderAscending,
},
}
got := deepcopy.Copy(want).([]*Task)
// Destroy wanted order to obtain some slice we can sort
rand.Shuffle(len(got), func(i, j int) {
got[i], got[j] = got[j], got[i]
})
sortTasks(got, sortParams)
assertTestSliceMatch(t, got, want)
})
}

View File

@ -49,7 +49,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
// We use individual variables for the tasks here to be able to rearrange or remove ones more easily
task1 := &Task{
ID: 1,
Text: "task #1",
Title: "task #1",
Description: "Lorem Ipsum",
Identifier: "test1-1",
Index: 1,
@ -71,10 +71,11 @@ func TestTaskCollection_ReadAll(t *testing.T) {
RelationKindSubtask: {
{
ID: 29,
Text: "task #29 with parent task (1)",
Title: "task #29 with parent task (1)",
Index: 14,
CreatedByID: 1,
ListID: 1,
BucketID: 1,
Created: 1543626724,
Updated: 1543626724,
},
@ -108,7 +109,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
}
task2 := &Task{
ID: 2,
Text: "task #2 done",
Title: "task #2 done",
Identifier: "test1-2",
Index: 2,
Done: true,
@ -132,7 +133,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
}
task3 := &Task{
ID: 3,
Text: "task #3 high prio",
Title: "task #3 high prio",
Identifier: "test1-3",
Index: 3,
CreatedByID: 1,
@ -146,7 +147,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
}
task4 := &Task{
ID: 4,
Text: "task #4 low prio",
Title: "task #4 low prio",
Identifier: "test1-4",
Index: 4,
CreatedByID: 1,
@ -160,7 +161,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
}
task5 := &Task{
ID: 5,
Text: "task #5 higher due date",
Title: "task #5 higher due date",
Identifier: "test1-5",
Index: 5,
CreatedByID: 1,
@ -174,7 +175,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
}
task6 := &Task{
ID: 6,
Text: "task #6 lower due date",
Title: "task #6 lower due date",
Identifier: "test1-6",
Index: 6,
CreatedByID: 1,
@ -188,7 +189,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
}
task7 := &Task{
ID: 7,
Text: "task #7 with start date",
Title: "task #7 with start date",
Identifier: "test1-7",
Index: 7,
CreatedByID: 1,
@ -202,7 +203,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
}
task8 := &Task{
ID: 8,
Text: "task #8 with end date",
Title: "task #8 with end date",
Identifier: "test1-8",
Index: 8,
CreatedByID: 1,
@ -216,13 +217,14 @@ func TestTaskCollection_ReadAll(t *testing.T) {
}
task9 := &Task{
ID: 9,
Text: "task #9 with start and end date",
Title: "task #9 with start and end date",
Identifier: "test1-9",
Index: 9,
CreatedByID: 1,
CreatedBy: user1,
ListID: 1,
RelatedTasks: map[RelationKind][]*Task{},
BucketID: 1,
Created: 1543626724,
Updated: 1543626724,
StartDate: 1544600000,
@ -230,200 +232,216 @@ func TestTaskCollection_ReadAll(t *testing.T) {
}
task10 := &Task{
ID: 10,
Text: "task #10 basic",
Title: "task #10 basic",
Identifier: "test1-10",
Index: 10,
CreatedByID: 1,
CreatedBy: user1,
ListID: 1,
RelatedTasks: map[RelationKind][]*Task{},
BucketID: 1,
Created: 1543626724,
Updated: 1543626724,
}
task11 := &Task{
ID: 11,
Text: "task #11 basic",
Title: "task #11 basic",
Identifier: "test1-11",
Index: 11,
CreatedByID: 1,
CreatedBy: user1,
ListID: 1,
RelatedTasks: map[RelationKind][]*Task{},
BucketID: 1,
Created: 1543626724,
Updated: 1543626724,
}
task12 := &Task{
ID: 12,
Text: "task #12 basic",
Title: "task #12 basic",
Identifier: "test1-12",
Index: 12,
CreatedByID: 1,
CreatedBy: user1,
ListID: 1,
RelatedTasks: map[RelationKind][]*Task{},
BucketID: 1,
Created: 1543626724,
Updated: 1543626724,
}
task15 := &Task{
ID: 15,
Text: "task #15",
Title: "task #15",
Identifier: "test6-1",
Index: 1,
CreatedByID: 6,
CreatedBy: user6,
ListID: 6,
RelatedTasks: map[RelationKind][]*Task{},
BucketID: 6,
Created: 1543626724,
Updated: 1543626724,
}
task16 := &Task{
ID: 16,
Text: "task #16",
Title: "task #16",
Identifier: "test7-1",
Index: 1,
CreatedByID: 6,
CreatedBy: user6,
ListID: 7,
RelatedTasks: map[RelationKind][]*Task{},
BucketID: 7,
Created: 1543626724,
Updated: 1543626724,
}
task17 := &Task{
ID: 17,
Text: "task #17",
Title: "task #17",
Identifier: "test8-1",
Index: 1,
CreatedByID: 6,
CreatedBy: user6,
ListID: 8,
RelatedTasks: map[RelationKind][]*Task{},
BucketID: 8,
Created: 1543626724,
Updated: 1543626724,
}
task18 := &Task{
ID: 18,
Text: "task #18",
Title: "task #18",
Identifier: "test9-1",
Index: 1,
CreatedByID: 6,
CreatedBy: user6,
ListID: 9,
RelatedTasks: map[RelationKind][]*Task{},
BucketID: 9,
Created: 1543626724,
Updated: 1543626724,
}
task19 := &Task{
ID: 19,
Text: "task #19",
Title: "task #19",
Identifier: "test10-1",
Index: 1,
CreatedByID: 6,
CreatedBy: user6,
ListID: 10,
RelatedTasks: map[RelationKind][]*Task{},
BucketID: 10,
Created: 1543626724,
Updated: 1543626724,
}
task20 := &Task{
ID: 20,
Text: "task #20",
Title: "task #20",
Identifier: "test11-1",
Index: 1,
CreatedByID: 6,
CreatedBy: user6,
ListID: 11,
RelatedTasks: map[RelationKind][]*Task{},
BucketID: 11,
Created: 1543626724,
Updated: 1543626724,
}
task21 := &Task{
ID: 21,
Text: "task #21",
Title: "task #21",
Identifier: "test12-1",
Index: 1,
CreatedByID: 6,
CreatedBy: user6,
ListID: 12,
RelatedTasks: map[RelationKind][]*Task{},
BucketID: 12,
Created: 1543626724,
Updated: 1543626724,
}
task22 := &Task{
ID: 22,
Text: "task #22",
Title: "task #22",
Identifier: "test13-1",
Index: 1,
CreatedByID: 6,
CreatedBy: user6,
ListID: 13,
RelatedTasks: map[RelationKind][]*Task{},
BucketID: 13,
Created: 1543626724,
Updated: 1543626724,
}
task23 := &Task{
ID: 23,
Text: "task #23",
Title: "task #23",
Identifier: "test14-1",
Index: 1,
CreatedByID: 6,
CreatedBy: user6,
ListID: 14,
RelatedTasks: map[RelationKind][]*Task{},
BucketID: 14,
Created: 1543626724,
Updated: 1543626724,
}
task24 := &Task{
ID: 24,
Text: "task #24",
Title: "task #24",
Identifier: "test15-1",
Index: 1,
CreatedByID: 6,
CreatedBy: user6,
ListID: 15,
RelatedTasks: map[RelationKind][]*Task{},
BucketID: 15,
Created: 1543626724,
Updated: 1543626724,
}
task25 := &Task{
ID: 25,
Text: "task #25",
Title: "task #25",
Identifier: "test16-1",
Index: 1,
CreatedByID: 6,
CreatedBy: user6,
ListID: 16,
RelatedTasks: map[RelationKind][]*Task{},
BucketID: 16,
Created: 1543626724,
Updated: 1543626724,
}
task26 := &Task{
ID: 26,
Text: "task #26",
Title: "task #26",
Identifier: "test17-1",
Index: 1,
CreatedByID: 6,
CreatedBy: user6,
ListID: 17,
RelatedTasks: map[RelationKind][]*Task{},
BucketID: 17,
Created: 1543626724,
Updated: 1543626724,
}
task27 := &Task{
ID: 27,
Text: "task #27 with reminders",
Title: "task #27 with reminders",
Identifier: "test1-12",
Index: 12,
CreatedByID: 1,
CreatedBy: user1,
Reminders: []timeutil.TimeStamp{1543626724, 1543626824},
ListID: 1,
BucketID: 1,
RelatedTasks: map[RelationKind][]*Task{},
Created: 1543626724,
Updated: 1543626724,
}
task28 := &Task{
ID: 28,
Text: "task #28 with repeat after",
Title: "task #28 with repeat after",
Identifier: "test1-13",
Index: 13,
CreatedByID: 1,
@ -431,12 +449,13 @@ func TestTaskCollection_ReadAll(t *testing.T) {
ListID: 1,
RelatedTasks: map[RelationKind][]*Task{},
RepeatAfter: 3600,
BucketID: 1,
Created: 1543626724,
Updated: 1543626724,
}
task29 := &Task{
ID: 29,
Text: "task #29 with parent task (1)",
Title: "task #29 with parent task (1)",
Identifier: "test1-14",
Index: 14,
CreatedByID: 1,
@ -446,7 +465,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
RelationKindParenttask: {
{
ID: 1,
Text: "task #1",
Title: "task #1",
Description: "Lorem Ipsum",
Index: 1,
CreatedByID: 1,
@ -457,12 +476,13 @@ func TestTaskCollection_ReadAll(t *testing.T) {
},
},
},
Created: 1543626724,
Updated: 1543626724,
BucketID: 1,
Created: 1543626724,
Updated: 1543626724,
}
task30 := &Task{
ID: 30,
Text: "task #30 with assignees",
Title: "task #30 with assignees",
Identifier: "test1-15",
Index: 15,
CreatedByID: 1,
@ -473,12 +493,13 @@ func TestTaskCollection_ReadAll(t *testing.T) {
user2,
},
RelatedTasks: map[RelationKind][]*Task{},
BucketID: 1,
Created: 1543626724,
Updated: 1543626724,
}
task31 := &Task{
ID: 31,
Text: "task #31 with color",
Title: "task #31 with color",
Identifier: "test1-16",
Index: 16,
HexColor: "f0f0f0",
@ -486,24 +507,26 @@ func TestTaskCollection_ReadAll(t *testing.T) {
CreatedBy: user1,
ListID: 1,
RelatedTasks: map[RelationKind][]*Task{},
BucketID: 1,
Created: 1543626724,
Updated: 1543626724,
}
task32 := &Task{
ID: 32,
Text: "task #32",
Title: "task #32",
Identifier: "test3-1",
Index: 1,
CreatedByID: 1,
CreatedBy: user1,
ListID: 3,
RelatedTasks: map[RelationKind][]*Task{},
BucketID: 21,
Created: 1543626724,
Updated: 1543626724,
}
task33 := &Task{
ID: 33,
Text: "task #33 with percent done",
Title: "task #33 with percent done",
Identifier: "test1-17",
Index: 17,
CreatedByID: 1,
@ -511,6 +534,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
ListID: 1,
PercentDone: 0.5,
RelatedTasks: map[RelationKind][]*Task{},
BucketID: 1,
Created: 1543626724,
Updated: 1543626724,
}

View File

@ -17,6 +17,7 @@
package models
import (
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/files"
"code.vikunja.io/api/pkg/metrics"
"code.vikunja.io/api/pkg/timeutil"
@ -24,10 +25,11 @@ import (
"code.vikunja.io/api/pkg/utils"
"code.vikunja.io/web"
"github.com/imdario/mergo"
"sort"
"math"
"strconv"
"time"
"xorm.io/builder"
"xorm.io/xorm/schemas"
)
// Task represents an task in a todolist
@ -35,7 +37,7 @@ type Task struct {
// The unique, numeric id of this task.
ID int64 `xorm:"int(11) autoincr not null unique pk" json:"id" param:"listtask"`
// The task text. This is what you'll see in the list.
Text string `xorm:"varchar(250) not null" json:"text" valid:"runelength(3|250)" minLength:"3" maxLength:"250"`
Title string `xorm:"varchar(250) not null" json:"title" valid:"runelength(3|250)" minLength:"3" maxLength:"250"`
// The task description.
Description string `xorm:"longtext null" json:"description"`
// Whether a task is done or not.
@ -88,6 +90,14 @@ type Task struct {
// BucketID is the ID of the kanban bucket this task belongs to.
BucketID int64 `xorm:"int(11) null" json:"bucket_id"`
// The position of the task - any task list can be sorted as usual by this parameter.
// When accessing tasks via kanban buckets, this is primarily used to sort them based on a range
// We're using a float64 here to make it possible to put any task within any two other tasks (by changing the number).
// You would calculate the new position between two tasks with something like task3.position = (task2.position - task1.position) / 2.
// A 64-Bit float leaves plenty of room to initially give tasks a position with 2^16 difference to the previous task
// which also leaves a lot of room for rearranging and sorting later.
Position float64 `xorm:"double null" json:"position"`
// The user who initially created the task.
CreatedBy *user.User `xorm:"-" json:"created_by" valid:"-"`
@ -113,12 +123,20 @@ func (TaskReminder) TableName() string {
return "task_reminders"
}
type taskFilterConcatinator string
const (
filterConcatAnd = "and"
filterConcatOr = "or"
)
type taskOptions struct {
search string
page int
perPage int
sortby []*sortParam
filters []*taskFilter
search string
page int
perPage int
sortby []*sortParam
filters []*taskFilter
filterConcat taskFilterConcatinator
}
// ReadAll is a dummy function to still have that endpoint documented
@ -135,6 +153,7 @@ type taskOptions struct {
// @Param filter_by query string false "The name of the field to filter by. Accepts an array for multiple filters which will be chanied together, all supplied filter must match."
// @Param filter_value query string false "The value to filter for."
// @Param filter_comparator query string false "The comparator to use for a filter. Available values are `equals`, `greater`, `greater_equals`, `less` and `less_equals`. Defaults to `equals`"
// @Param filter_concat query string false "The concatinator to use for filters. Available values are `and` or `or`. Defaults to `or`."
// @Security JWTKeyAuth
// @Success 200 {array} models.Task "The tasks"
// @Failure 500 {object} models.Message "Internal error"
@ -143,7 +162,17 @@ func (t *Task) ReadAll(a web.Auth, search string, page int, perPage int) (result
return nil, 0, 0, nil
}
func getRawTasksForLists(lists []*List, opts *taskOptions) (taskMap map[int64]*Task, resultCount int, totalItems int64, err error) {
func getRawTasksForLists(lists []*List, opts *taskOptions) (tasks []*Task, resultCount int, totalItems int64, err error) {
// If the user does not have any lists, don't try to get any tasks
if len(lists) == 0 {
return nil, 0, 0, nil
}
// Set the default concatinator of filter variables to or if none was provided
if opts.filterConcat == "" {
opts.filterConcat = filterConcatOr
}
// Get all list IDs and get the tasks
var listIDs []int64
@ -151,6 +180,15 @@ func getRawTasksForLists(lists []*List, opts *taskOptions) (taskMap map[int64]*T
listIDs = append(listIDs, l.ID)
}
// Add the id parameter as the last parameter to sorty by default, but only if it is not already passed as the last parameter.
if len(opts.sortby) == 0 ||
len(opts.sortby) > 0 && opts.sortby[len(opts.sortby)-1].sortBy != taskPropertyID {
opts.sortby = append(opts.sortby, &sortParam{
sortBy: taskPropertyID,
orderBy: orderAscending,
})
}
// Since xorm does not use placeholders for order by, it is possible to expose this with sql injection if we're directly
// passing user input to the db.
// As a workaround to prevent this, we check for valid column names here prior to passing it to the db.
@ -160,13 +198,26 @@ func getRawTasksForLists(lists []*List, opts *taskOptions) (taskMap map[int64]*T
if err := param.validate(); err != nil {
return nil, 0, 0, err
}
orderby += param.sortBy.String() + " " + param.orderBy.String()
orderby += param.sortBy + " " + param.orderBy.String()
// Postgres sorts by default entries with null values after ones with values.
// To make that consistent with the sort order we have and other dbms, we're adding a separate clause here.
if x.Dialect().URI().DBType == schemas.POSTGRES {
if param.orderBy == orderAscending {
orderby += " NULLS FIRST"
}
if param.orderBy == orderDescending {
orderby += " NULLS LAST"
}
}
if (i + 1) < len(opts.sortby) {
orderby += ", "
}
}
var filters = make([]builder.Cond, 0, len(opts.filters))
// To still find tasks with nil values, we exclude 0s when comparing with >/< values.
for _, f := range opts.filters {
switch f.comparator {
case taskFilterComparatorEquals:
@ -174,32 +225,50 @@ func getRawTasksForLists(lists []*List, opts *taskOptions) (taskMap map[int64]*T
case taskFilterComparatorNotEquals:
filters = append(filters, &builder.Neq{f.field: f.value})
case taskFilterComparatorGreater:
filters = append(filters, &builder.Gt{f.field: f.value})
filters = append(filters, builder.Or(&builder.Gt{f.field: f.value}, &builder.Eq{f.field: 0}))
case taskFilterComparatorGreateEquals:
filters = append(filters, &builder.Gte{f.field: f.value})
filters = append(filters, builder.Or(&builder.Gte{f.field: f.value}, &builder.Eq{f.field: 0}))
case taskFilterComparatorLess:
filters = append(filters, &builder.Lt{f.field: f.value})
filters = append(filters, builder.Or(&builder.Lt{f.field: f.value}, &builder.Eq{f.field: 0}))
case taskFilterComparatorLessEquals:
filters = append(filters, &builder.Lte{f.field: f.value})
filters = append(filters, builder.Or(&builder.Lte{f.field: f.value}, &builder.Eq{f.field: 0}))
}
}
taskMap = make(map[int64]*Task)
// Then return all tasks for that lists
query := x.
query := x.NewSession().
OrderBy(orderby)
queryCount := x.NewSession()
if len(opts.search) > 0 {
query = query.Where("text LIKE ?", "%"+opts.search+"%")
// Postgres' is case sensitive by default.
// To work around this, we're using ILIKE as opposed to normal LIKE statements.
// ILIKE is preferred over LOWER(text) LIKE for performance reasons.
// See https://stackoverflow.com/q/7005302/10924593
// Seems okay to use that now, we may need to find a better solution overall in the future.
if config.DatabaseType.GetString() == "postgres" {
query = query.Where("title ILIKE ?", "%"+opts.search+"%")
queryCount = queryCount.Where("title ILIKE ?", "%"+opts.search+"%")
} else {
query = query.Where("title LIKE ?", "%"+opts.search+"%")
queryCount = queryCount.Where("title LIKE ?", "%"+opts.search+"%")
}
}
if len(listIDs) > 0 {
query = query.In("list_id", listIDs)
queryCount = queryCount.In("list_id", listIDs)
}
if len(filters) > 0 {
query = query.Where(builder.Or(filters...))
if opts.filterConcat == filterConcatOr {
query = query.Where(builder.Or(filters...))
queryCount = queryCount.Where(builder.Or(filters...))
}
if opts.filterConcat == filterConcatAnd {
query = query.Where(builder.And(filters...))
queryCount = queryCount.Where(builder.And(filters...))
}
}
limit, start := getLimitFromPageIndex(opts.page, opts.perPage)
@ -208,34 +277,37 @@ func getRawTasksForLists(lists []*List, opts *taskOptions) (taskMap map[int64]*T
query = query.Limit(limit, start)
}
err = query.Find(&taskMap)
tasks = []*Task{}
err = query.Find(&tasks)
if err != nil {
return nil, 0, 0, err
}
totalItems, err = query.
totalItems, err = queryCount.
Count(&Task{})
if err != nil {
return nil, 0, 0, err
}
return taskMap, len(taskMap), totalItems, nil
return tasks, len(tasks), totalItems, nil
}
func getTasksForLists(lists []*List, opts *taskOptions) (tasks []*Task, resultCount int, totalItems int64, err error) {
taskMap, resultCount, totalItems, err := getRawTasksForLists(lists, opts)
tasks, resultCount, totalItems, err = getRawTasksForLists(lists, opts)
if err != nil {
return nil, 0, 0, err
}
tasks, err = addMoreInfoToTasks(taskMap)
taskMap := make(map[int64]*Task, len(tasks))
for _, t := range tasks {
taskMap[t.ID] = t
}
err = addMoreInfoToTasks(taskMap)
if err != nil {
return nil, 0, 0, err
}
// Because the list is fully unsorted (since we're dealing with maps)
// we have to manually sort the tasks again here.
sortTasks(tasks, opts.sortby)
return tasks, resultCount, totalItems, err
}
@ -271,25 +343,28 @@ func (bt *BulkTask) GetTasksByIDs() (err error) {
}
}
taskMap := make(map[int64]*Task, len(bt.Tasks))
err = x.In("id", bt.IDs).Find(&taskMap)
err = x.In("id", bt.IDs).Find(&bt.Tasks)
if err != nil {
return
}
bt.Tasks, err = addMoreInfoToTasks(taskMap)
return
}
// GetTasksByUIDs gets all tasks from a bunch of uids
func GetTasksByUIDs(uids []string) (tasks []*Task, err error) {
taskMap := make(map[int64]*Task)
err = x.In("uid", uids).Find(&taskMap)
tasks = []*Task{}
err = x.In("uid", uids).Find(&tasks)
if err != nil {
return
}
tasks, err = addMoreInfoToTasks(taskMap)
taskMap := make(map[int64]*Task, len(tasks))
for _, t := range tasks {
taskMap[t.ID] = t
}
err = addMoreInfoToTasks(taskMap)
return
}
@ -301,7 +376,7 @@ func getRemindersForTasks(taskIDs []int64) (reminders []*TaskReminder, err error
// This function takes a map with pointers and returns a slice with pointers to tasks
// It adds more stuff like assignees/labels/etc to a bunch of tasks
func addMoreInfoToTasks(taskMap map[int64]*Task) (tasks []*Task, err error) {
func addMoreInfoToTasks(taskMap map[int64]*Task) (err error) {
// No need to iterate over users and stuff if the list doesn't has tasks
if len(taskMap) == 0 {
@ -351,7 +426,7 @@ func addMoreInfoToTasks(taskMap map[int64]*Task) (tasks []*Task, err error) {
In("task_id", taskIDs).
Find(&attachments)
if err != nil {
return nil, err
return
}
fileIDs := []int64{}
@ -446,19 +521,6 @@ func addMoreInfoToTasks(taskMap map[int64]*Task) (tasks []*Task, err error) {
taskMap[rt.TaskID].RelatedTasks[rt.RelationKind] = append(taskMap[rt.TaskID].RelatedTasks[rt.RelationKind], fullRelatedTasks[rt.OtherTaskID])
}
// make a complete slice from the map
tasks = []*Task{}
for _, t := range taskMap {
tasks = append(tasks, t)
}
// Sort the output. In Go, contents on a map are put on that map in no particular order.
// Because of this, tasks are not sorted anymore in the output, this leads to confiusion.
// To avoid all this, we need to sort the slice afterwards
sort.Slice(tasks, func(i, j int) bool {
return tasks[i].ID < tasks[j].ID
})
return
}
@ -497,7 +559,7 @@ func (t *Task) Create(a web.Auth) (err error) {
t.ID = 0
// Check if we have at least a text
if t.Text == "" {
if t.Title == "" {
return ErrTaskCannotBeEmpty{}
}
@ -507,9 +569,16 @@ func (t *Task) Create(a web.Auth) (err error) {
return
}
u, err := user.GetUserByID(a.GetID())
if err != nil {
return err
if _, is := a.(*LinkSharing); is {
// A negative user id indicates user share links
t.CreatedByID = a.GetID() * -1
} else {
u, err := user.GetUserByID(a.GetID())
if err != nil {
return err
}
t.CreatedByID = u.ID
t.CreatedBy = u
}
// Generate a uuid if we don't already have one
@ -523,6 +592,15 @@ func (t *Task) Create(a web.Auth) (err error) {
return
}
// Get the default bucket and move the task there
if t.BucketID == 0 {
defaultBucket, err := getDefaultBucket(t.ListID)
if err != nil {
return err
}
t.BucketID = defaultBucket.ID
}
// Get the index for this task
latestTask := &Task{}
_, err = x.Where("list_id = ?", t.ListID).OrderBy("id desc").Get(latestTask)
@ -531,8 +609,10 @@ func (t *Task) Create(a web.Auth) (err error) {
}
t.Index = latestTask.Index + 1
t.CreatedByID = u.ID
t.CreatedBy = u
// If no position was supplied, set a default one
if t.Position == 0 {
t.Position = float64(latestTask.ID+1) * math.Pow(2, 16)
}
if _, err = x.Insert(t); err != nil {
return err
}
@ -604,6 +684,15 @@ func (t *Task) Update() (err error) {
return
}
// If the task is being moved between lists, make sure to move the bucket as well
if t.ListID != 0 && ot.ListID != t.ListID {
b, err := getDefaultBucket(t.ListID)
if err != nil {
return err
}
t.BucketID = b.ID
}
// Update the labels
//
// Maybe FIXME:
@ -668,14 +757,13 @@ func (t *Task) Update() (err error) {
if t.PercentDone == 0 {
ot.PercentDone = 0
}
// Bucket ID
// Yes it is possible to move a task back into the empty bucket
if t.BucketID == 0 {
ot.BucketID = 0
// Position
if t.Position == 0 {
ot.Position = 0
}
_, err = x.ID(t.ID).
Cols("text",
Cols("title",
"description",
"done",
"due_date_unix",
@ -688,6 +776,7 @@ func (t *Task) Update() (err error) {
"percent_done",
"list_id",
"bucket_id",
"position",
).
Update(ot)
*t = ot
@ -877,16 +966,16 @@ func (t *Task) ReadOne() (err error) {
return
}
tasks, err := addMoreInfoToTasks(taskMap)
err = addMoreInfoToTasks(taskMap)
if err != nil {
return
}
if len(tasks) == 0 {
if len(taskMap) == 0 {
return ErrTaskDoesNotExist{t.ID}
}
*t = *tasks[0]
*t = *taskMap[t.ID]
return
}

View File

@ -36,7 +36,7 @@ func TestTask_Create(t *testing.T) {
t.Run("normal", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
task := &Task{
Text: "Lorem",
Title: "Lorem",
Description: "Lorem Ipsum Dolor",
ListID: 1,
}
@ -47,12 +47,14 @@ func TestTask_Create(t *testing.T) {
// Assert getting a new index
assert.NotEmpty(t, task.Index)
assert.Equal(t, int64(18), task.Index)
// Assert moving it into the default bucket
assert.Equal(t, int64(1), task.BucketID)
})
t.Run("empty text", func(t *testing.T) {
t.Run("empty title", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
task := &Task{
Text: "",
Title: "",
Description: "Lorem Ipsum Dolor",
ListID: 1,
}
@ -63,7 +65,7 @@ func TestTask_Create(t *testing.T) {
t.Run("nonexistant list", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
task := &Task{
Text: "Test",
Title: "Test",
Description: "Lorem Ipsum Dolor",
ListID: 9999999,
}
@ -75,7 +77,7 @@ func TestTask_Create(t *testing.T) {
db.LoadAndAssertFixtures(t)
nUser := &user.User{ID: 99999999}
task := &Task{
Text: "Test",
Title: "Test",
Description: "Lorem Ipsum Dolor",
ListID: 1,
}
@ -90,7 +92,7 @@ func TestTask_Update(t *testing.T) {
db.LoadAndAssertFixtures(t)
task := &Task{
ID: 1,
Text: "test10000",
Title: "test10000",
Description: "Lorem Ipsum Dolor",
ListID: 1,
}
@ -101,7 +103,7 @@ func TestTask_Update(t *testing.T) {
db.LoadAndAssertFixtures(t)
task := &Task{
ID: 9999999,
Text: "test10000",
Title: "test10000",
Description: "Lorem Ipsum Dolor",
ListID: 1,
}
@ -213,7 +215,7 @@ func TestTask_ReadOne(t *testing.T) {
task := &Task{ID: 1}
err := task.ReadOne()
assert.NoError(t, err)
assert.Equal(t, "task #1", task.Text)
assert.Equal(t, "task #1", task.Title)
})
t.Run("nonexisting", func(t *testing.T) {
db.LoadAndAssertFixtures(t)

64
pkg/models/unsplash.go Normal file
View File

@ -0,0 +1,64 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2020 Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package models
import "code.vikunja.io/api/pkg/files"
// Unsplash requires us to do pingbacks to their site and also name the image author.
// To do this properly, we need to save these information somewhere.
// UnsplashPhoto is an unsplash photo in the db
type UnsplashPhoto struct {
ID int64 `xorm:"autoincr not null unique pk" json:"id,omitempty"`
FileID int64 `xorm:"not null" json:"-"`
UnsplashID string `xorm:"varchar(50)" json:"unsplash_id"`
Author string `xorm:"text" json:"author"`
AuthorName string `xorm:"text" json:"author_name"`
}
// TableName contains the table name for an unsplash photo
func (u *UnsplashPhoto) TableName() string {
return "unsplash_photos"
}
// Save persists an unsplash photo to the db
func (u *UnsplashPhoto) Save() error {
_, err := x.Insert(u)
return err
}
// GetUnsplashPhotoByFileID returns an unsplash photo by its saved file id
func GetUnsplashPhotoByFileID(fileID int64) (u *UnsplashPhoto, err error) {
u = &UnsplashPhoto{}
exists, err := x.Where("file_id = ?", fileID).Get(u)
if err != nil {
return
}
if !exists {
return nil, files.ErrFileIsNotUnsplashFile{FileID: fileID}
}
return
}
// RemoveUnsplashPhoto removes an unsplash photo from the db
func RemoveUnsplashPhoto(fileID int64) (err error) {
// This is intentionally "fire and forget" which is why we don't check if we have an
// unsplash entry for that file at all. If there is one, it will be deleted.
// We do this to keep the function simple.
_, err = x.Where("file_id = ?", fileID).Delete(&UnsplashPhoto{})
return
}

View File

@ -0,0 +1,39 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2020 Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package background
import (
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/web"
)
// Image represents an image which can be used as a list background
type Image struct {
ID string `json:"id"`
URL string `json:"url"`
Thumb string `json:"thumb,omitempty"`
// This can be used to supply extra information from an image provider to clients
Info interface{} `json:"info,omitempty"`
}
// Provider represents something that is able to get a list of images and set one of them as background
type Provider interface {
// Search is used to either return a pre-defined list of Image or let the user search for an image
Search(search string, page int64) (result []*Image, err error)
// Set sets an image which was most likely previously obtained by Search as list background
Set(image *Image, list *models.List, auth web.Auth) (err error)
}

View File

@ -0,0 +1,161 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2020 Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package handler
import (
"code.vikunja.io/api/pkg/files"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/background"
"code.vikunja.io/api/pkg/modules/background/unsplash"
v1 "code.vikunja.io/api/pkg/routes/api/v1"
"code.vikunja.io/web/handler"
"github.com/labstack/echo/v4"
"net/http"
"strconv"
)
// BackgroundProvider represents a thing which holds a background provider
// Lets us get a new fresh provider every time we need one.
type BackgroundProvider struct {
Provider func() background.Provider
}
// SearchBackgrounds is the web handler to search for backgrounds
func (bp *BackgroundProvider) SearchBackgrounds(c echo.Context) error {
p := bp.Provider()
err := c.Bind(p)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "No or invalid model provided: "+err.Error())
}
search := c.QueryParam("s")
var page int64 = 1
pg := c.QueryParam("p")
if pg != "" {
page, err = strconv.ParseInt(pg, 10, 64)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid page number: "+err.Error())
}
}
result, err := p.Search(search, page)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "An error occurred: "+err.Error())
}
return c.JSON(http.StatusOK, result)
}
// SetBackground sets an Image as list background
func (bp *BackgroundProvider) SetBackground(c echo.Context) error {
auth, err := v1.GetAuthFromClaims(c)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid auth token: "+err.Error())
}
p := bp.Provider()
listID, err := strconv.ParseInt(c.Param("list"), 10, 64)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid list ID: "+err.Error())
}
// Check if the user has the right to change the list background
list := &models.List{ID: listID}
can, err := list.CanUpdate(auth)
if err != nil {
return handler.HandleHTTPError(err, c)
}
if !can {
log.Infof("Tried to update list background of list %d while not having the rights for it (User: %v)", listID, auth)
return echo.NewHTTPError(http.StatusForbidden)
}
// Load the list
if err := list.GetSimpleByID(); err != nil {
return handler.HandleHTTPError(err, c)
}
image := &background.Image{}
err = c.Bind(image)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "No or invalid model provided: "+err.Error())
}
err = p.Set(image, list, auth)
if err != nil {
return handler.HandleHTTPError(err, c)
}
return c.JSON(http.StatusOK, list)
}
// GetListBackground serves a previously set background from a list
// It has no knowledge of the provider that was responsible for setting the background.
// @Summary Get the list background
// @Description Get the list background of a specific list. **Returns json on error.**
// @tags list
// @Produce octet-stream
// @Param id path int true "List ID"
// @Security JWTKeyAuth
// @Success 200 {} string "The list background file."
// @Failure 403 {object} models.Message "No access to this list."
// @Failure 404 {object} models.Message "The list does not exist."
// @Failure 500 {object} models.Message "Internal error"
// @Router /lists/{id}/background [get]
func GetListBackground(c echo.Context) error {
auth, err := v1.GetAuthFromClaims(c)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid auth token: "+err.Error())
}
listID, err := strconv.ParseInt(c.Param("list"), 10, 64)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid list ID: "+err.Error())
}
// Check if a background for this list exists + Rights
list := &models.List{ID: listID}
can, err := list.CanRead(auth)
if err != nil {
return handler.HandleHTTPError(err, c)
}
if !can {
log.Infof("Tried to get list background of list %d while not having the rights for it (User: %v)", listID, auth)
return echo.NewHTTPError(http.StatusForbidden)
}
if list.BackgroundFileID == 0 {
return echo.NotFoundHandler(c)
}
// Get the file
bgFile := &files.File{
ID: list.BackgroundFileID,
}
if err := bgFile.LoadFileByID(); err != nil {
return handler.HandleHTTPError(err, c)
}
// Unsplash requires pingbacks as per their api usage guidelines.
// To do this in a privacy-preserving manner, we do the ping from inside of Vikunja to not expose any user details.
// FIXME: This should use an event once we have events
unsplash.Pingback(bgFile)
// Serve the file
return c.Stream(http.StatusOK, "image/jpg", bgFile.File)
}

View File

@ -0,0 +1,68 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2020 Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package unsplash
import (
"github.com/labstack/echo/v4"
"net/http"
)
// ProxyUnsplashImage proxies a thumbnail from unsplash for privacy reasons.
// @Summary Get an unsplash image
// @Description Get an unsplash image. **Returns json on error.**
// @tags list
// @Produce octet-stream
// @Param thumb path int true "Unsplash Image ID"
// @Security JWTKeyAuth
// @Success 200 {} string "The image"
// @Failure 404 {object} models.Message "The image does not exist."
// @Failure 500 {object} models.Message "Internal error"
// @Router /backgrounds/unsplash/image/{image} [get]
func ProxyUnsplashImage(c echo.Context) error {
imageID := c.Param("image")
resp, err := http.Get("https://images.unsplash.com/photo-" + imageID + "?ixlib=rb-1.2.1&fm=jpg&ixid=eyJhcHBfaWQiOjcyODAwfQ")
if err != nil {
return err
}
if resp.StatusCode > 399 {
return echo.ErrNotFound
}
return c.Stream(http.StatusOK, "image/jpg", resp.Body)
}
// ProxyUnsplashThumb proxies a thumbnail from unsplash for privacy reasons.
// @Summary Get an unsplash thumbnail image
// @Description Get an unsplash thumbnail image. The thumbnail is cropped to a max width of 200px. **Returns json on error.**
// @tags list
// @Produce octet-stream
// @Param thumb path int true "Unsplash Image ID"
// @Security JWTKeyAuth
// @Success 200 {} string "The thumbnail"
// @Failure 404 {object} models.Message "The image does not exist."
// @Failure 500 {object} models.Message "Internal error"
// @Router /backgrounds/unsplash/image/{image}/thumb [get]
func ProxyUnsplashThumb(c echo.Context) error {
imageID := c.Param("image")
resp, err := http.Get("https://images.unsplash.com/photo-" + imageID + "?ixlib=rb-1.2.1&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=200&fit=max&ixid=eyJhcHBfaWQiOjcyODAwfQ")
if err != nil {
return err
}
if resp.StatusCode > 399 {
return echo.ErrNotFound
}
return c.Stream(http.StatusOK, "image/jpg", resp.Body)
}

View File

@ -0,0 +1,265 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2020 Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package unsplash
import (
"bytes"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/files"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/background"
"code.vikunja.io/web"
"encoding/json"
"net/http"
"strconv"
"strings"
)
// Provider represents an unsplash image provider
type Provider struct {
}
// SearchResult is a search result from unsplash's api
type SearchResult struct {
Total int `json:"total"`
TotalPages int `json:"total_pages"`
Results []*Photo `json:"results"`
}
// Photo represents an unpslash photo as returned by their api
type Photo struct {
ID string `json:"id"`
CreatedAt string `json:"created_at"`
Width int `json:"width"`
Height int `json:"height"`
Color string `json:"color"`
Description string `json:"description"`
User struct {
Username string `json:"username"`
Name string `json:"name"`
} `json:"user"`
Urls struct {
Raw string `json:"raw"`
Full string `json:"full"`
Regular string `json:"regular"`
Small string `json:"small"`
Thumb string `json:"thumb"`
} `json:"urls"`
Links struct {
Self string `json:"self"`
HTML string `json:"html"`
Download string `json:"download"`
} `json:"links"`
}
// Very simple caching method - pretty much only used to retain information when saving an image
// FIXME: Should use a proper cache
var photos map[string]*Photo
func init() {
photos = make(map[string]*Photo)
}
func doGet(url string, result interface{}) (err error) {
req, err := http.NewRequest("GET", "https://api.unsplash.com/"+url, nil)
if err != nil {
return
}
req.Header.Add("Authorization", "Client-ID "+config.BackgroundsUnsplashAccessToken.GetString())
hc := http.Client{}
resp, err := hc.Do(req)
if err != nil {
return
}
err = json.NewDecoder(resp.Body).Decode(result)
return
}
func getImageID(fullURL string) string {
// Unsplash image urls have the form
// https://images.unsplash.com/photo-1590622878565-c662a7fd1394?ixlib=rb-1.2.1&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=200&fit=max&ixid=eyJhcHBfaWQiOjcyODAwfQ
// We only need the "photo-*" part of it.
parts := strings.Split(strings.Split(fullURL, "?")[0], "photo-")
if len(parts) < 2 {
log.Errorf("Unsplash thumb url does not contain enough parts [parts: %v]", parts)
return ""
}
return parts[1]
}
// Search is the implementation to search on unsplash
// @Summary Search for a background from unsplash
// @Description Search for a list background from unsplash
// @tags list
// @Produce json
// @Security JWTKeyAuth
// @Param s query string false "Search backgrounds from unsplash with this search term."
// @Param p query int false "The page number. Used for pagination. If not provided, the first page of results is returned."
// @Success 200 {array} background.Image "An array with photos"
// @Failure 500 {object} models.Message "Internal error"
// @Router /backgrounds/unsplash/search [get]
func (p *Provider) Search(search string, page int64) (result []*background.Image, err error) {
// If we don't have a search query, return results from the unsplash featured collection
if search == "" {
collectionResult := []*Photo{}
err = doGet("collections/317099/photos?page="+strconv.FormatInt(page, 10)+"&per_page=25&order_by=latest", &collectionResult)
if err != nil {
return
}
result = []*background.Image{}
for _, p := range collectionResult {
result = append(result, &background.Image{
ID: p.ID,
URL: getImageID(p.Urls.Raw),
Info: &models.UnsplashPhoto{
UnsplashID: p.ID,
Author: p.User.Username,
AuthorName: p.User.Name,
},
})
photos[p.ID] = p
}
return
}
searchResult := &SearchResult{}
err = doGet("search/photos?query="+search+"&page="+strconv.FormatInt(page, 10)+"&per_page=25", &searchResult)
if err != nil {
return
}
result = []*background.Image{}
for _, p := range searchResult.Results {
result = append(result, &background.Image{
ID: p.ID,
URL: getImageID(p.Urls.Raw),
Info: &models.UnsplashPhoto{
UnsplashID: p.ID,
Author: p.User.Username,
AuthorName: p.User.Name,
},
})
photos[p.ID] = p
}
return
}
// Set sets an unsplash photo as list background
// @Summary Set an unsplash photo as list background
// @Description Sets a photo from unsplash as list background.1
// @tags list
// @Accept json
// @Produce json
// @Security JWTKeyAuth
// @Param id path int true "List ID"
// @Param list body background.Image true "The image you want to set as background"
// @Success 200 {object} models.List "The background has been successfully set."
// @Failure 400 {object} code.vikunja.io/web.HTTPError "Invalid image object provided."
// @Failure 403 {object} code.vikunja.io/web.HTTPError "The user does not have access to the list"
// @Failure 500 {object} models.Message "Internal error"
// @Router /lists/{id}/backgrounds/unsplash [post]
func (p *Provider) Set(image *background.Image, list *models.List, auth web.Auth) (err error) {
// Find the photo
var photo *Photo
var exists bool
photo, exists = photos[image.ID]
if !exists {
log.Debugf("Image information for unsplash photo %s not cached, requesting from unsplash...", image.ID)
photo = &Photo{}
err = doGet("photos/"+image.ID, photo)
if err != nil {
return
}
}
// Download the photo from unsplash
// The parameters crop the image to a max width of 2560 and a max height of 2048 to save bandwidth and storage.
resp, err := http.Get(photo.Urls.Raw + "&w=2560&h=2048&q=90")
if err != nil {
return
}
defer resp.Body.Close()
if resp.StatusCode > 399 {
b := bytes.Buffer{}
_, _ = b.ReadFrom(resp.Body)
log.Errorf("Error getting unsplash photo %s: Request failed with status %d, message was %s", photo.ID, resp.StatusCode, b.String())
return
}
log.Debugf("Downloaded Unsplash Photo %s", image.ID)
// Save it as a file in vikunja
file, err := files.Create(resp.Body, "", 0, auth)
if err != nil {
return
}
// Remove the old background if one exists
if list.BackgroundFileID != 0 {
file := files.File{ID: list.BackgroundFileID}
if err := file.Delete(); err != nil {
return err
}
if err := models.RemoveUnsplashPhoto(list.BackgroundFileID); err != nil {
return err
}
}
// Save the relation that we got it from unsplash
unsplashPhoto := &models.UnsplashPhoto{
FileID: file.ID,
UnsplashID: image.ID,
Author: photo.User.Username,
AuthorName: photo.User.Name,
}
err = unsplashPhoto.Save()
if err != nil {
return
}
log.Debugf("Saved unsplash photo %s as file %d with new entry %d", image.ID, file.ID, unsplashPhoto.ID)
// Set the file in the list
list.BackgroundFileID = file.ID
list.BackgroundInformation = unsplashPhoto
// Set it as the list background
return models.SetListBackground(list.ID, file)
}
// Pingback pings the unsplash api if an unsplash photo has been accessed.
func Pingback(f *files.File) {
// Check if the file is actually downloaded from unsplash
unsplashPhoto, err := models.GetUnsplashPhotoByFileID(f.ID)
if err != nil {
log.Errorf("Unsplash Pingback: %s", err.Error())
}
// Do the ping
if _, err := http.Get("https://views.unsplash.com/v?app_id=" + config.BackgroundsUnsplashApplicationID.GetString() + "&photo_id=" + unsplashPhoto.UnsplashID); err != nil {
log.Errorf("Unsplash Pingback Failed: %s", err.Error())
}
log.Debugf("Pinged unsplash for photo %s", unsplashPhoto.UnsplashID)
}

View File

@ -30,6 +30,8 @@ func InsertFromStructure(str []*models.NamespaceWithLists, user *user.User) (err
log.Debugf("[creating structure] Creating %d namespaces", len(str))
labels := make(map[string]*models.Label)
// Create all namespaces
for _, n := range str {
err = n.Create(user)
@ -118,6 +120,34 @@ func InsertFromStructure(str []*models.NamespaceWithLists, user *user.User) (err
log.Debugf("[creating structure] Created new attachment %d", a.ID)
}
}
// Create all labels
for _, label := range t.Labels {
// Check if we already have a label with that name + color combination and use it
// If not, create one and save it for later
var lb *models.Label
var exists bool
lb, exists = labels[label.Title+label.HexColor]
if !exists {
err = label.Create(user)
if err != nil {
return err
}
log.Debugf("[creating structure] Created new label %d", label.ID)
labels[label.Title+label.HexColor] = label
lb = label
}
lt := &models.LabelTask{
LabelID: lb.ID,
TaskID: t.ID,
}
err = lt.Create(user)
if err != nil {
return err
}
log.Debugf("[creating structure] Associated task %d with label %d", t.ID, lb.ID)
}
}
}
}

View File

@ -34,7 +34,7 @@ func TestInsertFromStructure(t *testing.T) {
testStructure := []*models.NamespaceWithLists{
{
Namespace: models.Namespace{
Name: "Test1",
Title: "Test1",
Description: "Lorem Ipsum",
},
Lists: []*models.List{
@ -43,22 +43,22 @@ func TestInsertFromStructure(t *testing.T) {
Description: "Something",
Tasks: []*models.Task{
{
Text: "Task1",
Title: "Task1",
Description: "Lorem",
},
{
Text: "Task with related tasks",
Title: "Task with related tasks",
RelatedTasks: map[models.RelationKind][]*models.Task{
models.RelationKindSubtask: {
{
Text: "Related to task with related task",
Title: "Related to task with related task",
Description: "As subtask",
},
},
},
},
{
Text: "Task with attachments",
Title: "Task with attachments",
Attachments: []*models.TaskAttachment{
{
File: &files.File{
@ -69,6 +69,28 @@ func TestInsertFromStructure(t *testing.T) {
},
},
},
{
Title: "Task with labels",
Labels: []*models.Label{
{
Title: "Label1",
HexColor: "ff00ff",
},
{
Title: "Label2",
HexColor: "ff00ff",
},
},
},
{
Title: "Task with same label",
Labels: []*models.Label{
{
Title: "Label1",
HexColor: "ff00ff",
},
},
},
},
},
},

View File

@ -30,7 +30,7 @@ type Migrator interface {
// The use case for this are Oauth flows, where the server token should remain hidden and not
// known to the frontend.
AuthURL() string
// Name holds the name of the migration.
// Title holds the name of the migration.
// This is used to show the name to users and to keep track of users who already migrated.
Name() string
}

View File

@ -0,0 +1,476 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2020 Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package todoist
import (
"bytes"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/files"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/migration"
"code.vikunja.io/api/pkg/timeutil"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/api/pkg/utils"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)
// Migration is the todoist migration struct
type Migration struct {
Code string `json:"code"`
}
type apiTokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
}
type label struct {
ID int `json:"id"`
Name string `json:"name"`
Color int `json:"color"`
ItemOrder int `json:"item_order"`
IsDeleted int `json:"is_deleted"`
IsFavorite int `json:"is_favorite"`
}
type project struct {
ID int `json:"id"`
LegacyID int `json:"legacy_id"`
Name string `json:"name"`
Color int `json:"color"`
ParentID int `json:"parent_id"`
ChildOrder int `json:"child_order"`
Collapsed int `json:"collapsed"`
Shared bool `json:"shared"`
LegacyParentID int `json:"legacy_parent_id"`
SyncID int `json:"sync_id"`
IsDeleted int `json:"is_deleted"`
IsArchived int `json:"is_archived"`
IsFavorite int `json:"is_favorite"`
}
type dueDate struct {
Date string `json:"date"`
Timezone interface{} `json:"timezone"`
String string `json:"string"`
Lang string `json:"lang"`
IsRecurring bool `json:"is_recurring"`
}
type item struct {
ID int `json:"id"`
LegacyID int `json:"legacy_id"`
UserID int `json:"user_id"`
ProjectID int `json:"project_id"`
LegacyProjectID int `json:"legacy_project_id"`
Content string `json:"content"`
Priority int `json:"priority"`
Due *dueDate `json:"due"`
ParentID int `json:"parent_id"`
LegacyParentID int `json:"legacy_parent_id"`
ChildOrder int `json:"child_order"`
SectionID int `json:"section_id"`
DayOrder int `json:"day_order"`
Collapsed int `json:"collapsed"`
Children interface{} `json:"children"`
Labels []int `json:"labels"`
AddedByUID int `json:"added_by_uid"`
AssignedByUID int `json:"assigned_by_uid"`
ResponsibleUID int `json:"responsible_uid"`
Checked int `json:"checked"`
InHistory int `json:"in_history"`
IsDeleted int `json:"is_deleted"`
DateAdded time.Time `json:"date_added"`
HasMoreNotes bool `json:"has_more_notes"`
DateCompleted time.Time `json:"date_completed"`
}
type fileAttachment struct {
FileType string `json:"file_type"`
FileName string `json:"file_name"`
FileSize int `json:"file_size"`
FileURL string `json:"file_url"`
UploadState string `json:"upload_state"`
}
type note struct {
ID int `json:"id"`
LegacyID int `json:"legacy_id"`
PostedUID int `json:"posted_uid"`
ProjectID int `json:"project_id"`
LegacyProjectID int `json:"legacy_project_id"`
ItemID int `json:"item_id"`
LegacyItemID int `json:"legacy_item_id"`
Content string `json:"content"`
FileAttachment *fileAttachment `json:"file_attachment"`
UidsToNotify []int `json:"uids_to_notify"`
IsDeleted int `json:"is_deleted"`
Posted time.Time `json:"posted"`
}
type projectNote struct {
Content string `json:"content"`
FileAttachment *fileAttachment `json:"file_attachment"`
ID int64 `json:"id"`
IsDeleted int `json:"is_deleted"`
Posted time.Time `json:"posted"`
PostedUID int `json:"posted_uid"`
ProjectID int `json:"project_id"`
UidsToNotify []int `json:"uids_to_notify"`
}
type reminder struct {
ID int `json:"id"`
NotifyUID int `json:"notify_uid"`
ItemID int `json:"item_id"`
Service string `json:"service"`
Type string `json:"type"`
Due *dueDate `json:"due"`
MmOffset int `json:"mm_offset"`
IsDeleted int `json:"is_deleted"`
}
type sync struct {
Projects []*project `json:"projects"`
Items []*item `json:"items"`
Labels []*label `json:"labels"`
Notes []*note `json:"notes"`
ProjectNotes []*projectNote `json:"project_notes"`
Reminders []*reminder `json:"reminders"`
}
var todoistColors = map[int]string{}
func init() {
todoistColors = make(map[int]string, 19)
// The todoists colors are static, taken from https://developer.todoist.com/sync/v8/#colors
todoistColors = map[int]string{
30: "b8256f",
31: "db4035",
32: "ff9933",
33: "fad000",
34: "afb83b",
35: "7ecc49",
36: "299438",
37: "6accbc",
38: "158fad",
39: "14aaf5",
40: "96c3eb",
41: "4073ff",
42: "884dff",
43: "af38eb",
44: "eb96eb",
45: "e05194",
46: "ff8d85",
47: "808080",
48: "b8b8b8",
49: "ccac93",
}
}
// Name is used to get the name of the todoist migration - we're using the docs here to annotate the status route.
// @Summary Get migration status
// @Description Returns if the current user already did the migation or not. This is useful to show a confirmation message in the frontend if the user is trying to do the same migration again.
// @tags migration
// @Produce json
// @Security JWTKeyAuth
// @Success 200 {object} migration.Status "The migration status"
// @Failure 500 {object} models.Message "Internal server error"
// @Router /migration/todoist/status [get]
func (m *Migration) Name() string {
return "todoist"
}
// AuthURL returns the url users need to authenticate against
// @Summary Get the auth url from todoist
// @Description Returns the auth url where the user needs to get its auth code. This code can then be used to migrate everything from todoist to Vikunja.
// @tags migration
// @Produce json
// @Security JWTKeyAuth
// @Success 200 {object} handler.AuthURL "The auth url."
// @Failure 500 {object} models.Message "Internal server error"
// @Router /migration/todoist/auth [get]
func (m *Migration) AuthURL() string {
return "https://todoist.com/oauth/authorize" +
"?client_id=" + config.MigrationTodoistClientID.GetString() +
"&scope=data:read" +
"&state=" + utils.MakeRandomString(32)
}
func doPost(url string, form url.Values) (resp *http.Response, err error) {
req, err := http.NewRequest("POST", url, strings.NewReader(form.Encode()))
if err != nil {
return
}
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
hc := http.Client{}
return hc.Do(req)
}
func convertTodoistToVikunja(sync *sync) (fullVikunjaHierachie []*models.NamespaceWithLists, err error) {
newNamespace := &models.NamespaceWithLists{
Namespace: models.Namespace{
Title: "Migrated from todoist",
},
}
// A map for all vikunja lists with the project id they're coming from as key
lists := make(map[int]*models.List, len(sync.Projects))
// A map for all vikunja tasks with the todoist task id as key to find them easily and add more data
tasks := make(map[int]*models.Task, len(sync.Items))
// A map for all vikunja labels with the todoist id as key to find them easier
labels := make(map[int]*models.Label, len(sync.Labels))
for _, p := range sync.Projects {
list := &models.List{
Title: p.Name,
HexColor: todoistColors[p.Color],
IsArchived: p.IsArchived == 1,
}
lists[p.ID] = list
newNamespace.Lists = append(newNamespace.Lists, list)
}
for _, label := range sync.Labels {
labels[label.ID] = &models.Label{
Title: label.Name,
HexColor: todoistColors[label.Color],
}
}
for _, i := range sync.Items {
task := &models.Task{
Title: i.Content,
Created: timeutil.FromTime(i.DateAdded),
Done: i.Checked == 1,
}
// Only try to parse the task done at date if the task is actually done
// Sometimes weired things happen if we try to parse nil dates.
if task.Done {
task.DoneAt = timeutil.FromTime(i.DateCompleted)
}
// Todoist priorities only range from 1 (lowest) and max 4 (highest), so we need to make slight adjustments
if i.Priority > 1 {
task.Priority = int64(i.Priority)
}
// Put the due date together
if i.Due != nil {
dueDate, err := time.Parse("2006-01-02", i.Due.Date)
if err != nil {
return nil, err
}
task.DueDate = timeutil.FromTime(dueDate)
}
// Put all labels together from earlier
for _, lID := range i.Labels {
task.Labels = append(task.Labels, labels[lID])
}
tasks[i.ID] = task
lists[i.ProjectID].Tasks = append(lists[i.ProjectID].Tasks, task)
}
// If the parenId of a task is not 0, create a task relation
// We're looping again here to make sure we have seem all tasks before and have them in our map
for _, i := range sync.Items {
if i.ParentID == 0 {
continue
}
// Prevent all those nil errors
if tasks[i.ParentID].RelatedTasks == nil {
tasks[i.ParentID].RelatedTasks = make(models.RelatedTaskMap)
}
tasks[i.ParentID].RelatedTasks[models.RelationKindSubtask] = append(tasks[i.ParentID].RelatedTasks[models.RelationKindSubtask], tasks[i.ID])
}
// Task Notes -> Task Descriptions
for _, n := range sync.Notes {
if tasks[n.ItemID].Description != "" {
tasks[n.ItemID].Description += "\n"
}
tasks[n.ItemID].Description += n.Content
if n.FileAttachment == nil {
continue
}
// Download the attachment and put it in the file
resp, err := http.Get(n.FileAttachment.FileURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
buf := &bytes.Buffer{}
_, err = buf.ReadFrom(resp.Body)
if err != nil {
return nil, err
}
tasks[n.ItemID].Attachments = append(tasks[n.ItemID].Attachments, &models.TaskAttachment{
File: &files.File{
Name: n.FileAttachment.FileName,
Mime: n.FileAttachment.FileType,
Size: uint64(n.FileAttachment.FileSize),
Created: n.Posted,
CreatedUnix: timeutil.FromTime(n.Posted),
// We directly pass the file contents here to have a way to link the attachment to the file later.
// Because we don't have an ID for our task at this point of the migration, we cannot just throw all
// attachments in a slice and do the work of downloading and properly storing them later.
FileContent: buf.Bytes(),
},
Created: timeutil.FromTime(n.Posted),
})
}
// Project Notes -> List Descriptions
for _, pn := range sync.ProjectNotes {
if lists[pn.ProjectID].Description != "" {
lists[pn.ProjectID].Description += "\n"
}
lists[pn.ProjectID].Description += pn.Content
}
// Reminders -> vikunja reminders
for _, r := range sync.Reminders {
if r.Due == nil {
continue
}
date, err := time.Parse("2006-01-02", r.Due.Date)
if err != nil {
return nil, err
}
tasks[r.ItemID].Reminders = append(tasks[r.ItemID].Reminders, timeutil.FromTime(date))
}
return []*models.NamespaceWithLists{
newNamespace,
}, err
}
func getAccessTokenFromAuthToken(authToken string) (accessToken string, err error) {
form := url.Values{
"client_id": []string{config.MigrationTodoistClientID.GetString()},
"client_secret": []string{config.MigrationTodoistClientSecret.GetString()},
"code": []string{authToken},
"redirect_uri": []string{config.MigrationTodoistRedirectURL.GetString()},
}
resp, err := doPost("https://todoist.com/oauth/access_token", form)
if err != nil {
return
}
if resp.StatusCode > 399 {
buf := &bytes.Buffer{}
_, _ = buf.ReadFrom(resp.Body)
return "", fmt.Errorf("got http status %d while trying to get token, error was %s", resp.StatusCode, buf.String())
}
token := &apiTokenResponse{}
err = json.NewDecoder(resp.Body).Decode(token)
return token.AccessToken, err
}
// Migrate gets all tasks from todoist for a user and puts them into vikunja
// @Summary Migrate all lists, tasks etc. from todoist
// @Description Migrates all projects, tasks, notes, reminders, subtasks and files from todoist to vikunja.
// @tags migration
// @Accept json
// @Produce json
// @Security JWTKeyAuth
// @Param migrationCode body todoist.Migration true "The auth code previously obtained from the auth url. See the docs for /migration/todoist/auth."
// @Success 200 {object} models.Message "A message telling you everything was migrated successfully."
// @Failure 500 {object} models.Message "Internal server error"
// @Router /migration/todoist/migrate [post]
func (m *Migration) Migrate(u *user.User) (err error) {
log.Debugf("[Todoist Migration] Starting migration for user %d", u.ID)
// 0. Get an api token from the obtained auth token
token, err := getAccessTokenFromAuthToken(m.Code)
if err != nil {
return
}
if token == "" {
log.Debugf("[Todoist Migration] Could not get token")
return
}
log.Debugf("[Todoist Migration] Got user token for user %d", u.ID)
log.Debugf("[Todoist Migration] Getting todoist data for user %d", u.ID)
// Get everything with the sync api
form := url.Values{
"token": []string{token},
"sync_token": []string{"*"},
"resource_types": []string{"[\"all\"]"},
}
resp, err := doPost("https://api.todoist.com/sync/v8/sync", form)
if err != nil {
return
}
syncResponse := &sync{}
err = json.NewDecoder(resp.Body).Decode(syncResponse)
if err != nil {
return
}
log.Debugf("[Todoist Migration] Got all todoist user data for user %d", u.ID)
log.Debugf("[Todoist Migration] Start converting data for user %d", u.ID)
fullVikunjaHierachie, err := convertTodoistToVikunja(syncResponse)
if err != nil {
return
}
log.Debugf("[Todoist Migration] Done converting data for user %d", u.ID)
log.Debugf("[Todoist Migration] Start inserting data for user %d", u.ID)
err = migration.InsertFromStructure(fullVikunjaHierachie, u)
if err != nil {
return
}
log.Debugf("[Todoist Migration] Done inserting data for user %d", u.ID)
log.Debugf("[Todoist Migration] Todoist migration done for user %d", u.ID)
return nil
}

View File

@ -0,0 +1,550 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2020 Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package todoist
import (
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/files"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/timeutil"
"github.com/stretchr/testify/assert"
"gopkg.in/d4l3k/messagediff.v1"
"io/ioutil"
"strconv"
"testing"
"time"
)
func TestConvertTodoistToVikunja(t *testing.T) {
config.InitConfig()
time1, err := time.Parse(time.RFC3339Nano, "2014-09-26T08:25:05Z")
assert.NoError(t, err)
time3, err := time.Parse(time.RFC3339Nano, "2014-10-21T08:25:05Z")
assert.NoError(t, err)
dueTime, err := time.Parse(time.RFC3339Nano, "2020-05-31T00:00:00Z")
assert.NoError(t, err)
nilTime, err := time.Parse(time.RFC3339Nano, "1970-01-01T00:00:00Z")
assert.NoError(t, err)
exampleFile, err := ioutil.ReadFile(config.ServiceRootpath.GetString() + "/pkg/modules/migration/wunderlist/testimage.jpg")
assert.NoError(t, err)
makeTestItem := func(id, projectId int, hasDueDate, hasLabels, done bool) *item {
item := &item{
ID: id,
UserID: 1855589,
ProjectID: projectId,
Content: "Task" + strconv.Itoa(id),
Priority: 1,
ParentID: 0,
ChildOrder: 1,
DateAdded: time1,
DateCompleted: nilTime,
}
if done {
item.Checked = 1
item.DateCompleted = time3
}
if hasLabels {
item.Labels = []int{
80000,
80001,
80002,
80003,
}
}
if hasDueDate {
item.Due = &dueDate{
Date: "2020-05-31",
Timezone: nil,
IsRecurring: false,
}
}
return item
}
testSync := &sync{
Projects: []*project{
{
ID: 396936926,
Name: "Project1",
Color: 30,
ChildOrder: 1,
Collapsed: 0,
Shared: false,
IsDeleted: 0,
IsArchived: 0,
IsFavorite: 0,
},
{
ID: 396936927,
Name: "Project2",
Color: 37,
ChildOrder: 1,
Collapsed: 0,
Shared: false,
IsDeleted: 0,
IsArchived: 0,
IsFavorite: 0,
},
{
ID: 396936928,
Name: "Project3 - Archived",
Color: 37,
ChildOrder: 1,
Collapsed: 0,
Shared: false,
IsDeleted: 0,
IsArchived: 1,
IsFavorite: 0,
},
},
Items: []*item{
makeTestItem(400000000, 396936926, false, false, false),
makeTestItem(400000001, 396936926, false, false, false),
makeTestItem(400000002, 396936926, false, false, false),
makeTestItem(400000003, 396936926, true, true, true),
makeTestItem(400000004, 396936926, false, true, false),
makeTestItem(400000005, 396936926, true, false, true),
makeTestItem(400000006, 396936926, true, false, true),
{
ID: 400000110,
UserID: 1855589,
ProjectID: 396936926,
Content: "Task with parent",
Priority: 2,
ParentID: 400000006,
ChildOrder: 1,
Checked: 0,
DateAdded: time1,
},
makeTestItem(400000106, 396936926, true, true, true),
makeTestItem(400000107, 396936926, false, false, true),
makeTestItem(400000108, 396936926, false, false, true),
makeTestItem(400000109, 396936926, false, false, true),
makeTestItem(400000007, 396936927, true, false, false),
makeTestItem(400000008, 396936927, true, false, false),
makeTestItem(400000009, 396936927, false, false, false),
makeTestItem(400000010, 396936927, false, false, true),
makeTestItem(400000101, 396936927, false, false, false),
makeTestItem(400000102, 396936927, true, true, false),
makeTestItem(400000103, 396936927, false, true, false),
makeTestItem(400000104, 396936927, false, true, false),
makeTestItem(400000105, 396936927, true, true, false),
makeTestItem(400000111, 396936928, false, false, true),
},
Labels: []*label{
{
ID: 80000,
Name: "Label1",
Color: 30,
},
{
ID: 80001,
Name: "Label2",
Color: 31,
},
{
ID: 80002,
Name: "Label3",
Color: 32,
},
{
ID: 80003,
Name: "Label4",
Color: 33,
},
},
Notes: []*note{
{
ID: 101476,
PostedUID: 1855589,
ItemID: 400000000,
Content: "Lorem Ipsum dolor sit amet",
Posted: time1,
},
{
ID: 101477,
PostedUID: 1855589,
ItemID: 400000001,
Content: "Lorem Ipsum dolor sit amet",
Posted: time1,
},
{
ID: 101478,
PostedUID: 1855589,
ItemID: 400000003,
Content: "Lorem Ipsum dolor sit amet",
Posted: time1,
},
{
ID: 101479,
PostedUID: 1855589,
ItemID: 400000010,
Content: "Lorem Ipsum dolor sit amet",
Posted: time1,
},
{
ID: 101480,
PostedUID: 1855589,
ItemID: 400000101,
Content: "Lorem Ipsum dolor sit amet",
FileAttachment: &fileAttachment{
FileName: "file.md",
FileType: "text/plain",
FileSize: 12345,
FileURL: "https://vikunja.io/testimage.jpg", // Using an image which we are hosting, so it'll still be up
UploadState: "completed",
},
Posted: time1,
},
},
ProjectNotes: []*projectNote{
{
ID: 102000,
Content: "Lorem Ipsum dolor sit amet",
ProjectID: 396936926,
Posted: time3,
PostedUID: 1855589,
},
{
ID: 102001,
Content: "Lorem Ipsum dolor sit amet 2",
ProjectID: 396936926,
Posted: time3,
PostedUID: 1855589,
},
{
ID: 102002,
Content: "Lorem Ipsum dolor sit amet 3",
ProjectID: 396936926,
Posted: time3,
PostedUID: 1855589,
},
{
ID: 102003,
Content: "Lorem Ipsum dolor sit amet 4",
ProjectID: 396936927,
Posted: time3,
PostedUID: 1855589,
},
{
ID: 102004,
Content: "Lorem Ipsum dolor sit amet 5",
ProjectID: 396936927,
Posted: time3,
PostedUID: 1855589,
},
},
Reminders: []*reminder{
{
ID: 103000,
ItemID: 400000000,
Due: &dueDate{
Date: "2020-06-15",
IsRecurring: false,
},
MmOffset: 180,
},
{
ID: 103001,
ItemID: 400000000,
Due: &dueDate{
Date: "2020-06-16",
IsRecurring: false,
},
},
{
ID: 103002,
ItemID: 400000002,
Due: &dueDate{
Date: "2020-07-15",
IsRecurring: true,
},
},
{
ID: 103003,
ItemID: 400000003,
Due: &dueDate{
Date: "2020-06-15",
IsRecurring: false,
},
},
{
ID: 103004,
ItemID: 400000005,
Due: &dueDate{
Date: "2020-06-15",
IsRecurring: false,
},
},
{
ID: 103006,
ItemID: 400000009,
Due: &dueDate{
Date: "2020-06-15",
IsRecurring: false,
},
},
},
}
vikunjaLabels := []*models.Label{
{
Title: "Label1",
HexColor: todoistColors[30],
},
{
Title: "Label2",
HexColor: todoistColors[31],
},
{
Title: "Label3",
HexColor: todoistColors[32],
},
{
Title: "Label4",
HexColor: todoistColors[33],
},
}
expectedHierachie := []*models.NamespaceWithLists{
{
Namespace: models.Namespace{
Title: "Migrated from todoist",
},
Lists: []*models.List{
{
Title: "Project1",
Description: "Lorem Ipsum dolor sit amet\nLorem Ipsum dolor sit amet 2\nLorem Ipsum dolor sit amet 3",
HexColor: todoistColors[30],
Tasks: []*models.Task{
{
Title: "Task400000000",
Description: "Lorem Ipsum dolor sit amet",
Done: false,
Created: timeutil.FromTime(time1),
Reminders: []timeutil.TimeStamp{
timeutil.FromTime(time.Date(2020, time.June, 15, 0, 0, 0, 0, time.UTC)),
timeutil.FromTime(time.Date(2020, time.June, 16, 0, 0, 0, 0, time.UTC)),
},
},
{
Title: "Task400000001",
Description: "Lorem Ipsum dolor sit amet",
Done: false,
Created: timeutil.FromTime(time1),
},
{
Title: "Task400000002",
Done: false,
Created: timeutil.FromTime(time1),
Reminders: []timeutil.TimeStamp{
timeutil.FromTime(time.Date(2020, time.July, 15, 0, 0, 0, 0, time.UTC)),
},
},
{
Title: "Task400000003",
Description: "Lorem Ipsum dolor sit amet",
Done: true,
DueDate: timeutil.FromTime(dueTime),
Created: timeutil.FromTime(time1),
DoneAt: timeutil.FromTime(time3),
Labels: vikunjaLabels,
Reminders: []timeutil.TimeStamp{
timeutil.FromTime(time.Date(2020, time.June, 15, 0, 0, 0, 0, time.UTC)),
},
},
{
Title: "Task400000004",
Done: false,
Created: timeutil.FromTime(time1),
Labels: vikunjaLabels,
},
{
Title: "Task400000005",
Done: true,
DueDate: timeutil.FromTime(dueTime),
Created: timeutil.FromTime(time1),
DoneAt: timeutil.FromTime(time3),
Reminders: []timeutil.TimeStamp{
timeutil.FromTime(time.Date(2020, time.June, 15, 0, 0, 0, 0, time.UTC)),
},
},
{
Title: "Task400000006",
Done: true,
DueDate: timeutil.FromTime(dueTime),
Created: timeutil.FromTime(time1),
DoneAt: timeutil.FromTime(time3),
RelatedTasks: map[models.RelationKind][]*models.Task{
models.RelationKindSubtask: {
{
Title: "Task with parent",
Done: false,
Priority: 2,
Created: timeutil.FromTime(time1),
DoneAt: timeutil.FromTime(nilTime),
},
},
},
},
{
Title: "Task with parent",
Done: false,
Priority: 2,
Created: timeutil.FromTime(time1),
DoneAt: timeutil.FromTime(nilTime),
},
{
Title: "Task400000106",
Done: true,
DueDate: timeutil.FromTime(dueTime),
Created: timeutil.FromTime(time1),
DoneAt: timeutil.FromTime(time3),
Labels: vikunjaLabels,
},
{
Title: "Task400000107",
Done: true,
Created: timeutil.FromTime(time1),
DoneAt: timeutil.FromTime(time3),
},
{
Title: "Task400000108",
Done: true,
Created: timeutil.FromTime(time1),
DoneAt: timeutil.FromTime(time3),
},
{
Title: "Task400000109",
Done: true,
Created: timeutil.FromTime(time1),
DoneAt: timeutil.FromTime(time3),
},
},
},
{
Title: "Project2",
Description: "Lorem Ipsum dolor sit amet 4\nLorem Ipsum dolor sit amet 5",
HexColor: todoistColors[37],
Tasks: []*models.Task{
{
Title: "Task400000007",
Done: false,
DueDate: timeutil.FromTime(dueTime),
Created: timeutil.FromTime(time1),
},
{
Title: "Task400000008",
Done: false,
DueDate: timeutil.FromTime(dueTime),
Created: timeutil.FromTime(time1),
},
{
Title: "Task400000009",
Done: false,
Created: timeutil.FromTime(time1),
Reminders: []timeutil.TimeStamp{
timeutil.FromTime(time.Date(2020, time.June, 15, 0, 0, 0, 0, time.UTC)),
},
},
{
Title: "Task400000010",
Description: "Lorem Ipsum dolor sit amet",
Done: true,
Created: timeutil.FromTime(time1),
DoneAt: timeutil.FromTime(time3),
},
{
Title: "Task400000101",
Description: "Lorem Ipsum dolor sit amet",
Done: false,
Created: timeutil.FromTime(time1),
Attachments: []*models.TaskAttachment{
{
File: &files.File{
Name: "file.md",
Mime: "text/plain",
Size: 12345,
Created: time1,
CreatedUnix: timeutil.FromTime(time1),
FileContent: exampleFile,
},
Created: timeutil.FromTime(time1),
},
},
},
{
Title: "Task400000102",
Done: false,
DueDate: timeutil.FromTime(dueTime),
Created: timeutil.FromTime(time1),
Labels: vikunjaLabels,
},
{
Title: "Task400000103",
Done: false,
Created: timeutil.FromTime(time1),
Labels: vikunjaLabels,
},
{
Title: "Task400000104",
Done: false,
Created: timeutil.FromTime(time1),
Labels: vikunjaLabels,
},
{
Title: "Task400000105",
Done: false,
DueDate: timeutil.FromTime(dueTime),
Created: timeutil.FromTime(time1),
Labels: vikunjaLabels,
},
},
},
{
Title: "Project3 - Archived",
HexColor: todoistColors[37],
IsArchived: true,
Tasks: []*models.Task{
{
Title: "Task400000111",
Done: true,
Created: timeutil.FromTime(time1),
DoneAt: timeutil.FromTime(time3),
},
},
},
},
},
}
hierachie, err := convertTodoistToVikunja(testSync)
assert.NoError(t, err)
assert.NotNil(t, hierachie)
if diff, equal := messagediff.PrettyDiff(hierachie, expectedHierachie); !equal {
t.Errorf("converted todoist data = %v, want %v, diff: %v", hierachie, expectedHierachie, diff)
}
}

View File

@ -152,7 +152,7 @@ func convertListForFolder(listID int, list *list, content *wunderlistContents) (
for _, t := range content.tasks {
if t.ListID == listID {
newTask := &models.Task{
Text: t.Title,
Title: t.Title,
Created: timeutil.FromTime(t.CreatedAt),
Done: t.Completed,
}
@ -217,7 +217,7 @@ func convertListForFolder(listID int, list *list, content *wunderlistContents) (
newTask.RelatedTasks = make(models.RelatedTaskMap)
}
newTask.RelatedTasks[models.RelationKindSubtask] = append(newTask.RelatedTasks[models.RelationKindSubtask], &models.Task{
Text: s.Title,
Title: s.Title,
})
}
}
@ -247,7 +247,7 @@ func convertWunderlistToVikunja(content *wunderlistContents) (fullVikunjaHierach
for _, folder := range content.folders {
namespace := &models.NamespaceWithLists{
Namespace: models.Namespace{
Name: folder.Title,
Title: folder.Title,
Created: timeutil.FromTime(folder.CreatedAt),
Updated: timeutil.FromTime(folder.UpdatedAt),
},
@ -274,7 +274,7 @@ func convertWunderlistToVikunja(content *wunderlistContents) (fullVikunjaHierach
if len(listMap) > 0 {
newNamespace := &models.NamespaceWithLists{
Namespace: models.Namespace{
Name: "Migrated from wunderlist",
Title: "Migrated from wunderlist",
},
}

View File

@ -192,7 +192,7 @@ func TestWunderlistParsing(t *testing.T) {
expectedHierachie := []*models.NamespaceWithLists{
{
Namespace: models.Namespace{
Name: "Lorem Ipsum",
Title: "Lorem Ipsum",
Created: timeutil.FromTime(time1),
Updated: timeutil.FromTime(time2),
},
@ -202,7 +202,7 @@ func TestWunderlistParsing(t *testing.T) {
Title: "Lorem1",
Tasks: []*models.Task{
{
Text: "Ipsum1",
Title: "Ipsum1",
DueDate: 1378339200,
Created: timeutil.FromTime(time1),
Description: "Lorem Ipsum dolor sit amet",
@ -222,17 +222,17 @@ func TestWunderlistParsing(t *testing.T) {
Reminders: []timeutil.TimeStamp{timeutil.FromTime(time4)},
},
{
Text: "Ipsum2",
Title: "Ipsum2",
DueDate: 1378339200,
Created: timeutil.FromTime(time1),
Description: "Lorem Ipsum dolor sit amet",
RelatedTasks: map[models.RelationKind][]*models.Task{
models.RelationKindSubtask: {
{
Text: "LoremSub1",
Title: "LoremSub1",
},
{
Text: "LoremSub2",
Title: "LoremSub2",
},
},
},
@ -244,7 +244,7 @@ func TestWunderlistParsing(t *testing.T) {
Title: "Lorem2",
Tasks: []*models.Task{
{
Text: "Ipsum3",
Title: "Ipsum3",
Done: true,
DoneAt: timeutil.FromTime(time1),
DueDate: 1378339200,
@ -265,14 +265,14 @@ func TestWunderlistParsing(t *testing.T) {
},
},
{
Text: "Ipsum4",
Title: "Ipsum4",
DueDate: 1378339200,
Created: timeutil.FromTime(time1),
Reminders: []timeutil.TimeStamp{timeutil.FromTime(time3)},
RelatedTasks: map[models.RelationKind][]*models.Task{
models.RelationKindSubtask: {
{
Text: "LoremSub3",
Title: "LoremSub3",
},
},
},
@ -284,26 +284,26 @@ func TestWunderlistParsing(t *testing.T) {
Title: "Lorem3",
Tasks: []*models.Task{
{
Text: "Ipsum5",
Title: "Ipsum5",
DueDate: 1378339200,
Created: timeutil.FromTime(time1),
},
{
Text: "Ipsum6",
Title: "Ipsum6",
DueDate: 1378339200,
Created: timeutil.FromTime(time1),
Done: true,
DoneAt: timeutil.FromTime(time1),
},
{
Text: "Ipsum7",
Title: "Ipsum7",
DueDate: 1378339200,
Created: timeutil.FromTime(time1),
Done: true,
DoneAt: timeutil.FromTime(time1),
},
{
Text: "Ipsum8",
Title: "Ipsum8",
DueDate: 1378339200,
Created: timeutil.FromTime(time1),
},
@ -314,14 +314,14 @@ func TestWunderlistParsing(t *testing.T) {
Title: "Lorem4",
Tasks: []*models.Task{
{
Text: "Ipsum9",
Title: "Ipsum9",
DueDate: 1378339200,
Created: timeutil.FromTime(time1),
Done: true,
DoneAt: timeutil.FromTime(time1),
},
{
Text: "Ipsum10",
Title: "Ipsum10",
DueDate: 1378339200,
Created: timeutil.FromTime(time1),
Done: true,
@ -333,7 +333,7 @@ func TestWunderlistParsing(t *testing.T) {
},
{
Namespace: models.Namespace{
Name: "Migrated from wunderlist",
Title: "Migrated from wunderlist",
},
Lists: []*models.List{
{
@ -348,6 +348,6 @@ func TestWunderlistParsing(t *testing.T) {
assert.NoError(t, err)
assert.NotNil(t, hierachie)
if diff, equal := messagediff.PrettyDiff(hierachie, expectedHierachie); !equal {
t.Errorf("ListUser.ReadAll() = %v, want %v, diff: %v", hierachie, expectedHierachie, diff)
t.Errorf("converted wunderlist data = %v, want %v, diff: %v", hierachie, expectedHierachie, diff)
}
}

File diff suppressed because one or more lines are too long

View File

@ -18,20 +18,24 @@ package v1
import (
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/modules/migration/todoist"
"code.vikunja.io/api/pkg/modules/migration/wunderlist"
"code.vikunja.io/api/pkg/version"
"github.com/labstack/echo/v4"
"net/http"
)
type vikunjaInfos struct {
Version string `json:"version"`
FrontendURL string `json:"frontend_url"`
Motd string `json:"motd"`
LinkSharingEnabled bool `json:"link_sharing_enabled"`
MaxFileSize string `json:"max_file_size"`
RegistrationEnabled bool `json:"registration_enabled"`
AvailableMigrators []string `json:"available_migrators"`
TaskAttachmentsEnabled bool `json:"task_attachments_enabled"`
Version string `json:"version"`
FrontendURL string `json:"frontend_url"`
Motd string `json:"motd"`
LinkSharingEnabled bool `json:"link_sharing_enabled"`
MaxFileSize string `json:"max_file_size"`
RegistrationEnabled bool `json:"registration_enabled"`
AvailableMigrators []string `json:"available_migrators"`
TaskAttachmentsEnabled bool `json:"task_attachments_enabled"`
EnabledBackgroundProviders []string `json:"enabled_background_providers"`
TotpEnabled bool `json:"totp_enabled"`
}
// Info is the handler to get infos about this vikunja instance
@ -42,7 +46,7 @@ type vikunjaInfos struct {
// @Success 200 {object} v1.vikunjaInfos
// @Router /info [get]
func Info(c echo.Context) error {
infos := vikunjaInfos{
info := vikunjaInfos{
Version: version.Version,
FrontendURL: config.ServiceFrontendurl.GetString(),
Motd: config.ServiceMotd.GetString(),
@ -50,9 +54,24 @@ func Info(c echo.Context) error {
MaxFileSize: config.FilesMaxSize.GetString(),
RegistrationEnabled: config.ServiceEnableRegistration.GetBool(),
TaskAttachmentsEnabled: config.ServiceEnableTaskAttachments.GetBool(),
TotpEnabled: config.ServiceEnableTotp.GetBool(),
}
// Migrators
if config.MigrationWunderlistEnable.GetBool() {
infos.AvailableMigrators = append(infos.AvailableMigrators, "wunderlist")
m := &wunderlist.Migration{}
info.AvailableMigrators = append(info.AvailableMigrators, m.Name())
}
return c.JSON(http.StatusOK, infos)
if config.MigrationTodoistEnable.GetBool() {
m := &todoist.Migration{}
info.AvailableMigrators = append(info.AvailableMigrators, m.Name())
}
if config.BackgroundsEnabled.GetBool() {
if config.BackgroundsUnsplashEnabled.GetBool() {
info.EnabledBackgroundProviders = append(info.EnabledBackgroundProviders, "unsplash")
}
}
return c.JSON(http.StatusOK, info)
}

View File

@ -18,7 +18,6 @@ package v1
import (
"code.vikunja.io/api/pkg/models"
user2 "code.vikunja.io/api/pkg/user"
"code.vikunja.io/web/handler"
"github.com/labstack/echo/v4"
"net/http"
@ -46,11 +45,12 @@ func UploadTaskAttachment(c echo.Context) error {
}
// Rights check
user, err := user2.GetCurrentUser(c)
auth, err := GetAuthFromClaims(c)
if err != nil {
return handler.HandleHTTPError(err, c)
}
can, err := taskAttachment.CanCreate(user)
can, err := taskAttachment.CanCreate(auth)
if err != nil {
return handler.HandleHTTPError(err, c)
}
@ -83,7 +83,7 @@ func UploadTaskAttachment(c echo.Context) error {
}
defer f.Close()
err = ta.NewAttachment(f, file.Filename, uint64(file.Size), user)
err = ta.NewAttachment(f, file.Filename, uint64(file.Size), auth)
if err != nil {
r.Errors = append(r.Errors, handler.HandleHTTPError(err, c))
continue
@ -115,11 +115,11 @@ func GetTaskAttachment(c echo.Context) error {
}
// Rights check
user, err := user2.GetCurrentUser(c)
auth, err := GetAuthFromClaims(c)
if err != nil {
return handler.HandleHTTPError(err, c)
}
can, err := taskAttachment.CanRead(user)
can, err := taskAttachment.CanRead(auth)
if err != nil {
return handler.HandleHTTPError(err, c)
}

View File

@ -53,7 +53,7 @@ func RegisterUser(c echo.Context) error {
}
// Add its namespace
newN := &models.Namespace{Name: newUser.Username, Description: newUser.Username + "'s namespace.", Owner: newUser}
newN := &models.Namespace{Title: newUser.Username, Description: newUser.Username + "'s namespace.", Owner: newUser}
err = newN.Create(newUser)
if err != nil {
return handler.HandleHTTPError(err, c)

View File

@ -73,11 +73,12 @@ func ListUsersForList(c echo.Context) error {
}
list := models.List{ID: listID}
currentUser, err := user.GetCurrentUser(c)
auth, err := GetAuthFromClaims(c)
if err != nil {
return handler.HandleHTTPError(err, c)
}
canRead, err := list.CanRead(currentUser)
canRead, err := list.CanRead(auth)
if err != nil {
return handler.HandleHTTPError(err, c)
}

View File

@ -82,7 +82,7 @@ func UserTOTPEnable(c echo.Context) error {
if he, is := err.(*echo.HTTPError); is {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid model provided. Error was: %s", he.Message))
}
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid model provided."))
return echo.NewHTTPError(http.StatusBadRequest, "Invalid model provided.")
}
err = user.EnableTOTP(passcode)
@ -113,7 +113,7 @@ func UserTOTPDisable(c echo.Context) error {
if he, is := err.(*echo.HTTPError); is {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid model provided. Error was: %s", he.Message))
}
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid model provided."))
return echo.NewHTTPError(http.StatusBadRequest, "Invalid model provided.")
}
u, err := user.GetCurrentUser(c)

View File

@ -47,7 +47,7 @@ func UpdateUserEmail(c echo.Context) (err error) {
if he, is := err.(*echo.HTTPError); is {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid model provided. Error was: %s", he.Message))
}
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid model provided."))
return echo.NewHTTPError(http.StatusBadRequest, "Invalid model provided.")
}
emailUpdate.User, err = user.GetCurrentUser(c)

View File

@ -137,7 +137,7 @@ func (vcls *VikunjaCaldavListStorage) GetResourcesByList(rpaths []string) ([]dat
task: t,
}
r := data.NewResource(getTaskURL(t), &rr)
r.Name = t.Text
r.Name = t.Title
resources = append(resources, r)
}
@ -159,7 +159,7 @@ func (vcls *VikunjaCaldavListStorage) GetResourcesByFilters(rpath string, filter
isCollection: false,
}
r := data.NewResource(getTaskURL(t), &rr)
r.Name = t.Text
r.Name = t.Title
resources = append(resources, r)
}
return resources, nil

View File

@ -37,7 +37,7 @@ func getCaldavTodosForTasks(list *models.List) string {
caldavtodos = append(caldavtodos, &caldav.Todo{
Timestamp: t.Updated,
UID: t.UID,
Summary: t.Text,
Summary: t.Title,
Description: t.Description,
Completed: t.DoneAt,
// Organizer: &t.CreatedBy, // Disabled until we figure out how this works
@ -91,7 +91,7 @@ func parseTaskFromVTODO(content string) (vTask *models.Task, err error) {
vTask = &models.Task{
UID: task["UID"],
Text: task["SUMMARY"],
Title: task["SUMMARY"],
Description: task["DESCRIPTION"],
Priority: priority,
DueDate: caldavTimeToTimestamp(task["DUE"]),

View File

@ -26,7 +26,6 @@ import (
"code.vikunja.io/api/pkg/user"
"github.com/labstack/echo/v4"
"github.com/prometheus/client_golang/prometheus/promhttp"
"time"
)
func setupMetrics(a *echo.Group) {
@ -77,11 +76,6 @@ func setupMetrics(a *echo.Group) {
}
}
// init active users, sometimes we'll have garbage from previous runs in redis instead
if err := metrics.SetActiveUsers([]*metrics.ActiveUser{}); err != nil {
log.Fatalf("Could not set initial count for active users, error was %s", err)
}
a.GET("/metrics", echo.WrapHandler(promhttp.Handler()))
}
@ -110,22 +104,5 @@ func updateActiveUsersFromContext(c echo.Context) (err error) {
return
}
allActiveUsers, err := metrics.GetActiveUsers()
if err != nil {
return
}
var uupdated bool
for in, u := range allActiveUsers {
if u.UserID == auth.GetID() {
allActiveUsers[in].LastSeen = time.Now()
uupdated = true
}
}
if !uupdated {
allActiveUsers = append(allActiveUsers, &metrics.ActiveUser{UserID: auth.GetID(), LastSeen: time.Now()})
}
return metrics.SetActiveUsers(allActiveUsers)
return metrics.SetUserActive(auth)
}

View File

@ -47,8 +47,12 @@ import (
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/background"
backgroundHandler "code.vikunja.io/api/pkg/modules/background/handler"
"code.vikunja.io/api/pkg/modules/background/unsplash"
"code.vikunja.io/api/pkg/modules/migration"
migrationHandler "code.vikunja.io/api/pkg/modules/migration/handler"
"code.vikunja.io/api/pkg/modules/migration/todoist"
"code.vikunja.io/api/pkg/modules/migration/wunderlist"
apiv1 "code.vikunja.io/api/pkg/routes/api/v1"
"code.vikunja.io/api/pkg/routes/caldav"
@ -209,11 +213,14 @@ func registerAPIRoutes(a *echo.Group) {
u.GET("s", apiv1.UserList)
u.POST("/token", apiv1.RenewToken)
u.POST("/settings/email", apiv1.UpdateUserEmail)
u.GET("/settings/totp", apiv1.UserTOTP)
u.POST("/settings/totp/enroll", apiv1.UserTOTPEnroll)
u.POST("/settings/totp/enable", apiv1.UserTOTPEnable)
u.POST("/settings/totp/disable", apiv1.UserTOTPDisable)
u.GET("/settings/totp/qrcode", apiv1.UserTOTPQrCode)
if config.ServiceEnableTotp.GetBool() {
u.GET("/settings/totp", apiv1.UserTOTP)
u.POST("/settings/totp/enroll", apiv1.UserTOTPEnroll)
u.POST("/settings/totp/enable", apiv1.UserTOTPEnable)
u.POST("/settings/totp/disable", apiv1.UserTOTPDisable)
u.GET("/settings/totp/qrcode", apiv1.UserTOTPQrCode)
}
listHandler := &handler.WebHandler{
EmptyStruct: func() handler.CObject {
@ -433,6 +440,32 @@ func registerAPIRoutes(a *echo.Group) {
}
wunderlistMigrationHandler.RegisterRoutes(m)
}
// Todoist
if config.MigrationTodoistEnable.GetBool() {
todoistMigrationHandler := &migrationHandler.MigrationWeb{
MigrationStruct: func() migration.Migrator {
return &todoist.Migration{}
},
}
todoistMigrationHandler.RegisterRoutes(m)
}
// List Backgrounds
if config.BackgroundsEnabled.GetBool() {
a.GET("/lists/:list/background", backgroundHandler.GetListBackground)
if config.BackgroundsUnsplashEnabled.GetBool() {
unsplashBackgroundProvider := &backgroundHandler.BackgroundProvider{
Provider: func() background.Provider {
return &unsplash.Provider{}
},
}
a.GET("/backgrounds/unsplash/search", unsplashBackgroundProvider.SearchBackgrounds)
a.POST("/lists/:list/backgrounds/unsplash", unsplashBackgroundProvider.SetBackground)
a.GET("/backgrounds/unsplash/images/:image/thumb", unsplash.ProxyUnsplashThumb)
a.GET("/backgrounds/unsplash/images/:image", unsplash.ProxyUnsplashImage)
}
}
}
func registerCalDavRoutes(c *echo.Group) {

View File

@ -85,7 +85,7 @@ func IsErrNoUsernamePassword(err error) bool {
}
func (err ErrNoUsernamePassword) Error() string {
return fmt.Sprintf("No username and password provided")
return "No username and password provided"
}
// ErrCodeNoUsernamePassword holds the unique world-error code of this error
@ -129,7 +129,7 @@ func IsErrCouldNotGetUserID(err error) bool {
}
func (err ErrCouldNotGetUserID) Error() string {
return fmt.Sprintf("Could not get user ID")
return "Could not get user ID"
}
// ErrCodeCouldNotGetUserID holds the unique world-error code of this error
@ -208,7 +208,7 @@ type ErrWrongUsernameOrPassword struct {
}
func (err ErrWrongUsernameOrPassword) Error() string {
return fmt.Sprintf("Wrong username or password")
return "Wrong username or password"
}
// ErrCodeWrongUsernameOrPassword holds the unique world-error code of this error
@ -258,7 +258,7 @@ func IsErrEmptyNewPassword(err error) bool {
}
func (err ErrEmptyNewPassword) Error() string {
return fmt.Sprintf("New password is empty")
return "New password is empty"
}
// ErrCodeEmptyNewPassword holds the unique world-error code of this error
@ -279,7 +279,7 @@ func IsErrEmptyOldPassword(err error) bool {
}
func (err ErrEmptyOldPassword) Error() string {
return fmt.Sprintf("Old password is empty")
return "Old password is empty"
}
// ErrCodeEmptyOldPassword holds the unique world-error code of this error
@ -300,7 +300,7 @@ func IsErrTOTPAlreadyEnabled(err error) bool {
}
func (err ErrTOTPAlreadyEnabled) Error() string {
return fmt.Sprintf("Totp is already enabled for this user")
return "Totp is already enabled for this user"
}
// ErrCodeTOTPAlreadyEnabled holds the unique world-error code of this error
@ -325,7 +325,7 @@ func IsErrTOTPNotEnabled(err error) bool {
}
func (err ErrTOTPNotEnabled) Error() string {
return fmt.Sprintf("Totp is not enabled for this user")
return "Totp is not enabled for this user"
}
// ErrCodeTOTPNotEnabled holds the unique world-error code of this error
@ -352,7 +352,7 @@ func IsErrInvalidTOTPPasscode(err error) bool {
}
func (err ErrInvalidTOTPPasscode) Error() string {
return fmt.Sprintf("Invalid totp passcode")
return "Invalid totp passcode"
}
// ErrCodeInvalidTOTPPasscode holds the unique world-error code of this error

View File

@ -17,6 +17,7 @@
package user
import (
"code.vikunja.io/api/pkg/config"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
"image"
@ -26,7 +27,7 @@ import (
type TOTP struct {
ID int64 `xorm:"int(11) autoincr not null unique pk" json:"-"`
UserID int64 `xorm:"int(11) not null" json:"-"`
Secret string `xorm:"varchar(20) not null" json:"secret"`
Secret string `xorm:"text not null" json:"secret"`
// The totp entry will only be enabled after the user verified they have a working totp setup.
Enabled bool `xorm:"null" json:"enabled"`
// The totp url used to be able to enroll the user later
@ -46,6 +47,9 @@ type TOTPPasscode struct {
// TOTPEnabledForUser checks if totp is enabled for a user - not if it is activated, use GetTOTPForUser to check that.
func TOTPEnabledForUser(user *User) (bool, error) {
if !config.ServiceEnableTotp.GetBool() {
return false, nil
}
return x.Where("user_id = ?", user.ID).Exist(&TOTP{})
}

7
run.sh Executable file
View File

@ -0,0 +1,7 @@
#!/bin/sh
# Set the uid and gid of the vikunja run user
usermod --non-unique --uid ${PUID} vikunja
groupmod --non-unique --gid ${PGID} vikunja
su vikunja -c '/app/vikunja/vikunja'

27
vendor/github.com/alexbrainman/sspi/LICENSE generated vendored Normal file
View File

@ -0,0 +1,27 @@
Copyright (c) 2012 The Go Authors. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of Google Inc. nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

1
vendor/github.com/alexbrainman/sspi/README.md generated vendored Normal file
View File

@ -0,0 +1 @@
This repository holds Go packages for accessing Security Support Provider Interface on Windows.

57
vendor/github.com/alexbrainman/sspi/buffer.go generated vendored Normal file
View File

@ -0,0 +1,57 @@
// Copyright 2015 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build windows
package sspi
import (
"io"
"unsafe"
)
func (b *SecBuffer) Set(buftype uint32, data []byte) {
b.BufferType = buftype
if len(data) > 0 {
b.Buffer = &data[0]
b.BufferSize = uint32(len(data))
} else {
b.Buffer = nil
b.BufferSize = 0
}
}
func (b *SecBuffer) Free() error {
if b.Buffer == nil {
return nil
}
return FreeContextBuffer((*byte)(unsafe.Pointer(b.Buffer)))
}
func (b *SecBuffer) Bytes() []byte {
if b.Buffer == nil || b.BufferSize <= 0 {
return nil
}
return (*[2 << 20]byte)(unsafe.Pointer(b.Buffer))[:b.BufferSize]
}
func (b *SecBuffer) WriteAll(w io.Writer) (int, error) {
if b.BufferSize == 0 || b.Buffer == nil {
return 0, nil
}
data := b.Bytes()
total := 0
for {
n, err := w.Write(data)
total += n
if err != nil {
return total, err
}
if n >= len(data) {
break
}
data = data[n:]
}
return total, nil
}

7
vendor/github.com/alexbrainman/sspi/mksyscall.go generated vendored Normal file
View File

@ -0,0 +1,7 @@
// Copyright 2018 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package sspi
//go:generate go run $GOROOT/src/syscall/mksyscall_windows.go -systemdll=false -output=zsyscall_windows.go syscall.go

View File

@ -0,0 +1,462 @@
// Copyright 2016 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build windows
// Package negotiate provides access to the Microsoft Negotiate SSP Package.
//
package negotiate
import (
"errors"
"syscall"
"time"
"unsafe"
"github.com/alexbrainman/sspi"
)
// TODO: maybe (if possible) move all winapi related out of sspi and into sspi/internal/winapi
// PackageInfo contains Negotiate SSP package description.
var PackageInfo *sspi.PackageInfo
func init() {
var err error
PackageInfo, err = sspi.QueryPackageInfo(sspi.NEGOSSP_NAME)
if err != nil {
panic("failed to fetch Negotiate package info: " + err.Error())
}
}
func acquireCredentials(principalName string, creduse uint32, ai *sspi.SEC_WINNT_AUTH_IDENTITY) (*sspi.Credentials, error) {
c, err := sspi.AcquireCredentials(principalName, sspi.NEGOSSP_NAME, creduse, (*byte)(unsafe.Pointer(ai)))
if err != nil {
return nil, err
}
return c, nil
}
// AcquireCurrentUserCredentials acquires credentials of currently
// logged on user. These will be used by the client to authenticate
// itself to the server. It will also be used by the server
// to impersonate the user.
func AcquireCurrentUserCredentials() (*sspi.Credentials, error) {
return acquireCredentials("", sspi.SECPKG_CRED_OUTBOUND, nil)
}
// TODO: see if I can share this common ntlm and negotiate code
// AcquireUserCredentials acquires credentials of user described by
// domain, username and password. These will be used by the client to
// authenticate itself to the server. It will also be used by the
// server to impersonate the user.
func AcquireUserCredentials(domain, username, password string) (*sspi.Credentials, error) {
if len(username) == 0 {
return nil, errors.New("username parameter cannot be empty")
}
d, err := syscall.UTF16FromString(domain)
if err != nil {
return nil, err
}
u, err := syscall.UTF16FromString(username)
if err != nil {
return nil, err
}
p, err := syscall.UTF16FromString(password)
if err != nil {
return nil, err
}
ai := sspi.SEC_WINNT_AUTH_IDENTITY{
User: &u[0],
UserLength: uint32(len(u) - 1), // do not count terminating 0
Domain: &d[0],
DomainLength: uint32(len(d) - 1), // do not count terminating 0
Password: &p[0],
PasswordLength: uint32(len(p) - 1), // do not count terminating 0
Flags: sspi.SEC_WINNT_AUTH_IDENTITY_UNICODE,
}
return acquireCredentials("", sspi.SECPKG_CRED_OUTBOUND, &ai)
}
// AcquireServerCredentials acquires server credentials that will
// be used to authenticate clients.
// The principalName parameter is passed to the underlying call to
// the winapi AcquireCredentialsHandle function (and specifies the
// name of the principal whose credentials the underlying handle
// will reference).
// As a special case, using an empty string for the principal name
// will require the credential of the user under whose security context
// the current process is running.
func AcquireServerCredentials(principalName string) (*sspi.Credentials, error) {
return acquireCredentials(principalName, sspi.SECPKG_CRED_INBOUND, nil)
}
func updateContext(c *sspi.Context, dst, src []byte, targetName *uint16) (authCompleted bool, n int, err error) {
var inBuf, outBuf [1]sspi.SecBuffer
inBuf[0].Set(sspi.SECBUFFER_TOKEN, src)
inBufs := &sspi.SecBufferDesc{
Version: sspi.SECBUFFER_VERSION,
BuffersCount: 1,
Buffers: &inBuf[0],
}
outBuf[0].Set(sspi.SECBUFFER_TOKEN, dst)
outBufs := &sspi.SecBufferDesc{
Version: sspi.SECBUFFER_VERSION,
BuffersCount: 1,
Buffers: &outBuf[0],
}
ret := c.Update(targetName, outBufs, inBufs)
switch ret {
case sspi.SEC_E_OK:
// session established -> return success
return true, int(outBuf[0].BufferSize), nil
case sspi.SEC_I_COMPLETE_NEEDED, sspi.SEC_I_COMPLETE_AND_CONTINUE:
ret = sspi.CompleteAuthToken(c.Handle, outBufs)
if ret != sspi.SEC_E_OK {
return false, 0, ret
}
case sspi.SEC_I_CONTINUE_NEEDED:
default:
return false, 0, ret
}
return false, int(outBuf[0].BufferSize), nil
}
func makeSignature(c *sspi.Context, msg []byte, qop, seqno uint32) ([]byte, error) {
_, maxSignature, _, _, err := c.Sizes()
if err != nil {
return nil, err
}
if maxSignature == 0 {
return nil, errors.New("integrity services are not requested or unavailable")
}
var b [2]sspi.SecBuffer
b[0].Set(sspi.SECBUFFER_DATA, msg)
b[1].Set(sspi.SECBUFFER_TOKEN, make([]byte, maxSignature))
ret := sspi.MakeSignature(c.Handle, qop, sspi.NewSecBufferDesc(b[:]), seqno)
if ret != sspi.SEC_E_OK {
return nil, ret
}
return b[1].Bytes(), nil
}
func encryptMessage(c *sspi.Context, msg []byte, qop, seqno uint32) ([]byte, error) {
_ /*maxToken*/, maxSignature, cBlockSize, cSecurityTrailer, err := c.Sizes()
if err != nil {
return nil, err
}
if maxSignature == 0 {
return nil, errors.New("integrity services are not requested or unavailable")
}
var b [3]sspi.SecBuffer
b[0].Set(sspi.SECBUFFER_TOKEN, make([]byte, cSecurityTrailer))
b[1].Set(sspi.SECBUFFER_DATA, msg)
b[2].Set(sspi.SECBUFFER_PADDING, make([]byte, cBlockSize))
ret := sspi.EncryptMessage(c.Handle, qop, sspi.NewSecBufferDesc(b[:]), seqno)
if ret != sspi.SEC_E_OK {
return nil, ret
}
r0, r1, r2 := b[0].Bytes(), b[1].Bytes(), b[2].Bytes()
res := make([]byte, 0, len(r0)+len(r1)+len(r2))
res = append(res, r0...)
res = append(res, r1...)
res = append(res, r2...)
return res, nil
}
func decryptMessage(c *sspi.Context, msg []byte, seqno uint32) (uint32, []byte, error) {
var b [2]sspi.SecBuffer
b[0].Set(sspi.SECBUFFER_STREAM, msg)
b[1].Set(sspi.SECBUFFER_DATA, []byte{})
var qop uint32
ret := sspi.DecryptMessage(c.Handle, sspi.NewSecBufferDesc(b[:]), seqno, &qop)
if ret != sspi.SEC_E_OK {
return qop, nil, ret
}
return qop, b[1].Bytes(), nil
}
func verifySignature(c *sspi.Context, msg, token []byte, seqno uint32) (uint32, error) {
var b [2]sspi.SecBuffer
b[0].Set(sspi.SECBUFFER_DATA, msg)
b[1].Set(sspi.SECBUFFER_TOKEN, token)
var qop uint32
ret := sspi.VerifySignature(c.Handle, sspi.NewSecBufferDesc(b[:]), seqno, &qop)
if ret != sspi.SEC_E_OK {
return 0, ret
}
return qop, nil
}
// ClientContext is used by the client to manage all steps of Negotiate negotiation.
type ClientContext struct {
sctxt *sspi.Context
targetName *uint16
}
// NewClientContext creates a new client context. It uses client
// credentials cred generated by AcquireCurrentUserCredentials or
// AcquireUserCredentials and SPN to start a client Negotiate
// negotiation sequence. targetName is the service principal name
// (SPN) or the security context of the destination server.
// NewClientContext returns a new token to be sent to the server.
func NewClientContext(cred *sspi.Credentials, targetName string) (cc *ClientContext, outputToken []byte, err error) {
return NewClientContextWithFlags(cred, targetName, sspi.ISC_REQ_CONNECTION)
}
// NewClientContextWithFlags creates a new client context. It uses client
// credentials cred generated by AcquireCurrentUserCredentials or
// AcquireUserCredentials and SPN to start a client Negotiate
// negotiation sequence. targetName is the service principal name
// (SPN) or the security context of the destination server.
// The flags parameter is used to indicate requests for the context
// (for example sspi.ISC_REQ_CONFIDENTIALITY|sspi.ISC_REQ_REPLAY_DETECT)
// NewClientContextWithFlags returns a new token to be sent to the server.
func NewClientContextWithFlags(cred *sspi.Credentials, targetName string, flags uint32) (cc *ClientContext, outputToken []byte, err error) {
var tname *uint16
if len(targetName) > 0 {
p, err2 := syscall.UTF16FromString(targetName)
if err2 != nil {
return nil, nil, err2
}
if len(p) > 0 {
tname = &p[0]
}
}
otoken := make([]byte, PackageInfo.MaxToken)
c := sspi.NewClientContext(cred, flags)
authCompleted, n, err2 := updateContext(c, otoken, nil, tname)
if err2 != nil {
return nil, nil, err2
}
if authCompleted {
c.Release()
return nil, nil, errors.New("negotiate authentication should not be completed yet")
}
if n == 0 {
c.Release()
return nil, nil, errors.New("negotiate token should not be empty")
}
otoken = otoken[:n]
return &ClientContext{sctxt: c, targetName: tname}, otoken, nil
}
// Release free up resources associated with client context c.
func (c *ClientContext) Release() error {
if c == nil {
return nil
}
return c.sctxt.Release()
}
// Expiry returns c expiry time.
func (c *ClientContext) Expiry() time.Time {
return c.sctxt.Expiry()
}
// Update advances client part of Negotiate negotiation c. It uses
// token received from the server and returns true if client part
// of authentication is complete. It also returns new token to be
// sent to the server.
func (c *ClientContext) Update(token []byte) (authCompleted bool, outputToken []byte, err error) {
otoken := make([]byte, PackageInfo.MaxToken)
authDone, n, err2 := updateContext(c.sctxt, otoken, token, c.targetName)
if err2 != nil {
return false, nil, err2
}
if n == 0 && !authDone {
return false, nil, errors.New("negotiate token should not be empty")
}
otoken = otoken[:n]
return authDone, otoken, nil
}
// Sizes queries the client context for the sizes used in per-message
// functions. It returns the maximum token size used in authentication
// exchanges, the maximum signature size, the preferred integral size of
// messages, the size of any security trailer, and any error.
func (c *ClientContext) Sizes() (uint32, uint32, uint32, uint32, error) {
return c.sctxt.Sizes()
}
// MakeSignature uses the established client context to create a signature
// for the given message using the provided quality of protection flags and
// sequence number. It returns the signature token in addition to any error.
func (c *ClientContext) MakeSignature(msg []byte, qop, seqno uint32) ([]byte, error) {
return makeSignature(c.sctxt, msg, qop, seqno)
}
// VerifySignature uses the established client context and signature token
// to check that the provided message hasn't been tampered or received out
// of sequence. It returns any quality of protection flags and any error
// that occurred.
func (c *ClientContext) VerifySignature(msg, token []byte, seqno uint32) (uint32, error) {
return verifySignature(c.sctxt, msg, token, seqno)
}
// EncryptMessage uses the established client context to encrypt a message
// using the provided quality of protection flags and sequence number.
// It returns the signature token in addition to any error.
// IMPORTANT: the input msg parameter is updated in place by the low-level windows api
// so must be copied if the initial content should not be modified.
func (c *ClientContext) EncryptMessage(msg []byte, qop, seqno uint32) ([]byte, error) {
return encryptMessage(c.sctxt, msg, qop, seqno)
}
// DecryptMessage uses the established client context to decrypt a message
// using the provided sequence number.
// It returns the quality of protection flag and the decrypted message in addition to any error.
func (c *ClientContext) DecryptMessage(msg []byte, seqno uint32) (uint32, []byte, error) {
return decryptMessage(c.sctxt, msg, seqno)
}
// VerifyFlags determines if all flags used to construct the client context
// were honored (see NewClientContextWithFlags). It should be called after c.Update.
func (c *ClientContext) VerifyFlags() error {
return c.sctxt.VerifyFlags()
}
// VerifySelectiveFlags determines if the given flags were honored (see NewClientContextWithFlags).
// It should be called after c.Update.
func (c *ClientContext) VerifySelectiveFlags(flags uint32) error {
return c.sctxt.VerifySelectiveFlags(flags)
}
// ServerContext is used by the server to manage all steps of Negotiate
// negotiation. Once authentication is completed the context can be
// used to impersonate client.
type ServerContext struct {
sctxt *sspi.Context
}
// NewServerContext creates new server context. It uses server
// credentials created by AcquireServerCredentials and token from
// the client to start server Negotiate negotiation sequence.
// It also returns new token to be sent to the client.
func NewServerContext(cred *sspi.Credentials, token []byte) (sc *ServerContext, authDone bool, outputToken []byte, err error) {
otoken := make([]byte, PackageInfo.MaxToken)
c := sspi.NewServerContext(cred, sspi.ASC_REQ_CONNECTION)
authDone, n, err2 := updateContext(c, otoken, token, nil)
if err2 != nil {
return nil, false, nil, err2
}
otoken = otoken[:n]
return &ServerContext{sctxt: c}, authDone, otoken, nil
}
// Release free up resources associated with server context c.
func (c *ServerContext) Release() error {
if c == nil {
return nil
}
return c.sctxt.Release()
}
// Expiry returns c expiry time.
func (c *ServerContext) Expiry() time.Time {
return c.sctxt.Expiry()
}
// Update advances server part of Negotiate negotiation c. It uses
// token received from the client and returns true if server part
// of authentication is complete. It also returns new token to be
// sent to the client.
func (c *ServerContext) Update(token []byte) (authCompleted bool, outputToken []byte, err error) {
otoken := make([]byte, PackageInfo.MaxToken)
authDone, n, err2 := updateContext(c.sctxt, otoken, token, nil)
if err2 != nil {
return false, nil, err2
}
if n == 0 && !authDone {
return false, nil, errors.New("negotiate token should not be empty")
}
otoken = otoken[:n]
return authDone, otoken, nil
}
const _SECPKG_ATTR_NATIVE_NAMES = 13
type _SecPkgContext_NativeNames struct {
ClientName *uint16
ServerName *uint16
}
// GetUsername returns the username corresponding to the authenticated client
func (c *ServerContext) GetUsername() (string, error) {
var ns _SecPkgContext_NativeNames
ret := sspi.QueryContextAttributes(c.sctxt.Handle, _SECPKG_ATTR_NATIVE_NAMES, (*byte)(unsafe.Pointer(&ns)))
if ret != sspi.SEC_E_OK {
return "", ret
}
sspi.FreeContextBuffer((*byte)(unsafe.Pointer(ns.ServerName)))
defer sspi.FreeContextBuffer((*byte)(unsafe.Pointer(ns.ClientName)))
return syscall.UTF16ToString((*[2 << 20]uint16)(unsafe.Pointer(ns.ClientName))[:]), nil
}
// ImpersonateUser changes current OS thread user. New user is
// the user as specified by client credentials.
func (c *ServerContext) ImpersonateUser() error {
return c.sctxt.ImpersonateUser()
}
// RevertToSelf stops impersonation. It changes current OS thread
// user to what it was before ImpersonateUser was executed.
func (c *ServerContext) RevertToSelf() error {
return c.sctxt.RevertToSelf()
}
// Sizes queries the server context for the sizes used in per-message
// functions. It returns the maximum token size used in authentication
// exchanges, the maximum signature size, the preferred integral size of
// messages, the size of any security trailer, and any error.
func (c *ServerContext) Sizes() (uint32, uint32, uint32, uint32, error) {
return c.sctxt.Sizes()
}
// MakeSignature uses the established server context to create a signature
// for the given message using the provided quality of protection flags and
// sequence number. It returns the signature token in addition to any error.
func (c *ServerContext) MakeSignature(msg []byte, qop, seqno uint32) ([]byte, error) {
return makeSignature(c.sctxt, msg, qop, seqno)
}
// VerifySignature uses the established server context and signature token
// to check that the provided message hasn't been tampered or received out
// of sequence. It returns any quality of protection flags and any error
// that occurred.
func (c *ServerContext) VerifySignature(msg, token []byte, seqno uint32) (uint32, error) {
return verifySignature(c.sctxt, msg, token, seqno)
}
// EncryptMessage uses the established server context to encrypt a message
// using the provided quality of protection flags and sequence number.
// It returns the signature token in addition to any error.
// IMPORTANT: the input msg parameter is updated in place by the low-level windows api
// so must be copied if the initial content should not be modified.
func (c *ServerContext) EncryptMessage(msg []byte, qop, seqno uint32) ([]byte, error) {
return encryptMessage(c.sctxt, msg, qop, seqno)
}
// DecryptMessage uses the established server context to decrypt a message
// using the provided sequence number.
// It returns the quality of protection flag and the decrypted message in addition to any error.
func (c *ServerContext) DecryptMessage(msg []byte, seqno uint32) (uint32, []byte, error) {
return decryptMessage(c.sctxt, msg, seqno)
}

226
vendor/github.com/alexbrainman/sspi/sspi.go generated vendored Normal file
View File

@ -0,0 +1,226 @@
// Copyright 2015 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build windows
package sspi
import (
"fmt"
"syscall"
"time"
"unsafe"
)
// TODO: add documentation
type PackageInfo struct {
Capabilities uint32
Version uint16
RPCID uint16
MaxToken uint32
Name string
Comment string
}
func QueryPackageInfo(pkgname string) (*PackageInfo, error) {
name, err := syscall.UTF16PtrFromString(pkgname)
if err != nil {
return nil, err
}
var pi *SecPkgInfo
ret := QuerySecurityPackageInfo(name, &pi)
if ret != SEC_E_OK {
return nil, ret
}
defer FreeContextBuffer((*byte)(unsafe.Pointer(pi)))
return &PackageInfo{
Capabilities: pi.Capabilities,
Version: pi.Version,
RPCID: pi.RPCID,
MaxToken: pi.MaxToken,
Name: syscall.UTF16ToString((*[2 << 12]uint16)(unsafe.Pointer(pi.Name))[:]),
Comment: syscall.UTF16ToString((*[2 << 12]uint16)(unsafe.Pointer(pi.Comment))[:]),
}, nil
}
type Credentials struct {
Handle CredHandle
expiry syscall.Filetime
}
// AcquireCredentials calls the windows AcquireCredentialsHandle function and
// returns Credentials containing a security handle that can be used for
// InitializeSecurityContext or AcceptSecurityContext operations.
// As a special case, passing an empty string as the principal parameter will
// pass a null string to the underlying function.
func AcquireCredentials(principal string, pkgname string, creduse uint32, authdata *byte) (*Credentials, error) {
var principalName *uint16
if principal != "" {
var err error
principalName, err = syscall.UTF16PtrFromString(principal)
if err != nil {
return nil, err
}
}
name, err := syscall.UTF16PtrFromString(pkgname)
if err != nil {
return nil, err
}
var c Credentials
ret := AcquireCredentialsHandle(principalName, name, creduse, nil, authdata, 0, 0, &c.Handle, &c.expiry)
if ret != SEC_E_OK {
return nil, ret
}
return &c, nil
}
func (c *Credentials) Release() error {
if c == nil {
return nil
}
ret := FreeCredentialsHandle(&c.Handle)
if ret != SEC_E_OK {
return ret
}
return nil
}
func (c *Credentials) Expiry() time.Time {
return time.Unix(0, c.expiry.Nanoseconds())
}
// TODO: add functions to display and manage RequestedFlags and EstablishedFlags fields.
// TODO: maybe get rid of RequestedFlags and EstablishedFlags fields, and replace them with input parameter for New...Context and return value of Update (instead of current bool parameter).
type updateFunc func(c *Context, targname *uint16, h, newh *CtxtHandle, out, in *SecBufferDesc) syscall.Errno
type Context struct {
Cred *Credentials
Handle *CtxtHandle
handle CtxtHandle
updFn updateFunc
expiry syscall.Filetime
RequestedFlags uint32
EstablishedFlags uint32
}
func NewClientContext(cred *Credentials, flags uint32) *Context {
return &Context{
Cred: cred,
updFn: initialize,
RequestedFlags: flags,
}
}
func NewServerContext(cred *Credentials, flags uint32) *Context {
return &Context{
Cred: cred,
updFn: accept,
RequestedFlags: flags,
}
}
func initialize(c *Context, targname *uint16, h, newh *CtxtHandle, out, in *SecBufferDesc) syscall.Errno {
return InitializeSecurityContext(&c.Cred.Handle, h, targname, c.RequestedFlags,
0, SECURITY_NATIVE_DREP, in, 0, newh, out, &c.EstablishedFlags, &c.expiry)
}
func accept(c *Context, targname *uint16, h, newh *CtxtHandle, out, in *SecBufferDesc) syscall.Errno {
return AcceptSecurityContext(&c.Cred.Handle, h, in, c.RequestedFlags,
SECURITY_NATIVE_DREP, newh, out, &c.EstablishedFlags, &c.expiry)
}
func (c *Context) Update(targname *uint16, out, in *SecBufferDesc) syscall.Errno {
h := c.Handle
if c.Handle == nil {
c.Handle = &c.handle
}
return c.updFn(c, targname, h, c.Handle, out, in)
}
func (c *Context) Release() error {
if c == nil {
return nil
}
ret := DeleteSecurityContext(c.Handle)
if ret != SEC_E_OK {
return ret
}
return nil
}
func (c *Context) Expiry() time.Time {
return time.Unix(0, c.expiry.Nanoseconds())
}
// TODO: add comment to function doco that this "impersonation" is applied to current OS thread.
func (c *Context) ImpersonateUser() error {
ret := ImpersonateSecurityContext(c.Handle)
if ret != SEC_E_OK {
return ret
}
return nil
}
func (c *Context) RevertToSelf() error {
ret := RevertSecurityContext(c.Handle)
if ret != SEC_E_OK {
return ret
}
return nil
}
// Sizes queries the context for the sizes used in per-message functions.
// It returns the maximum token size used in authentication exchanges, the
// maximum signature size, the preferred integral size of messages, the
// size of any security trailer, and any error.
func (c *Context) Sizes() (uint32, uint32, uint32, uint32, error) {
var s _SecPkgContext_Sizes
ret := QueryContextAttributes(c.Handle, _SECPKG_ATTR_SIZES, (*byte)(unsafe.Pointer(&s)))
if ret != SEC_E_OK {
return 0, 0, 0, 0, ret
}
return s.MaxToken, s.MaxSignature, s.BlockSize, s.SecurityTrailer, nil
}
// VerifyFlags determines if all flags used to construct the context
// were honored (see NewClientContext). It should be called after c.Update.
func (c *Context) VerifyFlags() error {
return c.VerifySelectiveFlags(c.RequestedFlags)
}
// VerifySelectiveFlags determines if the given flags were honored (see NewClientContext).
// It should be called after c.Update.
func (c *Context) VerifySelectiveFlags(flags uint32) error {
if valid, missing, extra := verifySelectiveFlags(flags, c.RequestedFlags); !valid {
return fmt.Errorf("sspi: invalid flags check: desired=%b requested=%b missing=%b extra=%b", flags, c.RequestedFlags, missing, extra)
}
if valid, missing, extra := verifySelectiveFlags(flags, c.EstablishedFlags); !valid {
return fmt.Errorf("sspi: invalid flags: desired=%b established=%b missing=%b extra=%b", flags, c.EstablishedFlags, missing, extra)
}
return nil
}
// verifySelectiveFlags determines if all bits requested in flags are set in establishedFlags.
// missing represents the bits set in flags that are not set in establishedFlags.
// extra represents the bits set in establishedFlags that are not set in flags.
// valid is true and missing is zero when establishedFlags has all of the requested flags.
func verifySelectiveFlags(flags, establishedFlags uint32) (valid bool, missing, extra uint32) {
missing = flags&establishedFlags ^ flags
extra = flags | establishedFlags ^ flags
valid = missing == 0
return valid, missing, extra
}
// NewSecBufferDesc returns an initialized SecBufferDesc describing the
// provided SecBuffer.
func NewSecBufferDesc(b []SecBuffer) *SecBufferDesc {
return &SecBufferDesc{
Version: SECBUFFER_VERSION,
BuffersCount: uint32(len(b)),
Buffers: &b[0],
}
}

174
vendor/github.com/alexbrainman/sspi/syscall.go generated vendored Normal file
View File

@ -0,0 +1,174 @@
// Copyright 2015 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build windows
package sspi
import (
"syscall"
)
const (
SEC_E_OK = syscall.Errno(0)
SEC_I_COMPLETE_AND_CONTINUE = syscall.Errno(590612)
SEC_I_COMPLETE_NEEDED = syscall.Errno(590611)
SEC_I_CONTINUE_NEEDED = syscall.Errno(590610)
SEC_E_LOGON_DENIED = syscall.Errno(0x8009030c)
SEC_E_CONTEXT_EXPIRED = syscall.Errno(0x80090317) // not sure if the value is valid
SEC_E_INCOMPLETE_MESSAGE = syscall.Errno(0x80090318)
NTLMSP_NAME = "NTLM"
MICROSOFT_KERBEROS_NAME = "Kerberos"
NEGOSSP_NAME = "Negotiate"
UNISP_NAME = "Microsoft Unified Security Protocol Provider"
_SECPKG_ATTR_SIZES = 0
_SECPKG_ATTR_NAMES = 1
_SECPKG_ATTR_LIFESPAN = 2
_SECPKG_ATTR_DCE_INFO = 3
_SECPKG_ATTR_STREAM_SIZES = 4
_SECPKG_ATTR_KEY_INFO = 5
_SECPKG_ATTR_AUTHORITY = 6
_SECPKG_ATTR_PROTO_INFO = 7
_SECPKG_ATTR_PASSWORD_EXPIRY = 8
_SECPKG_ATTR_SESSION_KEY = 9
_SECPKG_ATTR_PACKAGE_INFO = 10
_SECPKG_ATTR_USER_FLAGS = 11
_SECPKG_ATTR_NEGOTIATION_INFO = 12
_SECPKG_ATTR_NATIVE_NAMES = 13
_SECPKG_ATTR_FLAGS = 14
)
type SecPkgInfo struct {
Capabilities uint32
Version uint16
RPCID uint16
MaxToken uint32
Name *uint16
Comment *uint16
}
type _SecPkgContext_Sizes struct {
MaxToken uint32
MaxSignature uint32
BlockSize uint32
SecurityTrailer uint32
}
//sys QuerySecurityPackageInfo(pkgname *uint16, pkginfo **SecPkgInfo) (ret syscall.Errno) = secur32.QuerySecurityPackageInfoW
//sys FreeContextBuffer(buf *byte) (ret syscall.Errno) = secur32.FreeContextBuffer
const (
SECPKG_CRED_INBOUND = 1
SECPKG_CRED_OUTBOUND = 2
SECPKG_CRED_BOTH = (SECPKG_CRED_OUTBOUND | SECPKG_CRED_INBOUND)
SEC_WINNT_AUTH_IDENTITY_UNICODE = 0x2
)
type SEC_WINNT_AUTH_IDENTITY struct {
User *uint16
UserLength uint32
Domain *uint16
DomainLength uint32
Password *uint16
PasswordLength uint32
Flags uint32
}
type LUID struct {
LowPart uint32
HighPart int32
}
type CredHandle struct {
Lower uintptr
Upper uintptr
}
//sys AcquireCredentialsHandle(principal *uint16, pkgname *uint16, creduse uint32, logonid *LUID, authdata *byte, getkeyfn uintptr, getkeyarg uintptr, handle *CredHandle, expiry *syscall.Filetime) (ret syscall.Errno) = secur32.AcquireCredentialsHandleW
//sys FreeCredentialsHandle(handle *CredHandle) (ret syscall.Errno) = secur32.FreeCredentialsHandle
const (
SECURITY_NATIVE_DREP = 16
SECBUFFER_DATA = 1
SECBUFFER_TOKEN = 2
SECBUFFER_PKG_PARAMS = 3
SECBUFFER_MISSING = 4
SECBUFFER_EXTRA = 5
SECBUFFER_STREAM_TRAILER = 6
SECBUFFER_STREAM_HEADER = 7
SECBUFFER_PADDING = 9
SECBUFFER_STREAM = 10
SECBUFFER_READONLY = 0x80000000
SECBUFFER_ATTRMASK = 0xf0000000
SECBUFFER_VERSION = 0
SECBUFFER_EMPTY = 0
ISC_REQ_DELEGATE = 1
ISC_REQ_MUTUAL_AUTH = 2
ISC_REQ_REPLAY_DETECT = 4
ISC_REQ_SEQUENCE_DETECT = 8
ISC_REQ_CONFIDENTIALITY = 16
ISC_REQ_USE_SESSION_KEY = 32
ISC_REQ_PROMPT_FOR_CREDS = 64
ISC_REQ_USE_SUPPLIED_CREDS = 128
ISC_REQ_ALLOCATE_MEMORY = 256
ISC_REQ_USE_DCE_STYLE = 512
ISC_REQ_DATAGRAM = 1024
ISC_REQ_CONNECTION = 2048
ISC_REQ_EXTENDED_ERROR = 16384
ISC_REQ_STREAM = 32768
ISC_REQ_INTEGRITY = 65536
ISC_REQ_MANUAL_CRED_VALIDATION = 524288
ISC_REQ_HTTP = 268435456
ASC_REQ_DELEGATE = 1
ASC_REQ_MUTUAL_AUTH = 2
ASC_REQ_REPLAY_DETECT = 4
ASC_REQ_SEQUENCE_DETECT = 8
ASC_REQ_CONFIDENTIALITY = 16
ASC_REQ_USE_SESSION_KEY = 32
ASC_REQ_ALLOCATE_MEMORY = 256
ASC_REQ_USE_DCE_STYLE = 512
ASC_REQ_DATAGRAM = 1024
ASC_REQ_CONNECTION = 2048
ASC_REQ_EXTENDED_ERROR = 32768
ASC_REQ_STREAM = 65536
ASC_REQ_INTEGRITY = 131072
)
type CtxtHandle struct {
Lower uintptr
Upper uintptr
}
type SecBuffer struct {
BufferSize uint32
BufferType uint32
Buffer *byte
}
type SecBufferDesc struct {
Version uint32
BuffersCount uint32
Buffers *SecBuffer
}
//sys InitializeSecurityContext(credential *CredHandle, context *CtxtHandle, targname *uint16, contextreq uint32, reserved1 uint32, targdatarep uint32, input *SecBufferDesc, reserved2 uint32, newcontext *CtxtHandle, output *SecBufferDesc, contextattr *uint32, expiry *syscall.Filetime) (ret syscall.Errno) = secur32.InitializeSecurityContextW
//sys AcceptSecurityContext(credential *CredHandle, context *CtxtHandle, input *SecBufferDesc, contextreq uint32, targdatarep uint32, newcontext *CtxtHandle, output *SecBufferDesc, contextattr *uint32, expiry *syscall.Filetime) (ret syscall.Errno) = secur32.AcceptSecurityContext
//sys CompleteAuthToken(context *CtxtHandle, token *SecBufferDesc) (ret syscall.Errno) = secur32.CompleteAuthToken
//sys DeleteSecurityContext(context *CtxtHandle) (ret syscall.Errno) = secur32.DeleteSecurityContext
//sys ImpersonateSecurityContext(context *CtxtHandle) (ret syscall.Errno) = secur32.ImpersonateSecurityContext
//sys RevertSecurityContext(context *CtxtHandle) (ret syscall.Errno) = secur32.RevertSecurityContext
//sys QueryContextAttributes(context *CtxtHandle, attribute uint32, buf *byte) (ret syscall.Errno) = secur32.QueryContextAttributesW
//sys EncryptMessage(context *CtxtHandle, qop uint32, message *SecBufferDesc, messageseqno uint32) (ret syscall.Errno) = secur32.EncryptMessage
//sys DecryptMessage(context *CtxtHandle, message *SecBufferDesc, messageseqno uint32, qop *uint32) (ret syscall.Errno) = secur32.DecryptMessage
//sys ApplyControlToken(context *CtxtHandle, input *SecBufferDesc) (ret syscall.Errno) = secur32.ApplyControlToken
//sys MakeSignature(context *CtxtHandle, qop uint32, message *SecBufferDesc, messageseqno uint32) (ret syscall.Errno) = secur32.MakeSignature
//sys VerifySignature(context *CtxtHandle, message *SecBufferDesc, messageseqno uint32, qop *uint32) (ret syscall.Errno) = secur32.VerifySignature

152
vendor/github.com/alexbrainman/sspi/zsyscall_windows.go generated vendored Normal file
View File

@ -0,0 +1,152 @@
// MACHINE GENERATED BY 'go generate' COMMAND; DO NOT EDIT
package sspi
import (
"syscall"
"unsafe"
)
var _ unsafe.Pointer
// Do the interface allocations only once for common
// Errno values.
const (
errnoERROR_IO_PENDING = 997
)
var (
errERROR_IO_PENDING error = syscall.Errno(errnoERROR_IO_PENDING)
)
// errnoErr returns common boxed Errno values, to prevent
// allocations at runtime.
func errnoErr(e syscall.Errno) error {
switch e {
case 0:
return nil
case errnoERROR_IO_PENDING:
return errERROR_IO_PENDING
}
// TODO: add more here, after collecting data on the common
// error values see on Windows. (perhaps when running
// all.bat?)
return e
}
var (
modsecur32 = syscall.NewLazyDLL("secur32.dll")
procQuerySecurityPackageInfoW = modsecur32.NewProc("QuerySecurityPackageInfoW")
procFreeContextBuffer = modsecur32.NewProc("FreeContextBuffer")
procAcquireCredentialsHandleW = modsecur32.NewProc("AcquireCredentialsHandleW")
procFreeCredentialsHandle = modsecur32.NewProc("FreeCredentialsHandle")
procInitializeSecurityContextW = modsecur32.NewProc("InitializeSecurityContextW")
procAcceptSecurityContext = modsecur32.NewProc("AcceptSecurityContext")
procCompleteAuthToken = modsecur32.NewProc("CompleteAuthToken")
procDeleteSecurityContext = modsecur32.NewProc("DeleteSecurityContext")
procImpersonateSecurityContext = modsecur32.NewProc("ImpersonateSecurityContext")
procRevertSecurityContext = modsecur32.NewProc("RevertSecurityContext")
procQueryContextAttributesW = modsecur32.NewProc("QueryContextAttributesW")
procEncryptMessage = modsecur32.NewProc("EncryptMessage")
procDecryptMessage = modsecur32.NewProc("DecryptMessage")
procApplyControlToken = modsecur32.NewProc("ApplyControlToken")
procMakeSignature = modsecur32.NewProc("MakeSignature")
procVerifySignature = modsecur32.NewProc("VerifySignature")
)
func QuerySecurityPackageInfo(pkgname *uint16, pkginfo **SecPkgInfo) (ret syscall.Errno) {
r0, _, _ := syscall.Syscall(procQuerySecurityPackageInfoW.Addr(), 2, uintptr(unsafe.Pointer(pkgname)), uintptr(unsafe.Pointer(pkginfo)), 0)
ret = syscall.Errno(r0)
return
}
func FreeContextBuffer(buf *byte) (ret syscall.Errno) {
r0, _, _ := syscall.Syscall(procFreeContextBuffer.Addr(), 1, uintptr(unsafe.Pointer(buf)), 0, 0)
ret = syscall.Errno(r0)
return
}
func AcquireCredentialsHandle(principal *uint16, pkgname *uint16, creduse uint32, logonid *LUID, authdata *byte, getkeyfn uintptr, getkeyarg uintptr, handle *CredHandle, expiry *syscall.Filetime) (ret syscall.Errno) {
r0, _, _ := syscall.Syscall9(procAcquireCredentialsHandleW.Addr(), 9, uintptr(unsafe.Pointer(principal)), uintptr(unsafe.Pointer(pkgname)), uintptr(creduse), uintptr(unsafe.Pointer(logonid)), uintptr(unsafe.Pointer(authdata)), uintptr(getkeyfn), uintptr(getkeyarg), uintptr(unsafe.Pointer(handle)), uintptr(unsafe.Pointer(expiry)))
ret = syscall.Errno(r0)
return
}
func FreeCredentialsHandle(handle *CredHandle) (ret syscall.Errno) {
r0, _, _ := syscall.Syscall(procFreeCredentialsHandle.Addr(), 1, uintptr(unsafe.Pointer(handle)), 0, 0)
ret = syscall.Errno(r0)
return
}
func InitializeSecurityContext(credential *CredHandle, context *CtxtHandle, targname *uint16, contextreq uint32, reserved1 uint32, targdatarep uint32, input *SecBufferDesc, reserved2 uint32, newcontext *CtxtHandle, output *SecBufferDesc, contextattr *uint32, expiry *syscall.Filetime) (ret syscall.Errno) {
r0, _, _ := syscall.Syscall12(procInitializeSecurityContextW.Addr(), 12, uintptr(unsafe.Pointer(credential)), uintptr(unsafe.Pointer(context)), uintptr(unsafe.Pointer(targname)), uintptr(contextreq), uintptr(reserved1), uintptr(targdatarep), uintptr(unsafe.Pointer(input)), uintptr(reserved2), uintptr(unsafe.Pointer(newcontext)), uintptr(unsafe.Pointer(output)), uintptr(unsafe.Pointer(contextattr)), uintptr(unsafe.Pointer(expiry)))
ret = syscall.Errno(r0)
return
}
func AcceptSecurityContext(credential *CredHandle, context *CtxtHandle, input *SecBufferDesc, contextreq uint32, targdatarep uint32, newcontext *CtxtHandle, output *SecBufferDesc, contextattr *uint32, expiry *syscall.Filetime) (ret syscall.Errno) {
r0, _, _ := syscall.Syscall9(procAcceptSecurityContext.Addr(), 9, uintptr(unsafe.Pointer(credential)), uintptr(unsafe.Pointer(context)), uintptr(unsafe.Pointer(input)), uintptr(contextreq), uintptr(targdatarep), uintptr(unsafe.Pointer(newcontext)), uintptr(unsafe.Pointer(output)), uintptr(unsafe.Pointer(contextattr)), uintptr(unsafe.Pointer(expiry)))
ret = syscall.Errno(r0)
return
}
func CompleteAuthToken(context *CtxtHandle, token *SecBufferDesc) (ret syscall.Errno) {
r0, _, _ := syscall.Syscall(procCompleteAuthToken.Addr(), 2, uintptr(unsafe.Pointer(context)), uintptr(unsafe.Pointer(token)), 0)
ret = syscall.Errno(r0)
return
}
func DeleteSecurityContext(context *CtxtHandle) (ret syscall.Errno) {
r0, _, _ := syscall.Syscall(procDeleteSecurityContext.Addr(), 1, uintptr(unsafe.Pointer(context)), 0, 0)
ret = syscall.Errno(r0)
return
}
func ImpersonateSecurityContext(context *CtxtHandle) (ret syscall.Errno) {
r0, _, _ := syscall.Syscall(procImpersonateSecurityContext.Addr(), 1, uintptr(unsafe.Pointer(context)), 0, 0)
ret = syscall.Errno(r0)
return
}
func RevertSecurityContext(context *CtxtHandle) (ret syscall.Errno) {
r0, _, _ := syscall.Syscall(procRevertSecurityContext.Addr(), 1, uintptr(unsafe.Pointer(context)), 0, 0)
ret = syscall.Errno(r0)
return
}
func QueryContextAttributes(context *CtxtHandle, attribute uint32, buf *byte) (ret syscall.Errno) {
r0, _, _ := syscall.Syscall(procQueryContextAttributesW.Addr(), 3, uintptr(unsafe.Pointer(context)), uintptr(attribute), uintptr(unsafe.Pointer(buf)))
ret = syscall.Errno(r0)
return
}
func EncryptMessage(context *CtxtHandle, qop uint32, message *SecBufferDesc, messageseqno uint32) (ret syscall.Errno) {
r0, _, _ := syscall.Syscall6(procEncryptMessage.Addr(), 4, uintptr(unsafe.Pointer(context)), uintptr(qop), uintptr(unsafe.Pointer(message)), uintptr(messageseqno), 0, 0)
ret = syscall.Errno(r0)
return
}
func DecryptMessage(context *CtxtHandle, message *SecBufferDesc, messageseqno uint32, qop *uint32) (ret syscall.Errno) {
r0, _, _ := syscall.Syscall6(procDecryptMessage.Addr(), 4, uintptr(unsafe.Pointer(context)), uintptr(unsafe.Pointer(message)), uintptr(messageseqno), uintptr(unsafe.Pointer(qop)), 0, 0)
ret = syscall.Errno(r0)
return
}
func ApplyControlToken(context *CtxtHandle, input *SecBufferDesc) (ret syscall.Errno) {
r0, _, _ := syscall.Syscall(procApplyControlToken.Addr(), 2, uintptr(unsafe.Pointer(context)), uintptr(unsafe.Pointer(input)), 0)
ret = syscall.Errno(r0)
return
}
func MakeSignature(context *CtxtHandle, qop uint32, message *SecBufferDesc, messageseqno uint32) (ret syscall.Errno) {
r0, _, _ := syscall.Syscall6(procMakeSignature.Addr(), 4, uintptr(unsafe.Pointer(context)), uintptr(qop), uintptr(unsafe.Pointer(message)), uintptr(messageseqno), 0, 0)
ret = syscall.Errno(r0)
return
}
func VerifySignature(context *CtxtHandle, message *SecBufferDesc, messageseqno uint32, qop *uint32) (ret syscall.Errno) {
r0, _, _ := syscall.Syscall6(procVerifySignature.Addr(), 4, uintptr(unsafe.Pointer(context)), uintptr(unsafe.Pointer(message)), uintptr(messageseqno), uintptr(unsafe.Pointer(qop)), 0, 0)
ret = syscall.Errno(r0)
return
}

View File

@ -1,5 +1,4 @@
dist: xenial
sudo: false
language: go
services:

View File

@ -4,6 +4,10 @@
- Existing `HMSet` is renamed to `HSet` and old deprecated `HMSet` is restored for Redis 3 users.
## v7.1
- Existing `Cmd.String` is renamed to `Cmd.Text`. New `Cmd.String` implements `fmt.Stringer` interface.
## v7
- *Important*. Tx.Pipeline now returns a non-transactional pipeline. Use Tx.TxPipeline for a transactional pipeline.

View File

@ -101,7 +101,7 @@ set, err := client.SetNX("key", "value", 10*time.Second).Result()
vals, err := client.Sort("list", &redis.Sort{Offset: 0, Count: 2, Order: "ASC"}).Result()
// ZRANGEBYSCORE zset -inf +inf WITHSCORES LIMIT 0 2
vals, err := client.ZRangeByScoreWithScores("zset", redis.ZRangeBy{
vals, err := client.ZRangeByScoreWithScores("zset", &redis.ZRangeBy{
Min: "-inf",
Max: "+inf",
Offset: 0,
@ -109,7 +109,10 @@ vals, err := client.ZRangeByScoreWithScores("zset", redis.ZRangeBy{
}).Result()
// ZINTERSTORE out 2 zset1 zset2 WEIGHTS 2 3 AGGREGATE SUM
vals, err := client.ZInterStore("out", redis.ZStore{Weights: []int64{2, 3}}, "zset1", "zset2").Result()
vals, err := client.ZInterStore("out", &redis.ZStore{
Keys: []string{"zset1", "zset2"},
Weights: []int64{2, 3}
}).Result()
// EVAL "return {KEYS[1],ARGV[1]}" 1 "key" "hello"
vals, err := client.Eval("return {KEYS[1],ARGV[1]}", []string{"key"}, "hello").Result()

View File

@ -57,6 +57,7 @@ type ClusterOptions struct {
OnConnect func(*Conn) error
Username string
Password string
MaxRetries int
@ -67,6 +68,9 @@ type ClusterOptions struct {
ReadTimeout time.Duration
WriteTimeout time.Duration
// NewClient creates a cluster node client with provided name and options.
NewClient func(opt *Options) *Client
// PoolSize applies per cluster node and not for the whole cluster.
PoolSize int
MinIdleConns int
@ -118,6 +122,10 @@ func (opt *ClusterOptions) init() {
case 0:
opt.MaxRetryBackoff = 512 * time.Millisecond
}
if opt.NewClient == nil {
opt.NewClient = NewClient
}
}
func (opt *ClusterOptions) clientOptions() *Options {
@ -130,6 +138,7 @@ func (opt *ClusterOptions) clientOptions() *Options {
MaxRetries: opt.MaxRetries,
MinRetryBackoff: opt.MinRetryBackoff,
MaxRetryBackoff: opt.MaxRetryBackoff,
Username: opt.Username,
Password: opt.Password,
readOnly: opt.ReadOnly,
@ -162,7 +171,7 @@ func newClusterNode(clOpt *ClusterOptions, addr string) *clusterNode {
opt := clOpt.clientOptions()
opt.Addr = addr
node := clusterNode{
Client: NewClient(opt),
Client: clOpt.NewClient(opt),
}
node.latency = math.MaxUint32

View File

@ -671,7 +671,7 @@ func (cmd *StringCmd) Time() (time.Time, error) {
if cmd.err != nil {
return time.Time{}, cmd.err
}
return time.Parse(time.RFC3339, cmd.Val())
return time.Parse(time.RFC3339Nano, cmd.Val())
}
func (cmd *StringCmd) Scan(val interface{}) error {

View File

@ -302,6 +302,7 @@ type Cmdable interface {
type StatefulCmdable interface {
Cmdable
Auth(password string) *StatusCmd
AuthACL(username, password string) *StatusCmd
Select(index int) *StatusCmd
SwapDB(index1, index2 int) *StatusCmd
ClientSetName(name string) *BoolCmd
@ -324,6 +325,15 @@ func (c statefulCmdable) Auth(password string) *StatusCmd {
return cmd
}
// Perform an AUTH command, using the given user and pass.
// Should be used to authenticate the current connection with one of the connections defined in the ACL list
// when connecting to a Redis 6.0 instance, or greater, that is using the Redis ACL system.
func (c statefulCmdable) AuthACL(username, password string) *StatusCmd {
cmd := NewStatusCmd("auth", username, password)
_ = c(cmd)
return cmd
}
func (c cmdable) Echo(message interface{}) *StringCmd {
cmd := NewStringCmd("echo", message)
_ = c(cmd)

View File

@ -6,9 +6,24 @@ import (
"net"
"strings"
"github.com/go-redis/redis/v7/internal/pool"
"github.com/go-redis/redis/v7/internal/proto"
)
var ErrClosed = pool.ErrClosed
type Error interface {
error
// RedisError is a no-op function but
// serves to distinguish types that are Redis
// errors from ordinary errors: a type is a
// Redis error if it has a RedisError method.
RedisError()
}
var _ Error = proto.RedisError("")
func isRetryableError(err error, retryTimeout bool) bool {
switch err {
case nil, context.Canceled, context.DeadlineExceeded:

Some files were not shown because too many files have changed in this diff Show More