diff --git a/go.mod b/go.mod
index f759db0c5..a0fd3335b 100644
--- a/go.mod
+++ b/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
diff --git a/go.sum b/go.sum
index fe3bdbab8..a917d62a4 100644
--- a/go.sum
+++ b/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=
diff --git a/pkg/db/fixtures/api_tokens.yml b/pkg/db/fixtures/api_tokens.yml
index 2ff417bff..46a97e7f5 100644
--- a/pkg/db/fixtures/api_tokens.yml
+++ b/pkg/db/fixtures/api_tokens.yml
@@ -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
diff --git a/pkg/i18n/lang/en.json b/pkg/i18n/lang/en.json
index e8b05efb4..b9040d628 100644
--- a/pkg/i18n/lang/en.json
+++ b/pkg/i18n/lang/en.json
@@ -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"
+ }
}
}
diff --git a/pkg/mail/testing.go b/pkg/mail/testing.go
index 02a047586..b7e3f4bc1 100644
--- a/pkg/mail/testing.go
+++ b/pkg/mail/testing.go
@@ -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
+}
diff --git a/pkg/models/api_routes.go b/pkg/models/api_routes.go
index 0f8fbcbeb..607274092 100644
--- a/pkg/models/api_routes.go
+++ b/pkg/models/api_routes.go
@@ -35,6 +35,12 @@ func init() {
Method: "ANY",
},
}
+ apiTokenRoutes["feeds"] = APITokenRoute{
+ "access": &RouteDetail{
+ Path: "/feeds/*",
+ Method: "GET",
+ },
+ }
}
type APITokenRoute map[string]*RouteDetail
diff --git a/pkg/models/api_tokens.go b/pkg/models/api_tokens.go
index 3664f1b3a..8bbe48550 100644
--- a/pkg/models/api_tokens.go
+++ b/pkg/models/api_tokens.go
@@ -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:]
diff --git a/pkg/models/api_tokens_expiry_notification.go b/pkg/models/api_tokens_expiry_notification.go
index 8a84d90eb..f99ef435f 100644
--- a/pkg/models/api_tokens_expiry_notification.go
+++ b/pkg/models/api_tokens_expiry_notification.go
@@ -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").
diff --git a/pkg/models/api_tokens_test.go b/pkg/models/api_tokens_test.go
index 6677c09fa..261de26bd 100644
--- a/pkg/models/api_tokens_test.go
+++ b/pkg/models/api_tokens_test.go
@@ -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()
diff --git a/pkg/models/notifications.go b/pkg/models/notifications.go
index 3b1e4680a..4f27d04f4 100644
--- a/pkg/models/notifications.go
+++ b/pkg/models/notifications.go
@@ -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())
diff --git a/pkg/models/notifications_test.go b/pkg/models/notifications_test.go
index 9e777d896..1aac48239 100644
--- a/pkg/models/notifications_test.go
+++ b/pkg/models/notifications_test.go
@@ -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) })
diff --git a/pkg/notifications/notification.go b/pkg/notifications/notification.go
index d50735793..cdb795dfb 100644
--- a/pkg/notifications/notification.go
+++ b/pkg/notifications/notification.go
@@ -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
diff --git a/pkg/notifications/notification_test.go b/pkg/notifications/notification_test.go
index f15eb350b..54b0be9fe 100644
--- a/pkg/notifications/notification_test.go
+++ b/pkg/notifications/notification_test.go
@@ -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()
diff --git a/pkg/routes/feeds/auth.go b/pkg/routes/feeds/auth.go
new file mode 100644
index 000000000..419fd2ccd
--- /dev/null
+++ b/pkg/routes/feeds/auth.go
@@ -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 .
+
+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
+}
diff --git a/pkg/routes/feeds/auth_test.go b/pkg/routes/feeds/auth_test.go
new file mode 100644
index 000000000..5a8359fef
--- /dev/null
+++ b/pkg/routes/feeds/auth_test.go
@@ -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 .
+
+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)
+ })
+}
diff --git a/pkg/routes/feeds/handler.go b/pkg/routes/feeds/handler.go
new file mode 100644
index 000000000..9d0794c76
--- /dev/null
+++ b/pkg/routes/feeds/handler.go
@@ -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 .
+
+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)
+}
diff --git a/pkg/routes/feeds/handler_test.go b/pkg/routes/feeds/handler_test.go
new file mode 100644
index 000000000..dfc984d68
--- /dev/null
+++ b/pkg/routes/feeds/handler_test.go
@@ -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 .
+
+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)
+ })
+}
diff --git a/pkg/routes/feeds/main_test.go b/pkg/routes/feeds/main_test.go
new file mode 100644
index 000000000..6f58af476
--- /dev/null
+++ b/pkg/routes/feeds/main_test.go
@@ -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 .
+
+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())
+}
diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go
index e37927e5f..c4ce0a002 100644
--- a/pkg/routes/routes.go
+++ b/pkg/routes/routes.go
@@ -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")
},
}))
}