Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions api/application.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
27 changes: 26 additions & 1 deletion api/application_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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"
Expand Down
8 changes: 7 additions & 1 deletion config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions config/keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,5 +48,6 @@ const (
EnvOIDCIDPName = "GOTIFY_OIDC_IDP_NAME"
EnvOIDCAutoRedirect = "GOTIFY_OIDC_AUTO_REDIRECT"
EnvOIDCPrompt = "GOTIFY_OIDC_PROMPT"
EnvMessageRetentionSeconds = "GOTIFY_MESSAGERETENTIONSECONDS"
EnvNoColor = "NOCOLOR"
)
39 changes: 39 additions & 0 deletions database/message.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package database

import (
"time"

"github.com/gotify/server/v3/model"
"gorm.io/gorm"
)
Expand Down Expand Up @@ -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
}
58 changes: 58 additions & 0 deletions database/message_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
14 changes: 14 additions & 0 deletions docs/spec.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions gotify-server.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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/.
Expand Down
6 changes: 6 additions & 0 deletions model/application.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
6 changes: 3 additions & 3 deletions model/message.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 16 additions & 2 deletions ui/src/application/AddApplicationDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,23 @@ import React, {useState} from 'react';

interface IProps {
fClose: (token: string | null) => void;
fOnSubmit: (name: string, description: string, defaultPriority: number) => Promise<string>;
fOnSubmit: (
name: string,
description: string,
defaultPriority: number,
retentionSeconds: number
) => Promise<string>;
}

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);
};

Expand Down Expand Up @@ -61,6 +67,14 @@ export const AddApplicationDialog = ({fClose, fOnSubmit}: IProps) => {
onChange={(value) => setDefaultPriority(value)}
fullWidth
/>
<NumberField
margin="dense"
className="retention-seconds"
label="Message Retention (seconds, 0 = server default)"
value={retentionSeconds}
onChange={(value) => setRetentionSeconds(value)}
fullWidth
/>
</DialogContent>
<DialogActions>
<Button onClick={() => fClose(null)}>Cancel</Button>
Expand Down
6 changes: 4 additions & 2 deletions ui/src/application/AppStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export class AppStore extends BaseStore<IApplication> {
...app
}: Pick<
IApplication,
'id' | 'name' | 'description' | 'defaultPriority' | 'sortKey'
'id' | 'name' | 'description' | 'defaultPriority' | 'retentionSeconds' | 'sortKey'
>): Promise<void> => {
await axios.put(`${config.get('url')}application/${id}`, app);
await this.refresh();
Expand All @@ -97,12 +97,14 @@ export class AppStore extends BaseStore<IApplication> {
public create = async (
name: string,
description: string,
defaultPriority: number
defaultPriority: number,
retentionSeconds: number
): Promise<string> => {
const response = await axios.post(`${config.get('url')}application`, {
name,
description,
defaultPriority,
retentionSeconds,
});
await this.refresh();
this.snack('Application created');
Expand Down
Loading