From a75b226264b22158b6878510e405990054dea3c1 Mon Sep 17 00:00:00 2001 From: carlosalbertorg Date: Wed, 30 Sep 2026 11:50:21 -0300 Subject: [PATCH] feat: configurable per-application message retention period Applications now have a RetentionSeconds field (0 = use the server's default). A new GOTIFY_MESSAGERETENTIONSECONDS env var sets that server-wide default for applications that don't override it. A background goroutine prunes expired messages every minute. This follows the spec from #376: "Each application should get a separate setting for the retention period. The pruning should be done in a separate goroutine that executes an sql query every minute to prune messages outside of the retention period. The default retention period should be unlimited." Closes #376 --- api/application.go | 21 ++++--- api/application_test.go | 27 ++++++++- config/config.go | 8 ++- config/keys.go | 1 + database/message.go | 39 +++++++++++++ database/message_test.go | 58 +++++++++++++++++++ docs/spec.json | 14 +++++ gotify-server.env.example | 8 +++ model/application.go | 6 ++ model/message.go | 6 +- router/router.go | 8 +++ ui/src/application/AddApplicationDialog.tsx | 18 +++++- ui/src/application/AppStore.ts | 6 +- ui/src/application/Applications.tsx | 15 ++++- .../application/UpdateApplicationDialog.tsx | 20 ++++++- ui/src/tests/application.test.ts | 11 ++-- ui/src/types.ts | 1 + 17 files changed, 242 insertions(+), 25 deletions(-) diff --git a/api/application.go b/api/application.go index 612c20c60..d823c8d5c 100644 --- a/api/application.go +++ b/api/application.go @@ -54,6 +54,11 @@ type ApplicationParams struct { // // example: a1 SortKey string `form:"sortKey" query:"sortKey" json:"sortKey"` + // The number of seconds after which messages from this application are + // automatically deleted. 0 means the server's default retention period is used. + // + // example: 2592000 + RetentionSeconds uint `form:"retentionSeconds" query:"retentionSeconds" json:"retentionSeconds"` } // CreateApplication creates an application and returns the access token. @@ -94,13 +99,14 @@ func (a *ApplicationAPI) CreateApplication(ctx *gin.Context) { if err := ctx.Bind(&applicationParams); err == nil { tokenPublic, tokenPrivate := generateApplicationToken() app := model.Application{ - Name: applicationParams.Name, - Description: applicationParams.Description, - DefaultPriority: applicationParams.DefaultPriority, - SortKey: applicationParams.SortKey, - Token: tokenPublic, - UserID: auth.GetUserID(ctx), - Internal: false, + Name: applicationParams.Name, + Description: applicationParams.Description, + DefaultPriority: applicationParams.DefaultPriority, + SortKey: applicationParams.SortKey, + RetentionSeconds: applicationParams.RetentionSeconds, + Token: tokenPublic, + UserID: auth.GetUserID(ctx), + Internal: false, } if err := a.DB.CreateApplication(&app); err != nil { @@ -264,6 +270,7 @@ func (a *ApplicationAPI) UpdateApplication(ctx *gin.Context) { app.Description = applicationParams.Description app.Name = applicationParams.Name app.DefaultPriority = applicationParams.DefaultPriority + app.RetentionSeconds = applicationParams.RetentionSeconds if applicationParams.SortKey != "" { app.SortKey = applicationParams.SortKey } diff --git a/api/application_test.go b/api/application_test.go index 623ed8b50..0a6c931c4 100644 --- a/api/application_test.go +++ b/api/application_test.go @@ -87,7 +87,7 @@ func (s *ApplicationSuite) Test_ensureApplicationHasCorrectJsonRepresentation() SortKey: "a1", CreatedAt: testdb.Now, } - test.JSONEquals(s.T(), actual, `{"id":1,"token":"Aasdasfgeeg","name":"myapp","description":"mydesc", "image": "asd", "internal":true, "defaultPriority":0, "createdAt":"2020-01-01T00:00:00Z", "lastUsed":null, "sortKey":"a1"}`) + test.JSONEquals(s.T(), actual, `{"id":1,"token":"Aasdasfgeeg","name":"myapp","description":"mydesc", "image": "asd", "internal":true, "defaultPriority":0, "retentionSeconds":0, "createdAt":"2020-01-01T00:00:00Z", "lastUsed":null, "sortKey":"a1"}`) } func (s *ApplicationSuite) Test_CreateApplication_expectBadRequestOnEmptyName() { @@ -641,6 +641,31 @@ func (s *ApplicationSuite) Test_UpdateApplicationDefaultPriority_expectSuccess() } } +func (s *ApplicationSuite) Test_UpdateApplicationRetentionSeconds_expectSuccess() { + s.db.User(5).NewAppWithToken(2, "app-2") + + test.WithUser(s.ctx, 5) + s.withFormData("name=name&description=&retentionSeconds=3600") + s.ctx.Params = gin.Params{{Key: "id", Value: "2"}} + s.a.UpdateApplication(s.ctx) + + expected := &model.Application{ + ID: 2, + Token: "app-2", + UserID: 5, + Name: "name", + Description: "", + SortKey: "a0", + RetentionSeconds: 3600, + CreatedAt: testdb.Now, + } + + assert.Equal(s.T(), 200, s.recorder.Code) + if app, err := s.db.GetApplicationByID(2); assert.NoError(s.T(), err) { + assert.Equal(s.T(), expected, app) + } +} + func (s *ApplicationSuite) Test_UpdateApplication_preservesImageAndSortKey() { app := s.db.User(5).NewAppWithToken(2, "app-2") app.Image = "existing.png" diff --git a/config/config.go b/config/config.go index ffa7c16b0..a81ad56e9 100644 --- a/config/config.go +++ b/config/config.go @@ -87,7 +87,11 @@ type Configuration struct { Registration bool LocalAuthEnabled bool OIDC OIDC - NoColor string + // MessageRetentionSeconds is the default number of seconds after which + // messages are automatically deleted, for applications that don't set + // their own RetentionSeconds. 0 means messages are kept forever. + MessageRetentionSeconds int + NoColor string } // Get returns the configuration extracted from env variables. @@ -195,6 +199,8 @@ func Get() (*Configuration, []FutureLog) { add(parseBool(&c.OIDC.AutoRedirect, EnvOIDCAutoRedirect)) add(parseList(&c.OIDC.Prompt, EnvOIDCPrompt)) + add(parseInt(&c.MessageRetentionSeconds, EnvMessageRetentionSeconds)) + add(parseString(&c.NoColor, EnvNoColor)) addTrailingSlashToPaths(c) diff --git a/config/keys.go b/config/keys.go index e56919fc3..4874ed253 100644 --- a/config/keys.go +++ b/config/keys.go @@ -48,5 +48,6 @@ const ( EnvOIDCIDPName = "GOTIFY_OIDC_IDP_NAME" EnvOIDCAutoRedirect = "GOTIFY_OIDC_AUTO_REDIRECT" EnvOIDCPrompt = "GOTIFY_OIDC_PROMPT" + EnvMessageRetentionSeconds = "GOTIFY_MESSAGERETENTIONSECONDS" EnvNoColor = "NOCOLOR" ) diff --git a/database/message.go b/database/message.go index 405f517ad..77f80302d 100644 --- a/database/message.go +++ b/database/message.go @@ -1,6 +1,8 @@ package database import ( + "time" + "github.com/gotify/server/v3/model" "gorm.io/gorm" ) @@ -93,3 +95,40 @@ func (d *GormDatabase) DeleteMessagesByUser(userID uint) error { } return nil } + +// PruneMessages deletes messages older than their application's retention +// period. Applications with RetentionSeconds == 0 fall back to +// globalDefaultRetentionSeconds; if that is also 0, those messages are kept +// forever. Returns the number of deleted messages. +func (d *GormDatabase) PruneMessages(now time.Time, globalDefaultRetentionSeconds int) (int64, error) { + var apps []*model.Application + if err := d.DB.Select("id", "retention_seconds").Find(&apps).Error; err != nil { + return 0, err + } + + var deleted int64 + var defaultRetentionAppIDs []uint + for _, app := range apps { + if app.RetentionSeconds == 0 { + defaultRetentionAppIDs = append(defaultRetentionAppIDs, app.ID) + continue + } + cutoff := now.Add(-time.Duration(app.RetentionSeconds) * time.Second) + result := d.DB.Where("application_id = ? AND date <= ?", app.ID, cutoff).Delete(&model.Message{}) + if result.Error != nil { + return deleted, result.Error + } + deleted += result.RowsAffected + } + + if globalDefaultRetentionSeconds > 0 && len(defaultRetentionAppIDs) > 0 { + cutoff := now.Add(-time.Duration(globalDefaultRetentionSeconds) * time.Second) + result := d.DB.Where("application_id IN ? AND date <= ?", defaultRetentionAppIDs, cutoff).Delete(&model.Message{}) + if result.Error != nil { + return deleted, result.Error + } + deleted += result.RowsAffected + } + + return deleted, nil +} diff --git a/database/message_test.go b/database/message_test.go index 4c57109cc..a9241c2bd 100644 --- a/database/message_test.go +++ b/database/message_test.go @@ -223,6 +223,64 @@ func (s *DatabaseSuite) TestGetMessagesSince() { hasIDInclusiveBetween(s.T(), actual, 100, 2, 2) } +func (s *DatabaseSuite) TestPruneMessages() { + user := &model.User{Name: "test", Pass: []byte{1}} + require.NoError(s.T(), s.db.CreateUser(user)) + + refTime := time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC) + hour := time.Hour + + // appWithOverride has its own 1h retention period. + appWithOverride := &model.Application{UserID: user.ID, Token: "A0000000000", Name: "override", RetentionSeconds: 3600} + require.NoError(s.T(), s.db.CreateApplication(appWithOverride)) + + // appWithoutOverride relies on the server's global default. + appWithoutOverride := &model.Application{UserID: user.ID, Token: "A0000000001", Name: "default"} + require.NoError(s.T(), s.db.CreateApplication(appWithoutOverride)) + + old := &model.Message{ApplicationID: appWithOverride.ID, Message: "old", Date: refTime.Add(-2 * hour)} + require.NoError(s.T(), s.db.CreateMessage(old)) + recent := &model.Message{ApplicationID: appWithOverride.ID, Message: "recent", Date: refTime.Add(-30 * time.Minute)} + require.NoError(s.T(), s.db.CreateMessage(recent)) + atCutoff := &model.Message{ApplicationID: appWithOverride.ID, Message: "at cutoff", Date: refTime.Add(-hour)} + require.NoError(s.T(), s.db.CreateMessage(atCutoff)) + + defaultOld := &model.Message{ApplicationID: appWithoutOverride.ID, Message: "default old", Date: refTime.Add(-2 * hour)} + require.NoError(s.T(), s.db.CreateMessage(defaultOld)) + defaultRecent := &model.Message{ApplicationID: appWithoutOverride.ID, Message: "default recent", Date: refTime.Add(-30 * time.Minute)} + require.NoError(s.T(), s.db.CreateMessage(defaultRecent)) + + deleted, err := s.db.PruneMessages(refTime, 3600) + require.NoError(s.T(), err) + // appWithOverride: "old" and "at cutoff" (<=1h) are pruned, "recent" stays. + // appWithoutOverride: "default old" is pruned via the global default, "default recent" stays. + assert.Equal(s.T(), int64(3), deleted) + + msgs, err := s.db.GetMessagesByApplication(appWithOverride.ID) + require.NoError(s.T(), err) + assert.Len(s.T(), msgs, 1) + assertEquals(s.T(), msgs[0], recent) + + msgs, err = s.db.GetMessagesByApplication(appWithoutOverride.ID) + require.NoError(s.T(), err) + assert.Len(s.T(), msgs, 1) + assertEquals(s.T(), msgs[0], defaultRecent) + + // A fresh application with no override, pruned with a global default of 0, keeps its messages forever. + appUnlimited := &model.Application{UserID: user.ID, Token: "A0000000002", Name: "unlimited"} + require.NoError(s.T(), s.db.CreateApplication(appUnlimited)) + veryOld := &model.Message{ApplicationID: appUnlimited.ID, Message: "very old", Date: refTime.Add(-24 * 365 * hour)} + require.NoError(s.T(), s.db.CreateMessage(veryOld)) + + deleted, err = s.db.PruneMessages(refTime, 0) + require.NoError(s.T(), err) + assert.Equal(s.T(), int64(0), deleted) + + msgs, err = s.db.GetMessagesByApplication(appUnlimited.ID) + require.NoError(s.T(), err) + assert.Len(s.T(), msgs, 1, "messages are kept forever when neither the app nor the global default set a retention period") +} + func hasIDInclusiveBetween(t *testing.T, msgs []*model.Message, from, to, decrement int) { index := 0 for expectedID := from; expectedID >= to; expectedID -= decrement { diff --git a/docs/spec.json b/docs/spec.json index 1aa938fe4..99bab9ed5 100644 --- a/docs/spec.json +++ b/docs/spec.json @@ -2604,6 +2604,13 @@ "x-go-name": "Name", "example": "Backup Server" }, + "retentionSeconds": { + "description": "The number of seconds after which messages from this application are automatically\ndeleted. 0 means the server's default retention period is used.", + "type": "integer", + "format": "int64", + "x-go-name": "RetentionSeconds", + "example": 2592000 + }, "sortKey": { "description": "The sort key of this application. Uses fractional indexing.", "type": "string", @@ -2647,6 +2654,13 @@ "x-go-name": "Name", "example": "Backup Server" }, + "retentionSeconds": { + "description": "The number of seconds after which messages from this application are\nautomatically deleted. 0 means the server's default retention period is used.", + "type": "integer", + "format": "int64", + "x-go-name": "RetentionSeconds", + "example": 2592000 + }, "sortKey": { "description": "The sortKey for the application. Uses fractional indexing.", "type": "string", diff --git a/gotify-server.env.example b/gotify-server.env.example index 3912d1698..5d3ea5242 100644 --- a/gotify-server.env.example +++ b/gotify-server.env.example @@ -327,6 +327,14 @@ # Type: boolean # GOTIFY_REGISTRATION=false +# Default number of seconds after which messages are automatically deleted. +# Applies only to applications that don't set their own retention period. +# 0 means messages are kept forever. +# +# Type: number +# Example: 2592000 +# GOTIFY_MESSAGERETENTIONSECONDS=0 + # Disable colored log output. Set to "1" to force-disable colors regardless of # whether stdout is a terminal. When unset, colors are emitted only if stdout # is a TTY. See https://no-color.org/. diff --git a/model/application.go b/model/application.go index ed8e7489f..fb141b45b 100644 --- a/model/application.go +++ b/model/application.go @@ -64,4 +64,10 @@ type Application struct { // required: true // example: a1 SortKey string `gorm:"type:bytes;uniqueIndex:uix_application_user_id_sort_key,priority:2,length:255" form:"sortKey" query:"sortKey" json:"sortKey"` + // The number of seconds after which messages from this application are automatically + // deleted. 0 means the server's default retention period is used. + // + // required: false + // example: 2592000 + RetentionSeconds uint `gorm:"default:0;not null" form:"retentionSeconds" query:"retentionSeconds" json:"retentionSeconds"` } diff --git a/model/message.go b/model/message.go index b18273954..5666df32b 100644 --- a/model/message.go +++ b/model/message.go @@ -6,13 +6,13 @@ import ( // Message holds information about a message. type Message struct { - ID uint `gorm:"autoIncrement;primaryKey;index"` - ApplicationID uint + ID uint `gorm:"autoIncrement;primaryKey;index"` + ApplicationID uint `gorm:"index:idx_message_app_date,priority:1"` Message string `gorm:"type:text"` Title string `gorm:"type:text"` Priority int Extras []byte - Date time.Time + Date time.Time `gorm:"index:idx_message_app_date,priority:2"` } // MessageExternal Model diff --git a/router/router.go b/router/router.go index 57719f89e..f66fa009c 100644 --- a/router/router.go +++ b/router/router.go @@ -85,6 +85,14 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co } } }() + go func() { + ticker := time.NewTicker(1 * time.Minute) + for range ticker.C { + if _, err := db.PruneMessages(time.Now(), conf.MessageRetentionSeconds); err != nil { + log.Error().Err(err).Msg("Error pruning expired messages") + } + } + }() authentication := auth.Auth{ DB: db, SecureCookie: conf.Server.SecureCookie, diff --git a/ui/src/application/AddApplicationDialog.tsx b/ui/src/application/AddApplicationDialog.tsx index a3c197db2..3c3aa6e0a 100644 --- a/ui/src/application/AddApplicationDialog.tsx +++ b/ui/src/application/AddApplicationDialog.tsx @@ -11,17 +11,23 @@ import React, {useState} from 'react'; interface IProps { fClose: (token: string | null) => void; - fOnSubmit: (name: string, description: string, defaultPriority: number) => Promise; + fOnSubmit: ( + name: string, + description: string, + defaultPriority: number, + retentionSeconds: number + ) => Promise; } export const AddApplicationDialog = ({fClose, fOnSubmit}: IProps) => { const [name, setName] = useState(''); const [description, setDescription] = useState(''); const [defaultPriority, setDefaultPriority] = useState(0); + const [retentionSeconds, setRetentionSeconds] = useState(0); const submitEnabled = name.length !== 0; const submitAndNext = async () => { - const token = await fOnSubmit(name, description, defaultPriority); + const token = await fOnSubmit(name, description, defaultPriority, retentionSeconds); fClose(token); }; @@ -61,6 +67,14 @@ export const AddApplicationDialog = ({fClose, fOnSubmit}: IProps) => { onChange={(value) => setDefaultPriority(value)} fullWidth /> + setRetentionSeconds(value)} + fullWidth + /> diff --git a/ui/src/application/AppStore.ts b/ui/src/application/AppStore.ts index a6fa32ba6..acdb8496f 100644 --- a/ui/src/application/AppStore.ts +++ b/ui/src/application/AppStore.ts @@ -86,7 +86,7 @@ export class AppStore extends BaseStore { ...app }: Pick< IApplication, - 'id' | 'name' | 'description' | 'defaultPriority' | 'sortKey' + 'id' | 'name' | 'description' | 'defaultPriority' | 'retentionSeconds' | 'sortKey' >): Promise => { await axios.put(`${config.get('url')}application/${id}`, app); await this.refresh(); @@ -97,12 +97,14 @@ export class AppStore extends BaseStore { public create = async ( name: string, description: string, - defaultPriority: number + defaultPriority: number, + retentionSeconds: number ): Promise => { const response = await axios.post(`${config.get('url')}application`, { name, description, defaultPriority, + retentionSeconds, }); await this.refresh(); this.snack('Application created'); diff --git a/ui/src/application/Applications.tsx b/ui/src/application/Applications.tsx index 048edc394..02422fb83 100644 --- a/ui/src/application/Applications.tsx +++ b/ui/src/application/Applications.tsx @@ -131,6 +131,7 @@ const Applications = observer(() => { Name Description Priority + Retention Last Used Created @@ -178,12 +179,19 @@ const Applications = observer(() => { {toUpdateApp != null && ( setToUpdateApp(undefined)} - fOnSubmit={(name, description, defaultPriority) => - appStore.update({...toUpdateApp, name, description, defaultPriority}) + fOnSubmit={(name, description, defaultPriority, retentionSeconds) => + appStore.update({ + ...toUpdateApp, + name, + description, + defaultPriority, + retentionSeconds, + }) } initialDescription={toUpdateApp?.description} initialName={toUpdateApp?.name} initialDefaultPriority={toUpdateApp?.defaultPriority} + initialRetentionSeconds={toUpdateApp?.retentionSeconds} /> )} {toRegenerateTokenApp != null && ( @@ -286,6 +294,9 @@ const Row = ({app, fRegenerateToken, fDelete, fUpload, fDeleteImage, fEdit}: IRo {app.name} {app.description} {app.defaultPriority} + + {app.retentionSeconds === 0 ? 'server default' : `${app.retentionSeconds}s`} + diff --git a/ui/src/application/UpdateApplicationDialog.tsx b/ui/src/application/UpdateApplicationDialog.tsx index a4d13fad8..350206244 100644 --- a/ui/src/application/UpdateApplicationDialog.tsx +++ b/ui/src/application/UpdateApplicationDialog.tsx @@ -11,26 +11,34 @@ import React, {useState} from 'react'; interface IProps { fClose: VoidFunction; - fOnSubmit: (name: string, description: string, defaultPriority: number) => Promise; + fOnSubmit: ( + name: string, + description: string, + defaultPriority: number, + retentionSeconds: number + ) => Promise; initialName: string; initialDescription: string; initialDefaultPriority: number; + initialRetentionSeconds: number; } export const UpdateApplicationDialog = ({ initialName, initialDescription, initialDefaultPriority, + initialRetentionSeconds, fClose, fOnSubmit, }: IProps) => { const [name, setName] = useState(initialName); const [description, setDescription] = useState(initialDescription); const [defaultPriority, setDefaultPriority] = useState(initialDefaultPriority); + const [retentionSeconds, setRetentionSeconds] = useState(initialRetentionSeconds); const submitEnabled = name.length !== 0; const submitAndClose = async () => { - await fOnSubmit(name, description, defaultPriority); + await fOnSubmit(name, description, defaultPriority, retentionSeconds); fClose(); }; @@ -66,6 +74,14 @@ export const UpdateApplicationDialog = ({ onChange={(e) => setDefaultPriority(e)} fullWidth /> + setRetentionSeconds(e)} + fullWidth + /> diff --git a/ui/src/tests/application.test.ts b/ui/src/tests/application.test.ts index 71ff65ac4..23f8a91b6 100644 --- a/ui/src/tests/application.test.ts +++ b/ui/src/tests/application.test.ts @@ -18,11 +18,12 @@ enum Col { Name = 3, Description = 4, DefaultPriority = 5, - LastUsed = 6, - Created = 7, - EditRegenerateToken = 8, - EditUpdate = 9, - EditDelete = 10, + RetentionSeconds = 6, + LastUsed = 7, + Created = 8, + EditRegenerateToken = 9, + EditUpdate = 10, + EditDelete = 11, } const $table = selector.table('#app-table'); diff --git a/ui/src/types.ts b/ui/src/types.ts index c9f0324c4..61c76e3cc 100644 --- a/ui/src/types.ts +++ b/ui/src/types.ts @@ -7,6 +7,7 @@ export interface IApplication { image: string; internal: boolean; defaultPriority: number; + retentionSeconds: number; lastUsed: string | null; createdAt: string; }