Compare commits

...

99 Commits

Author SHA1 Message Date
Erwan Martin 0ae46aa363 Fix the bucket tests since we moved a task. 2023-10-29 21:17:23 +01:00
Erwan Martin 5313730752 Add unit tests for the CALDAV subtask feature. 2023-10-29 21:12:48 +01:00
Erwan Martin 2ae966c0ad Merge branch 'main' into feature/caldav-subtasks
# Conflicts:
#	go.mod
#	go.sum
2023-10-29 16:49:16 +01:00
renovate 66afe52afb fix(deps): update module xorm.io/xorm to v1.3.4 (#1630)
Reviewed-on: vikunja/api#1630
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2023-10-27 09:10:43 +00:00
kolaente f5e5b22641
chore(deps): update lockfile 2023-10-26 22:57:15 +02:00
renovate ae77ee068b
fix(deps): update module src.techknowlogick.com/xormigrate to v1.7.0
(cherry picked from commit 468acf72d6388dd3f1e8dd70c1c83522c78b01fb)
2023-10-26 22:56:43 +02:00
renovate f1a2028f59
fix(deps): update module github.com/google/uuid to v1.4.0
(cherry picked from commit 14e2842ceadf91bdf56012fc7862acbeb4f9b9c5)
2023-10-26 22:56:28 +02:00
renovate 70cec74239 fix(deps): update module src.techknowlogick.com/xormigrate to v1.6.0 (#1627)
Reviewed-on: vikunja/api#1627
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2023-10-26 10:56:15 +00:00
kolaente db0153a721
docs: fix typo 2023-10-24 19:55:43 +02:00
kolaente 6a7aec2e9d
docs: add n8n docs 2023-10-24 19:54:15 +02:00
Frederick [Bot] 1c416ae73e [skip ci] Updated swagger docs 2023-10-24 14:38:04 +00:00
kolaente a375223872
fix: properly tag bucket-related operations 2023-10-24 16:13:15 +02:00
kolaente a1ea77f751
feat: accept hex values which start with a # 2023-10-24 16:12:22 +02:00
Frederick [Bot] 4625377752 [skip ci] Updated swagger docs 2023-10-22 17:21:16 +00:00
kolaente aad6bc08f6
fix(ci): don't try to install when linting 2023-10-22 19:00:43 +02:00
kolaente 916e75da09
chore(ci): use golangci-lint docker image for lint step 2023-10-22 17:30:36 +02:00
kolaente 8a4856ad87 feat: convert all markdown content to html (#1625)
Migration for vikunja/frontend#2222

Reviewed-on: vikunja/api#1625
Co-authored-by: kolaente <k@knt.li>
Co-committed-by: kolaente <k@knt.li>
2023-10-22 13:48:22 +00:00
kolaente 10c9913e12
feat(notifications): add endpoint to mark all notifications as read 2023-10-20 16:40:47 +02:00
kolaente 66cf7ab50a
feat(reminders): include project in reminder notification 2023-10-20 13:56:14 +02:00
Frederick [Bot] b2b4b5423f [skip ci] Updated swagger docs 2023-10-20 11:39:17 +00:00
kolaente 1eefe265c5
chore(deps): update lockfile 2023-10-20 13:08:47 +02:00
renovate edab83b7c5
fix(deps): update src.techknowlogick.com/xgo digest to ecfba3d
(cherry picked from commit 552cc1384a615b55beddc8b8a17a690daaea18ca)
2023-10-20 13:08:18 +02:00
renovate 78812e47b2
fix(deps): update module github.com/coreos/go-oidc/v3 to v3.7.0
(cherry picked from commit 8b59eef595ba764cddcf4a82dea65fb89087eae1)
2023-10-20 13:08:02 +02:00
konrad 4d9baa38d0 feat: webhooks (#1624)
Reviewed-on: vikunja/api#1624
2023-10-20 11:06:36 +00:00
kolaente 55d345e236
feat(webhooks): validate events and target url 2023-10-20 12:42:28 +02:00
kolaente 61cd08fa13
fix(webhooks): add created by user object when creating a webhook 2023-10-18 22:18:45 +02:00
kolaente 72366a5b27
feat(webhooks): add created by user object when returning all webhooks 2023-10-18 20:06:07 +02:00
kolaente b4e3d8ee47
fix(webhooks): lint 2023-10-17 20:40:09 +02:00
kolaente 7a74e491da
fix(webhooks): lint 2023-10-17 20:35:14 +02:00
kolaente 2c84cec044
docs(webhooks): add swagger docs for all webhook endpoints 2023-10-17 20:35:14 +02:00
kolaente 35e8183f6a
docs(webhooks): add general docs about webhooks 2023-10-17 20:35:14 +02:00
kolaente fc0029eed7
fix(webhooks): don't send the proxy auth header to the webhook target 2023-10-17 20:35:14 +02:00
kolaente 177f367a8c
feat(webhooks): expose whether webhooks are enabled 2023-10-17 20:35:14 +02:00
kolaente 1b82f26d3e
chore(webhooks): simplify registering webhook events 2023-10-17 20:35:13 +02:00
kolaente ec4aa606e2
chore(webhooks): reuse webhook client 2023-10-17 20:35:13 +02:00
kolaente c3947e1016
docs(webhooks): add webhook config to sample config 2023-10-17 20:35:13 +02:00
kolaente 831aa4a014
feat(webhooks): add support for webhook proxy 2023-10-17 20:35:13 +02:00
kolaente b38360c9a5
feat(webhooks): add timeout config option 2023-10-17 20:35:13 +02:00
kolaente 34a92b759e
feat(webhooks): add setting to enable webhooks 2023-10-17 20:35:13 +02:00
kolaente 8cc775ac4c
fix(webhooks): routes should use the common schema used for other routes already 2023-10-17 20:35:13 +02:00
kolaente a0d8b28813
feat(webhooks): add hmac signing 2023-10-17 20:35:13 +02:00
kolaente a3a323cbf1
feat(webhooks): set user agent header to Vikunja 2023-10-17 20:35:13 +02:00
kolaente 4253d14367
chore(webhooks): remove WebhookEvent interface 2023-10-17 20:35:13 +02:00
kolaente 96ccf6b923
feat(webhooks): add route to get all available webhook events 2023-10-17 20:35:13 +02:00
kolaente eb1b9247ad
feat(webhooks): prevent link shares from managing webhooks 2023-10-17 20:35:13 +02:00
kolaente 57de44694c
feat(webhooks): add index on project id 2023-10-17 20:35:13 +02:00
kolaente 8d7a492936
feat(webhooks): add filter based on project id 2023-10-17 20:35:13 +02:00
kolaente 7d1c5c50c5
feat(webhooks): add basic sending of webhooks 2023-10-17 20:35:12 +02:00
kolaente 7f3c300240
feat(webhooks): add routes 2023-10-17 20:35:12 +02:00
kolaente c5de41f183
feat(webhooks): add event listener to send webhook payload 2023-10-17 20:35:12 +02:00
kolaente e5b8d8bd2d
feat(webhooks): register task and project events as webhook 2023-10-17 20:35:12 +02:00
kolaente ad7d485eb5
feat(webhooks): add basic crud actions for webhooks 2023-10-17 20:35:12 +02:00
kolaente a1d0541a7a
docs: add config guide for NGINX Proxy Manager
Taken from https://github.com/go-vikunja/frontend/issues/28#issuecomment-1765096790
2023-10-17 19:00:40 +02:00
kolaente e1525fca6e
docs: clarify required language code 2023-10-17 18:35:06 +02:00
kolaente 872acd329a
chore(deps): update lockfile 2023-10-13 14:34:36 +02:00
renovate baa907f738
fix(deps): update module github.com/gabriel-vasile/mimetype to v1.4.3
(cherry picked from commit 29e55577d05d296095f1972014b03d0d4ef0184f)
2023-10-13 14:32:49 +02:00
kolaente 21d0676399
chore(deps): update xgo to go 1.21 2023-10-11 23:05:51 +02:00
kolaente 9a29b29a04
fix(user): allow openid users to request their deletion
Resolves https://community.vikunja.io/t/delete-user-not-possible-when-using-oidc/1689/4
2023-10-11 19:06:59 +02:00
kolaente 58497f29e6
fix(kanban): filter for tasks in buckets by assignee should not modify the filter directly
Resolves https://github.com/go-vikunja/api/issues/84
2023-10-11 18:43:28 +02:00
renovate 9cdccd7005 chore(deps): update mariadb docker tag to v11 (#1544)
Reviewed-on: vikunja/api#1544
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2023-10-11 13:06:42 +00:00
renovate 3097336054 chore(deps): update goreleaser/nfpm docker tag to v2.33.1 (#1560)
Reviewed-on: vikunja/api#1560
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2023-10-11 10:25:21 +00:00
renovate 0449aaba0b chore(deps): update postgres docker tag to v16 (#1618)
Reviewed-on: vikunja/api#1618
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2023-10-11 08:51:55 +00:00
Frederick [Bot] 775b98b729 [skip ci] Updated swagger docs 2023-10-11 08:50:34 +00:00
kolaente 4ac8012117
chore(deps): update lockfile 2023-10-11 10:21:52 +02:00
renovate 83025fb527
fix(deps): update module github.com/labstack/echo/v4 to v4.11.2
(cherry picked from commit 365b46e14729689a55261f2bf6823c416455e4dd)
2023-10-11 10:21:13 +02:00
renovate 1019948216
fix(deps): update module golang.org/x/sync to v0.4.0
(cherry picked from commit 8bd5ab7f323b6ef02fe0821f795a496a794ca7f8)
2023-10-11 10:21:02 +02:00
renovate f5883db889
fix(deps): update module golang.org/x/image to v0.13.0
(cherry picked from commit aecd95bd8c159be5477e93d9ce10454ed5694dcb)
2023-10-11 10:20:51 +02:00
renovate d30965554c
fix(deps): update module github.com/swaggo/swag to v1.16.2
(cherry picked from commit 52c21eb6d539d0e9d0737deb9015ea952d820756)
2023-10-11 10:20:38 +02:00
KaibutsuX 0769d59054
feat(cli): added --confirm/-c argument when deleting users to bypass prompt (#86)
Reviewed-On: https://github.com/go-vikunja/api/pull/86
2023-10-10 21:24:25 +02:00
kolaente 332a7403ed
chore(deps): update lockfile 2023-10-10 21:23:06 +02:00
renovate eef4f0afa2
fix(deps): update module github.com/spf13/afero to v1.10.0
(cherry picked from commit 4ede68d7a753d88c84869fe5caefc9bc7b75ebbd)
2023-10-10 21:22:28 +02:00
renovate 5b7bb9f983
fix(deps): update module github.com/spf13/viper to v1.17.0
(cherry picked from commit 33ed4b71add0166b4a552c98edaf0475dffb7f6f)
2023-10-10 21:22:04 +02:00
kolaente 7eb59f577c
feat: add very basic bruno collection 2023-10-10 21:04:57 +02:00
kolaente 250043dd75
chore(deps): update lockfile 2023-10-10 20:41:12 +02:00
renovate ea476e738c
fix(deps): update module github.com/getsentry/sentry-go to v0.25.0
(cherry picked from commit ef3d658f2c5dc98ea23f414748dcb634983fb9bb)
2023-10-10 20:40:45 +02:00
renovate 7ce18860a6
fix(deps): update src.techknowlogick.com/xgo digest to 6fc6b16
(cherry picked from commit 95c5743438c5ed695d483545c618fac4ec6af44d)
2023-10-10 20:40:37 +02:00
renovate 75085302c9
fix(deps): update module github.com/prometheus/client_golang to v1.17.0
(cherry picked from commit f8c5b666859ae06979f3af388b04afc7f39632f4)
2023-10-10 20:40:19 +02:00
renovate 4c8712c70d
fix(deps): update github.com/dustinkirkland/golang-petname digest to 6a283f1
(cherry picked from commit 4bc3d50349a2c5c10d3beba91529dda3dedb72b2)
2023-10-10 20:39:50 +02:00
kolaente 56625b0b90
fix: lint 2023-10-10 20:35:43 +02:00
kolaente c9aec495d5
fix(ci): use the same go image for everything 2023-10-10 18:46:20 +02:00
kolaente f2b4702893
chore(deps): update lockfile 2023-10-10 18:21:09 +02:00
kolaente b3a932e903
chore(deps): update lockfile 2023-10-10 18:15:08 +02:00
renovate ac91a8a3a8
fix(deps): update module golang.org/x/oauth2 to v0.13.0
(cherry picked from commit 8b714eee7fa11a686e2bf543f7652bff6bdeb965)
2023-10-10 18:10:22 +02:00
renovate 33af96265b
fix(deps): update module github.com/threedotslabs/watermill to v1.3.5
(cherry picked from commit 9cccf7387ce62f38f07375f62533429e886037f6)
2023-10-10 18:10:08 +02:00
renovate 30e8dc53e5
fix(deps): update module github.com/redis/go-redis/v9 to v9.2.1
(cherry picked from commit a60158998539d43fe43a6a49652c868c891626db)
2023-10-10 18:09:57 +02:00
renovate 6e41567f9e
fix(deps): update module github.com/jinzhu/copier to v0.4.0
(cherry picked from commit a5ee64466b5a6bf9f98a08fa9ab8dbe2ee2ca29f)
2023-10-10 18:09:44 +02:00
renovate 7c008b1693
fix(deps): update module xorm.io/xorm to v1.3.3
(cherry picked from commit f3fe371f03adb6b17c1a98bfeda04834d333f020)
2023-10-10 18:09:29 +02:00
renovate 367a35912b
fix(deps): update module github.com/yuin/goldmark to v1.5.6
(cherry picked from commit 635351d0a2bd2124d98af7b5f7c78f6b21632916)
2023-10-10 18:09:15 +02:00
renovate 4f53a608a7
fix(deps): update src.techknowlogick.com/xgo digest to 1510ee0
(cherry picked from commit b555b010fd339e680dab93b80c7575c886cd3f89)
2023-10-10 18:08:58 +02:00
KaibutsuX 137f3bc151
chore: assume username instead of id when parsing fails for user commands (#87)
Reviewed-On: https://github.com/go-vikunja/api/pull/87
2023-10-10 18:06:10 +02:00
kolaente 0abf686f66
chore: add pr lockdown 2023-10-10 17:56:50 +02:00
kolaente 83f02b1ebc
chore: update contributing guidelines 2023-10-10 17:48:10 +02:00
kolaente f5ac3abb2a
chore(test): add task deleted assertion to project deletion test 2023-10-03 15:52:38 +02:00
Peter H0ffmann ad04d302af chore: reverse the coupling of module log and config (#1606)
This way the config module can already use the log module with the same result (default logging to StdOut with Level INFO, same output as before) but ENV variables can already change the logging of config file related log output). It is now possible to dump as a cronjob without having to filter the default log about the used config file.

Also:
- all logging modules are now configurable when initializing which makes testing easier
- viper dependency removed from logging
- log correct settings when configured error level is invalid
- deprecation of value "false" for log.standard and log.events (already not mentioned in https://vikunja.io/docs/config-options/)

Co-authored-by: Berengar W. Lehr <Berengar.Lehr@uni-jena.de>
Reviewed-on: vikunja/api#1606
Reviewed-by: konrad <k@knt.li>
Co-authored-by: Peter H0ffmann <hoffmannp@noreply.kolaente.de>
Co-committed-by: Peter H0ffmann <hoffmannp@noreply.kolaente.de>
2023-10-03 09:28:28 +00:00
kolaente c217233e08
fix(typesense): getting all data from typesense 2023-09-29 21:26:12 +02:00
kolaente 98102e59f2
feat(typesense): add new tasks to typesense directly when they are created 2023-09-29 21:15:28 +02:00
kolaente 8f4ee3a089
fix(typesense): make sure searching works when no task has a comment at index time 2023-09-29 16:35:59 +02:00
kolaente 70d1903dca
docs: add typesense setup 2023-09-29 12:30:53 +02:00
Erwan Martin feacbbff74 fix(caldav): do not update dates of tasks when repositioning them (#1605)
When a task is updated, the position of the tasks of the whole project/bucket are updated. This leads to column "updated" of model Task to be updated quite often. However, that column is used for the ETag field of CALDAV.
Thus, changing a task marks all the other tasks as updated, which prevents clients from synchronizing their edited tasks.

Co-authored-by: Erwan Martin <erwan@pepper.com>
Reviewed-on: vikunja/api#1605
Co-authored-by: Erwan Martin <public@fzwte.net>
Co-committed-by: Erwan Martin <public@fzwte.net>
2023-09-27 16:17:52 +00:00
82 changed files with 3625 additions and 1377 deletions

View File

@ -39,7 +39,7 @@ volumes:
services:
- name: test-mysql-unit
image: mariadb:10
image: mariadb:11
environment:
MYSQL_ROOT_PASSWORD: vikunjatest
MYSQL_DATABASE: vikunjatest
@ -47,7 +47,7 @@ services:
- name: tmp-mysql-unit
path: /var/lib/mysql
- name: test-mysql-integration
image: mariadb:10
image: mariadb:11
environment:
MYSQL_ROOT_PASSWORD: vikunjatest
MYSQL_DATABASE: vikunjatest
@ -55,7 +55,7 @@ services:
- name: tmp-mysql-integration
path: /var/lib/mysql
- name: test-mysql-migration
image: mariadb:10
image: mariadb:11
environment:
MYSQL_ROOT_PASSWORD: vikunjatest
MYSQL_DATABASE: vikunjatest
@ -63,7 +63,7 @@ services:
- name: tmp-mysql-migration
path: /var/lib/mysql
- name: test-postgres-unit
image: postgres:14
image: postgres:16
environment:
POSTGRES_PASSWORD: vikunjatest
POSTGRES_DB: vikunjatest
@ -73,7 +73,7 @@ services:
commands:
- docker-entrypoint.sh -c fsync=off -c full_page_writes=off # turns of wal
- name: test-postgres-integration
image: postgres:14
image: postgres:16
environment:
POSTGRES_PASSWORD: vikunjatest
POSTGRES_DB: vikunjatest
@ -83,7 +83,7 @@ services:
commands:
- docker-entrypoint.sh -c fsync=off -c full_page_writes=off # turns of wal
- name: test-postgres-migration
image: postgres:14
image: postgres:16
environment:
POSTGRES_PASSWORD: vikunjatest
POSTGRES_DB: vikunjatest
@ -133,15 +133,13 @@ steps:
event: [ push, tag, pull_request ]
- name: lint
image: golang:1.19-alpine
image: golangci/golangci-lint:v1.54.2
pull: always
environment:
GOPROXY: 'https://goproxy.kolaente.de'
depends_on: [ build ]
commands:
- export "GOROOT=$(go env GOROOT)"
- apk --no-cache add build-base git
- wget -O - -q https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.53.2
- ./mage-static check:golangci
when:
event: [ push, tag, pull_request ]
@ -553,7 +551,7 @@ steps:
# Build os packages and push it to our bucket
- name: build-os-packages-unstable
image: goreleaser/nfpm:v2.30.1
image: goreleaser/nfpm:v2.33.1
pull: always
commands:
- apk add git go
@ -569,7 +567,7 @@ steps:
depends_on: [ after-build-compress ]
- name: build-os-packages-version
image: goreleaser/nfpm:v2.30.1
image: goreleaser/nfpm:v2.33.1
pull: always
commands:
- apk add git go
@ -778,6 +776,6 @@ steps:
- failure
---
kind: signature
hmac: 6bc74f5b7e9c51e725100e05f07cdac656d6c3d49d19c2b112aed812c86e7a9a
hmac: 3ad78b828f36d4473527b8c6ee0985a5bf6f290fe73b3f7381a41d8c4937ffaa
...

23
.github/workflows/lockdown.yml vendored Normal file
View File

@ -0,0 +1,23 @@
name: 'Repo Lockdown'
on:
pull_request_target:
types: opened
permissions:
issues: write
pull-requests: write
jobs:
action:
runs-on: ubuntu-latest
steps:
- uses: dessant/repo-lockdown@v3
with:
pr-comment: 'Hi! Thank you for your contribution.
This repo is only a mirror and unfortunately we can''t accept PRs made here. Please re-submit your changes to [our Gitea instance](https://kolaente.dev/vikunja/api/pulls).
Also check out the [contribution guidelines](https://vikunja.io/docs/development/#pull-requests).
Thank you for your understanding.'

1
.gitignore vendored
View File

@ -27,3 +27,4 @@ vikunja-dump*
vendor/
os-packages/
mage_output_file.go
mage-static

View File

@ -103,3 +103,6 @@ issues:
- text: 'string `labels` has 3 occurrences, make it a constant'
linters:
- goconst
- text: 'string `off` has 6 occurrences, make it a constant'
linters:
- goconst

3
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,3 @@
# Contribution Guidelines
Please check out the guidelines on https://vikunja.io/docs/development/

View File

@ -3,7 +3,7 @@
# │─││ │││ │ │
# ┘─┘┘─┘┘┘─┘┘─┘
FROM --platform=$BUILDPLATFORM techknowlogick/xgo:go-1.20.x AS builder
FROM --platform=$BUILDPLATFORM techknowlogick/xgo:go-1.21.x AS builder
RUN go install github.com/magefile/mage@latest && \
mv /go/bin/mage /usr/local/go/bin

View File

@ -1,12 +0,0 @@
#!/usr/bin/env bash
curl -X POST http://localhost:3456/api/v1/register -H 'Content-Type: application/json' -d '{"username":"demo","password":"demo","email":"demo@vikunja.io"}'
BEARER=`curl -X POST -H 'Content-Type: application/json' -d '{"username": "demo", "password":"demo"}' localhost:3456/api/v1/login | jq -r '.token'`
echo "Bearer: $BEARER"
curl -X POST localhost:3456/api/v1/tokenTest -H "Authorization: Bearer $BEARER"
curl -X PUT localhost:3456/api/v1/namespaces/1/lists -H 'Content-Type: application/json' -H "Authorization: Bearer $BEARER" -d '{"title":"lorem"}'
curl -X PUT localhost:3456/api/v1/lists/1 -H 'Content-Type: application/json' -H "Authorization: Bearer $BEARER" -d '{"text":"lorem"}'
curl -X PUT -H "Authorization: Bearer $BEARER" localhost:3456/api/v1/tasks/1/attachments -F 'files=@/home/konrad/Pictures/Wallpaper/greg-rakozy-_Q4mepyyjMw-unsplash.jpg'

View File

@ -1,29 +0,0 @@
### Authorization by token, part 1. Retrieve and save token.
POST http://localhost:8080/api/v1/login
Content-Type: application/json
{
"username": "user3",
"password": "1234"
}
> {% client.global.set("auth_token", response.body.token); %}
### Register
POST http://localhost:8080/api/v1/register
Content-Type: application/json
{
"username": "user",
"password": "1234",
"email": "5@knt.li"
}
###
# Token test
POST http://localhost:8080/api/v1/tokenTest
Authorization: Bearer {{auth_token}}
Content-Type: application/json
###

View File

@ -1,70 +0,0 @@
# Get all labels
GET http://localhost:8080/api/v1/labels
Authorization: Bearer {{auth_token}}
###
# Add a new label
PUT http://localhost:8080/api/v1/labels
Authorization: Bearer {{auth_token}}
Content-Type: application/json
{
"title": "test5"
}
###
# Delete a label
DELETE http://localhost:8080/api/v1/labels/6
Authorization: Bearer {{auth_token}}
###
# Update a label
POST http://localhost:8080/api/v1/labels/1
Authorization: Bearer {{auth_token}}
Content-Type: application/json
{
"title": "testschinkenbrot",
"description": "käsebrot"
}
###
# Get one label
GET http://localhost:8080/api/v1/labels/1
Authorization: Bearer {{auth_token}}
###
# Get all labels on a task
GET http://localhost:8080/api/v1/tasks/3565/labels
Authorization: Bearer {{auth_token}}
###
# Add a new label to a task
PUT http://localhost:8080/api/v1/tasks/35236365/labels
Authorization: Bearer {{auth_token}}
Content-Type: application/json
{
"label_id": 1
}
###
# Delete a label from a task
DELETE http://localhost:8080/api/v1/tasks/3565/labels/1
Authorization: Bearer {{auth_token}}
###
# Add a new label to a task
POST http://localhost:8080/api/v1/tasks/3565/labels/bulk
Authorization: Bearer {{auth_token}}
Content-Type: application/json
{
"labels": [
{"id": 1},
{"id": 2},
{"id": 3}
]
}
###

View File

@ -1,177 +0,0 @@
# Get all lists
GET http://localhost:8080/api/v1/namespaces/35/lists
Authorization: Bearer {{auth_token}}
###
# Get one list
GET http://localhost:8080/api/v1/lists/3
Authorization: Bearer {{auth_token}}
###
# Add a new list
PUT http://localhost:8080/api/v1/namespaces/35/lists
Authorization: Bearer {{auth_token}}
Content-Type: application/json
{
"title": "test"
}
###
# Add a new item
PUT http://localhost:8080/api/v1/lists/1
Authorization: Bearer {{auth_token}}
Content-Type: application/json
{
"text": "Task",
"description": "Schinken"
}
###
# Delete a task from a list
DELETE http://localhost:8080/api/v1/lists/14
Authorization: Bearer {{auth_token}}
###
# Get all teams who have access to that list
GET http://localhost:8080/api/v1/lists/28/teams
Authorization: Bearer {{auth_token}}
###
# Give a team access to that list
PUT http://localhost:8080/api/v1/lists/1/teams
Authorization: Bearer {{auth_token}}
Content-Type: application/json
{"team_id":2, "right": 1}
###
# Update a teams access to that list
POST http://localhost:8080/api/v1/lists/1/teams/2
Authorization: Bearer {{auth_token}}
Content-Type: application/json
{"right": 0}
###
# Delete a team from a list
DELETE http://localhost:8080/api/v1/lists/10235/teams/1
Authorization: Bearer {{auth_token}}
###
# Delete a team from a list
DELETE http://localhost:8080/api/v1/lists/10235/teams/1
Authorization: Bearer {{auth_token}}
###
# Get all users who have access to that list
GET http://localhost:8080/api/v1/lists/28/users
Authorization: Bearer {{auth_token}}
###
# Give a user access to that list
PUT http://localhost:8080/api/v1/lists/3/users
Authorization: Bearer {{auth_token}}
Content-Type: application/json
{"userID":"user4", "right":1}
###
# Update a users access to that list
POST http://localhost:8080/api/v1/lists/30/users/3
Authorization: Bearer {{auth_token}}
Content-Type: application/json
{"right":2}
###
# Delete a user from a list
DELETE http://localhost:8080/api/v1/lists/28/users/3
Authorization: Bearer {{auth_token}}
###
# Get all pending tasks
GET http://localhost:8080/api/v1/tasks/all
Authorization: Bearer {{auth_token}}
###
# Get all pending tasks with priorities
GET http://localhost:8080/api/v1/tasks/all?sort=priorityasc
Authorization: Bearer {{auth_token}}
###
# Get all pending tasks in a range
GET http://localhost:8080/api/v1/tasks/all/dueadateasc/1546784000/1548784000
Authorization: Bearer {{auth_token}}
###
# Get all pending tasks in caldav
GET http://localhost:8080/api/v1/tasks/caldav
#Authorization: Bearer {{auth_token}}
###
# Update a task
POST http://localhost:8080/api/v1/tasks/3565
Authorization: Bearer {{auth_token}}
Content-Type: application/json
{
"priority": 0
}
###
# Bulk update multiple tasks at once
POST http://localhost:8080/api/v1/tasks/bulk
Authorization: Bearer {{auth_token}}
Content-Type: application/json
{
"task_ids": [3518,3519,3521],
"text":"bulkupdated"
}
###
# Get all assignees
GET http://localhost:8080/api/v1/tasks/3565/assignees
Authorization: Bearer {{auth_token}}
###
# Add a bunch of assignees
PUT http://localhost:8080/api/v1/tasks/3565/assignees/bulk
Authorization: Bearer {{auth_token}}
Content-Type: application/json
{
"assignees": [
{"id": 17}
]
}
###
# Get all users who have access to a list
GET http://localhost:8080/api/v1/lists/3/users
Authorization: Bearer {{auth_token}}
###

View File

@ -1,71 +0,0 @@
# Get all namespaces
GET http://localhost:8080/api/v1/namespaces
Authorization: Bearer {{auth_token}}
###
# Get one namespaces
GET http://localhost:8080/api/v1/namespaces/-1
Authorization: Bearer {{auth_token}}
###
# Get all users who have access to that namespace
GET http://localhost:8080/api/v1/namespaces/12/users
Authorization: Bearer {{auth_token}}
###
# Give a user access to that namespace
PUT http://localhost:8080/api/v1/namespaces/1/users
Authorization: Bearer {{auth_token}}
Content-Type: application/json
{"user_id":3, "right": 0}
###
# Update a users access to that namespace
POST http://localhost:8080/api/v1/namespaces/1/users/3
Authorization: Bearer {{auth_token}}
Content-Type: application/json
{"right": 2}
###
# Delete a user from a namespace
DELETE http://localhost:8080/api/v1/namespaces/1/users/2
Authorization: Bearer {{auth_token}}
###
# Get all teams who have access to that namespace
GET http://localhost:8080/api/v1/namespaces/1/teams
Authorization: Bearer {{auth_token}}
###
# Give a team access to that namespace
PUT http://localhost:8080/api/v1/namespaces/1/teams
Authorization: Bearer {{auth_token}}
Content-Type: application/json
{"team_id":3, "right": 0}
###
# Update a teams access to that namespace
POST http://localhost:8080/api/v1/namespaces/1/teams/1
Authorization: Bearer {{auth_token}}
Content-Type: application/json
{"right": 0}
###
# Delete a team from a namespace
DELETE http://localhost:8080/api/v1/namespaces/1/teams/2
Authorization: Bearer {{auth_token}}
###

View File

@ -1,29 +0,0 @@
# Get all teams
GET http://localhost:8080/api/v1/teams
Authorization: Bearer {{auth_token}}
###
# Get one team
GET http://localhost:8080/api/v1/teams/28
Authorization: Bearer {{auth_token}}
###
# Add a new member to that team
PUT http://localhost:8080/api/v1/teams/28/members
Authorization: Bearer {{auth_token}}
Content-Type: application/json
{
"user_id": 2
}
###
# Delete a member from a team
DELETE http://localhost:8080/api/v1/teams/28/members/2
Authorization: Bearer {{auth_token}}
###

View File

@ -1,53 +0,0 @@
# Get all users
GET http://localhost:8080/api/v1/user
Authorization: Bearer {{auth_token}}
######
# Search for a user
GET http://localhost:8080/api/v1/users?s=3
Authorization: Bearer {{auth_token}}
###
## Update password
POST http://localhost:8080/api/v1/user/password
Authorization: Bearer {{auth_token}}
Content-Type: application/json
{
"old_password": "1234",
"new_password": "1234"
}
### Request a password to reset a password
POST http://localhost:8080/api/v1/user/password/token
Content-Type: application/json
Accept: application/json
{
"email": "k@knt.li"
}
### Request a token to reset a password
POST http://localhost:8080/api/v1/user/password/reset
Content-Type: application/json
Accept: application/json
{
"token": "eAsZzakgqARnjzXHqsHqZtSUKuiOhoJjHANhgTxUIDBSalhbtdpAdLeywGXzVDBuRQGNpHdMxoHXhLVSlzpJsFvuoJgMdkhRhkNhaQXfufuZCdtUlerZHSJQLgYMUryHIxIREcmZLtWoZVrYyARkCvkyFhcGtoCwQOEjAOEZMQQuxTVoGYfAqcfNggQnerUcXCiRIgRtkusXSnltomhaeyRwAbrckXFeXxUjslgplSGqSTOqJTYuhrSzAVTwNvuYyvuXLaZoNnJEyeVDWlRydnxfgUQjQZOKwCBRWVQPKpZhlslLUyUAMsRQkHITkruQCjDnOGCCRsSNplbNCEuDmMfpWYHSQAcQIDZtbQWkxzpfmHDMQvvKPPrxEnrTErlvTfKDKICFYPQxXNpNE",
"new_password": "1234"
}
### Confirm a users email address
POST http://localhost:8080/api/v1/user/confirm
Content-Type: application/json
Accept: application/json
{
"token": ""
}
###

View File

@ -342,7 +342,17 @@ defaultsettings:
default_project_id: 0
# Start of the week for the user. `0` is sunday, `1` is monday and so on.
week_start: 0
# The language of the user interface. Must be an ISO 639-1 language code. Will default to the browser language the user uses when signing up.
# The language of the user interface. Must be an ISO 639-1 language code followed by an ISO 3166-1 alpha-2 country code. Check https://kolaente.dev/vikunja/frontend/src/branch/main/src/i18n/lang for a list of possible languages. Will default to the browser language the user uses when signing up.
language: <unset>
# The time zone of each individual user. This will affect when users get reminders and overdue task emails.
timezone: <time zone set at service.timezone>
webhooks:
# Whether to enable support for webhooks
enabled: true
# The timout in seconds until a webhook request fails when no response has been received.
timoutseconds: 30
# The URL of [a mole instance](https://github.com/frain-dev/mole) to use to proxy outgoing webhook requests. You should use this and configure appropriately if you're not the only one using your Vikunja instance. More info about why: https://webhooks.fyi/best-practices/webhook-providers#implement-security-on-egress-communication. Must be used in combination with `webhooks.password` (see below).
proxyurl:
# The proxy password to use when authenticating against the proxy.
proxypassword:

View File

@ -17,7 +17,9 @@ menu:
## General
To contribute to Vikunja, fork the project and work on the main branch.
Once you feel like your changes are ready, open a PR in the respective repo.
Once you feel like your changes are ready, open a PR in the respective repo [on our Gitea instance](https://kolaente.dev/vikunja).
We cannot accept PRs on mirror sites.
A maintainer will take a look and give you feedback. Once everyone is happy, the PR gets merged and released.
If you plan to do a bigger change, it is better to open an issue for discussion first.
@ -26,7 +28,7 @@ If you plan to do a bigger change, it is better to open an issue for discussion
The code for the api is located at [code.vikunja.io/api](https://code.vikunja.io/api).
We use go modules to manage third-party libraries for Vikunja, so you'll need at least go `1.17` to use these.
You'll need at least Go 1.21 to build Vikunja's api.
A lot of developing tasks are automated using a Magefile, so make sure to [take a look at it]({{< ref "mage.md">}}).
@ -38,11 +40,51 @@ Make sure to check the other doc articles for specific development tasks like [t
The code for the frontend is located at [code.vikunja.io/frontend](https://code.vikunja.io/frontend).
More instructions can be found in the repo's README.
You need to have [pnpm](https://pnpm.io/) and nodejs in version 16 or 18 installed.
You need to have [pnpm](https://pnpm.io/) and Node.JS in version 18 or higher installed.
## Git flow
## Pull Requests
All Pull Requests must be made [on our Gitea instance](https://kolaente.dev/vikunja).
We cannot accept PRs on mirror sites.
Please try to make your pull request easy to review.
For that, please read the [*Best Practices for Faster Reviews*](https://github.com/kubernetes/community/blob/261cb0fd089b64002c91e8eddceebf032462ccd6/contributors/guide/pull-requests.md#best-practices-for-faster-reviews) guide.
It has lots of useful tips for any project you may want to contribute to.
Some of the key points:
- Make small pull requests.
The smaller, the faster to review and the more likely it will be merged soon.
- Don't make changes unrelated to your PR.
Maybe there are typos on some comments, maybe refactoring would be welcome on a function…
but if that is not related to your PR, please make *another* PR for that.
- Split big pull requests into multiple small ones.
An incremental change will be faster to review than a huge PR.
- Allow edits by maintainers. This way, the maintainers will take care of merging the PR later on instead of you.
### PR title and summary
In the PR title, describe the problem you are fixing, not how you are fixing it.
Use the first comment as a summary of your PR.
In the PR summary, you can describe exactly how you are fixing this problem.
Keep this summary up-to-date as the PR evolves.
If your PR changes the UI, you must add **after** screenshots in the PR summary.
If your PR closes an issue, you must note that in a way that both GitHub and Gitea understand, i.e. by appending a paragraph like
```text
Fixes/Closes/Resolves #<ISSUE_NR_X>.
Fixes/Closes/Resolves #<ISSUE_NR_Y>.
```
to your summary.
Each issue that will be closed must stand on a separate line.
If your PR is related to a discussion in the forum, you must add a link to the forum discussion.
### Git flow
The `main` branch is the latest and bleeding edge branch with all changes. Unstable releases are automatically created from this branch.
New Pull-Requests should be made against the `main` branch.
A release gets tagged from the main branch with the version name as tag name.
@ -52,4 +94,4 @@ Backports and point-releases should go to a `release/version` branch, based on t
We're using [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) because they greatly simplify generating release notes.
It is not required to use them when creating a PR, but appreciated.
It is not required to use them when creating a PR, but appreciated.

View File

@ -1307,7 +1307,7 @@ Environment path: `VIKUNJA_DEFAULTSETTINGS_WEEK_START`
### language
The language of the user interface. Must be an ISO 639-1 language code. Will default to the browser language the user uses when signing up.
The language of the user interface. Must be an ISO 639-1 language code followed by an ISO 3166-1 alpha-2 country code. Check https://kolaente.dev/vikunja/frontend/src/branch/main/src/i18n/lang for a list of possible languages. Will default to the browser language the user uses when signing up.
Default: `<unset>`
@ -1327,3 +1327,53 @@ Full path: `defaultsettings.timezone`
Environment path: `VIKUNJA_DEFAULTSETTINGS_TIMEZONE`
---
## webhooks
### enabled
Whether to enable support for webhooks
Default: `true`
Full path: `webhooks.enabled`
Environment path: `VIKUNJA_WEBHOOKS_ENABLED`
### timoutseconds
The timout in seconds until a webhook request fails when no response has been received.
Default: `30`
Full path: `webhooks.timoutseconds`
Environment path: `VIKUNJA_WEBHOOKS_TIMOUTSECONDS`
### proxyurl
The URL of [a mole instance](https://github.com/frain-dev/mole) to use to proxy outgoing webhook requests. You should use this and configure appropriately if you're not the only one using your Vikunja instance. More info about why: https://webhooks.fyi/best-practices/webhook-providers#implement-security-on-egress-communication. Must be used in combination with `webhooks.password` (see below).
Default: `<empty>`
Full path: `webhooks.proxyurl`
Environment path: `VIKUNJA_WEBHOOKS_PROXYURL`
### proxypassword
The proxy password to use when authenticating against the proxy.
Default: `<empty>`
Full path: `webhooks.proxypassword`
Environment path: `VIKUNJA_WEBHOOKS_PROXYPASSWORD`

View File

@ -1,147 +1,316 @@
---
date: "2019-02-12:00:00+02:00"
title: "Reverse Proxy"
draft: false
type: "doc"
menu:
sidebar:
parent: "setup"
---
# Setup behind a reverse proxy which also serves the frontend
These examples assume you have an instance of the backend running on your server listening on port `3456`.
If you've changed this setting, you need to update the server configurations accordingly.
{{< table_of_contents >}}
## NGINX
Below are two example configurations which you can put in your `nginx.conf`:
You may need to adjust `server_name` and `root` accordingly.
### with gzip enabled (recommended)
{{< highlight conf >}}
gzip on;
gzip_disable "msie6";
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_min_length 256;
gzip_types text/plain text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript application/vnd.ms-fontobject application/x-font-ttf font/opentype image/svg+xml;
server {
listen 80;
server_name localhost;
location / {
root /path/to/vikunja/static/frontend/files;
try_files $uri $uri/ /;
index index.html index.htm;
}
location ~* ^/(api|dav|\.well-known)/ {
proxy_pass http://localhost:3456;
client_max_body_size 20M;
}
}
{{< /highlight >}}
<div class="notification is-warning">
<b>NOTE:</b> If you change the max upload size in Vikunja's settings, you'll need to also change the <code>client_max_body_size</code> in the nginx proxy config.
</div>
### without gzip
{{< highlight conf >}}
server {
listen 80;
server_name localhost;
location / {
root /path/to/vikunja/static/frontend/files;
try_files $uri $uri/ /;
index index.html index.htm;
}
location ~* ^/(api|dav|\.well-known)/ {
proxy_pass http://localhost:3456;
client_max_body_size 20M;
}
}
{{< /highlight >}}
<div class="notification is-warning">
<b>NOTE:</b> If you change the max upload size in Vikunja's settings, you'll need to also change the <code>client_max_body_size</code> in the nginx proxy config.
</div>
## NGINX Proxy Manager (NPM)
1. Create a standard Proxy Host for the Vikunja Frontend within NPM and point it to the URL you plan to use. The next several steps will enable the Proxy Host to successfully navigate to the API (on port 3456).
2. Verify that the page will pull up in your browser. (Do not bother trying to log in. It won't work. Trust me.)
3. Now, we'll work with the NPM container, so you need to identify the container name for your NPM installation. e.g. NGINX-PM
4. From the command line, enter `sudo docker exec -it [NGINX-PM container name] /bin/bash` and navigate to the proxy hosts folder where the `.conf` files are stashed. Probably `/data/nginx/proxy_host`. (This folder is a persistent folder created in the NPM container and mounted by NPM.)
5. Locate the `.conf` file where the server_name inside the file matches your Vikunja Proxy Host. Once found, add the following code, unchanged, just above the existing location block in that file. (They are listed by number, not name.)
```nginx
location ~* ^/(api|dav|\.well-known)/ {
proxy_pass http://api:3456;
client_max_body_size 20M;
}
```
6. After saving the edited file, return to NPM's UI browser window and refresh the page to verify your Proxy Host for Vikunja is still online.
7. Now, switch over to your Vikunja browser window and hit refresh. If you configured your URL correctly in original Vikunja container, you should be all set and the browser will correctly show Vikunja. If not, you'll need to adjust the address in the top of the login subscreen to match your proxy address.
## Apache
Put the following config in `cat /etc/apache2/sites-available/vikunja.conf`:
{{< highlight aconf >}}
<VirtualHost *:80>
ServerName localhost
<Proxy *>
Order Deny,Allow
Allow from all
</Proxy>
ProxyPass /api http://localhost:3456/api
ProxyPassReverse /api http://localhost:3456/api
ProxyPass /dav http://localhost:3456/dav
ProxyPassReverse /dav http://localhost:3456/dav
ProxyPass /.well-known http://localhost:3456/.well-known
ProxyPassReverse /.well-known http://localhost:3456/.well-known
DocumentRoot /var/www/html
RewriteEngine On
RewriteRule ^\/?(favicon\.ico|assets|audio|fonts|images|manifest\.webmanifest|robots\.txt|sw\.js|workbox-.*|api|dav|\.well-known) - [L]
RewriteRule ^(.*)$ /index.html [QSA,L]
</VirtualHost>
{{< /highlight >}}
**Note:** The apache modules `proxy`, `proxy_http` and `rewrite` must be enabled for this.
For more details see the [frontend apache configuration]({{< ref "install-frontend.md#apache">}}).
## Caddy
{{< highlight conf >}}
vikunja.domainname.tld {
@paths {
path /api/* /.well-known/* /dav/*
}
handle @paths {
reverse_proxy 127.0.0.1:3456
}
handle {
encode zstd gzip
root * /var/www/html/vikunja
try_files {path} index.html
file_server
}
}
{{< /highlight >}}
---
date: "2019-02-12:00:00+02:00"
title: "Reverse Proxy"
draft: false
type: "doc"
menu:
sidebar:
parent: "setup"
---
# Setup behind a reverse proxy which also serves the frontend
These examples assume you have an instance of the backend running on your server listening on port `3456`.
If you've changed this setting, you need to update the server configurations accordingly.
{{< table_of_contents >}}
## NGINX
Below are two example configurations which you can put in your `nginx.conf`:
You may need to adjust `server_name` and `root` accordingly.
### with gzip enabled (recommended)
{{< highlight conf >}}
gzip on;
gzip_disable "msie6";
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_min_length 256;
gzip_types text/plain text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript application/vnd.ms-fontobject application/x-font-ttf font/opentype image/svg+xml;
server {
listen 80;
server_name localhost;
location / {
root /path/to/vikunja/static/frontend/files;
try_files $uri $uri/ /;
index index.html index.htm;
}
location ~* ^/(api|dav|\.well-known)/ {
proxy_pass http://localhost:3456;
client_max_body_size 20M;
}
}
{{< /highlight >}}
<div class="notification is-warning">
<b>NOTE:</b> If you change the max upload size in Vikunja's settings, you'll need to also change the <code>client_max_body_size</code> in the nginx proxy config.
</div>
### without gzip
{{< highlight conf >}}
server {
listen 80;
server_name localhost;
location / {
root /path/to/vikunja/static/frontend/files;
try_files $uri $uri/ /;
index index.html index.htm;
}
location ~* ^/(api|dav|\.well-known)/ {
proxy_pass http://localhost:3456;
client_max_body_size 20M;
}
}
{{< /highlight >}}
<div class="notification is-warning">
<b>NOTE:</b> If you change the max upload size in Vikunja's settings, you'll need to also change the <code>client_max_body_size</code> in the nginx proxy config.
</div>
## NGINX Proxy Manager (NPM)
### Method 1
Following the [Docker Walkthrough]({{< ref "docker-start-to-finish.md" >}}) guide, you should be able to get Vikunja to work via HTTP connection to your server IP.
From there, all you have to do is adjust the following things:
#### In `docker-compose.yml`
Under `api:`,
1. Change `VIKUNJA_SERVICE_FRONTENDURL:` to your desired domain with `https://` and `/`.
2. Expose your desired port on host under `ports:`.
example:
```yaml
api:
image: vikunja/api
environment:
VIKUNJA_DATABASE_HOST: db
VIKUNJA_DATABASE_PASSWORD: secret
VIKUNJA_DATABASE_TYPE: mysql
VIKUNJA_DATABASE_USER: vikunja
VIKUNJA_DATABASE_DATABASE: vikunja
VIKUNJA_SERVICE_JWTSECRET: <your-random-secret>
VIKUNJA_SERVICE_FRONTENDURL: https://vikunja.your-domain.com/ # change vikunja.your-domain.com to your desired domain/subdomain.
ports:
- 3456:3456 # Change 3456 on the left to the port of your choice.
volumes:
- ./files:/app/vikunja/files
depends_on:
- db
restart: unless-stopped
```
Under `frontend:`,
1. Add `VIKUNJA_API_URL:` under `environment:` and input your desired `API` domain with `https://` and `/api/v1/`. The `API` domain should be different from the one in `VIKUNJA_SERVICE_FRONTENDURL:`.
example:
```yaml
frontend:
image: vikunja/frontend
environment:
VIKUNJA_API_URL: https://api.your-domain.com/api/v1/ # change api.your-domain.com to your desired domain/subdomain, it should be different from your frontend domain
restart: unless-stopped
```
Under `proxy:`,
1. Since we'll be using Nginx Proxy Manager, it should by default uses the port `80` and thus you should change `ports:` to expose another port not occupied by any service.
example:
```yaml
proxy:
image: nginx
ports:
- 1078:80 # change the number infront (host port) to whatever you desire, but make sure it's not 80 which will be used by Nginx Proxy Manager
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- api
- frontend
restart: unless-stopped
```
#### In your DNS provider
Add two `A` records that points to your server IP.
1. `vikunja` for accessing the frontend
2. `api` for accessing the api
You are of course free to change them to whatever domain/subdomain you desire and modify the `docker-compose.yml` accordingly but the two should be different.
(Tested on Cloudflare DNS. Settings are different for different DNS provider, in this case the end result should bei `vikunja.your-domain.com` and `api.your-domain.com` respectively.)
#### In Nginx Proxy Manager
Add two Proxy Host as you normally would, and you don't have to add anything extra in Advanced.
##### Frontend
Under `Details`:
```
Domain Names:
vikunja.your-domain.com
Scheme:
http
Forward Hostname/IP:
your-server-ip
Forward Port:
1078
Cached Assets:
Optional.
Block Common Exploits:
Toggled.
Websockets Support:
Toggled.
```
Under `SSL`:
```
SSL Certificate:
However you prefer.
Force SSL:
Toggled.
HTTP/2 Support:
Toggled.
HSTS Enabled:
Toggled.
HSTS Subdomains:
Toggled.
Use a DNS Challenge:
Not toggled.
Email Address for Let's Encrypt:
your-email@email.com
```
##### API
Under `Details`:
```
Domain Names:
api.your-domain.com
Scheme:
http
Forward Hostname/IP:
your-server-ip
Forward Port:
3456
Cached Assets:
Optional.
Block Common Exploits:
Toggled.
Websockets Support:
Toggled.
```
Under `SSL`:
```
SSL Certificate:
However you prefer.
Force SSL:
Toggled.
HTTP/2 Support:
Toggled.
HSTS Enabled:
Toggled.
HSTS Subdomains:
Toggled.
Use a DNS Challenge:
Not toggled.
Email Address for Let's Encrypt:
your-email@email.com
```
Your Vikunja service should now work and your HTTPS frontend should be able to reach the API after `docker-compose`.
### Method 2
1. Create a standard Proxy Host for the Vikunja Frontend within NPM and point it to the URL you plan to use. The next several steps will enable the Proxy Host to successfully navigate to the API (on port 3456).
2. Verify that the page will pull up in your browser. (Do not bother trying to log in. It won't work. Trust me.)
3. Now, we'll work with the NPM container, so you need to identify the container name for your NPM installation. e.g. NGINX-PM
4. From the command line, enter `sudo docker exec -it [NGINX-PM container name] /bin/bash` and navigate to the proxy hosts folder where the `.conf` files are stashed. Probably `/data/nginx/proxy_host`. (This folder is a persistent folder created in the NPM container and mounted by NPM.)
5. Locate the `.conf` file where the server_name inside the file matches your Vikunja Proxy Host. Once found, add the following code, unchanged, just above the existing location block in that file. (They are listed by number, not name.)
```nginx
location ~* ^/(api|dav|\.well-known)/ {
proxy_pass http://api:3456;
client_max_body_size 20M;
}
```
6. After saving the edited file, return to NPM's UI browser window and refresh the page to verify your Proxy Host for Vikunja is still online.
7. Now, switch over to your Vikunja browser window and hit refresh. If you configured your URL correctly in original Vikunja container, you should be all set and the browser will correctly show Vikunja. If not, you'll need to adjust the address in the top of the login subscreen to match your proxy address.
## Apache
Put the following config in `cat /etc/apache2/sites-available/vikunja.conf`:
{{< highlight aconf >}}
<VirtualHost *:80>
ServerName localhost
<Proxy *>
Order Deny,Allow
Allow from all
</Proxy>
ProxyPass /api http://localhost:3456/api
ProxyPassReverse /api http://localhost:3456/api
ProxyPass /dav http://localhost:3456/dav
ProxyPassReverse /dav http://localhost:3456/dav
ProxyPass /.well-known http://localhost:3456/.well-known
ProxyPassReverse /.well-known http://localhost:3456/.well-known
DocumentRoot /var/www/html
RewriteEngine On
RewriteRule ^\/?(favicon\.ico|assets|audio|fonts|images|manifest\.webmanifest|robots\.txt|sw\.js|workbox-.*|api|dav|\.well-known) - [L]
RewriteRule ^(.*)$ /index.html [QSA,L]
</VirtualHost>
{{< /highlight >}}
**Note:** The apache modules `proxy`, `proxy_http` and `rewrite` must be enabled for this.
For more details see the [frontend apache configuration]({{< ref "install-frontend.md#apache">}}).
## Caddy
{{< highlight conf >}}
vikunja.domainname.tld {
@paths {
path /api/* /.well-known/* /dav/*
}
handle @paths {
reverse_proxy 127.0.0.1:3456
}
handle {
encode zstd gzip
root * /var/www/html/vikunja
try_files {path} index.html
file_server
}
}
{{< /highlight >}}

View File

@ -0,0 +1,23 @@
---
title: "Typesense"
date: 2023-09-29T12:23:55+02:00
draft: false
menu:
sidebar:
parent: "setup"
---
# Use Typesense for enhanced search capabilities
Vikunja supports using [Typesense](https://typesense.org/) for a better search experience.
Typesense allows fast fulltext search including fuzzy matching support.
It may return different results than what you'd get with a database-only search, but generally, the results are more relevant to what you're looking for.
This document explains how to set up and use Typesense with Vikunja.
## Setup
1. First, install Typesense on your system. Refer to [their documentation](https://typesense.org/docs/guide/install-typesense.html) for specific instructions.
2. Once Typesense is available on your system and reachable by Vikunja, add the relevant configuration keys to your Vikunja config. [Check out the docs article about this]({{< ref "config.md#typesense">}}).
3. Index all tasks currently in Vikunja. To do that, run the `vikunja index` command with the api binary. This may take a while, depending on the size of your instance.
4. Restart the api. From now on, all task changes will be automatically indexed in Typesense.

View File

@ -0,0 +1,43 @@
---
title: "n8n"
date: 2023-10-24T19:31:35+02:00
draft: false
menu:
sidebar:
parent: "usage"
---
# Using Vikunja with n8n
Vikunja maintains a [community node](https://github.com/go-vikunja/n8n-vikunja-nodes) for [n8n](https://n8n.io),
allowing you to easily integrate Vikunja with all kinds of other tools and services.
{{< table_of_contents >}}
## Installation
To install the node in your n8n installation:
1. In your n8n instance, go to **Settings > Community Nodes**.
2. Select Install.
3. Enter `n8n-nodes-vikunja` as the npm Package Name
4. Agree to the risks of using community nodes: select I understand the risks of installing unverified code from a
public source.
5. Select Install. n8n installs the node, and returns to the Community Nodes list in Settings.
6. Vikunja actions and triggers are now available in n8n.
[Official n8n docs about the installation](https://docs.n8n.io/integrations/community-nodes/installation/)
## Authentication
To authenticate your automation against Vikunja:
1. In Vikunja, go to **Settings > API Tokens** and create a new token. Use all scopes for the kind of task you want to
do. \
*Note:* If you want to use the webhook trigger node, the api token should have permissions to create, read and delete
webhooks.
2. Now in n8n, go to **Credentials** and then click on **Add Credential**.
3. Search for `Vikunja API` and click *Continue*
4. Enter the API key you created in step 1.
5. Enter the API URL of your Vikunja instance, with `/api/v1` suffix.
6. When you now create a Vikunja node, select the created credentials.

View File

@ -0,0 +1,58 @@
---
title: "Webhooks"
date: 2023-10-17T19:51:32+02:00
draft: false
type: doc
menu:
sidebar:
parent: "usage"
---
# Webhooks
Starting with version 0.22.0, Vikunja allows you to define webhooks to notify other services of events happening within Vikunja.
{{< table_of_contents >}}
## How to create webhooks
To create a webhook, in the project options select "Webhooks". The form will allow you to create and modify webhooks.
Check out [the api docs](https://try.vikunja.io/api/v1/docs#tag/webhooks) for information about how to create webhooks programatically.
## Available events and their payload
All events registered as webhook events in [the event listeners definition](https://kolaente.dev/vikunja/api/src/branch/main/pkg/models/listeners.go#L69) can be used as webhook target.
A webhook payload will look similar to this:
```json
{
"event_name": "task.created",
"time": "2023-10-17T19:39:32.924194436+02:00",
"data": {}
}
```
The `data` property will contain the raw event data as it was registered in the `listeners.go` file.
The `time` property holds the time when the webhook payload data was sent.
It always uses the ISO 8601 format with date, time and time zone offset.
## Security considerations
### Signing
Vikunja allows you to provide a secret when creating the webhook.
If you set a secret, all outgoing webhook requests will contain an `X-Vikunja-Signature` header with an HMAC signature over the webhook json payload.
Check out [webhooks.fyi](https://webhooks.fyi/security/hmac) for more information about how to validate the HMAC signature.
### Hosting webhook infrastructure
Vikunja has support to use [mole](https://github.com/frain-dev/mole) as a proxy for outgoing webhook requests.
This allows you to prevent SSRF attacts on your own infrastructure.
You should use this and [configure it appropriately]({{< ref "../setup/config.md">}}#webhooks) if you're not the only one using your Vikunja instance.
Check out [webhooks.fyi](https://webhooks.fyi/best-practices/webhook-providers#implement-security-on-egress-communication) for more information about the attack vector and reasoning to prevent this.

83
go.mod
View File

@ -19,31 +19,31 @@ module code.vikunja.io/api
require (
code.vikunja.io/web v0.0.0-20210706160506-d85def955bd3
dario.cat/mergo v1.0.0
github.com/ThreeDotsLabs/watermill v1.2.0
github.com/ThreeDotsLabs/watermill v1.3.5
github.com/adlio/trello v1.10.0
github.com/arran4/golang-ical v0.1.0
github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef
github.com/bbrks/go-blurhash v1.1.1
github.com/c2h5oh/datasize v0.0.0-20220606134207-859f65c6625b
github.com/coreos/go-oidc/v3 v3.6.0
github.com/coreos/go-oidc/v3 v3.7.0
github.com/cweill/gotests v1.6.0
github.com/d4l3k/messagediff v1.2.1
github.com/disintegration/imaging v1.6.2
github.com/dustinkirkland/golang-petname v0.0.0-20230626224747-e794b9370d49
github.com/gabriel-vasile/mimetype v1.4.2
github.com/getsentry/sentry-go v0.23.0
github.com/dustinkirkland/golang-petname v0.0.0-20231002161417-6a283f1aaaf2
github.com/gabriel-vasile/mimetype v1.4.3
github.com/getsentry/sentry-go v0.25.0
github.com/go-sql-driver/mysql v1.7.1
github.com/go-testfixtures/testfixtures/v3 v3.9.0
github.com/gocarina/gocsv v0.0.0-20230616125104-99d496ca653d
github.com/golang-jwt/jwt/v5 v5.0.0
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
github.com/google/uuid v1.3.1
github.com/google/uuid v1.4.0
github.com/hashicorp/go-version v1.6.0
github.com/iancoleman/strcase v0.3.0
github.com/jinzhu/copier v0.3.5
github.com/jinzhu/copier v0.4.0
github.com/jszwedko/go-datemath v0.1.1-0.20230526204004-640a500621d6
github.com/labstack/echo-jwt/v4 v4.2.0
github.com/labstack/echo/v4 v4.11.1
github.com/labstack/echo/v4 v4.11.2
github.com/labstack/gommon v0.4.0
github.com/lib/pq v1.10.9
github.com/magefile/mage v1.15.0
@ -51,33 +51,32 @@ require (
github.com/olekukonko/tablewriter v0.0.5
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
github.com/pquerna/otp v1.4.0
github.com/prometheus/client_golang v1.16.0
github.com/redis/go-redis/v9 v9.0.5
github.com/prometheus/client_golang v1.17.0
github.com/redis/go-redis/v9 v9.2.1
github.com/robfig/cron/v3 v3.0.1
github.com/samedi/caldav-go v3.0.0+incompatible
github.com/spf13/afero v1.9.5
github.com/spf13/afero v1.10.0
github.com/spf13/cobra v1.7.0
github.com/spf13/viper v1.16.0
github.com/spf13/viper v1.17.0
github.com/stretchr/testify v1.8.4
github.com/swaggo/swag v1.8.12
github.com/swaggo/swag v1.16.2
github.com/tkuchiki/go-timezone v0.2.2
github.com/typesense/typesense-go v0.8.0
github.com/ulule/limiter/v3 v3.11.2
github.com/wneessen/go-mail v0.4.0
github.com/yuin/goldmark v1.5.4
golang.org/x/crypto v0.12.0
golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63
golang.org/x/image v0.11.0
golang.org/x/oauth2 v0.10.0
golang.org/x/sync v0.3.0
golang.org/x/sys v0.11.0
golang.org/x/term v0.11.0
github.com/yuin/goldmark v1.5.6
golang.org/x/crypto v0.14.0
golang.org/x/image v0.13.0
golang.org/x/oauth2 v0.13.0
golang.org/x/sync v0.4.0
golang.org/x/sys v0.13.0
golang.org/x/term v0.13.0
gopkg.in/d4l3k/messagediff.v1 v1.2.1
gopkg.in/yaml.v3 v3.0.1
src.techknowlogick.com/xgo v1.7.1-0.20230711181658-617d3b65dd40
src.techknowlogick.com/xormigrate v1.5.0
src.techknowlogick.com/xgo v1.7.1-0.20231019133136-ecfba3dfed5d
src.techknowlogick.com/xormigrate v1.7.0
xorm.io/builder v0.3.13
xorm.io/xorm v1.3.2
xorm.io/xorm v1.3.4
)
require (
@ -97,11 +96,10 @@ require (
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
github.com/chenzhuoyu/iasm v0.9.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/deepmap/oapi-codegen v1.13.4 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/ghodss/yaml v1.0.0 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/gin-gonic/gin v1.9.1 // indirect
github.com/go-chi/chi/v5 v5.0.10 // indirect
@ -125,7 +123,7 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.16.7 // indirect
github.com/klauspost/compress v1.17.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
github.com/laurent22/ical-go v0.1.1-0.20181107184520-7e5d6ade8eef // indirect
github.com/leodido/go-urn v1.2.4 // indirect
@ -143,22 +141,24 @@ require (
github.com/onsi/ginkgo v1.16.4 // indirect
github.com/onsi/gomega v1.16.0 // indirect
github.com/paulmach/orb v0.9.0 // indirect
github.com/pelletier/go-toml/v2 v2.0.9 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/pierrec/lz4/v4 v4.1.17 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.3.0 // indirect
github.com/prometheus/common v0.42.0 // indirect
github.com/prometheus/procfs v0.10.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 // indirect
github.com/prometheus/common v0.44.0 // indirect
github.com/prometheus/procfs v0.11.1 // indirect
github.com/rivo/uniseg v0.4.4 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sagikazarmark/locafero v0.3.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/shopspring/decimal v1.3.1 // indirect
github.com/sony/gobreaker v0.5.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/cast v1.5.1 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.4.2 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/syndtr/goleveldb v1.0.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
@ -168,18 +168,23 @@ require (
github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4 // indirect
go.opentelemetry.io/otel v1.15.0 // indirect
go.opentelemetry.io/otel/trace v1.15.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.4.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/mod v0.12.0 // indirect
golang.org/x/net v0.14.0 // indirect
golang.org/x/text v0.12.0 // indirect
golang.org/x/net v0.17.0 // indirect
golang.org/x/text v0.13.0 // indirect
golang.org/x/time v0.3.0 // indirect
golang.org/x/tools v0.12.1-0.20230815132531-74c255bcf846 // indirect
google.golang.org/appengine v1.6.7 // indirect
golang.org/x/tools v0.13.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
sigs.k8s.io/yaml v1.3.0 // indirect
)
replace github.com/samedi/caldav-go => github.com/kolaente/caldav-go v3.0.1-0.20190610114120-2a4eb8b5dcc9+incompatible // Branch: feature/dynamic-supported-components, PR: https://github.com/samedi/caldav-go/pull/6 and https://github.com/samedi/caldav-go/pull/7
go 1.20
go 1.21
toolchain go1.21.2

600
go.sum

File diff suppressed because it is too large Load Diff

View File

@ -412,7 +412,7 @@ func checkGolangCiLintInstalled() {
mg.Deps(initVars)
if err := exec.Command("golangci-lint").Run(); err != nil && strings.Contains(err.Error(), "executable file not found") {
fmt.Println("Please manually install golangci-lint by running")
fmt.Println("curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.53.2")
fmt.Println("curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.54.2")
os.Exit(1)
}
}

View File

@ -46,6 +46,7 @@ var (
userFlagEnableUser bool
userFlagDisableUser bool
userFlagDeleteNow bool
userFlagDeleteConfirm bool
)
func init() {
@ -73,6 +74,9 @@ func init() {
// User deletion flags
userDeleteCmd.Flags().BoolVarP(&userFlagDeleteNow, "now", "n", false, "If provided, deletes the user immediately instead of sending them an email first.")
// Bypass confirm prompt
userDeleteCmd.Flags().BoolVarP(&userFlagDeleteConfirm, "confirm", "c", false, "Bypasses any prompts confirming the deletion request, use with caution!")
userCmd.AddCommand(userListCmd, userCreateCmd, userUpdateCmd, userResetPasswordCmd, userChangeEnabledCmd, userDeleteCmd)
rootCmd.AddCommand(userCmd)
}
@ -100,12 +104,16 @@ func getPasswordFromFlagOrInput() (pw string) {
}
func getUserFromArg(s *xorm.Session, arg string) *user.User {
filter := user.User{}
id, err := strconv.ParseInt(arg, 10, 64)
if err != nil {
log.Fatalf("Invalid user id: %s", err)
log.Infof("Invalid user ID [%s], assuming username instead", arg)
filter.Username = arg
} else {
filter.ID = id
}
u, err := user.GetUserWithEmail(s, &user.User{ID: id})
u, err := user.GetUserWithEmail(s, &filter)
if err != nil {
log.Fatalf("Could not get user: %s", err)
}
@ -322,7 +330,7 @@ var userDeleteCmd = &cobra.Command{
initialize.FullInit()
},
Run: func(cmd *cobra.Command, args []string) {
if userFlagDeleteNow {
if userFlagDeleteNow && !userFlagDeleteConfirm {
fmt.Println("You requested to delete the user immediately. Are you sure?")
fmt.Println(`To confirm, please type "yes, I confirm" in all uppercase:`)

View File

@ -19,7 +19,6 @@ package config
import (
"crypto/rand"
"fmt"
"log"
"os"
"os/exec"
"path"
@ -29,6 +28,7 @@ import (
"time"
_ "time/tzdata" // Imports time zone data instead of relying on the os
"code.vikunja.io/api/pkg/log"
"github.com/spf13/viper"
)
@ -172,6 +172,11 @@ const (
DefaultSettingsLanguage Key = `defaultsettings.language`
DefaultSettingsTimezone Key = `defaultsettings.timezone`
DefaultSettingsOverdueTaskRemindersTime Key = `defaultsettings.overdue_tasks_reminders_time`
WebhooksEnabled Key = `webhooks.enabled`
WebhooksTimeoutSeconds Key = `webhooks.timeoutseconds`
WebhooksProxyURL Key = `webhooks.proxyurl`
WebhooksProxyPassword Key = `webhooks.proxypassword`
)
// GetString returns a string config value
@ -387,6 +392,9 @@ func InitDefaultConfig() {
DefaultSettingsAvatarProvider.setDefault("initials")
DefaultSettingsOverdueTaskRemindersEnabled.setDefault(true)
DefaultSettingsOverdueTaskRemindersTime.setDefault("9:00")
// Webhook
WebhooksEnabled.setDefault(true)
WebhooksTimeoutSeconds.setDefault(30)
}
// InitConfig initializes the config, sets defaults etc.
@ -400,13 +408,17 @@ func InitConfig() {
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
viper.AutomaticEnv()
// Just load environment variables
_ = viper.ReadInConfig()
log.ConfigLogger(LogEnabled.GetBool(), LogStandard.GetString(), LogPath.GetString(), LogLevel.GetString())
// Load the config file
viper.AddConfigPath(ServiceRootpath.GetString())
viper.AddConfigPath("/etc/vikunja/")
homeDir, err := os.UserHomeDir()
if err != nil {
log.Printf("No home directory found, not using config from ~/.config/vikunja/. Error was: %s\n", err.Error())
log.Debugf("No home directory found, not using config from ~/.config/vikunja/. Error was: %s\n", err.Error())
} else {
viper.AddConfigPath(path.Join(homeDir, ".config", "vikunja"))
}
@ -415,15 +427,18 @@ func InitConfig() {
viper.SetConfigName("config")
err = viper.ReadInConfig()
if viper.ConfigFileUsed() != "" {
log.Printf("Using config file: %s", viper.ConfigFileUsed())
log.Infof("Using config file: %s", viper.ConfigFileUsed())
if err != nil {
log.Println(err.Error())
log.Println("Using default config.")
log.Warning(err.Error())
log.Warning("Using default config.")
} else {
log.ConfigLogger(LogEnabled.GetBool(), LogStandard.GetString(), LogPath.GetString(), LogLevel.GetString())
}
} else {
log.Println("No config file found, using default or config from environment variables.")
log.Info("No config file found, using default or config from environment variables.")
}
if RateLimitStore.GetString() == "keyvalue" {
@ -455,7 +470,7 @@ func InitConfig() {
}
if ServiceEnableMetrics.GetBool() {
log.Println("WARNING: service.enablemetrics is deprecated and will be removed in a future release. Please use metrics.enable.")
log.Warning("service.enablemetrics is deprecated and will be removed in a future release. Please use metrics.enable.")
MetricsEnabled.Set(true)
}
}

View File

@ -79,7 +79,7 @@ func CreateDBEngine() (engine *xorm.Engine, err error) {
}
engine.SetTZDatabase(loc)
engine.SetMapper(names.GonicMapper{})
logger := log.NewXormLogger("")
logger := log.NewXormLogger(config.LogEnabled.GetBool(), config.LogDatabase.GetString(), config.LogDatabaseLevel.GetString())
engine.SetLogger(logger)
x = engine

View File

@ -34,3 +34,27 @@
relation_kind: 'related'
created_by_id: 1
created: 2018-12-01 15:13:12
- id: 7
task_id: 41
other_task_id: 43
relation_kind: 'subtask'
created_by_id: 15
created: 2018-12-01 15:13:12
- id: 8
task_id: 43
other_task_id: 41
relation_kind: 'parenttask'
created_by_id: 15
created: 2018-12-01 15:13:12
- id: 9
task_id: 41
other_task_id: 44
relation_kind: 'subtask'
created_by_id: 15
created: 2018-12-01 15:13:12
- id: 10
task_id: 44
other_task_id: 41
relation_kind: 'parenttask'
created_by_id: 15
created: 2018-12-01 15:13:12

View File

@ -374,5 +374,61 @@
due_date: 2023-03-01 15:00:00
created: 2018-12-01 01:12:04
updated: 2018-12-01 01:12:04
bucket_id: 1
bucket_id: 38
position: 39
- id: 41
uid: 'uid-caldav-test-parent-task'
title: 'Parent task for Caldav Test'
description: 'Description Caldav Test'
priority: 3
done: false
created_by_id: 15
project_id: 36
index: 40
due_date: 2023-03-01 15:00:00
created: 2018-12-01 01:12:04
updated: 2018-12-01 01:12:04
bucket_id: 38
position: 40
- id: 42
uid: 'uid-caldav-test-parent-task-2'
title: 'Parent task for Caldav Test 2'
description: 'Description Caldav Test 2'
priority: 3
done: false
created_by_id: 15
project_id: 36
index: 41
due_date: 2023-03-01 15:00:00
created: 2018-12-01 01:12:04
updated: 2018-12-01 01:12:04
bucket_id: 38
position: 41
- id: 43
uid: 'uid-caldav-test-child-task'
title: 'Child task for Caldav Test'
description: 'Description Caldav Test'
priority: 3
done: false
created_by_id: 15
project_id: 36
index: 42
due_date: 2023-03-01 15:00:00
created: 2018-12-01 01:12:04
updated: 2018-12-01 01:12:04
bucket_id: 38
position: 42
- id: 44
uid: 'uid-caldav-test-child-task-2'
title: 'Child task for Caldav Test 2'
description: 'Description Caldav Test'
priority: 3
done: false
created_by_id: 15
project_id: 36
index: 43
due_date: 2023-03-01 15:00:00
created: 2018-12-01 01:12:04
updated: 2018-12-01 01:12:04
bucket_id: 38
position: 43

View File

@ -52,7 +52,7 @@ func CreateTestEngine() (engine *xorm.Engine, err error) {
}
engine.SetMapper(names.GonicMapper{})
logger := log.NewXormLogger("DEBUG")
logger := log.NewXormLogger(config.LogEnabled.GetBool(), config.LogDatabase.GetString(), "DEBUG")
logger.ShowSQL(os.Getenv("UNIT_TESTS_VERBOSE") == "1")
engine.SetLogger(logger)
engine.SetTZLocation(config.GetTimeZone())

View File

@ -21,6 +21,7 @@ import (
"encoding/json"
"time"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/log"
vmetrics "code.vikunja.io/api/pkg/metrics"
"github.com/ThreeDotsLabs/watermill"
@ -39,7 +40,7 @@ type Event interface {
// InitEvents sets up everything needed to work with events
func InitEvents() (err error) {
logger := log.NewWatermillLogger()
logger := log.NewWatermillLogger(config.LogEnabled.GetBool(), config.LogEvents.GetString(), config.LogEventsLevel.GetString())
router, err := message.NewRouter(
message.RouterConfig{},

View File

@ -35,6 +35,9 @@ import (
// LightInit will only fullInit config, redis, logger but no db connection.
func LightInit() {
// Set logger
log.InitLogger()
// Init the config
config.InitConfig()
@ -43,9 +46,6 @@ func LightInit() {
// Init keyvalue store
keyvalue.InitStorage()
// Set logger
log.InitLogger()
}
// InitEngines intializes all db connections

View File

@ -22,9 +22,7 @@ import (
"strings"
"time"
"code.vikunja.io/api/pkg/config"
"github.com/op/go-logging"
"github.com/spf13/viper"
)
// ErrFmt holds the format for all the console logging
@ -41,42 +39,45 @@ const logModule = `vikunja`
// loginstance is the instance of the logger which is used under the hood to log
var logInstance = logging.MustGetLogger(logModule)
// logpath is the path in which log files will be written.
// This value is a mere fallback for other modules that could but shouldn't be used before calling ConfigLogger
var logPath = "."
// InitLogger initializes the global log handler
func InitLogger() {
if !config.LogEnabled.GetBool() {
// Disable all logging when loggin in general is disabled, overwriting everything a user might have set.
config.LogStandard.Set("off")
config.LogDatabase.Set("off")
config.LogHTTP.Set("off")
config.LogEcho.Set("off")
config.LogEvents.Set("off")
return
}
// This show correct caller functions
logInstance.ExtraCalldepth = 1
if config.LogStandard.GetString() == "file" {
err := os.Mkdir(config.LogPath.GetString(), 0744)
if err != nil && !os.IsExist(err) {
Fatalf("Could not create log folder: %s", err.Error())
}
// Init with stdout and INFO as default format and level
logBackend := logging.NewLogBackend(os.Stdout, "", 0)
backend := logging.NewBackendFormatter(logBackend, logging.MustStringFormatter(Fmt+"\n"))
backendLeveled := logging.AddModuleLevel(backend)
backendLeveled.SetLevel(logging.INFO, logModule)
logInstance.SetBackend(backendLeveled)
}
// ConfigLogger configures the global log handler
func ConfigLogger(configLogEnabled bool, configLogStandard string, configLogPath string, configLogLevel string) {
lvl := strings.ToUpper(configLogLevel)
level, err := logging.LogLevel(lvl)
if err != nil {
Fatalf("Error setting standard log level %s: %s", lvl, err.Error())
}
logPath = configLogPath
// The backend is the part which actually handles logging the log entries somewhere.
cf := config.LogStandard.GetString()
var backend logging.Backend
backend = &NoopBackend{}
if cf != "off" && cf != "false" {
stdWriter := GetLogWriter("standard")
logBackend := logging.NewLogBackend(stdWriter, "", 0)
backend = logging.NewBackendFormatter(logBackend, logging.MustStringFormatter(Fmt+"\n"))
if configLogStandard == "false" {
configLogStandard = "off"
Warning("log.standard value 'false' is deprecated and will be removed in a future release. Please use the value 'off'.")
}
level, err := logging.LogLevel(strings.ToUpper(config.LogLevel.GetString()))
if err != nil {
Fatalf("Error setting database log level: %s", err.Error())
if configLogEnabled && configLogStandard != "off" {
logBackend := logging.NewLogBackend(GetLogWriter(configLogStandard, "standard"), "", 0)
backend = logging.NewBackendFormatter(logBackend, logging.MustStringFormatter(Fmt+"\n"))
}
backendLeveled := logging.AddModuleLevel(backend)
@ -86,11 +87,14 @@ func InitLogger() {
}
// GetLogWriter returns the writer to where the normal log goes, depending on the config
func GetLogWriter(logfile string) (writer io.Writer) {
func GetLogWriter(logfmt string, logfile string) (writer io.Writer) {
writer = os.Stdout // Set the default case to prevent nil pointer panics
switch viper.GetString("log." + logfile) {
switch logfmt {
case "file":
fullLogFilePath := config.LogPath.GetString() + "/" + logfile + ".log"
if err := os.MkdirAll(logPath, 0744); err != nil {
Fatalf("Could not create log path: %s", err.Error())
}
fullLogFilePath := logPath + "/" + logfile + ".log"
f, err := os.OpenFile(fullLogFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
Fatalf("Could not create logfile %s: %s", fullLogFilePath, err.Error())

View File

@ -20,7 +20,6 @@ import (
"strings"
"time"
"code.vikunja.io/api/pkg/config"
"github.com/op/go-logging"
"xorm.io/xorm/log"
)
@ -33,19 +32,24 @@ type MailLogger struct {
const mailFormat = `%{color}%{time:` + time.RFC3339Nano + `}: %{level}` + "\t" + `▶ [MAIL] %{id:03x}%{color:reset} %{message}`
const mailLogModule = `vikunja_mail`
func NewMailLogger() *MailLogger {
lvl := strings.ToUpper(config.LogMailLevel.GetString())
// NewMailLogger creates and initializes a new mail logger
func NewMailLogger(configLogEnabled bool, configLogMail string, configLogMailLevel string) *MailLogger {
lvl := strings.ToUpper(configLogMailLevel)
level, err := logging.LogLevel(lvl)
if err != nil {
Criticalf("Error setting database log level: %s", err.Error())
Criticalf("Error setting mail log level %s: %s", lvl, err.Error())
}
mailLogger := &MailLogger{
logger: logging.MustGetLogger(mailLogModule),
}
logBackend := logging.NewLogBackend(GetLogWriter("mail"), "", 0)
backend := logging.NewBackendFormatter(logBackend, logging.MustStringFormatter(mailFormat+"\n"))
var backend logging.Backend
backend = &NoopBackend{}
if configLogEnabled && configLogMail != "off" {
logBackend := logging.NewLogBackend(GetLogWriter(configLogMail, "mail"), "", 0)
backend = logging.NewBackendFormatter(logBackend, logging.MustStringFormatter(mailFormat+"\n"))
}
backendLeveled := logging.AddModuleLevel(backend)
backendLeveled.SetLevel(level, mailLogModule)

View File

@ -21,7 +21,6 @@ import (
"strings"
"time"
"code.vikunja.io/api/pkg/config"
"github.com/ThreeDotsLabs/watermill"
"github.com/op/go-logging"
)
@ -34,8 +33,9 @@ type WatermillLogger struct {
logger *logging.Logger
}
func NewWatermillLogger() *WatermillLogger {
lvl := strings.ToUpper(config.LogEventsLevel.GetString())
// NewXormLogger creates and initializes a new watermill logger
func NewWatermillLogger(configLogEnabled bool, configLogEvents string, configLogEventsLevel string) *WatermillLogger {
lvl := strings.ToUpper(configLogEventsLevel)
level, err := logging.LogLevel(lvl)
if err != nil {
Criticalf("Error setting events log level %s: %s", lvl, err.Error())
@ -45,11 +45,14 @@ func NewWatermillLogger() *WatermillLogger {
logger: logging.MustGetLogger(watermillLogModule),
}
cf := config.LogEvents.GetString()
var backend logging.Backend
backend = &NoopBackend{}
if cf != "off" && cf != "false" {
logBackend := logging.NewLogBackend(GetLogWriter("events"), "", 0)
if configLogEvents == "false" {
configLogEvents = "off"
Warning("log.events value 'false' is deprecated and will be removed in a future release. Please use the value 'off'.")
}
if configLogEnabled && configLogEvents != "off" {
logBackend := logging.NewLogBackend(GetLogWriter(configLogEvents, "events"), "", 0)
backend = logging.NewBackendFormatter(logBackend, logging.MustStringFormatter(watermillFmt+"\n"))
}

View File

@ -20,7 +20,6 @@ import (
"strings"
"time"
"code.vikunja.io/api/pkg/config"
"github.com/op/go-logging"
"xorm.io/xorm/log"
)
@ -38,21 +37,23 @@ type XormLogger struct {
}
// NewXormLogger creates and initializes a new xorm logger
func NewXormLogger(lvl string) *XormLogger {
if lvl == "" {
lvl = strings.ToUpper(config.LogDatabaseLevel.GetString())
}
func NewXormLogger(configLogEnabled bool, configLogDatabase string, configLogDatabaseLevel string) *XormLogger {
lvl := strings.ToUpper(configLogDatabaseLevel)
level, err := logging.LogLevel(lvl)
if err != nil {
Criticalf("Error setting database log level: %s", err.Error())
Criticalf("Error setting database log level %s: %s", lvl, err.Error())
}
xormLogger := &XormLogger{
logger: logging.MustGetLogger(xormLogModule),
}
logBackend := logging.NewLogBackend(GetLogWriter("database"), "", 0)
backend := logging.NewBackendFormatter(logBackend, logging.MustStringFormatter(XormFmt+"\n"))
var backend logging.Backend
backend = &NoopBackend{}
if configLogEnabled && configLogDatabase != "off" {
logBackend := logging.NewLogBackend(GetLogWriter(configLogDatabase, "database"), "", 0)
backend = logging.NewBackendFormatter(logBackend, logging.MustStringFormatter(XormFmt+"\n"))
}
backendLeveled := logging.AddModuleLevel(backend)
backendLeveled.SetLevel(level, xormLogModule)
@ -64,8 +65,8 @@ func NewXormLogger(lvl string) *XormLogger {
case logging.ERROR:
xormLogger.level = log.LOG_ERR
case logging.WARNING:
case logging.NOTICE:
xormLogger.level = log.LOG_WARNING
case logging.NOTICE:
case logging.INFO:
xormLogger.level = log.LOG_INFO
case logging.DEBUG:

View File

@ -56,7 +56,7 @@ func getClient() (*mail.Client, error) {
ServerName: config.MailerHost.GetString(),
}),
mail.WithTimeout((config.MailerQueueTimeout.GetDuration() + 3) * time.Second), // 3s more for us to close before mail server timeout
mail.WithLogger(log.NewMailLogger()),
mail.WithLogger(log.NewMailLogger(config.LogEnabled.GetBool(), config.LogMail.GetString(), config.LogMailLevel.GetString())),
mail.WithDebugLog(),
}

View File

@ -0,0 +1,52 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present 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 Affero General Public Licensee 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 Affero General Public Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package migration
import (
"time"
"src.techknowlogick.com/xormigrate"
"xorm.io/xorm"
)
type webhooks20230913202615 struct {
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"webhook"`
TargetURL string `xorm:"not null" valid:"minstringlength(1)" minLength:"1" json:"target_url"`
Events []string `xorm:"JSON not null" valid:"minstringlength(1)" minLength:"1" json:"event"`
ProjectID int64 `xorm:"bigint not null index" json:"project_id" param:"project"`
Secret string `xorm:"null" json:"secret"`
CreatedByID int64 `xorm:"bigint not null" json:"-"`
Created time.Time `xorm:"created not null" json:"created"`
Updated time.Time `xorm:"updated not null" json:"updated"`
}
func (webhooks20230913202615) TableName() string {
return "webhooks"
}
func init() {
migrations = append(migrations, &xormigrate.Migration{
ID: "20230913202615",
Description: "",
Migrate: func(tx *xorm.Engine) error {
return tx.Sync2(webhooks20230913202615{})
},
Rollback: func(tx *xorm.Engine) error {
return nil
},
})
}

View File

@ -0,0 +1,96 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present 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 Affero General Public Licensee 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 Affero General Public Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package migration
import (
"bytes"
templatehtml "html/template"
"strings"
"github.com/yuin/goldmark"
"src.techknowlogick.com/xormigrate"
"xorm.io/xorm"
)
func convertMarkdownToHTML(input string) (output string, err error) {
md := []byte(templatehtml.HTMLEscapeString(input))
var buf bytes.Buffer
err = goldmark.Convert(md, &buf)
if err != nil {
return
}
//#nosec - the html is escaped few lines before
return buf.String(), nil
}
func convertDescription(tx *xorm.Engine, table string, column string) (err error) {
items := []map[string]interface{}{}
err = tx.Table(table).
Select("id, " + column).
Find(&items)
if err != nil {
return
}
for _, task := range items {
if task[column] == "" || strings.HasPrefix(task[column].(string), "<") {
continue
}
task[column], err = convertMarkdownToHTML(task[column].(string))
if err != nil {
return
}
_, err = tx.Where("id = ?", task["id"]).
Table(table).
Cols(column).
Update(task)
if err != nil {
return
}
}
return
}
func init() {
migrations = append(migrations, &xormigrate.Migration{
ID: "20231022144641",
Description: "Convert all descriptions to HTML",
Migrate: func(tx *xorm.Engine) (err error) {
for _, table := range []string{
"tasks",
"labels",
"projects",
"saved_filters",
"teams",
} {
err = convertDescription(tx, table, "description")
if err != nil {
return
}
}
err = convertDescription(tx, "task_comments", "comment")
return
},
Rollback: func(tx *xorm.Engine) error {
return nil
},
})
}

View File

@ -56,7 +56,7 @@ func initMigration(x *xorm.Engine) *xormigrate.Xormigrate {
})
m := xormigrate.New(x, migrations)
logger := log.NewXormLogger("")
logger := log.NewXormLogger(config.LogEnabled.GetBool(), config.LogEvents.GetString(), config.LogEventsLevel.GetString())
m.SetLogger(logger)
m.InitSchema(initSchema)
return m

View File

@ -122,9 +122,9 @@ func HashToken(token, salt string) string {
// @Accept json
// @Produce json
// @Security JWTKeyAuth
// @Param page query int false "The page number for tasks. Used for pagination. If not provided, the first page of results is returned."
// @Param per_page query int false "The maximum number of tasks per bucket per page. This parameter is limited by the configured maximum of items per page."
// @Param s query string false "Search tasks by task text."
// @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 tokens per page. This parameter is limited by the configured maximum of items per page."
// @Param s query string false "Search tokens by their title."
// @Success 200 {array} models.APIToken "The list of all tokens"
// @Failure 500 {object} models.Message "Internal server error"
// @Router /tokens [get]

View File

@ -110,6 +110,17 @@ func (err ValidationHTTPError) Error() string {
return theErr.Error()
}
func InvalidFieldError(fields []string) error {
return ValidationHTTPError{
HTTPError: web.HTTPError{
HTTPCode: http.StatusPreconditionFailed,
Code: ErrCodeInvalidData,
Message: "Invalid Data",
},
InvalidFields: fields,
}
}
// ===========
// Project errors
// ===========
@ -405,7 +416,7 @@ func (err *ErrCannotArchiveDefaultProject) HTTPError() web.HTTPError {
// Task errors
// ==============
// ErrTaskCannotBeEmpty represents a "ErrProjectDoesNotExist" kind of error. Used if the project does not exist.
// ErrTaskCannotBeEmpty represents a "ErrTaskCannotBeEmpty" kind of error.
type ErrTaskCannotBeEmpty struct{}
// IsErrTaskCannotBeEmpty checks if an error is a ErrProjectDoesNotExist.
@ -415,7 +426,7 @@ func IsErrTaskCannotBeEmpty(err error) bool {
}
func (err ErrTaskCannotBeEmpty) Error() string {
return "Project task title cannot be empty."
return "Task title cannot be empty."
}
// ErrCodeTaskCannotBeEmpty holds the unique world-error code of this error
@ -423,7 +434,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 project task title."}
return web.HTTPError{HTTPCode: http.StatusBadRequest, Code: ErrCodeTaskCannotBeEmpty, Message: "You must provide at least a task title."}
}
// ErrTaskDoesNotExist represents a "ErrProjectDoesNotExist" kind of error. Used if the project does not exist.

View File

@ -27,8 +27,8 @@ import (
// TaskCreatedEvent represents an event where a task has been created
type TaskCreatedEvent struct {
Task *Task
Doer *user.User
Task *Task `json:"task"`
Doer *user.User `json:"doer"`
}
// Name defines the name for TaskCreatedEvent
@ -38,8 +38,8 @@ func (t *TaskCreatedEvent) Name() string {
// TaskUpdatedEvent represents an event where a task has been updated
type TaskUpdatedEvent struct {
Task *Task
Doer *user.User
Task *Task `json:"task"`
Doer *user.User `json:"doer"`
}
// Name defines the name for TaskUpdatedEvent
@ -49,8 +49,8 @@ func (t *TaskUpdatedEvent) Name() string {
// TaskDeletedEvent represents a TaskDeletedEvent event
type TaskDeletedEvent struct {
Task *Task
Doer *user.User
Task *Task `json:"task"`
Doer *user.User `json:"doer"`
}
// Name defines the name for TaskDeletedEvent
@ -60,9 +60,9 @@ func (t *TaskDeletedEvent) Name() string {
// TaskAssigneeCreatedEvent represents an event where a task has been assigned to a user
type TaskAssigneeCreatedEvent struct {
Task *Task
Assignee *user.User
Doer *user.User
Task *Task `json:"task"`
Assignee *user.User `json:"assignee"`
Doer *user.User `json:"doer"`
}
// Name defines the name for TaskAssigneeCreatedEvent
@ -72,9 +72,9 @@ func (t *TaskAssigneeCreatedEvent) Name() string {
// TaskAssigneeDeletedEvent represents a TaskAssigneeDeletedEvent event
type TaskAssigneeDeletedEvent struct {
Task *Task
Assignee *user.User
Doer *user.User
Task *Task `json:"task"`
Assignee *user.User `json:"assignee"`
Doer *user.User `json:"doer"`
}
// Name defines the name for TaskAssigneeDeletedEvent
@ -84,9 +84,9 @@ func (t *TaskAssigneeDeletedEvent) Name() string {
// TaskCommentCreatedEvent represents an event where a task comment has been created
type TaskCommentCreatedEvent struct {
Task *Task
Comment *TaskComment
Doer *user.User
Task *Task `json:"task"`
Comment *TaskComment `json:"comment"`
Doer *user.User `json:"doer"`
}
// Name defines the name for TaskCommentCreatedEvent
@ -96,9 +96,9 @@ func (t *TaskCommentCreatedEvent) Name() string {
// TaskCommentUpdatedEvent represents a TaskCommentUpdatedEvent event
type TaskCommentUpdatedEvent struct {
Task *Task
Comment *TaskComment
Doer *user.User
Task *Task `json:"task"`
Comment *TaskComment `json:"comment"`
Doer *user.User `json:"doer"`
}
// Name defines the name for TaskCommentUpdatedEvent
@ -108,9 +108,9 @@ func (t *TaskCommentUpdatedEvent) Name() string {
// TaskCommentDeletedEvent represents a TaskCommentDeletedEvent event
type TaskCommentDeletedEvent struct {
Task *Task
Comment *TaskComment
Doer *user.User
Task *Task `json:"task"`
Comment *TaskComment `json:"comment"`
Doer *user.User `json:"doer"`
}
// Name defines the name for TaskCommentDeletedEvent
@ -120,9 +120,9 @@ func (t *TaskCommentDeletedEvent) Name() string {
// TaskAttachmentCreatedEvent represents a TaskAttachmentCreatedEvent event
type TaskAttachmentCreatedEvent struct {
Task *Task
Attachment *TaskAttachment
Doer *user.User
Task *Task `json:"task"`
Attachment *TaskAttachment `json:"attachment"`
Doer *user.User `json:"doer"`
}
// Name defines the name for TaskAttachmentCreatedEvent
@ -132,9 +132,9 @@ func (t *TaskAttachmentCreatedEvent) Name() string {
// TaskAttachmentDeletedEvent represents a TaskAttachmentDeletedEvent event
type TaskAttachmentDeletedEvent struct {
Task *Task
Attachment *TaskAttachment
Doer *user.User
Task *Task `json:"task"`
Attachment *TaskAttachment `json:"attachment"`
Doer *user.User `json:"doer"`
}
// Name defines the name for TaskAttachmentDeletedEvent
@ -144,9 +144,9 @@ func (t *TaskAttachmentDeletedEvent) Name() string {
// TaskRelationCreatedEvent represents a TaskRelationCreatedEvent event
type TaskRelationCreatedEvent struct {
Task *Task
Relation *TaskRelation
Doer *user.User
Task *Task `json:"task"`
Relation *TaskRelation `json:"relation"`
Doer *user.User `json:"doer"`
}
// Name defines the name for TaskRelationCreatedEvent
@ -156,9 +156,9 @@ func (t *TaskRelationCreatedEvent) Name() string {
// TaskRelationDeletedEvent represents a TaskRelationDeletedEvent event
type TaskRelationDeletedEvent struct {
Task *Task
Relation *TaskRelation
Doer *user.User
Task *Task `json:"task"`
Relation *TaskRelation `json:"relation"`
Doer *user.User `json:"doer"`
}
// Name defines the name for TaskRelationDeletedEvent
@ -172,8 +172,8 @@ func (t *TaskRelationDeletedEvent) Name() string {
// ProjectCreatedEvent represents an event where a project has been created
type ProjectCreatedEvent struct {
Project *Project
Doer *user.User
Project *Project `json:"project"`
Doer *user.User `json:"doer"`
}
// Name defines the name for ProjectCreatedEvent
@ -183,23 +183,23 @@ func (l *ProjectCreatedEvent) Name() string {
// ProjectUpdatedEvent represents an event where a project has been updated
type ProjectUpdatedEvent struct {
Project *Project
Doer web.Auth
Project *Project `json:"project"`
Doer web.Auth `json:"doer"`
}
// Name defines the name for ProjectUpdatedEvent
func (l *ProjectUpdatedEvent) Name() string {
func (p *ProjectUpdatedEvent) Name() string {
return "project.updated"
}
// ProjectDeletedEvent represents an event where a project has been deleted
type ProjectDeletedEvent struct {
Project *Project
Doer web.Auth
Project *Project `json:"project"`
Doer web.Auth `json:"doer"`
}
// Name defines the name for ProjectDeletedEvent
func (t *ProjectDeletedEvent) Name() string {
func (p *ProjectDeletedEvent) Name() string {
return "project.deleted"
}
@ -209,25 +209,25 @@ func (t *ProjectDeletedEvent) Name() string {
// ProjectSharedWithUserEvent represents an event where a project has been shared with a user
type ProjectSharedWithUserEvent struct {
Project *Project
User *user.User
Doer web.Auth
Project *Project `json:"project"`
User *user.User `json:"user"`
Doer web.Auth `json:"doer"`
}
// Name defines the name for ProjectSharedWithUserEvent
func (l *ProjectSharedWithUserEvent) Name() string {
func (p *ProjectSharedWithUserEvent) Name() string {
return "project.shared.user"
}
// ProjectSharedWithTeamEvent represents an event where a project has been shared with a team
type ProjectSharedWithTeamEvent struct {
Project *Project
Team *Team
Doer web.Auth
Project *Project `json:"project"`
Team *Team `json:"team"`
Doer web.Auth `json:"doer"`
}
// Name defines the name for ProjectSharedWithTeamEvent
func (l *ProjectSharedWithTeamEvent) Name() string {
func (p *ProjectSharedWithTeamEvent) Name() string {
return "project.shared.team"
}
@ -237,9 +237,9 @@ func (l *ProjectSharedWithTeamEvent) Name() string {
// TeamMemberAddedEvent defines an event where a user is added to a team
type TeamMemberAddedEvent struct {
Team *Team
Member *user.User
Doer *user.User
Team *Team `json:"team"`
Member *user.User `json:"member"`
Doer *user.User `json:"doer"`
}
// Name defines the name for TeamMemberAddedEvent
@ -249,8 +249,8 @@ func (t *TeamMemberAddedEvent) Name() string {
// TeamCreatedEvent represents a TeamCreatedEvent event
type TeamCreatedEvent struct {
Team *Team
Doer web.Auth
Team *Team `json:"team"`
Doer web.Auth `json:"doer"`
}
// Name defines the name for TeamCreatedEvent
@ -260,8 +260,8 @@ func (t *TeamCreatedEvent) Name() string {
// TeamDeletedEvent represents a TeamDeletedEvent event
type TeamDeletedEvent struct {
Team *Team
Doer web.Auth
Team *Team `json:"team"`
Doer web.Auth `json:"doer"`
}
// Name defines the name for TeamDeletedEvent
@ -271,7 +271,7 @@ func (t *TeamDeletedEvent) Name() string {
// UserDataExportRequestedEvent represents a UserDataExportRequestedEvent event
type UserDataExportRequestedEvent struct {
User *user.User
User *user.User `json:"user"`
}
// Name defines the name for UserDataExportRequestedEvent

View File

@ -98,7 +98,7 @@ func getDefaultBucketID(s *xorm.Session, project *Project) (bucketID int64, err
// ReadAll returns all buckets with their tasks for a certain project
// @Summary Get all kanban buckets of a project
// @Description Returns all kanban buckets with belong to a project including their tasks. Buckets are always sorted by their `position` in ascending order. Tasks are sorted by their `kanban_position` in ascending order.
// @tags task
// @tags project
// @Accept json
// @Produce json
// @Security JWTKeyAuth
@ -234,7 +234,7 @@ func (b *Bucket) ReadAll(s *xorm.Session, auth web.Auth, search string, page int
// Create creates a new bucket
// @Summary Create a new bucket
// @Description Creates a new kanban bucket on a project.
// @tags task
// @tags project
// @Accept json
// @Produce json
// @Security JWTKeyAuth
@ -265,7 +265,7 @@ func (b *Bucket) Create(s *xorm.Session, a web.Auth) (err error) {
// Update Updates an existing bucket
// @Summary Update an existing bucket
// @Description Updates an existing kanban bucket.
// @tags task
// @tags project
// @Accept json
// @Produce json
// @Security JWTKeyAuth
@ -292,7 +292,7 @@ func (b *Bucket) Update(s *xorm.Session, _ web.Auth) (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. You cannot delete the last bucket on a project.
// @tags task
// @tags project
// @Accept json
// @Produce json
// @Security JWTKeyAuth

View File

@ -147,7 +147,7 @@ func TestBucket_Delete(t *testing.T) {
tasks := []*Task{}
err = s.Where("bucket_id = ?", 1).Find(&tasks)
assert.NoError(t, err)
assert.Len(t, tasks, 16)
assert.Len(t, tasks, 15)
db.AssertMissing(t, "buckets", map[string]interface{}{
"id": 2,
"project_id": 1,

View File

@ -20,6 +20,8 @@ import (
"time"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/api/pkg/utils"
"code.vikunja.io/web"
"xorm.io/xorm"
)
@ -32,8 +34,8 @@ type Label struct {
Title string `xorm:"varchar(250) not null" json:"title" valid:"runelength(1|250)" minLength:"1" maxLength:"250"`
// The label description.
Description string `xorm:"longtext null" json:"description"`
// The color this label has
HexColor string `xorm:"varchar(6) null" json:"hex_color" valid:"runelength(0|6)" maxLength:"6"`
// The color this label has in hex format.
HexColor string `xorm:"varchar(6) null" json:"hex_color" valid:"runelength(0|7)" maxLength:"7"`
CreatedByID int64 `xorm:"bigint not null" json:"-"`
// The user who created this label
@ -71,6 +73,7 @@ func (l *Label) Create(s *xorm.Session, a web.Auth) (err error) {
return
}
l.HexColor = utils.NormalizeHex(l.HexColor)
l.CreatedBy = u
l.CreatedByID = u.ID
@ -94,6 +97,9 @@ func (l *Label) Create(s *xorm.Session, a web.Auth) (err error) {
// @Failure 500 {object} models.Message "Internal error"
// @Router /labels/{id} [put]
func (l *Label) Update(s *xorm.Session, a web.Auth) (err error) {
l.HexColor = utils.NormalizeHex(l.HexColor)
_, err = s.
ID(l.ID).
Cols(

View File

@ -403,8 +403,8 @@ func (ltb *LabelTaskBulk) Create(s *xorm.Session, a web.Auth) (err error) {
if err != nil {
return err
}
for _, l := range labels {
task.Labels = append(task.Labels, &l.Label)
for i := range labels {
task.Labels = append(task.Labels, &labels[i].Label)
}
return task.UpdateTaskLabels(s, a, ltb.Labels)
}

View File

@ -19,6 +19,7 @@ package models
import (
"encoding/json"
"strconv"
"time"
"code.vikunja.io/api/pkg/config"
@ -63,6 +64,25 @@ func RegisterListeners() {
events.RegisterListener((&TaskRelationDeletedEvent{}).Name(), &HandleTaskUpdateLastUpdated{})
if config.TypesenseEnabled.GetBool() {
events.RegisterListener((&TaskDeletedEvent{}).Name(), &RemoveTaskFromTypesense{})
events.RegisterListener((&TaskCreatedEvent{}).Name(), &AddTaskToTypesense{})
}
if config.WebhooksEnabled.GetBool() {
RegisterEventForWebhook(&TaskCreatedEvent{})
RegisterEventForWebhook(&TaskUpdatedEvent{})
RegisterEventForWebhook(&TaskDeletedEvent{})
RegisterEventForWebhook(&TaskAssigneeCreatedEvent{})
RegisterEventForWebhook(&TaskAssigneeDeletedEvent{})
RegisterEventForWebhook(&TaskCommentCreatedEvent{})
RegisterEventForWebhook(&TaskCommentUpdatedEvent{})
RegisterEventForWebhook(&TaskCommentDeletedEvent{})
RegisterEventForWebhook(&TaskAttachmentCreatedEvent{})
RegisterEventForWebhook(&TaskAttachmentDeletedEvent{})
RegisterEventForWebhook(&TaskRelationCreatedEvent{})
RegisterEventForWebhook(&TaskRelationDeletedEvent{})
RegisterEventForWebhook(&ProjectUpdatedEvent{})
RegisterEventForWebhook(&ProjectDeletedEvent{})
RegisterEventForWebhook(&ProjectSharedWithUserEvent{})
RegisterEventForWebhook(&ProjectSharedWithTeamEvent{})
}
}
@ -506,6 +526,38 @@ func (s *RemoveTaskFromTypesense) Handle(msg *message.Message) (err error) {
return err
}
// AddTaskToTypesense represents a listener
type AddTaskToTypesense struct {
}
// Name defines the name for the AddTaskToTypesense listener
func (l *AddTaskToTypesense) Name() string {
return "add.task.to.typesense"
}
// Handle is executed when the event AddTaskToTypesense listens on is fired
func (l *AddTaskToTypesense) Handle(msg *message.Message) (err error) {
event := &TaskCreatedEvent{}
err = json.Unmarshal(msg.Payload, event)
if err != nil {
return err
}
log.Debugf("New task %d created, adding to typesense…", event.Task.ID)
s := db.NewSession()
defer s.Close()
ttask, err := getTypesenseTaskForTask(s, event.Task, nil)
if err != nil {
return err
}
_, err = typesenseClient.Collection("tasks").
Documents().
Create(ttask)
return
}
///////
// Project Event Listeners
@ -576,6 +628,100 @@ func (s *SendProjectCreatedNotification) Handle(msg *message.Message) (err error
return nil
}
// WebhookListener represents a listener
type WebhookListener struct {
EventName string
}
// Name defines the name for the WebhookListener listener
func (wl *WebhookListener) Name() string {
return "webhook.listener"
}
type WebhookPayload struct {
EventName string `json:"event_name"`
Time time.Time `json:"time"`
Data interface{} `json:"data"`
}
func getProjectIDFromAnyEvent(eventPayload map[string]interface{}) int64 {
if task, has := eventPayload["task"]; has {
t := task.(map[string]interface{})
if projectID, has := t["project_id"]; has {
switch v := projectID.(type) {
case int64:
return v
case float64:
return int64(v)
}
return projectID.(int64)
}
}
if project, has := eventPayload["project"]; has {
t := project.(map[string]interface{})
if projectID, has := t["id"]; has {
switch v := projectID.(type) {
case int64:
return v
case float64:
return int64(v)
}
return projectID.(int64)
}
}
return 0
}
// Handle is executed when the event WebhookListener listens on is fired
func (wl *WebhookListener) Handle(msg *message.Message) (err error) {
var event map[string]interface{}
err = json.Unmarshal(msg.Payload, &event)
if err != nil {
return err
}
projectID := getProjectIDFromAnyEvent(event)
if projectID == 0 {
log.Debugf("event %s does not contain a project id, not handling webhook", wl.EventName)
return nil
}
s := db.NewSession()
defer s.Close()
ws := []*Webhook{}
err = s.Where("project_id = ?", projectID).
Find(&ws)
if err != nil {
return err
}
var webhook *Webhook
for _, w := range ws {
for _, e := range w.Events {
if e == wl.EventName {
webhook = w
break
}
}
}
if webhook == nil {
log.Debugf("Did not find any webhook for the %s event for project %d, not sending", wl.EventName, projectID)
return nil
}
err = webhook.sendWebhookPayload(&WebhookPayload{
EventName: wl.EventName,
Time: time.Now(),
Data: event,
})
return
}
///////
// Team Events

View File

@ -32,17 +32,18 @@ import (
// ReminderDueNotification represents a ReminderDueNotification notification
type ReminderDueNotification struct {
User *user.User `json:"user"`
Task *Task `json:"task"`
User *user.User `json:"user"`
Task *Task `json:"task"`
Project *Project `json:"project"`
}
// ToMail returns the mail notification for ReminderDueNotification
func (n *ReminderDueNotification) ToMail() *notifications.Mail {
return notifications.NewMail().
To(n.User.Email).
Subject(`Reminder for "`+n.Task.Title+`"`).
Subject(`Reminder for "`+n.Task.Title+`" (`+n.Project.Title+`)`).
Greeting("Hi "+n.User.GetName()+",").
Line(`This is a friendly reminder of the task "`+n.Task.Title+`".`).
Line(`This is a friendly reminder of the task "`+n.Task.Title+`" (`+n.Project.Title+`).`).
Action("Open Task", config.ServiceFrontendurl.GetString()+"tasks/"+strconv.FormatInt(n.Task.ID, 10)).
Line("Have a nice day!")
}
@ -203,17 +204,18 @@ func (n *TeamMemberAddedNotification) Name() string {
// UndoneTaskOverdueNotification represents a UndoneTaskOverdueNotification notification
type UndoneTaskOverdueNotification struct {
User *user.User
Task *Task
User *user.User
Task *Task
Project *Project
}
// ToMail returns the mail notification for UndoneTaskOverdueNotification
func (n *UndoneTaskOverdueNotification) ToMail() *notifications.Mail {
until := time.Until(n.Task.DueDate).Round(1*time.Hour) * -1
return notifications.NewMail().
Subject(`Task "`+n.Task.Title+`" is overdue`).
Subject(`Task "`+n.Task.Title+`" (`+n.Project.Title+`) is overdue`).
Greeting("Hi "+n.User.GetName()+",").
Line(`This is a friendly reminder of the task "`+n.Task.Title+`" which is overdue since `+utils.HumanizeDuration(until)+` and not yet done.`).
Line(`This is a friendly reminder of the task "`+n.Task.Title+`" (`+n.Project.Title+`) which is overdue since `+utils.HumanizeDuration(until)+` and not yet done.`).
Action("Open Task", config.ServiceFrontendurl.GetString()+"tasks/"+strconv.FormatInt(n.Task.ID, 10)).
Line("Have a nice day!")
}
@ -230,8 +232,9 @@ func (n *UndoneTaskOverdueNotification) Name() string {
// UndoneTasksOverdueNotification represents a UndoneTasksOverdueNotification notification
type UndoneTasksOverdueNotification struct {
User *user.User
Tasks map[int64]*Task
User *user.User
Tasks map[int64]*Task
Projects map[int64]*Project
}
// ToMail returns the mail notification for UndoneTasksOverdueNotification
@ -249,7 +252,7 @@ func (n *UndoneTasksOverdueNotification) ToMail() *notifications.Mail {
overdueLine := ""
for _, task := range sortedTasks {
until := time.Until(task.DueDate).Round(1*time.Hour) * -1
overdueLine += `* [` + task.Title + `](` + config.ServiceFrontendurl.GetString() + "tasks/" + strconv.FormatInt(task.ID, 10) + `), overdue since ` + utils.HumanizeDuration(until) + "\n"
overdueLine += `* [` + task.Title + `](` + config.ServiceFrontendurl.GetString() + "tasks/" + strconv.FormatInt(task.ID, 10) + `) (` + n.Projects[task.ProjectID].Title + `), overdue since ` + utils.HumanizeDuration(until) + "\n"
}
return notifications.NewMail().

View File

@ -22,13 +22,14 @@ import (
"strings"
"time"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/files"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/api/pkg/utils"
"code.vikunja.io/web"
"xorm.io/builder"
"xorm.io/xorm"
@ -45,7 +46,7 @@ type Project struct {
// The unique project short identifier. Used to build task identifiers.
Identifier string `xorm:"varchar(10) null" json:"identifier" valid:"runelength(0|10)" minLength:"0" maxLength:"10"`
// The hex color of this project
HexColor string `xorm:"varchar(6) null" json:"hex_color" valid:"runelength(0|6)" maxLength:"6"`
HexColor string `xorm:"varchar(6) null" json:"hex_color" valid:"runelength(0|7)" maxLength:"7"`
OwnerID int64 `xorm:"bigint INDEX not null" json:"-"`
ParentProjectID int64 `xorm:"bigint INDEX null" json:"parent_project_id"`
@ -314,6 +315,18 @@ func GetProjectSimplByTaskID(s *xorm.Session, taskID int64) (l *Project, err err
return &project, nil
}
// GetProjectsSimplByTaskIDs gets a list of projects by a task ids
func GetProjectsSimplByTaskIDs(s *xorm.Session, taskIDs []int64) (ps map[int64]*Project, err error) {
ps = make(map[int64]*Project)
err = s.
Select("projects.*").
Table(Project{}).
Join("INNER", "tasks", "projects.id = tasks.project_id").
In("tasks.id", taskIDs).
Find(&ps)
return
}
// GetProjectsByIDs returns a map of projects from a slice with project ids
func GetProjectsByIDs(s *xorm.Session, projectIDs []int64) (projects map[int64]*Project, err error) {
projects = make(map[int64]*Project, len(projectIDs))
@ -706,6 +719,8 @@ func CreateProject(s *xorm.Session, project *Project, auth web.Auth, createBackl
return
}
project.HexColor = utils.NormalizeHex(project.HexColor)
_, err = s.Insert(project)
if err != nil {
return
@ -819,6 +834,8 @@ func UpdateProject(s *xorm.Session, project *Project, auth web.Auth, updateProje
}
}
project.HexColor = utils.NormalizeHex(project.HexColor)
_, err = s.
ID(project.ID).
Cols(colsToUpdate...).

View File

@ -210,8 +210,8 @@ func (tl *TeamProject) ReadAll(s *xorm.Session, a web.Auth, search string, page
}
teams := []*Team{}
for _, t := range all {
teams = append(teams, &t.Team)
for i := range all {
teams = append(teams, &all[i].Team)
}
err = addMoreInfoToTeams(s, teams)

View File

@ -261,6 +261,9 @@ func TestProject_Delete(t *testing.T) {
db.AssertMissing(t, "projects", map[string]interface{}{
"id": 1,
})
db.AssertMissing(t, "tasks", map[string]interface{}{
"id": 1,
})
})
t.Run("with background", func(t *testing.T) {
db.LoadAndAssertFixtures(t)

View File

@ -217,7 +217,7 @@ func (lu *ProjectUser) ReadAll(s *xorm.Session, a web.Auth, search string, page
// Obfuscate all user emails
for _, u := range all {
u.Email = ""
u.User.Email = ""
}
numberOfTotalItems, err = s.

View File

@ -72,8 +72,8 @@ func (t *Task) updateTaskAssignees(s *xorm.Session, assignees []*user.User, doer
}
t.Assignees = make([]*user.User, 0, len(currentAssignees))
for _, assignee := range currentAssignees {
t.Assignees = append(t.Assignees, &assignee.User)
for i := range currentAssignees {
t.Assignees = append(t.Assignees, &currentAssignees[i].User)
}
// If we don't have any new assignees, delete everything right away. Saves us some hassle.
@ -349,8 +349,8 @@ func (ba *BulkAssignees) Create(s *xorm.Session, a web.Auth) (err error) {
if err != nil {
return err
}
for _, a := range assignees {
task.Assignees = append(task.Assignees, &a.User)
for i := range assignees {
task.Assignees = append(task.Assignees, &assignees[i].User)
}
err = task.updateTaskAssignees(s, ba.Assignees, a)

View File

@ -132,10 +132,24 @@ func RegisterOverdueReminderCron() {
log.Debugf("[Undone Overdue Tasks Reminder] Sending reminders to %d users", len(uts))
taskIDs := []int64{}
for _, ut := range uts {
for _, t := range ut.tasks {
taskIDs = append(taskIDs, t.ID)
}
}
projects, err := GetProjectsSimplByTaskIDs(s, taskIDs)
if err != nil {
log.Errorf("[Undone Overdue Tasks Reminder] Could not get projects for tasks: %s", err)
return
}
for _, ut := range uts {
var n notifications.Notification = &UndoneTasksOverdueNotification{
User: ut.user,
Tasks: ut.tasks,
User: ut.user,
Tasks: ut.tasks,
Projects: projects,
}
if len(ut.tasks) == 1 {
@ -143,8 +157,9 @@ func RegisterOverdueReminderCron() {
// first entry without knowing the key of it.
for _, t := range ut.tasks {
n = &UndoneTaskOverdueNotification{
User: ut.user,
Task: t,
User: ut.user,
Task: t,
Project: projects[t.ProjectID],
}
}
}

View File

@ -117,10 +117,10 @@ func getTaskUsersForTasks(s *xorm.Session, taskIDs []int64, cond builder.Cond) (
return
}
for _, assignee := range assignees {
for i := range assignees {
taskUsers = append(taskUsers, &taskUser{
Task: taskMap[assignee.TaskID],
User: &assignee.User,
Task: taskMap[assignees[i].TaskID],
User: &assignees[i].User,
})
}
@ -173,6 +173,11 @@ func getTasksWithRemindersDueAndTheirUsers(s *xorm.Session, now time.Time) (remi
seen := make(map[int64]map[int64]bool)
projects, err := GetProjectsSimplByTaskIDs(s, taskIDs)
if err != nil {
return
}
// Time zone cache per time zone string to avoid parsing the same time zone over and over again
tzs := make(map[string]*time.Location)
// Figure out which reminders are actually due in the time zone of the users
@ -208,8 +213,9 @@ func getTasksWithRemindersDueAndTheirUsers(s *xorm.Session, now time.Time) (remi
actualReminder := r.Reminder.In(tz)
if (actualReminder.After(now) && actualReminder.Before(now.Add(time.Minute))) || actualReminder.Equal(now) {
reminderNotifications = append(reminderNotifications, &ReminderDueNotification{
User: u.User,
Task: u.Task,
User: u.User,
Task: u.Task,
Project: projects[u.Task.ProjectID],
})
}
}

View File

@ -92,8 +92,13 @@ func (d *dbTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, totalCo
// To still find tasks with nil values, we exclude 0s when comparing with >/< values.
for _, f := range opts.filters {
if f.field == "reminders" {
f.field = "reminder" // This is the name in the db
filter, err := getFilterCond(f, opts.filterIncludeNulls)
filter, err := getFilterCond(&taskFilter{
// recreating the struct here to avoid modifying it when reusing the opts struct
field: "reminder",
value: f.value,
comparator: f.comparator,
isNumeric: f.isNumeric,
}, opts.filterIncludeNulls)
if err != nil {
return nil, totalCount, err
}
@ -105,8 +110,13 @@ func (d *dbTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, totalCo
if f.comparator == taskFilterComparatorLike {
return nil, totalCount, err
}
f.field = "username"
filter, err := getFilterCond(f, opts.filterIncludeNulls)
filter, err := getFilterCond(&taskFilter{
// recreating the struct here to avoid modifying it when reusing the opts struct
field: "username",
value: f.value,
comparator: f.comparator,
isNumeric: f.isNumeric,
}, opts.filterIncludeNulls)
if err != nil {
return nil, totalCount, err
}
@ -115,8 +125,13 @@ func (d *dbTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, totalCo
}
if f.field == "labels" || f.field == "label_id" {
f.field = "label_id"
filter, err := getFilterCond(f, opts.filterIncludeNulls)
filter, err := getFilterCond(&taskFilter{
// recreating the struct here to avoid modifying it when reusing the opts struct
field: "label_id",
value: f.value,
comparator: f.comparator,
isNumeric: f.isNumeric,
}, opts.filterIncludeNulls)
if err != nil {
return nil, totalCount, err
}
@ -125,8 +140,13 @@ func (d *dbTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, totalCo
}
if f.field == "parent_project" || f.field == "parent_project_id" {
f.field = "parent_project_id"
filter, err := getFilterCond(f, opts.filterIncludeNulls)
filter, err := getFilterCond(&taskFilter{
// recreating the struct here to avoid modifying it when reusing the opts struct
field: "parent_project_id",
value: f.value,
comparator: f.comparator,
isNumeric: f.isNumeric,
}, opts.filterIncludeNulls)
if err != nil {
return nil, totalCount, err
}
@ -374,11 +394,14 @@ func (t *typesenseTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task,
Q: opts.search,
QueryBy: "title, identifier, description, comments.comment",
Page: pointer.Int(opts.page),
PerPage: pointer.Int(opts.perPage),
ExhaustiveSearch: pointer.True(),
FilterBy: pointer.String(strings.Join(filterBy, " && ")),
}
if opts.perPage > 0 {
params.PerPage = pointer.Int(opts.perPage)
}
if sortby != "" {
params.SortBy = pointer.String(sortby)
}

View File

@ -28,6 +28,7 @@ import (
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/api/pkg/utils"
"code.vikunja.io/web"
"dario.cat/mergo"
@ -78,7 +79,7 @@ type Task struct {
// An array of labels which are associated with this task.
Labels []*Label `xorm:"-" json:"labels"`
// The task color in hex
HexColor string `xorm:"varchar(6) null" json:"hex_color" valid:"runelength(0|6)" maxLength:"6"`
HexColor string `xorm:"varchar(6) null" json:"hex_color" valid:"runelength(0|7)" maxLength:"7"`
// Determines how far a task is left from being done
PercentDone float64 `xorm:"DOUBLE null" json:"percent_done"`
@ -417,10 +418,10 @@ func addAssigneesToTasks(s *xorm.Session, taskIDs []int64, taskMap map[int64]*Ta
return
}
// Put the assignees in the task map
for _, a := range taskAssignees {
for i, a := range taskAssignees {
if a != nil {
a.Email = "" // Obfuscate the email
taskMap[a.TaskID].Assignees = append(taskMap[a.TaskID].Assignees, &a.User)
a.User.Email = "" // Obfuscate the email
taskMap[a.TaskID].Assignees = append(taskMap[a.TaskID].Assignees, &taskAssignees[i].User)
}
}
@ -436,9 +437,9 @@ func addLabelsToTasks(s *xorm.Session, taskIDs []int64, taskMap map[int64]*Task)
if err != nil {
return
}
for _, l := range labels {
for i, l := range labels {
if l != nil {
taskMap[l.TaskID].Labels = append(taskMap[l.TaskID].Labels, &l.Label)
taskMap[l.TaskID].Labels = append(taskMap[l.TaskID].Labels, &labels[i].Label)
}
}
@ -731,7 +732,7 @@ func createTask(s *xorm.Session, t *Task, a web.Auth, updateAssignees bool) (err
t.ID = 0
// Check if we have at least a text
// Check if we have at least a title
if t.Title == "" {
return ErrTaskCannotBeEmpty{}
}
@ -768,7 +769,11 @@ func createTask(s *xorm.Session, t *Task, a web.Auth, updateAssignees bool) (err
// If no position was supplied, set a default one
t.Position = calculateDefaultPosition(t.Index, t.Position)
t.KanbanPosition = calculateDefaultPosition(t.Index, t.KanbanPosition)
if _, err = s.Insert(t); err != nil {
t.HexColor = utils.NormalizeHex(t.HexColor)
_, err = s.Insert(t)
if err != nil {
return err
}
@ -959,6 +964,8 @@ func (t *Task) Update(s *xorm.Session, a web.Auth) (err error) {
return err
}
t.HexColor = utils.NormalizeHex(t.HexColor)
//////
// Mergo does ignore nil values. Because of that, we need to check all parameters and set the updated to
// nil/their nil value in the struct which is inserted.
@ -1081,8 +1088,13 @@ func recalculateTaskKanbanPositions(s *xorm.Session, bucketID int64) (err error)
currentPosition := maxPosition / float64(len(allTasks)) * (float64(i + 1))
// Here we use "NoAutoTime() to prevent the ORM from updating column "updated" automatically.
// Otherwise, this signals to CalDAV clients that the task has changed, which is not the case.
// Consequence: when synchronizing a list of tasks, the first one immediately changes the date of all the
// following ones from the same batch, which are then unable to be updated.
_, err = s.Cols("kanban_position").
Where("id = ?", task.ID).
NoAutoTime().
Update(&Task{KanbanPosition: currentPosition})
if err != nil {
return
@ -1109,8 +1121,13 @@ func recalculateTaskPositions(s *xorm.Session, projectID int64) (err error) {
currentPosition := maxPosition / float64(len(allTasks)) * (float64(i + 1))
// Here we use "NoAutoTime() to prevent the ORM from updating column "updated" automatically.
// Otherwise, this signals to CalDAV clients that the task has changed, which is not the case.
// Consequence: when synchronizing a list of tasks, the first one immediately changes the date of all the
// following ones from the same batch, which are then unable to be updated.
_, err = s.Cols("position").
Where("id = ?", task.ID).
NoAutoTime().
Update(&Task{Position: currentPosition})
if err != nil {
return

View File

@ -159,7 +159,7 @@ func addMoreInfoToTeams(s *xorm.Session, teams []*Team) (err error) {
if _, exists := teamMap[u.TeamID]; !exists {
continue
}
u.Email = ""
u.User.Email = ""
teamMap[u.TeamID].Members = append(teamMap[u.TeamID].Members, u)
}

View File

@ -226,6 +226,11 @@ func ReindexAllTasks() (err error) {
return fmt.Errorf("could not get all tasks: %s", err.Error())
}
err = indexDummyTask()
if err != nil {
return fmt.Errorf("could not index dummy task: %w", err)
}
err = reindexTasks(s, tasks)
if err != nil {
return fmt.Errorf("could not reindex all tasks: %s", err.Error())
@ -242,6 +247,36 @@ func ReindexAllTasks() (err error) {
return
}
func getTypesenseTaskForTask(s *xorm.Session, task *Task, projectsCache map[int64]*Project) (ttask *typesenseTask, err error) {
ttask = convertTaskToTypesenseTask(task)
var p *Project
if projectsCache == nil {
p, err = GetProjectSimpleByID(s, task.ProjectID)
if err != nil {
return nil, fmt.Errorf("could not fetch project %d: %s", task.ProjectID, err.Error())
}
} else {
var has bool
p, has = projectsCache[task.ProjectID]
if !has {
p, err = GetProjectSimpleByID(s, task.ProjectID)
if err != nil {
return nil, fmt.Errorf("could not fetch project %d: %s", task.ProjectID, err.Error())
}
projectsCache[task.ProjectID] = p
}
}
comment := &TaskComment{TaskID: task.ID}
ttask.Comments, _, _, err = comment.ReadAll(s, &user.User{ID: p.OwnerID}, "", -1, -1)
if err != nil {
return nil, fmt.Errorf("could not fetch comments for task %d: %s", task.ID, err.Error())
}
return
}
func reindexTasks(s *xorm.Session, tasks map[int64]*Task) (err error) {
if len(tasks) == 0 {
@ -258,24 +293,13 @@ func reindexTasks(s *xorm.Session, tasks map[int64]*Task) (err error) {
typesenseTasks := []interface{}{}
for _, task := range tasks {
searchTask := convertTaskToTypesenseTask(task)
p, has := projects[task.ProjectID]
if !has {
p, err = GetProjectSimpleByID(s, task.ProjectID)
if err != nil {
return fmt.Errorf("could not fetch project %d: %s", task.ProjectID, err.Error())
}
projects[task.ProjectID] = p
}
comment := &TaskComment{TaskID: task.ID}
searchTask.Comments, _, _, err = comment.ReadAll(s, &user.User{ID: p.OwnerID}, "", -1, -1)
ttask, err := getTypesenseTaskForTask(s, task, projects)
if err != nil {
return fmt.Errorf("could not fetch comments for task %d: %s", task.ID, err.Error())
return err
}
typesenseTasks = append(typesenseTasks, searchTask)
typesenseTasks = append(typesenseTasks, ttask)
}
_, err = typesenseClient.Collection("tasks").
@ -292,6 +316,82 @@ func reindexTasks(s *xorm.Session, tasks map[int64]*Task) (err error) {
return nil
}
func indexDummyTask() (err error) {
// The initial sync should contain one dummy task with all related fields populated so that typesense
// creates the indexes properly. A little hacky, but gets the job done.
dummyTask := &typesenseTask{
ID: "-100",
Title: "Dummytask",
Created: time.Now().Unix(),
Updated: time.Now().Unix(),
Reminders: []*TaskReminder{
{
ID: -10,
TaskID: -100,
Reminder: time.Now(),
RelativePeriod: 10,
RelativeTo: ReminderRelationDueDate,
Created: time.Now(),
},
},
Assignees: []*user.User{
{
ID: -100,
Username: "dummy",
Name: "dummy",
Email: "dummy@vikunja",
Created: time.Now(),
Updated: time.Now(),
},
},
Labels: []*Label{
{
ID: -110,
Title: "dummylabel",
Description: "Lorem Ipsum Dummy",
HexColor: "000000",
Created: time.Now(),
Updated: time.Now(),
},
},
Attachments: []*TaskAttachment{
{
ID: -120,
TaskID: -100,
Created: time.Now(),
},
},
Comments: []*TaskComment{
{
ID: -220,
Comment: "Lorem Ipsum Dummy",
Created: time.Now(),
Updated: time.Now(),
Author: &user.User{
ID: -100,
Username: "dummy",
Name: "dummy",
Email: "dummy@vikunja",
Created: time.Now(),
Updated: time.Now(),
},
},
},
}
_, err = typesenseClient.Collection("tasks").
Documents().
Create(dummyTask)
if err != nil {
return
}
_, err = typesenseClient.Collection("tasks").
Document(dummyTask.ID).
Delete()
return
}
type typesenseTask struct {
ID string `json:"id"`
Title string `json:"title"`
@ -406,6 +506,7 @@ func SyncUpdatedTasksIntoTypesense() (err error) {
err = s.
Where("updated >= ?", lastSync.SyncStartedAt).
And("updated != created"). // new tasks are already indexed via the event handler
Find(tasks)
if err != nil {
_ = s.Rollback()

298
pkg/models/webhooks.go Normal file
View File

@ -0,0 +1,298 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present 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 Affero General Public Licensee 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 Affero General Public Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package models
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"net/http"
"net/url"
"sort"
"strings"
"sync"
"time"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/api/pkg/version"
"code.vikunja.io/web"
"xorm.io/xorm"
)
var webhookClient *http.Client
type Webhook struct {
// The generated ID of this webhook target
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"webhook"`
// The target URL where the POST request with the webhook payload will be made
TargetURL string `xorm:"not null" valid:"required,url" json:"target_url"`
// The webhook events which should fire this webhook target
Events []string `xorm:"JSON not null" valid:"required" json:"events"`
// The project ID of the project this webhook target belongs to
ProjectID int64 `xorm:"bigint not null index" json:"project_id" param:"project"`
// If provided, webhook requests will be signed using HMAC. Check out the docs about how to use this: https://vikunja.io/docs/webhooks/#signing
Secret string `xorm:"null" json:"secret"`
// The user who initially created the webhook target.
CreatedBy *user.User `xorm:"-" json:"created_by" valid:"-"`
CreatedByID int64 `xorm:"bigint not null" json:"-"`
// A timestamp when this webhook target was created. You cannot change this value.
Created time.Time `xorm:"created not null" json:"created"`
// A timestamp when this webhook target was last updated. You cannot change this value.
Updated time.Time `xorm:"updated not null" json:"updated"`
web.CRUDable `xorm:"-" json:"-"`
web.Rights `xorm:"-" json:"-"`
}
func (w *Webhook) TableName() string {
return "webhooks"
}
var availableWebhookEvents map[string]bool
var availableWebhookEventsLock *sync.Mutex
func init() {
availableWebhookEvents = make(map[string]bool)
availableWebhookEventsLock = &sync.Mutex{}
}
func RegisterEventForWebhook(event events.Event) {
availableWebhookEventsLock.Lock()
defer availableWebhookEventsLock.Unlock()
availableWebhookEvents[event.Name()] = true
events.RegisterListener(event.Name(), &WebhookListener{
EventName: event.Name(),
})
}
func GetAvailableWebhookEvents() []string {
evts := []string{}
for e := range availableWebhookEvents {
evts = append(evts, e)
}
sort.Strings(evts)
return evts
}
// Create creates a webhook target
// @Summary Create a webhook target
// @Description Create a webhook target which receives POST requests about specified events from a project.
// @tags webhooks
// @Accept json
// @Produce json
// @Security JWTKeyAuth
// @Param id path int true "Project ID"
// @Param webhook body models.Webhook true "The webhook target object with required fields"
// @Success 200 {object} models.Webhook "The created webhook target."
// @Failure 400 {object} web.HTTPError "Invalid webhook object provided."
// @Failure 500 {object} models.Message "Internal error"
// @Router /projects/{id}/webhooks [put]
func (w *Webhook) Create(s *xorm.Session, a web.Auth) (err error) {
if !strings.HasPrefix(w.TargetURL, "http") {
return InvalidFieldError([]string{"target_url"})
}
for _, event := range w.Events {
if _, has := availableWebhookEvents[event]; !has {
return InvalidFieldError([]string{"events"})
}
}
w.CreatedByID = a.GetID()
_, err = s.Insert(w)
if err != nil {
return err
}
w.CreatedBy, err = user.GetUserByID(s, a.GetID())
return
}
// ReadAll returns all webhook targets for a project
// @Summary Get all api webhook targets for the specified project
// @Description Get all api webhook targets for the specified project.
// @tags webhooks
// @Accept json
// @Produce json
// @Security JWTKeyAuth
// @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 bucket per page. This parameter is limited by the configured maximum of items per page."
// @Param id path int true "Project ID"
// @Success 200 {array} models.Webhook "The list of all webhook targets"
// @Failure 500 {object} models.Message "Internal server error"
// @Router /projects/{id}/webhooks [get]
func (w *Webhook) ReadAll(s *xorm.Session, a web.Auth, _ string, page int, perPage int) (result interface{}, resultCount int, numberOfTotalItems int64, err error) {
p := &Project{ID: w.ProjectID}
can, _, err := p.CanRead(s, a)
if err != nil {
return nil, 0, 0, err
}
if !can {
return nil, 0, 0, ErrGenericForbidden{}
}
ws := []*Webhook{}
err = s.Where("project_id = ?", w.ProjectID).
Limit(getLimitFromPageIndex(page, perPage)).
Find(&ws)
if err != nil {
return
}
total, err := s.Where("project_id = ?", w.ProjectID).
Count(&Webhook{})
if err != nil {
return
}
userIDs := []int64{}
for _, webhook := range ws {
userIDs = append(userIDs, webhook.CreatedByID)
}
users, err := user.GetUsersByIDs(s, userIDs)
if err != nil {
return nil, 0, 0, err
}
for _, webhook := range ws {
webhook.Secret = ""
webhook.CreatedBy = users[webhook.CreatedByID]
}
return ws, len(ws), total, err
}
// Update updates a webhook target
// @Summary Change a webhook target's events.
// @Description Change a webhook target's events. You cannot change other values of a webhook.
// @tags webhooks
// @Accept json
// @Produce json
// @Security JWTKeyAuth
// @Param id path int true "Project ID"
// @Param webhookID path int true "Webhook ID"
// @Success 200 {object} models.Webhook "Updated webhook target"
// @Failure 404 {object} web.HTTPError "The webhok target does not exist"
// @Failure 500 {object} models.Message "Internal error"
// @Router /projects/{id}/webhooks/{webhookID} [post]
func (w *Webhook) Update(s *xorm.Session, _ web.Auth) (err error) {
for _, event := range w.Events {
if _, has := availableWebhookEvents[event]; !has {
return InvalidFieldError([]string{"events"})
}
}
_, err = s.Where("id = ?", w.ID).
Cols("events").
Update(w)
return
}
// Delete deletes a webhook target
// @Summary Deletes an existing webhook target
// @Description Delete any of the project's webhook targets.
// @tags webhooks
// @Accept json
// @Produce json
// @Security JWTKeyAuth
// @Param id path int true "Project ID"
// @Param webhookID path int true "Webhook ID"
// @Success 200 {object} models.Message "Successfully deleted."
// @Failure 404 {object} web.HTTPError "The webhok target does not exist."
// @Failure 500 {object} models.Message "Internal error"
// @Router /projects/{id}/webhooks/{webhookID} [delete]
func (w *Webhook) Delete(s *xorm.Session, _ web.Auth) (err error) {
_, err = s.Where("id = ?", w.ID).Delete(&Webhook{})
return
}
func getWebhookHTTPClient() (client *http.Client) {
if webhookClient != nil {
return webhookClient
}
client = http.DefaultClient
client.Timeout = time.Duration(config.WebhooksTimeoutSeconds.GetInt()) * time.Second
if config.WebhooksProxyURL.GetString() == "" || config.WebhooksProxyPassword.GetString() == "" {
webhookClient = client
return
}
proxyURL, _ := url.Parse(config.WebhooksProxyURL.GetString())
client.Transport = &http.Transport{
Proxy: http.ProxyURL(proxyURL),
ProxyConnectHeader: http.Header{
"Proxy-Authorization": []string{"Basic " + base64.StdEncoding.EncodeToString([]byte("vikunja:"+config.WebhooksProxyPassword.GetString()))},
"User-Agent": []string{"Vikunja/" + version.Version},
},
}
webhookClient = client
return
}
func (w *Webhook) sendWebhookPayload(p *WebhookPayload) (err error) {
payload, err := json.Marshal(p)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, w.TargetURL, bytes.NewReader(payload))
if err != nil {
return err
}
if len(w.Secret) > 0 {
sig256 := hmac.New(sha256.New, []byte(w.Secret))
_, err = sig256.Write(payload)
if err != nil {
log.Errorf("Could not generate webhook signature for Webhook %d: %s", w.ID, err)
}
signature := hex.EncodeToString(sig256.Sum(nil))
req.Header.Add("X-Vikunja-Signature", signature)
}
req.Header.Add("User-Agent", "Vikunja/"+version.Version)
client := getWebhookHTTPClient()
res, err := client.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
log.Debugf("Sent webhook payload for webhook %d for event %s", w.ID, p.EventName)
return
}

View File

@ -0,0 +1,49 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present 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 Affero General Public Licensee 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 Affero General Public Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package models
import (
"code.vikunja.io/web"
"xorm.io/xorm"
)
func (w *Webhook) CanRead(s *xorm.Session, a web.Auth) (bool, int, error) {
p := &Project{ID: w.ProjectID}
return p.CanRead(s, a)
}
func (w *Webhook) CanDelete(s *xorm.Session, a web.Auth) (bool, error) {
return w.canDoWebhook(s, a)
}
func (w *Webhook) CanUpdate(s *xorm.Session, a web.Auth) (bool, error) {
return w.canDoWebhook(s, a)
}
func (w *Webhook) CanCreate(s *xorm.Session, a web.Auth) (bool, error) {
return w.canDoWebhook(s, a)
}
func (w *Webhook) canDoWebhook(s *xorm.Session, a web.Auth) (bool, error) {
_, isShareAuth := a.(*LinkSharing)
if isShareAuth {
return false, nil
}
p := &Project{ID: w.ProjectID}
return p.CanUpdate(s, a)
}

View File

@ -55,7 +55,7 @@ func insertFromStructure(s *xorm.Session, str []*models.ProjectWithTasksAndBucke
childRelations := make(map[int64][]int64) // old id is the key, slice of old children ids
projectsByOldID := make(map[int64]*models.Project) // old id is the key
// Create all projects
for _, p := range str {
for i, p := range str {
oldID := p.ID
if p.ParentProjectID != 0 {
@ -67,7 +67,7 @@ func insertFromStructure(s *xorm.Session, str []*models.ProjectWithTasksAndBucke
if err != nil {
return err
}
projectsByOldID[oldID] = &p.Project
projectsByOldID[oldID] = &str[i].Project
}
// parent / child relations
@ -198,8 +198,8 @@ func createProjectWithEverything(s *xorm.Session, project *models.ProjectWithTas
tasksByOldID := make(map[int64]*models.TaskWithComments, len(tasks))
// Create all tasks
for _, t := range tasks {
setBucketOrDefault(&t.Task)
for i, t := range tasks {
setBucketOrDefault(&tasks[i].Task)
oldid := t.ID
t.ProjectID = project.ID

View File

@ -97,3 +97,11 @@ func MarkNotificationAsRead(s *xorm.Session, notification *DatabaseNotification,
Update(notification)
return
}
func MarkAllNotificationsAsRead(s *xorm.Session, userID int64) (err error) {
_, err = s.
Where("notifiable_id = ?", userID).
Cols("read_at").
Update(&DatabaseNotification{ReadAt: time.Now()})
return
}

View File

@ -50,6 +50,7 @@ type vikunjaInfos struct {
UserDeletionEnabled bool `json:"user_deletion_enabled"`
TaskCommentsEnabled bool `json:"task_comments_enabled"`
DemoModeEnabled bool `json:"demo_mode_enabled"`
WebhooksEnabled bool `json:"webhooks_enabled"`
}
type authInfo struct {
@ -94,6 +95,7 @@ func Info(c echo.Context) error {
UserDeletionEnabled: config.ServiceEnableUserDeletion.GetBool(),
TaskCommentsEnabled: config.ServiceEnableTaskComments.GetBool(),
DemoModeEnabled: config.ServiceDemoMode.GetBool(),
WebhooksEnabled: config.WebhooksEnabled.GetBool(),
AvailableMigrators: []string{
(&vikunja_file.FileMigrator{}).Name(),
(&ticktick.Migrator{}).Name(),

View File

@ -0,0 +1,56 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present 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 Affero General Public Licensee 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 Affero General Public Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package v1
import (
"net/http"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/auth"
"code.vikunja.io/api/pkg/notifications"
"github.com/labstack/echo/v4"
)
// MarkAllNotificationsAsRead marks all notifications of a user as read
// @Summary Mark all notifications of a user as read
// @tags sharing
// @Accept json
// @Produce json
// @Success 200 {object} models.Message "All notifications marked as read."
// @Failure 500 {object} models.Message "Internal error"
// @Router /notifications [post]
func MarkAllNotificationsAsRead(c echo.Context) error {
s := db.NewSession()
defer s.Close()
a, err := auth.GetAuthFromClaims(c)
if err != nil {
return err
}
if _, is := a.(*models.LinkSharing); is {
return echo.ErrForbidden
}
err = notifications.MarkAllNotificationsAsRead(s, a.GetID())
if err != nil {
return err
}
return c.JSON(http.StatusOK, models.Message{Message: "success"})
}

View File

@ -47,20 +47,11 @@ type UserDeletionRequestConfirm struct {
// @Failure 500 {object} models.Message "Internal error"
// @Router /user/deletion/request [post]
func UserRequestDeletion(c echo.Context) error {
var deletionRequest UserPasswordConfirmation
if err := c.Bind(&deletionRequest); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "No password provided.")
}
err := c.Validate(deletionRequest)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err)
}
s := db.NewSession()
defer s.Close()
err = s.Begin()
err := s.Begin()
if err != nil {
return handler.HandleHTTPError(err, c)
}
@ -71,10 +62,22 @@ func UserRequestDeletion(c echo.Context) error {
return handler.HandleHTTPError(err, c)
}
err = user.CheckUserPassword(u, deletionRequest.Password)
if err != nil {
_ = s.Rollback()
return handler.HandleHTTPError(err, c)
if u.IsLocalUser() {
var deletionRequest UserPasswordConfirmation
if err := c.Bind(&deletionRequest); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "No password provided.")
}
err = c.Validate(deletionRequest)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err)
}
err = user.CheckUserPassword(u, deletionRequest.Password)
if err != nil {
_ = s.Rollback()
return handler.HandleHTTPError(err, c)
}
}
err = user.RequestDeletion(s, u)
@ -155,20 +158,11 @@ func UserConfirmDeletion(c echo.Context) error {
// @Failure 500 {object} models.Message "Internal error"
// @Router /user/deletion/cancel [post]
func UserCancelDeletion(c echo.Context) error {
var deletionRequest UserPasswordConfirmation
if err := c.Bind(&deletionRequest); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "No password provided.")
}
err := c.Validate(deletionRequest)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err)
}
s := db.NewSession()
defer s.Close()
err = s.Begin()
err := s.Begin()
if err != nil {
return handler.HandleHTTPError(err, c)
}
@ -179,10 +173,22 @@ func UserCancelDeletion(c echo.Context) error {
return handler.HandleHTTPError(err, c)
}
err = user.CheckUserPassword(u, deletionRequest.Password)
if err != nil {
_ = s.Rollback()
return handler.HandleHTTPError(err, c)
if u.IsLocalUser() {
var deletionRequest UserPasswordConfirmation
if err := c.Bind(&deletionRequest); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "No password provided.")
}
err = c.Validate(deletionRequest)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err)
}
err = user.CheckUserPassword(u, deletionRequest.Password)
if err != nil {
_ = s.Rollback()
return handler.HandleHTTPError(err, c)
}
}
err = user.CancelDeletion(s, u)

View File

@ -0,0 +1,38 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present 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 Affero General Public Licensee 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 Affero General Public Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package v1
import (
"net/http"
"code.vikunja.io/api/pkg/models"
"github.com/labstack/echo/v4"
)
// GetAvailableWebhookEvents returns a list of all possible webhook target events
// @Summary Get all possible webhook events
// @Description Get all possible webhook events to use when creating or updating a webhook target.
// @tags webhooks
// @Accept json
// @Produce json
// @Security JWTKeyAuth
// @Success 200 {array} string "The list of all possible webhook events"
// @Failure 500 {object} models.Message "Internal server error"
// @Router /webhooks/events [get]
func GetAvailableWebhookEvents(c echo.Context) error {
return c.JSON(http.StatusOK, models.GetAvailableWebhookEvents())
}

View File

@ -172,14 +172,14 @@ func (vcls *VikunjaCaldavProjectStorage) GetResourcesByFilters(rpath string, _ *
// That project is coming from a previous "getProjectRessource" in L177
if vcls.project.Tasks != nil {
var resources []data.Resource
for _, t := range vcls.project.Tasks {
for i := range vcls.project.Tasks {
rr := VikunjaProjectResourceAdapter{
project: vcls.project,
task: &t.Task,
task: &vcls.project.Tasks[i].Task,
isCollection: false,
}
r := data.NewResource(getTaskURL(&t.Task), &rr)
r.Name = t.Title
r := data.NewResource(getTaskURL(&vcls.project.Tasks[i].Task), &rr)
r.Name = vcls.project.Tasks[i].Title
resources = append(resources, r)
}
return resources, nil
@ -428,8 +428,8 @@ func persistLabels(s *xorm.Session, a web.Auth, task *models.Task, labels []*mod
}
labelMap := make(map[string]*models.Label)
for _, l := range existingLabels {
labelMap[l.Title] = &l.Label
for i := range existingLabels {
labelMap[existingLabels[i].Title] = &existingLabels[i].Label
}
for _, label := range labels {

View File

@ -0,0 +1,443 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present 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 Affero General Public Licensee 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 Affero General Public Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package caldav
// This file tests logic related to handling tasks in CALDAV format
import (
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/user"
"github.com/stretchr/testify/assert"
"testing"
)
// Check logic related to creating sub-tasks
func TestSubTask_Create(t *testing.T) {
u := &user.User{
ID: 15,
Username: "user15",
Email: "user15@example.com",
}
//
// Create a subtask
//
t.Run("create", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
const taskUID = "uid_child1"
const taskContent = `BEGIN:VCALENDAR
VERSION:2.0
METHOD:PUBLISH
X-PUBLISHED-TTL:PT4H
X-WR-CALNAME:Project 36 for Caldav tests
PRODID:-//Vikunja Todo App//EN
BEGIN:VTODO
UID:uid_child1
DTSTAMP:20230301T073337Z
SUMMARY:Caldav child task 1
CREATED:20230301T073337Z
LAST-MODIFIED:20230301T073337Z
RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-task
END:VTODO
END:VCALENDAR`
storage := &VikunjaCaldavProjectStorage{
project: &models.ProjectWithTasksAndBuckets{Project: models.Project{ID: 36}},
task: &models.Task{UID: taskUID},
user: u,
}
// Create the subtask:
taskResource, err := storage.CreateResource(taskUID, taskContent)
assert.NoError(t, err)
// Check that the result CALDAV contains the relation:
content, _ := taskResource.GetContentData()
assert.Contains(t, content, "UID:"+taskUID)
assert.Contains(t, content, "RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-task")
// Get the task from the DB:
tasks, err := models.GetTasksByUIDs(s, []string{taskUID}, u)
assert.NoError(t, err)
task := tasks[0]
// Check that the parent-child relationship is present:
assert.Len(t, task.RelatedTasks[models.RelationKindParenttask], 1)
parentTask := task.RelatedTasks[models.RelationKindParenttask][0]
assert.Equal(t, "uid-caldav-test-parent-task", parentTask.UID)
})
//
// Create a subtask on a subtask, i.e. create a grand-child
//
t.Run("create grandchild on child task", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
const taskUID = "uid_child1"
const taskContent = `BEGIN:VCALENDAR
VERSION:2.0
METHOD:PUBLISH
X-PUBLISHED-TTL:PT4H
X-WR-CALNAME:Project 36 for Caldav tests
PRODID:-//Vikunja Todo App//EN
BEGIN:VTODO
UID:uid_child1
DTSTAMP:20230301T073337Z
SUMMARY:Caldav child task 1
CREATED:20230301T073337Z
LAST-MODIFIED:20230301T073337Z
RELATED-TO;RELTYPE=PARENT:uid-caldav-test-child-task
END:VTODO
END:VCALENDAR`
storage := &VikunjaCaldavProjectStorage{
project: &models.ProjectWithTasksAndBuckets{Project: models.Project{ID: 36}},
task: &models.Task{UID: taskUID},
user: u,
}
// Create the task:
taskResource, err := storage.CreateResource(taskUID, taskContent)
assert.NoError(t, err)
// Check that the result CALDAV contains the relation:
content, _ := taskResource.GetContentData()
assert.Contains(t, content, "UID:"+taskUID)
assert.Contains(t, content, "RELATED-TO;RELTYPE=PARENT:uid-caldav-test-child-task")
// Get the task from the DB:
tasks, err := models.GetTasksByUIDs(s, []string{taskUID}, u)
assert.NoError(t, err)
task := tasks[0]
// Check that the parent-child relationship of the grandchildren is present:
assert.Len(t, task.RelatedTasks[models.RelationKindParenttask], 1)
parentTask := task.RelatedTasks[models.RelationKindParenttask][0]
assert.Equal(t, "uid-caldav-test-child-task", parentTask.UID)
// Get the child task and check that it now has a parent and a child:
tasks, err = models.GetTasksByUIDs(s, []string{"uid-caldav-test-child-task"}, u)
assert.NoError(t, err)
task = tasks[0]
assert.Len(t, task.RelatedTasks[models.RelationKindParenttask], 1)
parentTask = task.RelatedTasks[models.RelationKindParenttask][0]
assert.Equal(t, "uid-caldav-test-parent-task", parentTask.UID)
assert.Len(t, task.RelatedTasks[models.RelationKindSubtask], 1)
childTask := task.RelatedTasks[models.RelationKindSubtask][0]
assert.Equal(t, taskUID, childTask.UID)
})
//
// Create a subtask on a parent that we don't know anything about (yet)
//
t.Run("create subtask on unknown parent", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Create a subtask:
const taskUID = "uid_child1"
const taskContent = `BEGIN:VCALENDAR
VERSION:2.0
METHOD:PUBLISH
X-PUBLISHED-TTL:PT4H
X-WR-CALNAME:Project 36 for Caldav tests
PRODID:-//Vikunja Todo App//EN
BEGIN:VTODO
UID:uid_child1
DTSTAMP:20230301T073337Z
SUMMARY:Caldav child task 1
CREATED:20230301T073337Z
LAST-MODIFIED:20230301T073337Z
RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-doesnt-exist-yet
END:VTODO
END:VCALENDAR`
storage := &VikunjaCaldavProjectStorage{
project: &models.ProjectWithTasksAndBuckets{Project: models.Project{ID: 36}},
task: &models.Task{UID: taskUID},
user: u,
}
// Create the task:
taskResource, err := storage.CreateResource(taskUID, taskContent)
assert.NoError(t, err)
// Check that the result CALDAV contains the relation:
content, _ := taskResource.GetContentData()
assert.Contains(t, content, "UID:"+taskUID)
assert.Contains(t, content, "RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-doesnt-exist-yet")
// Get the task from the DB:
tasks, err := models.GetTasksByUIDs(s, []string{taskUID}, u)
assert.NoError(t, err)
task := tasks[0]
// Check that the parent-child relationship is present:
assert.Len(t, task.RelatedTasks[models.RelationKindParenttask], 1)
parentTask := task.RelatedTasks[models.RelationKindParenttask][0]
assert.Equal(t, "uid-caldav-test-parent-doesnt-exist-yet", parentTask.UID)
// Check that the non-existent parent task was created in the process:
tasks, err = models.GetTasksByUIDs(s, []string{"uid-caldav-test-parent-doesnt-exist-yet"}, u)
assert.NoError(t, err)
task = tasks[0]
assert.Equal(t, "uid-caldav-test-parent-doesnt-exist-yet", task.UID)
})
}
// Logic related to editing tasks and subtasks
func TestSubTask_Edit(t *testing.T) {
u := &user.User{
ID: 15,
Username: "user15",
Email: "user15@example.com",
}
//
// Edit a subtask and check that the relations are not gone
//
t.Run("edit subtask", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Edit the subtask:
const taskUID = "uid-caldav-test-child-task"
const taskContent = `BEGIN:VCALENDAR
VERSION:2.0
METHOD:PUBLISH
X-PUBLISHED-TTL:PT4H
X-WR-CALNAME:Project 36 for Caldav tests
PRODID:-//Vikunja Todo App//EN
BEGIN:VTODO
UID:uid-caldav-test-child-task
DTSTAMP:20230301T073337Z
SUMMARY:Child task for Caldav Test (edited)
CREATED:20230301T073337Z
LAST-MODIFIED:20230301T073337Z
RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-task
END:VTODO
END:VCALENDAR`
tasks, err := models.GetTasksByUIDs(s, []string{taskUID}, u)
assert.NoError(t, err)
task := tasks[0]
storage := &VikunjaCaldavProjectStorage{
project: &models.ProjectWithTasksAndBuckets{Project: models.Project{ID: 36}},
task: task,
user: u,
}
// Edit the task:
taskResource, err := storage.UpdateResource(taskUID, taskContent)
assert.NoError(t, err)
// Check that the result CALDAV still contains the relation:
content, _ := taskResource.GetContentData()
assert.Contains(t, content, "UID:"+taskUID)
assert.Contains(t, content, "RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-task")
// Get the task from the DB:
tasks, err = models.GetTasksByUIDs(s, []string{taskUID}, u)
assert.NoError(t, err)
task = tasks[0]
// Check that the parent-child relationship is still present:
assert.Len(t, task.RelatedTasks[models.RelationKindParenttask], 1)
parentTask := task.RelatedTasks[models.RelationKindParenttask][0]
assert.Equal(t, "uid-caldav-test-parent-task", parentTask.UID)
})
//
// Edit a parent task and check that the subtasks are still linked
//
t.Run("edit parent", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Edit the parent task:
const taskUID = "uid-caldav-test-parent-task"
const taskContent = `BEGIN:VCALENDAR
VERSION:2.0
METHOD:PUBLISH
X-PUBLISHED-TTL:PT4H
X-WR-CALNAME:Project 36 for Caldav tests
PRODID:-//Vikunja Todo App//EN
BEGIN:VTODO
UID:uid-caldav-test-parent-task
DTSTAMP:20230301T073337Z
SUMMARY:Parent task for Caldav Test (edited)
CREATED:20230301T073337Z
LAST-MODIFIED:20230301T073337Z
END:VTODO
END:VCALENDAR`
tasks, err := models.GetTasksByUIDs(s, []string{taskUID}, u)
assert.NoError(t, err)
task := tasks[0]
storage := &VikunjaCaldavProjectStorage{
project: &models.ProjectWithTasksAndBuckets{Project: models.Project{ID: 36}},
task: task,
user: u,
}
// Edit the task:
_, err = storage.UpdateResource(taskUID, taskContent)
assert.NoError(t, err)
// Get the task from the DB:
tasks, err = models.GetTasksByUIDs(s, []string{taskUID}, u)
assert.NoError(t, err)
task = tasks[0]
// Check that the subtasks are still linked:
assert.Len(t, task.RelatedTasks[models.RelationKindSubtask], 2)
existingSubTask := task.RelatedTasks[models.RelationKindSubtask][0]
assert.Equal(t, "uid-caldav-test-child-task", existingSubTask.UID)
existingSubTask = task.RelatedTasks[models.RelationKindSubtask][1]
assert.Equal(t, "uid-caldav-test-child-task-2", existingSubTask.UID)
})
//
// Edit a subtask and change its parent
//
t.Run("edit subtask change parent", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Edit the subtask:
const taskUID = "uid-caldav-test-child-task"
const taskContent = `BEGIN:VCALENDAR
VERSION:2.0
METHOD:PUBLISH
X-PUBLISHED-TTL:PT4H
X-WR-CALNAME:Project 36 for Caldav tests
PRODID:-//Vikunja Todo App//EN
BEGIN:VTODO
UID:uid-caldav-test-child-task
DTSTAMP:20230301T073337Z
SUMMARY:Child task for Caldav Test (edited)
CREATED:20230301T073337Z
LAST-MODIFIED:20230301T073337Z
RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-task-2
END:VTODO
END:VCALENDAR`
tasks, err := models.GetTasksByUIDs(s, []string{taskUID}, u)
assert.NoError(t, err)
task := tasks[0]
storage := &VikunjaCaldavProjectStorage{
project: &models.ProjectWithTasksAndBuckets{Project: models.Project{ID: 36}},
task: task,
user: u,
}
// Edit the task:
taskResource, err := storage.UpdateResource(taskUID, taskContent)
assert.NoError(t, err)
// Check that the result CALDAV contains the new relation:
content, _ := taskResource.GetContentData()
assert.Contains(t, content, "UID:"+taskUID)
assert.Contains(t, content, "RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-task-2")
// Get the task from the DB:
tasks, err = models.GetTasksByUIDs(s, []string{taskUID}, u)
assert.NoError(t, err)
task = tasks[0]
// Check that the parent-child relationship has changed to the new parent:
assert.Len(t, task.RelatedTasks[models.RelationKindParenttask], 1)
parentTask := task.RelatedTasks[models.RelationKindParenttask][0]
assert.Equal(t, "uid-caldav-test-parent-task-2", parentTask.UID)
// Get the previous parent from the DB and check that its previous child is gone:
tasks, err = models.GetTasksByUIDs(s, []string{"uid-caldav-test-parent-task"}, u)
assert.NoError(t, err)
task = tasks[0]
assert.Len(t, task.RelatedTasks[models.RelationKindSubtask], 1)
// We're gone, but our former sibling is still there:
formerSiblingSubTask := task.RelatedTasks[models.RelationKindSubtask][0]
assert.Equal(t, "uid-caldav-test-child-task-2", formerSiblingSubTask.UID)
})
//
// Edit a subtask and remove its parent
//
t.Run("edit subtask remove parent", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Edit the subtask:
const taskUID = "uid-caldav-test-child-task"
const taskContent = `BEGIN:VCALENDAR
VERSION:2.0
METHOD:PUBLISH
X-PUBLISHED-TTL:PT4H
X-WR-CALNAME:Project 36 for Caldav tests
PRODID:-//Vikunja Todo App//EN
BEGIN:VTODO
UID:uid-caldav-test-child-task
DTSTAMP:20230301T073337Z
SUMMARY:Child task for Caldav Test (edited)
CREATED:20230301T073337Z
LAST-MODIFIED:20230301T073337Z
END:VTODO
END:VCALENDAR`
tasks, err := models.GetTasksByUIDs(s, []string{taskUID}, u)
assert.NoError(t, err)
task := tasks[0]
storage := &VikunjaCaldavProjectStorage{
project: &models.ProjectWithTasksAndBuckets{Project: models.Project{ID: 36}},
task: task,
user: u,
}
// Edit the task:
taskResource, err := storage.UpdateResource(taskUID, taskContent)
assert.NoError(t, err)
// Check that the result CALDAV contains the new relation:
content, _ := taskResource.GetContentData()
assert.Contains(t, content, "UID:"+taskUID)
assert.NotContains(t, content, "RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-task")
// Get the task from the DB:
tasks, err = models.GetTasksByUIDs(s, []string{taskUID}, u)
assert.NoError(t, err)
task = tasks[0]
// Check that the parent-child relationship is gone:
assert.Len(t, task.RelatedTasks[models.RelationKindParenttask], 0)
// Get the previous parent from the DB and check that its child is gone:
tasks, err = models.GetTasksByUIDs(s, []string{"uid-caldav-test-parent-task"}, u)
assert.NoError(t, err)
task = tasks[0]
// We're gone, but our former sibling is still there:
assert.Len(t, task.RelatedTasks[models.RelationKindSubtask], 1)
formerSiblingSubTask := task.RelatedTasks[models.RelationKindSubtask][0]
assert.Equal(t, "uid-caldav-test-child-task-2", formerSiblingSubTask.UID)
})
}

View File

@ -0,0 +1,19 @@
package caldav
import (
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/files"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/user"
"testing"
)
// TestMain is the main test function used to bootstrap the test env
func TestMain(m *testing.M) {
config.InitDefaultConfig()
files.InitTests(true)
user.InitTests()
models.SetupTests()
m.Run()
}

View File

@ -95,19 +95,19 @@ func NewEcho() *echo.Echo {
e.HideBanner = true
if l, ok := e.Logger.(*elog.Logger); ok {
if config.LogEcho.GetString() == "off" {
if !config.LogEnabled.GetBool() || config.LogEcho.GetString() == "off" {
l.SetLevel(elog.OFF)
}
l.EnableColor()
l.SetHeader(log.ErrFmt)
l.SetOutput(log.GetLogWriter("echo"))
l.SetOutput(log.GetLogWriter(config.LogEcho.GetString(), "echo"))
}
// Logger
if config.LogHTTP.GetString() != "off" {
if !config.LogEnabled.GetBool() || config.LogHTTP.GetString() != "off" {
e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
Format: log.WebFmt + "\n",
Output: log.GetLogWriter("http"),
Output: log.GetLogWriter(config.LogHTTP.GetString(), "http"),
}))
}
@ -535,6 +535,7 @@ func registerAPIRoutes(a *echo.Group) {
}
a.GET("/notifications", notificationHandler.ReadAllWeb)
a.POST("/notifications/:notificationid", notificationHandler.UpdateWeb)
a.POST("/notifications", apiv1.MarkAllNotificationsAsRead)
// Migrations
m := a.Group("/migration")
@ -574,6 +575,20 @@ func registerAPIRoutes(a *echo.Group) {
a.GET("/tokens", apiTokenProvider.ReadAllWeb)
a.PUT("/tokens", apiTokenProvider.CreateWeb)
a.DELETE("/tokens/:token", apiTokenProvider.DeleteWeb)
// Webhooks
if config.WebhooksEnabled.GetBool() {
webhookProvider := &handler.WebHandler{
EmptyStruct: func() handler.CObject {
return &models.Webhook{}
},
}
a.GET("/projects/:project/webhooks", webhookProvider.ReadAllWeb)
a.PUT("/projects/:project/webhooks", webhookProvider.CreateWeb)
a.DELETE("/projects/:project/webhooks/:webhook", webhookProvider.DeleteWeb)
a.POST("/projects/:project/webhooks/:webhook", webhookProvider.UpdateWeb)
a.GET("/webhooks/events", apiv1.GetAvailableWebhookEvents)
}
}
func registerMigrations(m *echo.Group) {

View File

@ -17,11 +17,8 @@
package routes
import (
"net/http"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/web"
"github.com/asaskevich/govalidator"
)
@ -43,14 +40,7 @@ func (cv *CustomValidator) Validate(i interface{}) error {
errs = append(errs, field+": "+e)
}
return models.ValidationHTTPError{
HTTPError: web.HTTPError{
HTTPCode: http.StatusPreconditionFailed,
Code: models.ErrCodeInvalidData,
Message: "Invalid Data",
},
InvalidFields: errs,
}
return models.InvalidFieldError(errs)
}
return nil
}

View File

@ -1,5 +1,4 @@
// Code generated by swaggo/swag. DO NOT EDIT.
// Package swagger Code generated by swaggo/swag. DO NOT EDIT
package swagger
import "github.com/swaggo/swag"
@ -1290,6 +1289,32 @@ const docTemplate = `{
}
}
}
},
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"sharing"
],
"summary": "Mark all notifications of a user as read",
"responses": {
"200": {
"description": "All notifications marked as read.",
"schema": {
"$ref": "#/definitions/models.Message"
}
},
"500": {
"description": "Internal error",
"schema": {
"$ref": "#/definitions/models.Message"
}
}
}
}
},
"/notifications/{id}": {
@ -1881,7 +1906,7 @@ const docTemplate = `{
"application/json"
],
"tags": [
"task"
"project"
],
"summary": "Get all kanban buckets of a project",
"parameters": [
@ -1973,7 +1998,7 @@ const docTemplate = `{
"application/json"
],
"tags": [
"task"
"project"
],
"summary": "Create a new bucket",
"parameters": [
@ -2426,6 +2451,230 @@ const docTemplate = `{
}
}
},
"/projects/{id}/webhooks": {
"get": {
"security": [
{
"JWTKeyAuth": []
}
],
"description": "Get all api webhook targets for the specified project.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"webhooks"
],
"summary": "Get all api webhook targets for the specified project",
"parameters": [
{
"type": "integer",
"description": "The page number. Used for pagination. If not provided, the first page of results is returned.",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "The maximum number of items per bucket per page. This parameter is limited by the configured maximum of items per page.",
"name": "per_page",
"in": "query"
},
{
"type": "integer",
"description": "Project ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "The list of all webhook targets",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/models.Webhook"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"$ref": "#/definitions/models.Message"
}
}
}
},
"put": {
"security": [
{
"JWTKeyAuth": []
}
],
"description": "Create a webhook target which receives POST requests about specified events from a project.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"webhooks"
],
"summary": "Create a webhook target",
"parameters": [
{
"type": "integer",
"description": "Project ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "The webhook target object with required fields",
"name": "webhook",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.Webhook"
}
}
],
"responses": {
"200": {
"description": "The created webhook target.",
"schema": {
"$ref": "#/definitions/models.Webhook"
}
},
"400": {
"description": "Invalid webhook object provided.",
"schema": {
"$ref": "#/definitions/web.HTTPError"
}
},
"500": {
"description": "Internal error",
"schema": {
"$ref": "#/definitions/models.Message"
}
}
}
}
},
"/projects/{id}/webhooks/{webhookID}": {
"post": {
"security": [
{
"JWTKeyAuth": []
}
],
"description": "Change a webhook target's events. You cannot change other values of a webhook.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"webhooks"
],
"summary": "Change a webhook target's events.",
"parameters": [
{
"type": "integer",
"description": "Project ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "Webhook ID",
"name": "webhookID",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Updated webhook target",
"schema": {
"$ref": "#/definitions/models.Webhook"
}
},
"404": {
"description": "The webhok target does not exist",
"schema": {
"$ref": "#/definitions/web.HTTPError"
}
},
"500": {
"description": "Internal error",
"schema": {
"$ref": "#/definitions/models.Message"
}
}
}
},
"delete": {
"security": [
{
"JWTKeyAuth": []
}
],
"description": "Delete any of the project's webhook targets.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"webhooks"
],
"summary": "Deletes an existing webhook target",
"parameters": [
{
"type": "integer",
"description": "Project ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "Webhook ID",
"name": "webhookID",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Successfully deleted.",
"schema": {
"$ref": "#/definitions/models.Message"
}
},
"404": {
"description": "The webhok target does not exist.",
"schema": {
"$ref": "#/definitions/web.HTTPError"
}
},
"500": {
"description": "Internal error",
"schema": {
"$ref": "#/definitions/models.Message"
}
}
}
}
},
"/projects/{projectID}/buckets/{bucketID}": {
"post": {
"security": [
@ -2441,7 +2690,7 @@ const docTemplate = `{
"application/json"
],
"tags": [
"task"
"project"
],
"summary": "Update an existing bucket",
"parameters": [
@ -2510,7 +2759,7 @@ const docTemplate = `{
"application/json"
],
"tags": [
"task"
"project"
],
"summary": "Deletes an existing bucket",
"parameters": [
@ -5428,19 +5677,19 @@ const docTemplate = `{
"parameters": [
{
"type": "integer",
"description": "The page number for tasks. Used for pagination. If not provided, the first page of results is returned.",
"description": "The page number, used for pagination. If not provided, the first page of results is returned.",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "The maximum number of tasks per bucket per page. This parameter is limited by the configured maximum of items per page.",
"description": "The maximum number of tokens per page. This parameter is limited by the configured maximum of items per page.",
"name": "per_page",
"in": "query"
},
{
"type": "string",
"description": "Search tasks by task text.",
"description": "Search tokens by their title.",
"name": "s",
"in": "query"
}
@ -6784,6 +7033,43 @@ const docTemplate = `{
}
}
},
"/webhooks/events": {
"get": {
"security": [
{
"JWTKeyAuth": []
}
],
"description": "Get all possible webhook events to use when creating or updating a webhook target.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"webhooks"
],
"summary": "Get all possible webhook events",
"responses": {
"200": {
"description": "The list of all possible webhook events",
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"$ref": "#/definitions/models.Message"
}
}
}
}
},
"/{username}/avatar": {
"get": {
"description": "Returns the user avatar as image.",
@ -7100,7 +7386,7 @@ const docTemplate = `{
"hex_color": {
"description": "The task color in hex",
"type": "string",
"maxLength": 6
"maxLength": 7
},
"id": {
"description": "The unique, numeric id of this task.",
@ -7250,9 +7536,9 @@ const docTemplate = `{
"type": "string"
},
"hex_color": {
"description": "The color this label has",
"description": "The color this label has in hex format.",
"type": "string",
"maxLength": 6
"maxLength": 7
},
"id": {
"description": "The unique, numeric id of this label.",
@ -7390,7 +7676,7 @@ const docTemplate = `{
"hex_color": {
"description": "The hex color of this project",
"type": "string",
"maxLength": 6
"maxLength": 7
},
"id": {
"description": "The unique, numeric id of this project.",
@ -7718,7 +8004,7 @@ const docTemplate = `{
"hex_color": {
"description": "The task color in hex",
"type": "string",
"maxLength": 6
"maxLength": 7
},
"id": {
"description": "The unique, numeric id of this task.",
@ -8189,6 +8475,50 @@ const docTemplate = `{
}
}
},
"models.Webhook": {
"type": "object",
"properties": {
"created": {
"description": "A timestamp when this webhook target was created. You cannot change this value.",
"type": "string"
},
"created_by": {
"description": "The user who initially created the webhook target.",
"allOf": [
{
"$ref": "#/definitions/user.User"
}
]
},
"events": {
"description": "The webhook events which should fire this webhook target",
"type": "array",
"items": {
"type": "string"
}
},
"id": {
"description": "The generated ID of this webhook target",
"type": "integer"
},
"project_id": {
"description": "The project ID of the project this webhook target belongs to",
"type": "integer"
},
"secret": {
"description": "If provided, webhook requests will be signed using HMAC. Check out the docs about how to use this: https://vikunja.io/docs/webhooks/#signing",
"type": "string"
},
"target_url": {
"description": "The target URL where the POST request with the webhook payload will be made",
"type": "string"
},
"updated": {
"description": "A timestamp when this webhook target was last updated. You cannot change this value.",
"type": "string"
}
}
},
"notifications.DatabaseNotification": {
"type": "object",
"properties": {
@ -8619,6 +8949,9 @@ const docTemplate = `{
},
"version": {
"type": "string"
},
"webhooks_enabled": {
"type": "boolean"
}
}
},
@ -8656,6 +8989,8 @@ var SwaggerInfo = &swag.Spec{
Description: "# Pagination\nEvery endpoint capable of pagination will return two headers:\n* `x-pagination-total-pages`: The total number of available pages for this request\n* `x-pagination-result-count`: The number of items returned for this request.\n# Rights\nAll endpoints which return a single item (project, task, etc.) - no array - will also return a `x-max-right` header with the max right the user has on this item as an int where `0` is `Read Only`, `1` is `Read & Write` and `2` is `Admin`.\nThis can be used to show or hide ui elements based on the rights the user has.\n# Errors\nAll errors have an error code and a human-readable error message in addition to the http status code. You should always check for the status code in the response, not only the http status code.\nDue to limitations in the swagger library we're using for this document, only one error per http status code is documented here. Make sure to check the [error docs](https://vikunja.io/docs/errors/) in Vikunja's documentation for a full list of available error codes.\n# Authorization\n**JWT-Auth:** Main authorization method, used for most of the requests. Needs `Authorization: Bearer <jwt-token>`-header to authenticate successfully.\n\n**API Token:** You can create scoped API tokens for your user and use the token to make authenticated requests in the context of that user. The token must be provided via an `Authorization: Bearer <token>` header, similar to jwt auth. See the documentation for the `api` group to manage token creation and revocation.\n\n**BasicAuth:** Only used when requesting tasks via CalDAV.\n<!-- ReDoc-Inject: <security-definitions> -->",
InfoInstanceName: "swagger",
SwaggerTemplate: docTemplate,
LeftDelim: "{{",
RightDelim: "}}",
}
func init() {

View File

@ -1281,6 +1281,32 @@
}
}
}
},
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"sharing"
],
"summary": "Mark all notifications of a user as read",
"responses": {
"200": {
"description": "All notifications marked as read.",
"schema": {
"$ref": "#/definitions/models.Message"
}
},
"500": {
"description": "Internal error",
"schema": {
"$ref": "#/definitions/models.Message"
}
}
}
}
},
"/notifications/{id}": {
@ -1872,7 +1898,7 @@
"application/json"
],
"tags": [
"task"
"project"
],
"summary": "Get all kanban buckets of a project",
"parameters": [
@ -1964,7 +1990,7 @@
"application/json"
],
"tags": [
"task"
"project"
],
"summary": "Create a new bucket",
"parameters": [
@ -2417,6 +2443,230 @@
}
}
},
"/projects/{id}/webhooks": {
"get": {
"security": [
{
"JWTKeyAuth": []
}
],
"description": "Get all api webhook targets for the specified project.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"webhooks"
],
"summary": "Get all api webhook targets for the specified project",
"parameters": [
{
"type": "integer",
"description": "The page number. Used for pagination. If not provided, the first page of results is returned.",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "The maximum number of items per bucket per page. This parameter is limited by the configured maximum of items per page.",
"name": "per_page",
"in": "query"
},
{
"type": "integer",
"description": "Project ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "The list of all webhook targets",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/models.Webhook"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"$ref": "#/definitions/models.Message"
}
}
}
},
"put": {
"security": [
{
"JWTKeyAuth": []
}
],
"description": "Create a webhook target which receives POST requests about specified events from a project.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"webhooks"
],
"summary": "Create a webhook target",
"parameters": [
{
"type": "integer",
"description": "Project ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "The webhook target object with required fields",
"name": "webhook",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.Webhook"
}
}
],
"responses": {
"200": {
"description": "The created webhook target.",
"schema": {
"$ref": "#/definitions/models.Webhook"
}
},
"400": {
"description": "Invalid webhook object provided.",
"schema": {
"$ref": "#/definitions/web.HTTPError"
}
},
"500": {
"description": "Internal error",
"schema": {
"$ref": "#/definitions/models.Message"
}
}
}
}
},
"/projects/{id}/webhooks/{webhookID}": {
"post": {
"security": [
{
"JWTKeyAuth": []
}
],
"description": "Change a webhook target's events. You cannot change other values of a webhook.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"webhooks"
],
"summary": "Change a webhook target's events.",
"parameters": [
{
"type": "integer",
"description": "Project ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "Webhook ID",
"name": "webhookID",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Updated webhook target",
"schema": {
"$ref": "#/definitions/models.Webhook"
}
},
"404": {
"description": "The webhok target does not exist",
"schema": {
"$ref": "#/definitions/web.HTTPError"
}
},
"500": {
"description": "Internal error",
"schema": {
"$ref": "#/definitions/models.Message"
}
}
}
},
"delete": {
"security": [
{
"JWTKeyAuth": []
}
],
"description": "Delete any of the project's webhook targets.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"webhooks"
],
"summary": "Deletes an existing webhook target",
"parameters": [
{
"type": "integer",
"description": "Project ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "Webhook ID",
"name": "webhookID",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Successfully deleted.",
"schema": {
"$ref": "#/definitions/models.Message"
}
},
"404": {
"description": "The webhok target does not exist.",
"schema": {
"$ref": "#/definitions/web.HTTPError"
}
},
"500": {
"description": "Internal error",
"schema": {
"$ref": "#/definitions/models.Message"
}
}
}
}
},
"/projects/{projectID}/buckets/{bucketID}": {
"post": {
"security": [
@ -2432,7 +2682,7 @@
"application/json"
],
"tags": [
"task"
"project"
],
"summary": "Update an existing bucket",
"parameters": [
@ -2501,7 +2751,7 @@
"application/json"
],
"tags": [
"task"
"project"
],
"summary": "Deletes an existing bucket",
"parameters": [
@ -5419,19 +5669,19 @@
"parameters": [
{
"type": "integer",
"description": "The page number for tasks. Used for pagination. If not provided, the first page of results is returned.",
"description": "The page number, used for pagination. If not provided, the first page of results is returned.",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "The maximum number of tasks per bucket per page. This parameter is limited by the configured maximum of items per page.",
"description": "The maximum number of tokens per page. This parameter is limited by the configured maximum of items per page.",
"name": "per_page",
"in": "query"
},
{
"type": "string",
"description": "Search tasks by task text.",
"description": "Search tokens by their title.",
"name": "s",
"in": "query"
}
@ -6775,6 +7025,43 @@
}
}
},
"/webhooks/events": {
"get": {
"security": [
{
"JWTKeyAuth": []
}
],
"description": "Get all possible webhook events to use when creating or updating a webhook target.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"webhooks"
],
"summary": "Get all possible webhook events",
"responses": {
"200": {
"description": "The list of all possible webhook events",
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"$ref": "#/definitions/models.Message"
}
}
}
}
},
"/{username}/avatar": {
"get": {
"description": "Returns the user avatar as image.",
@ -7091,7 +7378,7 @@
"hex_color": {
"description": "The task color in hex",
"type": "string",
"maxLength": 6
"maxLength": 7
},
"id": {
"description": "The unique, numeric id of this task.",
@ -7241,9 +7528,9 @@
"type": "string"
},
"hex_color": {
"description": "The color this label has",
"description": "The color this label has in hex format.",
"type": "string",
"maxLength": 6
"maxLength": 7
},
"id": {
"description": "The unique, numeric id of this label.",
@ -7381,7 +7668,7 @@
"hex_color": {
"description": "The hex color of this project",
"type": "string",
"maxLength": 6
"maxLength": 7
},
"id": {
"description": "The unique, numeric id of this project.",
@ -7709,7 +7996,7 @@
"hex_color": {
"description": "The task color in hex",
"type": "string",
"maxLength": 6
"maxLength": 7
},
"id": {
"description": "The unique, numeric id of this task.",
@ -8180,6 +8467,50 @@
}
}
},
"models.Webhook": {
"type": "object",
"properties": {
"created": {
"description": "A timestamp when this webhook target was created. You cannot change this value.",
"type": "string"
},
"created_by": {
"description": "The user who initially created the webhook target.",
"allOf": [
{
"$ref": "#/definitions/user.User"
}
]
},
"events": {
"description": "The webhook events which should fire this webhook target",
"type": "array",
"items": {
"type": "string"
}
},
"id": {
"description": "The generated ID of this webhook target",
"type": "integer"
},
"project_id": {
"description": "The project ID of the project this webhook target belongs to",
"type": "integer"
},
"secret": {
"description": "If provided, webhook requests will be signed using HMAC. Check out the docs about how to use this: https://vikunja.io/docs/webhooks/#signing",
"type": "string"
},
"target_url": {
"description": "The target URL where the POST request with the webhook payload will be made",
"type": "string"
},
"updated": {
"description": "A timestamp when this webhook target was last updated. You cannot change this value.",
"type": "string"
}
}
},
"notifications.DatabaseNotification": {
"type": "object",
"properties": {
@ -8610,6 +8941,9 @@
},
"version": {
"type": "string"
},
"webhooks_enabled": {
"type": "boolean"
}
}
},

View File

@ -190,7 +190,7 @@ definitions:
type: string
hex_color:
description: The task color in hex
maxLength: 6
maxLength: 7
type: string
id:
description: The unique, numeric id of this task.
@ -317,8 +317,8 @@ definitions:
description: The label description.
type: string
hex_color:
description: The color this label has
maxLength: 6
description: The color this label has in hex format.
maxLength: 7
type: string
id:
description: The unique, numeric id of this label.
@ -429,7 +429,7 @@ definitions:
type: integer
hex_color:
description: The hex color of this project
maxLength: 6
maxLength: 7
type: string
id:
description: The unique, numeric id of this project.
@ -675,7 +675,7 @@ definitions:
type: string
hex_color:
description: The task color in hex
maxLength: 6
maxLength: 7
type: string
id:
description: The unique, numeric id of this task.
@ -1040,6 +1040,40 @@ definitions:
minLength: 1
type: string
type: object
models.Webhook:
properties:
created:
description: A timestamp when this webhook target was created. You cannot
change this value.
type: string
created_by:
allOf:
- $ref: '#/definitions/user.User'
description: The user who initially created the webhook target.
events:
description: The webhook events which should fire this webhook target
items:
type: string
type: array
id:
description: The generated ID of this webhook target
type: integer
project_id:
description: The project ID of the project this webhook target belongs to
type: integer
secret:
description: 'If provided, webhook requests will be signed using HMAC. Check
out the docs about how to use this: https://vikunja.io/docs/webhooks/#signing'
type: string
target_url:
description: The target URL where the POST request with the webhook payload
will be made
type: string
updated:
description: A timestamp when this webhook target was last updated. You cannot
change this value.
type: string
type: object
notifications.DatabaseNotification:
properties:
created:
@ -1352,6 +1386,8 @@ definitions:
type: boolean
version:
type: string
webhooks_enabled:
type: boolean
type: object
web.HTTPError:
properties:
@ -2252,6 +2288,23 @@ paths:
summary: Get all notifications for the current user
tags:
- subscriptions
post:
consumes:
- application/json
produces:
- application/json
responses:
"200":
description: All notifications marked as read.
schema:
$ref: '#/definitions/models.Message'
"500":
description: Internal error
schema:
$ref: '#/definitions/models.Message'
summary: Mark all notifications of a user as read
tags:
- sharing
/notifications/{id}:
post:
consumes:
@ -2698,7 +2751,7 @@ paths:
- JWTKeyAuth: []
summary: Get all kanban buckets of a project
tags:
- task
- project
put:
consumes:
- application/json
@ -2738,7 +2791,7 @@ paths:
- JWTKeyAuth: []
summary: Create a new bucket
tags:
- task
- project
/projects/{id}/projectusers:
get:
consumes:
@ -3004,6 +3057,154 @@ paths:
summary: Add a user to a project
tags:
- sharing
/projects/{id}/webhooks:
get:
consumes:
- application/json
description: Get all api webhook targets for the specified project.
parameters:
- description: The page number. Used for pagination. If not provided, the first
page of results is returned.
in: query
name: page
type: integer
- description: The maximum number of items per bucket per page. This parameter
is limited by the configured maximum of items per page.
in: query
name: per_page
type: integer
- description: Project ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: The list of all webhook targets
schema:
items:
$ref: '#/definitions/models.Webhook'
type: array
"500":
description: Internal server error
schema:
$ref: '#/definitions/models.Message'
security:
- JWTKeyAuth: []
summary: Get all api webhook targets for the specified project
tags:
- webhooks
put:
consumes:
- application/json
description: Create a webhook target which receives POST requests about specified
events from a project.
parameters:
- description: Project ID
in: path
name: id
required: true
type: integer
- description: The webhook target object with required fields
in: body
name: webhook
required: true
schema:
$ref: '#/definitions/models.Webhook'
produces:
- application/json
responses:
"200":
description: The created webhook target.
schema:
$ref: '#/definitions/models.Webhook'
"400":
description: Invalid webhook object provided.
schema:
$ref: '#/definitions/web.HTTPError'
"500":
description: Internal error
schema:
$ref: '#/definitions/models.Message'
security:
- JWTKeyAuth: []
summary: Create a webhook target
tags:
- webhooks
/projects/{id}/webhooks/{webhookID}:
delete:
consumes:
- application/json
description: Delete any of the project's webhook targets.
parameters:
- description: Project ID
in: path
name: id
required: true
type: integer
- description: Webhook ID
in: path
name: webhookID
required: true
type: integer
produces:
- application/json
responses:
"200":
description: Successfully deleted.
schema:
$ref: '#/definitions/models.Message'
"404":
description: The webhok target does not exist.
schema:
$ref: '#/definitions/web.HTTPError'
"500":
description: Internal error
schema:
$ref: '#/definitions/models.Message'
security:
- JWTKeyAuth: []
summary: Deletes an existing webhook target
tags:
- webhooks
post:
consumes:
- application/json
description: Change a webhook target's events. You cannot change other values
of a webhook.
parameters:
- description: Project ID
in: path
name: id
required: true
type: integer
- description: Webhook ID
in: path
name: webhookID
required: true
type: integer
produces:
- application/json
responses:
"200":
description: Updated webhook target
schema:
$ref: '#/definitions/models.Webhook'
"404":
description: The webhok target does not exist
schema:
$ref: '#/definitions/web.HTTPError'
"500":
description: Internal error
schema:
$ref: '#/definitions/models.Message'
security:
- JWTKeyAuth: []
summary: Change a webhook target's events.
tags:
- webhooks
/projects/{project}/shares:
get:
consumes:
@ -3208,7 +3409,7 @@ paths:
- JWTKeyAuth: []
summary: Deletes an existing bucket
tags:
- task
- project
post:
consumes:
- application/json
@ -3253,7 +3454,7 @@ paths:
- JWTKeyAuth: []
summary: Update an existing bucket
tags:
- task
- project
/projects/{projectID}/duplicate:
put:
consumes:
@ -5018,17 +5219,17 @@ paths:
- application/json
description: Returns all api tokens the current user has created.
parameters:
- description: The page number for tasks. Used for pagination. If not provided,
the first page of results is returned.
- description: The page number, used for pagination. If not provided, the first
page of results is returned.
in: query
name: page
type: integer
- description: The maximum number of tasks per bucket per page. This parameter
is limited by the configured maximum of items per page.
- description: The maximum number of tokens per page. This parameter is limited
by the configured maximum of items per page.
in: query
name: per_page
type: integer
- description: Search tasks by task text.
- description: Search tokens by their title.
in: query
name: s
type: string
@ -5901,6 +6102,30 @@ paths:
summary: Get users
tags:
- user
/webhooks/events:
get:
consumes:
- application/json
description: Get all possible webhook events to use when creating or updating
a webhook target.
produces:
- application/json
responses:
"200":
description: The list of all possible webhook events
schema:
items:
type: string
type: array
"500":
description: Internal server error
schema:
$ref: '#/definitions/models.Message'
security:
- JWTKeyAuth: []
summary: Get all possible webhook events
tags:
- webhooks
securityDefinitions:
BasicAuth:
type: basic

View File

@ -154,7 +154,7 @@ func (u *User) GetID() int64 {
}
// TableName returns the table name for users
func (User) TableName() string {
func (*User) TableName() string {
return "users"
}
@ -353,6 +353,10 @@ func CheckUserCredentials(s *xorm.Session, u *Login) (*User, error) {
return user, nil
}
func (u *User) IsLocalUser() bool {
return u.Issuer == IssuerLocal
}
func handleFailedPassword(user *User) {
key := user.GetFailedPasswordAttemptsKey()
err := keyvalue.IncrBy(key, 1)

View File

@ -0,0 +1,27 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present 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 Affero General Public Licensee 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 Affero General Public Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package utils
import "strings"
func NormalizeHex(hex string) string {
if strings.HasPrefix(hex, "#") {
return strings.TrimPrefix(hex, "#")
}
return hex
}

5
rest/bruno.json Normal file
View File

@ -0,0 +1,5 @@
{
"version": "1",
"name": "API-Requests",
"type": "collection"
}

View File

@ -0,0 +1,3 @@
vars {
host: http://localhost:3456
}

26
rest/login.bru Normal file
View File

@ -0,0 +1,26 @@
meta {
name: login
type: http
seq: 1
}
post {
url: {{host}}/api/v1/login
body: json
}
body:json {
{
"username": "{{username}}",
"password": "{{password}}"
}
}
vars:pre-request {
username: test
password: 12345678
}
vars:post-response {
token: res.body.token
}

View File

@ -0,0 +1,14 @@
meta {
name: mark all notifications as read
type: http
seq: 3
}
post {
url: {{host}}/api/v1/notifications
body: none
}
headers {
Authorization: Bearer {{token}}
}

14
rest/user info.bru Normal file
View File

@ -0,0 +1,14 @@
meta {
name: user info
type: http
seq: 2
}
get {
url: {{host}}/api/v1/user
body: none
}
headers {
Authorization: Bearer {{token}}
}