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") }, })) }