diff --git a/pkg/models/bot_users.go b/pkg/models/bot_users.go
new file mode 100644
index 000000000..efc544987
--- /dev/null
+++ b/pkg/models/bot_users.go
@@ -0,0 +1,133 @@
+// 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 models
+
+import (
+ "strings"
+
+ "code.vikunja.io/api/pkg/user"
+ "code.vikunja.io/api/pkg/web"
+
+ "xorm.io/xorm"
+)
+
+// BotUser is a thin wrapper around user.User that implements CRUDable + Permissions
+// for bot-management endpoints. Ownership lives on users.bot_owner_id, so there is
+// no separate table.
+type BotUser struct {
+ // Status shadows user.User.Status so it is included in JSON responses
+ // (the original has json:"-").
+ Status user.Status `xorm:"-" json:"status"`
+
+ user.User `xorm:"extends"`
+
+ web.CRUDable `xorm:"-" json:"-"`
+ web.Permissions `xorm:"-" json:"-"`
+}
+
+// Create creates a new bot user.
+func (b *BotUser) Create(s *xorm.Session, a web.Auth) error {
+ owner, ok := a.(*user.User)
+ if !ok {
+ return ErrGenericForbidden{}
+ }
+ b.ID = 0
+ created, err := user.CreateBotUser(s, &b.User, owner)
+ if err != nil {
+ return err
+ }
+ b.User = *created
+ b.Status = created.Status
+ return nil
+}
+
+// ReadAll returns all bots owned by the calling user.
+func (b *BotUser) ReadAll(s *xorm.Session, a web.Auth, search string, page int, perPage int) (result any, resultCount int, numberOfTotalItems int64, err error) {
+ limit, start := getLimitFromPageIndex(page, perPage)
+ var bots []*BotUser
+ q := s.Where("bot_owner_id = ?", a.GetID())
+ if search != "" {
+ q = q.And("(username LIKE ? OR name LIKE ?)", "%"+search+"%", "%"+search+"%")
+ }
+ if limit > 0 {
+ q = q.Limit(limit, start)
+ }
+ total, err := q.FindAndCount(&bots)
+ if err != nil {
+ return nil, 0, 0, err
+ }
+ for _, bot := range bots {
+ bot.Status = bot.User.Status
+ }
+ return bots, len(bots), total, nil
+}
+
+// ReadOne returns a single bot user.
+// Ownership is verified in CanRead.
+func (b *BotUser) ReadOne(s *xorm.Session, _ web.Auth) error {
+ u, err := user.GetUserByID(s, b.ID)
+ if err != nil {
+ return err
+ }
+ b.User = *u
+ b.Status = u.Status
+ return nil
+}
+
+// Update allows a narrow set of fields to be changed on an owned bot.
+// Ownership is verified in CanUpdate.
+func (b *BotUser) Update(s *xorm.Session, _ web.Auth) error {
+ existing, err := user.GetUserByID(s, b.ID)
+ if err != nil {
+ return err
+ }
+
+ cols := []string{"name"}
+ existing.Name = b.Name
+
+ if b.Status == user.StatusDisabled {
+ existing.Status = b.Status
+ cols = append(cols, "status")
+ } else if b.Status == user.StatusActive && existing.Status != user.StatusActive {
+ existing.Status = b.Status
+ cols = append(cols, "status")
+ }
+ if b.Username != "" && b.Username != existing.Username {
+ if !strings.HasPrefix(b.Username, "bot-") {
+ return &user.ErrBotUsernameMustHavePrefix{Username: b.Username}
+ }
+ existing.Username = b.Username
+ cols = append(cols, "username")
+ }
+
+ if len(cols) > 0 {
+ _, err = s.ID(existing.ID).Cols(cols...).Update(existing)
+ }
+ b.User = *existing
+ b.Status = existing.Status
+ return err
+}
+
+// Delete completely removes the bot user and all associated data.
+// Ownership is verified in CanDelete.
+func (b *BotUser) Delete(s *xorm.Session, _ web.Auth) error {
+ existing, err := user.GetUserByID(s, b.ID)
+ if err != nil {
+ return err
+ }
+ return DeleteUser(s, existing)
+}
diff --git a/pkg/models/bot_users_permissions.go b/pkg/models/bot_users_permissions.go
new file mode 100644
index 000000000..8fb4db854
--- /dev/null
+++ b/pkg/models/bot_users_permissions.go
@@ -0,0 +1,56 @@
+// 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 models
+
+import (
+ "code.vikunja.io/api/pkg/user"
+ "code.vikunja.io/api/pkg/web"
+
+ "xorm.io/xorm"
+)
+
+// CanCreate checks if a user can create a bot user.
+func (b *BotUser) CanCreate(_ *xorm.Session, a web.Auth) (bool, error) {
+ u, ok := a.(*user.User)
+ if !ok || u.IsBot() {
+ return false, nil
+ }
+ return true, nil
+}
+
+// CanRead checks if a user can read a bot user.
+func (b *BotUser) CanRead(s *xorm.Session, a web.Auth) (bool, int, error) {
+ ok, err := b.isOwner(s, a)
+ return ok, 0, err
+}
+
+// CanUpdate checks if a user can update a bot user.
+func (b *BotUser) CanUpdate(s *xorm.Session, a web.Auth) (bool, error) { return b.isOwner(s, a) }
+
+// CanDelete checks if a user can delete a bot user.
+func (b *BotUser) CanDelete(s *xorm.Session, a web.Auth) (bool, error) { return b.isOwner(s, a) }
+
+func (b *BotUser) isOwner(s *xorm.Session, a web.Auth) (bool, error) {
+ u, err := user.GetUserByID(s, b.ID)
+ if err != nil {
+ if user.IsErrUserDoesNotExist(err) {
+ return false, nil
+ }
+ return false, err
+ }
+ return u.BotOwnerID == a.GetID(), nil
+}
diff --git a/pkg/models/bot_users_test.go b/pkg/models/bot_users_test.go
new file mode 100644
index 000000000..bfb808aed
--- /dev/null
+++ b/pkg/models/bot_users_test.go
@@ -0,0 +1,130 @@
+// 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 models
+
+import (
+ "testing"
+
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/user"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestBotUser_Create(t *testing.T) {
+ t.Run("success", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ owner, err := user.GetUserByID(s, 1)
+ require.NoError(t, err)
+
+ bot := &BotUser{User: user.User{Username: "bot-model-success"}}
+ require.NoError(t, bot.Create(s, owner))
+ assert.True(t, bot.IsBot())
+ assert.Equal(t, owner.ID, bot.BotOwnerID)
+ })
+ t.Run("bot cannot create bot", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ botOwner := &user.User{ID: 555, BotOwnerID: 1}
+ bot := &BotUser{User: user.User{Username: "bot-child"}}
+ err := bot.Create(s, botOwner)
+ require.Error(t, err)
+ assert.True(t, user.IsErrBotNotOwned(err))
+ })
+}
+
+func TestBotUser_ReadAll(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ owner, err := user.GetUserByID(s, 1)
+ require.NoError(t, err)
+
+ bot := &BotUser{User: user.User{Username: "bot-readall"}}
+ require.NoError(t, bot.Create(s, owner))
+
+ list := &BotUser{}
+ result, _, _, err := list.ReadAll(s, owner, "", 1, 50)
+ require.NoError(t, err)
+ bots, ok := result.([]*BotUser)
+ require.True(t, ok)
+ found := false
+ for _, u := range bots {
+ if u.Username == "bot-readall" {
+ found = true
+ }
+ }
+ assert.True(t, found)
+}
+
+func TestBotUser_CanRead_NotOwned(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ owner, err := user.GetUserByID(s, 1)
+ require.NoError(t, err)
+ other, err := user.GetUserByID(s, 2)
+ require.NoError(t, err)
+
+ bot := &BotUser{User: user.User{Username: "bot-notowned"}}
+ require.NoError(t, bot.Create(s, owner))
+
+ view := &BotUser{User: user.User{ID: bot.ID}}
+ canRead, _, err := view.CanRead(s, other)
+ require.NoError(t, err)
+ assert.False(t, canRead)
+}
+
+func TestBotUser_Update_Status(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ owner, err := user.GetUserByID(s, 1)
+ require.NoError(t, err)
+
+ bot := &BotUser{User: user.User{Username: "bot-update"}}
+ require.NoError(t, bot.Create(s, owner))
+
+ upd := &BotUser{Status: user.StatusDisabled, User: user.User{ID: bot.ID, Name: "Renamed"}}
+ require.NoError(t, upd.Update(s, owner))
+ assert.Equal(t, user.StatusDisabled, upd.Status)
+ assert.Equal(t, "Renamed", upd.Name)
+}
+
+func TestBotUser_Delete(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ owner, err := user.GetUserByID(s, 1)
+ require.NoError(t, err)
+
+ bot := &BotUser{User: user.User{Username: "bot-delete"}}
+ require.NoError(t, bot.Create(s, owner))
+
+ del := &BotUser{User: user.User{ID: bot.ID}}
+ require.NoError(t, del.Delete(s, owner))
+}
diff --git a/pkg/models/user_delete.go b/pkg/models/user_delete.go
index 8cdba1e0d..59bd33107 100644
--- a/pkg/models/user_delete.go
+++ b/pkg/models/user_delete.go
@@ -128,6 +128,17 @@ func getProjectsToDelete(s *xorm.Session, u *user.User) (projectsToDelete []*Pro
// This action is irrevocable.
// Public to allow deletion from the CLI.
func DeleteUser(s *xorm.Session, u *user.User) (err error) {
+ // Delete any bot users owned by this user first (cascades their data too).
+ var ownedBots []*user.User
+ if err = s.Where("bot_owner_id = ?", u.ID).Find(&ownedBots); err != nil {
+ return err
+ }
+ for _, bot := range ownedBots {
+ if err = DeleteUser(s, bot); err != nil {
+ return err
+ }
+ }
+
projectsToDelete, err := getProjectsToDelete(s, u)
if err != nil {
return err
diff --git a/pkg/routes/api/v1/info.go b/pkg/routes/api/v1/info.go
index 0f8ded367..6e7c9a6ba 100644
--- a/pkg/routes/api/v1/info.go
+++ b/pkg/routes/api/v1/info.go
@@ -55,6 +55,7 @@ type vikunjaInfos struct {
DemoModeEnabled bool `json:"demo_mode_enabled"`
WebhooksEnabled bool `json:"webhooks_enabled"`
PublicTeamsEnabled bool `json:"public_teams_enabled"`
+ BotUsersEnabled bool `json:"bot_users_enabled"`
EnabledProFeatures []license.Feature `json:"enabled_pro_features"`
}
@@ -107,6 +108,7 @@ func Info(c *echo.Context) error {
DemoModeEnabled: config.ServiceDemoMode.GetBool(),
WebhooksEnabled: config.WebhooksEnabled.GetBool(),
PublicTeamsEnabled: config.ServiceEnablePublicTeams.GetBool(),
+ BotUsersEnabled: config.ServiceEnableBotUsers.GetBool(),
EnabledProFeatures: license.EnabledProFeatures(),
AvailableMigrators: []string{
(&vikunja_file.FileMigrator{}).Name(),
diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go
index 6c52a1af5..a305c3dc6 100644
--- a/pkg/routes/routes.go
+++ b/pkg/routes/routes.go
@@ -481,6 +481,24 @@ func registerAPIRoutes(a *echo.Group) {
u.POST("/deletion/cancel", apiv1.UserCancelDeletion)
}
+ // Bot users
+ if config.ServiceEnableBotUsers.GetBool() {
+ botHandler := &handler.WebHandler{
+ EmptyStruct: func() handler.CObject {
+ return &models.BotUser{}
+ },
+ }
+ u.PUT("/bots", botHandler.CreateWeb)
+ u.GET("/bots", botHandler.ReadAllWeb)
+ u.GET("/bots/:bot", botHandler.ReadOneWeb)
+ u.POST("/bots/:bot", botHandler.UpdateWeb)
+ u.DELETE("/bots/:bot", botHandler.DeleteWeb)
+
+ u.PUT("/bots/:bot/tokens", apiv1.CreateBotToken)
+ u.GET("/bots/:bot/tokens", apiv1.ListBotTokens)
+ u.DELETE("/bots/:bot/tokens/:token", apiv1.DeleteBotToken)
+ }
+
projectHandler := &handler.WebHandler{
EmptyStruct: func() handler.CObject {
return &models.Project{}
diff --git a/pkg/user/user.go b/pkg/user/user.go
index ae2305628..eaf8b18b4 100644
--- a/pkg/user/user.go
+++ b/pkg/user/user.go
@@ -84,7 +84,7 @@ const (
// User holds information about an user
type User struct {
// The unique, numeric id of this user.
- ID int64 `xorm:"bigint autoincr not null unique pk" json:"id"`
+ ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"bot"`
// The full name of the user.
Name string `xorm:"text null" json:"name"`
// The username of the user. Is always unique.