Compare commits

...

8 Commits

Author SHA1 Message Date
k2s d7827d8c49 Merge branch 'main' into k2s-addListDetails-too-many 2022-06-19 19:04:19 +00:00
kolaente 01271c4c01
feat: allow only the authors of task comments to edit them 2022-06-16 17:38:27 +02:00
kolaente d837f8a624
fix: add missing migration 2022-06-16 16:56:35 +02:00
kolaente 8869adfc27
feat: add setting to change overdue tasks reminder email time 2022-06-16 16:20:26 +02:00
renovate 030bbfa47e fix(deps): update module github.com/swaggo/swag to v1.8.3 (#1185)
Reviewed-on: vikunja/api#1185
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2022-06-16 12:47:17 +00:00
kolaente 7eb3b96a44
feat: send overdue tasks email notification at 9:00 in the user's time zone 2022-06-12 21:24:28 +02:00
kolaente 2f25b48869
feat: restrict max avatar size
resolves #1171
2022-06-12 18:29:12 +02:00
k2s 172a6214d7 fix: VIKUNJA_SERVICE_JWT_SECRET should be VIKUNJA_SERVICE_JWTSECRET (#1184)
Reviewed-on: vikunja/api#1184
Reviewed-by: konrad <k@knt.li>
Co-authored-by: k2s <k2s@noreply.kolaente.de>
Co-committed-by: k2s <k2s@noreply.kolaente.de>
2022-06-12 12:50:43 +00:00
30 changed files with 393 additions and 134 deletions

View File

@ -56,6 +56,9 @@ service:
# it may be required to coordinate with them in order to delete the account. This setting will not affect the cli commands # it may be required to coordinate with them in order to delete the account. This setting will not affect the cli commands
# for user deletion. # for user deletion.
enableuserdeletion: true enableuserdeletion: true
# The maximum size clients will be able to request for user avatars.
# If clients request a size bigger than this, it will be changed on the fly.
maxavatarsize: 1024
database: database:
# Database type to use. Supported types are mysql, postgres and sqlite. # Database type to use. Supported types are mysql, postgres and sqlite.

View File

@ -76,7 +76,7 @@ Default: `<jwt-secret>`
Full path: `service.JWTSecret` Full path: `service.JWTSecret`
Environment path: `VIKUNJA_SERVICE_JWT_SECRET` Environment path: `VIKUNJA_SERVICE_JWTSECRET`
### jwtttl ### jwtttl
@ -321,6 +321,18 @@ Full path: `service.enableuserdeletion`
Environment path: `VIKUNJA_SERVICE_ENABLEUSERDELETION` Environment path: `VIKUNJA_SERVICE_ENABLEUSERDELETION`
### maxavatarsize
The maximum size clients will be able to request for user avatars.
If clients request a size bigger than this, it will be changed on the fly.
Default: `1024`
Full path: `service.maxavatarsize`
Environment path: `VIKUNJA_SERVICE_MAXAVATARSIZE`
--- ---
## database ## database

2
go.mod
View File

@ -57,7 +57,7 @@ require (
github.com/spf13/cobra v1.4.0 github.com/spf13/cobra v1.4.0
github.com/spf13/viper v1.11.0 github.com/spf13/viper v1.11.0
github.com/stretchr/testify v1.7.2 github.com/stretchr/testify v1.7.2
github.com/swaggo/swag v1.8.2 github.com/swaggo/swag v1.8.3
github.com/tkuchiki/go-timezone v0.2.2 github.com/tkuchiki/go-timezone v0.2.2
github.com/ulule/limiter/v3 v3.10.0 github.com/ulule/limiter/v3 v3.10.0
github.com/vectordotdev/go-datemath v0.1.1-0.20211214182920-0a4ac8742b93 github.com/vectordotdev/go-datemath v0.1.1-0.20211214182920-0a4ac8742b93

2
go.sum
View File

@ -756,6 +756,8 @@ github.com/swaggo/swag v1.8.1 h1:JuARzFX1Z1njbCGz+ZytBR15TFJwF2Q7fu8puJHhQYI=
github.com/swaggo/swag v1.8.1/go.mod h1:ugemnJsPZm/kRwFUnzBlbHRd0JY9zE1M4F+uy2pAaPQ= github.com/swaggo/swag v1.8.1/go.mod h1:ugemnJsPZm/kRwFUnzBlbHRd0JY9zE1M4F+uy2pAaPQ=
github.com/swaggo/swag v1.8.2 h1:D4aBiVS2a65zhyk3WFqOUz7Rz0sOaUcgeErcid5uGL4= github.com/swaggo/swag v1.8.2 h1:D4aBiVS2a65zhyk3WFqOUz7Rz0sOaUcgeErcid5uGL4=
github.com/swaggo/swag v1.8.2/go.mod h1:jMLeXOOmYyjk8PvHTsXBdrubsNd9gUJTTCzL5iBnseg= github.com/swaggo/swag v1.8.2/go.mod h1:jMLeXOOmYyjk8PvHTsXBdrubsNd9gUJTTCzL5iBnseg=
github.com/swaggo/swag v1.8.3 h1:3pZSSCQ//gAH88lfmxM3Cd1+JCsxV8Md6f36b9hrZ5s=
github.com/swaggo/swag v1.8.3/go.mod h1:jMLeXOOmYyjk8PvHTsXBdrubsNd9gUJTTCzL5iBnseg=
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE= github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ= github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
github.com/tkuchiki/go-timezone v0.2.2 h1:MdHR65KwgVTwWFQrota4SKzc4L5EfuH5SdZZGtk/P2Q= github.com/tkuchiki/go-timezone v0.2.2 h1:MdHR65KwgVTwWFQrota4SKzc4L5EfuH5SdZZGtk/P2Q=

View File

@ -1051,7 +1051,7 @@ func printConfig(config []*configOption, level int, parent string) (rendered str
fullPath := parent + "." + option.key fullPath := parent + "." + option.key
rendered += "Full path: `" + fullPath + "`\n\n" rendered += "Full path: `" + fullPath + "`\n\n"
rendered += "Environment path: `VIKUNJA_" + strcase.ToScreamingSnake(fullPath) + "`\n\n" rendered += "Environment path: `VIKUNJA_" + strcase.ToScreamingSnake(strings.ToUpper(fullPath)) + "`\n\n"
} }
} }

View File

@ -62,6 +62,7 @@ const (
ServiceTestingtoken Key = `service.testingtoken` ServiceTestingtoken Key = `service.testingtoken`
ServiceEnableEmailReminders Key = `service.enableemailreminders` ServiceEnableEmailReminders Key = `service.enableemailreminders`
ServiceEnableUserDeletion Key = `service.enableuserdeletion` ServiceEnableUserDeletion Key = `service.enableuserdeletion`
ServiceMaxAvatarSize Key = `service.maxavatarsize`
AuthLocalEnabled Key = `auth.local.enabled` AuthLocalEnabled Key = `auth.local.enabled`
AuthOpenIDEnabled Key = `auth.openid.enabled` AuthOpenIDEnabled Key = `auth.openid.enabled`
@ -287,6 +288,7 @@ func InitDefaultConfig() {
ServiceEnableTotp.setDefault(true) ServiceEnableTotp.setDefault(true)
ServiceEnableEmailReminders.setDefault(true) ServiceEnableEmailReminders.setDefault(true)
ServiceEnableUserDeletion.setDefault(true) ServiceEnableUserDeletion.setDefault(true)
ServiceMaxAvatarSize.setDefault(1024)
// Auth // Auth
AuthLocalEnabled.setDefault(true) AuthLocalEnabled.setDefault(true)

View File

@ -63,6 +63,7 @@ func TestTaskComments(t *testing.T) {
assertHandlerErrorCode(t, err, models.ErrCodeTaskDoesNotExist) assertHandlerErrorCode(t, err, models.ErrCodeTaskDoesNotExist)
}) })
t.Run("Rights check", func(t *testing.T) { t.Run("Rights check", func(t *testing.T) {
// Only the own comments can be updated
t.Run("Forbidden", func(t *testing.T) { t.Run("Forbidden", func(t *testing.T) {
_, err := testHandler.testUpdateWithUser(nil, map[string]string{"task": "14", "commentid": "2"}, `{"comment":"Lorem Ipsum"}`) _, err := testHandler.testUpdateWithUser(nil, map[string]string{"task": "14", "commentid": "2"}, `{"comment":"Lorem Ipsum"}`)
assert.Error(t, err) assert.Error(t, err)
@ -74,14 +75,14 @@ func TestTaskComments(t *testing.T) {
assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`) assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
}) })
t.Run("Shared Via Team write", func(t *testing.T) { t.Run("Shared Via Team write", func(t *testing.T) {
rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"task": "16", "commentid": "4"}, `{"comment":"Lorem Ipsum"}`) _, err := testHandler.testUpdateWithUser(nil, map[string]string{"task": "16", "commentid": "4"}, `{"comment":"Lorem Ipsum"}`)
assert.NoError(t, err) assert.Error(t, err)
assert.Contains(t, rec.Body.String(), `"comment":"Lorem Ipsum"`) assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
}) })
t.Run("Shared Via Team admin", func(t *testing.T) { t.Run("Shared Via Team admin", func(t *testing.T) {
rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"task": "17", "commentid": "5"}, `{"comment":"Lorem Ipsum"}`) _, err := testHandler.testUpdateWithUser(nil, map[string]string{"task": "17", "commentid": "5"}, `{"comment":"Lorem Ipsum"}`)
assert.NoError(t, err) assert.Error(t, err)
assert.Contains(t, rec.Body.String(), `"comment":"Lorem Ipsum"`) assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
}) })
t.Run("Shared Via User readonly", func(t *testing.T) { t.Run("Shared Via User readonly", func(t *testing.T) {
@ -90,14 +91,14 @@ func TestTaskComments(t *testing.T) {
assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`) assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
}) })
t.Run("Shared Via User write", func(t *testing.T) { t.Run("Shared Via User write", func(t *testing.T) {
rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"task": "19", "commentid": "7"}, `{"comment":"Lorem Ipsum"}`) _, err := testHandler.testUpdateWithUser(nil, map[string]string{"task": "19", "commentid": "7"}, `{"comment":"Lorem Ipsum"}`)
assert.NoError(t, err) assert.Error(t, err)
assert.Contains(t, rec.Body.String(), `"comment":"Lorem Ipsum"`) assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
}) })
t.Run("Shared Via User admin", func(t *testing.T) { t.Run("Shared Via User admin", func(t *testing.T) {
rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"task": "20", "commentid": "8"}, `{"comment":"Lorem Ipsum"}`) _, err := testHandler.testUpdateWithUser(nil, map[string]string{"task": "20", "commentid": "8"}, `{"comment":"Lorem Ipsum"}`)
assert.NoError(t, err) assert.Error(t, err)
assert.Contains(t, rec.Body.String(), `"comment":"Lorem Ipsum"`) assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
}) })
t.Run("Shared Via NamespaceTeam readonly", func(t *testing.T) { t.Run("Shared Via NamespaceTeam readonly", func(t *testing.T) {
@ -106,14 +107,14 @@ func TestTaskComments(t *testing.T) {
assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`) assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
}) })
t.Run("Shared Via NamespaceTeam write", func(t *testing.T) { t.Run("Shared Via NamespaceTeam write", func(t *testing.T) {
rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"task": "22", "commentid": "10"}, `{"comment":"Lorem Ipsum"}`) _, err := testHandler.testUpdateWithUser(nil, map[string]string{"task": "22", "commentid": "10"}, `{"comment":"Lorem Ipsum"}`)
assert.NoError(t, err) assert.Error(t, err)
assert.Contains(t, rec.Body.String(), `"comment":"Lorem Ipsum"`) assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
}) })
t.Run("Shared Via NamespaceTeam admin", func(t *testing.T) { t.Run("Shared Via NamespaceTeam admin", func(t *testing.T) {
rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"task": "23", "commentid": "11"}, `{"comment":"Lorem Ipsum"}`) _, err := testHandler.testUpdateWithUser(nil, map[string]string{"task": "23", "commentid": "11"}, `{"comment":"Lorem Ipsum"}`)
assert.NoError(t, err) assert.Error(t, err)
assert.Contains(t, rec.Body.String(), `"comment":"Lorem Ipsum"`) assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
}) })
t.Run("Shared Via NamespaceUser readonly", func(t *testing.T) { t.Run("Shared Via NamespaceUser readonly", func(t *testing.T) {
@ -122,14 +123,14 @@ func TestTaskComments(t *testing.T) {
assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`) assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
}) })
t.Run("Shared Via NamespaceUser write", func(t *testing.T) { t.Run("Shared Via NamespaceUser write", func(t *testing.T) {
rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"task": "25", "commentid": "13"}, `{"comment":"Lorem Ipsum"}`) _, err := testHandler.testUpdateWithUser(nil, map[string]string{"task": "25", "commentid": "13"}, `{"comment":"Lorem Ipsum"}`)
assert.NoError(t, err) assert.Error(t, err)
assert.Contains(t, rec.Body.String(), `"comment":"Lorem Ipsum"`) assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
}) })
t.Run("Shared Via NamespaceUser admin", func(t *testing.T) { t.Run("Shared Via NamespaceUser admin", func(t *testing.T) {
rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"task": "26", "commentid": "14"}, `{"comment":"Lorem Ipsum"}`) _, err := testHandler.testUpdateWithUser(nil, map[string]string{"task": "26", "commentid": "14"}, `{"comment":"Lorem Ipsum"}`)
assert.NoError(t, err) assert.Error(t, err)
assert.Contains(t, rec.Body.String(), `"comment":"Lorem Ipsum"`) assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
}) })
}) })
}) })
@ -145,6 +146,7 @@ func TestTaskComments(t *testing.T) {
assertHandlerErrorCode(t, err, models.ErrCodeTaskDoesNotExist) assertHandlerErrorCode(t, err, models.ErrCodeTaskDoesNotExist)
}) })
t.Run("Rights check", func(t *testing.T) { t.Run("Rights check", func(t *testing.T) {
// Only the own comments can be deleted
t.Run("Forbidden", func(t *testing.T) { t.Run("Forbidden", func(t *testing.T) {
_, err := testHandler.testDeleteWithUser(nil, map[string]string{"task": "14", "commentid": "2"}) _, err := testHandler.testDeleteWithUser(nil, map[string]string{"task": "14", "commentid": "2"})
assert.Error(t, err) assert.Error(t, err)
@ -156,14 +158,14 @@ func TestTaskComments(t *testing.T) {
assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`) assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
}) })
t.Run("Shared Via Team write", func(t *testing.T) { t.Run("Shared Via Team write", func(t *testing.T) {
rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"task": "16", "commentid": "4"}) _, err := testHandler.testDeleteWithUser(nil, map[string]string{"task": "16", "commentid": "4"})
assert.NoError(t, err) assert.Error(t, err)
assert.Contains(t, rec.Body.String(), `Successfully deleted.`) assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
}) })
t.Run("Shared Via Team admin", func(t *testing.T) { t.Run("Shared Via Team admin", func(t *testing.T) {
rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"task": "17", "commentid": "5"}) _, err := testHandler.testDeleteWithUser(nil, map[string]string{"task": "17", "commentid": "5"})
assert.NoError(t, err) assert.Error(t, err)
assert.Contains(t, rec.Body.String(), `Successfully deleted.`) assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
}) })
t.Run("Shared Via User readonly", func(t *testing.T) { t.Run("Shared Via User readonly", func(t *testing.T) {
@ -172,14 +174,14 @@ func TestTaskComments(t *testing.T) {
assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`) assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
}) })
t.Run("Shared Via User write", func(t *testing.T) { t.Run("Shared Via User write", func(t *testing.T) {
rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"task": "19", "commentid": "7"}) _, err := testHandler.testDeleteWithUser(nil, map[string]string{"task": "19", "commentid": "7"})
assert.NoError(t, err) assert.Error(t, err)
assert.Contains(t, rec.Body.String(), `Successfully deleted.`) assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
}) })
t.Run("Shared Via User admin", func(t *testing.T) { t.Run("Shared Via User admin", func(t *testing.T) {
rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"task": "20", "commentid": "8"}) _, err := testHandler.testDeleteWithUser(nil, map[string]string{"task": "20", "commentid": "8"})
assert.NoError(t, err) assert.Error(t, err)
assert.Contains(t, rec.Body.String(), `Successfully deleted.`) assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
}) })
t.Run("Shared Via NamespaceTeam readonly", func(t *testing.T) { t.Run("Shared Via NamespaceTeam readonly", func(t *testing.T) {
@ -188,14 +190,14 @@ func TestTaskComments(t *testing.T) {
assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`) assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
}) })
t.Run("Shared Via NamespaceTeam write", func(t *testing.T) { t.Run("Shared Via NamespaceTeam write", func(t *testing.T) {
rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"task": "22", "commentid": "10"}) _, err := testHandler.testDeleteWithUser(nil, map[string]string{"task": "22", "commentid": "10"})
assert.NoError(t, err) assert.Error(t, err)
assert.Contains(t, rec.Body.String(), `Successfully deleted.`) assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
}) })
t.Run("Shared Via NamespaceTeam admin", func(t *testing.T) { t.Run("Shared Via NamespaceTeam admin", func(t *testing.T) {
rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"task": "23", "commentid": "11"}) _, err := testHandler.testDeleteWithUser(nil, map[string]string{"task": "23", "commentid": "11"})
assert.NoError(t, err) assert.Error(t, err)
assert.Contains(t, rec.Body.String(), `Successfully deleted.`) assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
}) })
t.Run("Shared Via NamespaceUser readonly", func(t *testing.T) { t.Run("Shared Via NamespaceUser readonly", func(t *testing.T) {
@ -204,14 +206,14 @@ func TestTaskComments(t *testing.T) {
assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`) assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
}) })
t.Run("Shared Via NamespaceUser write", func(t *testing.T) { t.Run("Shared Via NamespaceUser write", func(t *testing.T) {
rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"task": "25", "commentid": "13"}) _, err := testHandler.testDeleteWithUser(nil, map[string]string{"task": "25", "commentid": "13"})
assert.NoError(t, err) assert.Error(t, err)
assert.Contains(t, rec.Body.String(), `Successfully deleted.`) assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
}) })
t.Run("Shared Via NamespaceUser admin", func(t *testing.T) { t.Run("Shared Via NamespaceUser admin", func(t *testing.T) {
rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"task": "26", "commentid": "14"}) _, err := testHandler.testDeleteWithUser(nil, map[string]string{"task": "26", "commentid": "14"})
assert.NoError(t, err) assert.Error(t, err)
assert.Contains(t, rec.Body.String(), `Successfully deleted.`) assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
}) })
}) })
}) })

View File

@ -0,0 +1,43 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2021 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 (
"src.techknowlogick.com/xormigrate"
"xorm.io/xorm"
)
type users20220616145228 struct {
OverdueTasksRemindersTime string `xorm:"varchar(5) not null default '09:00'" json:"-"`
}
func (users20220616145228) TableName() string {
return "users"
}
func init() {
migrations = append(migrations, &xormigrate.Migration{
ID: "20220616145228",
Description: "Add overdue task summary time field to users",
Migrate: func(tx *xorm.Engine) error {
return tx.Sync2(users20220616145228{})
},
Rollback: func(tx *xorm.Engine) error {
return nil
},
})
}

View File

@ -43,6 +43,7 @@ func TestLabelTask_ReadAll(t *testing.T) {
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true, EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true, OverdueTasksRemindersEnabled: true,
OverdueTasksRemindersTime: "09:00",
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
}, },

View File

@ -54,6 +54,7 @@ func TestLabel_ReadAll(t *testing.T) {
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true, EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true, OverdueTasksRemindersEnabled: true,
OverdueTasksRemindersTime: "09:00",
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
} }
@ -104,6 +105,7 @@ func TestLabel_ReadAll(t *testing.T) {
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true, EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true, OverdueTasksRemindersEnabled: true,
OverdueTasksRemindersTime: "09:00",
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
}, },
@ -168,6 +170,7 @@ func TestLabel_ReadOne(t *testing.T) {
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true, EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true, OverdueTasksRemindersEnabled: true,
OverdueTasksRemindersTime: "09:00",
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
} }
@ -229,6 +232,7 @@ func TestLabel_ReadOne(t *testing.T) {
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true, EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true, OverdueTasksRemindersEnabled: true,
OverdueTasksRemindersTime: "09:00",
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
}, },

View File

@ -151,6 +151,7 @@ func TestListUser_ReadAll(t *testing.T) {
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true, EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true, OverdueTasksRemindersEnabled: true,
OverdueTasksRemindersTime: "09:00",
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
}, },
@ -164,6 +165,7 @@ func TestListUser_ReadAll(t *testing.T) {
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true, EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true, OverdueTasksRemindersEnabled: true,
OverdueTasksRemindersTime: "09:00",
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
}, },

View File

@ -150,6 +150,7 @@ func TestNamespaceUser_ReadAll(t *testing.T) {
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true, EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true, OverdueTasksRemindersEnabled: true,
OverdueTasksRemindersTime: "09:00",
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
}, },
@ -163,6 +164,7 @@ func TestNamespaceUser_ReadAll(t *testing.T) {
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true, EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true, OverdueTasksRemindersEnabled: true,
OverdueTasksRemindersTime: "09:00",
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
}, },

View File

@ -37,6 +37,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true, EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true, OverdueTasksRemindersEnabled: true,
OverdueTasksRemindersTime: "09:00",
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
} }
@ -47,6 +48,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true, EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true, OverdueTasksRemindersEnabled: true,
OverdueTasksRemindersTime: "09:00",
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
} }
@ -57,6 +59,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true, EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true, OverdueTasksRemindersEnabled: true,
OverdueTasksRemindersTime: "09:00",
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
} }

View File

@ -27,16 +27,36 @@ func (tc *TaskComment) CanRead(s *xorm.Session, a web.Auth) (bool, int, error) {
return t.CanRead(s, a) return t.CanRead(s, a)
} }
func (tc *TaskComment) canUserModifyTaskComment(s *xorm.Session, a web.Auth) (bool, error) {
t := Task{ID: tc.TaskID}
canWriteTask, err := t.CanWrite(s, a)
if err != nil {
return false, err
}
if !canWriteTask {
return false, nil
}
savedComment := &TaskComment{
ID: tc.ID,
TaskID: tc.TaskID,
}
err = getTaskCommentSimple(s, savedComment)
if err != nil {
return false, err
}
return a.GetID() == savedComment.AuthorID, nil
}
// CanDelete checks if a user can delete a comment // CanDelete checks if a user can delete a comment
func (tc *TaskComment) CanDelete(s *xorm.Session, a web.Auth) (bool, error) { func (tc *TaskComment) CanDelete(s *xorm.Session, a web.Auth) (bool, error) {
t := Task{ID: tc.TaskID} return tc.canUserModifyTaskComment(s, a)
return t.CanWrite(s, a)
} }
// CanUpdate checks if a user can update a comment // CanUpdate checks if a user can update a comment
func (tc *TaskComment) CanUpdate(s *xorm.Session, a web.Auth) (bool, error) { func (tc *TaskComment) CanUpdate(s *xorm.Session, a web.Auth) (bool, error) {
t := Task{ID: tc.TaskID} return tc.canUserModifyTaskComment(s, a)
return t.CanWrite(s, a)
} }
// CanCreate checks if a user can create a new comment // CanCreate checks if a user can create a new comment

View File

@ -151,6 +151,24 @@ func (tc *TaskComment) Update(s *xorm.Session, a web.Auth) error {
}) })
} }
func getTaskCommentSimple(s *xorm.Session, tc *TaskComment) error {
exists, err := s.
Where("id = ? and task_id = ?", tc.ID, tc.TaskID).
NoAutoCondition().
Get(tc)
if err != nil {
return err
}
if !exists {
return ErrTaskCommentDoesNotExist{
ID: tc.ID,
TaskID: tc.TaskID,
}
}
return nil
}
// ReadOne handles getting a single comment // ReadOne handles getting a single comment
// @Summary Remove a task comment // @Summary Remove a task comment
// @Description Remove a task comment. The user doing this need to have at least read access to the task this comment belongs to. // @Description Remove a task comment. The user doing this need to have at least read access to the task this comment belongs to.
@ -166,15 +184,9 @@ func (tc *TaskComment) Update(s *xorm.Session, a web.Auth) error {
// @Failure 500 {object} models.Message "Internal error" // @Failure 500 {object} models.Message "Internal error"
// @Router /tasks/{taskID}/comments/{commentID} [get] // @Router /tasks/{taskID}/comments/{commentID} [get]
func (tc *TaskComment) ReadOne(s *xorm.Session, a web.Auth) (err error) { func (tc *TaskComment) ReadOne(s *xorm.Session, a web.Auth) (err error) {
exists, err := s.Get(tc) err = getTaskCommentSimple(s, tc)
if err != nil { if err != nil {
return return err
}
if !exists {
return ErrTaskCommentDoesNotExist{
ID: tc.ID,
TaskID: tc.TaskID,
}
} }
// Get the author // Get the author

View File

@ -121,6 +121,16 @@ func TestTaskComment_Delete(t *testing.T) {
assert.Error(t, err) assert.Error(t, err)
assert.True(t, IsErrTaskCommentDoesNotExist(err)) assert.True(t, IsErrTaskCommentDoesNotExist(err))
}) })
t.Run("not the own comment", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
tc := &TaskComment{ID: 1, TaskID: 1}
can, err := tc.CanDelete(s, &user.User{ID: 2})
assert.NoError(t, err)
assert.False(t, can)
})
} }
func TestTaskComment_Update(t *testing.T) { func TestTaskComment_Update(t *testing.T) {
@ -157,6 +167,16 @@ func TestTaskComment_Update(t *testing.T) {
assert.Error(t, err) assert.Error(t, err)
assert.True(t, IsErrTaskCommentDoesNotExist(err)) assert.True(t, IsErrTaskCommentDoesNotExist(err))
}) })
t.Run("not the own comment", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
tc := &TaskComment{ID: 1, TaskID: 1}
can, err := tc.CanUpdate(s, &user.User{ID: 2})
assert.NoError(t, err)
assert.False(t, can)
})
} }
func TestTaskComment_ReadOne(t *testing.T) { func TestTaskComment_ReadOne(t *testing.T) {
@ -167,7 +187,7 @@ func TestTaskComment_ReadOne(t *testing.T) {
s := db.NewSession() s := db.NewSession()
defer s.Close() defer s.Close()
tc := &TaskComment{ID: 1} tc := &TaskComment{ID: 1, TaskID: 1}
err := tc.ReadOne(s, u) err := tc.ReadOne(s, u)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "Lorem Ipsum Dolor Sit Amet", tc.Comment) assert.Equal(t, "Lorem Ipsum Dolor Sit Amet", tc.Comment)

View File

@ -19,35 +19,87 @@ package models
import ( import (
"time" "time"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/cron" "code.vikunja.io/api/pkg/cron"
"code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/notifications" "code.vikunja.io/api/pkg/notifications"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/api/pkg/utils" "code.vikunja.io/api/pkg/utils"
"xorm.io/builder" "xorm.io/builder"
"xorm.io/xorm" "xorm.io/xorm"
) )
func getUndoneOverdueTasks(s *xorm.Session, now time.Time) (taskIDs []int64, err error) { func getUndoneOverdueTasks(s *xorm.Session, now time.Time) (usersWithTasks map[int64]*userWithTasks, err error) {
now = utils.GetTimeWithoutNanoSeconds(now) now = utils.GetTimeWithoutSeconds(now)
nextMinute := now.Add(1 * time.Minute)
var tasks []*Task var tasks []*Task
err = s. err = s.
Where("due_date is not null and due_date < ?", now.Format(dbTimeFormat)). Where("due_date is not null and due_date < ?", nextMinute.Add(time.Hour*14).Format(dbTimeFormat)).
And("done = false"). And("done = false").
Find(&tasks) Find(&tasks)
if err != nil { if err != nil {
return return
} }
if len(tasks) == 0 {
return
}
var taskIDs []int64
for _, task := range tasks { for _, task := range tasks {
taskIDs = append(taskIDs, task.ID) taskIDs = append(taskIDs, task.ID)
} }
return users, err := getTaskUsersForTasks(s, taskIDs, builder.Eq{"users.overdue_tasks_reminders_enabled": true})
if err != nil {
return
}
if len(users) == 0 {
return
}
uts := make(map[int64]*userWithTasks)
tzs := make(map[string]*time.Location)
for _, t := range users {
if t.User.Timezone == "" {
t.User.Timezone = config.GetTimeZone().String()
}
tz, exists := tzs[t.User.Timezone]
if !exists {
tz, err = time.LoadLocation(t.User.Timezone)
if err != nil {
return
}
tzs[t.User.Timezone] = tz
}
// If it is time for that current user, add the task to their list of overdue tasks
tm, err := time.Parse("15:04", t.User.OverdueTasksRemindersTime)
if err != nil {
return nil, err
}
overdueMailTime := time.Date(now.Year(), now.Month(), now.Day(), tm.Hour(), tm.Minute(), 0, 0, tz)
isTimeForReminder := overdueMailTime.After(now) || overdueMailTime.Equal(now.In(tz))
wasTimeForReminder := overdueMailTime.Before(nextMinute)
taskIsOverdueInUserTimezone := overdueMailTime.After(t.Task.DueDate.In(tz))
if isTimeForReminder && wasTimeForReminder && taskIsOverdueInUserTimezone {
_, exists := uts[t.User.ID]
if !exists {
uts[t.User.ID] = &userWithTasks{
user: t.User,
tasks: []*Task{},
}
}
uts[t.User.ID].tasks = append(uts[t.User.ID].tasks, t.Task)
}
}
return uts, nil
} }
type userWithTasks struct { type userWithTasks struct {
@ -66,36 +118,18 @@ func RegisterOverdueReminderCron() {
return return
} }
err := cron.Schedule("0 8 * * *", func() { err := cron.Schedule("* * * * *", func() {
s := db.NewSession() s := db.NewSession()
defer s.Close() defer s.Close()
now := time.Now() now := time.Now()
taskIDs, err := getUndoneOverdueTasks(s, now) uts, err := getUndoneOverdueTasks(s, now)
if err != nil { if err != nil {
log.Errorf("[Undone Overdue Tasks Reminder] Could not get tasks with reminders in the next minute: %s", err) log.Errorf("[Undone Overdue Tasks Reminder] Could not get undone overdue tasks in the next minute: %s", err)
return return
} }
users, err := getTaskUsersForTasks(s, taskIDs, builder.Eq{"users.overdue_tasks_reminders_enabled": true}) log.Debugf("[Undone Overdue Tasks Reminder] Sending reminders to %d users", len(uts))
if err != nil {
log.Errorf("[Undone Overdue Tasks Reminder] Could not get task users to send them reminders: %s", err)
return
}
uts := make(map[int64]*userWithTasks)
for _, t := range users {
_, exists := uts[t.User.ID]
if !exists {
uts[t.User.ID] = &userWithTasks{
user: t.User,
tasks: []*Task{},
}
}
uts[t.User.ID].tasks = append(uts[t.User.ID].tasks, t.Task)
}
log.Debugf("[Undone Overdue Tasks Reminder] Sending reminders to %d users", len(users))
for _, ut := range uts { for _, ut := range uts {
var n notifications.Notification = &UndoneTasksOverdueNotification{ var n notifications.Notification = &UndoneTasksOverdueNotification{
@ -117,7 +151,6 @@ func RegisterOverdueReminderCron() {
} }
log.Debugf("[Undone Overdue Tasks Reminder] Sent reminder email for %d tasks to user %d", len(ut.tasks), ut.user.ID) log.Debugf("[Undone Overdue Tasks Reminder] Sent reminder email for %d tasks to user %d", len(ut.tasks), ut.user.ID)
continue
} }
}) })
if err != nil { if err != nil {

View File

@ -32,21 +32,34 @@ func TestGetUndoneOverDueTasks(t *testing.T) {
now, err := time.Parse(time.RFC3339Nano, "2018-01-01T01:13:00Z") now, err := time.Parse(time.RFC3339Nano, "2018-01-01T01:13:00Z")
assert.NoError(t, err) assert.NoError(t, err)
taskIDs, err := getUndoneOverdueTasks(s, now) tasks, err := getUndoneOverdueTasks(s, now)
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, taskIDs, 0) assert.Len(t, tasks, 0)
}) })
t.Run("undone overdue", func(t *testing.T) { t.Run("undone overdue", func(t *testing.T) {
db.LoadAndAssertFixtures(t) db.LoadAndAssertFixtures(t)
s := db.NewSession() s := db.NewSession()
defer s.Close() defer s.Close()
now, err := time.Parse(time.RFC3339Nano, "2018-12-01T01:13:00Z") now, err := time.Parse(time.RFC3339Nano, "2018-12-01T09:00:00Z")
assert.NoError(t, err) assert.NoError(t, err)
taskIDs, err := getUndoneOverdueTasks(s, now) uts, err := getUndoneOverdueTasks(s, now)
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, taskIDs, 1) assert.Len(t, uts, 1)
assert.Equal(t, int64(6), taskIDs[0]) assert.Len(t, uts[1].tasks, 2)
// The tasks don't always have the same order, so we only check their presence, not their position.
var task5Present bool
var task6Present bool
for _, t := range uts[1].tasks {
if t.ID == 5 {
task5Present = true
}
if t.ID == 6 {
task6Present = true
}
}
assert.Truef(t, task5Present, "expected task 5 to be present but was not")
assert.Truef(t, task6Present, "expected task 6 to be present but was not")
}) })
t.Run("done overdue", func(t *testing.T) { t.Run("done overdue", func(t *testing.T) {
db.LoadAndAssertFixtures(t) db.LoadAndAssertFixtures(t)
@ -55,8 +68,8 @@ func TestGetUndoneOverDueTasks(t *testing.T) {
now, err := time.Parse(time.RFC3339Nano, "2018-11-01T01:13:00Z") now, err := time.Parse(time.RFC3339Nano, "2018-11-01T01:13:00Z")
assert.NoError(t, err) assert.NoError(t, err)
taskIDs, err := getUndoneOverdueTasks(s, now) tasks, err := getUndoneOverdueTasks(s, now)
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, taskIDs, 0) assert.Len(t, tasks, 0)
}) })
} }

View File

@ -61,7 +61,7 @@ func getTaskUsersForTasks(s *xorm.Session, taskIDs []int64, cond builder.Cond) (
// Get all creators of tasks // Get all creators of tasks
creators := make(map[int64]*user.User, len(taskIDs)) creators := make(map[int64]*user.User, len(taskIDs))
err = s. err = s.
Select("users.id, users.username, users.email, users.name, users.timezone"). Select("users.*").
Join("LEFT", "tasks", "tasks.created_by_id = users.id"). Join("LEFT", "tasks", "tasks.created_by_id = users.id").
In("tasks.id", taskIDs). In("tasks.id", taskIDs).
Where(cond). Where(cond).

View File

@ -32,6 +32,7 @@ func TestListUsersFromList(t *testing.T) {
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true, EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true, OverdueTasksRemindersEnabled: true,
OverdueTasksRemindersTime: "09:00",
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
} }
@ -42,6 +43,7 @@ func TestListUsersFromList(t *testing.T) {
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true, EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true, OverdueTasksRemindersEnabled: true,
OverdueTasksRemindersTime: "09:00",
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
} }
@ -52,6 +54,7 @@ func TestListUsersFromList(t *testing.T) {
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true, EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true, OverdueTasksRemindersEnabled: true,
OverdueTasksRemindersTime: "09:00",
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
} }
@ -63,6 +66,7 @@ func TestListUsersFromList(t *testing.T) {
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true, EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true, OverdueTasksRemindersEnabled: true,
OverdueTasksRemindersTime: "09:00",
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
} }
@ -74,6 +78,7 @@ func TestListUsersFromList(t *testing.T) {
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true, EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true, OverdueTasksRemindersEnabled: true,
OverdueTasksRemindersTime: "09:00",
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
} }
@ -84,6 +89,7 @@ func TestListUsersFromList(t *testing.T) {
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true, EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true, OverdueTasksRemindersEnabled: true,
OverdueTasksRemindersTime: "09:00",
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
} }
@ -95,6 +101,7 @@ func TestListUsersFromList(t *testing.T) {
EmailRemindersEnabled: true, EmailRemindersEnabled: true,
DiscoverableByEmail: true, DiscoverableByEmail: true,
OverdueTasksRemindersEnabled: true, OverdueTasksRemindersEnabled: true,
OverdueTasksRemindersTime: "09:00",
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
} }
@ -105,6 +112,7 @@ func TestListUsersFromList(t *testing.T) {
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true, EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true, OverdueTasksRemindersEnabled: true,
OverdueTasksRemindersTime: "09:00",
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
} }
@ -115,6 +123,7 @@ func TestListUsersFromList(t *testing.T) {
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true, EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true, OverdueTasksRemindersEnabled: true,
OverdueTasksRemindersTime: "09:00",
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
} }
@ -125,6 +134,7 @@ func TestListUsersFromList(t *testing.T) {
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true, EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true, OverdueTasksRemindersEnabled: true,
OverdueTasksRemindersTime: "09:00",
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
} }
@ -136,6 +146,7 @@ func TestListUsersFromList(t *testing.T) {
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true, EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true, OverdueTasksRemindersEnabled: true,
OverdueTasksRemindersTime: "09:00",
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
} }
@ -148,6 +159,7 @@ func TestListUsersFromList(t *testing.T) {
EmailRemindersEnabled: true, EmailRemindersEnabled: true,
DiscoverableByName: true, DiscoverableByName: true,
OverdueTasksRemindersEnabled: true, OverdueTasksRemindersEnabled: true,
OverdueTasksRemindersTime: "09:00",
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
} }
@ -158,6 +170,7 @@ func TestListUsersFromList(t *testing.T) {
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true, EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true, OverdueTasksRemindersEnabled: true,
OverdueTasksRemindersTime: "09:00",
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
} }

View File

@ -17,6 +17,7 @@
package v1 package v1
import ( import (
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/files" "code.vikunja.io/api/pkg/files"
"code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/log"
@ -49,7 +50,7 @@ import (
// @tags user // @tags user
// @Produce octet-stream // @Produce octet-stream
// @Param username path string true "The username of the user who's avatar you want to get" // @Param username path string true "The username of the user who's avatar you want to get"
// @Param size query int false "The size of the avatar you want to get" // @Param size query int false "The size of the avatar you want to get. If bigger than the max configured size this will be adjusted to the maximum size."
// @Success 200 {} blob "The avatar" // @Success 200 {} blob "The avatar"
// @Failure 404 {object} models.Message "The user does not exist." // @Failure 404 {object} models.Message "The user does not exist."
// @Failure 500 {object} models.Message "Internal error" // @Failure 500 {object} models.Message "Internal error"
@ -97,6 +98,9 @@ func GetAvatar(c echo.Context) error {
return handler.HandleHTTPError(err, c) return handler.HandleHTTPError(err, c)
} }
} }
if sizeInt > config.ServiceMaxAvatarSize.GetInt64() {
sizeInt = config.ServiceMaxAvatarSize.GetInt64()
}
// Get the avatar // Get the avatar
a, mimeType, err := avatarProvider.GetAvatar(u, sizeInt) a, mimeType, err := avatarProvider.GetAvatar(u, sizeInt)

View File

@ -17,6 +17,8 @@
package v1 package v1
import ( import (
"errors"
"fmt"
"net/http" "net/http"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
@ -46,11 +48,13 @@ type UserSettings struct {
DiscoverableByEmail bool `json:"discoverable_by_email"` DiscoverableByEmail bool `json:"discoverable_by_email"`
// If enabled, the user will get an email for their overdue tasks each morning. // If enabled, the user will get an email for their overdue tasks each morning.
OverdueTasksRemindersEnabled bool `json:"overdue_tasks_reminders_enabled"` OverdueTasksRemindersEnabled bool `json:"overdue_tasks_reminders_enabled"`
// The time when the daily summary of overdue tasks will be sent via email.
OverdueTasksRemindersTime string `json:"overdue_tasks_reminders_time" valid:"time,required"`
// If a task is created without a specified list this value should be used. Applies // If a task is created without a specified list this value should be used. Applies
// to tasks made directly in API and from clients. // to tasks made directly in API and from clients.
DefaultListID int64 `json:"default_list_id"` DefaultListID int64 `json:"default_list_id"`
// The day when the week starts for this user. 0 = sunday, 1 = monday, etc. // The day when the week starts for this user. 0 = sunday, 1 = monday, etc.
WeekStart int `json:"week_start"` WeekStart int `json:"week_start" valid:"range(0|7)"`
// The user's language // The user's language
Language string `json:"language"` Language string `json:"language"`
// The user's time zone. Used to send task reminders in the time zone of the user. // The user's time zone. Used to send task reminders in the time zone of the user.
@ -158,7 +162,16 @@ func UpdateGeneralUserSettings(c echo.Context) error {
us := &UserSettings{} us := &UserSettings{}
err := c.Bind(us) err := c.Bind(us)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Bad user name provided.") var he *echo.HTTPError
if errors.As(err, &he) {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid model provided. Error was: %s", he.Message))
}
return echo.NewHTTPError(http.StatusBadRequest, "Invalid model provided.")
}
err = c.Validate(us)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err)
} }
u, err := user2.GetCurrentUser(c) u, err := user2.GetCurrentUser(c)
@ -184,6 +197,7 @@ func UpdateGeneralUserSettings(c echo.Context) error {
user.WeekStart = us.WeekStart user.WeekStart = us.WeekStart
user.Language = us.Language user.Language = us.Language
user.Timezone = us.Timezone user.Timezone = us.Timezone
user.OverdueTasksRemindersTime = us.OverdueTasksRemindersTime
_, err = user2.UpdateUser(s, user) _, err = user2.UpdateUser(s, user)
if err != nil { if err != nil {

View File

@ -75,6 +75,7 @@ func UserShow(c echo.Context) error {
WeekStart: u.WeekStart, WeekStart: u.WeekStart,
Language: u.Language, Language: u.Language,
Timezone: u.Timezone, Timezone: u.Timezone,
OverdueTasksRemindersTime: u.OverdueTasksRemindersTime,
}, },
DeletionScheduledAt: u.DeletionScheduledAt, DeletionScheduledAt: u.DeletionScheduledAt,
IsLocalUser: u.Issuer == user.IssuerLocal, IsLocalUser: u.Issuer == user.IssuerLocal,

View File

@ -79,7 +79,6 @@ import (
"code.vikunja.io/web" "code.vikunja.io/web"
"code.vikunja.io/web/handler" "code.vikunja.io/web/handler"
"github.com/asaskevich/govalidator"
"github.com/getsentry/sentry-go" "github.com/getsentry/sentry-go"
sentryecho "github.com/getsentry/sentry-go/echo" sentryecho "github.com/getsentry/sentry-go/echo"
"github.com/golang-jwt/jwt/v4" "github.com/golang-jwt/jwt/v4"
@ -88,31 +87,6 @@ import (
elog "github.com/labstack/gommon/log" elog "github.com/labstack/gommon/log"
) )
// CustomValidator is a dummy struct to use govalidator with echo
type CustomValidator struct{}
// Validate validates stuff
func (cv *CustomValidator) Validate(i interface{}) error {
if _, err := govalidator.ValidateStruct(i); err != nil {
var errs []string
for field, e := range govalidator.ErrorsByField(err) {
errs = append(errs, field+": "+e)
}
httperr := models.ValidationHTTPError{
HTTPError: web.HTTPError{
Code: models.ErrCodeInvalidData,
Message: "Invalid Data",
},
InvalidFields: errs,
}
return httperr
}
return nil
}
// NewEcho registers a new Echo instance // NewEcho registers a new Echo instance
func NewEcho() *echo.Echo { func NewEcho() *echo.Echo {
e := echo.New() e := echo.New()

55
pkg/routes/validation.go Normal file
View File

@ -0,0 +1,55 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2021 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 routes
import (
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/web"
"github.com/asaskevich/govalidator"
)
// CustomValidator is a dummy struct to use govalidator with echo
type CustomValidator struct{}
func init() {
govalidator.TagMap["time"] = govalidator.Validator(func(str string) bool {
return govalidator.IsTime(str, "15:04")
})
}
// Validate validates stuff
func (cv *CustomValidator) Validate(i interface{}) error {
if _, err := govalidator.ValidateStruct(i); err != nil {
var errs []string
for field, e := range govalidator.ErrorsByField(err) {
errs = append(errs, field+": "+e)
}
httperr := models.ValidationHTTPError{
HTTPError: web.HTTPError{
Code: models.ErrCodeInvalidData,
Message: "Invalid Data",
},
InvalidFields: errs,
}
return httperr
}
return nil
}

View File

@ -7537,7 +7537,7 @@ const docTemplate = `{
}, },
{ {
"type": "integer", "type": "integer",
"description": "The size of the avatar you want to get", "description": "The size of the avatar you want to get. If bigger than the max configured size this will be adjusted to the maximum size.",
"name": "size", "name": "size",
"in": "query" "in": "query"
} }
@ -9184,6 +9184,10 @@ const docTemplate = `{
"description": "If enabled, the user will get an email for their overdue tasks each morning.", "description": "If enabled, the user will get an email for their overdue tasks each morning.",
"type": "boolean" "type": "boolean"
}, },
"overdue_tasks_reminders_time": {
"description": "The time when the daily summary of overdue tasks will be sent via email.",
"type": "string"
},
"timezone": { "timezone": {
"description": "The user's time zone. Used to send task reminders in the time zone of the user.", "description": "The user's time zone. Used to send task reminders in the time zone of the user.",
"type": "string" "type": "string"

View File

@ -7528,7 +7528,7 @@
}, },
{ {
"type": "integer", "type": "integer",
"description": "The size of the avatar you want to get", "description": "The size of the avatar you want to get. If bigger than the max configured size this will be adjusted to the maximum size.",
"name": "size", "name": "size",
"in": "query" "in": "query"
} }
@ -9175,6 +9175,10 @@
"description": "If enabled, the user will get an email for their overdue tasks each morning.", "description": "If enabled, the user will get an email for their overdue tasks each morning.",
"type": "boolean" "type": "boolean"
}, },
"overdue_tasks_reminders_time": {
"description": "The time when the daily summary of overdue tasks will be sent via email.",
"type": "string"
},
"timezone": { "timezone": {
"description": "The user's time zone. Used to send task reminders in the time zone of the user.", "description": "The user's time zone. Used to send task reminders in the time zone of the user.",
"type": "string" "type": "string"

View File

@ -1310,6 +1310,10 @@ definitions:
description: If enabled, the user will get an email for their overdue tasks description: If enabled, the user will get an email for their overdue tasks
each morning. each morning.
type: boolean type: boolean
overdue_tasks_reminders_time:
description: The time when the daily summary of overdue tasks will be sent
via email.
type: string
timezone: timezone:
description: The user's time zone. Used to send task reminders in the time description: The user's time zone. Used to send task reminders in the time
zone of the user. zone of the user.
@ -1433,7 +1437,8 @@ paths:
name: username name: username
required: true required: true
type: string type: string
- description: The size of the avatar you want to get - description: The size of the avatar you want to get. If bigger than the max
configured size this will be adjusted to the maximum size.
in: query in: query
name: size name: size
type: integer type: integer

View File

@ -94,6 +94,7 @@ type User struct {
DiscoverableByName bool `xorm:"bool default false index" json:"-"` DiscoverableByName bool `xorm:"bool default false index" json:"-"`
DiscoverableByEmail bool `xorm:"bool default false index" json:"-"` DiscoverableByEmail bool `xorm:"bool default false index" json:"-"`
OverdueTasksRemindersEnabled bool `xorm:"bool default true index" json:"-"` OverdueTasksRemindersEnabled bool `xorm:"bool default true index" json:"-"`
OverdueTasksRemindersTime string `xorm:"varchar(5) not null default '09:00'" json:"-"`
DefaultListID int64 `xorm:"bigint null index" json:"-"` DefaultListID int64 `xorm:"bigint null index" json:"-"`
WeekStart int `xorm:"null" json:"-"` WeekStart int `xorm:"null" json:"-"`
Language string `xorm:"varchar(50) null" json:"-"` Language string `xorm:"varchar(50) null" json:"-"`
@ -493,6 +494,7 @@ func UpdateUser(s *xorm.Session, user *User) (updatedUser *User, err error) {
"week_start", "week_start",
"language", "language",
"timezone", "timezone",
"overdue_tasks_reminders_time",
). ).
Update(user) Update(user)
if err != nil { if err != nil {

View File

@ -30,3 +30,12 @@ func GetTimeWithoutNanoSeconds(t time.Time) time.Time {
// so we make sure the time we use to get the reminders don't contain nanoseconds. // so we make sure the time we use to get the reminders don't contain nanoseconds.
return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), 0, t.Location()).In(tz) return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), 0, t.Location()).In(tz)
} }
// GetTimeWithoutSeconds returns a time.Time with the seconds set to 0.
func GetTimeWithoutSeconds(t time.Time) time.Time {
tz := config.GetTimeZone()
// By default, time.Now() includes nanoseconds which we don't save. That results in getting the wrong dates,
// so we make sure the time we use to get the reminders don't contain nanoseconds.
return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), 0, 0, t.Location()).In(tz)
}