Files
vikunja/pkg/notifications/notification.go
T
kolaenteandkolaente cd9033184b fix(notifications): require a persisted notification to declare its project
The capability interface carrying a notification's project was optional, and
ProjectIDOf defaulted a missing implementation to 0 — which means
account-scoped, which means always visible. So a new project-scoped type whose
author forgot the method would have leaked task titles, project names and
comment bodies to users with no access to the project, with no compile error
and no test failure.

Register now takes a factory returning PersistedNotification, which requires
the method. Registering is what makes a notification persist, so a stored row
that cannot be permission-checked no longer compiles. The three account-scoped
types say so by returning 0 explicitly instead of by omission.
2026-07-29 07:58:17 +00:00

228 lines
6.7 KiB
Go

// 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 notifications
import (
"encoding/json"
"slices"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/log"
"xorm.io/xorm"
)
// Notification is a notification which can be sent via mail or db.
type Notification interface {
ToMail(lang string) *Mail
ToDB() interface{}
Name() string
}
type SubjectID interface {
SubjectID() int64
}
type NotificationWithSubject interface {
Notification
SubjectID
}
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
}
// ProjectID reports the project a notification is about, 0 if it is
// account-scoped, ProjectIDUnresolved if it is about a project it cannot name.
type ProjectID interface {
ProjectID() int64
}
// PersistedNotification is stored and read back, so it must declare its project.
type PersistedNotification interface {
Notification
ProjectID
}
// ProjectIDUnresolved marks a project-scoped notification whose project could
// not be determined. 0 would make the row account-scoped and hand its payload
// back unchecked.
const ProjectIDUnresolved int64 = -1
// ProjectIDOf returns the project a notification is about. Unregistered types
// need not implement ProjectID and count as account-scoped.
func ProjectIDOf(n Notification) int64 {
if p, is := n.(ProjectID); is {
return p.ProjectID()
}
return 0
}
var registry = map[string]func() PersistedNotification{}
// 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() PersistedNotification) {
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
}
// RegisteredNames returns every registered notification name, sorted.
func RegisteredNames() []string {
names := make([]string, 0, len(registry))
for name := range registry {
names = append(names, name)
}
slices.Sort(names)
return names
}
// Notifiable is an entity which can be notified. Usually a user.
type Notifiable interface {
// RouteForMail should return the email address this notifiable has.
RouteForMail() (string, error)
// RouteForDB should return the id of the notifiable entity to save it in the database.
RouteForDB() int64
// ShouldNotify provides a last-minute way to cancel a notification. It will be called immediately before
// sending a notification. An optional session can be passed to reuse an existing transaction.
ShouldNotify(sessions ...*xorm.Session) (should bool, err error)
// Lang provides the language which should be used for translations in the mail.
Lang() string
}
// Notify notifies a notifiable of a notification.
// An optional xorm session can be passed to reuse an existing transaction for the DB notification.
// For persisted notifications the mail is queued from DatabaseNotification.AfterInsert,
// i.e. only once the row is committed, so a rolled-back transaction (and its
// event-handler retry) cannot duplicate mails (#2971).
func Notify(notifiable Notifiable, notification Notification, sessions ...*xorm.Session) (err error) {
if isUnderTest {
sentTestNotifications = append(sentTestNotifications, notification)
return nil
}
should, err := notifiable.ShouldNotify(sessions...)
if err != nil || !should {
log.Debugf("Not notifying user %d because they are disabled", notifiable.RouteForDB())
return err
}
var s *xorm.Session
if len(sessions) > 0 && sessions[0] != nil {
s = sessions[0]
}
mailDeferred, err := notifyDB(notifiable, notification, s)
if err != nil || mailDeferred {
return err
}
return notifyMail(notifiable, notification)
}
func notifyMail(notifiable Notifiable, notification Notification) error {
mail := notification.ToMail(notifiable.Lang())
if mail == nil {
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
}
mail.To(to)
if threadID, is := notification.(ThreadID); is {
mail.ThreadID(threadID.ThreadID())
}
return SendMail(mail, notifiable.Lang())
}
// notifyDB inserts the notification row if the notification has a DB
// representation. mailDeferred reports that the mail will be queued from
// AfterInsert once the (possibly caller-owned) transaction commits, so the
// caller must not send it.
func notifyDB(notifiable Notifiable, notification Notification, existingSession *xorm.Session) (mailDeferred bool, err error) {
dbContent := notification.ToDB()
if dbContent == nil {
return false, nil
}
content, err := json.Marshal(dbContent)
if err != nil {
return false, err
}
dbNotification := &DatabaseNotification{
NotifiableID: notifiable.RouteForDB(),
Notification: json.RawMessage(content),
Name: notification.Name(),
notification: notification,
notifiable: notifiable,
}
if subject, is := notification.(SubjectID); is {
dbNotification.SubjectID = subject.SubjectID()
}
dbNotification.ProjectID = ProjectIDOf(notification)
if existingSession != nil {
_, err = existingSession.Insert(dbNotification)
return err == nil, err
}
s := db.NewSession()
defer s.Close()
_, err = s.Insert(dbNotification)
if err != nil {
_ = s.Rollback()
return false, err
}
return true, s.Commit()
}