feat: add Atom feed for user notifications with API token auth (#2758)
This commit is contained in:
parent
c371ca7196
commit
70393f38d2
1
go.mod
1
go.mod
|
|
@ -144,6 +144,7 @@ require (
|
|||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/gorilla/feeds v1.2.0 // indirect
|
||||
github.com/huandu/go-clone v1.7.3 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
|
|
|
|||
2
go.sum
2
go.sum
|
|
@ -245,6 +245,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/gorilla/feeds v1.2.0 h1:O6pBiXJ5JHhPvqy53NsjKOThq+dNFm8+DFrxBEdzSCc=
|
||||
github.com/gorilla/feeds v1.2.0/go.mod h1:WMib8uJP3BbY+X8Szd1rA5Pzhdfh+HCCAYT2z7Fza6Y=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
|
||||
|
|
|
|||
|
|
@ -68,3 +68,13 @@
|
|||
owner_id: 15
|
||||
created: 2024-01-01 00:00:00
|
||||
# token in plaintext is tk_nocaldav_token_test_000000005678efab
|
||||
- id: 8
|
||||
title: 'feeds access token for user 13'
|
||||
token_salt: fEdRTk9sR2
|
||||
token_hash: c1231ac23940702dcbdf20ae4c125a904780788b091f6d6c56f94f3620a634ec00aac5288659e04174a69a60b20ea86cdfa5
|
||||
token_last_eight: feed0013
|
||||
permissions: '{"feeds":["access"]}'
|
||||
expires_at: 2099-01-01 00:00:00
|
||||
owner_id: 13
|
||||
created: 2024-01-01 00:00:00
|
||||
# token in plaintext is tk_feeds_access_token_user_0013_feed0013
|
||||
|
|
|
|||
|
|
@ -173,5 +173,10 @@
|
|||
"since_hours": "one hour|%[1]d hours",
|
||||
"since_minutes": "one minute|%[1]d minutes",
|
||||
"list_last_separator": "and"
|
||||
},
|
||||
"feeds": {
|
||||
"notifications": {
|
||||
"title": "Vikunja notifications for %[1]s"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,3 +46,17 @@ func AssertSent(t *testing.T, opts *Opts) {
|
|||
|
||||
assert.True(t, found, "Failed to assert mail '%v' has been sent.", opts)
|
||||
}
|
||||
|
||||
// LastSent returns the most recently captured mail when running under Fake(),
|
||||
// or nil if no mail has been sent. Intended for tests.
|
||||
func LastSent() *Opts {
|
||||
if len(sentMails) == 0 {
|
||||
return nil
|
||||
}
|
||||
return sentMails[len(sentMails)-1]
|
||||
}
|
||||
|
||||
// ResetSent clears the captured mail buffer. Intended for tests.
|
||||
func ResetSent() {
|
||||
sentMails = nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,12 @@ func init() {
|
|||
Method: "ANY",
|
||||
},
|
||||
}
|
||||
apiTokenRoutes["feeds"] = APITokenRoute{
|
||||
"access": &RouteDetail{
|
||||
Path: "/feeds/*",
|
||||
Method: "GET",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type APITokenRoute map[string]*RouteDetail
|
||||
|
|
|
|||
|
|
@ -207,6 +207,15 @@ func (t *APIToken) HasCaldavAccess() bool {
|
|||
return slices.Contains(perms, "access")
|
||||
}
|
||||
|
||||
// HasFeedsAccess checks whether the token has the feeds access permission.
|
||||
func (t *APIToken) HasFeedsAccess() bool {
|
||||
perms, has := t.APIPermissions["feeds"]
|
||||
if !has {
|
||||
return false
|
||||
}
|
||||
return slices.Contains(perms, "access")
|
||||
}
|
||||
|
||||
// GetTokenFromTokenString returns the full token object from the original token string.
|
||||
func GetTokenFromTokenString(s *xorm.Session, token string) (apiToken *APIToken, err error) {
|
||||
lastEight := token[len(token)-8:]
|
||||
|
|
|
|||
|
|
@ -23,15 +23,23 @@ import (
|
|||
"code.vikunja.io/api/pkg/user"
|
||||
)
|
||||
|
||||
func init() {
|
||||
notifications.Register(func() notifications.Notification { return &APITokenExpiringWeekNotification{} })
|
||||
notifications.Register(func() notifications.Notification { return &APITokenExpiringDayNotification{} })
|
||||
}
|
||||
|
||||
// APITokenExpiringWeekNotification is sent 7 days before an API token expires.
|
||||
type APITokenExpiringWeekNotification struct {
|
||||
User *user.User `json:"user"`
|
||||
Token *APIToken `json:"api_token"`
|
||||
}
|
||||
|
||||
func (n *APITokenExpiringWeekNotification) ToTitle(lang string) string {
|
||||
return i18n.T(lang, "notifications.api_token.expiring.week.subject", n.Token.Title)
|
||||
}
|
||||
|
||||
func (n *APITokenExpiringWeekNotification) ToMail(lang string) *notifications.Mail {
|
||||
return notifications.NewMail().
|
||||
Subject(i18n.T(lang, "notifications.api_token.expiring.week.subject", n.Token.Title)).
|
||||
Greeting(i18n.T(lang, "notifications.greeting", n.User.GetName())).
|
||||
Line(i18n.T(lang, "notifications.api_token.expiring.week.message", notifications.EscapeMarkdown(n.Token.Title), n.Token.ExpiresAt.Format("2006-01-02"))).
|
||||
Action(i18n.T(lang, "notifications.api_token.expiring.action"), config.ServicePublicURL.GetString()+"user/settings/api-tokens").
|
||||
|
|
@ -56,9 +64,12 @@ type APITokenExpiringDayNotification struct {
|
|||
Token *APIToken `json:"api_token"`
|
||||
}
|
||||
|
||||
func (n *APITokenExpiringDayNotification) ToTitle(lang string) string {
|
||||
return i18n.T(lang, "notifications.api_token.expiring.day.subject", n.Token.Title)
|
||||
}
|
||||
|
||||
func (n *APITokenExpiringDayNotification) ToMail(lang string) *notifications.Mail {
|
||||
return notifications.NewMail().
|
||||
Subject(i18n.T(lang, "notifications.api_token.expiring.day.subject", n.Token.Title)).
|
||||
Greeting(i18n.T(lang, "notifications.greeting", n.User.GetName())).
|
||||
Line(i18n.T(lang, "notifications.api_token.expiring.day.message", notifications.EscapeMarkdown(n.Token.Title), n.Token.ExpiresAt.Format("2006-01-02"))).
|
||||
Action(i18n.T(lang, "notifications.api_token.expiring.action"), config.ServicePublicURL.GetString()+"user/settings/api-tokens").
|
||||
|
|
|
|||
|
|
@ -125,6 +125,36 @@ func TestAPIToken_HasCaldavAccess(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestAPIToken_HasFeedsAccess(t *testing.T) {
|
||||
t.Run("has feeds access", func(t *testing.T) {
|
||||
token := &APIToken{
|
||||
APIPermissions: APIPermissions{"feeds": {"access"}},
|
||||
}
|
||||
assert.True(t, token.HasFeedsAccess())
|
||||
})
|
||||
t.Run("no feeds group", func(t *testing.T) {
|
||||
token := &APIToken{
|
||||
APIPermissions: APIPermissions{"tasks": {"read_all"}},
|
||||
}
|
||||
assert.False(t, token.HasFeedsAccess())
|
||||
})
|
||||
t.Run("feeds group but wrong permission", func(t *testing.T) {
|
||||
token := &APIToken{
|
||||
APIPermissions: APIPermissions{"feeds": {"read_all"}},
|
||||
}
|
||||
assert.False(t, token.HasFeedsAccess())
|
||||
})
|
||||
t.Run("feeds access among other permissions", func(t *testing.T) {
|
||||
token := &APIToken{
|
||||
APIPermissions: APIPermissions{
|
||||
"tasks": {"read_all", "update"},
|
||||
"feeds": {"access"},
|
||||
},
|
||||
}
|
||||
assert.True(t, token.HasFeedsAccess())
|
||||
})
|
||||
}
|
||||
|
||||
func TestAPIToken_GetTokenFromTokenString(t *testing.T) {
|
||||
t.Run("valid token", func(t *testing.T) {
|
||||
s := db.NewSession()
|
||||
|
|
|
|||
|
|
@ -32,6 +32,16 @@ import (
|
|||
"code.vikunja.io/api/pkg/utils"
|
||||
)
|
||||
|
||||
func init() {
|
||||
notifications.Register(func() notifications.Notification { return &ReminderDueNotification{} })
|
||||
notifications.Register(func() notifications.Notification { return &TaskCommentNotification{} })
|
||||
notifications.Register(func() notifications.Notification { return &TaskAssignedNotification{} })
|
||||
notifications.Register(func() notifications.Notification { return &TaskDeletedNotification{} })
|
||||
notifications.Register(func() notifications.Notification { return &ProjectCreatedNotification{} })
|
||||
notifications.Register(func() notifications.Notification { return &TeamMemberAddedNotification{} })
|
||||
notifications.Register(func() notifications.Notification { return &UserMentionedInTaskNotification{} })
|
||||
}
|
||||
|
||||
// getDoerAvatarDataURI returns the avatar data URI for a user, for use in email headers.
|
||||
func getDoerAvatarDataURI(doer *user.User) string {
|
||||
provider := avatar.GetProvider(doer)
|
||||
|
|
@ -55,12 +65,16 @@ type ReminderDueNotification struct {
|
|||
TaskReminder *TaskReminder `json:"reminder"`
|
||||
}
|
||||
|
||||
// ToTitle returns the translated one-line title for ReminderDueNotification
|
||||
func (n *ReminderDueNotification) ToTitle(lang string) string {
|
||||
return i18n.T(lang, "notifications.task.reminder.subject", n.Task.Title, n.Project.Title)
|
||||
}
|
||||
|
||||
// ToMail returns the mail notification for ReminderDueNotification
|
||||
func (n *ReminderDueNotification) ToMail(lang string) *notifications.Mail {
|
||||
return notifications.NewMail().
|
||||
IncludeLinkToSettings(lang).
|
||||
To(n.User.Email).
|
||||
Subject(i18n.T(lang, "notifications.task.reminder.subject", n.Task.Title, n.Project.Title)).
|
||||
Greeting(i18n.T(lang, "notifications.greeting", n.User.GetName())).
|
||||
Line(i18n.T(lang, "notifications.task.reminder.message", notifications.EscapeMarkdown(n.Task.Title), notifications.EscapeMarkdown(n.Project.Title))).
|
||||
Action(i18n.T(lang, "notifications.common.actions.open_task"), config.ServicePublicURL.GetString()+"tasks/"+strconv.FormatInt(n.Task.ID, 10)).
|
||||
|
|
@ -98,6 +112,14 @@ func (n *TaskCommentNotification) SubjectID() int64 {
|
|||
return n.Comment.ID
|
||||
}
|
||||
|
||||
// ToTitle returns the translated one-line title for TaskCommentNotification
|
||||
func (n *TaskCommentNotification) ToTitle(lang string) string {
|
||||
if n.Mentioned {
|
||||
return i18n.T(lang, "notifications.task.comment.mentioned_subject", n.Doer.GetName(), n.Task.Title, n.Task.GetFullIdentifier())
|
||||
}
|
||||
return i18n.T(lang, "notifications.task.comment.subject", n.Task.Title, n.Task.GetFullIdentifier())
|
||||
}
|
||||
|
||||
// ToMail returns the mail notification for TaskCommentNotification
|
||||
func (n *TaskCommentNotification) ToMail(lang string) *notifications.Mail {
|
||||
s := db.NewSession()
|
||||
|
|
@ -106,14 +128,12 @@ func (n *TaskCommentNotification) ToMail(lang string) *notifications.Mail {
|
|||
|
||||
mail := notifications.NewMail().
|
||||
Conversational().
|
||||
From(n.Doer.GetNameAndFromEmail()).
|
||||
Subject(i18n.T(lang, "notifications.task.comment.subject", n.Task.Title, n.Task.GetFullIdentifier()))
|
||||
From(n.Doer.GetNameAndFromEmail())
|
||||
|
||||
// Add header line
|
||||
action := i18n.T(lang, "notifications.common.actions.left_comment", n.Doer.GetName())
|
||||
if n.Mentioned {
|
||||
action = i18n.T(lang, "notifications.common.actions.mentioned_you_comment", n.Doer.GetName())
|
||||
mail.Subject(i18n.T(lang, "notifications.task.comment.mentioned_subject", n.Doer.GetName(), n.Task.Title, n.Task.GetFullIdentifier()))
|
||||
}
|
||||
|
||||
headerLine := notifications.CreateConversationalHeader(
|
||||
|
|
@ -158,13 +178,23 @@ type TaskAssignedNotification struct {
|
|||
Project *Project `json:"project"`
|
||||
}
|
||||
|
||||
// ToTitle returns the translated one-line title for TaskAssignedNotification
|
||||
func (n *TaskAssignedNotification) ToTitle(lang string) string {
|
||||
if n.Target.ID == n.Assignee.ID {
|
||||
return i18n.T(lang, "notifications.task.assigned.subject_to_assignee", n.Task.Title, n.Task.GetFullIdentifier())
|
||||
}
|
||||
if n.Doer.ID == n.Assignee.ID {
|
||||
return i18n.T(lang, "notifications.task.assigned.subject_to_others_self", n.Task.Title, n.Task.GetFullIdentifier(), n.Doer.GetName())
|
||||
}
|
||||
return i18n.T(lang, "notifications.task.assigned.subject_to_others", n.Task.Title, n.Task.GetFullIdentifier(), n.Assignee.GetName())
|
||||
}
|
||||
|
||||
// ToMail returns the mail notification for TaskAssignedNotification
|
||||
func (n *TaskAssignedNotification) ToMail(lang string) *notifications.Mail {
|
||||
if n.Target.ID == n.Assignee.ID {
|
||||
// Notification to the assignee
|
||||
return notifications.NewMail().
|
||||
From(n.Doer.GetNameAndFromEmail()).
|
||||
Subject(i18n.T(lang, "notifications.task.assigned.subject_to_assignee", n.Task.Title, n.Task.GetFullIdentifier())).
|
||||
Greeting(i18n.T(lang, "notifications.greeting", n.Target.GetName())).
|
||||
Line(i18n.T(lang, "notifications.task.assigned.message_to_assignee", notifications.EscapeMarkdown(n.Doer.GetName()), notifications.EscapeMarkdown(n.Task.Title))).
|
||||
Action(i18n.T(lang, "notifications.common.actions.open_task"), n.Task.GetFrontendURL()).
|
||||
|
|
@ -175,7 +205,6 @@ func (n *TaskAssignedNotification) ToMail(lang string) *notifications.Mail {
|
|||
if n.Doer.ID == n.Assignee.ID {
|
||||
return notifications.NewMail().
|
||||
From(n.Doer.GetNameAndFromEmail()).
|
||||
Subject(i18n.T(lang, "notifications.task.assigned.subject_to_others_self", n.Task.Title, n.Task.GetFullIdentifier(), n.Doer.GetName())).
|
||||
Greeting(i18n.T(lang, "notifications.greeting", n.Target.GetName())).
|
||||
Line(i18n.T(lang, "notifications.task.assigned.message_to_others_self", notifications.EscapeMarkdown(n.Doer.GetName()))).
|
||||
Action(i18n.T(lang, "notifications.common.actions.open_task"), n.Task.GetFrontendURL()).
|
||||
|
|
@ -185,7 +214,6 @@ func (n *TaskAssignedNotification) ToMail(lang string) *notifications.Mail {
|
|||
// Notification to others about assignment
|
||||
return notifications.NewMail().
|
||||
From(n.Doer.GetNameAndFromEmail()).
|
||||
Subject(i18n.T(lang, "notifications.task.assigned.subject_to_others", n.Task.Title, n.Task.GetFullIdentifier(), n.Assignee.GetName())).
|
||||
Greeting(i18n.T(lang, "notifications.greeting", n.Target.GetName())).
|
||||
Line(i18n.T(lang, "notifications.task.assigned.message_to_others", notifications.EscapeMarkdown(n.Doer.GetName()), notifications.EscapeMarkdown(n.Assignee.GetName()))).
|
||||
Action(i18n.T(lang, "notifications.common.actions.open_task"), n.Task.GetFrontendURL()).
|
||||
|
|
@ -213,10 +241,14 @@ type TaskDeletedNotification struct {
|
|||
Task *Task `json:"task"`
|
||||
}
|
||||
|
||||
// ToTitle returns the translated one-line title for TaskDeletedNotification
|
||||
func (n *TaskDeletedNotification) ToTitle(lang string) string {
|
||||
return i18n.T(lang, "notifications.task.deleted.subject", n.Task.Title, n.Task.GetFullIdentifier())
|
||||
}
|
||||
|
||||
// ToMail returns the mail notification for TaskDeletedNotification
|
||||
func (n *TaskDeletedNotification) ToMail(lang string) *notifications.Mail {
|
||||
return notifications.NewMail().
|
||||
Subject(i18n.T(lang, "notifications.task.deleted.subject", n.Task.Title, n.Task.GetFullIdentifier())).
|
||||
Line(i18n.T(lang, "notifications.task.deleted.message", notifications.EscapeMarkdown(n.Doer.GetName()), notifications.EscapeMarkdown(n.Task.Title), notifications.EscapeMarkdown(n.Task.GetFullIdentifier())))
|
||||
}
|
||||
|
||||
|
|
@ -241,10 +273,14 @@ type ProjectCreatedNotification struct {
|
|||
Project *Project `json:"project"`
|
||||
}
|
||||
|
||||
// ToTitle returns the translated one-line title for ProjectCreatedNotification
|
||||
func (n *ProjectCreatedNotification) ToTitle(lang string) string {
|
||||
return i18n.T(lang, "notifications.project.created", n.Doer.GetName(), n.Project.Title)
|
||||
}
|
||||
|
||||
// ToMail returns the mail notification for ProjectCreatedNotification
|
||||
func (n *ProjectCreatedNotification) ToMail(lang string) *notifications.Mail {
|
||||
return notifications.NewMail().
|
||||
Subject(i18n.T(lang, "notifications.project.created", n.Doer.GetName(), n.Project.Title)).
|
||||
Line(i18n.T(lang, "notifications.project.created", notifications.EscapeMarkdown(n.Doer.GetName()), notifications.EscapeMarkdown(n.Project.Title))).
|
||||
Action(i18n.T(lang, "notifications.common.actions.open_project"), config.ServicePublicURL.GetString()+"projects/")
|
||||
}
|
||||
|
|
@ -266,10 +302,14 @@ type TeamMemberAddedNotification struct {
|
|||
Team *Team `json:"team"`
|
||||
}
|
||||
|
||||
// ToTitle returns the translated one-line title for TeamMemberAddedNotification
|
||||
func (n *TeamMemberAddedNotification) ToTitle(lang string) string {
|
||||
return i18n.T(lang, "notifications.team.member_added.subject", n.Doer.GetName(), n.Team.Name)
|
||||
}
|
||||
|
||||
// ToMail returns the mail notification for TeamMemberAddedNotification
|
||||
func (n *TeamMemberAddedNotification) ToMail(lang string) *notifications.Mail {
|
||||
return notifications.NewMail().
|
||||
Subject(i18n.T(lang, "notifications.team.member_added.subject", n.Doer.GetName(), n.Team.Name)).
|
||||
From(n.Doer.GetNameAndFromEmail()).
|
||||
Greeting(i18n.T(lang, "notifications.greeting", n.Member.GetName())).
|
||||
Line(i18n.T(lang, "notifications.team.member_added.message", notifications.EscapeMarkdown(n.Doer.GetName()), notifications.EscapeMarkdown(n.Team.Name))).
|
||||
|
|
@ -385,23 +425,23 @@ func (n *UserMentionedInTaskNotification) SubjectID() int64 {
|
|||
return n.Task.ID
|
||||
}
|
||||
|
||||
// ToTitle returns the translated one-line title for UserMentionedInTaskNotification
|
||||
func (n *UserMentionedInTaskNotification) ToTitle(lang string) string {
|
||||
if n.IsNew {
|
||||
return i18n.T(lang, "notifications.task.mentioned.subject_new", n.Doer.GetName(), n.Task.Title, n.Task.GetFullIdentifier())
|
||||
}
|
||||
return i18n.T(lang, "notifications.task.mentioned.subject", n.Doer.GetName(), n.Task.Title, n.Task.GetFullIdentifier())
|
||||
}
|
||||
|
||||
// ToMail returns the mail notification for UserMentionedInTaskNotification
|
||||
func (n *UserMentionedInTaskNotification) ToMail(lang string) *notifications.Mail {
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
formattedDescription := formatMentionsForEmail(s, n.Task.Description)
|
||||
|
||||
var subject string
|
||||
if n.IsNew {
|
||||
subject = i18n.T(lang, "notifications.task.mentioned.subject_new", n.Doer.GetName(), n.Task.Title, n.Task.GetFullIdentifier())
|
||||
} else {
|
||||
subject = i18n.T(lang, "notifications.task.mentioned.subject", n.Doer.GetName(), n.Task.Title, n.Task.GetFullIdentifier())
|
||||
}
|
||||
|
||||
mail := notifications.NewMail().
|
||||
Conversational().
|
||||
From(n.Doer.GetNameAndFromEmail()).
|
||||
Subject(subject)
|
||||
From(n.Doer.GetNameAndFromEmail())
|
||||
|
||||
// Add header line
|
||||
action := i18n.T(lang, "notifications.common.actions.mentioned_you", n.Doer.GetName())
|
||||
|
|
|
|||
|
|
@ -142,6 +142,90 @@ func TestUndoneTasksOverdueNotification_TitleIsMarkdownEscaped(t *testing.T) {
|
|||
"malicious title must render as literal text")
|
||||
}
|
||||
|
||||
func TestTaskCommentNotification_ToTitle(t *testing.T) {
|
||||
doer := &user.User{ID: 1, Name: "alice", Username: "alice"}
|
||||
task := &Task{ID: 42, Title: "Take out trash", Index: 7}
|
||||
|
||||
t.Run("regular comment", func(t *testing.T) {
|
||||
n := &TaskCommentNotification{Doer: doer, Task: task, Mentioned: false}
|
||||
title := n.ToTitle("en")
|
||||
assert.Contains(t, title, "Take out trash")
|
||||
assert.NotContains(t, title, "alice", "regular comment title should not mention the doer")
|
||||
})
|
||||
|
||||
t.Run("mention switches title", func(t *testing.T) {
|
||||
n := &TaskCommentNotification{Doer: doer, Task: task, Mentioned: true}
|
||||
title := n.ToTitle("en")
|
||||
assert.Contains(t, title, "alice", "mentioned title should mention the doer")
|
||||
assert.Contains(t, title, "Take out trash")
|
||||
})
|
||||
|
||||
t.Run("regular and mentioned produce different titles", func(t *testing.T) {
|
||||
regular := (&TaskCommentNotification{Doer: doer, Task: task, Mentioned: false}).ToTitle("en")
|
||||
mentioned := (&TaskCommentNotification{Doer: doer, Task: task, Mentioned: true}).ToTitle("en")
|
||||
assert.NotEqual(t, regular, mentioned)
|
||||
})
|
||||
}
|
||||
|
||||
func TestTaskAssignedNotification_ToTitle(t *testing.T) {
|
||||
doer := &user.User{ID: 1, Name: "alice", Username: "alice"}
|
||||
assignee := &user.User{ID: 2, Name: "bob", Username: "bob"}
|
||||
third := &user.User{ID: 3, Name: "carol", Username: "carol"}
|
||||
task := &Task{ID: 42, Title: "Take out trash", Index: 7}
|
||||
|
||||
t.Run("to assignee themself", func(t *testing.T) {
|
||||
n := &TaskAssignedNotification{Doer: doer, Task: task, Assignee: assignee, Target: assignee}
|
||||
title := n.ToTitle("en")
|
||||
assert.Contains(t, title, "Take out trash")
|
||||
})
|
||||
|
||||
t.Run("doer assigned to themself", func(t *testing.T) {
|
||||
n := &TaskAssignedNotification{Doer: doer, Task: task, Assignee: doer, Target: third}
|
||||
title := n.ToTitle("en")
|
||||
assert.Contains(t, title, "alice")
|
||||
})
|
||||
|
||||
t.Run("doer assigned someone else, target is third party", func(t *testing.T) {
|
||||
n := &TaskAssignedNotification{Doer: doer, Task: task, Assignee: assignee, Target: third}
|
||||
title := n.ToTitle("en")
|
||||
assert.Contains(t, title, "bob")
|
||||
})
|
||||
|
||||
t.Run("three branches produce three distinct titles", func(t *testing.T) {
|
||||
a := (&TaskAssignedNotification{Doer: doer, Task: task, Assignee: assignee, Target: assignee}).ToTitle("en")
|
||||
b := (&TaskAssignedNotification{Doer: doer, Task: task, Assignee: doer, Target: third}).ToTitle("en")
|
||||
c := (&TaskAssignedNotification{Doer: doer, Task: task, Assignee: assignee, Target: third}).ToTitle("en")
|
||||
assert.NotEqual(t, a, b)
|
||||
assert.NotEqual(t, a, c)
|
||||
assert.NotEqual(t, b, c)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUserMentionedInTaskNotification_ToTitle(t *testing.T) {
|
||||
doer := &user.User{ID: 1, Name: "alice", Username: "alice"}
|
||||
task := &Task{ID: 42, Title: "Take out trash", Index: 7}
|
||||
|
||||
t.Run("existing task", func(t *testing.T) {
|
||||
n := &UserMentionedInTaskNotification{Doer: doer, Task: task, IsNew: false}
|
||||
title := n.ToTitle("en")
|
||||
assert.Contains(t, title, "alice")
|
||||
assert.Contains(t, title, "Take out trash")
|
||||
})
|
||||
|
||||
t.Run("new task", func(t *testing.T) {
|
||||
n := &UserMentionedInTaskNotification{Doer: doer, Task: task, IsNew: true}
|
||||
title := n.ToTitle("en")
|
||||
assert.Contains(t, title, "alice")
|
||||
assert.Contains(t, title, "Take out trash")
|
||||
})
|
||||
|
||||
t.Run("new task and existing task produce different titles", func(t *testing.T) {
|
||||
existing := (&UserMentionedInTaskNotification{Doer: doer, Task: task, IsNew: false}).ToTitle("en")
|
||||
newOne := (&UserMentionedInTaskNotification{Doer: doer, Task: task, IsNew: true}).ToTitle("en")
|
||||
assert.NotEqual(t, existing, newOne)
|
||||
})
|
||||
}
|
||||
|
||||
func TestReminderDueNotification_TitleIsMarkdownEscaped(t *testing.T) {
|
||||
originalPublicURL := config.ServicePublicURL.GetString()
|
||||
t.Cleanup(func() { config.ServicePublicURL.Set(originalPublicURL) })
|
||||
|
|
|
|||
|
|
@ -45,6 +45,36 @@ type ThreadID interface {
|
|||
ThreadID() string
|
||||
}
|
||||
|
||||
// Titler is an optional capability for notifications that can render a
|
||||
// one-line, translated title. Used as the mail subject when ToMail does not
|
||||
// set one explicitly, and as the item title in the notifications feed.
|
||||
type Titler interface {
|
||||
ToTitle(lang string) string
|
||||
}
|
||||
|
||||
var registry = map[string]func() Notification{}
|
||||
|
||||
// Register makes a notification type discoverable by name. It should be
|
||||
// called from init() in the package that defines the type. Only notifications
|
||||
// that persist to the database need to register, since only persisted
|
||||
// notifications are re-hydrated from JSON (e.g. by the feed handler).
|
||||
// The name is derived from the notification's own Name() method, so it stays
|
||||
// in one place.
|
||||
func Register(factory func() Notification) {
|
||||
registry[factory().Name()] = factory
|
||||
}
|
||||
|
||||
// Lookup returns a fresh, empty instance of the notification type registered
|
||||
// under the given name. The second return value is false if no type is
|
||||
// registered with that name.
|
||||
func Lookup(name string) (Notification, bool) {
|
||||
f, ok := registry[name]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return f(), true
|
||||
}
|
||||
|
||||
// Notifiable is an entity which can be notified. Usually a user.
|
||||
type Notifiable interface {
|
||||
// RouteForMail should return the email address this notifiable has.
|
||||
|
|
@ -91,6 +121,12 @@ func notifyMail(notifiable Notifiable, notification Notification) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
if mail.subject == "" {
|
||||
if t, is := notification.(Titler); is {
|
||||
mail.subject = t.ToTitle(notifiable.Lang())
|
||||
}
|
||||
}
|
||||
|
||||
to, err := notifiable.RouteForMail()
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -20,7 +20,9 @@ import (
|
|||
"testing"
|
||||
|
||||
"code.vikunja.io/api/pkg/db"
|
||||
"code.vikunja.io/api/pkg/mail"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"xorm.io/xorm"
|
||||
"xorm.io/xorm/schemas"
|
||||
|
|
@ -74,6 +76,68 @@ func (t *testNotifiable) Lang() string {
|
|||
return t.Language
|
||||
}
|
||||
|
||||
// titlerNoSubjectNotification implements Titler and intentionally omits a
|
||||
// Subject from ToMail so the fallback path is exercised.
|
||||
type titlerNoSubjectNotification struct {
|
||||
title string
|
||||
}
|
||||
|
||||
func (n *titlerNoSubjectNotification) ToMail(_ string) *Mail {
|
||||
return NewMail().Line("body")
|
||||
}
|
||||
func (n *titlerNoSubjectNotification) ToDB() interface{} { return nil }
|
||||
func (n *titlerNoSubjectNotification) Name() string { return "test.titler.no.subject" }
|
||||
func (n *titlerNoSubjectNotification) ToTitle(_ string) string { return n.title }
|
||||
|
||||
// titlerWithExplicitSubjectNotification implements Titler but also sets an
|
||||
// explicit subject in ToMail; that explicit subject must win.
|
||||
type titlerWithExplicitSubjectNotification struct {
|
||||
title string
|
||||
subject string
|
||||
}
|
||||
|
||||
func (n *titlerWithExplicitSubjectNotification) ToMail(_ string) *Mail {
|
||||
return NewMail().Subject(n.subject).Line("body")
|
||||
}
|
||||
func (n *titlerWithExplicitSubjectNotification) ToDB() interface{} { return nil }
|
||||
func (n *titlerWithExplicitSubjectNotification) Name() string { return "test.titler.with.subject" }
|
||||
func (n *titlerWithExplicitSubjectNotification) ToTitle(_ string) string { return n.title }
|
||||
|
||||
// noTitlerNotification is the control: no Titler, no Subject, fallback must
|
||||
// leave subject empty without panicking.
|
||||
type noTitlerNotification struct{}
|
||||
|
||||
func (n *noTitlerNotification) ToMail(_ string) *Mail { return NewMail().Line("body") }
|
||||
func (n *noTitlerNotification) ToDB() interface{} { return nil }
|
||||
func (n *noTitlerNotification) Name() string { return "test.no.titler" }
|
||||
|
||||
// titlerRegisteredNotification is used to exercise Register/Lookup.
|
||||
type titlerRegisteredNotification struct {
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
func (n *titlerRegisteredNotification) ToMail(_ string) *Mail { return NewMail().Line("body") }
|
||||
func (n *titlerRegisteredNotification) ToDB() interface{} { return n }
|
||||
func (n *titlerRegisteredNotification) Name() string { return "test.registry.titler" }
|
||||
func (n *titlerRegisteredNotification) ToTitle(_ string) string { return n.Title }
|
||||
|
||||
func TestRegistry(t *testing.T) {
|
||||
Register(func() Notification { return &titlerRegisteredNotification{} })
|
||||
|
||||
t.Run("known name returns fresh instance", func(t *testing.T) {
|
||||
n, ok := Lookup("test.registry.titler")
|
||||
require.True(t, ok)
|
||||
require.NotNil(t, n)
|
||||
_, ok = n.(*titlerRegisteredNotification)
|
||||
assert.True(t, ok)
|
||||
})
|
||||
|
||||
t.Run("unknown name returns false", func(t *testing.T) {
|
||||
_, ok := Lookup("does.not.exist")
|
||||
assert.False(t, ok)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNotify(t *testing.T) {
|
||||
t.Run("normal", func(t *testing.T) {
|
||||
|
||||
|
|
@ -111,6 +175,42 @@ func TestNotify(t *testing.T) {
|
|||
|
||||
db.AssertExists(t, "notifications", vals, true)
|
||||
})
|
||||
t.Run("subject fallback uses ToTitle when ToMail omits Subject", func(t *testing.T) {
|
||||
mail.ResetSent()
|
||||
tnf := &testNotifiable{ShouldSendNotification: true, Language: "en"}
|
||||
|
||||
err := notifyMail(tnf, &titlerNoSubjectNotification{title: "From ToTitle"})
|
||||
require.NoError(t, err)
|
||||
|
||||
sent := mail.LastSent()
|
||||
require.NotNil(t, sent)
|
||||
assert.Equal(t, "From ToTitle", sent.Subject)
|
||||
})
|
||||
|
||||
t.Run("explicit Subject in ToMail wins over ToTitle", func(t *testing.T) {
|
||||
mail.ResetSent()
|
||||
tnf := &testNotifiable{ShouldSendNotification: true, Language: "en"}
|
||||
|
||||
err := notifyMail(tnf, &titlerWithExplicitSubjectNotification{title: "From ToTitle", subject: "Explicit"})
|
||||
require.NoError(t, err)
|
||||
|
||||
sent := mail.LastSent()
|
||||
require.NotNil(t, sent)
|
||||
assert.Equal(t, "Explicit", sent.Subject)
|
||||
})
|
||||
|
||||
t.Run("no fallback when notification does not implement Titler", func(t *testing.T) {
|
||||
mail.ResetSent()
|
||||
tnf := &testNotifiable{ShouldSendNotification: true, Language: "en"}
|
||||
|
||||
err := notifyMail(tnf, &noTitlerNotification{})
|
||||
require.NoError(t, err)
|
||||
|
||||
sent := mail.LastSent()
|
||||
require.NotNil(t, sent)
|
||||
assert.Empty(t, sent.Subject)
|
||||
})
|
||||
|
||||
t.Run("disabled notifiable", func(t *testing.T) {
|
||||
|
||||
s := db.NewSession()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
// 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 License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package feeds
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"code.vikunja.io/api/pkg/db"
|
||||
"code.vikunja.io/api/pkg/log"
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
"xorm.io/xorm"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
func checkAPIToken(s *xorm.Session, username, token string) (*user.User, error) {
|
||||
apiToken, u, err := models.ValidateTokenAndGetOwner(s, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if apiToken == nil || u == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if !apiToken.HasFeedsAccess() {
|
||||
log.Debugf("[feeds auth] API token %d does not have feeds access permission", apiToken.ID)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if u.Username != username {
|
||||
log.Debugf("[feeds auth] API token %d owner %s does not match provided username %s", apiToken.ID, u.Username, username)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// BasicAuth authenticates feed requests. Only API tokens are accepted —
|
||||
// password and LDAP credentials are rejected outright because feed URLs are
|
||||
// commonly exported, shared, or cached by feed readers.
|
||||
func BasicAuth(c *echo.Context, username, password string) (bool, error) {
|
||||
if !strings.HasPrefix(password, models.APITokenPrefix) {
|
||||
return false, nil
|
||||
}
|
||||
// GetTokenFromTokenString slices password[len-8:] without a length check,
|
||||
// so a stray "tk_" or other short prefix-only string would panic before
|
||||
// the credentials could be rejected. Real tokens are far longer than
|
||||
// prefix+8, so anything shorter is invalid by construction.
|
||||
if len(password) < len(models.APITokenPrefix)+8 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
u, err := checkAPIToken(s, username, password)
|
||||
if err != nil {
|
||||
log.Errorf("Error during API token auth for feeds: %v", err)
|
||||
return false, nil
|
||||
}
|
||||
if u == nil {
|
||||
return false, nil
|
||||
}
|
||||
if u.IsBot() {
|
||||
log.Warningf("Feed auth rejected for bot user %d", u.ID)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
c.Set("userBasicAuth", u)
|
||||
return true, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
// 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 License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package feeds
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"code.vikunja.io/api/pkg/db"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
feedsTokenUser13Valid = "tk_feeds_access_token_user_0013_feed0013" // owner_id 13, feeds scope
|
||||
caldavOnlyToken = "tk_caldav_api_token_test_00000000aabbccdd" // owner_id 15, caldav scope only
|
||||
expiredTasksToken = "tk_a5e6f92ddbad68f49ee2c63e52174db0235008c8" // expired
|
||||
tasksScopedTokenOwner1 = "tk_2eef46f40ebab3304919ab2e7e39993f75f29d2e" // owner_id 1, no feeds scope
|
||||
)
|
||||
|
||||
func newContext() *echo.Context {
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/feeds/notifications.atom", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
return e.NewContext(req, rec)
|
||||
}
|
||||
|
||||
func TestBasicAuth(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
|
||||
t.Run("rejects non-token password without touching db", func(t *testing.T) {
|
||||
c := newContext()
|
||||
ok, err := BasicAuth(c, "user1", "plaintextpassword")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, ok)
|
||||
assert.Nil(t, c.Get("userBasicAuth"))
|
||||
})
|
||||
|
||||
t.Run("rejects unknown token", func(t *testing.T) {
|
||||
c := newContext()
|
||||
ok, err := BasicAuth(c, "user1", "tk_nonexistent_token_value_aaaaaaaaaaaaaaaa")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, ok)
|
||||
})
|
||||
|
||||
t.Run("rejects token shorter than prefix+8 without panicking", func(t *testing.T) {
|
||||
// Real tokens are far longer; anything shorter is invalid by
|
||||
// construction and would otherwise panic inside GetTokenFromTokenString.
|
||||
for _, short := range []string{"tk_", "tk_abc", "tk_1234567"} {
|
||||
c := newContext()
|
||||
ok, err := BasicAuth(c, "user1", short)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, ok, "short token %q must be rejected", short)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects token whose owner username does not match", func(t *testing.T) {
|
||||
c := newContext()
|
||||
// feedsTokenUser13Valid belongs to user 13; supply a different username.
|
||||
ok, err := BasicAuth(c, "wrongname", feedsTokenUser13Valid)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, ok)
|
||||
})
|
||||
|
||||
t.Run("rejects token without feeds scope", func(t *testing.T) {
|
||||
c := newContext()
|
||||
ok, err := BasicAuth(c, "user1", tasksScopedTokenOwner1)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, ok)
|
||||
})
|
||||
|
||||
t.Run("rejects token with caldav scope but no feeds scope", func(t *testing.T) {
|
||||
c := newContext()
|
||||
ok, err := BasicAuth(c, "user15", caldavOnlyToken)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, ok)
|
||||
})
|
||||
|
||||
t.Run("rejects expired token", func(t *testing.T) {
|
||||
c := newContext()
|
||||
ok, err := BasicAuth(c, "user1", expiredTasksToken)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, ok)
|
||||
})
|
||||
|
||||
t.Run("accepts valid token with feeds scope", func(t *testing.T) {
|
||||
c := newContext()
|
||||
ok, err := BasicAuth(c, "user13", feedsTokenUser13Valid)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, ok)
|
||||
u, is := c.Get("userBasicAuth").(*user.User)
|
||||
require.True(t, is)
|
||||
assert.Equal(t, int64(13), u.ID)
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
// 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 License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package feeds
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"code.vikunja.io/api/pkg/config"
|
||||
"code.vikunja.io/api/pkg/db"
|
||||
"code.vikunja.io/api/pkg/i18n"
|
||||
"code.vikunja.io/api/pkg/notifications"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
|
||||
"github.com/gorilla/feeds"
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
const feedItemLimit = 50
|
||||
|
||||
// NotificationsAtomFeed serves the authenticated user's notifications as an
|
||||
// Atom feed. Notifications are not marked as read by being fetched here.
|
||||
func NotificationsAtomFeed(c *echo.Context) error {
|
||||
u, ok := c.Get("userBasicAuth").(*user.User)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized))
|
||||
}
|
||||
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
rows, _, _, err := notifications.GetNotificationsForUser(s, u.ID, feedItemLimit, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
publicURL := config.ServicePublicURL.GetString()
|
||||
feed := &feeds.Feed{
|
||||
Title: i18n.T(u.Language, "feeds.notifications.title", u.GetName()),
|
||||
Link: &feeds.Link{Href: publicURL + "feeds/notifications.atom"},
|
||||
Author: &feeds.Author{Name: u.GetName()},
|
||||
Updated: time.Now(),
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
typed, ok := notifications.Lookup(row.Name)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(row.Notification)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if err := json.Unmarshal(raw, typed); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
titler, ok := typed.(notifications.Titler)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
feed.Items = append(feed.Items, &feeds.Item{
|
||||
Id: "vikunja-notification-" + strconv.FormatInt(row.ID, 10),
|
||||
Title: titler.ToTitle(u.Language),
|
||||
Created: row.Created,
|
||||
Link: &feeds.Link{Href: publicURL},
|
||||
})
|
||||
}
|
||||
|
||||
atom, err := feed.ToAtom()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentType, "application/atom+xml; charset=utf-8")
|
||||
return c.String(http.StatusOK, atom)
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
// 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 License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package feeds
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.vikunja.io/api/pkg/db"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNotificationsAtomFeed(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
|
||||
t.Run("returns valid atom XML for authenticated user with no notifications", func(t *testing.T) {
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/feeds/notifications.atom", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
c.Set("userBasicAuth", &user.User{ID: 1, Name: "User 1", Username: "user1", Language: "en"})
|
||||
|
||||
err := NotificationsAtomFeed(c)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.True(t, strings.HasPrefix(rec.Header().Get(echo.HeaderContentType), "application/atom+xml"),
|
||||
"unexpected content type: %s", rec.Header().Get(echo.HeaderContentType))
|
||||
|
||||
// Must be parseable as XML.
|
||||
var doc struct {
|
||||
XMLName xml.Name `xml:"feed"`
|
||||
Title string `xml:"title"`
|
||||
}
|
||||
require.NoError(t, xml.Unmarshal(rec.Body.Bytes(), &doc))
|
||||
assert.Contains(t, doc.Title, "User 1", "feed title should include the user's name")
|
||||
})
|
||||
|
||||
t.Run("returns 401 when context has no authenticated user", func(t *testing.T) {
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/feeds/notifications.atom", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
err := NotificationsAtomFeed(c)
|
||||
require.Error(t, err)
|
||||
httpErr, ok := err.(*echo.HTTPError)
|
||||
require.True(t, ok, "expected echo.HTTPError, got %T", err)
|
||||
assert.Equal(t, http.StatusUnauthorized, httpErr.Code)
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
// 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 License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package feeds
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"code.vikunja.io/api/pkg/config"
|
||||
"code.vikunja.io/api/pkg/events"
|
||||
"code.vikunja.io/api/pkg/files"
|
||||
"code.vikunja.io/api/pkg/i18n"
|
||||
"code.vikunja.io/api/pkg/log"
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
log.InitLogger()
|
||||
config.InitDefaultConfig()
|
||||
i18n.Init()
|
||||
files.InitTests()
|
||||
user.InitTests()
|
||||
models.SetupTests()
|
||||
events.Fake()
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
|
@ -81,6 +81,7 @@ import (
|
|||
apiv1 "code.vikunja.io/api/pkg/routes/api/v1"
|
||||
adminapi "code.vikunja.io/api/pkg/routes/api/v1/admin"
|
||||
"code.vikunja.io/api/pkg/routes/caldav"
|
||||
"code.vikunja.io/api/pkg/routes/feeds"
|
||||
"code.vikunja.io/api/pkg/version"
|
||||
"code.vikunja.io/api/pkg/web/handler"
|
||||
ws "code.vikunja.io/api/pkg/websocket"
|
||||
|
|
@ -259,6 +260,11 @@ func RegisterRoutes(e *echo.Echo) {
|
|||
registerCalDavRoutes(c)
|
||||
}
|
||||
|
||||
// Feeds routes (Atom feed for user notifications)
|
||||
f := e.Group("/feeds")
|
||||
f.Use(middleware.BasicAuth(feeds.BasicAuth))
|
||||
f.GET("/notifications.atom", feeds.NotificationsAtomFeed)
|
||||
|
||||
// healthcheck
|
||||
e.GET("/health", HealthcheckHandler)
|
||||
|
||||
|
|
@ -282,8 +288,10 @@ func RegisterRoutes(e *echo.Echo) {
|
|||
// Since it is not possible to register this middleware just for the api group,
|
||||
// we just disable it when for caldav requests.
|
||||
// Caldav requires OPTIONS requests to be answered in a specific manner,
|
||||
// not doing this would break the caldav implementation
|
||||
return strings.HasPrefix(context.Path(), "/dav")
|
||||
// not doing this would break the caldav implementation.
|
||||
// Feed readers are server-side and don't need CORS either.
|
||||
p := context.Path()
|
||||
return strings.HasPrefix(p, "/dav") || strings.HasPrefix(p, "/feeds")
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue