diff --git a/internal/services/permissions/interfaces.go b/internal/services/permissions/interfaces.go index 06aa9be57..b78815d42 100644 --- a/internal/services/permissions/interfaces.go +++ b/internal/services/permissions/interfaces.go @@ -3,6 +3,7 @@ package permissions import ( "github.com/bwmarrin/discordgo" "github.com/zekroTJA/shinpuru/pkg/permissions" + "github.com/zekroTJA/shinpuru/pkg/roleutil" ) type Database interface { @@ -12,3 +13,10 @@ type Database interface { type State interface { Guild(id string, hydrate ...bool) (v *discordgo.Guild, err error) } + +type Session interface { + roleutil.Session + + GuildMember(guildID, userID string, options ...discordgo.RequestOption) (st *discordgo.Member, err error) + UserChannelPermissions(userID, channelID string, fetchOptions ...discordgo.RequestOption) (apermissions int64, err error) +} diff --git a/internal/services/permissions/permissions.go b/internal/services/permissions/permissions.go index 8e14f101e..caf37ecaa 100644 --- a/internal/services/permissions/permissions.go +++ b/internal/services/permissions/permissions.go @@ -27,8 +27,6 @@ type Permissions struct { cfg config.Provider } -var _ Provider = (*Permissions)(nil) - // NewPermissions returns a new PermissionsMiddleware // instance with the passed database and config instances. func NewPermissions(container di.Container) *Permissions { @@ -73,7 +71,7 @@ func (m *Permissions) Before(ctx *ken.Ctx) (next bool, err error) { return } -func (m *Permissions) HandleWs(s discordutil.ISession, required string) fiber.Handler { +func (m *Permissions) HandleWs(s Session, required string) fiber.Handler { if !stringutil.ContainsAny(required, static.AdditionalPermissions) { static.AdditionalPermissions = append(static.AdditionalPermissions, required) } @@ -109,7 +107,7 @@ func (m *Permissions) HandleWs(s discordutil.ISession, required string) fiber.Ha // permissions array is returned as well as the override, // which is true when the specified user is the bot owner, // guild owner or an admin of the guild. -func (m *Permissions) GetPermissions(s discordutil.ISession, guildID, userID string) (perm permissions.PermissionArray, overrideExplicits bool, err error) { +func (m *Permissions) GetPermissions(s Session, guildID, userID string) (perm permissions.PermissionArray, overrideExplicits bool, err error) { if guildID != "" { perm, err = m.GetMemberPermission(s, guildID, userID) if err != nil && !database.IsErrDatabaseNotFound(err) { @@ -166,7 +164,7 @@ func (m *Permissions) GetPermissions(s discordutil.ISession, guildID, userID str // // If guildID is passed as non-mepty string, all configured guild owner // permissions will be added to the fetched permissions array as well. -func (m *Permissions) CheckPermissions(s discordutil.ISession, guildID string, userID string, dns ...string) (bool, bool, error) { +func (m *Permissions) CheckPermissions(s Session, guildID string, userID string, dns ...string) (bool, bool, error) { perms, overrideExplicits, err := m.GetPermissions(s, guildID, userID) if err != nil { return false, false, err @@ -183,7 +181,7 @@ func (m *Permissions) CheckPermissions(s discordutil.ISession, guildID string, u // GetMemberPermission returns a PermissionsArray based on the passed // members roles permissions rulesets for the given guild. -func (m *Permissions) GetMemberPermission(s discordutil.ISession, guildID string, memberID string) (permissions.PermissionArray, error) { +func (m *Permissions) GetMemberPermission(s Session, guildID string, memberID string) (permissions.PermissionArray, error) { guildPerms, err := m.db.GetGuildPermissions(guildID) if err != nil { return nil, err diff --git a/internal/services/permissions/provider.go b/internal/services/permissions/provider.go index dac92f358..57191be0f 100644 --- a/internal/services/permissions/provider.go +++ b/internal/services/permissions/provider.go @@ -1,59 +1,58 @@ -package permissions - -import ( - "github.com/gofiber/fiber/v2" - "github.com/zekroTJA/shinpuru/pkg/discordutil" - "github.com/zekroTJA/shinpuru/pkg/permissions" - "github.com/zekrotja/ken" -) - -type Provider interface { - ken.MiddlewareBefore - - HandleWs(s discordutil.ISession, required string) fiber.Handler - - // GetPermissions tries to fetch the permissions array of - // the passed user of the specified guild. The merged - // permissions array is returned as well as the override, - // which is true when the specified user is the bot owner, - // guild owner or an admin of the guild. - GetPermissions( - s discordutil.ISession, - guildID string, - userID string, - ) (perm permissions.PermissionArray, overrideExplicits bool, err error) - - // CheckPermissions tries to fetch the permissions of the specified user - // on the specified guild and returns true, if any of the passed dns match - // the fetched permissions array. Also, the override status is returned as - // well as errors occured during permissions fetching. - // - // If the userID matches the configured bot owner, all bot owner permissions - // will be added to the fetched permissions array. - // - // If guildID is passed as non-mepty string, all configured guild owner - // permissions will be added to the fetched permissions array as well. - CheckPermissions( - s discordutil.ISession, - guildID string, - userID string, - dns ...string, - ) (bool, bool, error) - - // GetMemberPermissions returns a PermissionsArray based on the passed - // members roles permissions rulesets for the given guild. - GetMemberPermission( - s discordutil.ISession, - guildID string, - memberID string, - ) (permissions.PermissionArray, error) - - // CheckSubPerm takes a command context and checks is the given - // subDN is permitted. - CheckSubPerm( - ctx ken.Context, - subDN string, - explicit bool, - message ...string, - ) (ok bool, err error) -} +package permissions + +import ( + "github.com/gofiber/fiber/v2" + "github.com/zekroTJA/shinpuru/pkg/permissions" + "github.com/zekrotja/ken" +) + +type Provider interface { + ken.MiddlewareBefore + + HandleWs(s Session, required string) fiber.Handler + + // GetPermissions tries to fetch the permissions array of + // the passed user of the specified guild. The merged + // permissions array is returned as well as the override, + // which is true when the specified user is the bot owner, + // guild owner or an admin of the guild. + GetPermissions( + s Session, + guildID string, + userID string, + ) (perm permissions.PermissionArray, overrideExplicits bool, err error) + + // CheckPermissions tries to fetch the permissions of the specified user + // on the specified guild and returns true, if any of the passed dns match + // the fetched permissions array. Also, the override status is returned as + // well as errors occured during permissions fetching. + // + // If the userID matches the configured bot owner, all bot owner permissions + // will be added to the fetched permissions array. + // + // If guildID is passed as non-mepty string, all configured guild owner + // permissions will be added to the fetched permissions array as well. + CheckPermissions( + s Session, + guildID string, + userID string, + dns ...string, + ) (bool, bool, error) + + // GetMemberPermissions returns a PermissionsArray based on the passed + // members roles permissions rulesets for the given guild. + GetMemberPermission( + s Session, + guildID string, + memberID string, + ) (permissions.PermissionArray, error) + + // CheckSubPerm takes a command context and checks is the given + // subDN is permitted. + CheckSubPerm( + ctx ken.Context, + subDN string, + explicit bool, + message ...string, + ) (ok bool, err error) +} diff --git a/internal/services/webserver/v1/controllers/auth.go b/internal/services/webserver/v1/controllers/auth.go index da645dcd0..7228a3d59 100644 --- a/internal/services/webserver/v1/controllers/auth.go +++ b/internal/services/webserver/v1/controllers/auth.go @@ -1,263 +1,265 @@ -package controllers - -import ( - "sync" - "time" - - "github.com/bwmarrin/discordgo" - "github.com/gofiber/fiber/v2" - "github.com/rs/xid" - "github.com/sarulabs/di/v2" - "github.com/zekroTJA/shinpuru/internal/services/database" - "github.com/zekroTJA/shinpuru/internal/services/webserver/auth" - "github.com/zekroTJA/shinpuru/internal/services/webserver/v1/models" - "github.com/zekroTJA/shinpuru/internal/util" - "github.com/zekroTJA/shinpuru/internal/util/static" - "github.com/zekroTJA/shinpuru/pkg/acceptmsg/v2" - "github.com/zekroTJA/shinpuru/pkg/discordoauth/v2" - "github.com/zekroTJA/timedmap" - "github.com/zekrotja/dgrs" - "github.com/zekrotja/ken" - "github.com/zekrotja/rogu/log" -) - -const pushcodeTimeout = 60 * time.Second - -type AuthController struct { - discordOAuth *discordoauth.DiscordOAuth - rth auth.RefreshTokenHandler - ath auth.AccessTokenHandler - authMw auth.Middleware - st *dgrs.State - session *discordgo.Session - cmdHandler *ken.Ken - oauthHandler auth.RequestHandler - - pushcodeSubs *timedmap.TimedMap -} - -type pushCodeWaiter struct { - mtx sync.Mutex - code string - am *acceptmsg.AcceptMessage - subscription func() error - fin chan *discordgo.Message - closed bool -} - -func (pcw *pushCodeWaiter) close() bool { - pcw.mtx.Lock() - defer pcw.mtx.Unlock() - - if pcw.am != nil { - pcw.am.Ken.Session().ChannelMessageEditComplex(&discordgo.MessageEdit{ - Channel: pcw.am.ChannelID, - ID: pcw.am.ID, - Embeds: []*discordgo.MessageEmbed{ - { - Title: "Login", - Description: "The code has been timed out.", - }, - }, - Components: []discordgo.MessageComponent{}, - }) - pcw.am = nil - } - - if !pcw.closed { - close(pcw.fin) - pcw.subscription() - pcw.closed = true - - return true - } - - return false -} - -func (c *AuthController) Setup(container di.Container, router fiber.Router) { - c.discordOAuth = container.Get(static.DiDiscordOAuthModule).(*discordoauth.DiscordOAuth) - c.rth = container.Get(static.DiAuthRefreshTokenHandler).(auth.RefreshTokenHandler) - c.ath = container.Get(static.DiAuthAccessTokenHandler).(auth.AccessTokenHandler) - c.authMw = container.Get(static.DiAuthMiddleware).(auth.Middleware) - c.st = container.Get(static.DiState).(*dgrs.State) - c.session = container.Get(static.DiDiscordSession).(*discordgo.Session) - c.oauthHandler = container.Get(static.DiOAuthHandler).(auth.RequestHandler) - c.cmdHandler = container.Get(static.DiCommandHandler).(*ken.Ken) - - c.pushcodeSubs = timedmap.New(10 * time.Second) - - router.Get("/login", c.getLogin) - router.Get("/oauthcallback", c.discordOAuth.HandlerCallback) - router.Post("/accesstoken", c.postAccessToken) - router.Post("/pushcode", c.pushCode) - router.Get("/check", c.authMw.Handle, c.getCheck) - router.Post("/logout", c.authMw.Handle, c.postLogout) -} - -func (c *AuthController) getLogin(ctx *fiber.Ctx) error { - state := make(map[string]string) - - if redirect := ctx.Query("redirect"); redirect != "" { - state["redirect"] = redirect - } - - return c.discordOAuth.HandlerInitWithState(ctx, state) -} - -// @Summary Access Token Exchange -// @Description Exchanges the cookie-passed refresh token with a generated access token. -// @Tags Authorization -// @Accept json -// @Produce json -// @Success 200 {object} models.AccessTokenResponse -// @Failure 401 {object} models.Error -// @Router /auth/accesstoken [post] -func (c *AuthController) postAccessToken(ctx *fiber.Ctx) error { - refreshToken := ctx.Cookies(static.RefreshTokenCookieName) - if refreshToken == "" { - return fiber.ErrUnauthorized - } - - ident, err := c.rth.ValidateRefreshToken(refreshToken) - if err != nil && !database.IsErrDatabaseNotFound(err) { - ctlLog.Error().Err(err).Msg("Failed validating refresh token") - } - if ident == "" { - return fiber.ErrUnauthorized - } - - token, expires, err := c.ath.GetAccessToken(ident) - if err != nil { - return err - } - - return ctx.JSON(&models.AccessTokenResponse{ - Token: token, - Expires: expires, - }) -} - -// @Summary Authorization Check -// @Description Returns OK if the request is authorized. -// @Tags Authorization -// @Accept json -// @Produce json -// @Success 200 {object} models.Status -// @Failure 401 {object} models.Error -// @Router /auth/check [get] -func (c *AuthController) getCheck(ctx *fiber.Ctx) error { - return ctx.JSON(models.Ok) -} - -// @Summary Logout -// @Description Reovkes the currently used access token and clears the refresh token. -// @Tags Authorization -// @Accept json -// @Produce json -// @Success 200 {object} models.Status -// @Router /auth/logout [post] -func (c *AuthController) postLogout(ctx *fiber.Ctx) error { - uid := ctx.Locals("uid").(string) - - err := c.rth.RevokeToken(uid) - if err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - - ctx.ClearCookie(static.RefreshTokenCookieName) - - return ctx.JSON(models.Ok) -} - -// @Summary Pushcode -// @Description Send a login push code resulting in a long-fetch request waiting for the code to be sent to shinpurus DMs. -// @Tags Authorization -// @Accept json -// @Produce json -// @Param payload body models.PushCodeRequest true "The push code." -// @Success 200 {object} models.Status -// @Success 400 {object} models.Status -// @Success 410 {object} models.Status -// @Router /auth/pushcode [post] -func (c *AuthController) pushCode(ctx *fiber.Ctx) (err error) { - var req models.PushCodeRequest - if err = ctx.BodyParser(&req); err != nil { - return - } - - if req.Code == "" { - return fiber.NewError(fiber.StatusBadRequest, "empty code") - } - - ipaddr := ctx.IP() - if ipaddr == "" { - // When the IP address is empty, which might happen, just - // generate a new pcw for each request to avoid conflicts. - ipaddr = xid.New().String() - } - - pcw, ok := c.pushcodeSubs.GetValue(ipaddr).(*pushCodeWaiter) - if !ok { - pcw = new(pushCodeWaiter) - c.pushcodeSubs.Set(ipaddr, pcw, pushcodeTimeout, func(_ any) { - pcw.close() - }) - - pcw.code = req.Code - pcw.fin = make(chan *discordgo.Message) - pcw.subscription = c.st.Subscribe("dms", func(scan func(v any) error) { - var msg discordgo.Message - if err = scan(&msg); err != nil { - ctlLog.Error().Err(err).Msg("failed scanning message from 'dms' event bus") - return - } - if msg.Content == pcw.code && msg.Author != nil { - am, err := acceptmsg.New(). - WithKen(c.cmdHandler). - DeleteAfterAnswer().WithEmbed(&discordgo.MessageEmbed{ - Title: "Login", - Description: "Do you really want to log in to the web interface using this " + - "login code?\n\n⚠️ **Never __ever__ enter a login code here you got from someone else!**\n" + - "If you got this login code from someone else, press `Cancel` or do nothing!", - Color: static.ColorEmbedOrange, - }).WithAcceptButton(discordgo.Button{ - Label: "Accept", - Style: discordgo.SuccessButton, - }).WithDeclineButton(discordgo.Button{ - Label: "Cancel", - Style: discordgo.DangerButton, - }).DoOnAccept(func(ctx ken.ComponentContext) error { - pcw.am = nil - pcw.fin <- &msg - return nil - }).Send(msg.ChannelID) - if err == nil { - pcw.am = am - } - } - }) - } else { - log.Debug().Field("ipaddr", ipaddr).Msg("Reusing pushcode handler for this client") - pcw.code = req.Code - } - - res := <-pcw.fin - if res == nil { - err = fiber.NewError(fiber.StatusGone, "timeout") - return - } - - c.pushcodeSubs.Remove(ipaddr) - if pcw.close() { - util.SendEmbed(c.session, res.ChannelID, - "You are now being logged in!", "", static.ColorEmbedGreen) - } - - err = c.oauthHandler.BindRefreshToken(ctx, res.Author.ID) - if err != nil { - return - } - - return ctx.JSON(models.Ok) -} +package controllers + +import ( + "sync" + "time" + + "github.com/bwmarrin/discordgo" + "github.com/gofiber/fiber/v2" + "github.com/rs/xid" + "github.com/sarulabs/di/v2" + "github.com/zekroTJA/shinpuru/internal/services/database" + "github.com/zekroTJA/shinpuru/internal/services/webserver/auth" + "github.com/zekroTJA/shinpuru/internal/services/webserver/v1/models" + "github.com/zekroTJA/shinpuru/internal/util" + "github.com/zekroTJA/shinpuru/internal/util/static" + "github.com/zekroTJA/shinpuru/pkg/acceptmsg/v2" + "github.com/zekroTJA/shinpuru/pkg/discordoauth/v2" + "github.com/zekroTJA/timedmap" + "github.com/zekrotja/dgrs" + "github.com/zekrotja/ken" + "github.com/zekrotja/rogu/log" +) + +const pushcodeTimeout = 60 * time.Second + +type AuthController struct { + rth auth.RefreshTokenHandler + ath auth.AccessTokenHandler + authMw auth.Middleware + oauthHandler auth.RequestHandler + + st State + session Session + + cmdHandler *ken.Ken + discordOAuth *discordoauth.DiscordOAuth + + pushcodeSubs *timedmap.TimedMap +} + +type pushCodeWaiter struct { + mtx sync.Mutex + code string + am *acceptmsg.AcceptMessage + subscription func() error + fin chan *discordgo.Message + closed bool +} + +func (pcw *pushCodeWaiter) close() bool { + pcw.mtx.Lock() + defer pcw.mtx.Unlock() + + if pcw.am != nil { + pcw.am.Ken.Session().ChannelMessageEditComplex(&discordgo.MessageEdit{ + Channel: pcw.am.ChannelID, + ID: pcw.am.ID, + Embeds: []*discordgo.MessageEmbed{ + { + Title: "Login", + Description: "The code has been timed out.", + }, + }, + Components: []discordgo.MessageComponent{}, + }) + pcw.am = nil + } + + if !pcw.closed { + close(pcw.fin) + pcw.subscription() + pcw.closed = true + + return true + } + + return false +} + +func (c *AuthController) Setup(container di.Container, router fiber.Router) { + c.discordOAuth = container.Get(static.DiDiscordOAuthModule).(*discordoauth.DiscordOAuth) + c.rth = container.Get(static.DiAuthRefreshTokenHandler).(auth.RefreshTokenHandler) + c.ath = container.Get(static.DiAuthAccessTokenHandler).(auth.AccessTokenHandler) + c.authMw = container.Get(static.DiAuthMiddleware).(auth.Middleware) + c.st = container.Get(static.DiState).(*dgrs.State) + c.session = container.Get(static.DiDiscordSession).(*discordgo.Session) + c.oauthHandler = container.Get(static.DiOAuthHandler).(auth.RequestHandler) + c.cmdHandler = container.Get(static.DiCommandHandler).(*ken.Ken) + + c.pushcodeSubs = timedmap.New(10 * time.Second) + + router.Get("/login", c.getLogin) + router.Get("/oauthcallback", c.discordOAuth.HandlerCallback) + router.Post("/accesstoken", c.postAccessToken) + router.Post("/pushcode", c.pushCode) + router.Get("/check", c.authMw.Handle, c.getCheck) + router.Post("/logout", c.authMw.Handle, c.postLogout) +} + +func (c *AuthController) getLogin(ctx *fiber.Ctx) error { + state := make(map[string]string) + + if redirect := ctx.Query("redirect"); redirect != "" { + state["redirect"] = redirect + } + + return c.discordOAuth.HandlerInitWithState(ctx, state) +} + +// @Summary Access Token Exchange +// @Description Exchanges the cookie-passed refresh token with a generated access token. +// @Tags Authorization +// @Accept json +// @Produce json +// @Success 200 {object} models.AccessTokenResponse +// @Failure 401 {object} models.Error +// @Router /auth/accesstoken [post] +func (c *AuthController) postAccessToken(ctx *fiber.Ctx) error { + refreshToken := ctx.Cookies(static.RefreshTokenCookieName) + if refreshToken == "" { + return fiber.ErrUnauthorized + } + + ident, err := c.rth.ValidateRefreshToken(refreshToken) + if err != nil && !database.IsErrDatabaseNotFound(err) { + ctlLog.Error().Err(err).Msg("Failed validating refresh token") + } + if ident == "" { + return fiber.ErrUnauthorized + } + + token, expires, err := c.ath.GetAccessToken(ident) + if err != nil { + return err + } + + return ctx.JSON(&models.AccessTokenResponse{ + Token: token, + Expires: expires, + }) +} + +// @Summary Authorization Check +// @Description Returns OK if the request is authorized. +// @Tags Authorization +// @Accept json +// @Produce json +// @Success 200 {object} models.Status +// @Failure 401 {object} models.Error +// @Router /auth/check [get] +func (c *AuthController) getCheck(ctx *fiber.Ctx) error { + return ctx.JSON(models.Ok) +} + +// @Summary Logout +// @Description Reovkes the currently used access token and clears the refresh token. +// @Tags Authorization +// @Accept json +// @Produce json +// @Success 200 {object} models.Status +// @Router /auth/logout [post] +func (c *AuthController) postLogout(ctx *fiber.Ctx) error { + uid := ctx.Locals("uid").(string) + + err := c.rth.RevokeToken(uid) + if err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + + ctx.ClearCookie(static.RefreshTokenCookieName) + + return ctx.JSON(models.Ok) +} + +// @Summary Pushcode +// @Description Send a login push code resulting in a long-fetch request waiting for the code to be sent to shinpurus DMs. +// @Tags Authorization +// @Accept json +// @Produce json +// @Param payload body models.PushCodeRequest true "The push code." +// @Success 200 {object} models.Status +// @Success 400 {object} models.Status +// @Success 410 {object} models.Status +// @Router /auth/pushcode [post] +func (c *AuthController) pushCode(ctx *fiber.Ctx) (err error) { + var req models.PushCodeRequest + if err = ctx.BodyParser(&req); err != nil { + return + } + + if req.Code == "" { + return fiber.NewError(fiber.StatusBadRequest, "empty code") + } + + ipaddr := ctx.IP() + if ipaddr == "" { + // When the IP address is empty, which might happen, just + // generate a new pcw for each request to avoid conflicts. + ipaddr = xid.New().String() + } + + pcw, ok := c.pushcodeSubs.GetValue(ipaddr).(*pushCodeWaiter) + if !ok { + pcw = new(pushCodeWaiter) + c.pushcodeSubs.Set(ipaddr, pcw, pushcodeTimeout, func(_ any) { + pcw.close() + }) + + pcw.code = req.Code + pcw.fin = make(chan *discordgo.Message) + pcw.subscription = c.st.Subscribe("dms", func(scan func(v any) error) { + var msg discordgo.Message + if err = scan(&msg); err != nil { + ctlLog.Error().Err(err).Msg("failed scanning message from 'dms' event bus") + return + } + if msg.Content == pcw.code && msg.Author != nil { + am, err := acceptmsg.New(). + WithKen(c.cmdHandler). + DeleteAfterAnswer().WithEmbed(&discordgo.MessageEmbed{ + Title: "Login", + Description: "Do you really want to log in to the web interface using this " + + "login code?\n\n⚠️ **Never __ever__ enter a login code here you got from someone else!**\n" + + "If you got this login code from someone else, press `Cancel` or do nothing!", + Color: static.ColorEmbedOrange, + }).WithAcceptButton(discordgo.Button{ + Label: "Accept", + Style: discordgo.SuccessButton, + }).WithDeclineButton(discordgo.Button{ + Label: "Cancel", + Style: discordgo.DangerButton, + }).DoOnAccept(func(ctx ken.ComponentContext) error { + pcw.am = nil + pcw.fin <- &msg + return nil + }).Send(msg.ChannelID) + if err == nil { + pcw.am = am + } + } + }) + } else { + log.Debug().Field("ipaddr", ipaddr).Msg("Reusing pushcode handler for this client") + pcw.code = req.Code + } + + res := <-pcw.fin + if res == nil { + err = fiber.NewError(fiber.StatusGone, "timeout") + return + } + + c.pushcodeSubs.Remove(ipaddr) + if pcw.close() { + util.SendEmbed(c.session, res.ChannelID, + "You are now being logged in!", "", static.ColorEmbedGreen) + } + + err = c.oauthHandler.BindRefreshToken(ctx, res.Author.ID) + if err != nil { + return + } + + return ctx.JSON(models.Ok) +} diff --git a/internal/services/webserver/v1/controllers/backups.go b/internal/services/webserver/v1/controllers/backups.go index af633a45d..e042baee1 100644 --- a/internal/services/webserver/v1/controllers/backups.go +++ b/internal/services/webserver/v1/controllers/backups.go @@ -1,196 +1,197 @@ -package controllers - -import ( - "bytes" - "compress/gzip" - "fmt" - "io" - "strings" - - "github.com/bwmarrin/discordgo" - "github.com/gofiber/fiber/v2" - "github.com/sarulabs/di/v2" - _ "github.com/zekroTJA/shinpuru/internal/services/backup/backupmodels" - "github.com/zekroTJA/shinpuru/internal/services/database" - "github.com/zekroTJA/shinpuru/internal/services/permissions" - "github.com/zekroTJA/shinpuru/internal/services/storage" - "github.com/zekroTJA/shinpuru/internal/services/webserver/v1/models" - "github.com/zekroTJA/shinpuru/internal/util/static" - "github.com/zekroTJA/shinpuru/pkg/onetimeauth/v2" -) - -type GuildBackupsController struct { - db database.Database - st storage.Storage - ota onetimeauth.OneTimeAuth -} - -func (c *GuildBackupsController) Setup(container di.Container, router fiber.Router) { - c.db = container.Get(static.DiDatabase).(database.Database) - c.st = container.Get(static.DiObjectStorage).(storage.Storage) - c.ota = container.Get(static.DiOneTimeAuth).(onetimeauth.OneTimeAuth) - - session := container.Get(static.DiDiscordSession).(*discordgo.Session) - pmw := container.Get(static.DiPermissions).(*permissions.Permissions) - - router.Get("", c.getBackups) - router.Post("/toggle", pmw.HandleWs(session, "sp.guild.admin.backup"), c.postToggleBackups) - router.Post("/:backupid/download", pmw.HandleWs(session, "sp.guild.admin.backup"), c.postDownloadBackup) - router.Get("/:backupid/download", c.getDownloadBackup) -} - -// @Summary Get Guild Backups -// @Description Returns a list of guild backups. -// @Tags Guild Backups -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Success 200 {array} backupmodels.Entry "Wrapped in models.ListResponse" -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/backups [get] -func (c *GuildBackupsController) getBackups(ctx *fiber.Ctx) error { - guildID := ctx.Params("guildid") - - backupEntries, err := c.db.GetBackups(guildID) - if database.IsErrDatabaseNotFound(err) { - return fiber.ErrNotFound - } else if err != nil { - return err - } - - return ctx.JSON(models.NewListResponse(backupEntries)) -} - -// @Summary Obtain Backup Download OTA Key -// @Description Returns an OTA key which is used to download a backup entry. -// @Tags Guild Backups -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Param backupid path string true "The ID of the backup." -// @Success 200 {object} models.AccessTokenResponse -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/backups/{backupid}/download [post] -func (c *GuildBackupsController) postDownloadBackup(ctx *fiber.Ctx) error { - guildID := ctx.Params("guildid") - backupID := ctx.Params("backupid") - - ident := getBackupIdent(guildID, backupID) - - token, expires, err := c.ota.GetKey(ident, ctx.Path()) - if err != nil { - return err - } - - return ctx.JSON(&models.AccessTokenResponse{ - Token: token, - Expires: expires, - }) -} - -// @Summary Download Backup File -// @Description Download a single gziped backup file. -// @Tags Guild Backups -// @Accept json -// @Produce application/gzip -// @Param id path string true "The ID of the guild." -// @Param backupid path string true "The ID of the backup." -// @Param ota_token query string true "The previously obtained OTA token to authorize the download." -// @Success 200 {file} gziped bakcup file -// @Failure 401 {object} models.Error -// @Failure 403 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/backups/{backupid}/download [get] -func (c *GuildBackupsController) getDownloadBackup(ctx *fiber.Ctx) error { - guildID := ctx.Params("guildid") - backupID := ctx.Params("backupid") - - ident, _ := ctx.Locals("uid").(string) - if rGuildID, rBackupID := decodeBackupIdent(ident); rGuildID != guildID || rBackupID != backupID { - return fiber.ErrForbidden - } - - backupEntries, err := c.db.GetBackups(guildID) - if database.IsErrDatabaseNotFound(err) { - return fiber.ErrNotFound - } else if err != nil { - return err - } - - var found bool - for _, e := range backupEntries { - if e.FileID == backupID { - found = true - break - } - } - - if !found { - return fiber.ErrNotFound - } - - f, size, err := c.st.GetObject(static.StorageBucketBackups, backupID) - if err != nil { - return err - } - defer f.Close() - - buff := bytes.NewBuffer([]byte{}) - zf := gzip.NewWriter(buff) - zf.Name = fmt.Sprintf("backup_%s_%s.json", guildID, backupID) - - _, err = io.CopyN(zf, f, size) - if err != nil { - return err - } - zf.Close() - - // 24 hours browser caching - ctx.Set("Cache-Control", "public, max-age=86400, immutable") - ctx.Set("Content-Type", "application/gzip") - ctx.Set("Content-Disposition", fmt.Sprintf(`filename="backup_%s_%s.gz"`, guildID, backupID)) - return ctx.SendStream(buff) -} - -// @Summary Toggle Guild Backup Enable -// @Description Toggle guild backup enable state. -// @Tags Guild Backups -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Param payload body models.EnableStatus true "Enable state payload." -// @Success 200 {object} models.Status -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/backups/toggle [post] -func (c *GuildBackupsController) postToggleBackups(ctx *fiber.Ctx) error { - guildID := ctx.Params("guildid") - - var data models.EnableStatus - if err := ctx.BodyParser(&data); err != nil { - return err - } - - if err := c.db.SetGuildBackup(guildID, data.Enabled); err != nil { - return err - } - - return ctx.JSON(models.Ok) -} - -// --- HELPERS --- - -func getBackupIdent(guildID, backupID string) string { - return fmt.Sprintf("%s#%s", guildID, backupID) -} - -func decodeBackupIdent(ident string) (guildID, backupID string) { - split := strings.Split(ident, "#") - if len(split) == 2 { - guildID = split[0] - backupID = split[1] - } - return -} +package controllers + +import ( + "bytes" + "compress/gzip" + "fmt" + "io" + "strings" + + "github.com/bwmarrin/discordgo" + "github.com/gofiber/fiber/v2" + "github.com/sarulabs/di/v2" + _ "github.com/zekroTJA/shinpuru/internal/services/backup/backupmodels" + "github.com/zekroTJA/shinpuru/internal/services/database" + "github.com/zekroTJA/shinpuru/internal/services/permissions" + "github.com/zekroTJA/shinpuru/internal/services/storage" + "github.com/zekroTJA/shinpuru/internal/services/webserver/v1/models" + "github.com/zekroTJA/shinpuru/internal/util/static" + "github.com/zekroTJA/shinpuru/pkg/onetimeauth/v2" +) + +type GuildBackupsController struct { + db Database + st Storage + + ota onetimeauth.OneTimeAuth +} + +func (c *GuildBackupsController) Setup(container di.Container, router fiber.Router) { + c.db = container.Get(static.DiDatabase).(database.Database) + c.st = container.Get(static.DiObjectStorage).(storage.Storage) + c.ota = container.Get(static.DiOneTimeAuth).(onetimeauth.OneTimeAuth) + + session := container.Get(static.DiDiscordSession).(*discordgo.Session) + pmw := container.Get(static.DiPermissions).(*permissions.Permissions) + + router.Get("", c.getBackups) + router.Post("/toggle", pmw.HandleWs(session, "sp.guild.admin.backup"), c.postToggleBackups) + router.Post("/:backupid/download", pmw.HandleWs(session, "sp.guild.admin.backup"), c.postDownloadBackup) + router.Get("/:backupid/download", c.getDownloadBackup) +} + +// @Summary Get Guild Backups +// @Description Returns a list of guild backups. +// @Tags Guild Backups +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Success 200 {array} backupmodels.Entry "Wrapped in models.ListResponse" +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/backups [get] +func (c *GuildBackupsController) getBackups(ctx *fiber.Ctx) error { + guildID := ctx.Params("guildid") + + backupEntries, err := c.db.GetBackups(guildID) + if database.IsErrDatabaseNotFound(err) { + return fiber.ErrNotFound + } else if err != nil { + return err + } + + return ctx.JSON(models.NewListResponse(backupEntries)) +} + +// @Summary Obtain Backup Download OTA Key +// @Description Returns an OTA key which is used to download a backup entry. +// @Tags Guild Backups +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Param backupid path string true "The ID of the backup." +// @Success 200 {object} models.AccessTokenResponse +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/backups/{backupid}/download [post] +func (c *GuildBackupsController) postDownloadBackup(ctx *fiber.Ctx) error { + guildID := ctx.Params("guildid") + backupID := ctx.Params("backupid") + + ident := getBackupIdent(guildID, backupID) + + token, expires, err := c.ota.GetKey(ident, ctx.Path()) + if err != nil { + return err + } + + return ctx.JSON(&models.AccessTokenResponse{ + Token: token, + Expires: expires, + }) +} + +// @Summary Download Backup File +// @Description Download a single gziped backup file. +// @Tags Guild Backups +// @Accept json +// @Produce application/gzip +// @Param id path string true "The ID of the guild." +// @Param backupid path string true "The ID of the backup." +// @Param ota_token query string true "The previously obtained OTA token to authorize the download." +// @Success 200 {file} gziped bakcup file +// @Failure 401 {object} models.Error +// @Failure 403 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/backups/{backupid}/download [get] +func (c *GuildBackupsController) getDownloadBackup(ctx *fiber.Ctx) error { + guildID := ctx.Params("guildid") + backupID := ctx.Params("backupid") + + ident, _ := ctx.Locals("uid").(string) + if rGuildID, rBackupID := decodeBackupIdent(ident); rGuildID != guildID || rBackupID != backupID { + return fiber.ErrForbidden + } + + backupEntries, err := c.db.GetBackups(guildID) + if database.IsErrDatabaseNotFound(err) { + return fiber.ErrNotFound + } else if err != nil { + return err + } + + var found bool + for _, e := range backupEntries { + if e.FileID == backupID { + found = true + break + } + } + + if !found { + return fiber.ErrNotFound + } + + f, size, err := c.st.GetObject(static.StorageBucketBackups, backupID) + if err != nil { + return err + } + defer f.Close() + + buff := bytes.NewBuffer([]byte{}) + zf := gzip.NewWriter(buff) + zf.Name = fmt.Sprintf("backup_%s_%s.json", guildID, backupID) + + _, err = io.CopyN(zf, f, size) + if err != nil { + return err + } + zf.Close() + + // 24 hours browser caching + ctx.Set("Cache-Control", "public, max-age=86400, immutable") + ctx.Set("Content-Type", "application/gzip") + ctx.Set("Content-Disposition", fmt.Sprintf(`filename="backup_%s_%s.gz"`, guildID, backupID)) + return ctx.SendStream(buff) +} + +// @Summary Toggle Guild Backup Enable +// @Description Toggle guild backup enable state. +// @Tags Guild Backups +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Param payload body models.EnableStatus true "Enable state payload." +// @Success 200 {object} models.Status +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/backups/toggle [post] +func (c *GuildBackupsController) postToggleBackups(ctx *fiber.Ctx) error { + guildID := ctx.Params("guildid") + + var data models.EnableStatus + if err := ctx.BodyParser(&data); err != nil { + return err + } + + if err := c.db.SetGuildBackup(guildID, data.Enabled); err != nil { + return err + } + + return ctx.JSON(models.Ok) +} + +// --- HELPERS --- + +func getBackupIdent(guildID, backupID string) string { + return fmt.Sprintf("%s#%s", guildID, backupID) +} + +func decodeBackupIdent(ident string) (guildID, backupID string) { + split := strings.Split(ident, "#") + if len(split) == 2 { + guildID = split[0] + backupID = split[1] + } + return +} diff --git a/internal/services/webserver/v1/controllers/channels.go b/internal/services/webserver/v1/controllers/channels.go index e683f78c8..9761228a7 100644 --- a/internal/services/webserver/v1/controllers/channels.go +++ b/internal/services/webserver/v1/controllers/channels.go @@ -1,186 +1,186 @@ -package controllers - -import ( - "fmt" - "time" - - "github.com/bwmarrin/discordgo" - "github.com/gofiber/fiber/v2" - "github.com/sarulabs/di/v2" - "github.com/zekroTJA/shinpuru/internal/services/kvcache" - "github.com/zekroTJA/shinpuru/internal/services/permissions" - "github.com/zekroTJA/shinpuru/internal/services/webserver/v1/models" - "github.com/zekroTJA/shinpuru/internal/util/static" - "github.com/zekroTJA/shinpuru/pkg/discordutil" - "github.com/zekroTJA/shinpuru/pkg/stringutil" - "github.com/zekrotja/dgrs" -) - -type ChannelController struct { - session *discordgo.Session - st *dgrs.State - pmw *permissions.Permissions - kv kvcache.Provider -} - -func (c *ChannelController) Setup(container di.Container, router fiber.Router) { - c.session = container.Get(static.DiDiscordSession).(*discordgo.Session) - c.st = container.Get(static.DiState).(*dgrs.State) - c.pmw = container.Get(static.DiPermissions).(*permissions.Permissions) - c.kv = container.Get(static.DiKVCache).(kvcache.Provider) - - router.Get("", c.getChannels) - router.Post("/:id", c.pmw.HandleWs(c.session, "sp.chat.say"), c.postChannelMessage) - router.Post("/:id/:msgid", c.pmw.HandleWs(c.session, "sp.chat.say"), c.postChannelMessage) -} - -// @Summary Get Allowed Channels -// @Description Returns a list of channels the user has access to. -// @Tags Channels -// @Accept json -// @Produce json -// @Param guildid path string true "The ID of the guild." -// @Success 201 {object} discordgo.Message -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /channels/{guildid} [get] -func (c *ChannelController) getChannels(ctx *fiber.Ctx) (err error) { - uid := ctx.Locals("uid").(string) - gid := ctx.Params("guildid") - - guildChans, err := c.st.Channels(gid) - if err != nil { - return - } - - chans := make([]*models.ChannelWithPermissions, 0) - var perms int64 - for _, gc := range guildChans { - if perms, err = c.getUserChannelPermissions(uid, gc.ID); err != nil { - return - } - if perms&discordgo.PermissionViewChannel != 0 { - chans = append(chans, &models.ChannelWithPermissions{ - Channel: gc, - CanRead: true, - CanWrite: perms&discordgo.PermissionSendMessages != 0, - }) - } - } - - return ctx.JSON(models.NewListResponse(chans)) -} - -// @Summary Send Embed Message -// @Description Send an Embed Message into a specified Channel. -// @Tags Channels -// @Accept json -// @Produce json -// @Param guildid path string true "The ID of the guild." -// @Param id path string true "The ID of the channel." -// @Param payload body discordgo.MessageEmbed true "The message embed object." -// @Success 201 {object} discordgo.Message -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /channels/{guildid}/{id} [post] -func (c *ChannelController) postChannelMessage(ctx *fiber.Ctx) (err error) { - uid := ctx.Locals("uid").(string) - id := ctx.Params("id") - msgid := ctx.Params("msgid") - - perms, err := c.getUserChannelPermissions(uid, id) - if err != nil { - return - } - - if perms&discordgo.PermissionSendMessages != discordgo.PermissionSendMessages { - return fiber.ErrForbidden - } - - emb := new(discordgo.MessageEmbed) - if err = ctx.BodyParser(emb); err != nil { - return - } - - ch, err := c.st.Channel(id) - if err != nil { - if discordutil.IsErrCode(err, discordgo.ErrCodeUnknownChannel) { - err = fiber.ErrNotFound - } - return - } - - gids, err := c.st.UserGuilds(uid) - if err != nil { - return - } - - if !stringutil.ContainsAny(ch.GuildID, gids) { - return fiber.ErrNotFound - } - - memb, err := c.st.Member(ch.GuildID, uid) - if err != nil { - return - } - - emb.Author = &discordgo.MessageEmbedAuthor{ - Name: memb.User.String(), - IconURL: memb.User.AvatarURL("16"), - } - - var msg *discordgo.Message - if msgid != "" { - if _, err = c.st.Message(ch.ID, msgid); err != nil { - if discordutil.IsErrCode(err, discordgo.ErrCodeUnknownMessage) { - return fiber.ErrNotFound - } - return - } - msg, err = c.session.ChannelMessageEditEmbed(ch.ID, msgid, emb) - ctx.Status(fiber.StatusOK) - } else { - msg, err = c.session.ChannelMessageSendEmbed(ch.ID, emb) - ctx.Status(fiber.StatusCreated) - } - - if err != nil { - return - } - - return ctx.JSON(msg) -} - -// @Summary Update Embed Message -// @Description Update an Embed Message in a specified Channel with the given message ID. -// @Tags Channels -// @Accept json -// @Produce json -// @Param guildid path string true "The ID of the guild." -// @Param id path string true "The ID of the channel." -// @Param msgid path string true "The ID of the message." -// @Param payload body discordgo.MessageEmbed true "The message embed object." -// @Success 200 {object} discordgo.Message -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /channels/{guildid}/{id}/{msgid} [post] -// -// This is a dummy method for API doc generation. -func (*ChannelController) _(*fiber.Ctx) error { - return nil -} - -// --- HELPER --- - -func (c *ChannelController) getUserChannelPermissions(uid, chid string) (perms int64, err error) { - var ok bool - cacheKey := fmt.Sprintf("userchanperms:%s:%s", uid, chid) - if perms, ok = c.kv.Get(cacheKey).(int64); !ok { - perms, err = c.session.UserChannelPermissions(uid, chid) - if err != nil { - return - } - c.kv.Set(cacheKey, perms, 10*time.Minute) - } - return -} +package controllers + +import ( + "fmt" + "time" + + "github.com/bwmarrin/discordgo" + "github.com/gofiber/fiber/v2" + "github.com/sarulabs/di/v2" + "github.com/zekroTJA/shinpuru/internal/services/kvcache" + "github.com/zekroTJA/shinpuru/internal/services/permissions" + "github.com/zekroTJA/shinpuru/internal/services/webserver/v1/models" + "github.com/zekroTJA/shinpuru/internal/util/static" + "github.com/zekroTJA/shinpuru/pkg/discordutil" + "github.com/zekroTJA/shinpuru/pkg/stringutil" + "github.com/zekrotja/dgrs" +) + +type ChannelController struct { + session Session + st State + pmw Permissions + kv KvCache +} + +func (c *ChannelController) Setup(container di.Container, router fiber.Router) { + c.session = container.Get(static.DiDiscordSession).(*discordgo.Session) + c.st = container.Get(static.DiState).(*dgrs.State) + c.pmw = container.Get(static.DiPermissions).(*permissions.Permissions) + c.kv = container.Get(static.DiKVCache).(kvcache.Provider) + + router.Get("", c.getChannels) + router.Post("/:id", c.pmw.HandleWs(c.session, "sp.chat.say"), c.postChannelMessage) + router.Post("/:id/:msgid", c.pmw.HandleWs(c.session, "sp.chat.say"), c.postChannelMessage) +} + +// @Summary Get Allowed Channels +// @Description Returns a list of channels the user has access to. +// @Tags Channels +// @Accept json +// @Produce json +// @Param guildid path string true "The ID of the guild." +// @Success 201 {object} discordgo.Message +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /channels/{guildid} [get] +func (c *ChannelController) getChannels(ctx *fiber.Ctx) (err error) { + uid := ctx.Locals("uid").(string) + gid := ctx.Params("guildid") + + guildChans, err := c.st.Channels(gid) + if err != nil { + return + } + + chans := make([]*models.ChannelWithPermissions, 0) + var perms int64 + for _, gc := range guildChans { + if perms, err = c.getUserChannelPermissions(uid, gc.ID); err != nil { + return + } + if perms&discordgo.PermissionViewChannel != 0 { + chans = append(chans, &models.ChannelWithPermissions{ + Channel: gc, + CanRead: true, + CanWrite: perms&discordgo.PermissionSendMessages != 0, + }) + } + } + + return ctx.JSON(models.NewListResponse(chans)) +} + +// @Summary Send Embed Message +// @Description Send an Embed Message into a specified Channel. +// @Tags Channels +// @Accept json +// @Produce json +// @Param guildid path string true "The ID of the guild." +// @Param id path string true "The ID of the channel." +// @Param payload body discordgo.MessageEmbed true "The message embed object." +// @Success 201 {object} discordgo.Message +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /channels/{guildid}/{id} [post] +func (c *ChannelController) postChannelMessage(ctx *fiber.Ctx) (err error) { + uid := ctx.Locals("uid").(string) + id := ctx.Params("id") + msgid := ctx.Params("msgid") + + perms, err := c.getUserChannelPermissions(uid, id) + if err != nil { + return + } + + if perms&discordgo.PermissionSendMessages != discordgo.PermissionSendMessages { + return fiber.ErrForbidden + } + + emb := new(discordgo.MessageEmbed) + if err = ctx.BodyParser(emb); err != nil { + return + } + + ch, err := c.st.Channel(id) + if err != nil { + if discordutil.IsErrCode(err, discordgo.ErrCodeUnknownChannel) { + err = fiber.ErrNotFound + } + return + } + + gids, err := c.st.UserGuilds(uid) + if err != nil { + return + } + + if !stringutil.ContainsAny(ch.GuildID, gids) { + return fiber.ErrNotFound + } + + memb, err := c.st.Member(ch.GuildID, uid) + if err != nil { + return + } + + emb.Author = &discordgo.MessageEmbedAuthor{ + Name: memb.User.String(), + IconURL: memb.User.AvatarURL("16"), + } + + var msg *discordgo.Message + if msgid != "" { + if _, err = c.st.Message(ch.ID, msgid); err != nil { + if discordutil.IsErrCode(err, discordgo.ErrCodeUnknownMessage) { + return fiber.ErrNotFound + } + return + } + msg, err = c.session.ChannelMessageEditEmbed(ch.ID, msgid, emb) + ctx.Status(fiber.StatusOK) + } else { + msg, err = c.session.ChannelMessageSendEmbed(ch.ID, emb) + ctx.Status(fiber.StatusCreated) + } + + if err != nil { + return + } + + return ctx.JSON(msg) +} + +// @Summary Update Embed Message +// @Description Update an Embed Message in a specified Channel with the given message ID. +// @Tags Channels +// @Accept json +// @Produce json +// @Param guildid path string true "The ID of the guild." +// @Param id path string true "The ID of the channel." +// @Param msgid path string true "The ID of the message." +// @Param payload body discordgo.MessageEmbed true "The message embed object." +// @Success 200 {object} discordgo.Message +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /channels/{guildid}/{id}/{msgid} [post] +// +// This is a dummy method for API doc generation. +func (*ChannelController) _(*fiber.Ctx) error { + return nil +} + +// --- HELPER --- + +func (c *ChannelController) getUserChannelPermissions(uid, chid string) (perms int64, err error) { + var ok bool + cacheKey := fmt.Sprintf("userchanperms:%s:%s", uid, chid) + if perms, ok = c.kv.Get(cacheKey).(int64); !ok { + perms, err = c.session.UserChannelPermissions(uid, chid) + if err != nil { + return + } + c.kv.Set(cacheKey, perms, 10*time.Minute) + } + return +} diff --git a/internal/services/webserver/v1/controllers/debug.go b/internal/services/webserver/v1/controllers/debug.go deleted file mode 100644 index 0125842d1..000000000 --- a/internal/services/webserver/v1/controllers/debug.go +++ /dev/null @@ -1,34 +0,0 @@ -package controllers - -import ( - "github.com/gofiber/fiber/v2" - "github.com/sarulabs/di/v2" - "github.com/zekroTJA/shinpuru/internal/services/webserver/v1/models" - "github.com/zekroTJA/shinpuru/internal/util/static" - "github.com/zekrotja/dgrs" -) - -type DebugController struct { - st dgrs.IState -} - -func (c *DebugController) Setup(container di.Container, router fiber.Router) { - c.st = container.Get(static.DiState).(*dgrs.State) - - router.Get("", c.get) -} - -func (c *DebugController) get(ctx *fiber.Ctx) error { - g, err := c.st.Guild("526196711962705925") - if err != nil { - return err - } - - g.MemberCount = 1 - err = c.st.SetGuild(g) - if err != nil { - return err - } - - return ctx.JSON(models.Ok) -} diff --git a/internal/services/webserver/v1/controllers/etc.go b/internal/services/webserver/v1/controllers/etc.go index 133e49b5c..056dd6782 100644 --- a/internal/services/webserver/v1/controllers/etc.go +++ b/internal/services/webserver/v1/controllers/etc.go @@ -1,188 +1,186 @@ -package controllers - -import ( - "context" - "fmt" - "runtime" - "strconv" - "sync/atomic" - "time" - - "github.com/bwmarrin/discordgo" - "github.com/go-redis/redis/v8" - "github.com/gofiber/fiber/v2" - "github.com/sarulabs/di/v2" - "github.com/zekroTJA/shinpuru/internal/models" - "github.com/zekroTJA/shinpuru/internal/services/config" - "github.com/zekroTJA/shinpuru/internal/services/database" - "github.com/zekroTJA/shinpuru/internal/services/storage" - "github.com/zekroTJA/shinpuru/internal/services/webserver/auth" - apiModels "github.com/zekroTJA/shinpuru/internal/services/webserver/v1/models" - "github.com/zekroTJA/shinpuru/internal/util" - "github.com/zekroTJA/shinpuru/internal/util/embedded" - "github.com/zekroTJA/shinpuru/internal/util/static" - "github.com/zekroTJA/shinpuru/pkg/discordutil" - "github.com/zekrotja/dgrs" - "github.com/zekrotja/ken" -) - -type EtcController struct { - session *discordgo.Session - cfg config.Provider - authMw auth.Middleware - st *dgrs.State - storage storage.Storage - db database.Database - cmdHandler *ken.Ken - rd *redis.Client -} - -func (c *EtcController) Setup(container di.Container, router fiber.Router) { - c.session = container.Get(static.DiDiscordSession).(*discordgo.Session) - c.cfg = container.Get(static.DiConfig).(config.Provider) - c.authMw = container.Get(static.DiAuthMiddleware).(auth.Middleware) - c.st = container.Get(static.DiState).(*dgrs.State) - c.storage = container.Get(static.DiObjectStorage).(storage.Storage) - c.db = container.Get(static.DiDatabase).(database.Database) - c.cmdHandler = container.Get(static.DiCommandHandler).(*ken.Ken) - c.rd = container.Get(static.DiRedis).(*redis.Client) - - router.Get("/me", c.authMw.Handle, c.getMe) - router.Get("/sysinfo", c.getSysinfo) - router.Get("/privacyinfo", c.getPrivacyinfo) - router.Get("/allpermissions", c.getAllPermissions) - router.Get("/healthcheck", c.getHealthcheck) -} - -// @Summary Me -// @Description Returns the user object of the currently authenticated user. -// @Tags Etc -// @Accept json -// @Produce json -// @Success 200 {object} apiModels.User -// @Router /me [get] -func (c *EtcController) getMe(ctx *fiber.Ctx) error { - uid := ctx.Locals("uid").(string) - - user, err := c.st.User(uid) - if err != nil { - return err - } - - caapchaVerified, err := c.db.GetUserVerified(uid) - if err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - - created, _ := discordutil.GetDiscordSnowflakeCreationTime(user.ID) - - res := &apiModels.User{ - User: user, - AvatarURL: user.AvatarURL(""), - CreatedAt: created, - BotOwner: uid == c.cfg.Config().Discord.OwnerID, - CaptchaVerified: caapchaVerified, - } - - return ctx.JSON(res) -} - -// @Summary System Information -// @Description Returns general global system information. -// @Tags Etc -// @Accept json -// @Produce json -// @Success 200 {object} apiModels.SystemInfo -// @Router /sysinfo [get] -func (c *EtcController) getSysinfo(ctx *fiber.Ctx) error { - buildTS, _ := strconv.Atoi(embedded.AppDate) - buildDate := time.Unix(int64(buildTS), 0) - - var memStats runtime.MemStats - runtime.ReadMemStats(&memStats) - - uptime := int64(time.Since(util.StatsStartupTime).Seconds()) - - self, err := c.st.SelfUser() - if err != nil { - return err - } - - guilds, err := c.st.Guilds() - if err != nil { - return err - } - - res := &apiModels.SystemInfo{ - Version: embedded.AppVersion, - CommitHash: embedded.AppCommit, - BuildDate: buildDate, - GoVersion: runtime.Version(), - - Uptime: uptime, - UptimeStr: fmt.Sprintf("%d", uptime), - - OS: runtime.GOOS, - Arch: runtime.GOARCH, - CPUs: runtime.NumCPU(), - GoRoutines: runtime.NumGoroutine(), - StackUse: memStats.StackInuse, - StackUseStr: fmt.Sprintf("%d", memStats.StackInuse), - HeapUse: memStats.HeapInuse, - HeapUseStr: fmt.Sprintf("%d", memStats.HeapInuse), - - BotUserID: self.ID, - BotInvite: util.GetInviteLink(self.ID), - - Guilds: len(guilds), - } - - return ctx.JSON(res) -} - -// @Summary Privacy Information -// @Description Returns general global privacy information. -// @Tags Etc -// @Accept json -// @Produce json -// @Success 200 {object} models.Privacy -// @Router /privacyinfo [get] -func (c *EtcController) getPrivacyinfo(ctx *fiber.Ctx) error { - return ctx.JSON(c.cfg.Config().Privacy) -} - -// @Summary All Permissions -// @Description Return a list of all available permissions. -// @Tags Etc -// @Accept json -// @Produce json -// @Success 200 {array} string "Wrapped in models.ListResponse" -// @Router /allpermissions [get] -func (c *EtcController) getAllPermissions(ctx *fiber.Ctx) error { - all := util.GetAllPermissions(c.cmdHandler) - return ctx.JSON(apiModels.NewListResponse(all.Unwrap())) -} - -// @Summary Healthcheck -// @Description General system healthcheck. -// @Tags Etc -// @Accept json -// @Produce json -// @Success 200 {array} string "Wrapped in models.ListResponse" -// @Router /healthcheck [get] -func (c *EtcController) getHealthcheck(ctx *fiber.Ctx) error { - var hc models.HealthcheckResponse - - hc.Database = models.HealthcheckStatusFromError(c.db.Status()) - hc.Storage = models.HealthcheckStatusFromError(c.storage.Status()) - hc.Redis = models.HealthcheckStatusFromError(c.rd.Ping(context.Background()).Err()) - - hc.Discord.Ok = atomic.LoadInt32(&util.ConnectedState) == 1 - if !hc.Discord.Ok { - hc.Discord.Message = "gateway connection has been disconnected" - } - - hc.AllOk = hc.Database.Ok && hc.Storage.Ok && hc.Redis.Ok && hc.Discord.Ok - - return ctx.JSON(hc) -} +package controllers + +import ( + "context" + "fmt" + "runtime" + "strconv" + "sync/atomic" + "time" + + "github.com/go-redis/redis/v8" + "github.com/gofiber/fiber/v2" + "github.com/sarulabs/di/v2" + "github.com/zekroTJA/shinpuru/internal/models" + "github.com/zekroTJA/shinpuru/internal/services/config" + "github.com/zekroTJA/shinpuru/internal/services/database" + "github.com/zekroTJA/shinpuru/internal/services/storage" + "github.com/zekroTJA/shinpuru/internal/services/webserver/auth" + apiModels "github.com/zekroTJA/shinpuru/internal/services/webserver/v1/models" + "github.com/zekroTJA/shinpuru/internal/util" + "github.com/zekroTJA/shinpuru/internal/util/embedded" + "github.com/zekroTJA/shinpuru/internal/util/static" + "github.com/zekroTJA/shinpuru/pkg/discordutil" + "github.com/zekrotja/dgrs" + "github.com/zekrotja/ken" +) + +type EtcController struct { + db Database + st State + storage Storage + rd *redis.Client + + cmdHandler *ken.Ken + authMw auth.Middleware + cfg config.Provider +} + +func (c *EtcController) Setup(container di.Container, router fiber.Router) { + c.cfg = container.Get(static.DiConfig).(config.Provider) + c.authMw = container.Get(static.DiAuthMiddleware).(auth.Middleware) + c.st = container.Get(static.DiState).(*dgrs.State) + c.storage = container.Get(static.DiObjectStorage).(storage.Storage) + c.db = container.Get(static.DiDatabase).(database.Database) + c.cmdHandler = container.Get(static.DiCommandHandler).(*ken.Ken) + c.rd = container.Get(static.DiRedis).(*redis.Client) + + router.Get("/me", c.authMw.Handle, c.getMe) + router.Get("/sysinfo", c.getSysinfo) + router.Get("/privacyinfo", c.getPrivacyinfo) + router.Get("/allpermissions", c.getAllPermissions) + router.Get("/healthcheck", c.getHealthcheck) +} + +// @Summary Me +// @Description Returns the user object of the currently authenticated user. +// @Tags Etc +// @Accept json +// @Produce json +// @Success 200 {object} apiModels.User +// @Router /me [get] +func (c *EtcController) getMe(ctx *fiber.Ctx) error { + uid := ctx.Locals("uid").(string) + + user, err := c.st.User(uid) + if err != nil { + return err + } + + caapchaVerified, err := c.db.GetUserVerified(uid) + if err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + + created, _ := discordutil.GetDiscordSnowflakeCreationTime(user.ID) + + res := &apiModels.User{ + User: user, + AvatarURL: user.AvatarURL(""), + CreatedAt: created, + BotOwner: uid == c.cfg.Config().Discord.OwnerID, + CaptchaVerified: caapchaVerified, + } + + return ctx.JSON(res) +} + +// @Summary System Information +// @Description Returns general global system information. +// @Tags Etc +// @Accept json +// @Produce json +// @Success 200 {object} apiModels.SystemInfo +// @Router /sysinfo [get] +func (c *EtcController) getSysinfo(ctx *fiber.Ctx) error { + buildTS, _ := strconv.Atoi(embedded.AppDate) + buildDate := time.Unix(int64(buildTS), 0) + + var memStats runtime.MemStats + runtime.ReadMemStats(&memStats) + + uptime := int64(time.Since(util.StatsStartupTime).Seconds()) + + self, err := c.st.SelfUser() + if err != nil { + return err + } + + guilds, err := c.st.Guilds() + if err != nil { + return err + } + + res := &apiModels.SystemInfo{ + Version: embedded.AppVersion, + CommitHash: embedded.AppCommit, + BuildDate: buildDate, + GoVersion: runtime.Version(), + + Uptime: uptime, + UptimeStr: fmt.Sprintf("%d", uptime), + + OS: runtime.GOOS, + Arch: runtime.GOARCH, + CPUs: runtime.NumCPU(), + GoRoutines: runtime.NumGoroutine(), + StackUse: memStats.StackInuse, + StackUseStr: fmt.Sprintf("%d", memStats.StackInuse), + HeapUse: memStats.HeapInuse, + HeapUseStr: fmt.Sprintf("%d", memStats.HeapInuse), + + BotUserID: self.ID, + BotInvite: util.GetInviteLink(self.ID), + + Guilds: len(guilds), + } + + return ctx.JSON(res) +} + +// @Summary Privacy Information +// @Description Returns general global privacy information. +// @Tags Etc +// @Accept json +// @Produce json +// @Success 200 {object} models.Privacy +// @Router /privacyinfo [get] +func (c *EtcController) getPrivacyinfo(ctx *fiber.Ctx) error { + return ctx.JSON(c.cfg.Config().Privacy) +} + +// @Summary All Permissions +// @Description Return a list of all available permissions. +// @Tags Etc +// @Accept json +// @Produce json +// @Success 200 {array} string "Wrapped in models.ListResponse" +// @Router /allpermissions [get] +func (c *EtcController) getAllPermissions(ctx *fiber.Ctx) error { + all := util.GetAllPermissions(c.cmdHandler) + return ctx.JSON(apiModels.NewListResponse(all.Unwrap())) +} + +// @Summary Healthcheck +// @Description General system healthcheck. +// @Tags Etc +// @Accept json +// @Produce json +// @Success 200 {array} string "Wrapped in models.ListResponse" +// @Router /healthcheck [get] +func (c *EtcController) getHealthcheck(ctx *fiber.Ctx) error { + var hc models.HealthcheckResponse + + hc.Database = models.HealthcheckStatusFromError(c.db.Status()) + hc.Storage = models.HealthcheckStatusFromError(c.storage.Status()) + hc.Redis = models.HealthcheckStatusFromError(c.rd.Ping(context.Background()).Err()) + + hc.Discord.Ok = atomic.LoadInt32(&util.ConnectedState) == 1 + if !hc.Discord.Ok { + hc.Discord.Message = "gateway connection has been disconnected" + } + + hc.AllOk = hc.Database.Ok && hc.Storage.Ok && hc.Redis.Ok && hc.Discord.Ok + + return ctx.JSON(hc) +} diff --git a/internal/services/webserver/v1/controllers/globalsettings.go b/internal/services/webserver/v1/controllers/globalsettings.go index 42be4e031..d23272e40 100644 --- a/internal/services/webserver/v1/controllers/globalsettings.go +++ b/internal/services/webserver/v1/controllers/globalsettings.go @@ -1,287 +1,287 @@ -package controllers - -import ( - "fmt" - - "github.com/bwmarrin/discordgo" - "github.com/gofiber/fiber/v2" - "github.com/sarulabs/di/v2" - "github.com/zekroTJA/shinpuru/internal/services/database" - "github.com/zekroTJA/shinpuru/internal/services/permissions" - "github.com/zekroTJA/shinpuru/internal/services/webserver/v1/models" - "github.com/zekroTJA/shinpuru/internal/util/presence" - "github.com/zekroTJA/shinpuru/internal/util/static" - "github.com/zekrotja/dgrs" -) - -type GlobalSettingsController struct { - session *discordgo.Session - db database.Database - st *dgrs.State -} - -func (c *GlobalSettingsController) Setup(container di.Container, router fiber.Router) { - c.session = container.Get(static.DiDiscordSession).(*discordgo.Session) - c.db = container.Get(static.DiDatabase).(database.Database) - c.st = container.Get(static.DiState).(*dgrs.State) - - pmw := container.Get(static.DiPermissions).(*permissions.Permissions) - - router.Get("/presence", pmw.HandleWs(c.session, "sp.presence"), c.getPresence) - router.Post("/presence", pmw.HandleWs(c.session, "sp.presence"), c.postPresence) - router.Get("/noguildinvite", pmw.HandleWs(c.session, "sp.noguildinvite"), c.getNoGuildInvites) - router.Post("/noguildinvite", pmw.HandleWs(c.session, "sp.noguildinvite"), c.postNoGuildInvites) -} - -// @Summary Get Presence -// @Description Returns the bot's displayed presence status. -// @Tags Global Settings -// @Accept json -// @Produce json -// @Success 200 {object} presence.Presence -// @Failure 401 {object} models.Error -// @Router /settings/presence [get] -func (c *GlobalSettingsController) getPresence(ctx *fiber.Ctx) error { - presenceRaw, err := c.db.GetSetting(static.SettingPresence) - if err != nil { - if database.IsErrDatabaseNotFound(err) { - return ctx.JSON(&presence.Presence{ - Game: static.StdMotd, - Status: "online", - }) - } - return err - } - - pre, err := presence.Unmarshal(presenceRaw) - if err != nil { - return err - } - - return ctx.JSON(pre) -} - -// @Summary Set Presence -// @Description Set the bot's displayed presence status. -// @Tags Global Settings -// @Accept json -// @Produce json -// @Param payload body presence.Presence true "Presence Payload" -// @Success 200 {object} models.APITokenResponse -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error "Is returned when no token was generated before." -// @Router /settings/presence [post] -func (c *GlobalSettingsController) postPresence(ctx *fiber.Ctx) error { - pre := new(presence.Presence) - if err := ctx.BodyParser(pre); err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - - if err := pre.Validate(); err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - - if err := c.db.SetSetting(static.SettingPresence, pre.Marshal()); err != nil { - return err - } - - if err := c.session.UpdateStatusComplex(pre.ToUpdateStatusData()); err != nil { - return err - } - - return ctx.JSON(pre) -} - -// @Summary Get No Guild Invites Status -// @Description Returns the settings status for the suggested guild invite when the logged in user is not on any guild with shinpuru. -// @Tags Global Settings -// @Accept json -// @Produce json -// @Success 200 {object} models.InviteSettingsResponse -// @Failure 401 {object} models.Error -// @Failure 409 {object} models.Error "Returned when no channel could be found to create invite for." -// @Router /settings/noguildinvite [get] -func (c *GlobalSettingsController) getNoGuildInvites(ctx *fiber.Ctx) error { - var guildID, message, inviteCode string - var err error - - empty := func() error { return ctx.JSON(&models.InviteSettingsResponse{}) } - - if guildID, err = c.db.GetSetting(static.SettingWIInviteGuildID); err != nil { - if err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - } - - if guildID == "" { - return empty() - } - - if message, err = c.db.GetSetting(static.SettingWIInviteText); err != nil { - if err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - } - - if inviteCode, err = c.db.GetSetting(static.SettingWIInviteCode); err != nil { - if err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - } - - guild, err := c.st.Guild(guildID, true) - if apiErr, ok := err.(*discordgo.RESTError); ok && apiErr.Message.Code == discordgo.ErrCodeMissingAccess { - if err = c.db.SetSetting(static.SettingWIInviteGuildID, ""); err != nil { - return err - } - return empty() - } - - if err != nil { - return err - } - - invites, err := c.session.GuildInvites(guildID) - if err != nil { - return err - } - - if inviteCode != "" { - self, err := c.st.SelfUser() - if err != nil { - return err - } - for _, inv := range invites { - if inv.Inviter != nil && inv.Inviter.ID == self.ID && !inv.Revoked { - inviteCode = inv.Code - break - } - } - } - - if inviteCode == "" { - chans, err := c.st.Channels(guild.ID) - if err != nil { - return err - } - var channel *discordgo.Channel - for _, c := range chans { - if c.Type == discordgo.ChannelTypeGuildText { - channel = c - break - } - } - if channel == nil { - return fiber.NewError(fiber.StatusConflict, "could not find any channel to create invite for") - } - - invite, err := c.session.ChannelInviteCreate(channel.ID, discordgo.Invite{ - Temporary: false, - }) - if err != nil { - return err - } - - inviteCode = invite.Code - if err = c.db.SetSetting(static.SettingWIInviteCode, inviteCode); err != nil { - return err - } - } - - res := &models.InviteSettingsResponse{ - Message: message, - InviteURL: fmt.Sprintf("https://discord.gg/%s", inviteCode), - } - - res.Guild, err = models.GuildFromGuild(guild, nil, nil, "") - if err != nil { - return err - } - - return ctx.JSON(res) -} - -// @Summary Set No Guild Invites Status -// @Description Set the status for the suggested guild invite when the logged in user is not on any guild with shinpuru. -// @Tags Global Settings -// @Accept json -// @Produce json -// @Param payload body models.InviteSettingsRequest true "Invite Settings Payload" -// @Success 200 {object} models.APITokenResponse -// @Failure 400 {object} models.Error -// @Failure 401 {object} models.Error -// @Failure 409 {object} models.Error "Returned when no channel could be found to create invite for." -// @Router /settings/noguildinvite [post] -func (c *GlobalSettingsController) postNoGuildInvites(ctx *fiber.Ctx) error { - req := new(models.InviteSettingsRequest) - if err := ctx.BodyParser(req); err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - - var err error - - if req.GuildID != "" { - - guild, err := c.st.Guild(req.GuildID, true) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - - if req.InviteCode != "" { - invites, err := c.session.GuildInvites(req.GuildID) - if err != nil { - return err - } - - var valid bool - for _, inv := range invites { - if inv.Code == req.InviteCode && !inv.Revoked { - valid = true - break - } - } - - if !valid { - return fiber.NewError(fiber.StatusBadRequest, "invalid invite code") - } - } else { - var channel *discordgo.Channel - chans, err := c.st.Channels(guild.ID, true) - if err != nil { - return err - } - for _, c := range chans { - if c.Type == discordgo.ChannelTypeGuildText { - channel = c - break - } - } - if channel == nil { - return fiber.NewError(fiber.StatusConflict, "could not find any channel to create invite for") - } - - invite, err := c.session.ChannelInviteCreate(channel.ID, discordgo.Invite{ - Temporary: false, - }) - if err != nil { - return err - } - - req.InviteCode = invite.Code - } - } - - if err = c.db.SetSetting(static.SettingWIInviteCode, req.InviteCode); err != nil { - return err - } - - if err = c.db.SetSetting(static.SettingWIInviteGuildID, req.GuildID); err != nil { - return err - } - - if err = c.db.SetSetting(static.SettingWIInviteText, req.Messsage); err != nil { - return err - } - - return ctx.JSON(models.Ok) -} +package controllers + +import ( + "fmt" + + "github.com/bwmarrin/discordgo" + "github.com/gofiber/fiber/v2" + "github.com/sarulabs/di/v2" + "github.com/zekroTJA/shinpuru/internal/services/database" + "github.com/zekroTJA/shinpuru/internal/services/permissions" + "github.com/zekroTJA/shinpuru/internal/services/webserver/v1/models" + "github.com/zekroTJA/shinpuru/internal/util/presence" + "github.com/zekroTJA/shinpuru/internal/util/static" + "github.com/zekrotja/dgrs" +) + +type GlobalSettingsController struct { + session Session + db Database + st State +} + +func (c *GlobalSettingsController) Setup(container di.Container, router fiber.Router) { + c.session = container.Get(static.DiDiscordSession).(*discordgo.Session) + c.db = container.Get(static.DiDatabase).(database.Database) + c.st = container.Get(static.DiState).(*dgrs.State) + + pmw := container.Get(static.DiPermissions).(*permissions.Permissions) + + router.Get("/presence", pmw.HandleWs(c.session, "sp.presence"), c.getPresence) + router.Post("/presence", pmw.HandleWs(c.session, "sp.presence"), c.postPresence) + router.Get("/noguildinvite", pmw.HandleWs(c.session, "sp.noguildinvite"), c.getNoGuildInvites) + router.Post("/noguildinvite", pmw.HandleWs(c.session, "sp.noguildinvite"), c.postNoGuildInvites) +} + +// @Summary Get Presence +// @Description Returns the bot's displayed presence status. +// @Tags Global Settings +// @Accept json +// @Produce json +// @Success 200 {object} presence.Presence +// @Failure 401 {object} models.Error +// @Router /settings/presence [get] +func (c *GlobalSettingsController) getPresence(ctx *fiber.Ctx) error { + presenceRaw, err := c.db.GetSetting(static.SettingPresence) + if err != nil { + if database.IsErrDatabaseNotFound(err) { + return ctx.JSON(&presence.Presence{ + Game: static.StdMotd, + Status: "online", + }) + } + return err + } + + pre, err := presence.Unmarshal(presenceRaw) + if err != nil { + return err + } + + return ctx.JSON(pre) +} + +// @Summary Set Presence +// @Description Set the bot's displayed presence status. +// @Tags Global Settings +// @Accept json +// @Produce json +// @Param payload body presence.Presence true "Presence Payload" +// @Success 200 {object} models.APITokenResponse +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error "Is returned when no token was generated before." +// @Router /settings/presence [post] +func (c *GlobalSettingsController) postPresence(ctx *fiber.Ctx) error { + pre := new(presence.Presence) + if err := ctx.BodyParser(pre); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + if err := pre.Validate(); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + if err := c.db.SetSetting(static.SettingPresence, pre.Marshal()); err != nil { + return err + } + + if err := c.session.UpdateStatusComplex(pre.ToUpdateStatusData()); err != nil { + return err + } + + return ctx.JSON(pre) +} + +// @Summary Get No Guild Invites Status +// @Description Returns the settings status for the suggested guild invite when the logged in user is not on any guild with shinpuru. +// @Tags Global Settings +// @Accept json +// @Produce json +// @Success 200 {object} models.InviteSettingsResponse +// @Failure 401 {object} models.Error +// @Failure 409 {object} models.Error "Returned when no channel could be found to create invite for." +// @Router /settings/noguildinvite [get] +func (c *GlobalSettingsController) getNoGuildInvites(ctx *fiber.Ctx) error { + var guildID, message, inviteCode string + var err error + + empty := func() error { return ctx.JSON(&models.InviteSettingsResponse{}) } + + if guildID, err = c.db.GetSetting(static.SettingWIInviteGuildID); err != nil { + if err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + } + + if guildID == "" { + return empty() + } + + if message, err = c.db.GetSetting(static.SettingWIInviteText); err != nil { + if err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + } + + if inviteCode, err = c.db.GetSetting(static.SettingWIInviteCode); err != nil { + if err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + } + + guild, err := c.st.Guild(guildID, true) + if apiErr, ok := err.(*discordgo.RESTError); ok && apiErr.Message.Code == discordgo.ErrCodeMissingAccess { + if err = c.db.SetSetting(static.SettingWIInviteGuildID, ""); err != nil { + return err + } + return empty() + } + + if err != nil { + return err + } + + invites, err := c.session.GuildInvites(guildID) + if err != nil { + return err + } + + if inviteCode != "" { + self, err := c.st.SelfUser() + if err != nil { + return err + } + for _, inv := range invites { + if inv.Inviter != nil && inv.Inviter.ID == self.ID && !inv.Revoked { + inviteCode = inv.Code + break + } + } + } + + if inviteCode == "" { + chans, err := c.st.Channels(guild.ID) + if err != nil { + return err + } + var channel *discordgo.Channel + for _, c := range chans { + if c.Type == discordgo.ChannelTypeGuildText { + channel = c + break + } + } + if channel == nil { + return fiber.NewError(fiber.StatusConflict, "could not find any channel to create invite for") + } + + invite, err := c.session.ChannelInviteCreate(channel.ID, discordgo.Invite{ + Temporary: false, + }) + if err != nil { + return err + } + + inviteCode = invite.Code + if err = c.db.SetSetting(static.SettingWIInviteCode, inviteCode); err != nil { + return err + } + } + + res := &models.InviteSettingsResponse{ + Message: message, + InviteURL: fmt.Sprintf("https://discord.gg/%s", inviteCode), + } + + res.Guild, err = models.GuildFromGuild(guild, nil, nil, "") + if err != nil { + return err + } + + return ctx.JSON(res) +} + +// @Summary Set No Guild Invites Status +// @Description Set the status for the suggested guild invite when the logged in user is not on any guild with shinpuru. +// @Tags Global Settings +// @Accept json +// @Produce json +// @Param payload body models.InviteSettingsRequest true "Invite Settings Payload" +// @Success 200 {object} models.APITokenResponse +// @Failure 400 {object} models.Error +// @Failure 401 {object} models.Error +// @Failure 409 {object} models.Error "Returned when no channel could be found to create invite for." +// @Router /settings/noguildinvite [post] +func (c *GlobalSettingsController) postNoGuildInvites(ctx *fiber.Ctx) error { + req := new(models.InviteSettingsRequest) + if err := ctx.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + var err error + + if req.GuildID != "" { + + guild, err := c.st.Guild(req.GuildID, true) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + if req.InviteCode != "" { + invites, err := c.session.GuildInvites(req.GuildID) + if err != nil { + return err + } + + var valid bool + for _, inv := range invites { + if inv.Code == req.InviteCode && !inv.Revoked { + valid = true + break + } + } + + if !valid { + return fiber.NewError(fiber.StatusBadRequest, "invalid invite code") + } + } else { + var channel *discordgo.Channel + chans, err := c.st.Channels(guild.ID, true) + if err != nil { + return err + } + for _, c := range chans { + if c.Type == discordgo.ChannelTypeGuildText { + channel = c + break + } + } + if channel == nil { + return fiber.NewError(fiber.StatusConflict, "could not find any channel to create invite for") + } + + invite, err := c.session.ChannelInviteCreate(channel.ID, discordgo.Invite{ + Temporary: false, + }) + if err != nil { + return err + } + + req.InviteCode = invite.Code + } + } + + if err = c.db.SetSetting(static.SettingWIInviteCode, req.InviteCode); err != nil { + return err + } + + if err = c.db.SetSetting(static.SettingWIInviteGuildID, req.GuildID); err != nil { + return err + } + + if err = c.db.SetSetting(static.SettingWIInviteText, req.Messsage); err != nil { + return err + } + + return ctx.JSON(models.Ok) +} diff --git a/internal/services/webserver/v1/controllers/guilds.go b/internal/services/webserver/v1/controllers/guilds.go index a2ada50a8..54ad22570 100644 --- a/internal/services/webserver/v1/controllers/guilds.go +++ b/internal/services/webserver/v1/controllers/guilds.go @@ -1,770 +1,770 @@ -package controllers - -import ( - "fmt" - "strings" - "time" - - _ "crypto/sha512" - - "github.com/bwmarrin/discordgo" - "github.com/bwmarrin/snowflake" - "github.com/gofiber/fiber/v2" - "github.com/makeworld-the-better-one/go-isemoji" - "github.com/sarulabs/di/v2" - sharedmodels "github.com/zekroTJA/shinpuru/internal/models" - "github.com/zekroTJA/shinpuru/internal/services/codeexec" - "github.com/zekroTJA/shinpuru/internal/services/config" - "github.com/zekroTJA/shinpuru/internal/services/database" - "github.com/zekroTJA/shinpuru/internal/services/guildlog" - "github.com/zekroTJA/shinpuru/internal/services/kvcache" - permservice "github.com/zekroTJA/shinpuru/internal/services/permissions" - "github.com/zekroTJA/shinpuru/internal/services/report" - "github.com/zekroTJA/shinpuru/internal/services/storage" - "github.com/zekroTJA/shinpuru/internal/services/timeprovider" - "github.com/zekroTJA/shinpuru/internal/services/verification" - "github.com/zekroTJA/shinpuru/internal/services/webserver/v1/models" - "github.com/zekroTJA/shinpuru/internal/services/webserver/wsutil" - "github.com/zekroTJA/shinpuru/internal/util/modnot" - "github.com/zekroTJA/shinpuru/internal/util/static" - "github.com/zekroTJA/shinpuru/pkg/discordutil" - "github.com/zekroTJA/shinpuru/pkg/permissions" - "github.com/zekroTJA/shinpuru/pkg/stringutil" - "github.com/zekrotja/dgrs" - "github.com/zekrotja/rogu/log" - "github.com/zekrotja/sop" -) - -type GuildsController struct { - db database.Database - st storage.Storage - kvc kvcache.Provider - session *discordgo.Session - cfg config.Provider - pmw *permservice.Permissions - state *dgrs.State - vs verification.Provider - cef codeexec.Factory - tp timeprovider.Provider - rep report.Provider - gl guildlog.Logger -} - -func (c *GuildsController) Setup(container di.Container, router fiber.Router) { - c.session = container.Get(static.DiDiscordSession).(*discordgo.Session) - c.cfg = container.Get(static.DiConfig).(config.Provider) - c.db = container.Get(static.DiDatabase).(database.Database) - c.pmw = container.Get(static.DiPermissions).(*permservice.Permissions) - c.kvc = container.Get(static.DiKVCache).(kvcache.Provider) - c.st = container.Get(static.DiObjectStorage).(storage.Storage) - c.state = container.Get(static.DiState).(*dgrs.State) - c.vs = container.Get(static.DiVerification).(verification.Provider) - c.cef = container.Get(static.DiCodeExecFactory).(codeexec.Factory) - c.tp = container.Get(static.DiTimeProvider).(timeprovider.Provider) - c.rep = container.Get(static.DiReport).(report.Provider) - c.gl = container.Get(static.DiGuildLog).(guildlog.Logger) - - router.Get("", c.getGuilds) - router.Get("/:guildid", c.getGuild) - router.Get("/:guildid/scoreboard", c.getGuildScoreboard) - router.Get("/:guildid/starboard", c.getGuildStarboard) - router.Get("/:guildid/starboard/count", c.getGuildStarboardCount) - router.Get("/:guildid/antiraid/joinlog", c.pmw.HandleWs(c.session, "sp.guild.config.antiraid"), c.getGuildAntiraidJoinlog) - router.Delete("/:guildid/antiraid/joinlog", c.pmw.HandleWs(c.session, "sp.guild.config.antiraid"), c.deleteGuildAntiraidJoinlog) - router.Get("/:guildid/reports", c.getReports) - router.Get("/:guildid/reports/count", c.getReportsCount) - router.Get("/:guildid/permissions", c.getGuildPermissions) - router.Post("/:guildid/permissions", c.pmw.HandleWs(c.session, "sp.guild.config.perms"), c.postGuildPermissions) - router.Post("/:guildid/inviteblock", c.pmw.HandleWs(c.session, "sp.guild.mod.inviteblock"), c.postGuildToggleInviteblock) - router.Get("/:guildid/unbanrequests", c.pmw.HandleWs(c.session, "sp.guild.mod.unbanrequests"), c.getGuildUnbanrequests) - router.Get("/:guildid/unbanrequests/count", c.pmw.HandleWs(c.session, "sp.guild.mod.unbanrequests"), c.getGuildUnbanrequestsCount) - router.Get("/:guildid/unbanrequests/:id", c.pmw.HandleWs(c.session, "sp.guild.mod.unbanrequests"), c.getGuildUnbanrequest) - router.Post("/:guildid/unbanrequests/:id", c.pmw.HandleWs(c.session, "sp.guild.mod.unbanrequests"), c.postGuildUnbanrequest) -} - -// @Summary List Guilds -// @Description Returns a list of guilds the authenticated user has in common with shinpuru. -// @Tags Guilds -// @Accept json -// @Produce json -// @Success 200 {array} models.GuildReduced "Wrapped in models.ListResponse" -// @Failure 401 {object} models.Error -// @Router /guilds [get] -func (c *GuildsController) getGuilds(ctx *fiber.Ctx) (err error) { - uid := ctx.Locals("uid").(string) - - guilds, err := c.state.Guilds() - if err != nil { - return err - } - - userGuilds, err := c.state.UserGuilds(uid) - if err != nil { - return - } - - guildRs := make([]*models.GuildReduced, len(userGuilds)) - i := 0 - for _, guild := range guilds { - if stringutil.ContainsAny(guild.ID, userGuilds) { - guildRs[i] = models.GuildReducedFromGuild(guild) - i++ - } - } - guildRs = guildRs[:i] - - return ctx.JSON(models.NewListResponse(guildRs)) -} - -// @Summary Get Guild -// @Description Returns a single guild object by it's ID. -// @Tags Guilds -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Success 200 {object} models.Guild -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id} [get] -func (c *GuildsController) getGuild(ctx *fiber.Ctx) error { - uid := ctx.Locals("uid").(string) - - guildID := ctx.Params("guildid") - - memb, _ := c.state.Member(guildID, uid) - if memb == nil { - return fiber.ErrNotFound - } - - guild, err := c.state.Guild(guildID, true) - if err != nil { - return err - } - - gRes, err := models.GuildFromGuild(guild, memb, c.db, c.cfg.Config().Discord.OwnerID) - if err != nil { - return err - } - - return ctx.JSON(gRes) -} - -// @Summary Get Guild Scoreboard -// @Description Returns a list of scoreboard entries for the given guild. -// @Tags Guilds -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Param limit query int false "Limit the amount of result values" default(25) minimum(1) maximum(100) -// @Success 200 {array} models.GuildKarmaEntry "Wrapped in models.ListResponse" -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/scoreboard [get] -func (c *GuildsController) getGuildScoreboard(ctx *fiber.Ctx) error { - guildID := ctx.Params("guildid") - limit, err := wsutil.GetQueryInt(ctx, "limit", 25, 1, 100) - if err != nil { - return err - } - - karmaList, err := c.db.GetKarmaGuild(guildID, limit) - - if err == database.ErrDatabaseNotFound { - return fiber.ErrNotFound - } else if err != nil { - return err - } - - results := make([]*models.GuildKarmaEntry, len(karmaList)) - - var i int - for _, e := range karmaList { - member, err := c.state.Member(guildID, e.UserID) - if err != nil { - continue - } - results[i] = &models.GuildKarmaEntry{ - Member: models.MemberFromMember(member), - Value: e.Value, - } - i++ - } - - return ctx.JSON(models.NewListResponse(results[:i])) -} - -// @Summary Get Antiraid Joinlog -// @Description Returns a list of joined members during an antiraid trigger. -// @Tags Guilds -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Success 200 {array} sharedmodels.JoinLogEntry "Wrapped in models.ListResponse" -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/antiraid/joinlog [get] -func (c *GuildsController) getGuildAntiraidJoinlog(ctx *fiber.Ctx) error { - guildID := ctx.Params("guildid") - - joinlog, err := c.db.GetAntiraidJoinList(guildID) - if err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - - if joinlog == nil { - joinlog = make([]sharedmodels.JoinLogEntry, 0) - } - - return ctx.JSON(models.NewListResponse(joinlog)) -} - -// @Summary Reset Antiraid Joinlog -// @Description Deletes all entries of the antiraid joinlog. -// @Tags Guilds -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Success 200 {object} models.Status -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/antiraid/joinlog [delete] -func (c *GuildsController) deleteGuildAntiraidJoinlog(ctx *fiber.Ctx) error { - guildID := ctx.Params("guildid") - - if err := c.db.FlushAntiraidJoinList(guildID); err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - - return ctx.JSON(models.Ok) -} - -// @Summary Get Guild Starboard -// @Description Returns a list of starboard entries for the given guild. -// @Tags Guilds -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Success 200 {array} models.StarboardEntryResponse "Wrapped in models.ListResponse" -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/starboard [get] -func (c *GuildsController) getGuildStarboard(ctx *fiber.Ctx) error { - guildID := ctx.Params("guildid") - limit, err := wsutil.GetQueryInt(ctx, "limit", 20, 1, 100) - if err != nil { - return err - } - offset, err := wsutil.GetQueryInt(ctx, "offset", 0, 0, 0) - if err != nil { - return err - } - sortQ := ctx.Query("sort") - - var sort sharedmodels.StarboardSortBy - switch string(sortQ) { - case "latest": - sort = sharedmodels.StarboardSortByLatest - case "top": - sort = sharedmodels.StarboardSortByMostRated - default: - return fiber.NewError(fiber.StatusBadRequest, "invalid sort property") - } - - entries, err := c.db.GetStarboardEntries(guildID, sort, limit, offset) - if err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - - results := make([]*models.StarboardEntryResponse, len(entries)) - - var i int - for _, e := range entries { - if e.Deleted { - continue - } - - member, err := c.state.Member(guildID, e.AuthorID) - if err != nil { - continue - } - - results[i] = &models.StarboardEntryResponse{ - StarboardEntry: e, - AuthorUsername: member.User.String(), - AvatarURL: member.User.AvatarURL(""), - MessageURL: discordutil.GetMessageLink(&discordgo.Message{ - ChannelID: e.ChannelID, - ID: e.MessageID, - }, guildID), - } - - i++ - } - - return ctx.JSON(models.NewListResponse(results[:i])) -} - -// @Summary Get Guild Starboard Count -// @Description Returns the count of starboard entries for the given guild. -// @Tags Guilds -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Success 200 {object} models.Count -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/starboard/count [get] -func (c *GuildsController) getGuildStarboardCount(ctx *fiber.Ctx) error { - guildID := ctx.Params("guildid") - - count, err := c.db.GetStarboardEntriesCount(guildID) - if err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - - return ctx.JSON(models.Count{Count: count}) -} - -// @Summary Get Guild Modlog -// @Description Returns a list of guild modlog entries for the given guild. -// @Tags Guilds -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Param offset query int false "The offset of returned entries" default(0) -// @Param limit query int false "The amount of returned entries (0 = all)" default(0) -// @Success 200 {array} models.Report "Wrapped in models.ListResponse" -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/reports [get] -func (c *GuildsController) getReports(ctx *fiber.Ctx) error { - uid := ctx.Locals("uid").(string) - - guildID := ctx.Params("guildid") - - offset, err := wsutil.GetQueryInt(ctx, "offset", 0, 0, 0) - if err != nil { - return err - } - - limit, err := wsutil.GetQueryInt(ctx, "limit", 0, 0, 0) - if err != nil { - return err - } - - if memb, _ := c.session.GuildMember(guildID, uid); memb == nil { - return fiber.ErrNotFound - } - - var reps []sharedmodels.Report - - reps, err = c.db.GetReportsGuild(guildID, offset, limit) - if err != nil { - return err - } - - resReps := make([]models.Report, 0) - if reps != nil { - resReps = make([]models.Report, len(reps)) - for i, r := range reps { - resReps[i] = models.ReportFromReport(r, c.cfg.Config().WebServer.PublicAddr) - user, err := c.state.User(r.VictimID) - if err == nil { - resReps[i].Victim = models.FlatUserFromUser(user) - } - user, err = c.state.User(r.ExecutorID) - if err == nil { - resReps[i].Executor = models.FlatUserFromUser(user) - } - } - } - - return ctx.JSON(models.NewListResponse(resReps)) -} - -// @Summary Get Guild Modlog Count -// @Description Returns the total count of entries in the guild mod log. -// @Tags Guilds -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Success 200 {object} models.Count -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/reports/count [get] -func (c *GuildsController) getReportsCount(ctx *fiber.Ctx) error { - uid := ctx.Locals("uid").(string) - - guildID := ctx.Params("guildid") - - if memb, _ := c.session.GuildMember(guildID, uid); memb == nil { - return fiber.ErrNotFound - } - - count, err := c.db.GetReportsGuildCount(guildID) - if err != nil { - return err - } - - return ctx.JSON(&models.Count{Count: count}) -} - -// @Summary Get Guild Permission Settings -// @Description Returns the specified guild permission settings. -// @Tags Guilds -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Success 200 {object} models.PermissionsMap -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/permissions [get] -func (c *GuildsController) getGuildPermissions(ctx *fiber.Ctx) error { - uid := ctx.Locals("uid").(string) - - guildID := ctx.Params("guildid") - - if memb, _ := c.session.GuildMember(guildID, uid); memb == nil { - return fiber.ErrNotFound - } - - var perms models.PermissionsMap - var err error - - if perms, err = c.db.GetGuildPermissions(guildID); err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - - return ctx.JSON(perms) -} - -// @Summary Apply Guild Permission Rule -// @Description Apply a new guild permission rule for a specified role. -// @Tags Guilds -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Param payload body models.PermissionsUpdate true "The permission rule payload." -// @Success 200 {object} models.PermissionsMap -// @Failure 400 {object} models.Error -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/permissions [post] -func (c *GuildsController) postGuildPermissions(ctx *fiber.Ctx) error { - guildID := ctx.Params("guildid") - - update := new(models.PermissionsUpdate) - if err := ctx.BodyParser(update); err != nil { - return fiber.ErrBadRequest - } - - sperm := update.Perm[1:] - if !strings.HasPrefix(sperm, "sp.guild") && !strings.HasPrefix(sperm, "sp.etc") && !strings.HasPrefix(sperm, "sp.chat") { - return fiber.NewError(fiber.StatusBadRequest, "you can only give permissions over the domains 'sp.guild', 'sp.etc' and 'sp.chat'") - } - - perms, err := c.db.GetGuildPermissions(guildID) - if err != nil { - if database.IsErrDatabaseNotFound(err) { - return fiber.ErrNotFound - } - return err - } - - for _, roleID := range update.RoleIDs { - rperms, ok := perms[roleID] - if !ok { - rperms = permissions.PermissionArray{} - } - - rperms, changed := rperms.Update(update.Perm, update.Override) - - if len(rperms) == 0 { - delete(perms, roleID) - } else { - perms[roleID] = rperms - } - - if changed { - if err = c.db.SetGuildRolePermission(guildID, roleID, rperms); err != nil { - return err - } - } - } - - return ctx.JSON(perms) -} - -// @Summary Toggle Guild Inviteblock Enable -// @Description Toggle enabled state of the guild invite block system. -// @Tags Guilds -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Param payload body models.EnableStatus true "The enable status payload." -// @Success 200 {object} models.Status -// @Failure 400 {object} models.Error -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/inviteblock [post] -func (c *GuildsController) postGuildToggleInviteblock(ctx *fiber.Ctx) error { - guildID := ctx.Params("guildid") - - var data models.EnableStatus - if err := ctx.BodyParser(&data); err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - - val := "" - if data.Enabled { - val = "1" - } - - if err := c.db.SetGuildInviteBlock(guildID, val); err != nil { - return err - } - - return ctx.JSON(models.Ok) -} - -// @Summary Get Guild Unbanrequests -// @Description Returns the list of the guild unban requests. -// @Tags Guilds -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Success 200 {array} models.RichUnbanRequest "Wrapped in models.ListReponse" -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/unbanrequests [get] -func (c *GuildsController) getGuildUnbanrequests(ctx *fiber.Ctx) error { - guildID := ctx.Params("guildid") - limit, err := wsutil.GetQueryInt(ctx, "limit", 20, 1, 100) - if err != nil { - return err - } - offset, err := wsutil.GetQueryInt(ctx, "offset", 0, 0, 0) - if err != nil { - return err - } - - requests, err := c.db.GetGuildUnbanRequests(guildID, limit, offset) - if err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - if requests == nil { - requests = make([]sharedmodels.UnbanRequest, 0) - } - - res := sop.Map[sharedmodels.UnbanRequest](sop.Slice(requests), - func(r sharedmodels.UnbanRequest, i int) *models.RichUnbanRequest { - r.Hydrate() - rub := &models.RichUnbanRequest{ - UnbanRequest: r, - } - if creator, _ := c.state.User(rub.UserID); creator != nil { - rub.Creator = models.FlatUserFromUser(creator) - } - if proc, _ := c.state.User(rub.ProcessedBy); proc != nil { - rub.Processor = models.FlatUserFromUser(proc) - } - return rub - }) - - return ctx.JSON(models.NewListResponse(res.Unwrap())) -} - -// @Summary Get Guild Unbanrequests Count -// @Description Returns the total or filtered count of guild unban requests. -// @Tags Guilds -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Param state query sharedmodels.UnbanRequestState false "Filter count by given state." -// @Success 200 {object} models.Count -// @Failure 400 {object} models.Error -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/unbanrequests/count [get] -func (c *GuildsController) getGuildUnbanrequestsCount(ctx *fiber.Ctx) error { - guildID := ctx.Params("guildid") - - stateFilter, err := wsutil.GetQueryInt(ctx, "state", -1, 0, 0) - if err != nil { - return err - } - - var stateFilterParam *sharedmodels.UnbanRequestState - if stateFilter > -1 { - filer := sharedmodels.UnbanRequestState(stateFilter) - stateFilterParam = &filer - } - - count, err := c.db.GetGuildUnbanRequestsCount(guildID, stateFilterParam) - if err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - - return ctx.JSON(&models.Count{Count: count}) -} - -// @Summary Get Single Guild Unbanrequest -// @Description Returns a single guild unban request by ID. -// @Tags Guilds -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Param requestid path string true "The ID of the unbanrequest." -// @Success 200 {object} models.RichUnbanRequest -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/unbanrequests/{requestid} [get] -func (c *GuildsController) getGuildUnbanrequest(ctx *fiber.Ctx) error { - guildID := ctx.Params("guildid") - id := ctx.Params("id") - - request, err := c.db.GetUnbanRequest(id) - if err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - if request.GuildID != guildID { - return fiber.ErrNotFound - } - - request.Hydrate() - rub := &models.RichUnbanRequest{ - UnbanRequest: request, - } - if creator, _ := c.state.User(rub.UserID); creator != nil { - rub.Processor = models.FlatUserFromUser(creator) - } - if proc, _ := c.state.User(rub.ProcessedBy); proc != nil { - rub.Processor = models.FlatUserFromUser(proc) - } - - return ctx.JSON(rub) -} - -// @Summary Process Guild Unbanrequest -// @Description Process a guild unban request. -// @Tags Guilds -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Param requestid path string true "The ID of the unbanrequest." -// @Success 200 {object} models.RichUnbanRequest -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/unbanrequests/{requestid} [post] -func (c *GuildsController) postGuildUnbanrequest(ctx *fiber.Ctx) error { - uid := ctx.Locals("uid").(string) - - guildID := ctx.Params("guildid") - id := ctx.Params("id") - - rUpdate := new(sharedmodels.UnbanRequest) - if err := ctx.BodyParser(rUpdate); err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - - request, err := c.db.GetUnbanRequest(id) - if err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - if request.GuildID != guildID { - return fiber.ErrNotFound - } - - if rUpdate.ProcessedMessage == "" { - return fiber.NewError(fiber.StatusBadRequest, "process reason message must be provided") - } - - if request.ID, err = snowflake.ParseString(id); err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - request.ProcessedBy = uid - request.Status = rUpdate.Status - request.Processed = c.tp.Now() - request.ProcessedMessage = rUpdate.ProcessedMessage - - _, err = c.rep.UnbanReport( - request, uid, rUpdate.ProcessedMessage, - request.Status == sharedmodels.UnbanRequestStateAccepted, - ) - if err != nil { - return err - } - - if err = c.db.UpdateUnbanRequest(request); err != nil { - return err - } - - if request.Status == sharedmodels.UnbanRequestStateAccepted { - if err = c.session.GuildBanDelete(request.GuildID, request.UserID); err != nil { - return err - } - } - - request.Hydrate() - rub := &models.RichUnbanRequest{ - UnbanRequest: request, - } - if creator, _ := c.state.User(rub.UserID); creator != nil { - rub.Creator = models.FlatUserFromUser(creator) - } - if proc, _ := c.state.User(rub.ProcessedBy); proc != nil { - rub.Processor = models.FlatUserFromUser(proc) - } - - emb := &discordgo.MessageEmbed{ - URL: fmt.Sprintf("%s/db/guilds/%s/modlog", - c.cfg.Config().WebServer.PublicAddr, guildID), - Fields: []*discordgo.MessageEmbedField{ - { - Name: "Target User", - Value: fmt.Sprintf("%s (`%s`)", rub.Creator.Username, rub.Creator.ID), - }, - { - Name: "Executed by", - Value: fmt.Sprintf("%s (`%s`)", rub.Processor.Username, rub.Processor.ID), - }, - { - Name: "Execution Message", - Value: rub.ProcessedMessage, - }, - }, - Footer: &discordgo.MessageEmbedFooter{ - Text: fmt.Sprintf("ID: %s", rub.ID), - }, - Timestamp: rub.Created.Format(time.RFC3339), - } - - switch rub.Status { - case sharedmodels.UnbanRequestStateAccepted: - emb.Title = "Unban request has been accepted" - emb.Color = static.ColorEmbedGreen - case sharedmodels.UnbanRequestStateDeclined: - emb.Title = "Unban request has been declined" - emb.Color = static.ColorEmbedOrange - } - - err = modnot.Send(c.db, c.session, guildID, emb) - if err != nil { - log.Error().Err(err).Tag("WebServer").Msg("Failed sending mod notification") - c.gl.Section("modnot").Errorf(guildID, "Failed sending mod notification: %s", err.Error()) - } - - return ctx.JSON(rub) -} - -// --------------------------------------------------------------------------- -// - HELPERS - -func checkEmojis(emojis []string) bool { - for _, e := range emojis { - if !isemoji.IsEmojiNonStrict(e) { - return false - } - } - return true -} +package controllers + +import ( + "fmt" + "strings" + "time" + + _ "crypto/sha512" + + "github.com/bwmarrin/discordgo" + "github.com/bwmarrin/snowflake" + "github.com/gofiber/fiber/v2" + "github.com/makeworld-the-better-one/go-isemoji" + "github.com/sarulabs/di/v2" + sharedmodels "github.com/zekroTJA/shinpuru/internal/models" + "github.com/zekroTJA/shinpuru/internal/services/codeexec" + "github.com/zekroTJA/shinpuru/internal/services/config" + "github.com/zekroTJA/shinpuru/internal/services/database" + "github.com/zekroTJA/shinpuru/internal/services/guildlog" + "github.com/zekroTJA/shinpuru/internal/services/kvcache" + permservice "github.com/zekroTJA/shinpuru/internal/services/permissions" + "github.com/zekroTJA/shinpuru/internal/services/report" + "github.com/zekroTJA/shinpuru/internal/services/storage" + "github.com/zekroTJA/shinpuru/internal/services/timeprovider" + "github.com/zekroTJA/shinpuru/internal/services/verification" + "github.com/zekroTJA/shinpuru/internal/services/webserver/v1/models" + "github.com/zekroTJA/shinpuru/internal/services/webserver/wsutil" + "github.com/zekroTJA/shinpuru/internal/util/modnot" + "github.com/zekroTJA/shinpuru/internal/util/static" + "github.com/zekroTJA/shinpuru/pkg/discordutil" + "github.com/zekroTJA/shinpuru/pkg/permissions" + "github.com/zekroTJA/shinpuru/pkg/stringutil" + "github.com/zekrotja/dgrs" + "github.com/zekrotja/rogu/log" + "github.com/zekrotja/sop" +) + +type GuildsController struct { + db Database + st storage.Storage + kvc kvcache.Provider + session *discordgo.Session + cfg config.Provider + pmw *permservice.Permissions + state *dgrs.State + vs verification.Provider + cef codeexec.Factory + tp timeprovider.Provider + rep report.Provider + gl guildlog.Logger +} + +func (c *GuildsController) Setup(container di.Container, router fiber.Router) { + c.session = container.Get(static.DiDiscordSession).(*discordgo.Session) + c.cfg = container.Get(static.DiConfig).(config.Provider) + c.db = container.Get(static.DiDatabase).(database.Database) + c.pmw = container.Get(static.DiPermissions).(*permservice.Permissions) + c.kvc = container.Get(static.DiKVCache).(kvcache.Provider) + c.st = container.Get(static.DiObjectStorage).(storage.Storage) + c.state = container.Get(static.DiState).(*dgrs.State) + c.vs = container.Get(static.DiVerification).(verification.Provider) + c.cef = container.Get(static.DiCodeExecFactory).(codeexec.Factory) + c.tp = container.Get(static.DiTimeProvider).(timeprovider.Provider) + c.rep = container.Get(static.DiReport).(report.Provider) + c.gl = container.Get(static.DiGuildLog).(guildlog.Logger) + + router.Get("", c.getGuilds) + router.Get("/:guildid", c.getGuild) + router.Get("/:guildid/scoreboard", c.getGuildScoreboard) + router.Get("/:guildid/starboard", c.getGuildStarboard) + router.Get("/:guildid/starboard/count", c.getGuildStarboardCount) + router.Get("/:guildid/antiraid/joinlog", c.pmw.HandleWs(c.session, "sp.guild.config.antiraid"), c.getGuildAntiraidJoinlog) + router.Delete("/:guildid/antiraid/joinlog", c.pmw.HandleWs(c.session, "sp.guild.config.antiraid"), c.deleteGuildAntiraidJoinlog) + router.Get("/:guildid/reports", c.getReports) + router.Get("/:guildid/reports/count", c.getReportsCount) + router.Get("/:guildid/permissions", c.getGuildPermissions) + router.Post("/:guildid/permissions", c.pmw.HandleWs(c.session, "sp.guild.config.perms"), c.postGuildPermissions) + router.Post("/:guildid/inviteblock", c.pmw.HandleWs(c.session, "sp.guild.mod.inviteblock"), c.postGuildToggleInviteblock) + router.Get("/:guildid/unbanrequests", c.pmw.HandleWs(c.session, "sp.guild.mod.unbanrequests"), c.getGuildUnbanrequests) + router.Get("/:guildid/unbanrequests/count", c.pmw.HandleWs(c.session, "sp.guild.mod.unbanrequests"), c.getGuildUnbanrequestsCount) + router.Get("/:guildid/unbanrequests/:id", c.pmw.HandleWs(c.session, "sp.guild.mod.unbanrequests"), c.getGuildUnbanrequest) + router.Post("/:guildid/unbanrequests/:id", c.pmw.HandleWs(c.session, "sp.guild.mod.unbanrequests"), c.postGuildUnbanrequest) +} + +// @Summary List Guilds +// @Description Returns a list of guilds the authenticated user has in common with shinpuru. +// @Tags Guilds +// @Accept json +// @Produce json +// @Success 200 {array} models.GuildReduced "Wrapped in models.ListResponse" +// @Failure 401 {object} models.Error +// @Router /guilds [get] +func (c *GuildsController) getGuilds(ctx *fiber.Ctx) (err error) { + uid := ctx.Locals("uid").(string) + + guilds, err := c.state.Guilds() + if err != nil { + return err + } + + userGuilds, err := c.state.UserGuilds(uid) + if err != nil { + return + } + + guildRs := make([]*models.GuildReduced, len(userGuilds)) + i := 0 + for _, guild := range guilds { + if stringutil.ContainsAny(guild.ID, userGuilds) { + guildRs[i] = models.GuildReducedFromGuild(guild) + i++ + } + } + guildRs = guildRs[:i] + + return ctx.JSON(models.NewListResponse(guildRs)) +} + +// @Summary Get Guild +// @Description Returns a single guild object by it's ID. +// @Tags Guilds +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Success 200 {object} models.Guild +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id} [get] +func (c *GuildsController) getGuild(ctx *fiber.Ctx) error { + uid := ctx.Locals("uid").(string) + + guildID := ctx.Params("guildid") + + memb, _ := c.state.Member(guildID, uid) + if memb == nil { + return fiber.ErrNotFound + } + + guild, err := c.state.Guild(guildID, true) + if err != nil { + return err + } + + gRes, err := models.GuildFromGuild(guild, memb, c.db, c.cfg.Config().Discord.OwnerID) + if err != nil { + return err + } + + return ctx.JSON(gRes) +} + +// @Summary Get Guild Scoreboard +// @Description Returns a list of scoreboard entries for the given guild. +// @Tags Guilds +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Param limit query int false "Limit the amount of result values" default(25) minimum(1) maximum(100) +// @Success 200 {array} models.GuildKarmaEntry "Wrapped in models.ListResponse" +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/scoreboard [get] +func (c *GuildsController) getGuildScoreboard(ctx *fiber.Ctx) error { + guildID := ctx.Params("guildid") + limit, err := wsutil.GetQueryInt(ctx, "limit", 25, 1, 100) + if err != nil { + return err + } + + karmaList, err := c.db.GetKarmaGuild(guildID, limit) + + if err == database.ErrDatabaseNotFound { + return fiber.ErrNotFound + } else if err != nil { + return err + } + + results := make([]*models.GuildKarmaEntry, len(karmaList)) + + var i int + for _, e := range karmaList { + member, err := c.state.Member(guildID, e.UserID) + if err != nil { + continue + } + results[i] = &models.GuildKarmaEntry{ + Member: models.MemberFromMember(member), + Value: e.Value, + } + i++ + } + + return ctx.JSON(models.NewListResponse(results[:i])) +} + +// @Summary Get Antiraid Joinlog +// @Description Returns a list of joined members during an antiraid trigger. +// @Tags Guilds +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Success 200 {array} sharedmodels.JoinLogEntry "Wrapped in models.ListResponse" +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/antiraid/joinlog [get] +func (c *GuildsController) getGuildAntiraidJoinlog(ctx *fiber.Ctx) error { + guildID := ctx.Params("guildid") + + joinlog, err := c.db.GetAntiraidJoinList(guildID) + if err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + + if joinlog == nil { + joinlog = make([]sharedmodels.JoinLogEntry, 0) + } + + return ctx.JSON(models.NewListResponse(joinlog)) +} + +// @Summary Reset Antiraid Joinlog +// @Description Deletes all entries of the antiraid joinlog. +// @Tags Guilds +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Success 200 {object} models.Status +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/antiraid/joinlog [delete] +func (c *GuildsController) deleteGuildAntiraidJoinlog(ctx *fiber.Ctx) error { + guildID := ctx.Params("guildid") + + if err := c.db.FlushAntiraidJoinList(guildID); err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + + return ctx.JSON(models.Ok) +} + +// @Summary Get Guild Starboard +// @Description Returns a list of starboard entries for the given guild. +// @Tags Guilds +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Success 200 {array} models.StarboardEntryResponse "Wrapped in models.ListResponse" +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/starboard [get] +func (c *GuildsController) getGuildStarboard(ctx *fiber.Ctx) error { + guildID := ctx.Params("guildid") + limit, err := wsutil.GetQueryInt(ctx, "limit", 20, 1, 100) + if err != nil { + return err + } + offset, err := wsutil.GetQueryInt(ctx, "offset", 0, 0, 0) + if err != nil { + return err + } + sortQ := ctx.Query("sort") + + var sort sharedmodels.StarboardSortBy + switch string(sortQ) { + case "latest": + sort = sharedmodels.StarboardSortByLatest + case "top": + sort = sharedmodels.StarboardSortByMostRated + default: + return fiber.NewError(fiber.StatusBadRequest, "invalid sort property") + } + + entries, err := c.db.GetStarboardEntries(guildID, sort, limit, offset) + if err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + + results := make([]*models.StarboardEntryResponse, len(entries)) + + var i int + for _, e := range entries { + if e.Deleted { + continue + } + + member, err := c.state.Member(guildID, e.AuthorID) + if err != nil { + continue + } + + results[i] = &models.StarboardEntryResponse{ + StarboardEntry: e, + AuthorUsername: member.User.String(), + AvatarURL: member.User.AvatarURL(""), + MessageURL: discordutil.GetMessageLink(&discordgo.Message{ + ChannelID: e.ChannelID, + ID: e.MessageID, + }, guildID), + } + + i++ + } + + return ctx.JSON(models.NewListResponse(results[:i])) +} + +// @Summary Get Guild Starboard Count +// @Description Returns the count of starboard entries for the given guild. +// @Tags Guilds +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Success 200 {object} models.Count +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/starboard/count [get] +func (c *GuildsController) getGuildStarboardCount(ctx *fiber.Ctx) error { + guildID := ctx.Params("guildid") + + count, err := c.db.GetStarboardEntriesCount(guildID) + if err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + + return ctx.JSON(models.Count{Count: count}) +} + +// @Summary Get Guild Modlog +// @Description Returns a list of guild modlog entries for the given guild. +// @Tags Guilds +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Param offset query int false "The offset of returned entries" default(0) +// @Param limit query int false "The amount of returned entries (0 = all)" default(0) +// @Success 200 {array} models.Report "Wrapped in models.ListResponse" +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/reports [get] +func (c *GuildsController) getReports(ctx *fiber.Ctx) error { + uid := ctx.Locals("uid").(string) + + guildID := ctx.Params("guildid") + + offset, err := wsutil.GetQueryInt(ctx, "offset", 0, 0, 0) + if err != nil { + return err + } + + limit, err := wsutil.GetQueryInt(ctx, "limit", 0, 0, 0) + if err != nil { + return err + } + + if memb, _ := c.session.GuildMember(guildID, uid); memb == nil { + return fiber.ErrNotFound + } + + var reps []sharedmodels.Report + + reps, err = c.db.GetReportsGuild(guildID, offset, limit) + if err != nil { + return err + } + + resReps := make([]models.Report, 0) + if reps != nil { + resReps = make([]models.Report, len(reps)) + for i, r := range reps { + resReps[i] = models.ReportFromReport(r, c.cfg.Config().WebServer.PublicAddr) + user, err := c.state.User(r.VictimID) + if err == nil { + resReps[i].Victim = models.FlatUserFromUser(user) + } + user, err = c.state.User(r.ExecutorID) + if err == nil { + resReps[i].Executor = models.FlatUserFromUser(user) + } + } + } + + return ctx.JSON(models.NewListResponse(resReps)) +} + +// @Summary Get Guild Modlog Count +// @Description Returns the total count of entries in the guild mod log. +// @Tags Guilds +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Success 200 {object} models.Count +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/reports/count [get] +func (c *GuildsController) getReportsCount(ctx *fiber.Ctx) error { + uid := ctx.Locals("uid").(string) + + guildID := ctx.Params("guildid") + + if memb, _ := c.session.GuildMember(guildID, uid); memb == nil { + return fiber.ErrNotFound + } + + count, err := c.db.GetReportsGuildCount(guildID) + if err != nil { + return err + } + + return ctx.JSON(&models.Count{Count: count}) +} + +// @Summary Get Guild Permission Settings +// @Description Returns the specified guild permission settings. +// @Tags Guilds +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Success 200 {object} models.PermissionsMap +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/permissions [get] +func (c *GuildsController) getGuildPermissions(ctx *fiber.Ctx) error { + uid := ctx.Locals("uid").(string) + + guildID := ctx.Params("guildid") + + if memb, _ := c.session.GuildMember(guildID, uid); memb == nil { + return fiber.ErrNotFound + } + + var perms models.PermissionsMap + var err error + + if perms, err = c.db.GetGuildPermissions(guildID); err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + + return ctx.JSON(perms) +} + +// @Summary Apply Guild Permission Rule +// @Description Apply a new guild permission rule for a specified role. +// @Tags Guilds +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Param payload body models.PermissionsUpdate true "The permission rule payload." +// @Success 200 {object} models.PermissionsMap +// @Failure 400 {object} models.Error +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/permissions [post] +func (c *GuildsController) postGuildPermissions(ctx *fiber.Ctx) error { + guildID := ctx.Params("guildid") + + update := new(models.PermissionsUpdate) + if err := ctx.BodyParser(update); err != nil { + return fiber.ErrBadRequest + } + + sperm := update.Perm[1:] + if !strings.HasPrefix(sperm, "sp.guild") && !strings.HasPrefix(sperm, "sp.etc") && !strings.HasPrefix(sperm, "sp.chat") { + return fiber.NewError(fiber.StatusBadRequest, "you can only give permissions over the domains 'sp.guild', 'sp.etc' and 'sp.chat'") + } + + perms, err := c.db.GetGuildPermissions(guildID) + if err != nil { + if database.IsErrDatabaseNotFound(err) { + return fiber.ErrNotFound + } + return err + } + + for _, roleID := range update.RoleIDs { + rperms, ok := perms[roleID] + if !ok { + rperms = permissions.PermissionArray{} + } + + rperms, changed := rperms.Update(update.Perm, update.Override) + + if len(rperms) == 0 { + delete(perms, roleID) + } else { + perms[roleID] = rperms + } + + if changed { + if err = c.db.SetGuildRolePermission(guildID, roleID, rperms); err != nil { + return err + } + } + } + + return ctx.JSON(perms) +} + +// @Summary Toggle Guild Inviteblock Enable +// @Description Toggle enabled state of the guild invite block system. +// @Tags Guilds +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Param payload body models.EnableStatus true "The enable status payload." +// @Success 200 {object} models.Status +// @Failure 400 {object} models.Error +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/inviteblock [post] +func (c *GuildsController) postGuildToggleInviteblock(ctx *fiber.Ctx) error { + guildID := ctx.Params("guildid") + + var data models.EnableStatus + if err := ctx.BodyParser(&data); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + val := "" + if data.Enabled { + val = "1" + } + + if err := c.db.SetGuildInviteBlock(guildID, val); err != nil { + return err + } + + return ctx.JSON(models.Ok) +} + +// @Summary Get Guild Unbanrequests +// @Description Returns the list of the guild unban requests. +// @Tags Guilds +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Success 200 {array} models.RichUnbanRequest "Wrapped in models.ListReponse" +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/unbanrequests [get] +func (c *GuildsController) getGuildUnbanrequests(ctx *fiber.Ctx) error { + guildID := ctx.Params("guildid") + limit, err := wsutil.GetQueryInt(ctx, "limit", 20, 1, 100) + if err != nil { + return err + } + offset, err := wsutil.GetQueryInt(ctx, "offset", 0, 0, 0) + if err != nil { + return err + } + + requests, err := c.db.GetGuildUnbanRequests(guildID, limit, offset) + if err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + if requests == nil { + requests = make([]sharedmodels.UnbanRequest, 0) + } + + res := sop.Map[sharedmodels.UnbanRequest](sop.Slice(requests), + func(r sharedmodels.UnbanRequest, i int) *models.RichUnbanRequest { + r.Hydrate() + rub := &models.RichUnbanRequest{ + UnbanRequest: r, + } + if creator, _ := c.state.User(rub.UserID); creator != nil { + rub.Creator = models.FlatUserFromUser(creator) + } + if proc, _ := c.state.User(rub.ProcessedBy); proc != nil { + rub.Processor = models.FlatUserFromUser(proc) + } + return rub + }) + + return ctx.JSON(models.NewListResponse(res.Unwrap())) +} + +// @Summary Get Guild Unbanrequests Count +// @Description Returns the total or filtered count of guild unban requests. +// @Tags Guilds +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Param state query sharedmodels.UnbanRequestState false "Filter count by given state." +// @Success 200 {object} models.Count +// @Failure 400 {object} models.Error +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/unbanrequests/count [get] +func (c *GuildsController) getGuildUnbanrequestsCount(ctx *fiber.Ctx) error { + guildID := ctx.Params("guildid") + + stateFilter, err := wsutil.GetQueryInt(ctx, "state", -1, 0, 0) + if err != nil { + return err + } + + var stateFilterParam *sharedmodels.UnbanRequestState + if stateFilter > -1 { + filer := sharedmodels.UnbanRequestState(stateFilter) + stateFilterParam = &filer + } + + count, err := c.db.GetGuildUnbanRequestsCount(guildID, stateFilterParam) + if err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + + return ctx.JSON(&models.Count{Count: count}) +} + +// @Summary Get Single Guild Unbanrequest +// @Description Returns a single guild unban request by ID. +// @Tags Guilds +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Param requestid path string true "The ID of the unbanrequest." +// @Success 200 {object} models.RichUnbanRequest +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/unbanrequests/{requestid} [get] +func (c *GuildsController) getGuildUnbanrequest(ctx *fiber.Ctx) error { + guildID := ctx.Params("guildid") + id := ctx.Params("id") + + request, err := c.db.GetUnbanRequest(id) + if err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + if request.GuildID != guildID { + return fiber.ErrNotFound + } + + request.Hydrate() + rub := &models.RichUnbanRequest{ + UnbanRequest: request, + } + if creator, _ := c.state.User(rub.UserID); creator != nil { + rub.Processor = models.FlatUserFromUser(creator) + } + if proc, _ := c.state.User(rub.ProcessedBy); proc != nil { + rub.Processor = models.FlatUserFromUser(proc) + } + + return ctx.JSON(rub) +} + +// @Summary Process Guild Unbanrequest +// @Description Process a guild unban request. +// @Tags Guilds +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Param requestid path string true "The ID of the unbanrequest." +// @Success 200 {object} models.RichUnbanRequest +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/unbanrequests/{requestid} [post] +func (c *GuildsController) postGuildUnbanrequest(ctx *fiber.Ctx) error { + uid := ctx.Locals("uid").(string) + + guildID := ctx.Params("guildid") + id := ctx.Params("id") + + rUpdate := new(sharedmodels.UnbanRequest) + if err := ctx.BodyParser(rUpdate); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + request, err := c.db.GetUnbanRequest(id) + if err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + if request.GuildID != guildID { + return fiber.ErrNotFound + } + + if rUpdate.ProcessedMessage == "" { + return fiber.NewError(fiber.StatusBadRequest, "process reason message must be provided") + } + + if request.ID, err = snowflake.ParseString(id); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + request.ProcessedBy = uid + request.Status = rUpdate.Status + request.Processed = c.tp.Now() + request.ProcessedMessage = rUpdate.ProcessedMessage + + _, err = c.rep.UnbanReport( + request, uid, rUpdate.ProcessedMessage, + request.Status == sharedmodels.UnbanRequestStateAccepted, + ) + if err != nil { + return err + } + + if err = c.db.UpdateUnbanRequest(request); err != nil { + return err + } + + if request.Status == sharedmodels.UnbanRequestStateAccepted { + if err = c.session.GuildBanDelete(request.GuildID, request.UserID); err != nil { + return err + } + } + + request.Hydrate() + rub := &models.RichUnbanRequest{ + UnbanRequest: request, + } + if creator, _ := c.state.User(rub.UserID); creator != nil { + rub.Creator = models.FlatUserFromUser(creator) + } + if proc, _ := c.state.User(rub.ProcessedBy); proc != nil { + rub.Processor = models.FlatUserFromUser(proc) + } + + emb := &discordgo.MessageEmbed{ + URL: fmt.Sprintf("%s/db/guilds/%s/modlog", + c.cfg.Config().WebServer.PublicAddr, guildID), + Fields: []*discordgo.MessageEmbedField{ + { + Name: "Target User", + Value: fmt.Sprintf("%s (`%s`)", rub.Creator.Username, rub.Creator.ID), + }, + { + Name: "Executed by", + Value: fmt.Sprintf("%s (`%s`)", rub.Processor.Username, rub.Processor.ID), + }, + { + Name: "Execution Message", + Value: rub.ProcessedMessage, + }, + }, + Footer: &discordgo.MessageEmbedFooter{ + Text: fmt.Sprintf("ID: %s", rub.ID), + }, + Timestamp: rub.Created.Format(time.RFC3339), + } + + switch rub.Status { + case sharedmodels.UnbanRequestStateAccepted: + emb.Title = "Unban request has been accepted" + emb.Color = static.ColorEmbedGreen + case sharedmodels.UnbanRequestStateDeclined: + emb.Title = "Unban request has been declined" + emb.Color = static.ColorEmbedOrange + } + + err = modnot.Send(c.db, c.session, guildID, emb) + if err != nil { + log.Error().Err(err).Tag("WebServer").Msg("Failed sending mod notification") + c.gl.Section("modnot").Errorf(guildID, "Failed sending mod notification: %s", err.Error()) + } + + return ctx.JSON(rub) +} + +// --------------------------------------------------------------------------- +// - HELPERS + +func checkEmojis(emojis []string) bool { + for _, e := range emojis { + if !isemoji.IsEmojiNonStrict(e) { + return false + } + } + return true +} diff --git a/internal/services/webserver/v1/controllers/guildsettings.go b/internal/services/webserver/v1/controllers/guildsettings.go index 36cf735e5..2069d746d 100644 --- a/internal/services/webserver/v1/controllers/guildsettings.go +++ b/internal/services/webserver/v1/controllers/guildsettings.go @@ -1,1221 +1,1222 @@ -package controllers - -import ( - "crypto" - "fmt" - "strings" - "time" - - "github.com/bwmarrin/discordgo" - "github.com/bwmarrin/snowflake" - "github.com/gofiber/fiber/v2" - "github.com/sarulabs/di/v2" - sharedmodels "github.com/zekroTJA/shinpuru/internal/models" - "github.com/zekroTJA/shinpuru/internal/services/codeexec" - "github.com/zekroTJA/shinpuru/internal/services/config" - "github.com/zekroTJA/shinpuru/internal/services/database" - "github.com/zekroTJA/shinpuru/internal/services/kvcache" - permservice "github.com/zekroTJA/shinpuru/internal/services/permissions" - "github.com/zekroTJA/shinpuru/internal/services/storage" - "github.com/zekroTJA/shinpuru/internal/services/verification" - "github.com/zekroTJA/shinpuru/internal/services/webserver/v1/models" - "github.com/zekroTJA/shinpuru/internal/services/webserver/wsutil" - "github.com/zekroTJA/shinpuru/internal/util" - "github.com/zekroTJA/shinpuru/internal/util/snowflakenodes" - "github.com/zekroTJA/shinpuru/internal/util/static" - "github.com/zekroTJA/shinpuru/pkg/fetch" - "github.com/zekroTJA/shinpuru/pkg/hashutil" - "github.com/zekroTJA/shinpuru/pkg/jdoodle" - "github.com/zekroTJA/shinpuru/pkg/stringutil" - "github.com/zekrotja/dgrs" -) - -type GuildsSettingsController struct { - db database.Database - st storage.Storage - kvc kvcache.Provider - session *discordgo.Session - cfg config.Provider - pmw *permservice.Permissions - state *dgrs.State - vs verification.Provider - cef codeexec.Factory -} - -func (c *GuildsSettingsController) Setup(container di.Container, router fiber.Router) { - c.session = container.Get(static.DiDiscordSession).(*discordgo.Session) - c.cfg = container.Get(static.DiConfig).(config.Provider) - c.db = container.Get(static.DiDatabase).(database.Database) - c.pmw = container.Get(static.DiPermissions).(*permservice.Permissions) - c.kvc = container.Get(static.DiKVCache).(kvcache.Provider) - c.st = container.Get(static.DiObjectStorage).(storage.Storage) - c.state = container.Get(static.DiState).(*dgrs.State) - c.vs = container.Get(static.DiVerification).(verification.Provider) - c.cef = container.Get(static.DiCodeExecFactory).(codeexec.Factory) - - router.Get("", c.getGuildSettings) - router.Post("", c.postGuildSettings) - router.Get("/karma", c.pmw.HandleWs(c.session, "sp.guild.config.karma"), c.getGuildSettingsKarma) - router.Post("/karma", c.pmw.HandleWs(c.session, "sp.guild.config.karma"), c.postGuildSettingsKarma) - router.Get("/karma/blocklist", c.pmw.HandleWs(c.session, "sp.guild.config.karma"), c.getGuildSettingsKarmaBlocklist) - router.Put("/karma/blocklist/:memberid", c.pmw.HandleWs(c.session, "sp.guild.config.karma"), c.putGuildSettingsKarmaBlocklist) - router.Delete("/karma/blocklist/:memberid", c.pmw.HandleWs(c.session, "sp.guild.config.karma"), c.deleteGuildSettingsKarmaBlocklist) - router.Get("/karma/rules", c.pmw.HandleWs(c.session, "sp.guild.config.karma"), c.getGuildSettingsKarmaRules) - router.Post("/karma/rules", c.pmw.HandleWs(c.session, "sp.guild.config.karma"), c.createGuildSettingsKrameRule) - router.Post("/karma/rules/:id", c.pmw.HandleWs(c.session, "sp.guild.config.karma"), c.updateGuildSettingsKrameRule) - router.Delete("/karma/rules/:id", c.pmw.HandleWs(c.session, "sp.guild.config.karma"), c.deleteGuildSettingsKrameRule) - router.Get("/antiraid", c.pmw.HandleWs(c.session, "sp.guild.config.antiraid"), c.getGuildSettingsAntiraid) - router.Post("/antiraid", c.pmw.HandleWs(c.session, "sp.guild.config.antiraid"), c.postGuildSettingsAntiraid) - router.Post("/antiraid/action", c.pmw.HandleWs(c.session, "sp.guild.config.antiraid"), c.postGuildSettingsAntiraidAction) - router.Get("/logs", c.pmw.HandleWs(c.session, "sp.guild.config.logs"), c.getGuildSettingsLogs) - router.Get("/logs/count", c.pmw.HandleWs(c.session, "sp.guild.config.logs"), c.getGuildSettingsLogsCount) - router.Delete("/logs", c.pmw.HandleWs(c.session, "sp.guild.config.logs"), c.deleteGuildSettingsLogEntries) - router.Delete("/logs/:id", c.pmw.HandleWs(c.session, "sp.guild.config.logs"), c.deleteGuildSettingsLogEntries) - router.Get("/logs/state", c.pmw.HandleWs(c.session, "sp.guild.config.logs"), c.getGuildSettingsLogsState) - router.Post("/logs/state", c.pmw.HandleWs(c.session, "sp.guild.config.logs"), c.postGuildSettingsLogsState) - router.Post("/flushguilddata", c.pmw.HandleWs(c.session, "sp.guild.admin.flushdata"), c.postFlushGuildData) - router.Get("/api", c.pmw.HandleWs(c.session, "sp.guild.config.api"), c.getGuildSettingsAPI) - router.Post("/api", c.pmw.HandleWs(c.session, "sp.guild.config.api"), c.postGuildSettingsAPI) - router.Get("/verification", c.pmw.HandleWs(c.session, "sp.guild.config.verification"), c.getGuildSettingsVerification) - router.Post("/verification", c.pmw.HandleWs(c.session, "sp.guild.config.verification"), c.postGuildSettingsVerification) - router.Get("/codeexec", c.pmw.HandleWs(c.session, "sp.guild.config.exec"), c.getGuildSettingsCodeExec) - router.Post("/codeexec", c.pmw.HandleWs(c.session, "sp.guild.config.exec"), c.postGuildSettingsCodeExec) -} - -// @Summary Get Guild Settings -// @Description Returns the specified general guild settings. -// @Tags Guild Settings -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Success 200 {object} models.GuildSettings -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/settings [get] -func (c *GuildsSettingsController) getGuildSettings(ctx *fiber.Ctx) error { - guildID := ctx.Params("guildid") - - gs := new(models.GuildSettings) - var err error - - if gs.Prefix, err = c.db.GetGuildPrefix(guildID); err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - - if gs.Perms, err = c.db.GetGuildPermissions(guildID); err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - - if gs.AutoRoles, err = c.db.GetGuildAutoRole(guildID); err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - - if gs.ModLogChannel, err = c.db.GetGuildModLog(guildID); err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - - if gs.ModNotChannel, err = c.db.GetGuildModNot(guildID); err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - - if gs.VoiceLogChannel, err = c.db.GetGuildVoiceLog(guildID); err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - - if gs.JoinMessageChannel, gs.JoinMessageText, err = c.db.GetGuildJoinMsg(guildID); err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - - if gs.LeaveMessageChannel, gs.LeaveMessageText, err = c.db.GetGuildLeaveMsg(guildID); err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - - return ctx.JSON(gs) -} - -// @Summary Get Guild Settings -// @Description Returns the specified general guild settings. -// @Tags Guild Settings -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Param payload body models.GuildSettings true "Modified guild settings payload." -// @Success 200 {object} models.Status -// @Failure 400 {object} models.Error -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/settings [post] -func (c *GuildsSettingsController) postGuildSettings(ctx *fiber.Ctx) error { - uid := ctx.Locals("uid").(string) - - guildID := ctx.Params("guildid") - - var err error - - gs := new(models.GuildSettings) - if err = ctx.BodyParser(gs); err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - - // TODO: Change `fiber.ErrUnauthorized` to `fiber.ErrForbidden` 👇 - - if gs.AutoRoles != nil { - if ok, _, err := c.pmw.CheckPermissions(c.session, guildID, uid, "sp.guild.config.autorole"); err != nil { - return wsutil.ErrInternalOrNotFound(err) - } else if !ok { - return fiber.ErrUnauthorized - } - - if stringutil.ContainsAny("@everyone", gs.AutoRoles) { - return fiber.NewError(fiber.StatusBadRequest, - "@everyone can not be set as autorole") - } - - guildRoles, err := c.state.Roles(guildID, true) - if err != nil { - return err - } - guildRoleIDs := make([]string, len(guildRoles)) - for i, role := range guildRoles { - guildRoleIDs[i] = role.ID - } - - if nc := stringutil.NotContained(gs.AutoRoles, guildRoleIDs); len(nc) > 0 { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf( - "Following RoleIDs are not existent on this guild: [%s]", strings.Join(nc, ", "))) - } - - if err = c.db.SetGuildAutoRole(guildID, gs.AutoRoles); err != nil { - return wsutil.ErrInternalOrNotFound(err) - } - } - - if gs.ModLogChannel != "" { - if ok, _, err := c.pmw.CheckPermissions(c.session, guildID, uid, "sp.guild.config.modlog"); err != nil { - return wsutil.ErrInternalOrNotFound(err) - } else if !ok { - return fiber.ErrUnauthorized - } - - if gs.ModLogChannel == "__RESET__" { - gs.ModLogChannel = "" - } - - if err = c.db.SetGuildModLog(guildID, gs.ModLogChannel); err != nil { - return wsutil.ErrInternalOrNotFound(err) - } - } - - if gs.ModNotChannel != "" { - if ok, _, err := c.pmw.CheckPermissions(c.session, guildID, uid, "sp.guild.config.modnot"); err != nil { - return wsutil.ErrInternalOrNotFound(err) - } else if !ok { - return fiber.ErrForbidden - } - - if gs.ModNotChannel == "__RESET__" { - gs.ModNotChannel = "" - } - - if err = c.db.SetGuildModNot(guildID, gs.ModNotChannel); err != nil { - return wsutil.ErrInternalOrNotFound(err) - } - } - - if gs.Prefix != "" { - if ok, _, err := c.pmw.CheckPermissions(c.session, guildID, uid, "sp.guild.config.prefix"); err != nil { - return wsutil.ErrInternalOrNotFound(err) - } else if !ok { - return fiber.ErrUnauthorized - } - - if gs.Prefix == "__RESET__" { - gs.Prefix = "" - } - - if err = c.db.SetGuildPrefix(guildID, gs.Prefix); err != nil { - return wsutil.ErrInternalOrNotFound(err) - } - } - - if gs.VoiceLogChannel != "" { - if ok, _, err := c.pmw.CheckPermissions(c.session, guildID, uid, "sp.guild.config.voicelog"); err != nil { - return wsutil.ErrInternalOrNotFound(err) - } else if !ok { - return fiber.ErrUnauthorized - } - - if gs.VoiceLogChannel == "__RESET__" { - gs.VoiceLogChannel = "" - } - - if err = c.db.SetGuildVoiceLog(guildID, gs.VoiceLogChannel); err != nil { - return wsutil.ErrInternalOrNotFound(err) - } - } - - if gs.JoinMessageChannel != "" && gs.JoinMessageText != "" { - if ok, _, err := c.pmw.CheckPermissions(c.session, guildID, uid, "sp.guild.config.announcements"); err != nil { - return wsutil.ErrInternalOrNotFound(err) - } else if !ok { - return fiber.ErrUnauthorized - } - - if gs.JoinMessageChannel == "__RESET__" && gs.JoinMessageText == "__RESET__" { - gs.JoinMessageChannel = "" - gs.JoinMessageText = "" - } - - if err = c.db.SetGuildJoinMsg(guildID, gs.JoinMessageChannel, gs.JoinMessageText); err != nil { - return wsutil.ErrInternalOrNotFound(err) - } - } - - if gs.LeaveMessageChannel != "" && gs.LeaveMessageText != "" { - if ok, _, err := c.pmw.CheckPermissions(c.session, guildID, uid, "sp.guild.config.announcements"); err != nil { - return wsutil.ErrInternalOrNotFound(err) - } else if !ok { - return fiber.ErrUnauthorized - } - - if gs.LeaveMessageChannel == "__RESET__" && gs.LeaveMessageText == "__RESET__" { - gs.LeaveMessageChannel = "" - gs.LeaveMessageText = "" - } - - if err = c.db.SetGuildLeaveMsg(guildID, gs.LeaveMessageChannel, gs.LeaveMessageText); err != nil { - return wsutil.ErrInternalOrNotFound(err) - } - } - - return ctx.JSON(models.Ok) -} - -// @Summary Get Guild Karma Settings -// @Description Returns the specified guild karma settings. -// @Tags Guild Settings -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Success 200 {object} models.KarmaSettings -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/settings/karma [get] -func (c *GuildsSettingsController) getGuildSettingsKarma(ctx *fiber.Ctx) error { - guildID := ctx.Params("guildid") - - settings := new(models.KarmaSettings) - - var err error - - if settings.State, err = c.db.GetKarmaState(guildID); err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - - if settings.Tokens, err = c.db.GetKarmaTokens(guildID); err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - - emotesInc, emotesDec, err := c.db.GetKarmaEmotes(guildID) - if err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - settings.EmotesIncrease = strings.Split(emotesInc, "") - settings.EmotesDecrease = strings.Split(emotesDec, "") - - if settings.Penalty, err = c.db.GetKarmaPenalty(guildID); err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - - return ctx.JSON(settings) -} - -// @Summary Update Guild Karma Settings -// @Description Update the guild karma settings specification. -// @Tags Guild Settings -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Param payload body models.KarmaSettings true "The guild karma settings payload." -// @Success 200 {object} models.Status -// @Failure 400 {object} models.Error -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/settings/karma [post] -func (c *GuildsSettingsController) postGuildSettingsKarma(ctx *fiber.Ctx) error { - guildID := ctx.Params("guildid") - - settings := new(models.KarmaSettings) - var err error - - if err = ctx.BodyParser(settings); err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - - if err = c.db.SetKarmaState(guildID, settings.State); err != nil { - return err - } - - if !checkEmojis(settings.EmotesIncrease) || !checkEmojis(settings.EmotesDecrease) { - return fiber.NewError(fiber.StatusBadRequest, "invalid emoji") - } - - emotesInc := strings.Join(settings.EmotesIncrease, "") - emotesDec := strings.Join(settings.EmotesDecrease, "") - if err = c.db.SetKarmaEmotes(guildID, emotesInc, emotesDec); err != nil { - return err - } - - if err = c.db.SetKarmaTokens(guildID, settings.Tokens); err != nil { - return err - } - - if err = c.db.SetKarmaPenalty(guildID, settings.Penalty); err != nil { - return err - } - - return ctx.JSON(models.Ok) -} - -// @Summary Get Guild Karma Blocklist -// @Description Returns the specified guild karma blocklist entries. -// @Tags Guild Settings -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Success 200 {array} models.Member "Wrapped in models.ListResponse" -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/settings/karma/blocklist [get] -func (c *GuildsSettingsController) getGuildSettingsKarmaBlocklist(ctx *fiber.Ctx) error { - guildID := ctx.Params("guildid") - - idList, err := c.db.GetKarmaBlockList(guildID) - if err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - - memberList := make([]*models.Member, len(idList)) - var m *discordgo.Member - var i int - for _, id := range idList { - if m, err = c.state.Member(guildID, id); err != nil { - continue - } - memberList[i] = models.MemberFromMember(m) - i++ - } - - memberList = memberList[:i] - - return ctx.JSON(models.NewListResponse(memberList)) -} - -// @Summary Add Guild Karma Blocklist Entry -// @Description Add a guild karma blocklist entry. -// @Tags Guild Settings -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Param memberid path string true "The ID of the guild." -// @Success 200 {object} models.Member -// @Failure 400 {object} models.Error -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/settings/karma/blocklist/{memberid} [put] -func (c *GuildsSettingsController) putGuildSettingsKarmaBlocklist(ctx *fiber.Ctx) error { - guildID := ctx.Params("guildid") - memberID := ctx.Params("memberid") - - memb, err := fetch.FetchMember(c.session, guildID, memberID) - if err == fetch.ErrNotFound { - return fiber.ErrNotFound - } - if err != nil { - return err - } - - ok, err := c.db.IsKarmaBlockListed(guildID, memb.User.ID) - if err != nil { - return err - } - if ok { - return fiber.NewError(fiber.StatusBadRequest, "member is already blocklisted") - } - - if err = c.db.AddKarmaBlockList(guildID, memb.User.ID); err != nil { - return err - } - - return ctx.JSON(memb) -} - -// @Summary Remove Guild Karma Blocklist Entry -// @Description Remove a guild karma blocklist entry. -// @Tags Guild Settings -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Param memberid path string true "The ID of the guild." -// @Success 200 {object} models.Status -// @Failure 400 {object} models.Error -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/settings/karma/blocklist/{memberid} [delete] -func (c *GuildsSettingsController) deleteGuildSettingsKarmaBlocklist(ctx *fiber.Ctx) error { - guildID := ctx.Params("guildid") - memberID := ctx.Params("memberid") - - ok, err := c.db.IsKarmaBlockListed(guildID, memberID) - if err != nil { - return err - } - if !ok { - return fiber.NewError(fiber.StatusBadRequest, "member is not blocklisted") - } - - if err = c.db.RemoveKarmaBlockList(guildID, memberID); err != nil { - return err - } - - return ctx.JSON(models.Ok) -} - -// @Summary Get Guild Antiraid Settings -// @Description Returns the specified guild antiraid settings. -// @Tags Guild Settings -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Success 200 {object} models.AntiraidSettings -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/settings/antiraid [get] -func (c *GuildsSettingsController) getGuildSettingsAntiraid(ctx *fiber.Ctx) error { - guildID := ctx.Params("guildid") - - settings := new(models.AntiraidSettings) - - var err error - if settings.State, err = c.db.GetAntiraidState(guildID); err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - - if settings.RegenerationPeriod, err = c.db.GetAntiraidRegeneration(guildID); err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - - if settings.Burst, err = c.db.GetAntiraidBurst(guildID); err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - - if settings.Verification, err = c.db.GetAntiraidVerification(guildID); err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - - return ctx.JSON(settings) -} - -// @Summary Update Guild Antiraid Settings -// @Description Update the guild antiraid settings specification. -// @Tags Guild Settings -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Param payload body models.AntiraidSettings true "The guild antiraid settings payload." -// @Success 200 {object} models.Status -// @Failure 400 {object} models.Error -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/settings/antiraid [post] -func (c *GuildsSettingsController) postGuildSettingsAntiraid(ctx *fiber.Ctx) error { - guildID := ctx.Params("guildid") - - settings := new(models.AntiraidSettings) - if err := ctx.BodyParser(settings); err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - - if settings.RegenerationPeriod < 1 { - return fiber.NewError(fiber.StatusBadRequest, "regeneration period must be larger than 0") - } - if settings.Burst < 1 { - return fiber.NewError(fiber.StatusBadRequest, "burst must be larger than 0") - } - - var err error - - if err = c.db.SetAntiraidState(guildID, settings.State); err != nil { - return err - } - - if err = c.db.SetAntiraidRegeneration(guildID, settings.RegenerationPeriod); err != nil { - return err - } - - if err = c.db.SetAntiraidBurst(guildID, settings.Burst); err != nil { - return err - } - - if err = c.db.SetAntiraidVerification(guildID, settings.Verification); err != nil { - return err - } - - return ctx.JSON(models.Ok) -} - -// @Summary Guild Antiraid Bulk Action -// @Description Execute a specific action on antiraid listed users -// @Tags Guild Settings -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Param payload body models.AntiraidAction true "The antiraid action payload." -// @Success 200 {object} models.Status -// @Failure 400 {object} models.Error -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/settings/antiraid/action [post] -func (c *GuildsSettingsController) postGuildSettingsAntiraidAction(ctx *fiber.Ctx) (err error) { - guildID := ctx.Params("guildid") - - var action models.AntiraidAction - if err = ctx.BodyParser(&action); err != nil { - return - } - - var actF func(id string) error - switch action.Type { - case models.AntiraidActionTypeKick: - actF = func(id string) error { - return c.session.GuildMemberDelete(guildID, id) - } - case models.AntiraidActionTypeBan: - actF = func(id string) error { - return c.session.GuildBanCreateWithReason(guildID, id, "antiraid purge", 7) - } - default: - return fiber.NewError(fiber.StatusBadRequest, "invalid action type") - } - - if len(action.IDs) == 0 { - return - } - - joinList, err := c.db.GetAntiraidJoinList(guildID) - if err != nil && !database.IsErrDatabaseNotFound(err) { - return fiber.NewError(fiber.StatusBadRequest, "ID list must contain entries") - } - - var contained int - for _, e := range joinList { - inner: - for _, id := range action.IDs { - if e.UserID == id { - contained++ - break inner - } - } - } - if contained != len(action.IDs) { - return fiber.NewError(fiber.StatusBadRequest, "ID list contains entry not contained in antiraid joinlist") - } - - for _, id := range action.IDs { - if err = actF(id); err != nil { - return - } - if err = c.db.RemoveAntiraidJoinList(guildID, id); err != nil { - return - } - } - - return ctx.JSON(models.Ok) -} - -// @Summary Get Guild Settings Karma Rules -// @Description Returns a list of specified guild karma rules. -// @Tags Guild Settings -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Success 200 {array} sharedmodels.KarmaRule "Wrapped in models.ListResponse" -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/settings/karma/rules [get] -func (c *GuildsSettingsController) getGuildSettingsKarmaRules(ctx *fiber.Ctx) error { - guildID := ctx.Params("guildid") - - rules, err := c.db.GetKarmaRules(guildID) - if err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - - return ctx.JSON(models.NewListResponse(rules)) -} - -// @Summary Create Guild Settings Karma -// @Description Create a guild karma rule. -// @Tags Guild Settings -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Param payload body sharedmodels.KarmaRule true "The karma rule payload." -// @Success 200 {object} sharedmodels.KarmaRule -// @Failure 400 {object} models.Error -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/settings/karma/rules [post] -func (c *GuildsSettingsController) createGuildSettingsKrameRule(ctx *fiber.Ctx) error { - guildID := ctx.Params("guildid") - - var rule sharedmodels.KarmaRule - if err := ctx.BodyParser(&rule); err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - - if err := rule.Validate(); err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - - rule.GuildID = guildID - rule.ID = snowflakenodes.NodeKarmaRules.Generate() - - if rule.Action == sharedmodels.KarmaActionToggleRole { - role, err := fetch.FetchRole(c.session, guildID, rule.Argument) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - rule.Argument = role.ID - } - - sum := rule.CalculateChecksum() - ok, err := c.db.CheckKarmaRule(guildID, sum) - if err != nil { - return err - } - if ok { - return fiber.NewError(fiber.StatusBadRequest, "same rule already exists") - } - - if err := c.db.AddOrUpdateKarmaRule(rule); err != nil { - return err - } - - return ctx.JSON(rule) -} - -// @Summary Update Guild Settings Karma -// @Description Update a karma rule by ID. -// @Tags Guild Settings -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Param ruleid path string true "The ID of the rule." -// @Param payload body sharedmodels.KarmaRule true "The karma rule update payload." -// @Success 200 {object} sharedmodels.KarmaRule -// @Failure 400 {object} models.Error -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/settings/karma/rules/{ruleid} [post] -func (c *GuildsSettingsController) updateGuildSettingsKrameRule(ctx *fiber.Ctx) (err error) { - guildID := ctx.Params("guildid") - id := ctx.Params("id") - - var rule sharedmodels.KarmaRule - if err := ctx.BodyParser(&rule); err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - - if err := rule.Validate(); err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - - rule.GuildID = guildID - rule.ID, err = snowflake.ParseString(id) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - - if rule.Action == sharedmodels.KarmaActionToggleRole { - role, err := fetch.FetchRole(c.session, guildID, rule.Argument) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - rule.Argument = role.ID - } - - sum := rule.CalculateChecksum() - ok, err := c.db.CheckKarmaRule(guildID, sum) - if err != nil { - return err - } - if ok { - return fiber.NewError(fiber.StatusBadRequest, "same rule already exists") - } - - if err := c.db.AddOrUpdateKarmaRule(rule); err != nil { - return err - } - - return ctx.JSON(rule) -} - -// @Summary Remove Guild Settings Karma -// @Description Remove a guild karma rule by ID. -// @Tags Guild Settings -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Param ruleid path string true "The ID of the rule." -// @Success 200 {object} models.State -// @Failure 400 {object} models.Error -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/settings/karma/rules/{ruleid} [delete] -func (c *GuildsSettingsController) deleteGuildSettingsKrameRule(ctx *fiber.Ctx) error { - guildID := ctx.Params("guildid") - id := ctx.Params("id") - - sfId, err := snowflake.ParseString(id) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - - if err := c.db.RemoveKarmaRule(guildID, sfId); err != nil { - return err - } - - return ctx.JSON(models.Ok) -} - -// @Summary Get Guild Log -// @Description Returns a list of entries of the guild log. -// @Tags Guild Settings -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Param limit query int false "The amount of values returned." default(50) minimum(1) maximum(1000) -// @Param offset query int false "The amount of values to be skipped." default(0) -// @Param severity query sharedmodels.GuildLogSeverity false "Filter by log severity." default(sharedmodels.GLAll) -// @Success 200 {array} sharedmodels.GuildLogEntry "Wrapped in models.ListResponse" -// @Failure 400 {object} models.Error -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/settings/logs [get] -func (c *GuildsSettingsController) getGuildSettingsLogs(ctx *fiber.Ctx) error { - guildID := ctx.Params("guildid") - - limit, err := wsutil.GetQueryInt(ctx, "limit", 50, 1, 1000) - if err != nil { - return err - } - offset, err := wsutil.GetQueryInt(ctx, "offset", 0, 0, 0) - if err != nil { - return err - } - severity, err := wsutil.GetQueryInt(ctx, "severity", - int(sharedmodels.GLAll), int(sharedmodels.GLAll), int(sharedmodels.GLFatal)) - if err != nil { - return err - } - order := ctx.Query("order", "desc") - ascending := order == "asc" - - res, err := c.db.GetGuildLogEntries( - guildID, offset, limit, sharedmodels.GuildLogSeverity(severity), ascending) - if err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - - return ctx.JSON(models.NewListResponse(res)) -} - -// @Summary Get Guild Log Count -// @Description Returns the total or filtered count of guild log entries. -// @Tags Guild Settings -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Param severity query sharedmodels.GuildLogSeverity false "Filter by log severity." default(sharedmodels.GLAll) -// @Success 200 {object} models.Count -// @Failure 400 {object} models.Error -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/settings/logs [get] -func (c *GuildsSettingsController) getGuildSettingsLogsCount(ctx *fiber.Ctx) error { - guildID := ctx.Params("guildid") - - severity, err := wsutil.GetQueryInt(ctx, "severity", - int(sharedmodels.GLAll), int(sharedmodels.GLAll), int(sharedmodels.GLFatal)) - if err != nil { - return err - } - - res, err := c.db.GetGuildLogEntriesCount(guildID, sharedmodels.GuildLogSeverity(severity)) - if err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - - return ctx.JSON(&models.Count{Count: res}) -} - -// @Summary Get Guild Settings Log State -// @Description Returns the enabled state of the guild log setting. -// @Tags Guild Settings -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Success 200 {object} models.State -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/settings/logs/state [get] -func (c *GuildsSettingsController) getGuildSettingsLogsState(ctx *fiber.Ctx) error { - guildID := ctx.Params("guildid") - - disabled, err := c.db.GetGuildLogDisable(guildID) - if err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - - return ctx.JSON(&models.State{ - State: !disabled, - }) -} - -// @Summary Update Guild Settings Log State -// @Description Update the enabled state of the log state guild setting. -// @Tags Guild Settings -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Param payload body models.State true "The state payload." -// @Success 200 {object} models.State -// @Failure 400 {object} models.Error -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/settings/logs/state [post] -func (c *GuildsSettingsController) postGuildSettingsLogsState(ctx *fiber.Ctx) error { - guildID := ctx.Params("guildid") - - state := new(models.State) - if err := ctx.BodyParser(state); err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - - err := c.db.SetGuildLogDisable(guildID, !state.State) - if err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - - return ctx.JSON(state) -} - -// @Summary Delete Guild Log Entries -// @Description Delete all guild log entries. -// @Tags Guild Settings -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Success 200 {object} models.State -// @Failure 400 {object} models.Error -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/settings/logs [delete] -// -// This is a dummy method for API doc generation. -func (*GuildsSettingsController) _(*fiber.Ctx) error { - return nil -} - -// @Summary Delete Guild Log Entries -// @Description Delete a single log entry. -// @Tags Guild Settings -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Param entryid path string true "The ID of the entry to be deleted." -// @Success 200 {object} models.State -// @Failure 400 {object} models.Error -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/settings/logs/{entryid} [delete] -func (c *GuildsSettingsController) deleteGuildSettingsLogEntries(ctx *fiber.Ctx) (err error) { - guildID := ctx.Params("guildid") - id := ctx.Params("id") - - if id != "" { - var ids snowflake.ID - ids, err = snowflake.ParseString(id) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - err = c.db.DeleteLogEntry(guildID, ids) - } else { - err = c.db.DeleteLogEntries(guildID) - } - - if database.IsErrDatabaseNotFound(err) { - return fiber.ErrNotFound - } - if err != nil { - return - } - - return ctx.JSON(models.Ok) -} - -// @Summary Flush Guild Data -// @Description Flushes all guild data from the database. -// @Tags Guild Settings -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Param payload body models.FlushGuildRequest true "The guild flush payload." -// @Success 200 {object} models.State -// @Failure 400 {object} models.Error -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/settings/flushguilddata [post] -func (c *GuildsSettingsController) postFlushGuildData(ctx *fiber.Ctx) (err error) { - guildID := ctx.Params("guildid") - - timeoutKey := "GUILDFLUSH:" + guildID - if reset, ok := c.kvc.Get(timeoutKey).(bool); reset && ok { - return fiber.NewError(fiber.StatusTooManyRequests, "this action can only be performed every 24 hours") - } - - guild, err := c.state.Guild(guildID) - if err != nil { - return - } - - var payload models.FlushGuildRequest - if err = ctx.BodyParser(&payload); err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - - if payload.Validation != guild.Name { - return fiber.NewError(fiber.StatusBadRequest, "invalid validation") - } - - if err = util.FlushAllGuildData(c.session, c.db, c.st, c.state, guildID); err != nil { - return - } - - if payload.LeaveAfter { - if err = c.session.GuildLeave(guildID); err != nil { - return - } - } - - c.kvc.Set(timeoutKey, true, 24*time.Hour) - - return ctx.JSON(models.Ok) -} - -// @Summary Get Guild Settings API State -// @Description Returns the settings state of the Guild API. -// @Tags Guild Settings -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Success 200 {object} sharedmodels.GuildAPISettings -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/settings/api [get] -func (c *GuildsSettingsController) getGuildSettingsAPI(ctx *fiber.Ctx) error { - guildID := ctx.Params("guildid") - - state, err := c.db.GetGuildAPI(guildID) - if err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - - state.Hydrate() - state.TokenHash = "" - return ctx.JSON(state) -} - -// @Summary Set Guild Settings API State -// @Description Set the settings state of the Guild API. -// @Tags Guild Settings -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Param payload body models.GuildAPISettingsRequest true "The guild API settings payload." -// @Success 200 {object} sharedmodels.GuildAPISettings -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/settings/api [post] -func (c *GuildsSettingsController) postGuildSettingsAPI(ctx *fiber.Ctx) (err error) { - guildID := ctx.Params("guildid") - - state, err := c.db.GetGuildAPI(guildID) - if err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - - newState := new(models.GuildAPISettingsRequest) - if err = ctx.BodyParser(newState); err != nil { - return - } - - newState.TokenHash = state.TokenHash - - if newState.ResetToken { - newState.TokenHash = "" - } else if newState.NewToken != "" { - hasher := hashutil.Hasher{HashFunc: crypto.SHA512, SaltSize: 128} - newState.TokenHash, err = hasher.Hash(newState.NewToken) - if err != nil { - return - } - } - - if err = c.db.SetGuildAPI(guildID, newState.GuildAPISettings); err != nil { - return - } - - newState.Hydrate() - newState.TokenHash = "" - return ctx.JSON(newState.GuildAPISettings) -} - -// @Summary Get Guild Settings Verification State -// @Description Returns the settings state of the Guild Verification. -// @Tags Guild Settings -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Success 200 {object} models.EnableStatus -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/settings/verification [get] -func (c *GuildsSettingsController) getGuildSettingsVerification(ctx *fiber.Ctx) error { - guildID := ctx.Params("guildid") - - state, err := c.vs.GetEnabled(guildID) - if err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - - res := models.EnableStatus{ - Enabled: state, - } - - return ctx.JSON(res) -} - -// @Summary Set Guild Settings Verification State -// @Description Set the settings state of the Guild Verification. -// @Tags Guild Settings -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Param payload body models.EnableStatus true "The guild API settings payload." -// @Success 200 {object} models.EnableStatus -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/settings/verification [post] -func (c *GuildsSettingsController) postGuildSettingsVerification(ctx *fiber.Ctx) (err error) { - guildID := ctx.Params("guildid") - - var state models.EnableStatus - if err = ctx.BodyParser(&state); err != nil { - return - } - - err = c.vs.SetEnabled(guildID, state.Enabled) - if err != nil { - return - } - - return ctx.JSON(state) -} - -// @Summary Get Guild Settings Code Exec State -// @Description Returns the settings state of the Guild Code Exec. -// @Tags Guild Settings -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Success 200 {object} models.EnableStatus -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/settings/codeexec [get] -func (c *GuildsSettingsController) getGuildSettingsCodeExec(ctx *fiber.Ctx) error { - guildID := ctx.Params("guildid") - - var ( - res models.CodeExecSettings - err error - ) - - res.Enabled, err = c.db.GetGuildCodeExecEnabled(guildID) - if err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - - res.Type = c.cef.Name() - - if res.Type == "jdoodle" { - creds, err := c.db.GetGuildJdoodleKey(guildID) - if err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - credsSplit := strings.Split(creds, "#") - if len(credsSplit) == 2 { - res.JdoodleClientId = credsSplit[0] - res.JdoodleClientSecret = credsSplit[1] - } - } - - return ctx.JSON(res) -} - -// @Summary Set Guild Settings Code Exec State -// @Description Set the settings state of the Guild Code Exec. -// @Tags Guild Settings -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Param payload body models.EnableStatus true "The guild API settings payload." -// @Success 200 {object} models.EnableStatus -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/settings/codeexec [post] -func (c *GuildsSettingsController) postGuildSettingsCodeExec(ctx *fiber.Ctx) (err error) { - guildID := ctx.Params("guildid") - - var state models.CodeExecSettings - if err = ctx.BodyParser(&state); err != nil { - return - } - - err = c.db.SetGuildCodeExecEnabled(guildID, state.Enabled) - if err != nil { - return - } - - if c.cef.Name() == "jdoodle" { - var creds string - if state.JdoodleClientId == "" && state.JdoodleClientSecret == "" { - } else if state.JdoodleClientId != "" && state.JdoodleClientSecret != "" { - _, err = jdoodle.NewWrapper(state.JdoodleClientId, state.JdoodleClientSecret).CreditsSpent() - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, "The JDoodle credentials are invalid.") - } - creds = fmt.Sprintf("%s#%s", state.JdoodleClientId, state.JdoodleClientSecret) - } else { - return fiber.NewError(fiber.StatusBadRequest, "Either both credential values must be empty or both must be defined!") - } - - err = c.db.SetGuildJdoodleKey(guildID, creds) - if err != nil { - return - } - } - - return ctx.JSON(state) -} +package controllers + +import ( + "crypto" + "fmt" + "github.com/zekroTJA/shinpuru/internal/util/privacy" + "strings" + "time" + + "github.com/bwmarrin/discordgo" + "github.com/bwmarrin/snowflake" + "github.com/gofiber/fiber/v2" + "github.com/sarulabs/di/v2" + sharedmodels "github.com/zekroTJA/shinpuru/internal/models" + "github.com/zekroTJA/shinpuru/internal/services/codeexec" + "github.com/zekroTJA/shinpuru/internal/services/config" + "github.com/zekroTJA/shinpuru/internal/services/database" + "github.com/zekroTJA/shinpuru/internal/services/kvcache" + permservice "github.com/zekroTJA/shinpuru/internal/services/permissions" + "github.com/zekroTJA/shinpuru/internal/services/storage" + "github.com/zekroTJA/shinpuru/internal/services/verification" + "github.com/zekroTJA/shinpuru/internal/services/webserver/v1/models" + "github.com/zekroTJA/shinpuru/internal/services/webserver/wsutil" + "github.com/zekroTJA/shinpuru/internal/util/snowflakenodes" + "github.com/zekroTJA/shinpuru/internal/util/static" + "github.com/zekroTJA/shinpuru/pkg/fetch" + "github.com/zekroTJA/shinpuru/pkg/hashutil" + "github.com/zekroTJA/shinpuru/pkg/jdoodle" + "github.com/zekroTJA/shinpuru/pkg/stringutil" + "github.com/zekrotja/dgrs" +) + +type GuildsSettingsController struct { + db Database + st Storage + session Session + state State + kvc KvCache + + cfg config.Provider + pmw *permservice.Permissions + vs verification.Provider + cef codeexec.Factory +} + +func (c *GuildsSettingsController) Setup(container di.Container, router fiber.Router) { + c.session = container.Get(static.DiDiscordSession).(*discordgo.Session) + c.cfg = container.Get(static.DiConfig).(config.Provider) + c.db = container.Get(static.DiDatabase).(database.Database) + c.pmw = container.Get(static.DiPermissions).(*permservice.Permissions) + c.kvc = container.Get(static.DiKVCache).(kvcache.Provider) + c.st = container.Get(static.DiObjectStorage).(storage.Storage) + c.state = container.Get(static.DiState).(*dgrs.State) + c.vs = container.Get(static.DiVerification).(verification.Provider) + c.cef = container.Get(static.DiCodeExecFactory).(codeexec.Factory) + + router.Get("", c.getGuildSettings) + router.Post("", c.postGuildSettings) + router.Get("/karma", c.pmw.HandleWs(c.session, "sp.guild.config.karma"), c.getGuildSettingsKarma) + router.Post("/karma", c.pmw.HandleWs(c.session, "sp.guild.config.karma"), c.postGuildSettingsKarma) + router.Get("/karma/blocklist", c.pmw.HandleWs(c.session, "sp.guild.config.karma"), c.getGuildSettingsKarmaBlocklist) + router.Put("/karma/blocklist/:memberid", c.pmw.HandleWs(c.session, "sp.guild.config.karma"), c.putGuildSettingsKarmaBlocklist) + router.Delete("/karma/blocklist/:memberid", c.pmw.HandleWs(c.session, "sp.guild.config.karma"), c.deleteGuildSettingsKarmaBlocklist) + router.Get("/karma/rules", c.pmw.HandleWs(c.session, "sp.guild.config.karma"), c.getGuildSettingsKarmaRules) + router.Post("/karma/rules", c.pmw.HandleWs(c.session, "sp.guild.config.karma"), c.createGuildSettingsKrameRule) + router.Post("/karma/rules/:id", c.pmw.HandleWs(c.session, "sp.guild.config.karma"), c.updateGuildSettingsKrameRule) + router.Delete("/karma/rules/:id", c.pmw.HandleWs(c.session, "sp.guild.config.karma"), c.deleteGuildSettingsKrameRule) + router.Get("/antiraid", c.pmw.HandleWs(c.session, "sp.guild.config.antiraid"), c.getGuildSettingsAntiraid) + router.Post("/antiraid", c.pmw.HandleWs(c.session, "sp.guild.config.antiraid"), c.postGuildSettingsAntiraid) + router.Post("/antiraid/action", c.pmw.HandleWs(c.session, "sp.guild.config.antiraid"), c.postGuildSettingsAntiraidAction) + router.Get("/logs", c.pmw.HandleWs(c.session, "sp.guild.config.logs"), c.getGuildSettingsLogs) + router.Get("/logs/count", c.pmw.HandleWs(c.session, "sp.guild.config.logs"), c.getGuildSettingsLogsCount) + router.Delete("/logs", c.pmw.HandleWs(c.session, "sp.guild.config.logs"), c.deleteGuildSettingsLogEntries) + router.Delete("/logs/:id", c.pmw.HandleWs(c.session, "sp.guild.config.logs"), c.deleteGuildSettingsLogEntries) + router.Get("/logs/state", c.pmw.HandleWs(c.session, "sp.guild.config.logs"), c.getGuildSettingsLogsState) + router.Post("/logs/state", c.pmw.HandleWs(c.session, "sp.guild.config.logs"), c.postGuildSettingsLogsState) + router.Post("/flushguilddata", c.pmw.HandleWs(c.session, "sp.guild.admin.flushdata"), c.postFlushGuildData) + router.Get("/api", c.pmw.HandleWs(c.session, "sp.guild.config.api"), c.getGuildSettingsAPI) + router.Post("/api", c.pmw.HandleWs(c.session, "sp.guild.config.api"), c.postGuildSettingsAPI) + router.Get("/verification", c.pmw.HandleWs(c.session, "sp.guild.config.verification"), c.getGuildSettingsVerification) + router.Post("/verification", c.pmw.HandleWs(c.session, "sp.guild.config.verification"), c.postGuildSettingsVerification) + router.Get("/codeexec", c.pmw.HandleWs(c.session, "sp.guild.config.exec"), c.getGuildSettingsCodeExec) + router.Post("/codeexec", c.pmw.HandleWs(c.session, "sp.guild.config.exec"), c.postGuildSettingsCodeExec) +} + +// @Summary Get Guild Settings +// @Description Returns the specified general guild settings. +// @Tags Guild Settings +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Success 200 {object} models.GuildSettings +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/settings [get] +func (c *GuildsSettingsController) getGuildSettings(ctx *fiber.Ctx) error { + guildID := ctx.Params("guildid") + + gs := new(models.GuildSettings) + var err error + + if gs.Prefix, err = c.db.GetGuildPrefix(guildID); err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + + if gs.Perms, err = c.db.GetGuildPermissions(guildID); err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + + if gs.AutoRoles, err = c.db.GetGuildAutoRole(guildID); err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + + if gs.ModLogChannel, err = c.db.GetGuildModLog(guildID); err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + + if gs.ModNotChannel, err = c.db.GetGuildModNot(guildID); err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + + if gs.VoiceLogChannel, err = c.db.GetGuildVoiceLog(guildID); err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + + if gs.JoinMessageChannel, gs.JoinMessageText, err = c.db.GetGuildJoinMsg(guildID); err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + + if gs.LeaveMessageChannel, gs.LeaveMessageText, err = c.db.GetGuildLeaveMsg(guildID); err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + + return ctx.JSON(gs) +} + +// @Summary Get Guild Settings +// @Description Returns the specified general guild settings. +// @Tags Guild Settings +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Param payload body models.GuildSettings true "Modified guild settings payload." +// @Success 200 {object} models.Status +// @Failure 400 {object} models.Error +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/settings [post] +func (c *GuildsSettingsController) postGuildSettings(ctx *fiber.Ctx) error { + uid := ctx.Locals("uid").(string) + + guildID := ctx.Params("guildid") + + var err error + + gs := new(models.GuildSettings) + if err = ctx.BodyParser(gs); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + // TODO: Change `fiber.ErrUnauthorized` to `fiber.ErrForbidden` 👇 + + if gs.AutoRoles != nil { + if ok, _, err := c.pmw.CheckPermissions(c.session, guildID, uid, "sp.guild.config.autorole"); err != nil { + return wsutil.ErrInternalOrNotFound(err) + } else if !ok { + return fiber.ErrUnauthorized + } + + if stringutil.ContainsAny("@everyone", gs.AutoRoles) { + return fiber.NewError(fiber.StatusBadRequest, + "@everyone can not be set as autorole") + } + + guildRoles, err := c.state.Roles(guildID, true) + if err != nil { + return err + } + guildRoleIDs := make([]string, len(guildRoles)) + for i, role := range guildRoles { + guildRoleIDs[i] = role.ID + } + + if nc := stringutil.NotContained(gs.AutoRoles, guildRoleIDs); len(nc) > 0 { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf( + "Following RoleIDs are not existent on this guild: [%s]", strings.Join(nc, ", "))) + } + + if err = c.db.SetGuildAutoRole(guildID, gs.AutoRoles); err != nil { + return wsutil.ErrInternalOrNotFound(err) + } + } + + if gs.ModLogChannel != "" { + if ok, _, err := c.pmw.CheckPermissions(c.session, guildID, uid, "sp.guild.config.modlog"); err != nil { + return wsutil.ErrInternalOrNotFound(err) + } else if !ok { + return fiber.ErrUnauthorized + } + + if gs.ModLogChannel == "__RESET__" { + gs.ModLogChannel = "" + } + + if err = c.db.SetGuildModLog(guildID, gs.ModLogChannel); err != nil { + return wsutil.ErrInternalOrNotFound(err) + } + } + + if gs.ModNotChannel != "" { + if ok, _, err := c.pmw.CheckPermissions(c.session, guildID, uid, "sp.guild.config.modnot"); err != nil { + return wsutil.ErrInternalOrNotFound(err) + } else if !ok { + return fiber.ErrForbidden + } + + if gs.ModNotChannel == "__RESET__" { + gs.ModNotChannel = "" + } + + if err = c.db.SetGuildModNot(guildID, gs.ModNotChannel); err != nil { + return wsutil.ErrInternalOrNotFound(err) + } + } + + if gs.Prefix != "" { + if ok, _, err := c.pmw.CheckPermissions(c.session, guildID, uid, "sp.guild.config.prefix"); err != nil { + return wsutil.ErrInternalOrNotFound(err) + } else if !ok { + return fiber.ErrUnauthorized + } + + if gs.Prefix == "__RESET__" { + gs.Prefix = "" + } + + if err = c.db.SetGuildPrefix(guildID, gs.Prefix); err != nil { + return wsutil.ErrInternalOrNotFound(err) + } + } + + if gs.VoiceLogChannel != "" { + if ok, _, err := c.pmw.CheckPermissions(c.session, guildID, uid, "sp.guild.config.voicelog"); err != nil { + return wsutil.ErrInternalOrNotFound(err) + } else if !ok { + return fiber.ErrUnauthorized + } + + if gs.VoiceLogChannel == "__RESET__" { + gs.VoiceLogChannel = "" + } + + if err = c.db.SetGuildVoiceLog(guildID, gs.VoiceLogChannel); err != nil { + return wsutil.ErrInternalOrNotFound(err) + } + } + + if gs.JoinMessageChannel != "" && gs.JoinMessageText != "" { + if ok, _, err := c.pmw.CheckPermissions(c.session, guildID, uid, "sp.guild.config.announcements"); err != nil { + return wsutil.ErrInternalOrNotFound(err) + } else if !ok { + return fiber.ErrUnauthorized + } + + if gs.JoinMessageChannel == "__RESET__" && gs.JoinMessageText == "__RESET__" { + gs.JoinMessageChannel = "" + gs.JoinMessageText = "" + } + + if err = c.db.SetGuildJoinMsg(guildID, gs.JoinMessageChannel, gs.JoinMessageText); err != nil { + return wsutil.ErrInternalOrNotFound(err) + } + } + + if gs.LeaveMessageChannel != "" && gs.LeaveMessageText != "" { + if ok, _, err := c.pmw.CheckPermissions(c.session, guildID, uid, "sp.guild.config.announcements"); err != nil { + return wsutil.ErrInternalOrNotFound(err) + } else if !ok { + return fiber.ErrUnauthorized + } + + if gs.LeaveMessageChannel == "__RESET__" && gs.LeaveMessageText == "__RESET__" { + gs.LeaveMessageChannel = "" + gs.LeaveMessageText = "" + } + + if err = c.db.SetGuildLeaveMsg(guildID, gs.LeaveMessageChannel, gs.LeaveMessageText); err != nil { + return wsutil.ErrInternalOrNotFound(err) + } + } + + return ctx.JSON(models.Ok) +} + +// @Summary Get Guild Karma Settings +// @Description Returns the specified guild karma settings. +// @Tags Guild Settings +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Success 200 {object} models.KarmaSettings +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/settings/karma [get] +func (c *GuildsSettingsController) getGuildSettingsKarma(ctx *fiber.Ctx) error { + guildID := ctx.Params("guildid") + + settings := new(models.KarmaSettings) + + var err error + + if settings.State, err = c.db.GetKarmaState(guildID); err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + + if settings.Tokens, err = c.db.GetKarmaTokens(guildID); err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + + emotesInc, emotesDec, err := c.db.GetKarmaEmotes(guildID) + if err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + settings.EmotesIncrease = strings.Split(emotesInc, "") + settings.EmotesDecrease = strings.Split(emotesDec, "") + + if settings.Penalty, err = c.db.GetKarmaPenalty(guildID); err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + + return ctx.JSON(settings) +} + +// @Summary Update Guild Karma Settings +// @Description Update the guild karma settings specification. +// @Tags Guild Settings +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Param payload body models.KarmaSettings true "The guild karma settings payload." +// @Success 200 {object} models.Status +// @Failure 400 {object} models.Error +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/settings/karma [post] +func (c *GuildsSettingsController) postGuildSettingsKarma(ctx *fiber.Ctx) error { + guildID := ctx.Params("guildid") + + settings := new(models.KarmaSettings) + var err error + + if err = ctx.BodyParser(settings); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + if err = c.db.SetKarmaState(guildID, settings.State); err != nil { + return err + } + + if !checkEmojis(settings.EmotesIncrease) || !checkEmojis(settings.EmotesDecrease) { + return fiber.NewError(fiber.StatusBadRequest, "invalid emoji") + } + + emotesInc := strings.Join(settings.EmotesIncrease, "") + emotesDec := strings.Join(settings.EmotesDecrease, "") + if err = c.db.SetKarmaEmotes(guildID, emotesInc, emotesDec); err != nil { + return err + } + + if err = c.db.SetKarmaTokens(guildID, settings.Tokens); err != nil { + return err + } + + if err = c.db.SetKarmaPenalty(guildID, settings.Penalty); err != nil { + return err + } + + return ctx.JSON(models.Ok) +} + +// @Summary Get Guild Karma Blocklist +// @Description Returns the specified guild karma blocklist entries. +// @Tags Guild Settings +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Success 200 {array} models.Member "Wrapped in models.ListResponse" +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/settings/karma/blocklist [get] +func (c *GuildsSettingsController) getGuildSettingsKarmaBlocklist(ctx *fiber.Ctx) error { + guildID := ctx.Params("guildid") + + idList, err := c.db.GetKarmaBlockList(guildID) + if err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + + memberList := make([]*models.Member, len(idList)) + var m *discordgo.Member + var i int + for _, id := range idList { + if m, err = c.state.Member(guildID, id); err != nil { + continue + } + memberList[i] = models.MemberFromMember(m) + i++ + } + + memberList = memberList[:i] + + return ctx.JSON(models.NewListResponse(memberList)) +} + +// @Summary Add Guild Karma Blocklist Entry +// @Description Add a guild karma blocklist entry. +// @Tags Guild Settings +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Param memberid path string true "The ID of the guild." +// @Success 200 {object} models.Member +// @Failure 400 {object} models.Error +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/settings/karma/blocklist/{memberid} [put] +func (c *GuildsSettingsController) putGuildSettingsKarmaBlocklist(ctx *fiber.Ctx) error { + guildID := ctx.Params("guildid") + memberID := ctx.Params("memberid") + + memb, err := fetch.FetchMember(c.session, guildID, memberID) + if err == fetch.ErrNotFound { + return fiber.ErrNotFound + } + if err != nil { + return err + } + + ok, err := c.db.IsKarmaBlockListed(guildID, memb.User.ID) + if err != nil { + return err + } + if ok { + return fiber.NewError(fiber.StatusBadRequest, "member is already blocklisted") + } + + if err = c.db.AddKarmaBlockList(guildID, memb.User.ID); err != nil { + return err + } + + return ctx.JSON(memb) +} + +// @Summary Remove Guild Karma Blocklist Entry +// @Description Remove a guild karma blocklist entry. +// @Tags Guild Settings +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Param memberid path string true "The ID of the guild." +// @Success 200 {object} models.Status +// @Failure 400 {object} models.Error +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/settings/karma/blocklist/{memberid} [delete] +func (c *GuildsSettingsController) deleteGuildSettingsKarmaBlocklist(ctx *fiber.Ctx) error { + guildID := ctx.Params("guildid") + memberID := ctx.Params("memberid") + + ok, err := c.db.IsKarmaBlockListed(guildID, memberID) + if err != nil { + return err + } + if !ok { + return fiber.NewError(fiber.StatusBadRequest, "member is not blocklisted") + } + + if err = c.db.RemoveKarmaBlockList(guildID, memberID); err != nil { + return err + } + + return ctx.JSON(models.Ok) +} + +// @Summary Get Guild Antiraid Settings +// @Description Returns the specified guild antiraid settings. +// @Tags Guild Settings +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Success 200 {object} models.AntiraidSettings +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/settings/antiraid [get] +func (c *GuildsSettingsController) getGuildSettingsAntiraid(ctx *fiber.Ctx) error { + guildID := ctx.Params("guildid") + + settings := new(models.AntiraidSettings) + + var err error + if settings.State, err = c.db.GetAntiraidState(guildID); err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + + if settings.RegenerationPeriod, err = c.db.GetAntiraidRegeneration(guildID); err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + + if settings.Burst, err = c.db.GetAntiraidBurst(guildID); err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + + if settings.Verification, err = c.db.GetAntiraidVerification(guildID); err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + + return ctx.JSON(settings) +} + +// @Summary Update Guild Antiraid Settings +// @Description Update the guild antiraid settings specification. +// @Tags Guild Settings +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Param payload body models.AntiraidSettings true "The guild antiraid settings payload." +// @Success 200 {object} models.Status +// @Failure 400 {object} models.Error +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/settings/antiraid [post] +func (c *GuildsSettingsController) postGuildSettingsAntiraid(ctx *fiber.Ctx) error { + guildID := ctx.Params("guildid") + + settings := new(models.AntiraidSettings) + if err := ctx.BodyParser(settings); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + if settings.RegenerationPeriod < 1 { + return fiber.NewError(fiber.StatusBadRequest, "regeneration period must be larger than 0") + } + if settings.Burst < 1 { + return fiber.NewError(fiber.StatusBadRequest, "burst must be larger than 0") + } + + var err error + + if err = c.db.SetAntiraidState(guildID, settings.State); err != nil { + return err + } + + if err = c.db.SetAntiraidRegeneration(guildID, settings.RegenerationPeriod); err != nil { + return err + } + + if err = c.db.SetAntiraidBurst(guildID, settings.Burst); err != nil { + return err + } + + if err = c.db.SetAntiraidVerification(guildID, settings.Verification); err != nil { + return err + } + + return ctx.JSON(models.Ok) +} + +// @Summary Guild Antiraid Bulk Action +// @Description Execute a specific action on antiraid listed users +// @Tags Guild Settings +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Param payload body models.AntiraidAction true "The antiraid action payload." +// @Success 200 {object} models.Status +// @Failure 400 {object} models.Error +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/settings/antiraid/action [post] +func (c *GuildsSettingsController) postGuildSettingsAntiraidAction(ctx *fiber.Ctx) (err error) { + guildID := ctx.Params("guildid") + + var action models.AntiraidAction + if err = ctx.BodyParser(&action); err != nil { + return + } + + var actF func(id string) error + switch action.Type { + case models.AntiraidActionTypeKick: + actF = func(id string) error { + return c.session.GuildMemberDelete(guildID, id) + } + case models.AntiraidActionTypeBan: + actF = func(id string) error { + return c.session.GuildBanCreateWithReason(guildID, id, "antiraid purge", 7) + } + default: + return fiber.NewError(fiber.StatusBadRequest, "invalid action type") + } + + if len(action.IDs) == 0 { + return + } + + joinList, err := c.db.GetAntiraidJoinList(guildID) + if err != nil && !database.IsErrDatabaseNotFound(err) { + return fiber.NewError(fiber.StatusBadRequest, "ID list must contain entries") + } + + var contained int + for _, e := range joinList { + inner: + for _, id := range action.IDs { + if e.UserID == id { + contained++ + break inner + } + } + } + if contained != len(action.IDs) { + return fiber.NewError(fiber.StatusBadRequest, "ID list contains entry not contained in antiraid joinlist") + } + + for _, id := range action.IDs { + if err = actF(id); err != nil { + return + } + if err = c.db.RemoveAntiraidJoinList(guildID, id); err != nil { + return + } + } + + return ctx.JSON(models.Ok) +} + +// @Summary Get Guild Settings Karma Rules +// @Description Returns a list of specified guild karma rules. +// @Tags Guild Settings +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Success 200 {array} sharedmodels.KarmaRule "Wrapped in models.ListResponse" +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/settings/karma/rules [get] +func (c *GuildsSettingsController) getGuildSettingsKarmaRules(ctx *fiber.Ctx) error { + guildID := ctx.Params("guildid") + + rules, err := c.db.GetKarmaRules(guildID) + if err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + + return ctx.JSON(models.NewListResponse(rules)) +} + +// @Summary Create Guild Settings Karma +// @Description Create a guild karma rule. +// @Tags Guild Settings +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Param payload body sharedmodels.KarmaRule true "The karma rule payload." +// @Success 200 {object} sharedmodels.KarmaRule +// @Failure 400 {object} models.Error +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/settings/karma/rules [post] +func (c *GuildsSettingsController) createGuildSettingsKrameRule(ctx *fiber.Ctx) error { + guildID := ctx.Params("guildid") + + var rule sharedmodels.KarmaRule + if err := ctx.BodyParser(&rule); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + if err := rule.Validate(); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + rule.GuildID = guildID + rule.ID = snowflakenodes.NodeKarmaRules.Generate() + + if rule.Action == sharedmodels.KarmaActionToggleRole { + role, err := fetch.FetchRole(c.session, guildID, rule.Argument) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + rule.Argument = role.ID + } + + sum := rule.CalculateChecksum() + ok, err := c.db.CheckKarmaRule(guildID, sum) + if err != nil { + return err + } + if ok { + return fiber.NewError(fiber.StatusBadRequest, "same rule already exists") + } + + if err := c.db.AddOrUpdateKarmaRule(rule); err != nil { + return err + } + + return ctx.JSON(rule) +} + +// @Summary Update Guild Settings Karma +// @Description Update a karma rule by ID. +// @Tags Guild Settings +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Param ruleid path string true "The ID of the rule." +// @Param payload body sharedmodels.KarmaRule true "The karma rule update payload." +// @Success 200 {object} sharedmodels.KarmaRule +// @Failure 400 {object} models.Error +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/settings/karma/rules/{ruleid} [post] +func (c *GuildsSettingsController) updateGuildSettingsKrameRule(ctx *fiber.Ctx) (err error) { + guildID := ctx.Params("guildid") + id := ctx.Params("id") + + var rule sharedmodels.KarmaRule + if err := ctx.BodyParser(&rule); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + if err := rule.Validate(); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + rule.GuildID = guildID + rule.ID, err = snowflake.ParseString(id) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + if rule.Action == sharedmodels.KarmaActionToggleRole { + role, err := fetch.FetchRole(c.session, guildID, rule.Argument) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + rule.Argument = role.ID + } + + sum := rule.CalculateChecksum() + ok, err := c.db.CheckKarmaRule(guildID, sum) + if err != nil { + return err + } + if ok { + return fiber.NewError(fiber.StatusBadRequest, "same rule already exists") + } + + if err := c.db.AddOrUpdateKarmaRule(rule); err != nil { + return err + } + + return ctx.JSON(rule) +} + +// @Summary Remove Guild Settings Karma +// @Description Remove a guild karma rule by ID. +// @Tags Guild Settings +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Param ruleid path string true "The ID of the rule." +// @Success 200 {object} models.State +// @Failure 400 {object} models.Error +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/settings/karma/rules/{ruleid} [delete] +func (c *GuildsSettingsController) deleteGuildSettingsKrameRule(ctx *fiber.Ctx) error { + guildID := ctx.Params("guildid") + id := ctx.Params("id") + + sfId, err := snowflake.ParseString(id) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + if err := c.db.RemoveKarmaRule(guildID, sfId); err != nil { + return err + } + + return ctx.JSON(models.Ok) +} + +// @Summary Get Guild Log +// @Description Returns a list of entries of the guild log. +// @Tags Guild Settings +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Param limit query int false "The amount of values returned." default(50) minimum(1) maximum(1000) +// @Param offset query int false "The amount of values to be skipped." default(0) +// @Param severity query sharedmodels.GuildLogSeverity false "Filter by log severity." default(sharedmodels.GLAll) +// @Success 200 {array} sharedmodels.GuildLogEntry "Wrapped in models.ListResponse" +// @Failure 400 {object} models.Error +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/settings/logs [get] +func (c *GuildsSettingsController) getGuildSettingsLogs(ctx *fiber.Ctx) error { + guildID := ctx.Params("guildid") + + limit, err := wsutil.GetQueryInt(ctx, "limit", 50, 1, 1000) + if err != nil { + return err + } + offset, err := wsutil.GetQueryInt(ctx, "offset", 0, 0, 0) + if err != nil { + return err + } + severity, err := wsutil.GetQueryInt(ctx, "severity", + int(sharedmodels.GLAll), int(sharedmodels.GLAll), int(sharedmodels.GLFatal)) + if err != nil { + return err + } + order := ctx.Query("order", "desc") + ascending := order == "asc" + + res, err := c.db.GetGuildLogEntries( + guildID, offset, limit, sharedmodels.GuildLogSeverity(severity), ascending) + if err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + + return ctx.JSON(models.NewListResponse(res)) +} + +// @Summary Get Guild Log Count +// @Description Returns the total or filtered count of guild log entries. +// @Tags Guild Settings +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Param severity query sharedmodels.GuildLogSeverity false "Filter by log severity." default(sharedmodels.GLAll) +// @Success 200 {object} models.Count +// @Failure 400 {object} models.Error +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/settings/logs [get] +func (c *GuildsSettingsController) getGuildSettingsLogsCount(ctx *fiber.Ctx) error { + guildID := ctx.Params("guildid") + + severity, err := wsutil.GetQueryInt(ctx, "severity", + int(sharedmodels.GLAll), int(sharedmodels.GLAll), int(sharedmodels.GLFatal)) + if err != nil { + return err + } + + res, err := c.db.GetGuildLogEntriesCount(guildID, sharedmodels.GuildLogSeverity(severity)) + if err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + + return ctx.JSON(&models.Count{Count: res}) +} + +// @Summary Get Guild Settings Log State +// @Description Returns the enabled state of the guild log setting. +// @Tags Guild Settings +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Success 200 {object} models.State +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/settings/logs/state [get] +func (c *GuildsSettingsController) getGuildSettingsLogsState(ctx *fiber.Ctx) error { + guildID := ctx.Params("guildid") + + disabled, err := c.db.GetGuildLogDisable(guildID) + if err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + + return ctx.JSON(&models.State{ + State: !disabled, + }) +} + +// @Summary Update Guild Settings Log State +// @Description Update the enabled state of the log state guild setting. +// @Tags Guild Settings +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Param payload body models.State true "The state payload." +// @Success 200 {object} models.State +// @Failure 400 {object} models.Error +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/settings/logs/state [post] +func (c *GuildsSettingsController) postGuildSettingsLogsState(ctx *fiber.Ctx) error { + guildID := ctx.Params("guildid") + + state := new(models.State) + if err := ctx.BodyParser(state); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + err := c.db.SetGuildLogDisable(guildID, !state.State) + if err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + + return ctx.JSON(state) +} + +// @Summary Delete Guild Log Entries +// @Description Delete all guild log entries. +// @Tags Guild Settings +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Success 200 {object} models.State +// @Failure 400 {object} models.Error +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/settings/logs [delete] +// +// This is a dummy method for API doc generation. +func (*GuildsSettingsController) _(*fiber.Ctx) error { + return nil +} + +// @Summary Delete Guild Log Entries +// @Description Delete a single log entry. +// @Tags Guild Settings +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Param entryid path string true "The ID of the entry to be deleted." +// @Success 200 {object} models.State +// @Failure 400 {object} models.Error +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/settings/logs/{entryid} [delete] +func (c *GuildsSettingsController) deleteGuildSettingsLogEntries(ctx *fiber.Ctx) (err error) { + guildID := ctx.Params("guildid") + id := ctx.Params("id") + + if id != "" { + var ids snowflake.ID + ids, err = snowflake.ParseString(id) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + err = c.db.DeleteLogEntry(guildID, ids) + } else { + err = c.db.DeleteLogEntries(guildID) + } + + if database.IsErrDatabaseNotFound(err) { + return fiber.ErrNotFound + } + if err != nil { + return + } + + return ctx.JSON(models.Ok) +} + +// @Summary Flush Guild Data +// @Description Flushes all guild data from the database. +// @Tags Guild Settings +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Param payload body models.FlushGuildRequest true "The guild flush payload." +// @Success 200 {object} models.State +// @Failure 400 {object} models.Error +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/settings/flushguilddata [post] +func (c *GuildsSettingsController) postFlushGuildData(ctx *fiber.Ctx) (err error) { + guildID := ctx.Params("guildid") + + timeoutKey := "GUILDFLUSH:" + guildID + if reset, ok := c.kvc.Get(timeoutKey).(bool); reset && ok { + return fiber.NewError(fiber.StatusTooManyRequests, "this action can only be performed every 24 hours") + } + + guild, err := c.state.Guild(guildID) + if err != nil { + return + } + + var payload models.FlushGuildRequest + if err = ctx.BodyParser(&payload); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + if payload.Validation != guild.Name { + return fiber.NewError(fiber.StatusBadRequest, "invalid validation") + } + + if err = privacy.FlushAllGuildData(c.session, c.db, c.st, c.state, guildID); err != nil { + return + } + + if payload.LeaveAfter { + if err = c.session.GuildLeave(guildID); err != nil { + return + } + } + + c.kvc.Set(timeoutKey, true, 24*time.Hour) + + return ctx.JSON(models.Ok) +} + +// @Summary Get Guild Settings API State +// @Description Returns the settings state of the Guild API. +// @Tags Guild Settings +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Success 200 {object} sharedmodels.GuildAPISettings +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/settings/api [get] +func (c *GuildsSettingsController) getGuildSettingsAPI(ctx *fiber.Ctx) error { + guildID := ctx.Params("guildid") + + state, err := c.db.GetGuildAPI(guildID) + if err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + + state.Hydrate() + state.TokenHash = "" + return ctx.JSON(state) +} + +// @Summary Set Guild Settings API State +// @Description Set the settings state of the Guild API. +// @Tags Guild Settings +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Param payload body models.GuildAPISettingsRequest true "The guild API settings payload." +// @Success 200 {object} sharedmodels.GuildAPISettings +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/settings/api [post] +func (c *GuildsSettingsController) postGuildSettingsAPI(ctx *fiber.Ctx) (err error) { + guildID := ctx.Params("guildid") + + state, err := c.db.GetGuildAPI(guildID) + if err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + + newState := new(models.GuildAPISettingsRequest) + if err = ctx.BodyParser(newState); err != nil { + return + } + + newState.TokenHash = state.TokenHash + + if newState.ResetToken { + newState.TokenHash = "" + } else if newState.NewToken != "" { + hasher := hashutil.Hasher{HashFunc: crypto.SHA512, SaltSize: 128} + newState.TokenHash, err = hasher.Hash(newState.NewToken) + if err != nil { + return + } + } + + if err = c.db.SetGuildAPI(guildID, newState.GuildAPISettings); err != nil { + return + } + + newState.Hydrate() + newState.TokenHash = "" + return ctx.JSON(newState.GuildAPISettings) +} + +// @Summary Get Guild Settings Verification State +// @Description Returns the settings state of the Guild Verification. +// @Tags Guild Settings +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Success 200 {object} models.EnableStatus +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/settings/verification [get] +func (c *GuildsSettingsController) getGuildSettingsVerification(ctx *fiber.Ctx) error { + guildID := ctx.Params("guildid") + + state, err := c.vs.GetEnabled(guildID) + if err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + + res := models.EnableStatus{ + Enabled: state, + } + + return ctx.JSON(res) +} + +// @Summary Set Guild Settings Verification State +// @Description Set the settings state of the Guild Verification. +// @Tags Guild Settings +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Param payload body models.EnableStatus true "The guild API settings payload." +// @Success 200 {object} models.EnableStatus +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/settings/verification [post] +func (c *GuildsSettingsController) postGuildSettingsVerification(ctx *fiber.Ctx) (err error) { + guildID := ctx.Params("guildid") + + var state models.EnableStatus + if err = ctx.BodyParser(&state); err != nil { + return + } + + err = c.vs.SetEnabled(guildID, state.Enabled) + if err != nil { + return + } + + return ctx.JSON(state) +} + +// @Summary Get Guild Settings Code Exec State +// @Description Returns the settings state of the Guild Code Exec. +// @Tags Guild Settings +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Success 200 {object} models.EnableStatus +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/settings/codeexec [get] +func (c *GuildsSettingsController) getGuildSettingsCodeExec(ctx *fiber.Ctx) error { + guildID := ctx.Params("guildid") + + var ( + res models.CodeExecSettings + err error + ) + + res.Enabled, err = c.db.GetGuildCodeExecEnabled(guildID) + if err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + + res.Type = c.cef.Name() + + if res.Type == "jdoodle" { + creds, err := c.db.GetGuildJdoodleKey(guildID) + if err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + credsSplit := strings.Split(creds, "#") + if len(credsSplit) == 2 { + res.JdoodleClientId = credsSplit[0] + res.JdoodleClientSecret = credsSplit[1] + } + } + + return ctx.JSON(res) +} + +// @Summary Set Guild Settings Code Exec State +// @Description Set the settings state of the Guild Code Exec. +// @Tags Guild Settings +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Param payload body models.EnableStatus true "The guild API settings payload." +// @Success 200 {object} models.EnableStatus +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/settings/codeexec [post] +func (c *GuildsSettingsController) postGuildSettingsCodeExec(ctx *fiber.Ctx) (err error) { + guildID := ctx.Params("guildid") + + var state models.CodeExecSettings + if err = ctx.BodyParser(&state); err != nil { + return + } + + err = c.db.SetGuildCodeExecEnabled(guildID, state.Enabled) + if err != nil { + return + } + + if c.cef.Name() == "jdoodle" { + var creds string + if state.JdoodleClientId == "" && state.JdoodleClientSecret == "" { + } else if state.JdoodleClientId != "" && state.JdoodleClientSecret != "" { + _, err = jdoodle.NewWrapper(state.JdoodleClientId, state.JdoodleClientSecret).CreditsSpent() + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "The JDoodle credentials are invalid.") + } + creds = fmt.Sprintf("%s#%s", state.JdoodleClientId, state.JdoodleClientSecret) + } else { + return fiber.NewError(fiber.StatusBadRequest, "Either both credential values must be empty or both must be defined!") + } + + err = c.db.SetGuildJdoodleKey(guildID, creds) + if err != nil { + return + } + } + + return ctx.JSON(state) +} diff --git a/internal/services/webserver/v1/controllers/imagestore.go b/internal/services/webserver/v1/controllers/imagestore.go index b16998d40..3763ab574 100644 --- a/internal/services/webserver/v1/controllers/imagestore.go +++ b/internal/services/webserver/v1/controllers/imagestore.go @@ -15,7 +15,7 @@ import ( ) type ImagestoreController struct { - st storage.Storage + st Storage } func (c *ImagestoreController) Setup(container di.Container, router fiber.Router) { diff --git a/internal/services/webserver/v1/controllers/interfaces.go b/internal/services/webserver/v1/controllers/interfaces.go new file mode 100644 index 000000000..ffb61b80e --- /dev/null +++ b/internal/services/webserver/v1/controllers/interfaces.go @@ -0,0 +1,345 @@ +package controllers + +import ( + "github.com/bwmarrin/discordgo" + "github.com/bwmarrin/snowflake" + "github.com/gofiber/fiber/v2" + "github.com/zekroTJA/shinpuru/internal/models" + "github.com/zekroTJA/shinpuru/internal/services/backup/backupmodels" + permService "github.com/zekroTJA/shinpuru/internal/services/permissions" + "github.com/zekroTJA/shinpuru/internal/util" + "github.com/zekroTJA/shinpuru/internal/util/tag" + "github.com/zekroTJA/shinpuru/internal/util/vote" + "github.com/zekroTJA/shinpuru/pkg/permissions" + "github.com/zekroTJA/shinpuru/pkg/twitchnotify" + "io" + "time" +) + +type TimeProvider interface { + Now() time.Time +} + +type State interface { + Subscribe(channel string, handler func(scan func(v interface{}) error)) (close func() error) + Channels(guildID string, forceFetch ...bool) (v []*discordgo.Channel, err error) + Channel(id string) (v *discordgo.Channel, err error) + UserGuilds(id string) (res []string, err error) + Member(guildID, memberID string, forceNoFetch ...bool) (v *discordgo.Member, err error) + Message(channelID, messageID string) (v *discordgo.Message, err error) + User(id string) (v *discordgo.User, err error) + SelfUser() (v *discordgo.User, err error) + Guilds() (v []*discordgo.Guild, err error) + Guild(id string, hydrate ...bool) (v *discordgo.Guild, err error) + Roles(guildID string, forceFetch ...bool) (v []*discordgo.Role, err error) + Members(guildID string, forceFetch ...bool) (v []*discordgo.Member, err error) + RemoveGuild(id string, dehydrate ...bool) error + RemoveMember(guildID, memberID string) (err error) + RemoveUser(id string) (err error) +} + +type Session interface { + util.MessageSession + permService.Session + + UpdateStatusComplex(usd discordgo.UpdateStatusData) (err error) + GuildInvites(guildID string, options ...discordgo.RequestOption) (st []*discordgo.Invite, err error) + ChannelInviteCreate(channelID string, i discordgo.Invite, options ...discordgo.RequestOption) (st *discordgo.Invite, err error) + ChannelMessageSendEmbed(channelID string, embed *discordgo.MessageEmbed, options ...discordgo.RequestOption) (*discordgo.Message, error) + GuildRoles(guildID string, options ...discordgo.RequestOption) ([]*discordgo.Role, error) + GuildMembers(guildID string, after string, limit int, options ...discordgo.RequestOption) (st []*discordgo.Member, err error) + GuildChannels(guildID string, options ...discordgo.RequestOption) (st []*discordgo.Channel, err error) + GuildMemberDelete(guildID, userID string, options ...discordgo.RequestOption) (err error) + GuildBanCreateWithReason(guildID, userID, reason string, days int, options ...discordgo.RequestOption) (err error) + GuildLeave(guildID string, options ...discordgo.RequestOption) (err error) + User(userID string, options ...discordgo.RequestOption) (st *discordgo.User, err error) + ChannelMessageSendComplex(channelID string, data *discordgo.MessageSend, options ...discordgo.RequestOption) (st *discordgo.Message, err error) + MessageReactionAdd(channelID, messageID, emojiID string, options ...discordgo.RequestOption) error + ChannelMessageEditEmbed(channelID, messageID string, embed *discordgo.MessageEmbed, options ...discordgo.RequestOption) (*discordgo.Message, error) + MessageReactionsRemoveAll(channelID, messageID string, options ...discordgo.RequestOption) error + UserChannelCreate(recipientID string, options ...discordgo.RequestOption) (st *discordgo.Channel, err error) +} + +type Database interface { + ////////////////////////////////////////////////////// + //// GENERAL + + Status() error + + ////////////////////////////////////////////////////// + //// GUILD SETTINGS + + GetSetting(setting string) (string, error) + SetSetting(setting, value string) error + + GetGuildPrefix(guildID string) (string, error) + SetGuildPrefix(guildID, newPrefix string) error + + GetGuildAutoRole(guildID string) ([]string, error) + SetGuildAutoRole(guildID string, autoRoleIDs []string) error + + GetGuildAutoVC(guildID string) ([]string, error) + SetGuildAutoVC(guildID string, autoVCIDs []string) error + + GetGuildModLog(guildID string) (string, error) + SetGuildModLog(guildID, chanID string) error + + GetGuildVoiceLog(guildID string) (string, error) + SetGuildVoiceLog(guildID, chanID string) error + + GetGuildVoiceLogIgnores(guildID string) ([]string, error) + IsGuildVoiceLogIgnored(guildID, channelID string) (bool, error) + SetGuildVoiceLogIngore(guildID, channelID string) error + RemoveGuildVoiceLogIgnore(guildID, channelID string) error + + GetGuildNotifyRole(guildID string) (string, error) + SetGuildNotifyRole(guildID, roleID string) error + + GetGuildGhostpingMsg(guildID string) (string, error) + SetGuildGhostpingMsg(guildID, msg string) error + + GetGuildPermissions(guildID string) (map[string]permissions.PermissionArray, error) + SetGuildRolePermission(guildID, roleID string, p permissions.PermissionArray) error + + GetGuildJdoodleKey(guildID string) (string, error) + SetGuildJdoodleKey(guildID, key string) error + + GetGuildCodeExecEnabled(guildID string) (bool, error) + SetGuildCodeExecEnabled(guildID string, enabled bool) error + + GetGuildBackup(guildID string) (bool, error) + SetGuildBackup(guildID string, enabled bool) error + + GetGuildInviteBlock(guildID string) (string, error) + SetGuildInviteBlock(guildID string, data string) error + + GetGuildJoinMsg(guildID string) (string, string, error) + SetGuildJoinMsg(guildID string, channelID string, msg string) error + + GetGuildLeaveMsg(guildID string) (string, string, error) + SetGuildLeaveMsg(guildID string, channelID string, msg string) error + + GetGuildColorReaction(guildID string) (bool, error) + SetGuildColorReaction(guildID string, enable bool) error + + GetGuildLogDisable(guildID string) (bool, error) + SetGuildLogDisable(guildID string, enabled bool) error + + GetGuildAPI(guildID string) (models.GuildAPISettings, error) + SetGuildAPI(guildID string, settings models.GuildAPISettings) error + + GetGuildVerificationRequired(guildID string) (bool, error) + SetGuildVerificationRequired(guildID string, enable bool) error + + GetGuildBirthdayChan(guildID string) (string, error) + SetGuildBirthdayChan(guildID string, chanID string) error + + GetGuildModNot(guildID string) (string, error) + SetGuildModNot(guildID string, chanID string) error + + ////////////////////////////////////////////////////// + //// USER SETTINGS + + GetUserOTAEnabled(userID string) (bool, error) + SetUserOTAEnabled(userID string, enabled bool) error + + GetUserVerified(userID string) (bool, error) + SetUserVerified(userID string, enabled bool) error + + GetUserStarboardOptout(userID string) (bool, error) + SetUserStarboardOptout(userID string, enabled bool) error + + GetUserByRefreshToken(token string) (string, time.Time, error) + SetUserRefreshToken(userID, token string, expires time.Time) error + RevokeUserRefreshToken(userID string) error + CleanupExpiredRefreshTokens() (int64, error) + + FlushUserData(userID string) (res map[string]int, err error) + + ////////////////////////////////////////////////////// + //// REPORTS + + AddReport(rep models.Report) error + DeleteReport(id snowflake.ID) error + GetReport(id snowflake.ID) (models.Report, error) + GetReportsGuild(guildID string, offset, limit int) ([]models.Report, error) + GetReportsFiltered(guildID, memberID string, repType models.ReportType, offset, limit int) ([]models.Report, error) + GetReportsGuildCount(guildID string) (int, error) + GetReportsFilteredCount(guildID, memberID string, repType int) (int, error) + GetExpiredReports() ([]models.Report, error) + ExpireReports(id ...string) (err error) + + ////////////////////////////////////////////////////// + //// UNBAN REQUESTS + + GetGuildUnbanRequests(guildID string, limit, offset int) ([]models.UnbanRequest, error) + GetGuildUnbanRequestsCount(guildID string, state *models.UnbanRequestState) (int, error) + GetGuildUserUnbanRequests(userID, guildID string) ([]models.UnbanRequest, error) + GetUnbanRequest(id string) (models.UnbanRequest, error) + AddUnbanRequest(request models.UnbanRequest) error + UpdateUnbanRequest(request models.UnbanRequest) error + + ////////////////////////////////////////////////////// + //// VOTES + + GetVotes() (map[string]vote.Vote, error) + AddUpdateVote(votes vote.Vote) error + DeleteVote(voteID string) error + + ////////////////////////////////////////////////////// + //// TWITCHNOTIFY + + GetAllTwitchNotifies(twitchUserID string) ([]twitchnotify.DBEntry, error) + GetTwitchNotify(twitchUserID, guildID string) (twitchnotify.DBEntry, error) + SetTwitchNotify(twitchNotify twitchnotify.DBEntry) error + DeleteTwitchNotify(twitchUserID, guildID string) error + + ////////////////////////////////////////////////////// + //// GUILD BACKUPS + + AddBackup(guildID, fileID string) error + DeleteBackup(guildID, fileID string) error + GetBackups(guildID string) ([]backupmodels.Entry, error) + GetGuilds() ([]string, error) + + ////////////////////////////////////////////////////// + //// TAGS + + AddTag(tag tag.Tag) error + EditTag(tag tag.Tag) error + GetTagByID(id snowflake.ID) (tag.Tag, error) + GetTagByIdent(ident string, guildID string) (tag.Tag, error) + GetGuildTags(guildID string) ([]tag.Tag, error) + DeleteTag(id snowflake.ID) error + + ////////////////////////////////////////////////////// + //// API TOKEN + + SetAPIToken(token models.APITokenEntry) error + GetAPIToken(userID string) (models.APITokenEntry, error) + DeleteAPIToken(userID string) error + + ////////////////////////////////////////////////////// + //// KARMA + + GetKarma(userID, guildID string) (int, error) + GetKarmaSum(userID string) (int, error) + GetKarmaGuild(guildID string, limit int) ([]models.GuildKarma, error) + SetKarma(userID, guildID string, val int) error + UpdateKarma(userID, guildID string, diff int) error + + SetKarmaState(guildID string, state bool) error + GetKarmaState(guildID string) (bool, error) + + SetKarmaEmotes(guildID, emotesInc, emotesDec string) error + GetKarmaEmotes(guildID string) (emotesInc, emotesDec string, err error) + + SetKarmaTokens(guildID string, tokens int) error + GetKarmaTokens(guildID string) (int, error) + + SetKarmaPenalty(guildID string, state bool) error + GetKarmaPenalty(guildID string) (bool, error) + + GetKarmaBlockList(guildID string) ([]string, error) + IsKarmaBlockListed(guildID, userID string) (bool, error) + AddKarmaBlockList(guildID, userID string) error + RemoveKarmaBlockList(guildID, userID string) error + + GetKarmaRules(guildID string) ([]models.KarmaRule, error) + CheckKarmaRule(guildID, checksum string) (ok bool, err error) + AddOrUpdateKarmaRule(rule models.KarmaRule) error + RemoveKarmaRule(guildID string, id snowflake.ID) error + + ////////////////////////////////////////////////////// + //// CHAN LOCK + + SetLockChan(chanID, guildID, executorID, permissions string) error + GetLockChan(chanID string) (guildID, executorID, permissions string, err error) + GetLockChannels(guildID string) (chanIDs []string, err error) + DeleteLockChan(chanID string) error + + ////////////////////////////////////////////////////// + //// ANTI RAID + + SetAntiraidState(guildID string, state bool) error + GetAntiraidState(guildID string) (bool, error) + + SetAntiraidRegeneration(guildID string, periodSecs int) error + GetAntiraidRegeneration(guildID string) (int, error) + + SetAntiraidBurst(guildID string, burst int) error + GetAntiraidBurst(guildID string) (int, error) + + SetAntiraidVerification(guildID string, state bool) error + GetAntiraidVerification(guildID string) (bool, error) + + AddToAntiraidJoinList(guildID, userID, userTag string, accountCreated time.Time) error + GetAntiraidJoinList(guildID string) ([]models.JoinLogEntry, error) + FlushAntiraidJoinList(guildID string) error + RemoveAntiraidJoinList(guildID, userID string) error + + ////////////////////////////////////////////////////// + //// STARBOARD + + SetStarboardConfig(config models.StarboardConfig) error + GetStarboardConfig(guildID string) (models.StarboardConfig, error) + SetStarboardEntry(e models.StarboardEntry) (err error) + RemoveStarboardEntry(msgID string) error + GetStarboardEntries(guildID string, sortBy models.StarboardSortBy, limit, offset int) ([]models.StarboardEntry, error) + GetStarboardEntriesCount(guildID string) (int, error) + GetStarboardEntry(messageID string) (models.StarboardEntry, error) + + ////////////////////////////////////////////////////// + //// GUILDLOG + + GetGuildLogEntries(guildID string, offset, limit int, severity models.GuildLogSeverity, ascending bool) ([]models.GuildLogEntry, error) + GetGuildLogEntriesCount(guildID string, severity models.GuildLogSeverity) (int, error) + AddGuildLogEntry(entry models.GuildLogEntry) error + DeleteLogEntry(guildID string, id snowflake.ID) error + DeleteLogEntries(guildID string) error + + ////////////////////////////////////////////////////// + //// FUNCTIONALITIES + + FlushGuildData(guildID string) error + + ////////////////////////////////////////////////////// + //// VERIFICATION QUEUE + + GetVerificationQueue(guildID, userID string) ([]models.VerificationQueueEntry, error) + FlushVerificationQueue(guildID string) error + AddVerificationQueue(e models.VerificationQueueEntry) error + RemoveVerificationQueue(guildID, userID string) (bool, error) + + ////////////////////////////////////////////////////// + //// BIRTHDAYS + + GetBirthdays(guildID string) ([]models.Birthday, error) + SetBirthday(m models.Birthday) error + DeleteBirthday(guildID, userID string) error + + ////////////////////////////////////////////////////// + //// ROLE SELECT + + AddRoleSelects(v []models.RoleSelect) error + GetRoleSelects() ([]models.RoleSelect, error) + RemoveRoleSelect(guildID, channelID, messageID string) error +} + +type Storage interface { + GetObject(bucketName, objectName string) (io.ReadCloser, int64, error) + PutObject(bucketName, objectName string, reader io.Reader, objectSize int64, mimeType string) error + DeleteObject(bucketName, objectName string) error + + Status() error +} + +type Permissions interface { + HandleWs(s permService.Session, required string) fiber.Handler +} + +type KvCache interface { + Get(key string) interface{} + Set(key string, v interface{}, lifetime time.Duration) + Del(key string) +} diff --git a/internal/services/webserver/v1/controllers/invite.go b/internal/services/webserver/v1/controllers/invite.go index ba2a324a9..a48227a1d 100644 --- a/internal/services/webserver/v1/controllers/invite.go +++ b/internal/services/webserver/v1/controllers/invite.go @@ -15,9 +15,9 @@ import ( ) type InviteController struct { - session *discordgo.Session - st *dgrs.State - kv kvcache.Provider + session Session + st State + kv KvCache } func (c *InviteController) Setup(container di.Container, router fiber.Router) { diff --git a/internal/services/webserver/v1/controllers/memberreporting.go b/internal/services/webserver/v1/controllers/memberreporting.go index 856b0b70a..ab202d120 100644 --- a/internal/services/webserver/v1/controllers/memberreporting.go +++ b/internal/services/webserver/v1/controllers/memberreporting.go @@ -1,354 +1,355 @@ -package controllers - -import ( - "bytes" - - "github.com/bwmarrin/discordgo" - "github.com/gofiber/fiber/v2" - "github.com/sarulabs/di/v2" - sharedmodels "github.com/zekroTJA/shinpuru/internal/models" - "github.com/zekroTJA/shinpuru/internal/services/config" - "github.com/zekroTJA/shinpuru/internal/services/database" - "github.com/zekroTJA/shinpuru/internal/services/permissions" - "github.com/zekroTJA/shinpuru/internal/services/report" - "github.com/zekroTJA/shinpuru/internal/services/storage" - "github.com/zekroTJA/shinpuru/internal/services/webserver/v1/models" - "github.com/zekroTJA/shinpuru/internal/services/webserver/wsutil" - "github.com/zekroTJA/shinpuru/internal/util/imgstore" - "github.com/zekroTJA/shinpuru/internal/util/static" - "github.com/zekrotja/dgrs" -) - -type MemberReportingController struct { - session *discordgo.Session - cfg config.Provider - db database.Database - st storage.Storage - repSvc *report.ReportService - state *dgrs.State -} - -func (c *MemberReportingController) Setup(container di.Container, router fiber.Router) { - c.session = container.Get(static.DiDiscordSession).(*discordgo.Session) - c.cfg = container.Get(static.DiConfig).(config.Provider) - c.db = container.Get(static.DiDatabase).(database.Database) - c.st = container.Get(static.DiObjectStorage).(storage.Storage) - c.repSvc = container.Get(static.DiReport).(*report.ReportService) - c.state = container.Get(static.DiState).(*dgrs.State) - - pmw := container.Get(static.DiPermissions).(*permissions.Permissions) - - router.Post("/reports", pmw.HandleWs(c.session, "sp.guild.mod.report"), c.postReport) - router.Post("/kick", pmw.HandleWs(c.session, "sp.guild.mod.kick"), c.postKick) - router.Post("/ban", pmw.HandleWs(c.session, "sp.guild.mod.ban"), c.postBan) - router.Post("/mute", pmw.HandleWs(c.session, "sp.guild.mod.mute"), c.postMute) - router.Post("/unmute", pmw.HandleWs(c.session, "sp.guild.mod.mute"), c.postUnmute) -} - -// @Summary Create A Member Report -// @Description Creates a member report. -// @Tags Member Reporting -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Param memberid path string true "The ID of the victim member." -// @Param payload body models.ReportRequest true "The report payload." -// @Success 200 {object} models.Report -// @Failure 400 {object} models.Error -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/{memberid}/reports [post] -func (c *MemberReportingController) postReport(ctx *fiber.Ctx) (err error) { - uid := ctx.Locals("uid").(string) - - guildID := ctx.Params("guildid") - memberID := ctx.Params("memberid") - - repReq := new(models.ReportRequest) - if err := ctx.BodyParser(repReq); err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - - if memberID == uid { - return fiber.NewError(fiber.StatusBadRequest, "you can not report yourself") - } - - if ok, err := repReq.Validate(false); !ok { - return err - } - - if err = c.uploadAttachment(repReq.ReasonRequest); err != nil { - return - } - - rep, err := c.repSvc.PushReport(sharedmodels.Report{ - GuildID: guildID, - ExecutorID: uid, - VictimID: memberID, - Msg: repReq.Reason, - AttachmentURL: repReq.Attachment, - Type: repReq.Type, - }) - - if err != nil { - return err - } - - return ctx.JSON(models.ReportFromReport(rep, c.cfg.Config().WebServer.PublicAddr)) -} - -// @Summary Create A Member Kick Report -// @Description Creates a member kick report. -// @Tags Member Reporting -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Param memberid path string true "The ID of the victim member." -// @Param payload body models.ReasonRequest true "The report payload." -// @Success 200 {object} models.Report -// @Failure 400 {object} models.Error -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/{memberid}/kick [post] -func (c *MemberReportingController) postKick(ctx *fiber.Ctx) (err error) { - uid := ctx.Locals("uid").(string) - - guildID := ctx.Params("guildid") - memberID := ctx.Params("memberid") - - req := new(models.ReasonRequest) - if err := ctx.BodyParser(req); err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - - if memberID == uid { - return fiber.NewError(fiber.StatusBadRequest, "you can not kick yourself") - } - - if ok, err := req.Validate(false); !ok { - return err - } - - if err = c.uploadAttachment(req); err != nil { - return - } - - rep, err := c.repSvc.PushKick(sharedmodels.Report{ - GuildID: guildID, - ExecutorID: uid, - VictimID: memberID, - Msg: req.Reason, - AttachmentURL: req.Attachment, - }) - - if err == report.ErrRoleDiff { - return fiber.NewError(fiber.StatusBadRequest, "you can not kick members with higher or same permissions than/as yours") - } - - if err != nil { - return err - } - - return ctx.JSON(models.ReportFromReport(rep, c.cfg.Config().WebServer.PublicAddr)) -} - -// @Summary Create A Member Ban Report -// @Description Creates a member ban report. -// @Tags Member Reporting -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Param memberid path string true "The ID of the victim member." -// @Param payload body models.ReasonRequest true "The report payload." -// @Success 200 {object} models.Report -// @Failure 400 {object} models.Error -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/{memberid}/ban [post] -func (c *MemberReportingController) postBan(ctx *fiber.Ctx) (err error) { - uid := ctx.Locals("uid").(string) - - guildID := ctx.Params("guildid") - memberID := ctx.Params("memberid") - - anonymous, err := wsutil.GetQueryBool(ctx, "anonymous", false) - if err != nil { - return - } - - req := new(models.ReasonRequest) - if err := ctx.BodyParser(req); err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - - if memberID == uid { - return fiber.NewError(fiber.StatusBadRequest, "you can not ban yourself") - } - - if ok, err := req.Validate(false); !ok { - return err - } - - if err = c.uploadAttachment(req); err != nil { - return - } - - rep, err := c.repSvc.PushBan(sharedmodels.Report{ - GuildID: guildID, - ExecutorID: uid, - VictimID: memberID, - Msg: req.Reason, - AttachmentURL: req.Attachment, - Timeout: req.Timeout, - Anonymous: anonymous, - }) - - if err == report.ErrRoleDiff { - return fiber.NewError(fiber.StatusBadRequest, "you can not ban members with higher or same permissions than/as yours") - } - - if err != nil { - return err - } - - return ctx.JSON(models.ReportFromReport(rep, c.cfg.Config().WebServer.PublicAddr)) -} - -// @Summary Create A Member Mute Report -// @Description Creates a member mute report. -// @Tags Member Reporting -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Param memberid path string true "The ID of the victim member." -// @Param payload body models.ReasonRequest true "The report payload." -// @Success 200 {object} models.Report -// @Failure 400 {object} models.Error -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/{memberid}/mute [post] -func (c *MemberReportingController) postMute(ctx *fiber.Ctx) (err error) { - uid := ctx.Locals("uid").(string) - - guildID := ctx.Params("guildid") - memberID := ctx.Params("memberid") - - req := new(models.ReasonRequest) - if err := ctx.BodyParser(req); err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - - if memberID == uid { - return fiber.NewError(fiber.StatusBadRequest, "you can not mute yourself") - } - - if ok, err := req.Validate(true); !ok { - return err - } - - if err = c.uploadAttachment(req); err != nil { - return - } - - if req.Timeout == nil { - return fiber.NewError(fiber.StatusBadRequest, "You must pass a valid mute timeout duration") - } - - rep, err := c.repSvc.PushMute(sharedmodels.Report{ - GuildID: guildID, - ExecutorID: uid, - VictimID: memberID, - Msg: req.Reason, - AttachmentURL: req.Attachment, - Timeout: req.Timeout, - }) - - if err == report.ErrRoleDiff { - return fiber.NewError(fiber.StatusBadRequest, "you can not mute members with higher or same permissions than/as yours") - } - - if err != nil { - return err - } - - return ctx.JSON(models.ReportFromReport(rep, c.cfg.Config().WebServer.PublicAddr)) -} - -// @Summary Unmute A Member -// @Description Unmute a muted member. -// @Tags Member Reporting -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Param memberid path string true "The ID of the victim member." -// @Param payload body models.ReasonRequest true "The unmute payload." -// @Success 200 {object} models.Status -// @Failure 400 {object} models.Error -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/{memberid}/mute [post] -func (c *MemberReportingController) postUnmute(ctx *fiber.Ctx) (err error) { - uid := ctx.Locals("uid").(string) - - guildID := ctx.Params("guildid") - memberID := ctx.Params("memberid") - - req := new(models.ReasonRequest) - if err := ctx.BodyParser(req); err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - - if memberID == uid { - return fiber.NewError(fiber.StatusBadRequest, "you can not mute yourself") - } - - _, err = c.repSvc.RevokeMute( - guildID, - uid, - memberID, - req.Reason) - - if err == report.ErrRoleDiff { - return fiber.NewError(fiber.StatusBadRequest, "you can not unmute members with higher or same permissions than/as yours") - } - - if err != nil { - return err - } - - return ctx.JSON(models.Ok) -} - -// --- HELPERS --- - -func (c *MemberReportingController) uploadAttachment(repReq *models.ReasonRequest) (err error) { - var img *imgstore.Image - if repReq.AttachmentData != "" { - img = new(imgstore.Image) - img.MimeType, img.Data, err = wsutil.ParseBase64Data(repReq.AttachmentData) - if err != nil { - return - } - if img.MimeType != "image/jpeg" && img.MimeType != "image/jpg" && img.MimeType != "image/png" && img.MimeType != "image/gif" { - return fiber.NewError(fiber.StatusBadRequest, "invalid or unset mime type") - } - img.Size = len(img.Data) - img.GenerateID() - } else if repReq.Attachment != "" { - img, err = imgstore.DownloadFromURL(repReq.Attachment) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - } - - if img != nil { - err = c.st.PutObject(static.StorageBucketImages, img.ID.String(), - bytes.NewReader(img.Data), int64(img.Size), img.MimeType) - if err != nil { - return - } - repReq.Attachment = img.ID.String() - } - - return -} +package controllers + +import ( + "bytes" + + "github.com/bwmarrin/discordgo" + "github.com/gofiber/fiber/v2" + "github.com/sarulabs/di/v2" + sharedmodels "github.com/zekroTJA/shinpuru/internal/models" + "github.com/zekroTJA/shinpuru/internal/services/config" + "github.com/zekroTJA/shinpuru/internal/services/database" + "github.com/zekroTJA/shinpuru/internal/services/permissions" + "github.com/zekroTJA/shinpuru/internal/services/report" + "github.com/zekroTJA/shinpuru/internal/services/storage" + "github.com/zekroTJA/shinpuru/internal/services/webserver/v1/models" + "github.com/zekroTJA/shinpuru/internal/services/webserver/wsutil" + "github.com/zekroTJA/shinpuru/internal/util/imgstore" + "github.com/zekroTJA/shinpuru/internal/util/static" + "github.com/zekrotja/dgrs" +) + +type MemberReportingController struct { + db Database + session Session + st Storage + state State + + cfg config.Provider + repSvc *report.ReportService +} + +func (c *MemberReportingController) Setup(container di.Container, router fiber.Router) { + c.session = container.Get(static.DiDiscordSession).(*discordgo.Session) + c.cfg = container.Get(static.DiConfig).(config.Provider) + c.db = container.Get(static.DiDatabase).(database.Database) + c.st = container.Get(static.DiObjectStorage).(storage.Storage) + c.repSvc = container.Get(static.DiReport).(*report.ReportService) + c.state = container.Get(static.DiState).(*dgrs.State) + + pmw := container.Get(static.DiPermissions).(*permissions.Permissions) + + router.Post("/reports", pmw.HandleWs(c.session, "sp.guild.mod.report"), c.postReport) + router.Post("/kick", pmw.HandleWs(c.session, "sp.guild.mod.kick"), c.postKick) + router.Post("/ban", pmw.HandleWs(c.session, "sp.guild.mod.ban"), c.postBan) + router.Post("/mute", pmw.HandleWs(c.session, "sp.guild.mod.mute"), c.postMute) + router.Post("/unmute", pmw.HandleWs(c.session, "sp.guild.mod.mute"), c.postUnmute) +} + +// @Summary Create A Member Report +// @Description Creates a member report. +// @Tags Member Reporting +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Param memberid path string true "The ID of the victim member." +// @Param payload body models.ReportRequest true "The report payload." +// @Success 200 {object} models.Report +// @Failure 400 {object} models.Error +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/{memberid}/reports [post] +func (c *MemberReportingController) postReport(ctx *fiber.Ctx) (err error) { + uid := ctx.Locals("uid").(string) + + guildID := ctx.Params("guildid") + memberID := ctx.Params("memberid") + + repReq := new(models.ReportRequest) + if err := ctx.BodyParser(repReq); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + if memberID == uid { + return fiber.NewError(fiber.StatusBadRequest, "you can not report yourself") + } + + if ok, err := repReq.Validate(false); !ok { + return err + } + + if err = c.uploadAttachment(repReq.ReasonRequest); err != nil { + return + } + + rep, err := c.repSvc.PushReport(sharedmodels.Report{ + GuildID: guildID, + ExecutorID: uid, + VictimID: memberID, + Msg: repReq.Reason, + AttachmentURL: repReq.Attachment, + Type: repReq.Type, + }) + + if err != nil { + return err + } + + return ctx.JSON(models.ReportFromReport(rep, c.cfg.Config().WebServer.PublicAddr)) +} + +// @Summary Create A Member Kick Report +// @Description Creates a member kick report. +// @Tags Member Reporting +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Param memberid path string true "The ID of the victim member." +// @Param payload body models.ReasonRequest true "The report payload." +// @Success 200 {object} models.Report +// @Failure 400 {object} models.Error +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/{memberid}/kick [post] +func (c *MemberReportingController) postKick(ctx *fiber.Ctx) (err error) { + uid := ctx.Locals("uid").(string) + + guildID := ctx.Params("guildid") + memberID := ctx.Params("memberid") + + req := new(models.ReasonRequest) + if err := ctx.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + if memberID == uid { + return fiber.NewError(fiber.StatusBadRequest, "you can not kick yourself") + } + + if ok, err := req.Validate(false); !ok { + return err + } + + if err = c.uploadAttachment(req); err != nil { + return + } + + rep, err := c.repSvc.PushKick(sharedmodels.Report{ + GuildID: guildID, + ExecutorID: uid, + VictimID: memberID, + Msg: req.Reason, + AttachmentURL: req.Attachment, + }) + + if err == report.ErrRoleDiff { + return fiber.NewError(fiber.StatusBadRequest, "you can not kick members with higher or same permissions than/as yours") + } + + if err != nil { + return err + } + + return ctx.JSON(models.ReportFromReport(rep, c.cfg.Config().WebServer.PublicAddr)) +} + +// @Summary Create A Member Ban Report +// @Description Creates a member ban report. +// @Tags Member Reporting +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Param memberid path string true "The ID of the victim member." +// @Param payload body models.ReasonRequest true "The report payload." +// @Success 200 {object} models.Report +// @Failure 400 {object} models.Error +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/{memberid}/ban [post] +func (c *MemberReportingController) postBan(ctx *fiber.Ctx) (err error) { + uid := ctx.Locals("uid").(string) + + guildID := ctx.Params("guildid") + memberID := ctx.Params("memberid") + + anonymous, err := wsutil.GetQueryBool(ctx, "anonymous", false) + if err != nil { + return + } + + req := new(models.ReasonRequest) + if err := ctx.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + if memberID == uid { + return fiber.NewError(fiber.StatusBadRequest, "you can not ban yourself") + } + + if ok, err := req.Validate(false); !ok { + return err + } + + if err = c.uploadAttachment(req); err != nil { + return + } + + rep, err := c.repSvc.PushBan(sharedmodels.Report{ + GuildID: guildID, + ExecutorID: uid, + VictimID: memberID, + Msg: req.Reason, + AttachmentURL: req.Attachment, + Timeout: req.Timeout, + Anonymous: anonymous, + }) + + if err == report.ErrRoleDiff { + return fiber.NewError(fiber.StatusBadRequest, "you can not ban members with higher or same permissions than/as yours") + } + + if err != nil { + return err + } + + return ctx.JSON(models.ReportFromReport(rep, c.cfg.Config().WebServer.PublicAddr)) +} + +// @Summary Create A Member Mute Report +// @Description Creates a member mute report. +// @Tags Member Reporting +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Param memberid path string true "The ID of the victim member." +// @Param payload body models.ReasonRequest true "The report payload." +// @Success 200 {object} models.Report +// @Failure 400 {object} models.Error +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/{memberid}/mute [post] +func (c *MemberReportingController) postMute(ctx *fiber.Ctx) (err error) { + uid := ctx.Locals("uid").(string) + + guildID := ctx.Params("guildid") + memberID := ctx.Params("memberid") + + req := new(models.ReasonRequest) + if err := ctx.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + if memberID == uid { + return fiber.NewError(fiber.StatusBadRequest, "you can not mute yourself") + } + + if ok, err := req.Validate(true); !ok { + return err + } + + if err = c.uploadAttachment(req); err != nil { + return + } + + if req.Timeout == nil { + return fiber.NewError(fiber.StatusBadRequest, "You must pass a valid mute timeout duration") + } + + rep, err := c.repSvc.PushMute(sharedmodels.Report{ + GuildID: guildID, + ExecutorID: uid, + VictimID: memberID, + Msg: req.Reason, + AttachmentURL: req.Attachment, + Timeout: req.Timeout, + }) + + if err == report.ErrRoleDiff { + return fiber.NewError(fiber.StatusBadRequest, "you can not mute members with higher or same permissions than/as yours") + } + + if err != nil { + return err + } + + return ctx.JSON(models.ReportFromReport(rep, c.cfg.Config().WebServer.PublicAddr)) +} + +// @Summary Unmute A Member +// @Description Unmute a muted member. +// @Tags Member Reporting +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Param memberid path string true "The ID of the victim member." +// @Param payload body models.ReasonRequest true "The unmute payload." +// @Success 200 {object} models.Status +// @Failure 400 {object} models.Error +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/{memberid}/mute [post] +func (c *MemberReportingController) postUnmute(ctx *fiber.Ctx) (err error) { + uid := ctx.Locals("uid").(string) + + guildID := ctx.Params("guildid") + memberID := ctx.Params("memberid") + + req := new(models.ReasonRequest) + if err := ctx.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + if memberID == uid { + return fiber.NewError(fiber.StatusBadRequest, "you can not mute yourself") + } + + _, err = c.repSvc.RevokeMute( + guildID, + uid, + memberID, + req.Reason) + + if err == report.ErrRoleDiff { + return fiber.NewError(fiber.StatusBadRequest, "you can not unmute members with higher or same permissions than/as yours") + } + + if err != nil { + return err + } + + return ctx.JSON(models.Ok) +} + +// --- HELPERS --- + +func (c *MemberReportingController) uploadAttachment(repReq *models.ReasonRequest) (err error) { + var img *imgstore.Image + if repReq.AttachmentData != "" { + img = new(imgstore.Image) + img.MimeType, img.Data, err = wsutil.ParseBase64Data(repReq.AttachmentData) + if err != nil { + return + } + if img.MimeType != "image/jpeg" && img.MimeType != "image/jpg" && img.MimeType != "image/png" && img.MimeType != "image/gif" { + return fiber.NewError(fiber.StatusBadRequest, "invalid or unset mime type") + } + img.Size = len(img.Data) + img.GenerateID() + } else if repReq.Attachment != "" { + img, err = imgstore.DownloadFromURL(repReq.Attachment) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + } + + if img != nil { + err = c.st.PutObject(static.StorageBucketImages, img.ID.String(), + bytes.NewReader(img.Data), int64(img.Size), img.MimeType) + if err != nil { + return + } + repReq.Attachment = img.ID.String() + } + + return +} diff --git a/internal/services/webserver/v1/controllers/members.go b/internal/services/webserver/v1/controllers/members.go index ad34c09b1..c340a8a59 100644 --- a/internal/services/webserver/v1/controllers/members.go +++ b/internal/services/webserver/v1/controllers/members.go @@ -1,396 +1,397 @@ -package controllers - -import ( - "strings" - - "github.com/bwmarrin/discordgo" - "github.com/gofiber/fiber/v2" - "github.com/sarulabs/di/v2" - sharedmodels "github.com/zekroTJA/shinpuru/internal/models" - "github.com/zekroTJA/shinpuru/internal/services/config" - "github.com/zekroTJA/shinpuru/internal/services/database" - "github.com/zekroTJA/shinpuru/internal/services/permissions" - "github.com/zekroTJA/shinpuru/internal/services/webserver/v1/models" - "github.com/zekroTJA/shinpuru/internal/services/webserver/wsutil" - "github.com/zekroTJA/shinpuru/internal/util" - "github.com/zekroTJA/shinpuru/internal/util/static" - "github.com/zekroTJA/shinpuru/pkg/discordutil" - "github.com/zekrotja/dgrs" - "github.com/zekrotja/ken" - "github.com/zekrotja/sop" -) - -type GuildMembersController struct { - session *discordgo.Session - cfg config.Provider - db database.Database - pmw *permissions.Permissions - cmdHandler *ken.Ken - st *dgrs.State -} - -func (c *GuildMembersController) Setup(container di.Container, router fiber.Router) { - c.session = container.Get(static.DiDiscordSession).(*discordgo.Session) - c.cfg = container.Get(static.DiConfig).(config.Provider) - c.db = container.Get(static.DiDatabase).(database.Database) - c.pmw = container.Get(static.DiPermissions).(*permissions.Permissions) - c.cmdHandler = container.Get(static.DiCommandHandler).(*ken.Ken) - c.st = container.Get(static.DiState).(*dgrs.State) - - router.Get("/members", c.getMembers) - router.Get("/:memberid", c.getMember) - router.Get("/:memberid/permissions", c.getMemberPermissions) - router.Get("/:memberid/permissions/allowed", c.getMemberPermissionsAllowed) - router.Get("/:memberid/reports", c.getReports) - router.Get("/:memberid/reports/count", c.getReportsCount) - router.Get("/:memberid/unbanrequests", c.pmw.HandleWs(c.session, "sp.guild.mod.unbanrequests"), c.getMemberUnbanrequests) - router.Get("/:memberid/unbanrequests/count", c.pmw.HandleWs(c.session, "sp.guild.mod.unbanrequests"), c.getMemberUnbanrequestsCount) -} - -// @Summary Get Guild Member List -// @Description Returns a list of guild members. -// @Tags Members -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Param after query string false "Request members after the given member ID." -// @Param limit query int false "The amount of results returned." default(100) minimum(1) maximum(1000) -// @Success 200 {array} models.Member "Wraped in models.ListResponse" -// @Failure 400 {object} models.Error -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/members [get] -func (c *GuildMembersController) getMembers(ctx *fiber.Ctx) (err error) { - uid := ctx.Locals("uid").(string) - - guildID := ctx.Params("guildid") - - memb, _ := c.session.GuildMember(guildID, uid) - if memb == nil { - return fiber.ErrNotFound - } - - after := "" - limit := 0 - - after = ctx.Query("after") - limit, err = wsutil.GetQueryInt(ctx, "limit", 100, 1, 1000) - if err != nil { - return err - } - - members, err := c.st.Members(guildID) - if err != nil { - return err - } - - if filter := ctx.Query("filter"); filter != "" { - filter = strings.ToLower(filter) - members = sop.Slice(members).Filter(func(v *discordgo.Member, i int) bool { - return strings.Contains(strings.ToLower(v.Nick), filter) || - strings.Contains(strings.ToLower(v.User.Username), filter) || - strings.Contains(strings.ToLower(v.User.ID), filter) - }).Unwrap() - } else if after != "" { - for i := 0; i < len(members); i++ { - if members[i].User.ID == after { - members = members[i+1:] - break - } - } - } - - if limit > 0 && limit < len(members) { - members = members[:limit] - } - - fhmembers := make([]*models.Member, len(members)) - - for i, m := range members { - fhmembers[i] = models.MemberFromMember(m) - } - - return ctx.JSON(models.NewListResponse(fhmembers)) -} - -// @Summary Get Guild Member -// @Description Returns a single guild member by ID. -// @Tags Members -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Param memberid path string true "The ID of the member." -// @Success 200 {object} models.Member -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/{memberid} [get] -func (c *GuildMembersController) getMember(ctx *fiber.Ctx) (err error) { - uid := ctx.Locals("uid").(string) - - guildID := ctx.Params("guildid") - memberID := ctx.Params("memberid") - - var memb *discordgo.Member - - if memb, _ = c.session.GuildMember(guildID, uid); memb == nil { - return fiber.ErrNotFound - } - - guild, err := c.st.Guild(guildID, true) - if err != nil { - return err - } - - memb, _ = c.session.GuildMember(guildID, memberID) - if memb == nil { - return fiber.ErrNotFound - } - - memb.GuildID = guildID - - mm := models.MemberFromMember(memb) - - switch { - case discordutil.IsAdmin(guild, memb): - mm.Dominance = 1 - case guild.OwnerID == memberID: - mm.Dominance = 2 - case c.cfg.Config().Discord.OwnerID == memb.User.ID: - mm.Dominance = 3 - } - - mm.Karma, err = c.db.GetKarma(memberID, guildID) - if !database.IsErrDatabaseNotFound(err) && err != nil { - return err - } - - mm.KarmaTotal, err = c.db.GetKarmaSum(memberID) - if !database.IsErrDatabaseNotFound(err) && err != nil { - return err - } - - mm.ChatMuted = memb.CommunicationDisabledUntil != nil - - return ctx.JSON(mm) -} - -// @Summary Get Guild Member Permissions -// @Description Returns the permission array of the given user. -// @Tags Members -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Param memberid path string true "The ID of the member." -// @Success 200 {object} models.PermissionsResponse -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/{memberid}/permissions [get] -func (c *GuildMembersController) getMemberPermissions(ctx *fiber.Ctx) (err error) { - uid := ctx.Locals("uid").(string) - - guildID := ctx.Params("guildid") - memberID := ctx.Params("memberid") - - if memb, _ := c.session.GuildMember(guildID, uid); memb == nil { - return fiber.ErrNotFound - } - - perm, _, err := c.pmw.GetPermissions(c.session, guildID, memberID) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - - return ctx.JSON(&models.PermissionsResponse{ - Permissions: perm, - }) -} - -// @Summary Get Guild Member Allowed Permissions -// @Description Returns all detailed permission DNS which the member is alloed to perform. -// @Tags Members -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Param memberid path string true "The ID of the member." -// @Success 200 {array} string "Wrapped in models.ListResponse" -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/{memberid}/permissions/allowed [get] -func (c *GuildMembersController) getMemberPermissionsAllowed(ctx *fiber.Ctx) (err error) { - guildID := ctx.Params("guildid") - memberID := ctx.Params("memberid") - - perms, _, err := c.pmw.GetPermissions(c.session, guildID, memberID) - if database.IsErrDatabaseNotFound(err) { - return fiber.ErrNotFound - } - if err != nil { - return err - } - - all := util.GetAllPermissions(c.cmdHandler) - allowed := all.Filter(func(v string, i int) bool { - return perms.Check(v) - }) - - return ctx.JSON(models.NewListResponse(allowed.Unwrap())) -} - -// @Summary Get Guild Member Reports -// @Description Returns a list of reports of the given member. -// @Tags Members -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Param memberid path string true "The ID of the member." -// @Param limit query int false "The amount of results returned." default(100) minimum(1) maxmimum(100) -// @Param offset query int false "The amount of results to be skipped." default(0) -// @Success 200 {array} models.Report "Wrapped in models.ListResponse" -// @Failure 400 {object} models.Error -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/{memberid}/reports [get] -func (c *GuildMembersController) getReports(ctx *fiber.Ctx) (err error) { - uid := ctx.Locals("uid").(string) - - guildID := ctx.Params("guildid") - memberID := ctx.Params("memberid") - - limit, err := wsutil.GetQueryInt(ctx, "limit", 100, 1, 100) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - - offset, err := wsutil.GetQueryInt(ctx, "offset", 0, 0, -1) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - - if memb, _ := c.session.GuildMember(guildID, uid); memb == nil { - return fiber.ErrNotFound - } - - reps, err := c.db.GetReportsFiltered(guildID, memberID, -1, offset, limit) - if err != nil { - return err - } - - resReps := make([]models.Report, 0) - if reps != nil { - resReps = make([]models.Report, len(reps)) - for i, r := range reps { - resReps[i] = models.ReportFromReport(r, c.cfg.Config().WebServer.PublicAddr) - user, err := c.st.User(r.VictimID) - if err == nil { - resReps[i].Victim = models.FlatUserFromUser(user) - } - user, err = c.st.User(r.ExecutorID) - if err == nil { - resReps[i].Executor = models.FlatUserFromUser(user) - } - } - } - - return ctx.JSON(models.NewListResponse(resReps)) -} - -// @Summary Get Guild Member Reports Count -// @Description Returns the total count of reports of the given user. -// @Tags Members -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Param memberid path string true "The ID of the member." -// @Success 200 {object} models.Count -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/{memberid}/reports/count [get] -func (c *GuildMembersController) getReportsCount(ctx *fiber.Ctx) (err error) { - uid := ctx.Locals("uid").(string) - - guildID := ctx.Params("guildid") - memberID := ctx.Params("memberid") - - if memb, _ := c.session.GuildMember(guildID, uid); memb == nil { - return fiber.ErrNotFound - } - - count, err := c.db.GetReportsFilteredCount(guildID, memberID, -1) - if err != nil { - return err - } - - return ctx.JSON(&models.Count{Count: count}) -} - -// @Summary Get Guild Member Unban Requests -// @Description Returns the list of unban requests of the given member -// @Tags Members -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Param memberid path string true "The ID of the member." -// @Success 200 {array} sharedmodels.UnbanRequest "Wrapped in models.ListResponse" -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/{memberid}/unbanrequests [get] -func (c *GuildMembersController) getMemberUnbanrequests(ctx *fiber.Ctx) (err error) { - guildID := ctx.Params("guildid") - memberID := ctx.Params("memberid") - - requests, err := c.db.GetGuildUserUnbanRequests(guildID, memberID) - if err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - if requests == nil { - requests = make([]sharedmodels.UnbanRequest, 0) - } - - for _, r := range requests { - r.Hydrate() - } - - return ctx.JSON(models.NewListResponse(requests)) -} - -// @Summary Get Guild Member Unban Requests Count -// @Description Returns the total or filtered count of unban requests of the given member. -// @Tags Members -// @Accept json -// @Produce json -// @Param id path string true "The ID of the guild." -// @Param memberid path string true "The ID of the member." -// @Param state query sharedmodels.UnbanRequestState false "Filter unban requests by state." default(-1) -// @Success 200 {object} models.Count -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /guilds/{id}/{memberid}/unbanrequests/count [get] -func (c *GuildMembersController) getMemberUnbanrequestsCount(ctx *fiber.Ctx) (err error) { - guildID := ctx.Params("guildid") - memberID := ctx.Params("memberid") - - stateFilter, err := wsutil.GetQueryInt(ctx, "state", -1, 0, 0) - if err != nil { - return err - } - - requests, err := c.db.GetGuildUserUnbanRequests(guildID, memberID) - if err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - if requests == nil { - requests = make([]sharedmodels.UnbanRequest, 0) - } - - count := len(requests) - if stateFilter > -1 { - count = 0 - for _, r := range requests { - if int(r.Status) == stateFilter { - count++ - } - } - } - - return ctx.JSON(&models.Count{Count: count}) -} +package controllers + +import ( + "strings" + + "github.com/bwmarrin/discordgo" + "github.com/gofiber/fiber/v2" + "github.com/sarulabs/di/v2" + sharedmodels "github.com/zekroTJA/shinpuru/internal/models" + "github.com/zekroTJA/shinpuru/internal/services/config" + "github.com/zekroTJA/shinpuru/internal/services/database" + "github.com/zekroTJA/shinpuru/internal/services/permissions" + "github.com/zekroTJA/shinpuru/internal/services/webserver/v1/models" + "github.com/zekroTJA/shinpuru/internal/services/webserver/wsutil" + "github.com/zekroTJA/shinpuru/internal/util" + "github.com/zekroTJA/shinpuru/internal/util/static" + "github.com/zekroTJA/shinpuru/pkg/discordutil" + "github.com/zekrotja/dgrs" + "github.com/zekrotja/ken" + "github.com/zekrotja/sop" +) + +type GuildMembersController struct { + db Database + session Session + st State + + cfg config.Provider + pmw *permissions.Permissions + cmdHandler *ken.Ken +} + +func (c *GuildMembersController) Setup(container di.Container, router fiber.Router) { + c.session = container.Get(static.DiDiscordSession).(*discordgo.Session) + c.cfg = container.Get(static.DiConfig).(config.Provider) + c.db = container.Get(static.DiDatabase).(database.Database) + c.pmw = container.Get(static.DiPermissions).(*permissions.Permissions) + c.cmdHandler = container.Get(static.DiCommandHandler).(*ken.Ken) + c.st = container.Get(static.DiState).(*dgrs.State) + + router.Get("/members", c.getMembers) + router.Get("/:memberid", c.getMember) + router.Get("/:memberid/permissions", c.getMemberPermissions) + router.Get("/:memberid/permissions/allowed", c.getMemberPermissionsAllowed) + router.Get("/:memberid/reports", c.getReports) + router.Get("/:memberid/reports/count", c.getReportsCount) + router.Get("/:memberid/unbanrequests", c.pmw.HandleWs(c.session, "sp.guild.mod.unbanrequests"), c.getMemberUnbanrequests) + router.Get("/:memberid/unbanrequests/count", c.pmw.HandleWs(c.session, "sp.guild.mod.unbanrequests"), c.getMemberUnbanrequestsCount) +} + +// @Summary Get Guild Member List +// @Description Returns a list of guild members. +// @Tags Members +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Param after query string false "Request members after the given member ID." +// @Param limit query int false "The amount of results returned." default(100) minimum(1) maximum(1000) +// @Success 200 {array} models.Member "Wraped in models.ListResponse" +// @Failure 400 {object} models.Error +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/members [get] +func (c *GuildMembersController) getMembers(ctx *fiber.Ctx) (err error) { + uid := ctx.Locals("uid").(string) + + guildID := ctx.Params("guildid") + + memb, _ := c.session.GuildMember(guildID, uid) + if memb == nil { + return fiber.ErrNotFound + } + + after := "" + limit := 0 + + after = ctx.Query("after") + limit, err = wsutil.GetQueryInt(ctx, "limit", 100, 1, 1000) + if err != nil { + return err + } + + members, err := c.st.Members(guildID) + if err != nil { + return err + } + + if filter := ctx.Query("filter"); filter != "" { + filter = strings.ToLower(filter) + members = sop.Slice(members).Filter(func(v *discordgo.Member, i int) bool { + return strings.Contains(strings.ToLower(v.Nick), filter) || + strings.Contains(strings.ToLower(v.User.Username), filter) || + strings.Contains(strings.ToLower(v.User.ID), filter) + }).Unwrap() + } else if after != "" { + for i := 0; i < len(members); i++ { + if members[i].User.ID == after { + members = members[i+1:] + break + } + } + } + + if limit > 0 && limit < len(members) { + members = members[:limit] + } + + fhmembers := make([]*models.Member, len(members)) + + for i, m := range members { + fhmembers[i] = models.MemberFromMember(m) + } + + return ctx.JSON(models.NewListResponse(fhmembers)) +} + +// @Summary Get Guild Member +// @Description Returns a single guild member by ID. +// @Tags Members +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Param memberid path string true "The ID of the member." +// @Success 200 {object} models.Member +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/{memberid} [get] +func (c *GuildMembersController) getMember(ctx *fiber.Ctx) (err error) { + uid := ctx.Locals("uid").(string) + + guildID := ctx.Params("guildid") + memberID := ctx.Params("memberid") + + var memb *discordgo.Member + + if memb, _ = c.session.GuildMember(guildID, uid); memb == nil { + return fiber.ErrNotFound + } + + guild, err := c.st.Guild(guildID, true) + if err != nil { + return err + } + + memb, _ = c.session.GuildMember(guildID, memberID) + if memb == nil { + return fiber.ErrNotFound + } + + memb.GuildID = guildID + + mm := models.MemberFromMember(memb) + + switch { + case discordutil.IsAdmin(guild, memb): + mm.Dominance = 1 + case guild.OwnerID == memberID: + mm.Dominance = 2 + case c.cfg.Config().Discord.OwnerID == memb.User.ID: + mm.Dominance = 3 + } + + mm.Karma, err = c.db.GetKarma(memberID, guildID) + if !database.IsErrDatabaseNotFound(err) && err != nil { + return err + } + + mm.KarmaTotal, err = c.db.GetKarmaSum(memberID) + if !database.IsErrDatabaseNotFound(err) && err != nil { + return err + } + + mm.ChatMuted = memb.CommunicationDisabledUntil != nil + + return ctx.JSON(mm) +} + +// @Summary Get Guild Member Permissions +// @Description Returns the permission array of the given user. +// @Tags Members +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Param memberid path string true "The ID of the member." +// @Success 200 {object} models.PermissionsResponse +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/{memberid}/permissions [get] +func (c *GuildMembersController) getMemberPermissions(ctx *fiber.Ctx) (err error) { + uid := ctx.Locals("uid").(string) + + guildID := ctx.Params("guildid") + memberID := ctx.Params("memberid") + + if memb, _ := c.session.GuildMember(guildID, uid); memb == nil { + return fiber.ErrNotFound + } + + perm, _, err := c.pmw.GetPermissions(c.session, guildID, memberID) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + return ctx.JSON(&models.PermissionsResponse{ + Permissions: perm, + }) +} + +// @Summary Get Guild Member Allowed Permissions +// @Description Returns all detailed permission DNS which the member is alloed to perform. +// @Tags Members +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Param memberid path string true "The ID of the member." +// @Success 200 {array} string "Wrapped in models.ListResponse" +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/{memberid}/permissions/allowed [get] +func (c *GuildMembersController) getMemberPermissionsAllowed(ctx *fiber.Ctx) (err error) { + guildID := ctx.Params("guildid") + memberID := ctx.Params("memberid") + + perms, _, err := c.pmw.GetPermissions(c.session, guildID, memberID) + if database.IsErrDatabaseNotFound(err) { + return fiber.ErrNotFound + } + if err != nil { + return err + } + + all := util.GetAllPermissions(c.cmdHandler) + allowed := all.Filter(func(v string, i int) bool { + return perms.Check(v) + }) + + return ctx.JSON(models.NewListResponse(allowed.Unwrap())) +} + +// @Summary Get Guild Member Reports +// @Description Returns a list of reports of the given member. +// @Tags Members +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Param memberid path string true "The ID of the member." +// @Param limit query int false "The amount of results returned." default(100) minimum(1) maxmimum(100) +// @Param offset query int false "The amount of results to be skipped." default(0) +// @Success 200 {array} models.Report "Wrapped in models.ListResponse" +// @Failure 400 {object} models.Error +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/{memberid}/reports [get] +func (c *GuildMembersController) getReports(ctx *fiber.Ctx) (err error) { + uid := ctx.Locals("uid").(string) + + guildID := ctx.Params("guildid") + memberID := ctx.Params("memberid") + + limit, err := wsutil.GetQueryInt(ctx, "limit", 100, 1, 100) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + offset, err := wsutil.GetQueryInt(ctx, "offset", 0, 0, -1) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + if memb, _ := c.session.GuildMember(guildID, uid); memb == nil { + return fiber.ErrNotFound + } + + reps, err := c.db.GetReportsFiltered(guildID, memberID, -1, offset, limit) + if err != nil { + return err + } + + resReps := make([]models.Report, 0) + if reps != nil { + resReps = make([]models.Report, len(reps)) + for i, r := range reps { + resReps[i] = models.ReportFromReport(r, c.cfg.Config().WebServer.PublicAddr) + user, err := c.st.User(r.VictimID) + if err == nil { + resReps[i].Victim = models.FlatUserFromUser(user) + } + user, err = c.st.User(r.ExecutorID) + if err == nil { + resReps[i].Executor = models.FlatUserFromUser(user) + } + } + } + + return ctx.JSON(models.NewListResponse(resReps)) +} + +// @Summary Get Guild Member Reports Count +// @Description Returns the total count of reports of the given user. +// @Tags Members +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Param memberid path string true "The ID of the member." +// @Success 200 {object} models.Count +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/{memberid}/reports/count [get] +func (c *GuildMembersController) getReportsCount(ctx *fiber.Ctx) (err error) { + uid := ctx.Locals("uid").(string) + + guildID := ctx.Params("guildid") + memberID := ctx.Params("memberid") + + if memb, _ := c.session.GuildMember(guildID, uid); memb == nil { + return fiber.ErrNotFound + } + + count, err := c.db.GetReportsFilteredCount(guildID, memberID, -1) + if err != nil { + return err + } + + return ctx.JSON(&models.Count{Count: count}) +} + +// @Summary Get Guild Member Unban Requests +// @Description Returns the list of unban requests of the given member +// @Tags Members +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Param memberid path string true "The ID of the member." +// @Success 200 {array} sharedmodels.UnbanRequest "Wrapped in models.ListResponse" +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/{memberid}/unbanrequests [get] +func (c *GuildMembersController) getMemberUnbanrequests(ctx *fiber.Ctx) (err error) { + guildID := ctx.Params("guildid") + memberID := ctx.Params("memberid") + + requests, err := c.db.GetGuildUserUnbanRequests(guildID, memberID) + if err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + if requests == nil { + requests = make([]sharedmodels.UnbanRequest, 0) + } + + for _, r := range requests { + r.Hydrate() + } + + return ctx.JSON(models.NewListResponse(requests)) +} + +// @Summary Get Guild Member Unban Requests Count +// @Description Returns the total or filtered count of unban requests of the given member. +// @Tags Members +// @Accept json +// @Produce json +// @Param id path string true "The ID of the guild." +// @Param memberid path string true "The ID of the member." +// @Param state query sharedmodels.UnbanRequestState false "Filter unban requests by state." default(-1) +// @Success 200 {object} models.Count +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /guilds/{id}/{memberid}/unbanrequests/count [get] +func (c *GuildMembersController) getMemberUnbanrequestsCount(ctx *fiber.Ctx) (err error) { + guildID := ctx.Params("guildid") + memberID := ctx.Params("memberid") + + stateFilter, err := wsutil.GetQueryInt(ctx, "state", -1, 0, 0) + if err != nil { + return err + } + + requests, err := c.db.GetGuildUserUnbanRequests(guildID, memberID) + if err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + if requests == nil { + requests = make([]sharedmodels.UnbanRequest, 0) + } + + count := len(requests) + if stateFilter > -1 { + count = 0 + for _, r := range requests { + if int(r.Status) == stateFilter { + count++ + } + } + } + + return ctx.JSON(&models.Count{Count: count}) +} diff --git a/internal/services/webserver/v1/controllers/ota.go b/internal/services/webserver/v1/controllers/ota.go index 6224b5385..de26b1d61 100644 --- a/internal/services/webserver/v1/controllers/ota.go +++ b/internal/services/webserver/v1/controllers/ota.go @@ -1,86 +1,87 @@ -package controllers - -import ( - "fmt" - "time" - - "github.com/bwmarrin/discordgo" - "github.com/gofiber/fiber/v2" - "github.com/sarulabs/di/v2" - "github.com/zekroTJA/shinpuru/internal/services/config" - "github.com/zekroTJA/shinpuru/internal/services/database" - "github.com/zekroTJA/shinpuru/internal/services/timeprovider" - "github.com/zekroTJA/shinpuru/internal/services/webserver/auth" - _ "github.com/zekroTJA/shinpuru/internal/services/webserver/v1/models" // Import for API documentation - "github.com/zekroTJA/shinpuru/internal/util/static" - "github.com/zekroTJA/shinpuru/pkg/discordoauth/v2" - "github.com/zekroTJA/shinpuru/pkg/onetimeauth/v2" -) - -type OTAController struct { - session *discordgo.Session - cfg config.Provider - db database.Database - ota onetimeauth.OneTimeAuth - oauthHandler auth.RequestHandler - tp timeprovider.Provider -} - -func (c *OTAController) Setup(container di.Container, router fiber.Router) { - c.session = container.Get(static.DiDiscordSession).(*discordgo.Session) - c.cfg = container.Get(static.DiConfig).(config.Provider) - c.db = container.Get(static.DiDatabase).(database.Database) - c.ota = container.Get(static.DiOneTimeAuth).(onetimeauth.OneTimeAuth) - c.oauthHandler = container.Get(static.DiOAuthHandler).(auth.RequestHandler) - c.tp = container.Get(static.DiTimeProvider).(timeprovider.Provider) - - router.Get("", c.getOta) -} - -// @Summary OTA Login -// @Description Logs in the current browser session by using the passed pre-obtained OTA token. -// @Tags OTA -// @Accept json -// @Produce json -// @Success 200 -// @Failure 401 {object} models.Error -// @Router /ota [get] -func (c *OTAController) getOta(ctx *fiber.Ctx) error { - token := ctx.Query("token") - - if token == "" { - return fiber.NewError(fiber.StatusUnauthorized, "invalid ota token") - } - - userID, err := c.ota.ValidateKey(token, "login-via-dm") - if err != nil { - return fiber.NewError(fiber.StatusUnauthorized, "invalid ota token") - } - - enabled, err := c.db.GetUserOTAEnabled(userID) - if err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - - if !enabled { - return fiber.NewError(fiber.StatusUnauthorized, "ota disabled") - } - - if ch, err := c.session.UserChannelCreate(userID); err == nil { - ipaddr := ctx.IP() - useragent := string(ctx.Context().UserAgent()) - emb := &discordgo.MessageEmbed{ - Color: static.ColorEmbedOrange, - Description: fmt.Sprintf("Someone logged in to the web interface as you.\n"+ - "\n**Details:**\nIP Address: ||`%s`||\nUser Agent: `%s`\n\n"+ - "If this was not you, consider disabling OTA [**here**](%s/usersettings).", - ipaddr, useragent, c.cfg.Config().WebServer.PublicAddr), - Timestamp: c.tp.Now().Format(time.RFC3339), - } - c.session.ChannelMessageSendEmbed(ch.ID, emb) - } - - return c.oauthHandler.LoginSuccessHandler(ctx, discordoauth.SuccessResult{ - UserID: userID, - }) -} +package controllers + +import ( + "fmt" + "time" + + "github.com/bwmarrin/discordgo" + "github.com/gofiber/fiber/v2" + "github.com/sarulabs/di/v2" + "github.com/zekroTJA/shinpuru/internal/services/config" + "github.com/zekroTJA/shinpuru/internal/services/database" + "github.com/zekroTJA/shinpuru/internal/services/timeprovider" + "github.com/zekroTJA/shinpuru/internal/services/webserver/auth" + _ "github.com/zekroTJA/shinpuru/internal/services/webserver/v1/models" // Import for API documentation + "github.com/zekroTJA/shinpuru/internal/util/static" + "github.com/zekroTJA/shinpuru/pkg/discordoauth/v2" + "github.com/zekroTJA/shinpuru/pkg/onetimeauth/v2" +) + +type OTAController struct { + db Database + session Session + + cfg config.Provider + ota onetimeauth.OneTimeAuth + oauthHandler auth.RequestHandler + tp timeprovider.Provider +} + +func (c *OTAController) Setup(container di.Container, router fiber.Router) { + c.session = container.Get(static.DiDiscordSession).(*discordgo.Session) + c.cfg = container.Get(static.DiConfig).(config.Provider) + c.db = container.Get(static.DiDatabase).(database.Database) + c.ota = container.Get(static.DiOneTimeAuth).(onetimeauth.OneTimeAuth) + c.oauthHandler = container.Get(static.DiOAuthHandler).(auth.RequestHandler) + c.tp = container.Get(static.DiTimeProvider).(timeprovider.Provider) + + router.Get("", c.getOta) +} + +// @Summary OTA Login +// @Description Logs in the current browser session by using the passed pre-obtained OTA token. +// @Tags OTA +// @Accept json +// @Produce json +// @Success 200 +// @Failure 401 {object} models.Error +// @Router /ota [get] +func (c *OTAController) getOta(ctx *fiber.Ctx) error { + token := ctx.Query("token") + + if token == "" { + return fiber.NewError(fiber.StatusUnauthorized, "invalid ota token") + } + + userID, err := c.ota.ValidateKey(token, "login-via-dm") + if err != nil { + return fiber.NewError(fiber.StatusUnauthorized, "invalid ota token") + } + + enabled, err := c.db.GetUserOTAEnabled(userID) + if err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + + if !enabled { + return fiber.NewError(fiber.StatusUnauthorized, "ota disabled") + } + + if ch, err := c.session.UserChannelCreate(userID); err == nil { + ipaddr := ctx.IP() + useragent := string(ctx.Context().UserAgent()) + emb := &discordgo.MessageEmbed{ + Color: static.ColorEmbedOrange, + Description: fmt.Sprintf("Someone logged in to the web interface as you.\n"+ + "\n**Details:**\nIP Address: ||`%s`||\nUser Agent: `%s`\n\n"+ + "If this was not you, consider disabling OTA [**here**](%s/usersettings).", + ipaddr, useragent, c.cfg.Config().WebServer.PublicAddr), + Timestamp: c.tp.Now().Format(time.RFC3339), + } + c.session.ChannelMessageSendEmbed(ch.ID, emb) + } + + return c.oauthHandler.LoginSuccessHandler(ctx, discordoauth.SuccessResult{ + UserID: userID, + }) +} diff --git a/internal/services/webserver/v1/controllers/public.go b/internal/services/webserver/v1/controllers/public.go index ae21417b3..c52901620 100644 --- a/internal/services/webserver/v1/controllers/public.go +++ b/internal/services/webserver/v1/controllers/public.go @@ -1,97 +1,97 @@ -package controllers - -import ( - "strings" - - "github.com/bwmarrin/discordgo" - "github.com/gofiber/fiber/v2" - "github.com/sarulabs/di/v2" - "github.com/zekroTJA/shinpuru/internal/services/database" - "github.com/zekroTJA/shinpuru/internal/services/webserver/v1/models" - "github.com/zekroTJA/shinpuru/internal/util/static" - "github.com/zekroTJA/shinpuru/pkg/hashutil" - "github.com/zekrotja/dgrs" -) - -type PublicController struct { - session *discordgo.Session - db database.Database - st *dgrs.State -} - -func (c *PublicController) Setup(container di.Container, router fiber.Router) { - c.session = container.Get(static.DiDiscordSession).(*discordgo.Session) - c.db = container.Get(static.DiDatabase).(database.Database) - c.st = container.Get(static.DiState).(*dgrs.State) - - router.Get("/guilds/:guildid", c.getGuild) -} - -// @Summary Get Public Guild -// @Description Returns public guild information, if enabled by guild config. -// @Tags Public -// @Accept json -// @Produce json -// @Param id path string true "The Guild ID." -// @Success 200 {object} models.GuildReduced -// @Router /public/guilds/{id} [get] -func (c *PublicController) getGuild(ctx *fiber.Ctx) error { - guildID := ctx.Params("guildid") - - state, err := c.db.GetGuildAPI(guildID) - if err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - - if !state.Enabled { - return fiber.ErrNotFound - } - - if state.Hydrate().Protected { - token := c.obtainToken(ctx) - if token == "" { - return fiber.ErrNotFound - } - ok, err := hashutil.Compare(token, state.TokenHash) - if err != nil { - return err - } - if !ok { - return fiber.ErrNotFound - } - } - - if state.AllowedOrigins == "" { - state.AllowedOrigins = "*" - } - - guild, err := c.st.Guild(guildID) - if err != nil { - return err - } - - ctx.Set("Access-Control-Allow-Origin", state.AllowedOrigins) - ctx.Set("Access-Control-Allow-Methods", "GET") - ctx.Set("Access-Control-Allow-Headers", "*") - - gr := models.GuildReducedFromGuild(guild) - - return ctx.JSON(gr) -} - -func (c *PublicController) obtainToken(ctx *fiber.Ctx) (token string) { - token = ctx.Query("token") - - if token == "" { - split := strings.SplitN(ctx.Get("Authorization"), " ", 2) - if len(split) < 2 { - return - } - if strings.ToLower(split[0]) != "bearer" { - return - } - token = split[1] - } - - return -} +package controllers + +import ( + "strings" + + "github.com/bwmarrin/discordgo" + "github.com/gofiber/fiber/v2" + "github.com/sarulabs/di/v2" + "github.com/zekroTJA/shinpuru/internal/services/database" + "github.com/zekroTJA/shinpuru/internal/services/webserver/v1/models" + "github.com/zekroTJA/shinpuru/internal/util/static" + "github.com/zekroTJA/shinpuru/pkg/hashutil" + "github.com/zekrotja/dgrs" +) + +type PublicController struct { + db Database + session Session + st State +} + +func (c *PublicController) Setup(container di.Container, router fiber.Router) { + c.session = container.Get(static.DiDiscordSession).(*discordgo.Session) + c.db = container.Get(static.DiDatabase).(database.Database) + c.st = container.Get(static.DiState).(*dgrs.State) + + router.Get("/guilds/:guildid", c.getGuild) +} + +// @Summary Get Public Guild +// @Description Returns public guild information, if enabled by guild config. +// @Tags Public +// @Accept json +// @Produce json +// @Param id path string true "The Guild ID." +// @Success 200 {object} models.GuildReduced +// @Router /public/guilds/{id} [get] +func (c *PublicController) getGuild(ctx *fiber.Ctx) error { + guildID := ctx.Params("guildid") + + state, err := c.db.GetGuildAPI(guildID) + if err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + + if !state.Enabled { + return fiber.ErrNotFound + } + + if state.Hydrate().Protected { + token := c.obtainToken(ctx) + if token == "" { + return fiber.ErrNotFound + } + ok, err := hashutil.Compare(token, state.TokenHash) + if err != nil { + return err + } + if !ok { + return fiber.ErrNotFound + } + } + + if state.AllowedOrigins == "" { + state.AllowedOrigins = "*" + } + + guild, err := c.st.Guild(guildID) + if err != nil { + return err + } + + ctx.Set("Access-Control-Allow-Origin", state.AllowedOrigins) + ctx.Set("Access-Control-Allow-Methods", "GET") + ctx.Set("Access-Control-Allow-Headers", "*") + + gr := models.GuildReducedFromGuild(guild) + + return ctx.JSON(gr) +} + +func (c *PublicController) obtainToken(ctx *fiber.Ctx) (token string) { + token = ctx.Query("token") + + if token == "" { + split := strings.SplitN(ctx.Get("Authorization"), " ", 2) + if len(split) < 2 { + return + } + if strings.ToLower(split[0]) != "bearer" { + return + } + token = split[1] + } + + return +} diff --git a/internal/services/webserver/v1/controllers/reports.go b/internal/services/webserver/v1/controllers/reports.go index 125cd7564..3277192a8 100644 --- a/internal/services/webserver/v1/controllers/reports.go +++ b/internal/services/webserver/v1/controllers/reports.go @@ -1,120 +1,121 @@ -package controllers - -import ( - "github.com/bwmarrin/discordgo" - "github.com/bwmarrin/snowflake" - "github.com/gofiber/fiber/v2" - "github.com/sarulabs/di/v2" - "github.com/zekroTJA/shinpuru/internal/services/config" - "github.com/zekroTJA/shinpuru/internal/services/database" - "github.com/zekroTJA/shinpuru/internal/services/permissions" - "github.com/zekroTJA/shinpuru/internal/services/report" - "github.com/zekroTJA/shinpuru/internal/services/webserver/v1/models" - "github.com/zekroTJA/shinpuru/internal/util/static" -) - -type ReportsController struct { - session *discordgo.Session - cfg config.Provider - db database.Database - repSvc *report.ReportService - pmw *permissions.Permissions -} - -func (c *ReportsController) Setup(container di.Container, router fiber.Router) { - c.session = container.Get(static.DiDiscordSession).(*discordgo.Session) - c.cfg = container.Get(static.DiConfig).(config.Provider) - c.db = container.Get(static.DiDatabase).(database.Database) - c.repSvc = container.Get(static.DiReport).(*report.ReportService) - c.pmw = container.Get(static.DiPermissions).(*permissions.Permissions) - - router.Get("/:id", c.getReport) - router.Post("/:id/revoke", c.postRevoke) -} - -// @Summary Get Report -// @Description Returns a single report object by its ID. -// @Tags Reports -// @Accept json -// @Produce json -// @Param id path string true "The report ID." -// @Success 200 {object} models.Report -// @Failure 400 {object} models.Error -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /reports/{id} [get] -func (c *ReportsController) getReport(ctx *fiber.Ctx) (err error) { - _id := ctx.Params("id") - - id, err := snowflake.ParseString(_id) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - - rep, err := c.db.GetReport(id) - if database.IsErrDatabaseNotFound(err) { - return fiber.ErrNotFound - } - if err != nil { - return err - } - - return ctx.JSON(models.ReportFromReport(rep, c.cfg.Config().WebServer.PublicAddr)) -} - -// @Summary Revoke Report -// @Description Revokes a given report by ID. -// @Tags Reports -// @Accept json -// @Produce json -// @Param id path string true "The report ID." -// @Param payload body models.ReasonRequest true "The revoke reason payload." -// @Success 200 {object} models.Report -// @Failure 400 {object} models.Error -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /reports/{id}/revoke [post] -func (c *ReportsController) postRevoke(ctx *fiber.Ctx) (err error) { - uid := ctx.Locals("uid").(string) - - _id := ctx.Params("id") - - id, err := snowflake.ParseString(_id) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - - rep, err := c.db.GetReport(id) - if database.IsErrDatabaseNotFound(err) { - return fiber.ErrNotFound - } - if err != nil { - return err - } - - ok, _, err := c.pmw.CheckPermissions(c.session, rep.GuildID, uid, "sp.guild.mod.report.revoke") - if err != nil { - return err - } - if !ok { - return fiber.ErrForbidden - } - - var reason models.ReasonRequest - if err := ctx.BodyParser(&reason); err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - - _, err = c.repSvc.RevokeReport( - rep, - uid, - reason.Reason, - c.cfg.Config().WebServer.Addr, - ) - - if err != nil { - return err - } - - return ctx.JSON(models.Ok) -} +package controllers + +import ( + "github.com/bwmarrin/discordgo" + "github.com/bwmarrin/snowflake" + "github.com/gofiber/fiber/v2" + "github.com/sarulabs/di/v2" + "github.com/zekroTJA/shinpuru/internal/services/config" + "github.com/zekroTJA/shinpuru/internal/services/database" + "github.com/zekroTJA/shinpuru/internal/services/permissions" + "github.com/zekroTJA/shinpuru/internal/services/report" + "github.com/zekroTJA/shinpuru/internal/services/webserver/v1/models" + "github.com/zekroTJA/shinpuru/internal/util/static" +) + +type ReportsController struct { + db Database + session Session + + cfg config.Provider + repSvc *report.ReportService + pmw *permissions.Permissions +} + +func (c *ReportsController) Setup(container di.Container, router fiber.Router) { + c.session = container.Get(static.DiDiscordSession).(*discordgo.Session) + c.cfg = container.Get(static.DiConfig).(config.Provider) + c.db = container.Get(static.DiDatabase).(database.Database) + c.repSvc = container.Get(static.DiReport).(*report.ReportService) + c.pmw = container.Get(static.DiPermissions).(*permissions.Permissions) + + router.Get("/:id", c.getReport) + router.Post("/:id/revoke", c.postRevoke) +} + +// @Summary Get Report +// @Description Returns a single report object by its ID. +// @Tags Reports +// @Accept json +// @Produce json +// @Param id path string true "The report ID." +// @Success 200 {object} models.Report +// @Failure 400 {object} models.Error +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /reports/{id} [get] +func (c *ReportsController) getReport(ctx *fiber.Ctx) (err error) { + _id := ctx.Params("id") + + id, err := snowflake.ParseString(_id) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + rep, err := c.db.GetReport(id) + if database.IsErrDatabaseNotFound(err) { + return fiber.ErrNotFound + } + if err != nil { + return err + } + + return ctx.JSON(models.ReportFromReport(rep, c.cfg.Config().WebServer.PublicAddr)) +} + +// @Summary Revoke Report +// @Description Revokes a given report by ID. +// @Tags Reports +// @Accept json +// @Produce json +// @Param id path string true "The report ID." +// @Param payload body models.ReasonRequest true "The revoke reason payload." +// @Success 200 {object} models.Report +// @Failure 400 {object} models.Error +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /reports/{id}/revoke [post] +func (c *ReportsController) postRevoke(ctx *fiber.Ctx) (err error) { + uid := ctx.Locals("uid").(string) + + _id := ctx.Params("id") + + id, err := snowflake.ParseString(_id) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + rep, err := c.db.GetReport(id) + if database.IsErrDatabaseNotFound(err) { + return fiber.ErrNotFound + } + if err != nil { + return err + } + + ok, _, err := c.pmw.CheckPermissions(c.session, rep.GuildID, uid, "sp.guild.mod.report.revoke") + if err != nil { + return err + } + if !ok { + return fiber.ErrForbidden + } + + var reason models.ReasonRequest + if err := ctx.BodyParser(&reason); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + _, err = c.repSvc.RevokeReport( + rep, + uid, + reason.Reason, + c.cfg.Config().WebServer.Addr, + ) + + if err != nil { + return err + } + + return ctx.JSON(models.Ok) +} diff --git a/internal/services/webserver/v1/controllers/search.go b/internal/services/webserver/v1/controllers/search.go index 60c34f75b..edd2412fc 100644 --- a/internal/services/webserver/v1/controllers/search.go +++ b/internal/services/webserver/v1/controllers/search.go @@ -16,9 +16,9 @@ import ( ) type SearchController struct { - session *discordgo.Session - st *dgrs.State - kv kvcache.Provider + session Session + st State + kv KvCache } func (c *SearchController) Setup(container di.Container, router fiber.Router) { diff --git a/internal/services/webserver/v1/controllers/token.go b/internal/services/webserver/v1/controllers/token.go index 95f2176ba..fd6934891 100644 --- a/internal/services/webserver/v1/controllers/token.go +++ b/internal/services/webserver/v1/controllers/token.go @@ -1,98 +1,99 @@ -package controllers - -import ( - "github.com/gofiber/fiber/v2" - "github.com/sarulabs/di/v2" - "github.com/zekroTJA/shinpuru/internal/services/database" - "github.com/zekroTJA/shinpuru/internal/services/timeprovider" - "github.com/zekroTJA/shinpuru/internal/services/webserver/auth" - "github.com/zekroTJA/shinpuru/internal/services/webserver/v1/models" - "github.com/zekroTJA/shinpuru/internal/util/static" -) - -type TokenController struct { - db database.Database - apith auth.APITokenHandler - tp timeprovider.Provider -} - -func (c *TokenController) Setup(container di.Container, router fiber.Router) { - c.db = container.Get(static.DiDatabase).(database.Database) - c.apith = container.Get(static.DiAuthAPITokenHandler).(auth.APITokenHandler) - c.tp = container.Get(static.DiTimeProvider).(timeprovider.Provider) - - router.Get("", c.getToken) - router.Post("", c.postToken) - router.Delete("", c.deleteToken) -} - -// @Summary API Token Info -// @Description Returns general metadata information about a generated API token. The response does **not** contain the actual token! -// @Tags Tokens -// @Accept json -// @Produce json -// @Success 200 {object} models.APITokenResponse -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error "Is returned when no token was generated before." -// @Router /token [get] -func (c *TokenController) getToken(ctx *fiber.Ctx) error { - uid := ctx.Locals("uid").(string) - - token, err := c.db.GetAPIToken(uid) - if database.IsErrDatabaseNotFound(err) { - return fiber.NewError(fiber.StatusNotFound, "no token found") - } else if err != nil { - return err - } - - tokenResp := &models.APITokenResponse{ - Created: token.Created, - Expires: token.Expires, - Hits: token.Hits, - LastAccess: token.LastAccess, - } - - return ctx.JSON(tokenResp) -} - -// @Summary API Token Generation -// @Description (Re-)Generates and returns general metadata information about an API token **including** the actual API token. -// @Tags Tokens -// @Accept json -// @Produce json -// @Success 200 {object} models.APITokenResponse -// @Failure 401 {object} models.Error -// @Router /token [post] -func (c *TokenController) postToken(ctx *fiber.Ctx) error { - uid := ctx.Locals("uid").(string) - - token, expires, err := c.apith.GetAPIToken(uid) - if err != nil { - return err - } - - return ctx.JSON(&models.APITokenResponse{ - Created: c.tp.Now(), - Expires: expires, - Token: token, - }) -} - -// @Summary API Token Deletion -// @Description Invalidates the currently generated API token. -// @Tags Tokens -// @Accept json -// @Produce json -// @Success 200 {object} models.Status -// @Failure 401 {object} models.Error -// @Router /token [delete] -func (c *TokenController) deleteToken(ctx *fiber.Ctx) error { - uid := ctx.Locals("uid").(string) - - err := c.apith.RevokeToken(uid) - if err != nil { - return err - } - - return ctx.JSON(models.Ok) -} +package controllers + +import ( + "github.com/gofiber/fiber/v2" + "github.com/sarulabs/di/v2" + "github.com/zekroTJA/shinpuru/internal/services/database" + "github.com/zekroTJA/shinpuru/internal/services/timeprovider" + "github.com/zekroTJA/shinpuru/internal/services/webserver/auth" + "github.com/zekroTJA/shinpuru/internal/services/webserver/v1/models" + "github.com/zekroTJA/shinpuru/internal/util/static" +) + +type TokenController struct { + db Database + tp TimeProvider + + apith auth.APITokenHandler +} + +func (c *TokenController) Setup(container di.Container, router fiber.Router) { + c.db = container.Get(static.DiDatabase).(database.Database) + c.apith = container.Get(static.DiAuthAPITokenHandler).(auth.APITokenHandler) + c.tp = container.Get(static.DiTimeProvider).(timeprovider.Provider) + + router.Get("", c.getToken) + router.Post("", c.postToken) + router.Delete("", c.deleteToken) +} + +// @Summary API Token Info +// @Description Returns general metadata information about a generated API token. The response does **not** contain the actual token! +// @Tags Tokens +// @Accept json +// @Produce json +// @Success 200 {object} models.APITokenResponse +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error "Is returned when no token was generated before." +// @Router /token [get] +func (c *TokenController) getToken(ctx *fiber.Ctx) error { + uid := ctx.Locals("uid").(string) + + token, err := c.db.GetAPIToken(uid) + if database.IsErrDatabaseNotFound(err) { + return fiber.NewError(fiber.StatusNotFound, "no token found") + } else if err != nil { + return err + } + + tokenResp := &models.APITokenResponse{ + Created: token.Created, + Expires: token.Expires, + Hits: token.Hits, + LastAccess: token.LastAccess, + } + + return ctx.JSON(tokenResp) +} + +// @Summary API Token Generation +// @Description (Re-)Generates and returns general metadata information about an API token **including** the actual API token. +// @Tags Tokens +// @Accept json +// @Produce json +// @Success 200 {object} models.APITokenResponse +// @Failure 401 {object} models.Error +// @Router /token [post] +func (c *TokenController) postToken(ctx *fiber.Ctx) error { + uid := ctx.Locals("uid").(string) + + token, expires, err := c.apith.GetAPIToken(uid) + if err != nil { + return err + } + + return ctx.JSON(&models.APITokenResponse{ + Created: c.tp.Now(), + Expires: expires, + Token: token, + }) +} + +// @Summary API Token Deletion +// @Description Invalidates the currently generated API token. +// @Tags Tokens +// @Accept json +// @Produce json +// @Success 200 {object} models.Status +// @Failure 401 {object} models.Error +// @Router /token [delete] +func (c *TokenController) deleteToken(ctx *fiber.Ctx) error { + uid := ctx.Locals("uid").(string) + + err := c.apith.RevokeToken(uid) + if err != nil { + return err + } + + return ctx.JSON(models.Ok) +} diff --git a/internal/services/webserver/v1/controllers/unbanreqeusts.go b/internal/services/webserver/v1/controllers/unbanreqeusts.go index cc9df4d23..c09a06fa5 100644 --- a/internal/services/webserver/v1/controllers/unbanreqeusts.go +++ b/internal/services/webserver/v1/controllers/unbanreqeusts.go @@ -1,283 +1,284 @@ -package controllers - -import ( - "fmt" - "time" - - "github.com/bwmarrin/discordgo" - "github.com/gofiber/fiber/v2" - "github.com/sarulabs/di/v2" - sharedmodels "github.com/zekroTJA/shinpuru/internal/models" - "github.com/zekroTJA/shinpuru/internal/services/config" - "github.com/zekroTJA/shinpuru/internal/services/database" - "github.com/zekroTJA/shinpuru/internal/services/guildlog" - "github.com/zekroTJA/shinpuru/internal/services/permissions" - "github.com/zekroTJA/shinpuru/internal/services/webserver/v1/models" - "github.com/zekroTJA/shinpuru/internal/util/modnot" - "github.com/zekroTJA/shinpuru/internal/util/snowflakenodes" - "github.com/zekroTJA/shinpuru/internal/util/static" - "github.com/zekroTJA/shinpuru/pkg/discordutil" - "github.com/zekrotja/dgrs" - "github.com/zekrotja/rogu/log" - "github.com/zekrotja/sop" -) - -type UnbanrequestsController struct { - session *discordgo.Session - db database.Database - pmw *permissions.Permissions - st *dgrs.State - cfg config.Provider - gl guildlog.Logger -} - -func (c *UnbanrequestsController) Setup(container di.Container, router fiber.Router) { - c.session = container.Get(static.DiDiscordSession).(*discordgo.Session) - c.db = container.Get(static.DiDatabase).(database.Database) - c.pmw = container.Get(static.DiPermissions).(*permissions.Permissions) - c.st = container.Get(static.DiState).(*dgrs.State) - c.cfg = container.Get(static.DiConfig).(config.Provider) - c.gl = container.Get(static.DiGuildLog).(guildlog.Logger) - - router.Get("", c.getUnbanrequests) - router.Post("", c.postUnbanrequests) - router.Get("/bannedguilds", c.getBannedGuilds) -} - -// @Summary Get Unban Requests -// @Description Returns a list of unban requests created by the authenticated user. -// @Tags Unban Requests -// @Accept json -// @Produce json -// @Success 200 {array} models.RichUnbanRequest "Wrapped in models.ListResponse" -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /unbanrequests [get] -func (c *UnbanrequestsController) getUnbanrequests(ctx *fiber.Ctx) error { - uid := ctx.Locals("uid").(string) - - requests, err := c.db.GetGuildUserUnbanRequests(uid, "") - if err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - if requests == nil { - requests = make([]sharedmodels.UnbanRequest, 0) - } - - self, err := c.st.User(uid) - if err != nil { - return err - } - - res := sop.Map[sharedmodels.UnbanRequest](sop.Slice(requests), - func(r sharedmodels.UnbanRequest, i int) models.RichUnbanRequest { - r.Hydrate() - rub := models.RichUnbanRequest{ - UnbanRequest: r, - Creator: models.FlatUserFromUser(self), - } - if proc, _ := c.st.User(rub.ProcessedBy); proc != nil { - rub.Processor = models.FlatUserFromUser(proc) - } - return rub - }) - - return ctx.JSON(models.NewListResponse(res.Unwrap())) -} - -// @Summary Create Unban Requests -// @Description Create an unban reuqest. -// @Tags Unban Requests -// @Accept json -// @Produce json -// @Param payload body sharedmodels.UnbanRequest true "The unban request payload." -// @Success 200 {object} models.RichUnbanRequest -// @Failure 400 {object} models.Error -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /unbanrequests [post] -func (c *UnbanrequestsController) postUnbanrequests(ctx *fiber.Ctx) error { - uid := ctx.Locals("uid").(string) - - user, err := c.session.User(uid) - if err != nil { - return err - } - - req := new(sharedmodels.UnbanRequest) - if err := ctx.BodyParser(req); err != nil { - return err - } - if err := req.Validate(); err != nil { - return err - } - - applicableReps, err := c.getUserApplicableReports(uid) - if err != nil { - return err - } - - rep, i := sop.Slice(applicableReps).First(func(v sharedmodels.Report, i int) bool { - return v.GuildID == req.GuildID - }) - if i == -1 { - return fiber.NewError(fiber.StatusBadRequest, "you are not able to create an unban request for this guild") - } - - requests, err := c.db.GetGuildUserUnbanRequests(uid, req.GuildID) - if err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - - for _, r := range requests { - if r.Status == sharedmodels.UnbanRequestStatePending { - return fiber.NewError(fiber.StatusBadRequest, "there is still one open unban request to be proceed") - } - } - - finalReq := sharedmodels.UnbanRequest{ - ID: snowflakenodes.NodeUnbanRequests.Generate(), - UserID: uid, - GuildID: req.GuildID, - UserTag: user.String(), - Message: req.Message, - Status: sharedmodels.UnbanRequestStatePending, - ReportID: rep.ID, - } - - if err := c.db.AddUnbanRequest(finalReq); err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - - finalReq.Hydrate() - - err = modnot.Send(c.db, c.session, req.GuildID, &discordgo.MessageEmbed{ - Color: static.ColorEmbedViolett, - Title: "New unban request", - URL: fmt.Sprintf("%s/db/guilds/%s/modlog", - c.cfg.Config().WebServer.PublicAddr, finalReq.GuildID), - Fields: []*discordgo.MessageEmbedField{ - { - Name: "User", - Value: fmt.Sprintf("%s (`%s`)", user.String(), user.ID), - }, - { - Name: "Message", - Value: finalReq.Message, - }, - }, - Footer: &discordgo.MessageEmbedFooter{ - Text: fmt.Sprintf("ID: %s", finalReq.ID), - }, - Timestamp: finalReq.Created.Format(time.RFC3339), - }) - if err != nil { - log.Error().Err(err).Tag("WebServer").Msg("Failed sending mod notification") - c.gl.Section("modnot").Errorf(finalReq.GuildID, "Failed sending mod notification: %s", err.Error()) - } - - return ctx.JSON(models.RichUnbanRequest{ - UnbanRequest: finalReq, - Creator: models.FlatUserFromUser(user), - }) -} - -// @Summary Get Banned Guilds -// @Description Returns a list of guilds where the currently authenticated user is banned. -// @Tags Unban Requests -// @Accept json -// @Produce json -// @Success 200 {array} models.GuildReduced "Wrapped in models.ListResponse" -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /unbanrequests/bannedguilds [get] -func (c *UnbanrequestsController) getBannedGuilds(ctx *fiber.Ctx) error { - uid := ctx.Locals("uid").(string) - - applicableReps, err := c.getUserApplicableReports(uid) - if err != nil { - return err - } - - guilds := make(map[string]*models.GuildReduced) - for _, rep := range applicableReps { - if _, ok := guilds[rep.GuildID]; ok { - continue - } - guild, err := c.st.Guild(rep.GuildID) - if discordutil.IsErrCode(err, discordgo.ErrCodeUnknownGuild) { - continue - } - if err != nil { - return err - } - guilds[rep.GuildID] = models.GuildReducedFromGuild(guild) - } - - guildsArr := make([]*models.GuildReduced, len(guilds)) - i := 0 - for _, g := range guilds { - guildsArr[i] = g - i++ - } - - return ctx.JSON(models.NewListResponse(guildsArr)) -} - -// --- HELPERS ------------ - -func (c *UnbanrequestsController) getUserApplicableReports(userID string) ([]sharedmodels.Report, error) { - // Get all ban reports - banReps, err := c.db.GetReportsFiltered( - "", userID, sharedmodels.TypeBan, 0, 100000) - if err != nil { - if database.IsErrDatabaseNotFound(err) { - return nil, nil - } - return nil, err - } - - // Get all unban accepted reports - unbanAcceptedReps, err := c.db.GetReportsFiltered( - "", userID, sharedmodels.TypeUnban, 0, 100000) - if err != nil { - if database.IsErrDatabaseNotFound(err) { - return nil, nil - } - return nil, err - } - - // Get all unban rejected reports - unbanRejectedReps, err := c.db.GetReportsFiltered( - "", userID, sharedmodels.TypeUnbanRejected, 0, 100000) - if err != nil { - if database.IsErrDatabaseNotFound(err) { - return nil, nil - } - return nil, err - } - - applicableReps := make([]sharedmodels.Report, 0, len(banReps)) - for _, banRep := range banReps { - _, i := sop.Slice(unbanAcceptedReps).First(func(v sharedmodels.Report, i int) bool { - return v.GuildID == banRep.GuildID && - time.UnixMilli(banRep.ID.Time()).Before(time.UnixMilli(v.ID.Time())) - }) - if i != -1 { - continue - } - _, i = sop.Slice(unbanRejectedReps).First(func(v sharedmodels.Report, i int) bool { - return v.GuildID == banRep.GuildID && - time.UnixMilli(banRep.ID.Time()).Before(time.UnixMilli(v.ID.Time())) && - time.UnixMilli(v.ID.Time()). - Add(sharedmodels.UnbanRequestCooldown). - After(time.Now()) - }) - if i != -1 { - continue - } - applicableReps = append(applicableReps, banRep) - } - - return applicableReps, nil -} +package controllers + +import ( + "fmt" + "time" + + "github.com/bwmarrin/discordgo" + "github.com/gofiber/fiber/v2" + "github.com/sarulabs/di/v2" + sharedmodels "github.com/zekroTJA/shinpuru/internal/models" + "github.com/zekroTJA/shinpuru/internal/services/config" + "github.com/zekroTJA/shinpuru/internal/services/database" + "github.com/zekroTJA/shinpuru/internal/services/guildlog" + "github.com/zekroTJA/shinpuru/internal/services/permissions" + "github.com/zekroTJA/shinpuru/internal/services/webserver/v1/models" + "github.com/zekroTJA/shinpuru/internal/util/modnot" + "github.com/zekroTJA/shinpuru/internal/util/snowflakenodes" + "github.com/zekroTJA/shinpuru/internal/util/static" + "github.com/zekroTJA/shinpuru/pkg/discordutil" + "github.com/zekrotja/dgrs" + "github.com/zekrotja/rogu/log" + "github.com/zekrotja/sop" +) + +type UnbanrequestsController struct { + db Database + session Session + st State + + pmw *permissions.Permissions + cfg config.Provider + gl guildlog.Logger +} + +func (c *UnbanrequestsController) Setup(container di.Container, router fiber.Router) { + c.session = container.Get(static.DiDiscordSession).(*discordgo.Session) + c.db = container.Get(static.DiDatabase).(database.Database) + c.pmw = container.Get(static.DiPermissions).(*permissions.Permissions) + c.st = container.Get(static.DiState).(*dgrs.State) + c.cfg = container.Get(static.DiConfig).(config.Provider) + c.gl = container.Get(static.DiGuildLog).(guildlog.Logger) + + router.Get("", c.getUnbanrequests) + router.Post("", c.postUnbanrequests) + router.Get("/bannedguilds", c.getBannedGuilds) +} + +// @Summary Get Unban Requests +// @Description Returns a list of unban requests created by the authenticated user. +// @Tags Unban Requests +// @Accept json +// @Produce json +// @Success 200 {array} models.RichUnbanRequest "Wrapped in models.ListResponse" +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /unbanrequests [get] +func (c *UnbanrequestsController) getUnbanrequests(ctx *fiber.Ctx) error { + uid := ctx.Locals("uid").(string) + + requests, err := c.db.GetGuildUserUnbanRequests(uid, "") + if err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + if requests == nil { + requests = make([]sharedmodels.UnbanRequest, 0) + } + + self, err := c.st.User(uid) + if err != nil { + return err + } + + res := sop.Map[sharedmodels.UnbanRequest](sop.Slice(requests), + func(r sharedmodels.UnbanRequest, i int) models.RichUnbanRequest { + r.Hydrate() + rub := models.RichUnbanRequest{ + UnbanRequest: r, + Creator: models.FlatUserFromUser(self), + } + if proc, _ := c.st.User(rub.ProcessedBy); proc != nil { + rub.Processor = models.FlatUserFromUser(proc) + } + return rub + }) + + return ctx.JSON(models.NewListResponse(res.Unwrap())) +} + +// @Summary Create Unban Requests +// @Description Create an unban reuqest. +// @Tags Unban Requests +// @Accept json +// @Produce json +// @Param payload body sharedmodels.UnbanRequest true "The unban request payload." +// @Success 200 {object} models.RichUnbanRequest +// @Failure 400 {object} models.Error +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /unbanrequests [post] +func (c *UnbanrequestsController) postUnbanrequests(ctx *fiber.Ctx) error { + uid := ctx.Locals("uid").(string) + + user, err := c.session.User(uid) + if err != nil { + return err + } + + req := new(sharedmodels.UnbanRequest) + if err := ctx.BodyParser(req); err != nil { + return err + } + if err := req.Validate(); err != nil { + return err + } + + applicableReps, err := c.getUserApplicableReports(uid) + if err != nil { + return err + } + + rep, i := sop.Slice(applicableReps).First(func(v sharedmodels.Report, i int) bool { + return v.GuildID == req.GuildID + }) + if i == -1 { + return fiber.NewError(fiber.StatusBadRequest, "you are not able to create an unban request for this guild") + } + + requests, err := c.db.GetGuildUserUnbanRequests(uid, req.GuildID) + if err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + + for _, r := range requests { + if r.Status == sharedmodels.UnbanRequestStatePending { + return fiber.NewError(fiber.StatusBadRequest, "there is still one open unban request to be proceed") + } + } + + finalReq := sharedmodels.UnbanRequest{ + ID: snowflakenodes.NodeUnbanRequests.Generate(), + UserID: uid, + GuildID: req.GuildID, + UserTag: user.String(), + Message: req.Message, + Status: sharedmodels.UnbanRequestStatePending, + ReportID: rep.ID, + } + + if err := c.db.AddUnbanRequest(finalReq); err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + + finalReq.Hydrate() + + err = modnot.Send(c.db, c.session, req.GuildID, &discordgo.MessageEmbed{ + Color: static.ColorEmbedViolett, + Title: "New unban request", + URL: fmt.Sprintf("%s/db/guilds/%s/modlog", + c.cfg.Config().WebServer.PublicAddr, finalReq.GuildID), + Fields: []*discordgo.MessageEmbedField{ + { + Name: "User", + Value: fmt.Sprintf("%s (`%s`)", user.String(), user.ID), + }, + { + Name: "Message", + Value: finalReq.Message, + }, + }, + Footer: &discordgo.MessageEmbedFooter{ + Text: fmt.Sprintf("ID: %s", finalReq.ID), + }, + Timestamp: finalReq.Created.Format(time.RFC3339), + }) + if err != nil { + log.Error().Err(err).Tag("WebServer").Msg("Failed sending mod notification") + c.gl.Section("modnot").Errorf(finalReq.GuildID, "Failed sending mod notification: %s", err.Error()) + } + + return ctx.JSON(models.RichUnbanRequest{ + UnbanRequest: finalReq, + Creator: models.FlatUserFromUser(user), + }) +} + +// @Summary Get Banned Guilds +// @Description Returns a list of guilds where the currently authenticated user is banned. +// @Tags Unban Requests +// @Accept json +// @Produce json +// @Success 200 {array} models.GuildReduced "Wrapped in models.ListResponse" +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /unbanrequests/bannedguilds [get] +func (c *UnbanrequestsController) getBannedGuilds(ctx *fiber.Ctx) error { + uid := ctx.Locals("uid").(string) + + applicableReps, err := c.getUserApplicableReports(uid) + if err != nil { + return err + } + + guilds := make(map[string]*models.GuildReduced) + for _, rep := range applicableReps { + if _, ok := guilds[rep.GuildID]; ok { + continue + } + guild, err := c.st.Guild(rep.GuildID) + if discordutil.IsErrCode(err, discordgo.ErrCodeUnknownGuild) { + continue + } + if err != nil { + return err + } + guilds[rep.GuildID] = models.GuildReducedFromGuild(guild) + } + + guildsArr := make([]*models.GuildReduced, len(guilds)) + i := 0 + for _, g := range guilds { + guildsArr[i] = g + i++ + } + + return ctx.JSON(models.NewListResponse(guildsArr)) +} + +// --- HELPERS ------------ + +func (c *UnbanrequestsController) getUserApplicableReports(userID string) ([]sharedmodels.Report, error) { + // Get all ban reports + banReps, err := c.db.GetReportsFiltered( + "", userID, sharedmodels.TypeBan, 0, 100000) + if err != nil { + if database.IsErrDatabaseNotFound(err) { + return nil, nil + } + return nil, err + } + + // Get all unban accepted reports + unbanAcceptedReps, err := c.db.GetReportsFiltered( + "", userID, sharedmodels.TypeUnban, 0, 100000) + if err != nil { + if database.IsErrDatabaseNotFound(err) { + return nil, nil + } + return nil, err + } + + // Get all unban rejected reports + unbanRejectedReps, err := c.db.GetReportsFiltered( + "", userID, sharedmodels.TypeUnbanRejected, 0, 100000) + if err != nil { + if database.IsErrDatabaseNotFound(err) { + return nil, nil + } + return nil, err + } + + applicableReps := make([]sharedmodels.Report, 0, len(banReps)) + for _, banRep := range banReps { + _, i := sop.Slice(unbanAcceptedReps).First(func(v sharedmodels.Report, i int) bool { + return v.GuildID == banRep.GuildID && + time.UnixMilli(banRep.ID.Time()).Before(time.UnixMilli(v.ID.Time())) + }) + if i != -1 { + continue + } + _, i = sop.Slice(unbanRejectedReps).First(func(v sharedmodels.Report, i int) bool { + return v.GuildID == banRep.GuildID && + time.UnixMilli(banRep.ID.Time()).Before(time.UnixMilli(v.ID.Time())) && + time.UnixMilli(v.ID.Time()). + Add(sharedmodels.UnbanRequestCooldown). + After(time.Now()) + }) + if i != -1 { + continue + } + applicableReps = append(applicableReps, banRep) + } + + return applicableReps, nil +} diff --git a/internal/services/webserver/v1/controllers/users.go b/internal/services/webserver/v1/controllers/users.go index 3a21e192e..baa9df295 100644 --- a/internal/services/webserver/v1/controllers/users.go +++ b/internal/services/webserver/v1/controllers/users.go @@ -1,56 +1,57 @@ -package controllers - -import ( - "github.com/bwmarrin/discordgo" - "github.com/gofiber/fiber/v2" - "github.com/sarulabs/di/v2" - "github.com/zekroTJA/shinpuru/internal/services/config" - "github.com/zekroTJA/shinpuru/internal/services/webserver/auth" - "github.com/zekroTJA/shinpuru/internal/services/webserver/v1/models" - "github.com/zekroTJA/shinpuru/internal/util/static" - "github.com/zekroTJA/shinpuru/pkg/discordutil" - "github.com/zekrotja/dgrs" -) - -type UsersController struct { - session *discordgo.Session - cfg config.Provider - authMw auth.Middleware - st *dgrs.State -} - -func (c *UsersController) Setup(container di.Container, router fiber.Router) { - c.session = container.Get(static.DiDiscordSession).(*discordgo.Session) - c.cfg = container.Get(static.DiConfig).(config.Provider) - c.authMw = container.Get(static.DiAuthMiddleware).(auth.Middleware) - c.st = container.Get(static.DiState).(*dgrs.State) - - router.Get(":id", c.getUser) -} - -// @Summary User -// @Description Returns the information of a user by ID. -// @Tags Users -// @Accept json -// @Produce json -// @Success 200 {object} models.User -// @Router /users/{id} [get] -func (c *UsersController) getUser(ctx *fiber.Ctx) error { - uid := ctx.Params("id") - - user, err := c.st.User(uid) - if err != nil { - return err - } - - created, _ := discordutil.GetDiscordSnowflakeCreationTime(user.ID) - - res := &models.User{ - User: user, - AvatarURL: user.AvatarURL(""), - CreatedAt: created, - BotOwner: uid == c.cfg.Config().Discord.OwnerID, - } - - return ctx.JSON(res) -} +package controllers + +import ( + "github.com/bwmarrin/discordgo" + "github.com/gofiber/fiber/v2" + "github.com/sarulabs/di/v2" + "github.com/zekroTJA/shinpuru/internal/services/config" + "github.com/zekroTJA/shinpuru/internal/services/webserver/auth" + "github.com/zekroTJA/shinpuru/internal/services/webserver/v1/models" + "github.com/zekroTJA/shinpuru/internal/util/static" + "github.com/zekroTJA/shinpuru/pkg/discordutil" + "github.com/zekrotja/dgrs" +) + +type UsersController struct { + session Session + st State + + cfg config.Provider + authMw auth.Middleware +} + +func (c *UsersController) Setup(container di.Container, router fiber.Router) { + c.session = container.Get(static.DiDiscordSession).(*discordgo.Session) + c.cfg = container.Get(static.DiConfig).(config.Provider) + c.authMw = container.Get(static.DiAuthMiddleware).(auth.Middleware) + c.st = container.Get(static.DiState).(*dgrs.State) + + router.Get(":id", c.getUser) +} + +// @Summary User +// @Description Returns the information of a user by ID. +// @Tags Users +// @Accept json +// @Produce json +// @Success 200 {object} models.User +// @Router /users/{id} [get] +func (c *UsersController) getUser(ctx *fiber.Ctx) error { + uid := ctx.Params("id") + + user, err := c.st.User(uid) + if err != nil { + return err + } + + created, _ := discordutil.GetDiscordSnowflakeCreationTime(user.ID) + + res := &models.User{ + User: user, + AvatarURL: user.AvatarURL(""), + CreatedAt: created, + BotOwner: uid == c.cfg.Config().Discord.OwnerID, + } + + return ctx.JSON(res) +} diff --git a/internal/services/webserver/v1/controllers/usersettings.go b/internal/services/webserver/v1/controllers/usersettings.go index 5421f2c05..c253dd8fa 100644 --- a/internal/services/webserver/v1/controllers/usersettings.go +++ b/internal/services/webserver/v1/controllers/usersettings.go @@ -1,154 +1,155 @@ -package controllers - -import ( - "github.com/bwmarrin/discordgo" - "github.com/gofiber/fiber/v2" - "github.com/sarulabs/di/v2" - "github.com/zekroTJA/shinpuru/internal/services/database" - "github.com/zekroTJA/shinpuru/internal/services/permissions" - "github.com/zekroTJA/shinpuru/internal/services/webserver/v1/models" - "github.com/zekroTJA/shinpuru/internal/util" - "github.com/zekroTJA/shinpuru/internal/util/static" - "github.com/zekrotja/dgrs" -) - -type UsersettingsController struct { - session *discordgo.Session - db database.Database - state *dgrs.State - pmw *permissions.Permissions -} - -func (c *UsersettingsController) Setup(container di.Container, router fiber.Router) { - c.session = container.Get(static.DiDiscordSession).(*discordgo.Session) - c.db = container.Get(static.DiDatabase).(database.Database) - c.state = container.Get(static.DiState).(*dgrs.State) - c.pmw = container.Get(static.DiPermissions).(*permissions.Permissions) - - router.Get("/ota", c.getOTA) - router.Post("/ota", c.postOTA) - router.Get("/privacy", c.getPrivacy) - router.Post("/privacy", c.postPrivacy) - router.Post("/flush", c.postFlush) -} - -// @Summary Get OTA Usersettings State -// @Description Returns the current state of the OTA user setting. -// @Tags User Settings -// @Accept json -// @Produce json -// @Success 200 {object} models.UsersettingsOTA -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /usersettings/ota [get] -func (c *UsersettingsController) getOTA(ctx *fiber.Ctx) error { - uid := ctx.Locals("uid").(string) - - enabled, err := c.db.GetUserOTAEnabled(uid) - if err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - - return ctx.JSON(&models.UsersettingsOTA{Enabled: enabled}) -} - -// @Summary Update OTA Usersettings State -// @Description Update the OTA user settings state. -// @Tags User Settings -// @Accept json -// @Produce json -// @Param payload body models.UsersettingsOTA true "The OTA settings payload." -// @Success 200 {object} models.UsersettingsOTA -// @Failure 400 {object} models.Error -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /usersettings/ota [post] -func (c *UsersettingsController) postOTA(ctx *fiber.Ctx) error { - uid := ctx.Locals("uid").(string) - - var err error - - data := new(models.UsersettingsOTA) - if err = ctx.BodyParser(data); err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - - if err = c.db.SetUserOTAEnabled(uid, data.Enabled); err != nil { - return err - } - - return ctx.JSON(data) -} - -// @Summary Get Privacy Usersettings -// @Description Returns the current Privacy user settinga. -// @Tags User Settings -// @Accept json -// @Produce json -// @Success 200 {object} models.UsersettingsPrivacy -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /usersettings/privacy [get] -func (c *UsersettingsController) getPrivacy(ctx *fiber.Ctx) error { - uid := ctx.Locals("uid").(string) - - var ( - res models.UsersettingsPrivacy - err error - ) - - res.StarboardOptout, err = c.db.GetUserStarboardOptout(uid) - if err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - - return ctx.JSON(res) -} - -// @Summary Update Privacy Usersettings -// @Description Update the Privacy user settings. -// @Tags User Settings -// @Accept json -// @Produce json -// @Param payload body models.UsersettingsPrivacy true "The privacy settings payload." -// @Success 200 {object} models.UsersettingsPrivacy -// @Failure 400 {object} models.Error -// @Failure 401 {object} models.Error -// @Failure 404 {object} models.Error -// @Router /usersettings/privacy [post] -func (c *UsersettingsController) postPrivacy(ctx *fiber.Ctx) error { - uid := ctx.Locals("uid").(string) - - var err error - - var res models.UsersettingsPrivacy - if err = ctx.BodyParser(&res); err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - - if err = c.db.SetUserStarboardOptout(uid, res.StarboardOptout); err != nil { - return err - } - - return ctx.JSON(res) -} - -// @Summary FLush all user data -// @Description Flush all user data. -// @Tags User Settings -// @Accept json -// @Produce json -// @Success 200 {object} models.UsersettingsOTA -// @Failure 400 {object} models.Error -// @Failure 401 {object} models.Error -// @Router /usersettings/flush [post] -func (c *UsersettingsController) postFlush(ctx *fiber.Ctx) error { - uid := ctx.Locals("uid").(string) - - res, err := util.FlushAllUserData(c.db, c.state, uid) - if err != nil { - return err - } - - return ctx.JSON(res) -} +package controllers + +import ( + "github.com/bwmarrin/discordgo" + "github.com/gofiber/fiber/v2" + "github.com/sarulabs/di/v2" + "github.com/zekroTJA/shinpuru/internal/services/database" + "github.com/zekroTJA/shinpuru/internal/services/permissions" + "github.com/zekroTJA/shinpuru/internal/services/webserver/v1/models" + "github.com/zekroTJA/shinpuru/internal/util/privacy" + "github.com/zekroTJA/shinpuru/internal/util/static" + "github.com/zekrotja/dgrs" +) + +type UsersettingsController struct { + db Database + state State + session Session + + pmw *permissions.Permissions +} + +func (c *UsersettingsController) Setup(container di.Container, router fiber.Router) { + c.session = container.Get(static.DiDiscordSession).(*discordgo.Session) + c.db = container.Get(static.DiDatabase).(database.Database) + c.state = container.Get(static.DiState).(*dgrs.State) + c.pmw = container.Get(static.DiPermissions).(*permissions.Permissions) + + router.Get("/ota", c.getOTA) + router.Post("/ota", c.postOTA) + router.Get("/privacy", c.getPrivacy) + router.Post("/privacy", c.postPrivacy) + router.Post("/flush", c.postFlush) +} + +// @Summary Get OTA Usersettings State +// @Description Returns the current state of the OTA user setting. +// @Tags User Settings +// @Accept json +// @Produce json +// @Success 200 {object} models.UsersettingsOTA +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /usersettings/ota [get] +func (c *UsersettingsController) getOTA(ctx *fiber.Ctx) error { + uid := ctx.Locals("uid").(string) + + enabled, err := c.db.GetUserOTAEnabled(uid) + if err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + + return ctx.JSON(&models.UsersettingsOTA{Enabled: enabled}) +} + +// @Summary Update OTA Usersettings State +// @Description Update the OTA user settings state. +// @Tags User Settings +// @Accept json +// @Produce json +// @Param payload body models.UsersettingsOTA true "The OTA settings payload." +// @Success 200 {object} models.UsersettingsOTA +// @Failure 400 {object} models.Error +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /usersettings/ota [post] +func (c *UsersettingsController) postOTA(ctx *fiber.Ctx) error { + uid := ctx.Locals("uid").(string) + + var err error + + data := new(models.UsersettingsOTA) + if err = ctx.BodyParser(data); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + if err = c.db.SetUserOTAEnabled(uid, data.Enabled); err != nil { + return err + } + + return ctx.JSON(data) +} + +// @Summary Get Privacy Usersettings +// @Description Returns the current Privacy user settinga. +// @Tags User Settings +// @Accept json +// @Produce json +// @Success 200 {object} models.UsersettingsPrivacy +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /usersettings/privacy [get] +func (c *UsersettingsController) getPrivacy(ctx *fiber.Ctx) error { + uid := ctx.Locals("uid").(string) + + var ( + res models.UsersettingsPrivacy + err error + ) + + res.StarboardOptout, err = c.db.GetUserStarboardOptout(uid) + if err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + + return ctx.JSON(res) +} + +// @Summary Update Privacy Usersettings +// @Description Update the Privacy user settings. +// @Tags User Settings +// @Accept json +// @Produce json +// @Param payload body models.UsersettingsPrivacy true "The privacy settings payload." +// @Success 200 {object} models.UsersettingsPrivacy +// @Failure 400 {object} models.Error +// @Failure 401 {object} models.Error +// @Failure 404 {object} models.Error +// @Router /usersettings/privacy [post] +func (c *UsersettingsController) postPrivacy(ctx *fiber.Ctx) error { + uid := ctx.Locals("uid").(string) + + var err error + + var res models.UsersettingsPrivacy + if err = ctx.BodyParser(&res); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + if err = c.db.SetUserStarboardOptout(uid, res.StarboardOptout); err != nil { + return err + } + + return ctx.JSON(res) +} + +// @Summary FLush all user data +// @Description Flush all user data. +// @Tags User Settings +// @Accept json +// @Produce json +// @Success 200 {object} models.UsersettingsOTA +// @Failure 400 {object} models.Error +// @Failure 401 {object} models.Error +// @Router /usersettings/flush [post] +func (c *UsersettingsController) postFlush(ctx *fiber.Ctx) error { + uid := ctx.Locals("uid").(string) + + res, err := privacy.FlushAllUserData(c.db, c.state, uid) + if err != nil { + return err + } + + return ctx.JSON(res) +} diff --git a/internal/services/webserver/v1/controllers/util.go b/internal/services/webserver/v1/controllers/util.go index c0fc170b3..a93ec2aa4 100644 --- a/internal/services/webserver/v1/controllers/util.go +++ b/internal/services/webserver/v1/controllers/util.go @@ -1,162 +1,163 @@ -package controllers - -import ( - "strconv" - "strings" - - "github.com/bwmarrin/discordgo" - "github.com/gofiber/fiber/v2" - "github.com/sarulabs/di/v2" - "github.com/zekroTJA/shinpuru/internal/services/config" - "github.com/zekroTJA/shinpuru/internal/services/webserver/v1/models" - "github.com/zekroTJA/shinpuru/internal/util" - "github.com/zekroTJA/shinpuru/internal/util/static" - "github.com/zekroTJA/shinpuru/pkg/colors" - "github.com/zekroTJA/shinpuru/pkg/etag" - "github.com/zekrotja/dgrs" - "github.com/zekrotja/ken" -) - -type UtilController struct { - session *discordgo.Session - cfg config.Provider - cmdHandler *ken.Ken - st *dgrs.State -} - -func (c *UtilController) Setup(container di.Container, router fiber.Router) { - c.session = container.Get(static.DiDiscordSession).(*discordgo.Session) - c.cfg = container.Get(static.DiConfig).(config.Provider) - c.cmdHandler = container.Get(static.DiCommandHandler).(*ken.Ken) - c.st = container.Get(static.DiState).(*dgrs.State) - - router.Get("/landingpageinfo", c.getLandingPageInfo) - router.Get("/color/:hexcode", c.getColor) - router.Get("/commands", c.getSlashCommands) - router.Get("/slashcommands", c.getSlashCommands) - router.Get("/updateinfo", c.getUpdateInfo) -} - -// @Summary Landing Page Info -// @Description Returns general information for the landing page like the local invite parameters. -// @Tags Utilities -// @Accept json -// @Produce json -// @Success 200 {object} models.LandingPageResponse -// @Router /util/landingpageinfo [get] -func (c *UtilController) getLandingPageInfo(ctx *fiber.Ctx) error { - res := new(models.LandingPageResponse) - - publicInvites := c.cfg.Config().WebServer.LandingPage.ShowPublicInvites - localInvite := c.cfg.Config().WebServer.LandingPage.ShowLocalInvite - - if publicInvites { - res.PublicCanaryInvite = static.PublicCanaryInvite - res.PublicMainInvite = static.PublicMainInvite - } - - if localInvite { - self, err := c.st.SelfUser() - if err != nil { - return err - } - res.LocalInvite = util.GetInviteLink(self.ID) - } - - return ctx.JSON(res) -} - -// @Summary Color Generator -// @Description Produces a square image of the given color and size. -// @Param hexcode path string true "Hex Code of the Color to produce" -// @Param size query int false "The dimension of the square image" default(24) -// @Tags Utilities -// @Accept json -// @Produce image/png -// @Success 200 {file} png image data -// @Router /util/color/{hexcode} [get] -func (c *UtilController) getColor(ctx *fiber.Ctx) error { - hexcode := ctx.Params("hexcode") - size := strings.ToLower(ctx.Query("size")) - - var xSize, ySize int - var err error - - if size == "" { - xSize, ySize = 24, 24 - } else if strings.Contains(size, "x") { - split := strings.Split(size, "x") - if len(split) != 2 { - return fiber.NewError(fiber.StatusBadRequest, "invalid size parameter; must provide two size dimensions") - } - if xSize, err = strconv.Atoi(split[0]); err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - if ySize, err = strconv.Atoi(split[1]); err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - } else { - if xSize, err = strconv.Atoi(size); err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - ySize = xSize - } - - if xSize < 1 || ySize < 1 || xSize > 5000 || ySize > 5000 { - return fiber.NewError(fiber.StatusBadRequest, "invalid size parameter; value must be in range [1..5000]") - } - - clr, err := colors.FromHex(hexcode) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - - buff, err := colors.CreateImage(clr, xSize, ySize) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - - data := buff.Bytes() - - etag := etag.Generate(data, false) - - ctx.Context().SetContentType("image/png") - // 365 days browser caching - ctx.Set("Cache-Control", "public, max-age=31536000, immutable") - ctx.Set("ETag", etag) - return ctx.Send(data) -} - -// @Summary Slash Command List -// @Description Returns a list of registered slash commands and their description. -// @Tags Utilities -// @Accept json -// @Produce json -// @Success 200 {array} models.SlashCommandInfo "Wrapped in models.ListResponse" -// @Router /util/slashcommands [get] -func (c *UtilController) getSlashCommands(ctx *fiber.Ctx) error { - cmdInfo := c.cmdHandler.GetCommandInfo() - res := make([]*models.SlashCommandInfo, len(cmdInfo)) - - for i, ci := range cmdInfo { - res[i] = models.GetSlashCommandInfoFromCommand(ci) - } - - return ctx.JSON(models.NewListResponse(res)) -} - -// @Summary Update Information -// @Description Returns update information. -// @Tags Utilities -// @Accept json -// @Produce json -// @Success 200 {object} models.UpdateInfoResponse "Update info response" -// @Router /util/updateinfo [get] -func (c *UtilController) getUpdateInfo(ctx *fiber.Ctx) error { - var res models.UpdateInfoResponse - res.IsOld, res.Current, res.Latest = util.CheckForUpdate() - res.CurrentStr = res.Current.String() - res.LatestStr = res.Latest.String() - - return ctx.JSON(res) -} +package controllers + +import ( + "strconv" + "strings" + + "github.com/bwmarrin/discordgo" + "github.com/gofiber/fiber/v2" + "github.com/sarulabs/di/v2" + "github.com/zekroTJA/shinpuru/internal/services/config" + "github.com/zekroTJA/shinpuru/internal/services/webserver/v1/models" + "github.com/zekroTJA/shinpuru/internal/util" + "github.com/zekroTJA/shinpuru/internal/util/static" + "github.com/zekroTJA/shinpuru/pkg/colors" + "github.com/zekroTJA/shinpuru/pkg/etag" + "github.com/zekrotja/dgrs" + "github.com/zekrotja/ken" +) + +type UtilController struct { + session Session + st State + + cfg config.Provider + cmdHandler *ken.Ken +} + +func (c *UtilController) Setup(container di.Container, router fiber.Router) { + c.session = container.Get(static.DiDiscordSession).(*discordgo.Session) + c.cfg = container.Get(static.DiConfig).(config.Provider) + c.cmdHandler = container.Get(static.DiCommandHandler).(*ken.Ken) + c.st = container.Get(static.DiState).(*dgrs.State) + + router.Get("/landingpageinfo", c.getLandingPageInfo) + router.Get("/color/:hexcode", c.getColor) + router.Get("/commands", c.getSlashCommands) + router.Get("/slashcommands", c.getSlashCommands) + router.Get("/updateinfo", c.getUpdateInfo) +} + +// @Summary Landing Page Info +// @Description Returns general information for the landing page like the local invite parameters. +// @Tags Utilities +// @Accept json +// @Produce json +// @Success 200 {object} models.LandingPageResponse +// @Router /util/landingpageinfo [get] +func (c *UtilController) getLandingPageInfo(ctx *fiber.Ctx) error { + res := new(models.LandingPageResponse) + + publicInvites := c.cfg.Config().WebServer.LandingPage.ShowPublicInvites + localInvite := c.cfg.Config().WebServer.LandingPage.ShowLocalInvite + + if publicInvites { + res.PublicCanaryInvite = static.PublicCanaryInvite + res.PublicMainInvite = static.PublicMainInvite + } + + if localInvite { + self, err := c.st.SelfUser() + if err != nil { + return err + } + res.LocalInvite = util.GetInviteLink(self.ID) + } + + return ctx.JSON(res) +} + +// @Summary Color Generator +// @Description Produces a square image of the given color and size. +// @Param hexcode path string true "Hex Code of the Color to produce" +// @Param size query int false "The dimension of the square image" default(24) +// @Tags Utilities +// @Accept json +// @Produce image/png +// @Success 200 {file} png image data +// @Router /util/color/{hexcode} [get] +func (c *UtilController) getColor(ctx *fiber.Ctx) error { + hexcode := ctx.Params("hexcode") + size := strings.ToLower(ctx.Query("size")) + + var xSize, ySize int + var err error + + if size == "" { + xSize, ySize = 24, 24 + } else if strings.Contains(size, "x") { + split := strings.Split(size, "x") + if len(split) != 2 { + return fiber.NewError(fiber.StatusBadRequest, "invalid size parameter; must provide two size dimensions") + } + if xSize, err = strconv.Atoi(split[0]); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + if ySize, err = strconv.Atoi(split[1]); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + } else { + if xSize, err = strconv.Atoi(size); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + ySize = xSize + } + + if xSize < 1 || ySize < 1 || xSize > 5000 || ySize > 5000 { + return fiber.NewError(fiber.StatusBadRequest, "invalid size parameter; value must be in range [1..5000]") + } + + clr, err := colors.FromHex(hexcode) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + buff, err := colors.CreateImage(clr, xSize, ySize) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + data := buff.Bytes() + + etag := etag.Generate(data, false) + + ctx.Context().SetContentType("image/png") + // 365 days browser caching + ctx.Set("Cache-Control", "public, max-age=31536000, immutable") + ctx.Set("ETag", etag) + return ctx.Send(data) +} + +// @Summary Slash Command List +// @Description Returns a list of registered slash commands and their description. +// @Tags Utilities +// @Accept json +// @Produce json +// @Success 200 {array} models.SlashCommandInfo "Wrapped in models.ListResponse" +// @Router /util/slashcommands [get] +func (c *UtilController) getSlashCommands(ctx *fiber.Ctx) error { + cmdInfo := c.cmdHandler.GetCommandInfo() + res := make([]*models.SlashCommandInfo, len(cmdInfo)) + + for i, ci := range cmdInfo { + res[i] = models.GetSlashCommandInfoFromCommand(ci) + } + + return ctx.JSON(models.NewListResponse(res)) +} + +// @Summary Update Information +// @Description Returns update information. +// @Tags Utilities +// @Accept json +// @Produce json +// @Success 200 {object} models.UpdateInfoResponse "Update info response" +// @Router /util/updateinfo [get] +func (c *UtilController) getUpdateInfo(ctx *fiber.Ctx) error { + var res models.UpdateInfoResponse + res.IsOld, res.Current, res.Latest = util.CheckForUpdate() + res.CurrentStr = res.Current.String() + res.LatestStr = res.Latest.String() + + return ctx.JSON(res) +} diff --git a/internal/services/webserver/v1/models/interfaces.go b/internal/services/webserver/v1/models/interfaces.go new file mode 100644 index 000000000..82ad4821c --- /dev/null +++ b/internal/services/webserver/v1/models/interfaces.go @@ -0,0 +1,11 @@ +package models + +import "github.com/zekroTJA/shinpuru/internal/services/backup/backupmodels" + +type Database interface { + GetKarma(userID, guildID string) (int, error) + GetKarmaSum(userID string) (int, error) + GetGuildBackup(guildID string) (bool, error) + GetBackups(guildID string) ([]backupmodels.Entry, error) + GetGuildInviteBlock(guildID string) (string, error) +} diff --git a/internal/services/webserver/v1/models/models.go b/internal/services/webserver/v1/models/models.go index e81193069..716258673 100644 --- a/internal/services/webserver/v1/models/models.go +++ b/internal/services/webserver/v1/models/models.go @@ -1,585 +1,586 @@ -package models - -import ( - "errors" - "fmt" - "strings" - "time" - - "github.com/bwmarrin/discordgo" - "github.com/golang-jwt/jwt/v4" - sharedmodels "github.com/zekroTJA/shinpuru/internal/models" - "github.com/zekroTJA/shinpuru/internal/services/backup/backupmodels" - "github.com/zekroTJA/shinpuru/internal/services/database" - permService "github.com/zekroTJA/shinpuru/internal/services/permissions" - "github.com/zekroTJA/shinpuru/internal/util/imgstore" - "github.com/zekroTJA/shinpuru/pkg/discordutil" - "github.com/zekroTJA/shinpuru/pkg/permissions" - "github.com/zekroTJA/shinpuru/pkg/versioncheck" - "github.com/zekrotja/ken" - "github.com/zekrotja/rogu/log" -) - -var Ok = &Status{200} - -type Status struct { - Code int `json:"code"` -} - -type State struct { - State bool `json:"state"` -} - -type AccessTokenResponse struct { - Token string `json:"token"` - Expires time.Time `json:"expires"` -} - -type Error struct { - Error string `json:"error"` - Code int `json:"code"` - Context string `json:"context,omitempty"` -} - -// ListResponse wraps a list response object -// with the list as Data and N as len(Data). -type ListResponse[T any] struct { - N int `json:"n"` - Data []T `json:"data"` -} - -func NewListResponse[T any](data []T) ListResponse[T] { - return ListResponse[T]{len(data), data} -} - -// User extends a discordgo.User as reponse -// model. -type User struct { - *discordgo.User - - AvatarURL string `json:"avatar_url"` - CreatedAt time.Time `json:"created_at"` - BotOwner bool `json:"bot_owner"` - CaptchaVerified bool `json:"captcha_verified"` -} - -// FlatUser shrinks the user object to the only -// necessary parts for the web interface. -type FlatUser struct { - ID string `json:"id"` - Username string `json:"username"` - Discriminator string `json:"discriminator"` - AvatarURL string `json:"avatar_url"` - Bot bool `json:"bot"` -} - -// Member extends a discordgo.Member as -// response model. -type Member struct { - *discordgo.Member - - GuildName string `json:"guild_name,omitempty"` - AvatarURL string `json:"avatar_url"` - CreatedAt time.Time `json:"created_at"` - Dominance int `json:"dominance"` - Karma int `json:"karma"` - KarmaTotal int `json:"karma_total"` - ChatMuted bool `json:"chat_muted"` -} - -// Guild extends a discordgo.Guild as -// response model. -type Guild struct { - ID string `json:"id"` - Name string `json:"name"` - Icon string `json:"icon"` - Region string `json:"region"` - AfkChannelID string `json:"afk_channel_id"` - OwnerID string `json:"owner_id"` - JoinedAt time.Time `json:"joined_at"` - Splash string `json:"splash"` - MemberCount int `json:"member_count"` - VerificationLevel discordgo.VerificationLevel `json:"verification_level"` - Large bool `json:"large"` - Unavailable bool `json:"unavailable"` - MfaLevel discordgo.MfaLevel `json:"mfa_level"` - Description string `json:"description"` - Banner string `json:"banner"` - PremiumTier discordgo.PremiumTier `json:"premium_tier"` - PremiumSubscriptionCount int `json:"premium_subscription_count"` - - Roles []*discordgo.Role `json:"roles"` - Channels []*discordgo.Channel `json:"channels"` - - SelfMember *Member `json:"self_member"` - IconURL string `json:"icon_url"` - BackupsEnabled bool `json:"backups_enabled"` - LatestBackupEntry time.Time `json:"latest_backup_entry"` - InviteBlockEnabled bool `json:"invite_block_enabled"` -} - -// GuildReduced is a Guild model with fewer -// details than Guild model. -type GuildReduced struct { - ID string `json:"id"` - Name string `json:"name"` - Icon string `json:"icon"` - IconURL string `json:"icon_url"` - Region string `json:"region"` - OwnerID string `json:"owner_id"` - JoinedAt time.Time `json:"joined_at"` - MemberCount int `json:"member_count"` - OnlineMemberCount int `json:"online_member_count,omitempty"` -} - -// PermissionsResponse wraps a -// permissions.PermissionsArra as response -// model. -type PermissionsResponse struct { - Permissions permissions.PermissionArray `json:"permissions"` -} - -// Report extends models.Report by TypeName -// and Created time. -type Report struct { - sharedmodels.Report - - TypeName string `json:"type_name"` - Created time.Time `json:"created"` - Executor *FlatUser `json:"executor,omitempty"` - Victim *FlatUser `json:"victim,omitempty"` -} - -// GuildSettings is the response model for -// guild settings and preferences. -type GuildSettings struct { - Prefix string `json:"prefix"` - Perms map[string]permissions.PermissionArray `json:"perms"` - AutoRoles []string `json:"autoroles"` - ModLogChannel string `json:"modlogchannel"` - ModNotChannel string `json:"modnotchannel"` - VoiceLogChannel string `json:"voicelogchannel"` - JoinMessageChannel string `json:"joinmessagechannel"` - JoinMessageText string `json:"joinmessagetext"` - LeaveMessageChannel string `json:"leavemessagechannel"` - LeaveMessageText string `json:"leavemessagetext"` -} - -// PermissionsUpdate is the request model to -// update a permissions array. -type PermissionsUpdate struct { - Perm string `json:"perm"` - RoleIDs []string `json:"role_ids"` - Override bool `json:"override"` -} - -// ReasonRequest is a request model wrapping a -// Reason and Attachment URL. -type ReasonRequest struct { - Reason string `json:"reason"` - Timeout *time.Time `json:"timeout"` - Attachment string `json:"attachment"` - AttachmentData string `json:"attachment_data"` -} - -// ReportRequest extends ReasonRequest by -// Type of report. -type ReportRequest struct { - *ReasonRequest - - Type sharedmodels.ReportType `json:"type"` -} - -// InviteSettingsRequest is the request model -// for setting the global invite setting. -type InviteSettingsRequest struct { - GuildID string `json:"guild_id"` - Messsage string `json:"message"` - InviteCode string `json:"invite_code"` -} - -// InviteSettingsResponse is the response model -// sent back when setting the global invite setting. -type InviteSettingsResponse struct { - Guild *Guild `json:"guild"` - InviteURL string `json:"invite_url"` - Message string `json:"message"` -} - -// Count is a simple response wrapper for a -// count number. -type Count struct { - Count int `json:"count"` -} - -type LandingPageResponse struct { - LocalInvite string `json:"localinvite"` - PublicMainInvite string `json:"publicmaininvite"` - PublicCanaryInvite string `json:"publiccaranyinvite"` -} - -// SystemInfo is the response model for a -// system info request. -type SystemInfo struct { - Version string `json:"version"` - CommitHash string `json:"commit_hash"` - BuildDate time.Time `json:"build_date"` - GoVersion string `json:"go_version"` - - Uptime int64 `json:"uptime"` - UptimeStr string `json:"uptime_str"` - - OS string `json:"os"` - Arch string `json:"arch"` - CPUs int `json:"cpus"` - GoRoutines int `json:"go_routines"` - StackUse uint64 `json:"stack_use"` - StackUseStr string `json:"stack_use_str"` - HeapUse uint64 `json:"heap_use"` - HeapUseStr string `json:"heap_use_str"` - - BotUserID string `json:"bot_user_id"` - BotInvite string `json:"bot_invite"` - - Guilds int `json:"guilds"` -} - -// APITokenResponse wraps the reponse model of -// an apit token request. -type APITokenResponse struct { - Created time.Time `json:"created"` - Expires time.Time `json:"expires"` - LastAccess time.Time `json:"last_access"` - Hits int `json:"hits"` - Token string `json:"token,omitempty"` -} - -// APITokenClaims extends the standard JWT claims -// by private claims used for api tokens. -type APITokenClaims struct { - jwt.StandardClaims - - Salt string `json:"sp_salt,omitempty"` -} - -// SessionTokenClaims extends the standard JWT -// claims by information used for session tokens. -// -// Currently, no additional information is -// extended but this wrapper is used tho to -// be able to add session information later. -type SessionTokenClaims struct { - jwt.StandardClaims -} - -// GuildKarmaEntry wraps a Member model and karma -// value for an entry of the karma scoreboard -// of a guild. -type GuildKarmaEntry struct { - Member *Member `json:"member"` - Value int `json:"value"` -} - -// SlashCommandInfo wraps a slash command object -// containing all information of a slash command -// instance. -type SlashCommandInfo struct { - Name string `json:"name"` - Description string `json:"description"` - Version string `json:"version"` - Options []*discordgo.ApplicationCommandOption `json:"options"` - Domain string `json:"domain"` - SubDomains []permService.SubPermission `json:"subdomains"` - DmCapable bool `json:"dm_capable"` - Group string `json:"group"` -} - -// KarmaSettings wraps settings properties for -// guild karma settings. -type KarmaSettings struct { - State bool `json:"state"` - EmotesIncrease []string `json:"emotes_increase"` - EmotesDecrease []string `json:"emotes_decrease"` - Tokens int `json:"tokens"` - Penalty bool `json:"penalty"` -} - -// AntiraidSettings wraps settings properties for -// guild antiraid settings. -type AntiraidSettings struct { - State bool `json:"state"` - RegenerationPeriod int `json:"regeneration_period"` - Burst int `json:"burst"` - Verification bool `json:"verification"` -} - -type UsersettingsOTA struct { - Enabled bool `json:"enabled"` -} - -type UsersettingsPrivacy struct { - StarboardOptout bool `json:"starboard_optout"` -} - -// StarboardEntryResponse wraps a starboard entry -// as response model containing hydrated information -// of the author. -type StarboardEntryResponse struct { - sharedmodels.StarboardEntry - - MessageURL string `json:"message_url"` - AuthorUsername string `json:"author_username"` - AvatarURL string `json:"author_avatar_url"` -} - -type PermissionsMap map[string]permissions.PermissionArray - -type EnableStatus struct { - Enabled bool `json:"enabled"` -} - -type FlushGuildRequest struct { - Validation string `json:"validation"` - LeaveAfter bool `json:"leave_after"` -} - -type SearchResult struct { - Guilds []*GuildReduced `json:"guilds"` - Members []*Member `json:"members"` -} - -type GuildAPISettingsRequest struct { - sharedmodels.GuildAPISettings - NewToken string `json:"token"` - ResetToken bool `json:"reset_token"` -} - -type AntiraidActionType int - -const ( - AntiraidActionTypeKick = iota - AntiraidActionTypeBan -) - -type AntiraidAction struct { - Type AntiraidActionType `json:"type"` - IDs []string `json:"ids"` -} - -type ChannelWithPermissions struct { - *discordgo.Channel - - CanRead bool `json:"can_read"` - CanWrite bool `json:"can_write"` -} - -type CaptchaSiteKey struct { - SiteKey string `json:"sitekey"` -} - -type CaptchaVerificationRequest struct { - Token string `json:"token"` -} - -type CodeExecSettings struct { - EnableStatus - - Type string `json:"type"` - TypesOptions []string `json:"types_options,omitempty"` - JdoodleClientId string `json:"jdoodle_clientid,omitempty"` - JdoodleClientSecret string `json:"jdoodle_clientsecret,omitempty"` -} - -type PushCodeRequest struct { - Code string `json:"code"` -} - -type UpdateInfoResponse struct { - Current versioncheck.Semver `json:"current"` - CurrentStr string `json:"current_str"` - Latest versioncheck.Semver `json:"latest"` - LatestStr string `json:"latest_str"` - IsOld bool `json:"isold"` -} - -type RichUnbanRequest struct { - sharedmodels.UnbanRequest - - Creator *FlatUser `json:"creator"` - Processor *FlatUser `json:"processor"` -} - -// Validate returns true, when the ReasonRequest is valid. -// Otherwise, false is returned and an error response is -// returned. -func (req *ReasonRequest) Validate(acceptEmptyReason bool) (bool, error) { - if !acceptEmptyReason && len(req.Reason) < 3 { - return false, errors.New("invalid argument") - } - - if req.Attachment != "" && !imgstore.ImgUrlSRx.MatchString(req.Attachment) { - return false, fmt.Errorf("attachment must be a valid url to a file with type of png, jpg, jpeg, gif, ico, tiff, img, bmp or mp4") - } - - return true, nil -} - -// GuildFromGuild returns a Guild model from the passed -// discordgo.Guild g, discordgo.Member m and cmdHandler. -func GuildFromGuild(g *discordgo.Guild, m *discordgo.Member, db database.Database, botOwnerID string) (ng *Guild, err error) { - if g == nil { - return - } - - selfmm := MemberFromMember(m) - - if m != nil { - switch { - case discordutil.IsAdmin(g, m): - selfmm.Dominance = 1 - case g.OwnerID == m.User.ID: - selfmm.Dominance = 2 - case botOwnerID == m.User.ID: - selfmm.Dominance = 3 - } - } - - ng = &Guild{ - AfkChannelID: g.AfkChannelID, - Banner: g.Banner, - Channels: g.Channels, - Description: g.Description, - ID: g.ID, - Icon: g.Icon, - JoinedAt: g.JoinedAt, - Large: g.Large, - MemberCount: g.MemberCount, - MfaLevel: g.MfaLevel, - Name: g.Name, - OwnerID: g.OwnerID, - PremiumSubscriptionCount: g.PremiumSubscriptionCount, - PremiumTier: g.PremiumTier, - Region: g.Region, - Roles: g.Roles, - Splash: g.Splash, - Unavailable: g.Unavailable, - VerificationLevel: g.VerificationLevel, - - SelfMember: selfmm, - IconURL: g.IconURL(""), - } - - if db != nil { - selfmm.Karma, err = db.GetKarma(m.User.ID, g.ID) - if !database.IsErrDatabaseNotFound(err) && err != nil { - return - } - - selfmm.KarmaTotal, err = db.GetKarmaSum(m.User.ID) - if !database.IsErrDatabaseNotFound(err) && err != nil { - return - } - - ng.BackupsEnabled, err = db.GetGuildBackup(g.ID) - if err != nil && !database.IsErrDatabaseNotFound(err) { - return - } - - var backupEntries []backupmodels.Entry - backupEntries, err = db.GetBackups(g.ID) - if err != nil && !database.IsErrDatabaseNotFound(err) { - return - } else { - for _, e := range backupEntries { - if e.Timestamp.After(ng.LatestBackupEntry) { - ng.LatestBackupEntry = e.Timestamp - } - } - } - - status, err := db.GetGuildInviteBlock(g.ID) - if err != nil && !database.IsErrDatabaseNotFound(err) { - log.Error().Tag("WebServer").Err(err).Field("gid", g.ID).Msg("Failed getting inviteblock status") - } else { - ng.InviteBlockEnabled = status != "" - } - } - - return -} - -// GuildReducedFromGuild returns a GuildReduced from the passed -// discordgo.Guild g. -func GuildReducedFromGuild(g *discordgo.Guild) *GuildReduced { - return &GuildReduced{ - ID: g.ID, - Name: g.Name, - Icon: g.Icon, - IconURL: g.IconURL(""), - Region: g.Region, - OwnerID: g.OwnerID, - JoinedAt: g.JoinedAt, - MemberCount: g.MemberCount, - } -} - -// MemberFromMember returns a Member from the passed -// discordgo.Member m. -func MemberFromMember(m *discordgo.Member) *Member { - if m == nil { - return nil - } - - created, _ := discordutil.GetDiscordSnowflakeCreationTime(m.User.ID) - return &Member{ - Member: m, - AvatarURL: m.User.AvatarURL(""), - CreatedAt: created, - } -} - -// ReportFromReport returns a Report from the passed -// models.Report r and publicAddr to generate an -// attachment URL. -func ReportFromReport(r sharedmodels.Report, publicAddr string) Report { - rtype := sharedmodels.ReportTypes[r.Type] - r.AttachmentURL = imgstore.GetLink(r.AttachmentURL, publicAddr) - return Report{ - Report: r, - TypeName: rtype, - Created: r.GetTimestamp(), - } -} - -func GetSlashCommandInfoFromCommand(cmd *ken.CommandInfo) (ci *SlashCommandInfo) { - ci = new(SlashCommandInfo) - - ci.Name = cmd.ApplicationCommand.Name - ci.Description = cmd.ApplicationCommand.Description - ci.Options = cmd.ApplicationCommand.Options - ci.Version = cmd.ApplicationCommand.Version - ci.Domain = cmd.Implementations["Domain"][0].(string) - ci.SubDomains = cmd.Implementations["SubDomains"][0].([]permService.SubPermission) - - if v, ok := cmd.Implementations["IsDmCapable"]; ok && len(v) != 0 { - ci.DmCapable = v[0].(bool) - } - - domainSplit := strings.Split(ci.Domain, ".") - ci.Group = strings.Join(domainSplit[1:len(domainSplit)-1], " ") - ci.Group = strings.ToUpper(ci.Group) - - return -} - -// FlatUserFromUser returns the reduced FlatUser object -// from the given user object. -func FlatUserFromUser(u *discordgo.User) (fu *FlatUser) { - return &FlatUser{ - ID: u.ID, - Username: u.Username, - Discriminator: u.Discriminator, - AvatarURL: u.AvatarURL(""), - Bot: u.Bot, - } -} +package models + +import ( + "errors" + "fmt" + "strings" + "time" + + "github.com/bwmarrin/discordgo" + "github.com/golang-jwt/jwt/v4" + sharedmodels "github.com/zekroTJA/shinpuru/internal/models" + "github.com/zekroTJA/shinpuru/internal/services/backup/backupmodels" + "github.com/zekroTJA/shinpuru/internal/services/database" + permService "github.com/zekroTJA/shinpuru/internal/services/permissions" + "github.com/zekroTJA/shinpuru/internal/util/imgstore" + "github.com/zekroTJA/shinpuru/pkg/discordutil" + "github.com/zekroTJA/shinpuru/pkg/permissions" + "github.com/zekroTJA/shinpuru/pkg/versioncheck" + "github.com/zekrotja/ken" + "github.com/zekrotja/rogu/log" +) + +var Ok = &Status{200} + +type Status struct { + Code int `json:"code"` +} + +type State struct { + State bool `json:"state"` +} + +type AccessTokenResponse struct { + Token string `json:"token"` + Expires time.Time `json:"expires"` +} + +type Error struct { + Error string `json:"error"` + Code int `json:"code"` + Context string `json:"context,omitempty"` +} + +// ListResponse wraps a list response object +// with the list as Data and N as len(Data). +type ListResponse[T any] struct { + N int `json:"n"` + Data []T `json:"data"` +} + +func NewListResponse[T any](data []T) ListResponse[T] { + return ListResponse[T]{len(data), data} +} + +// User extends a discordgo.User as reponse +// model. +type User struct { + *discordgo.User + + AvatarURL string `json:"avatar_url"` + CreatedAt time.Time `json:"created_at"` + BotOwner bool `json:"bot_owner"` + CaptchaVerified bool `json:"captcha_verified"` +} + +// FlatUser shrinks the user object to the only +// necessary parts for the web interface. +type FlatUser struct { + ID string `json:"id"` + Username string `json:"username"` + Discriminator string `json:"discriminator"` + AvatarURL string `json:"avatar_url"` + Bot bool `json:"bot"` +} + +// Member extends a discordgo.Member as +// response model. +type Member struct { + *discordgo.Member + + GuildName string `json:"guild_name,omitempty"` + AvatarURL string `json:"avatar_url"` + CreatedAt time.Time `json:"created_at"` + Dominance int `json:"dominance"` + Karma int `json:"karma"` + KarmaTotal int `json:"karma_total"` + ChatMuted bool `json:"chat_muted"` +} + +// Guild extends a discordgo.Guild as +// response model. +type Guild struct { + ID string `json:"id"` + Name string `json:"name"` + Icon string `json:"icon"` + Region string `json:"region"` + AfkChannelID string `json:"afk_channel_id"` + OwnerID string `json:"owner_id"` + JoinedAt time.Time `json:"joined_at"` + Splash string `json:"splash"` + MemberCount int `json:"member_count"` + VerificationLevel discordgo.VerificationLevel `json:"verification_level"` + Large bool `json:"large"` + Unavailable bool `json:"unavailable"` + MfaLevel discordgo.MfaLevel `json:"mfa_level"` + Description string `json:"description"` + Banner string `json:"banner"` + PremiumTier discordgo.PremiumTier `json:"premium_tier"` + PremiumSubscriptionCount int `json:"premium_subscription_count"` + + Roles []*discordgo.Role `json:"roles"` + Channels []*discordgo.Channel `json:"channels"` + + SelfMember *Member `json:"self_member"` + IconURL string `json:"icon_url"` + BackupsEnabled bool `json:"backups_enabled"` + LatestBackupEntry time.Time `json:"latest_backup_entry"` + InviteBlockEnabled bool `json:"invite_block_enabled"` +} + +// GuildReduced is a Guild model with fewer +// details than Guild model. +type GuildReduced struct { + ID string `json:"id"` + Name string `json:"name"` + Icon string `json:"icon"` + IconURL string `json:"icon_url"` + Region string `json:"region"` + OwnerID string `json:"owner_id"` + JoinedAt time.Time `json:"joined_at"` + MemberCount int `json:"member_count"` + OnlineMemberCount int `json:"online_member_count,omitempty"` +} + +// PermissionsResponse wraps a +// permissions.PermissionsArra as response +// model. +type PermissionsResponse struct { + Permissions permissions.PermissionArray `json:"permissions"` +} + +// Report extends models.Report by TypeName +// and Created time. +type Report struct { + sharedmodels.Report + + TypeName string `json:"type_name"` + Created time.Time `json:"created"` + Executor *FlatUser `json:"executor,omitempty"` + Victim *FlatUser `json:"victim,omitempty"` +} + +// GuildSettings is the response model for +// guild settings and preferences. +type GuildSettings struct { + Prefix string `json:"prefix"` + Perms map[string]permissions.PermissionArray `json:"perms"` + AutoRoles []string `json:"autoroles"` + ModLogChannel string `json:"modlogchannel"` + ModNotChannel string `json:"modnotchannel"` + VoiceLogChannel string `json:"voicelogchannel"` + JoinMessageChannel string `json:"joinmessagechannel"` + JoinMessageText string `json:"joinmessagetext"` + LeaveMessageChannel string `json:"leavemessagechannel"` + LeaveMessageText string `json:"leavemessagetext"` +} + +// PermissionsUpdate is the request model to +// update a permissions array. +type PermissionsUpdate struct { + Perm string `json:"perm"` + RoleIDs []string `json:"role_ids"` + Override bool `json:"override"` +} + +// ReasonRequest is a request model wrapping a +// Reason and Attachment URL. +type ReasonRequest struct { + Reason string `json:"reason"` + Timeout *time.Time `json:"timeout"` + Attachment string `json:"attachment"` + AttachmentData string `json:"attachment_data"` +} + +// ReportRequest extends ReasonRequest by +// Type of report. +type ReportRequest struct { + *ReasonRequest + + Type sharedmodels.ReportType `json:"type"` +} + +// InviteSettingsRequest is the request model +// for setting the global invite setting. +type InviteSettingsRequest struct { + GuildID string `json:"guild_id"` + Messsage string `json:"message"` + InviteCode string `json:"invite_code"` +} + +// InviteSettingsResponse is the response model +// sent back when setting the global invite setting. +type InviteSettingsResponse struct { + Guild *Guild `json:"guild"` + InviteURL string `json:"invite_url"` + Message string `json:"message"` +} + +// Count is a simple response wrapper for a +// count number. +type Count struct { + Count int `json:"count"` +} + +type LandingPageResponse struct { + LocalInvite string `json:"localinvite"` + PublicMainInvite string `json:"publicmaininvite"` + PublicCanaryInvite string `json:"publiccaranyinvite"` +} + +// SystemInfo is the response model for a +// system info request. +type SystemInfo struct { + Version string `json:"version"` + CommitHash string `json:"commit_hash"` + BuildDate time.Time `json:"build_date"` + GoVersion string `json:"go_version"` + + Uptime int64 `json:"uptime"` + UptimeStr string `json:"uptime_str"` + + OS string `json:"os"` + Arch string `json:"arch"` + CPUs int `json:"cpus"` + GoRoutines int `json:"go_routines"` + StackUse uint64 `json:"stack_use"` + StackUseStr string `json:"stack_use_str"` + HeapUse uint64 `json:"heap_use"` + HeapUseStr string `json:"heap_use_str"` + + BotUserID string `json:"bot_user_id"` + BotInvite string `json:"bot_invite"` + + Guilds int `json:"guilds"` +} + +// APITokenResponse wraps the reponse model of +// an apit token request. +type APITokenResponse struct { + Created time.Time `json:"created"` + Expires time.Time `json:"expires"` + LastAccess time.Time `json:"last_access"` + Hits int `json:"hits"` + Token string `json:"token,omitempty"` +} + +// APITokenClaims extends the standard JWT claims +// by private claims used for api tokens. +type APITokenClaims struct { + jwt.StandardClaims + + Salt string `json:"sp_salt,omitempty"` +} + +// SessionTokenClaims extends the standard JWT +// claims by information used for session tokens. +// +// Currently, no additional information is +// extended but this wrapper is used tho to +// be able to add session information later. +type SessionTokenClaims struct { + jwt.StandardClaims +} + +// GuildKarmaEntry wraps a Member model and karma +// value for an entry of the karma scoreboard +// of a guild. +type GuildKarmaEntry struct { + Member *Member `json:"member"` + Value int `json:"value"` +} + +// SlashCommandInfo wraps a slash command object +// containing all information of a slash command +// instance. +type SlashCommandInfo struct { + Name string `json:"name"` + Description string `json:"description"` + Version string `json:"version"` + Options []*discordgo.ApplicationCommandOption `json:"options"` + Domain string `json:"domain"` + SubDomains []permService.SubPermission `json:"subdomains"` + DmCapable bool `json:"dm_capable"` + Group string `json:"group"` +} + +// KarmaSettings wraps settings properties for +// guild karma settings. +type KarmaSettings struct { + State bool `json:"state"` + EmotesIncrease []string `json:"emotes_increase"` + EmotesDecrease []string `json:"emotes_decrease"` + Tokens int `json:"tokens"` + Penalty bool `json:"penalty"` +} + +// AntiraidSettings wraps settings properties for +// guild antiraid settings. +type AntiraidSettings struct { + State bool `json:"state"` + RegenerationPeriod int `json:"regeneration_period"` + Burst int `json:"burst"` + Verification bool `json:"verification"` +} + +type UsersettingsOTA struct { + Enabled bool `json:"enabled"` +} + +type UsersettingsPrivacy struct { + StarboardOptout bool `json:"starboard_optout"` +} + +// StarboardEntryResponse wraps a starboard entry +// as response model containing hydrated information +// of the author. +type StarboardEntryResponse struct { + sharedmodels.StarboardEntry + + MessageURL string `json:"message_url"` + AuthorUsername string `json:"author_username"` + AvatarURL string `json:"author_avatar_url"` +} + +type PermissionsMap map[string]permissions.PermissionArray + +type EnableStatus struct { + Enabled bool `json:"enabled"` +} + +type FlushGuildRequest struct { + Validation string `json:"validation"` + LeaveAfter bool `json:"leave_after"` +} + +type SearchResult struct { + Guilds []*GuildReduced `json:"guilds"` + Members []*Member `json:"members"` +} + +type GuildAPISettingsRequest struct { + sharedmodels.GuildAPISettings + NewToken string `json:"token"` + ResetToken bool `json:"reset_token"` +} + +type AntiraidActionType int + +const ( + AntiraidActionTypeKick = iota + AntiraidActionTypeBan +) + +type AntiraidAction struct { + Type AntiraidActionType `json:"type"` + IDs []string `json:"ids"` +} + +type ChannelWithPermissions struct { + *discordgo.Channel + + CanRead bool `json:"can_read"` + CanWrite bool `json:"can_write"` +} + +type CaptchaSiteKey struct { + SiteKey string `json:"sitekey"` +} + +type CaptchaVerificationRequest struct { + Token string `json:"token"` +} + +type CodeExecSettings struct { + EnableStatus + + Type string `json:"type"` + TypesOptions []string `json:"types_options,omitempty"` + JdoodleClientId string `json:"jdoodle_clientid,omitempty"` + JdoodleClientSecret string `json:"jdoodle_clientsecret,omitempty"` +} + +type PushCodeRequest struct { + Code string `json:"code"` +} + +type UpdateInfoResponse struct { + Current versioncheck.Semver `json:"current"` + CurrentStr string `json:"current_str"` + Latest versioncheck.Semver `json:"latest"` + LatestStr string `json:"latest_str"` + IsOld bool `json:"isold"` +} + +type RichUnbanRequest struct { + sharedmodels.UnbanRequest + + Creator *FlatUser `json:"creator"` + Processor *FlatUser `json:"processor"` +} + +// Validate returns true, when the ReasonRequest is valid. +// Otherwise, false is returned and an error response is +// returned. +func (req *ReasonRequest) Validate(acceptEmptyReason bool) (bool, error) { + if !acceptEmptyReason && len(req.Reason) < 3 { + return false, errors.New("invalid argument") + } + + if req.Attachment != "" && !imgstore.ImgUrlSRx.MatchString(req.Attachment) { + return false, fmt.Errorf("attachment must be a valid url to a file with type of png, jpg, jpeg, gif, ico, tiff, img, bmp or mp4") + } + + return true, nil +} + +// GuildFromGuild returns a Guild model from the passed +// discordgo.Guild g, discordgo.Member m and cmdHandler. +func GuildFromGuild(g *discordgo.Guild, m *discordgo.Member, db Database, botOwnerID string) (ng *Guild, err error) { + if g == nil { + return + } + + selfmm := MemberFromMember(m) + + if m != nil { + switch { + case discordutil.IsAdmin(g, m): + selfmm.Dominance = 1 + case g.OwnerID == m.User.ID: + selfmm.Dominance = 2 + case botOwnerID == m.User.ID: + selfmm.Dominance = 3 + } + } + + ng = &Guild{ + AfkChannelID: g.AfkChannelID, + Banner: g.Banner, + Channels: g.Channels, + Description: g.Description, + ID: g.ID, + Icon: g.Icon, + JoinedAt: g.JoinedAt, + Large: g.Large, + MemberCount: g.MemberCount, + MfaLevel: g.MfaLevel, + Name: g.Name, + OwnerID: g.OwnerID, + PremiumSubscriptionCount: g.PremiumSubscriptionCount, + PremiumTier: g.PremiumTier, + Region: g.Region, + Roles: g.Roles, + Splash: g.Splash, + Unavailable: g.Unavailable, + VerificationLevel: g.VerificationLevel, + + SelfMember: selfmm, + IconURL: g.IconURL(""), + } + + if db != nil { + // TODO: Transfer the database stuff somewhere else, this has actually nothing to do here + selfmm.Karma, err = db.GetKarma(m.User.ID, g.ID) + if !database.IsErrDatabaseNotFound(err) && err != nil { + return + } + + selfmm.KarmaTotal, err = db.GetKarmaSum(m.User.ID) + if !database.IsErrDatabaseNotFound(err) && err != nil { + return + } + + ng.BackupsEnabled, err = db.GetGuildBackup(g.ID) + if err != nil && !database.IsErrDatabaseNotFound(err) { + return + } + + var backupEntries []backupmodels.Entry + backupEntries, err = db.GetBackups(g.ID) + if err != nil && !database.IsErrDatabaseNotFound(err) { + return + } else { + for _, e := range backupEntries { + if e.Timestamp.After(ng.LatestBackupEntry) { + ng.LatestBackupEntry = e.Timestamp + } + } + } + + status, err := db.GetGuildInviteBlock(g.ID) + if err != nil && !database.IsErrDatabaseNotFound(err) { + log.Error().Tag("WebServer").Err(err).Field("gid", g.ID).Msg("Failed getting inviteblock status") + } else { + ng.InviteBlockEnabled = status != "" + } + } + + return +} + +// GuildReducedFromGuild returns a GuildReduced from the passed +// discordgo.Guild g. +func GuildReducedFromGuild(g *discordgo.Guild) *GuildReduced { + return &GuildReduced{ + ID: g.ID, + Name: g.Name, + Icon: g.Icon, + IconURL: g.IconURL(""), + Region: g.Region, + OwnerID: g.OwnerID, + JoinedAt: g.JoinedAt, + MemberCount: g.MemberCount, + } +} + +// MemberFromMember returns a Member from the passed +// discordgo.Member m. +func MemberFromMember(m *discordgo.Member) *Member { + if m == nil { + return nil + } + + created, _ := discordutil.GetDiscordSnowflakeCreationTime(m.User.ID) + return &Member{ + Member: m, + AvatarURL: m.User.AvatarURL(""), + CreatedAt: created, + } +} + +// ReportFromReport returns a Report from the passed +// models.Report r and publicAddr to generate an +// attachment URL. +func ReportFromReport(r sharedmodels.Report, publicAddr string) Report { + rtype := sharedmodels.ReportTypes[r.Type] + r.AttachmentURL = imgstore.GetLink(r.AttachmentURL, publicAddr) + return Report{ + Report: r, + TypeName: rtype, + Created: r.GetTimestamp(), + } +} + +func GetSlashCommandInfoFromCommand(cmd *ken.CommandInfo) (ci *SlashCommandInfo) { + ci = new(SlashCommandInfo) + + ci.Name = cmd.ApplicationCommand.Name + ci.Description = cmd.ApplicationCommand.Description + ci.Options = cmd.ApplicationCommand.Options + ci.Version = cmd.ApplicationCommand.Version + ci.Domain = cmd.Implementations["Domain"][0].(string) + ci.SubDomains = cmd.Implementations["SubDomains"][0].([]permService.SubPermission) + + if v, ok := cmd.Implementations["IsDmCapable"]; ok && len(v) != 0 { + ci.DmCapable = v[0].(bool) + } + + domainSplit := strings.Split(ci.Domain, ".") + ci.Group = strings.Join(domainSplit[1:len(domainSplit)-1], " ") + ci.Group = strings.ToUpper(ci.Group) + + return +} + +// FlatUserFromUser returns the reduced FlatUser object +// from the given user object. +func FlatUserFromUser(u *discordgo.User) (fu *FlatUser) { + return &FlatUser{ + ID: u.ID, + Username: u.Username, + Discriminator: u.Discriminator, + AvatarURL: u.AvatarURL(""), + Bot: u.Bot, + } +} diff --git a/internal/services/webserver/v1/router.go b/internal/services/webserver/v1/router.go index ee13a0c73..5fa396ae8 100644 --- a/internal/services/webserver/v1/router.go +++ b/internal/services/webserver/v1/router.go @@ -1,114 +1,109 @@ -package v1 - -import ( - "github.com/gofiber/fiber/v2" - "github.com/sarulabs/di/v2" - "github.com/zekroTJA/shinpuru/internal/services/webserver/auth" - "github.com/zekroTJA/shinpuru/internal/services/webserver/v1/controllers" - "github.com/zekroTJA/shinpuru/internal/util/embedded" - "github.com/zekroTJA/shinpuru/internal/util/static" -) - -type Router struct { - container di.Container -} - -func (r *Router) SetContainer(container di.Container) { - r.container = container -} - -// Route registrar. -// -// @title shinpuru main API -// @version 1.0 -// @description The shinpuru main REST API. -// -// @Tag.Name Etc -// @tag.Description General root API functionalities. -// -// @Tag.Name Utilities -// @tag.Description General utility functionalities. -// -// @Tag.Name Authorization -// @tag.Description Authorization endpoints. -// -// @Tag.Name OTA -// @tag.Description One Time Auth token endpoints. -// -// @Tag.Name Public -// @tag.Description Public API endpoints. -// -// @Tag.Name Search -// @tag.Description Search endpoints. -// -// @Tag.Name Tokens -// @tag.Description API token endpoints. -// -// @Tag.Name Global Settings -// @tag.Description Global bot settings endpoints. -// -// @Tag.Name Reports -// @tag.Description General reports endpoints. -// -// @Tag.Name Guilds -// @tag.Description Guild specific endpoints. -// -// @Tag.Name Guild Settings -// @Tag.Description Guild specific settings endpoints. -// -// @Tag.Name Guild Backups -// @tag.Description Guild backup endpoints. -// -// @Tag.Name Unban Requests -// @tag.Description Unban requests endpoints. -// -// @Tag.Name User Settings -// @tag.Description User specific settings endpoints. -// -// @Tag.Name Member Reporting -// @tag.Description Member reporting endpoints. -// -// @Tag.Name Members -// @tag.Description Members specific endpoints. -// -// @Tag.Name Channels -// @tag.Description Channels specific endpoints. -// -// @Tag.Name Verification -// @tag.Description User verification endpoints. -// -// @BasePath /api/v1 -func (r *Router) Route(router fiber.Router) { - authMw := r.container.Get(static.DiAuthMiddleware).(auth.Middleware) - - if !embedded.IsRelease() { - new(controllers.DebugController).Setup(r.container, router.Group("debug")) - } - - new(controllers.EtcController).Setup(r.container, router) - new(controllers.UtilController).Setup(r.container, router.Group("/util")) - new(controllers.AuthController).Setup(r.container, router.Group("/auth")) - new(controllers.OTAController).Setup(r.container, router.Group("/ota")) - new(controllers.PublicController).Setup(r.container, router.Group("/public")) - - router.Get("/stack", func(ctx *fiber.Ctx) error { return ctx.JSON(ctx.App().Stack()) }) - - // --- REQUIRES ACCESS TOKEN AUTH --- - - router.Use(authMw.Handle) - - new(controllers.SearchController).Setup(r.container, router.Group("/search")) - new(controllers.TokenController).Setup(r.container, router.Group("/token")) - new(controllers.GlobalSettingsController).Setup(r.container, router.Group("/settings")) - new(controllers.ReportsController).Setup(r.container, router.Group("/reports")) - new(controllers.GuildsController).Setup(r.container, router.Group("/guilds")) - new(controllers.MemberReportingController).Setup(r.container, router.Group("/guilds/:guildid/:memberid")) - new(controllers.GuildBackupsController).Setup(r.container, router.Group("/guilds/:guildid/backups")) - new(controllers.GuildsSettingsController).Setup(r.container, router.Group("/guilds/:guildid/settings")) - new(controllers.GuildMembersController).Setup(r.container, router.Group("/guilds/:guildid")) - new(controllers.ChannelController).Setup(r.container, router.Group("/channels/:guildid")) - new(controllers.UsersController).Setup(r.container, router.Group("/users")) - new(controllers.UsersettingsController).Setup(r.container, router.Group("/usersettings")) - new(controllers.UnbanrequestsController).Setup(r.container, router.Group("/unbanrequests")) - new(controllers.VerificationController).Setup(r.container, router.Group("/verification")) -} +package v1 + +import ( + "github.com/gofiber/fiber/v2" + "github.com/sarulabs/di/v2" + "github.com/zekroTJA/shinpuru/internal/services/webserver/auth" + "github.com/zekroTJA/shinpuru/internal/services/webserver/v1/controllers" + "github.com/zekroTJA/shinpuru/internal/util/static" +) + +type Router struct { + container di.Container +} + +func (r *Router) SetContainer(container di.Container) { + r.container = container +} + +// Route registrar. +// +// @title shinpuru main API +// @version 1.0 +// @description The shinpuru main REST API. +// +// @Tag.Name Etc +// @tag.Description General root API functionalities. +// +// @Tag.Name Utilities +// @tag.Description General utility functionalities. +// +// @Tag.Name Authorization +// @tag.Description Authorization endpoints. +// +// @Tag.Name OTA +// @tag.Description One Time Auth token endpoints. +// +// @Tag.Name Public +// @tag.Description Public API endpoints. +// +// @Tag.Name Search +// @tag.Description Search endpoints. +// +// @Tag.Name Tokens +// @tag.Description API token endpoints. +// +// @Tag.Name Global Settings +// @tag.Description Global bot settings endpoints. +// +// @Tag.Name Reports +// @tag.Description General reports endpoints. +// +// @Tag.Name Guilds +// @tag.Description Guild specific endpoints. +// +// @Tag.Name Guild Settings +// @Tag.Description Guild specific settings endpoints. +// +// @Tag.Name Guild Backups +// @tag.Description Guild backup endpoints. +// +// @Tag.Name Unban Requests +// @tag.Description Unban requests endpoints. +// +// @Tag.Name User Settings +// @tag.Description User specific settings endpoints. +// +// @Tag.Name Member Reporting +// @tag.Description Member reporting endpoints. +// +// @Tag.Name Members +// @tag.Description Members specific endpoints. +// +// @Tag.Name Channels +// @tag.Description Channels specific endpoints. +// +// @Tag.Name Verification +// @tag.Description User verification endpoints. +// +// @BasePath /api/v1 +func (r *Router) Route(router fiber.Router) { + authMw := r.container.Get(static.DiAuthMiddleware).(auth.Middleware) + + new(controllers.EtcController).Setup(r.container, router) + new(controllers.UtilController).Setup(r.container, router.Group("/util")) + new(controllers.AuthController).Setup(r.container, router.Group("/auth")) + new(controllers.OTAController).Setup(r.container, router.Group("/ota")) + new(controllers.PublicController).Setup(r.container, router.Group("/public")) + + router.Get("/stack", func(ctx *fiber.Ctx) error { return ctx.JSON(ctx.App().Stack()) }) + + // --- REQUIRES ACCESS TOKEN AUTH --- + + router.Use(authMw.Handle) + + new(controllers.SearchController).Setup(r.container, router.Group("/search")) + new(controllers.TokenController).Setup(r.container, router.Group("/token")) + new(controllers.GlobalSettingsController).Setup(r.container, router.Group("/settings")) + new(controllers.ReportsController).Setup(r.container, router.Group("/reports")) + new(controllers.GuildsController).Setup(r.container, router.Group("/guilds")) + new(controllers.MemberReportingController).Setup(r.container, router.Group("/guilds/:guildid/:memberid")) + new(controllers.GuildBackupsController).Setup(r.container, router.Group("/guilds/:guildid/backups")) + new(controllers.GuildsSettingsController).Setup(r.container, router.Group("/guilds/:guildid/settings")) + new(controllers.GuildMembersController).Setup(r.container, router.Group("/guilds/:guildid")) + new(controllers.ChannelController).Setup(r.container, router.Group("/channels/:guildid")) + new(controllers.UsersController).Setup(r.container, router.Group("/users")) + new(controllers.UsersettingsController).Setup(r.container, router.Group("/usersettings")) + new(controllers.UnbanrequestsController).Setup(r.container, router.Group("/unbanrequests")) + new(controllers.VerificationController).Setup(r.container, router.Group("/verification")) +} diff --git a/internal/util/modnot/interfaces.go b/internal/util/modnot/interfaces.go new file mode 100644 index 000000000..988c983ac --- /dev/null +++ b/internal/util/modnot/interfaces.go @@ -0,0 +1,11 @@ +package modnot + +import "github.com/bwmarrin/discordgo" + +type Session interface { + ChannelMessageSendEmbed(channelID string, embed *discordgo.MessageEmbed, options ...discordgo.RequestOption) (*discordgo.Message, error) +} + +type Database interface { + GetGuildModNot(guildID string) (string, error) +} diff --git a/internal/util/modnot/modnot.go b/internal/util/modnot/modnot.go index 4dedb5cd9..df5f967d3 100644 --- a/internal/util/modnot/modnot.go +++ b/internal/util/modnot/modnot.go @@ -1,28 +1,27 @@ -package modnot - -import ( - "github.com/bwmarrin/discordgo" - "github.com/zekroTJA/shinpuru/internal/services/database" - "github.com/zekroTJA/shinpuru/pkg/discordutil" -) - -// Send embed messages into the mod notification channel -// specified for the given guildID. -func Send( - db database.Database, - s discordutil.ISession, - guildID string, - embed *discordgo.MessageEmbed, -) error { - chanID, err := db.GetGuildModNot(guildID) - if err != nil && !database.IsErrDatabaseNotFound(err) { - return err - } - if chanID == "" { - return nil - } - - _, err = s.ChannelMessageSendEmbed(chanID, embed) - - return err -} +package modnot + +import ( + "github.com/bwmarrin/discordgo" + "github.com/zekroTJA/shinpuru/internal/services/database" +) + +// Send embed messages into the mod notification channel +// specified for the given guildID. +func Send( + db Database, + s Session, + guildID string, + embed *discordgo.MessageEmbed, +) error { + chanID, err := db.GetGuildModNot(guildID) + if err != nil && !database.IsErrDatabaseNotFound(err) { + return err + } + if chanID == "" { + return nil + } + + _, err = s.ChannelMessageSendEmbed(chanID, embed) + + return err +} diff --git a/internal/util/privacy/interfaces.go b/internal/util/privacy/interfaces.go new file mode 100644 index 000000000..03870eb79 --- /dev/null +++ b/internal/util/privacy/interfaces.go @@ -0,0 +1,34 @@ +package privacy + +import ( + "github.com/bwmarrin/discordgo" + "github.com/zekroTJA/shinpuru/internal/models" + "github.com/zekroTJA/shinpuru/internal/services/backup/backupmodels" +) + +type Session interface { + User(userID string, options ...discordgo.RequestOption) (st *discordgo.User, err error) + ChannelMessageSendComplex(channelID string, data *discordgo.MessageSend, options ...discordgo.RequestOption) (st *discordgo.Message, err error) + MessageReactionAdd(channelID, messageID, emojiID string, options ...discordgo.RequestOption) error + ChannelMessageEditEmbed(channelID, messageID string, embed *discordgo.MessageEmbed, options ...discordgo.RequestOption) (*discordgo.Message, error) + MessageReactionsRemoveAll(channelID, messageID string, options ...discordgo.RequestOption) error +} + +type Database interface { + GetBackups(guildID string) ([]backupmodels.Entry, error) + GetReportsGuildCount(guildID string) (int, error) + GetReportsGuild(guildID string, offset, limit int) ([]models.Report, error) + FlushGuildData(guildID string) error + FlushUserData(userID string) (res map[string]int, err error) +} + +type Storage interface { + DeleteObject(bucketName, objectName string) error +} + +type State interface { + UserGuilds(id string) (res []string, err error) + RemoveGuild(id string, dehydrate ...bool) error + RemoveMember(guildID, memberID string) (err error) + RemoveUser(id string) (err error) +} diff --git a/internal/util/privacy.go b/internal/util/privacy/privacy.go similarity index 77% rename from internal/util/privacy.go rename to internal/util/privacy/privacy.go index 773d24e3d..5b4275ae9 100644 --- a/internal/util/privacy.go +++ b/internal/util/privacy/privacy.go @@ -1,84 +1,80 @@ -package util - -import ( - "github.com/bwmarrin/discordgo" - "github.com/zekroTJA/shinpuru/internal/services/database" - "github.com/zekroTJA/shinpuru/internal/services/storage" - "github.com/zekroTJA/shinpuru/internal/util/static" - "github.com/zekroTJA/shinpuru/internal/util/vote" - "github.com/zekroTJA/shinpuru/pkg/multierror" - "github.com/zekrotja/dgrs" -) - -func FlushAllGuildData( - s *discordgo.Session, - db database.Database, - st storage.Storage, - state *dgrs.State, - guildID string, -) (err error) { - backups, err := db.GetBackups(guildID) - if err != nil { - return - } - - reportsCount, err := db.GetReportsGuildCount(guildID) - if err != nil { - return - } - reports, err := db.GetReportsGuild(guildID, 0, reportsCount) - if err != nil { - return - } - - for _, v := range vote.VotesRunning { - if v.GuildID == guildID { - v.Close(s, vote.VoteStateClosedNC) - } - } - - if err = db.FlushGuildData(guildID); err != nil { - return - } - - if err = state.RemoveGuild(guildID, true); err != nil { - return - } - - mErr := multierror.New() - for _, b := range backups { - mErr.Append(st.DeleteObject(static.StorageBucketBackups, b.FileID)) - } - for _, r := range reports { - if r.AttachmentURL != "" { - mErr.Append(st.DeleteObject(static.StorageBucketImages, r.AttachmentURL)) - } - } - - return mErr.Nillify() -} - -func FlushAllUserData( - db database.Database, - state *dgrs.State, - userID string, -) (res map[string]int, err error) { - res, err = db.FlushUserData(userID) - if err != nil { - return - } - - guildIDs, err := state.UserGuilds(userID) - if err != nil { - return - } - - for _, gid := range guildIDs { - if err = state.RemoveMember(gid, userID); err != nil { - return - } - } - - err = state.RemoveUser(userID) - return -} +package privacy + +import ( + "github.com/zekroTJA/shinpuru/internal/util/static" + "github.com/zekroTJA/shinpuru/internal/util/vote" + "github.com/zekroTJA/shinpuru/pkg/multierror" +) + +func FlushAllGuildData( + s Session, + db Database, + st Storage, + state State, + guildID string, +) (err error) { + backups, err := db.GetBackups(guildID) + if err != nil { + return + } + + reportsCount, err := db.GetReportsGuildCount(guildID) + if err != nil { + return + } + reports, err := db.GetReportsGuild(guildID, 0, reportsCount) + if err != nil { + return + } + + for _, v := range vote.VotesRunning { + if v.GuildID == guildID { + v.Close(s, vote.VoteStateClosedNC) + } + } + + if err = db.FlushGuildData(guildID); err != nil { + return + } + + if err = state.RemoveGuild(guildID, true); err != nil { + return + } + + mErr := multierror.New() + for _, b := range backups { + mErr.Append(st.DeleteObject(static.StorageBucketBackups, b.FileID)) + } + for _, r := range reports { + if r.AttachmentURL != "" { + mErr.Append(st.DeleteObject(static.StorageBucketImages, r.AttachmentURL)) + } + } + + return mErr.Nillify() +} + +func FlushAllUserData( + db Database, + state State, + userID string, +) (res map[string]int, err error) { + res, err = db.FlushUserData(userID) + if err != nil { + return + } + + guildIDs, err := state.UserGuilds(userID) + if err != nil { + return + } + + for _, gid := range guildIDs { + if err = state.RemoveMember(gid, userID); err != nil { + return + } + } + + err = state.RemoveUser(userID) + return +} diff --git a/internal/util/vote/interfaces.go b/internal/util/vote/interfaces.go new file mode 100644 index 000000000..43312cc2a --- /dev/null +++ b/internal/util/vote/interfaces.go @@ -0,0 +1,11 @@ +package vote + +import "github.com/bwmarrin/discordgo" + +type Session interface { + User(userID string, options ...discordgo.RequestOption) (st *discordgo.User, err error) + ChannelMessageSendComplex(channelID string, data *discordgo.MessageSend, options ...discordgo.RequestOption) (st *discordgo.Message, err error) + MessageReactionAdd(channelID, messageID, emojiID string, options ...discordgo.RequestOption) error + ChannelMessageEditEmbed(channelID, messageID string, embed *discordgo.MessageEmbed, options ...discordgo.RequestOption) (*discordgo.Message, error) + MessageReactionsRemoveAll(channelID, messageID string, options ...discordgo.RequestOption) error +} diff --git a/internal/util/vote/vote.go b/internal/util/vote/vote.go index 2c34fa0ee..07f5ff586 100644 --- a/internal/util/vote/vote.go +++ b/internal/util/vote/vote.go @@ -1,294 +1,294 @@ -package vote - -import ( - "bytes" - "encoding/base64" - "encoding/gob" - "fmt" - "strings" - "time" - - "github.com/wcharczuk/go-chart/drawing" - "github.com/zekroTJA/shinpuru/internal/services/timeprovider" - "github.com/zekroTJA/shinpuru/internal/util/static" - "github.com/zekroTJA/shinpuru/pkg/discordutil" - - "github.com/bwmarrin/discordgo" - "github.com/wcharczuk/go-chart" -) - -// VoteState defines the lifecycle state of a Vote. -type VoteState int - -const ( - VoteStateOpen VoteState = iota - VoteStateClosed - VoteStateClosedNC - VoteStateExpired -) - -// VotesRunning maps running vote IDs to -// their vote instances. -var VotesRunning = map[string]Vote{} - -// VoteEmotes contains the emotes used to tick a vote. -var VoteEmotes = strings.Fields("\u0031\u20E3 \u0032\u20E3 \u0033\u20E3 \u0034\u20E3 \u0035\u20E3 \u0036\u20E3 \u0037\u20E3 \u0038\u20E3 \u0039\u20E3 \u0030\u20E3") - -// Vote wraps the information and current -// state of a vote and its ticks. -type Vote struct { - ID string - MsgID string - CreatorID string - GuildID string - ChannelID string - Description string - ImageURL string - Expires time.Time - Possibilities []string - Ticks map[string]*Tick -} - -// Tick wraps a user ID and the index of -// the selection ticked. -type Tick struct { - UserID string - Tick int -} - -// Unmarshal tries to deserialize a raw data string -// to a Vote object. Errors occured during -// deserialization are returned as well. -func Unmarshal(data string) (Vote, error) { - rawData, err := base64.StdEncoding.DecodeString(data) - if err != nil { - return Vote{}, err - } - - var res Vote - buffer := bytes.NewBuffer(rawData) - gobdec := gob.NewDecoder(buffer) - err = gobdec.Decode(&res) - return res, err -} - -// Marshal serializes the vote to a raw data string. -// -// The vote object is encoded to a byte array using -// the gob encoder and then encoded to a base64 string. -func (v *Vote) Marshal() (string, error) { - var buffer bytes.Buffer - gobenc := gob.NewEncoder(&buffer) - err := gobenc.Encode(v) - if err != nil { - return "", err - } - gobres := buffer.Bytes() - res := base64.StdEncoding.EncodeToString(gobres) - return res, nil -} - -// AsEmbed creates a discordgo.MessageEmbed from the -// vote. If voteState is passed, the state will be -// displayed as well. Otherwise, it will be assumed -// that the vote is open. -// -// If voteState is VoteStateClosed or VoteStateExpired, -// a pie chart will be generated representing the -// distribution of vote ticks and sent as image to -// the channel. -func (v *Vote) AsEmbed(s *discordgo.Session, voteState ...VoteState) (*discordgo.MessageEmbed, error) { - state := VoteStateOpen - if len(voteState) > 0 { - state = voteState[0] - } - - creator, err := s.User(v.CreatorID) - if err != nil { - return nil, err - } - title := "Open Vote" - color := static.ColorEmbedDefault - - switch state { - case VoteStateClosed, VoteStateClosedNC: - title = "Vote closed" - color = static.ColorEmbedOrange - case VoteStateExpired: - title = "Vote expired" - color = static.ColorEmbedViolett - } - - totalTicks := make(map[int]int) - for _, t := range v.Ticks { - if _, ok := totalTicks[t.Tick]; !ok { - totalTicks[t.Tick] = 1 - } else { - totalTicks[t.Tick]++ - } - } - - description := v.Description + "\n\n" - for i, p := range v.Possibilities { - description += fmt.Sprintf("%s %s - `%d`\n", VoteEmotes[i], p, totalTicks[i]) - } - - footerText := fmt.Sprintf("ID: %s", v.ID) - if (v.Expires != time.Time{} && state == VoteStateOpen) { - footerText = fmt.Sprintf("%s | Expires: %s", footerText, v.Expires.Format("01/02 15:04 MST")) - } - - emb := &discordgo.MessageEmbed{ - Color: color, - Title: title, - Description: description, - Author: &discordgo.MessageEmbedAuthor{ - IconURL: creator.AvatarURL("16x16"), - Name: creator.String(), - }, - Footer: &discordgo.MessageEmbedFooter{ - Text: footerText, - }, - } - - if len(totalTicks) > 0 && (state == VoteStateClosed || state == VoteStateExpired) { - - values := make([]chart.Value, len(v.Possibilities)) - - for i, p := range v.Possibilities { - values[i] = chart.Value{ - Value: float64(totalTicks[i]), - Label: p, - } - } - - pie := chart.PieChart{ - Width: 512, - Height: 512, - Values: values, - Background: chart.Style{ - FillColor: drawing.ColorTransparent, - }, - } - - imgData := []byte{} - buff := bytes.NewBuffer(imgData) - err = pie.Render(chart.PNG, buff) - if err != nil { - return nil, err - } - - _, err := s.ChannelMessageSendComplex(v.ChannelID, &discordgo.MessageSend{ - File: &discordgo.File{ - Name: fmt.Sprintf("vote_chart_%s.png", v.ID), - Reader: buff, - }, - Reference: &discordgo.MessageReference{ - MessageID: v.MsgID, - ChannelID: v.ChannelID, - GuildID: v.GuildID, - }, - }) - if err != nil { - return nil, err - } - } - - if v.ImageURL != "" { - emb.Image = &discordgo.MessageEmbedImage{ - URL: v.ImageURL, - } - } - - return emb, nil -} - -// AsField creates a discordgo.MessageEmbedField from -// the vote information. -func (v *Vote) AsField() *discordgo.MessageEmbedField { - shortenedDescription := v.Description - if len(shortenedDescription) > 200 { - shortenedDescription = shortenedDescription[200:] + "..." - } - - expiresTxt := "never" - if (v.Expires != time.Time{}) { - expiresTxt = v.Expires.Format("01/02 15:04 MST") - } - - return &discordgo.MessageEmbedField{ - Name: "VID: " + v.ID, - Value: fmt.Sprintf("**Description:** %s\n**Expires:** %s\n`%d votes`\n[*jump to msg*](%s)", - shortenedDescription, expiresTxt, len(v.Ticks), discordutil.GetMessageLink(&discordgo.Message{ - ID: v.MsgID, - ChannelID: v.ChannelID, - }, v.GuildID)), - } -} - -// AddReactions adds the reactions to the votes message -// for each selection possibility. -// -// Vote emotes are used from VoteEmotes. -func (v *Vote) AddReactions(s *discordgo.Session) error { - for i := 0; i < len(v.Possibilities); i++ { - err := s.MessageReactionAdd(v.ChannelID, v.MsgID, VoteEmotes[i]) - if err != nil { - return err - } - } - return nil -} - -// Tick sets the tick for the specified user to the vote. -func (v *Vote) Tick(s *discordgo.Session, userID string, tick int) (err error) { - if userID, err = HashUserID(userID, []byte(v.ID)); err != nil { - return - } - - if t, ok := v.Ticks[userID]; ok { - t.Tick = tick - } else { - v.Ticks[userID] = &Tick{ - UserID: userID, - Tick: tick, - } - } - - emb, err := v.AsEmbed(s) - if err != nil { - return - } - - _, err = s.ChannelMessageEditEmbed(v.ChannelID, v.MsgID, emb) - return -} - -// SetExpire sets the expiration for a vote. -func (v *Vote) SetExpire(s *discordgo.Session, d time.Duration, tp timeprovider.Provider) error { - v.Expires = tp.Now().Add(d) - - emb, err := v.AsEmbed(s) - if err != nil { - return err - } - _, err = s.ChannelMessageEditEmbed(v.ChannelID, v.MsgID, emb) - - return err -} - -// Close closes the vote and removes it -// from the VotesRunning map. -func (v *Vote) Close(s *discordgo.Session, voteState VoteState) error { - delete(VotesRunning, v.ID) - emb, err := v.AsEmbed(s, voteState) - if err != nil { - return err - } - _, err = s.ChannelMessageEditEmbed(v.ChannelID, v.MsgID, emb) - if err != nil { - return err - } - err = s.MessageReactionsRemoveAll(v.ChannelID, v.MsgID) - return err -} +package vote + +import ( + "bytes" + "encoding/base64" + "encoding/gob" + "fmt" + "strings" + "time" + + "github.com/wcharczuk/go-chart/drawing" + "github.com/zekroTJA/shinpuru/internal/services/timeprovider" + "github.com/zekroTJA/shinpuru/internal/util/static" + "github.com/zekroTJA/shinpuru/pkg/discordutil" + + "github.com/bwmarrin/discordgo" + "github.com/wcharczuk/go-chart" +) + +// VoteState defines the lifecycle state of a Vote. +type VoteState int + +const ( + VoteStateOpen VoteState = iota + VoteStateClosed + VoteStateClosedNC + VoteStateExpired +) + +// VotesRunning maps running vote IDs to +// their vote instances. +var VotesRunning = map[string]Vote{} + +// VoteEmotes contains the emotes used to tick a vote. +var VoteEmotes = strings.Fields("\u0031\u20E3 \u0032\u20E3 \u0033\u20E3 \u0034\u20E3 \u0035\u20E3 \u0036\u20E3 \u0037\u20E3 \u0038\u20E3 \u0039\u20E3 \u0030\u20E3") + +// Vote wraps the information and current +// state of a vote and its ticks. +type Vote struct { + ID string + MsgID string + CreatorID string + GuildID string + ChannelID string + Description string + ImageURL string + Expires time.Time + Possibilities []string + Ticks map[string]*Tick +} + +// Tick wraps a user ID and the index of +// the selection ticked. +type Tick struct { + UserID string + Tick int +} + +// Unmarshal tries to deserialize a raw data string +// to a Vote object. Errors occured during +// deserialization are returned as well. +func Unmarshal(data string) (Vote, error) { + rawData, err := base64.StdEncoding.DecodeString(data) + if err != nil { + return Vote{}, err + } + + var res Vote + buffer := bytes.NewBuffer(rawData) + gobdec := gob.NewDecoder(buffer) + err = gobdec.Decode(&res) + return res, err +} + +// Marshal serializes the vote to a raw data string. +// +// The vote object is encoded to a byte array using +// the gob encoder and then encoded to a base64 string. +func (v *Vote) Marshal() (string, error) { + var buffer bytes.Buffer + gobenc := gob.NewEncoder(&buffer) + err := gobenc.Encode(v) + if err != nil { + return "", err + } + gobres := buffer.Bytes() + res := base64.StdEncoding.EncodeToString(gobres) + return res, nil +} + +// AsEmbed creates a discordgo.MessageEmbed from the +// vote. If voteState is passed, the state will be +// displayed as well. Otherwise, it will be assumed +// that the vote is open. +// +// If voteState is VoteStateClosed or VoteStateExpired, +// a pie chart will be generated representing the +// distribution of vote ticks and sent as image to +// the channel. +func (v *Vote) AsEmbed(s Session, voteState ...VoteState) (*discordgo.MessageEmbed, error) { + state := VoteStateOpen + if len(voteState) > 0 { + state = voteState[0] + } + + creator, err := s.User(v.CreatorID) + if err != nil { + return nil, err + } + title := "Open Vote" + color := static.ColorEmbedDefault + + switch state { + case VoteStateClosed, VoteStateClosedNC: + title = "Vote closed" + color = static.ColorEmbedOrange + case VoteStateExpired: + title = "Vote expired" + color = static.ColorEmbedViolett + } + + totalTicks := make(map[int]int) + for _, t := range v.Ticks { + if _, ok := totalTicks[t.Tick]; !ok { + totalTicks[t.Tick] = 1 + } else { + totalTicks[t.Tick]++ + } + } + + description := v.Description + "\n\n" + for i, p := range v.Possibilities { + description += fmt.Sprintf("%s %s - `%d`\n", VoteEmotes[i], p, totalTicks[i]) + } + + footerText := fmt.Sprintf("ID: %s", v.ID) + if (v.Expires != time.Time{} && state == VoteStateOpen) { + footerText = fmt.Sprintf("%s | Expires: %s", footerText, v.Expires.Format("01/02 15:04 MST")) + } + + emb := &discordgo.MessageEmbed{ + Color: color, + Title: title, + Description: description, + Author: &discordgo.MessageEmbedAuthor{ + IconURL: creator.AvatarURL("16x16"), + Name: creator.String(), + }, + Footer: &discordgo.MessageEmbedFooter{ + Text: footerText, + }, + } + + if len(totalTicks) > 0 && (state == VoteStateClosed || state == VoteStateExpired) { + + values := make([]chart.Value, len(v.Possibilities)) + + for i, p := range v.Possibilities { + values[i] = chart.Value{ + Value: float64(totalTicks[i]), + Label: p, + } + } + + pie := chart.PieChart{ + Width: 512, + Height: 512, + Values: values, + Background: chart.Style{ + FillColor: drawing.ColorTransparent, + }, + } + + imgData := []byte{} + buff := bytes.NewBuffer(imgData) + err = pie.Render(chart.PNG, buff) + if err != nil { + return nil, err + } + + _, err := s.ChannelMessageSendComplex(v.ChannelID, &discordgo.MessageSend{ + File: &discordgo.File{ + Name: fmt.Sprintf("vote_chart_%s.png", v.ID), + Reader: buff, + }, + Reference: &discordgo.MessageReference{ + MessageID: v.MsgID, + ChannelID: v.ChannelID, + GuildID: v.GuildID, + }, + }) + if err != nil { + return nil, err + } + } + + if v.ImageURL != "" { + emb.Image = &discordgo.MessageEmbedImage{ + URL: v.ImageURL, + } + } + + return emb, nil +} + +// AsField creates a discordgo.MessageEmbedField from +// the vote information. +func (v *Vote) AsField() *discordgo.MessageEmbedField { + shortenedDescription := v.Description + if len(shortenedDescription) > 200 { + shortenedDescription = shortenedDescription[200:] + "..." + } + + expiresTxt := "never" + if (v.Expires != time.Time{}) { + expiresTxt = v.Expires.Format("01/02 15:04 MST") + } + + return &discordgo.MessageEmbedField{ + Name: "VID: " + v.ID, + Value: fmt.Sprintf("**Description:** %s\n**Expires:** %s\n`%d votes`\n[*jump to msg*](%s)", + shortenedDescription, expiresTxt, len(v.Ticks), discordutil.GetMessageLink(&discordgo.Message{ + ID: v.MsgID, + ChannelID: v.ChannelID, + }, v.GuildID)), + } +} + +// AddReactions adds the reactions to the votes message +// for each selection possibility. +// +// Vote emotes are used from VoteEmotes. +func (v *Vote) AddReactions(s Session) error { + for i := 0; i < len(v.Possibilities); i++ { + err := s.MessageReactionAdd(v.ChannelID, v.MsgID, VoteEmotes[i]) + if err != nil { + return err + } + } + return nil +} + +// Tick sets the tick for the specified user to the vote. +func (v *Vote) Tick(s Session, userID string, tick int) (err error) { + if userID, err = HashUserID(userID, []byte(v.ID)); err != nil { + return + } + + if t, ok := v.Ticks[userID]; ok { + t.Tick = tick + } else { + v.Ticks[userID] = &Tick{ + UserID: userID, + Tick: tick, + } + } + + emb, err := v.AsEmbed(s) + if err != nil { + return + } + + _, err = s.ChannelMessageEditEmbed(v.ChannelID, v.MsgID, emb) + return +} + +// SetExpire sets the expiration for a vote. +func (v *Vote) SetExpire(s Session, d time.Duration, tp timeprovider.Provider) error { + v.Expires = tp.Now().Add(d) + + emb, err := v.AsEmbed(s) + if err != nil { + return err + } + _, err = s.ChannelMessageEditEmbed(v.ChannelID, v.MsgID, emb) + + return err +} + +// Close closes the vote and removes it +// from the VotesRunning map. +func (v *Vote) Close(s Session, voteState VoteState) error { + delete(VotesRunning, v.ID) + emb, err := v.AsEmbed(s, voteState) + if err != nil { + return err + } + _, err = s.ChannelMessageEditEmbed(v.ChannelID, v.MsgID, emb) + if err != nil { + return err + } + err = s.MessageReactionsRemoveAll(v.ChannelID, v.MsgID) + return err +} diff --git a/pkg/fetch/dgrs_outlet.go b/pkg/fetch/dgrs_outlet.go index 1e20d81c9..3cd19e3f0 100644 --- a/pkg/fetch/dgrs_outlet.go +++ b/pkg/fetch/dgrs_outlet.go @@ -1,30 +1,30 @@ -package fetch - -import ( - "github.com/bwmarrin/discordgo" - "github.com/zekrotja/dgrs" -) - -type DgrsDataOutlet struct { - state *dgrs.State - forceFetch bool -} - -var _ DataOutlet = (*DgrsDataOutlet)(nil) - -func WrapDrgs(state *dgrs.State, forceFetch ...bool) DgrsDataOutlet { - ff := len(forceFetch) > 0 && forceFetch[0] - return DgrsDataOutlet{state, ff} -} - -func (o DgrsDataOutlet) GuildRoles(guildID string, options ...discordgo.RequestOption) ([]*discordgo.Role, error) { - return o.state.Roles(guildID, o.forceFetch) -} - -func (o DgrsDataOutlet) GuildMembers(guildID string, _ string, _ int, options ...discordgo.RequestOption) (st []*discordgo.Member, err error) { - return o.state.Members(guildID, o.forceFetch) -} - -func (o DgrsDataOutlet) GuildChannels(guildID string, options ...discordgo.RequestOption) (st []*discordgo.Channel, err error) { - return o.state.Channels(guildID, o.forceFetch) -} +package fetch + +import ( + "github.com/bwmarrin/discordgo" + "github.com/zekrotja/dgrs" +) + +type DgrsDataOutlet struct { + state *dgrs.State + forceFetch bool +} + +var _ Session = (*DgrsDataOutlet)(nil) + +func WrapDrgs(state *dgrs.State, forceFetch ...bool) DgrsDataOutlet { + ff := len(forceFetch) > 0 && forceFetch[0] + return DgrsDataOutlet{state, ff} +} + +func (o DgrsDataOutlet) GuildRoles(guildID string, options ...discordgo.RequestOption) ([]*discordgo.Role, error) { + return o.state.Roles(guildID, o.forceFetch) +} + +func (o DgrsDataOutlet) GuildMembers(guildID string, _ string, _ int, options ...discordgo.RequestOption) (st []*discordgo.Member, err error) { + return o.state.Members(guildID, o.forceFetch) +} + +func (o DgrsDataOutlet) GuildChannels(guildID string, options ...discordgo.RequestOption) (st []*discordgo.Channel, err error) { + return o.state.Channels(guildID, o.forceFetch) +} diff --git a/pkg/fetch/fetch.go b/pkg/fetch/fetch.go index 1703d7d47..e478626ad 100644 --- a/pkg/fetch/fetch.go +++ b/pkg/fetch/fetch.go @@ -1,235 +1,235 @@ -// Package fetch provides functionalities to fetch roles, -// channels, members and users by so called resolavbles. -// That means, these functions try to match a member, role -// or channel by their names, displaynames, IDs or mentions -// as greedy as prossible. -package fetch - -import ( - "regexp" - "strings" - - "github.com/bwmarrin/discordgo" -) - -var ( - RoleCheckFuncs = []func(*discordgo.Role, string) bool{ - // 1. ID exact match - func(r *discordgo.Role, resolvable string) bool { - return r.ID == resolvable - }, - // 2. name exact match - func(r *discordgo.Role, resolvable string) bool { - return r.Name == resolvable - }, - // 3. name lowercased exact match - func(r *discordgo.Role, resolvable string) bool { - return strings.EqualFold(r.Name, resolvable) - }, - // 4. name lowercased startswith - func(r *discordgo.Role, resolvable string) bool { - return strings.HasPrefix(strings.ToLower(r.Name), strings.ToLower(resolvable)) - }, - // 5. name lowercased contains - func(r *discordgo.Role, resolvable string) bool { - return strings.Contains(strings.ToLower(r.Name), strings.ToLower(resolvable)) - }, - } - - MemberCheckFuncs = []func(*discordgo.Member, string) bool{ - // 1. ID exact match - func(r *discordgo.Member, resolvable string) bool { - return r.User.ID == resolvable - }, - // 2. username exact match - func(r *discordgo.Member, resolvable string) bool { - return r.User.Username == resolvable - }, - // 3. username lowercased exact match - func(r *discordgo.Member, resolvable string) bool { - return strings.EqualFold(r.User.Username, resolvable) - }, - // 4. username lowercased startswith - func(r *discordgo.Member, resolvable string) bool { - return strings.HasPrefix(strings.ToLower(r.User.Username), strings.ToLower(resolvable)) - - }, - // 5. username lowercased contains - func(r *discordgo.Member, resolvable string) bool { - return strings.Contains(strings.ToLower(r.User.Username), strings.ToLower(resolvable)) - }, - // 6. nick exact match - func(r *discordgo.Member, resolvable string) bool { - return r.Nick == resolvable - }, - // 7. nick lowercased exact match - func(r *discordgo.Member, resolvable string) bool { - return r.Nick != "" && strings.EqualFold(r.Nick, resolvable) - }, - // 8. nick lowercased starts with - func(r *discordgo.Member, resolvable string) bool { - return r.Nick != "" && strings.HasPrefix(strings.ToLower(r.Nick), strings.ToLower(resolvable)) - }, - // 9. nick lowercased contains - func(r *discordgo.Member, resolvable string) bool { - return r.Nick != "" && strings.Contains(strings.ToLower(r.Nick), strings.ToLower(resolvable)) - }, - } - - ChannelCheckFuncs = []func(*discordgo.Channel, string) bool{ - // 1. ID exact match - func(r *discordgo.Channel, resolvable string) bool { - return r.ID == resolvable - }, - // 2. mention exact match - func(r *discordgo.Channel, resolvable string) bool { - l := len(resolvable) - return l > 3 && r.ID == resolvable[2:l-1] - }, - // 3. name exact match - func(r *discordgo.Channel, resolvable string) bool { - return r.Name == resolvable - }, - // 4. name lowercased exact match - func(r *discordgo.Channel, resolvable string) bool { - return strings.EqualFold(r.Name, resolvable) - }, - // 5. name lowercased starts with - func(r *discordgo.Channel, resolvable string) bool { - return strings.HasPrefix(strings.ToLower(r.Name), strings.ToLower(resolvable)) - }, - // 6. name lowercased contains - func(r *discordgo.Channel, resolvable string) bool { - return strings.Contains(strings.ToLower(r.Name), strings.ToLower(resolvable)) - }, - } - - GuildCheckFuncs = []func(*discordgo.Guild, string) bool{ - // 1. ID exact match - func(r *discordgo.Guild, resolvable string) bool { - return r.ID == resolvable - }, - // 2. mention exact match - func(r *discordgo.Guild, resolvable string) bool { - l := len(resolvable) - return l > 3 && r.ID == resolvable[2:l-1] - }, - // 3. name exact match - func(r *discordgo.Guild, resolvable string) bool { - return r.Name == resolvable - }, - // 4. name lowercased exact match - func(r *discordgo.Guild, resolvable string) bool { - return strings.EqualFold(r.Name, resolvable) - }, - // 5. name lowercased starts with - func(r *discordgo.Guild, resolvable string) bool { - return strings.HasPrefix(strings.ToLower(r.Name), strings.ToLower(resolvable)) - }, - // 6. name lowercased contains - func(r *discordgo.Guild, resolvable string) bool { - return strings.Contains(strings.ToLower(r.Name), strings.ToLower(resolvable)) - }, - } -) - -// FetchRole tries to fetch a role on the specified guild -// by given resolvable and returns this role, when found. -// You can pass a condition function which ignores the result -// if this functions returns false on the given object. -// If no object was found, ErrNotFound is returned. -// If any other unexpected error occurs during fetching, -// this error is returned as well. -func FetchRole(s DataOutlet, guildID, resolvable string, condition ...func(*discordgo.Role) bool) (*discordgo.Role, error) { - roles, err := s.GuildRoles(guildID) - if err != nil { - return nil, err - } - rx := regexp.MustCompile("<@&|>") - resolvable = rx.ReplaceAllString(resolvable, "") - - for _, checkFunc := range RoleCheckFuncs { - for _, r := range roles { - if len(condition) > 0 && condition[0] != nil { - if !condition[0](r) { - continue - } - } - if checkFunc(r, resolvable) { - return r, nil - } - } - } - - return nil, ErrNotFound -} - -// FetchMember tries to fetch a member on the specified guild -// by given resolvable and returns this member, when found. -// You can pass a condition function which ignores the result -// if this functions returns false on the given object. -// If no object was found, ErrNotFound is returned. -// If any other unexpected error occurs during fetching, -// this error is returned as well. -func FetchMember(s DataOutlet, guildID, resolvable string, condition ...func(*discordgo.Member) bool) (*discordgo.Member, error) { - rx := regexp.MustCompile("<@|!|>") - resolvable = rx.ReplaceAllString(resolvable, "") - var lastUserID string - - for { - members, err := s.GuildMembers(guildID, lastUserID, 1000) - if err != nil { - return nil, err - } - - if len(members) < 1 { - break - } - - lastUserID = members[len(members)-1].User.ID - - for _, checkFunc := range MemberCheckFuncs { - for _, m := range members { - if len(condition) > 0 && condition[0] != nil { - if !condition[0](m) { - continue - } - } - if checkFunc(m, resolvable) { - return m, nil - } - } - } - } - - return nil, ErrNotFound -} - -// FetchChannel tries to fetch a channel on the specified guild -// by given resolvable and returns this channel, when found. -// You can pass a condition function which ignores the result -// if this functions returns false on the given object. -// If no object was found, ErrNotFound is returned. -// If any other unexpected error occurs during fetching, -// this error is returned as well. -func FetchChannel(s DataOutlet, guildID, resolvable string, condition ...func(*discordgo.Channel) bool) (*discordgo.Channel, error) { - channels, err := s.GuildChannels(guildID) - if err != nil { - return nil, err - } - - for _, checkFunc := range ChannelCheckFuncs { - for _, c := range channels { - if len(condition) > 0 && condition[0] != nil { - if !condition[0](c) { - continue - } - } - if checkFunc(c, resolvable) { - return c, nil - } - } - } - - return nil, ErrNotFound -} +// Package fetch provides functionalities to fetch roles, +// channels, members and users by so called resolavbles. +// That means, these functions try to match a member, role +// or channel by their names, displaynames, IDs or mentions +// as greedy as prossible. +package fetch + +import ( + "regexp" + "strings" + + "github.com/bwmarrin/discordgo" +) + +var ( + RoleCheckFuncs = []func(*discordgo.Role, string) bool{ + // 1. ID exact match + func(r *discordgo.Role, resolvable string) bool { + return r.ID == resolvable + }, + // 2. name exact match + func(r *discordgo.Role, resolvable string) bool { + return r.Name == resolvable + }, + // 3. name lowercased exact match + func(r *discordgo.Role, resolvable string) bool { + return strings.EqualFold(r.Name, resolvable) + }, + // 4. name lowercased startswith + func(r *discordgo.Role, resolvable string) bool { + return strings.HasPrefix(strings.ToLower(r.Name), strings.ToLower(resolvable)) + }, + // 5. name lowercased contains + func(r *discordgo.Role, resolvable string) bool { + return strings.Contains(strings.ToLower(r.Name), strings.ToLower(resolvable)) + }, + } + + MemberCheckFuncs = []func(*discordgo.Member, string) bool{ + // 1. ID exact match + func(r *discordgo.Member, resolvable string) bool { + return r.User.ID == resolvable + }, + // 2. username exact match + func(r *discordgo.Member, resolvable string) bool { + return r.User.Username == resolvable + }, + // 3. username lowercased exact match + func(r *discordgo.Member, resolvable string) bool { + return strings.EqualFold(r.User.Username, resolvable) + }, + // 4. username lowercased startswith + func(r *discordgo.Member, resolvable string) bool { + return strings.HasPrefix(strings.ToLower(r.User.Username), strings.ToLower(resolvable)) + + }, + // 5. username lowercased contains + func(r *discordgo.Member, resolvable string) bool { + return strings.Contains(strings.ToLower(r.User.Username), strings.ToLower(resolvable)) + }, + // 6. nick exact match + func(r *discordgo.Member, resolvable string) bool { + return r.Nick == resolvable + }, + // 7. nick lowercased exact match + func(r *discordgo.Member, resolvable string) bool { + return r.Nick != "" && strings.EqualFold(r.Nick, resolvable) + }, + // 8. nick lowercased starts with + func(r *discordgo.Member, resolvable string) bool { + return r.Nick != "" && strings.HasPrefix(strings.ToLower(r.Nick), strings.ToLower(resolvable)) + }, + // 9. nick lowercased contains + func(r *discordgo.Member, resolvable string) bool { + return r.Nick != "" && strings.Contains(strings.ToLower(r.Nick), strings.ToLower(resolvable)) + }, + } + + ChannelCheckFuncs = []func(*discordgo.Channel, string) bool{ + // 1. ID exact match + func(r *discordgo.Channel, resolvable string) bool { + return r.ID == resolvable + }, + // 2. mention exact match + func(r *discordgo.Channel, resolvable string) bool { + l := len(resolvable) + return l > 3 && r.ID == resolvable[2:l-1] + }, + // 3. name exact match + func(r *discordgo.Channel, resolvable string) bool { + return r.Name == resolvable + }, + // 4. name lowercased exact match + func(r *discordgo.Channel, resolvable string) bool { + return strings.EqualFold(r.Name, resolvable) + }, + // 5. name lowercased starts with + func(r *discordgo.Channel, resolvable string) bool { + return strings.HasPrefix(strings.ToLower(r.Name), strings.ToLower(resolvable)) + }, + // 6. name lowercased contains + func(r *discordgo.Channel, resolvable string) bool { + return strings.Contains(strings.ToLower(r.Name), strings.ToLower(resolvable)) + }, + } + + GuildCheckFuncs = []func(*discordgo.Guild, string) bool{ + // 1. ID exact match + func(r *discordgo.Guild, resolvable string) bool { + return r.ID == resolvable + }, + // 2. mention exact match + func(r *discordgo.Guild, resolvable string) bool { + l := len(resolvable) + return l > 3 && r.ID == resolvable[2:l-1] + }, + // 3. name exact match + func(r *discordgo.Guild, resolvable string) bool { + return r.Name == resolvable + }, + // 4. name lowercased exact match + func(r *discordgo.Guild, resolvable string) bool { + return strings.EqualFold(r.Name, resolvable) + }, + // 5. name lowercased starts with + func(r *discordgo.Guild, resolvable string) bool { + return strings.HasPrefix(strings.ToLower(r.Name), strings.ToLower(resolvable)) + }, + // 6. name lowercased contains + func(r *discordgo.Guild, resolvable string) bool { + return strings.Contains(strings.ToLower(r.Name), strings.ToLower(resolvable)) + }, + } +) + +// FetchRole tries to fetch a role on the specified guild +// by given resolvable and returns this role, when found. +// You can pass a condition function which ignores the result +// if this functions returns false on the given object. +// If no object was found, ErrNotFound is returned. +// If any other unexpected error occurs during fetching, +// this error is returned as well. +func FetchRole(s Session, guildID, resolvable string, condition ...func(*discordgo.Role) bool) (*discordgo.Role, error) { + roles, err := s.GuildRoles(guildID) + if err != nil { + return nil, err + } + rx := regexp.MustCompile("<@&|>") + resolvable = rx.ReplaceAllString(resolvable, "") + + for _, checkFunc := range RoleCheckFuncs { + for _, r := range roles { + if len(condition) > 0 && condition[0] != nil { + if !condition[0](r) { + continue + } + } + if checkFunc(r, resolvable) { + return r, nil + } + } + } + + return nil, ErrNotFound +} + +// FetchMember tries to fetch a member on the specified guild +// by given resolvable and returns this member, when found. +// You can pass a condition function which ignores the result +// if this functions returns false on the given object. +// If no object was found, ErrNotFound is returned. +// If any other unexpected error occurs during fetching, +// this error is returned as well. +func FetchMember(s Session, guildID, resolvable string, condition ...func(*discordgo.Member) bool) (*discordgo.Member, error) { + rx := regexp.MustCompile("<@|!|>") + resolvable = rx.ReplaceAllString(resolvable, "") + var lastUserID string + + for { + members, err := s.GuildMembers(guildID, lastUserID, 1000) + if err != nil { + return nil, err + } + + if len(members) < 1 { + break + } + + lastUserID = members[len(members)-1].User.ID + + for _, checkFunc := range MemberCheckFuncs { + for _, m := range members { + if len(condition) > 0 && condition[0] != nil { + if !condition[0](m) { + continue + } + } + if checkFunc(m, resolvable) { + return m, nil + } + } + } + } + + return nil, ErrNotFound +} + +// FetchChannel tries to fetch a channel on the specified guild +// by given resolvable and returns this channel, when found. +// You can pass a condition function which ignores the result +// if this functions returns false on the given object. +// If no object was found, ErrNotFound is returned. +// If any other unexpected error occurs during fetching, +// this error is returned as well. +func FetchChannel(s Session, guildID, resolvable string, condition ...func(*discordgo.Channel) bool) (*discordgo.Channel, error) { + channels, err := s.GuildChannels(guildID) + if err != nil { + return nil, err + } + + for _, checkFunc := range ChannelCheckFuncs { + for _, c := range channels { + if len(condition) > 0 && condition[0] != nil { + if !condition[0](c) { + continue + } + } + if checkFunc(c, resolvable) { + return c, nil + } + } + } + + return nil, ErrNotFound +} diff --git a/pkg/fetch/outlet.go b/pkg/fetch/outlet.go index e3b4992d0..9e51baea9 100644 --- a/pkg/fetch/outlet.go +++ b/pkg/fetch/outlet.go @@ -1,11 +1,11 @@ -package fetch - -import ( - "github.com/bwmarrin/discordgo" -) - -type DataOutlet interface { - GuildRoles(guildID string, options ...discordgo.RequestOption) ([]*discordgo.Role, error) - GuildMembers(guildID string, after string, limit int, options ...discordgo.RequestOption) (st []*discordgo.Member, err error) - GuildChannels(guildID string, options ...discordgo.RequestOption) (st []*discordgo.Channel, err error) -} +package fetch + +import ( + "github.com/bwmarrin/discordgo" +) + +type Session interface { + GuildRoles(guildID string, options ...discordgo.RequestOption) ([]*discordgo.Role, error) + GuildMembers(guildID string, after string, limit int, options ...discordgo.RequestOption) (st []*discordgo.Member, err error) + GuildChannels(guildID string, options ...discordgo.RequestOption) (st []*discordgo.Channel, err error) +} diff --git a/pkg/roleutil/interfaces.go b/pkg/roleutil/interfaces.go new file mode 100644 index 000000000..927088829 --- /dev/null +++ b/pkg/roleutil/interfaces.go @@ -0,0 +1,8 @@ +package roleutil + +import "github.com/bwmarrin/discordgo" + +type Session interface { + GuildMember(guildID, userID string, options ...discordgo.RequestOption) (st *discordgo.Member, err error) + GuildRoles(guildID string, options ...discordgo.RequestOption) (st []*discordgo.Role, err error) +} diff --git a/pkg/roleutil/roleutil.go b/pkg/roleutil/roleutil.go index 2b78d17c4..3a60f2702 100644 --- a/pkg/roleutil/roleutil.go +++ b/pkg/roleutil/roleutil.go @@ -1,122 +1,122 @@ -// Package roleutil provides general purpose -// utilities for discordgo.Role objects and -// arrays. -package roleutil - -import ( - "sort" - - "github.com/bwmarrin/discordgo" - "github.com/zekroTJA/shinpuru/pkg/discordutil" -) - -// SortRoles sorts a given array of discordgo.Role -// object references by position in ascending order. -// If reversed, the order is descending. -func SortRoles(r []*discordgo.Role, reversed bool) { - var f func(i, j int) bool - - if reversed { - f = func(i, j int) bool { - return r[i].Position > r[j].Position - } - } else { - f = func(i, j int) bool { - return r[i].Position < r[j].Position - } - } - - sort.Slice(r, f) -} - -// GetSortedMemberRoles tries to fetch the roles of a given -// member on a given guild and returns the role array in -// sorted ascending order by position. -// If any error occurs, the error is returned as well. -// If reversed, the order is descending. -func GetSortedMemberRoles(s discordutil.ISession, guildID, memberID string, reversed bool, includeEveryone bool) ([]*discordgo.Role, error) { - member, err := s.GuildMember(guildID, memberID) - if err != nil { - return nil, err - } - - roles, err := s.GuildRoles(guildID) - if err != nil { - return nil, err - } - - rolesMap := make(map[string]*discordgo.Role) - for _, r := range roles { - rolesMap[r.ID] = r - } - - membRoles := make([]*discordgo.Role, len(member.Roles)+1) - applied := 0 - for _, rID := range member.Roles { - if r, ok := rolesMap[rID]; ok { - membRoles[applied] = r - applied++ - } - } - - if includeEveryone { - membRoles[applied] = rolesMap[guildID] - applied++ - } - - membRoles = membRoles[:applied] - - SortRoles(membRoles, reversed) - - return membRoles, nil -} - -// GetSortedGuildRoles tries to fetch the roles of a given -// guild and returns the role array in sorted ascending -// order by position. -// If any error occurs, the error is returned as well. -// If reversed, the order is descending. -func GetSortedGuildRoles(s discordutil.ISession, guildID string, reversed bool) ([]*discordgo.Role, error) { - roles, err := s.GuildRoles(guildID) - if err != nil { - return nil, err - } - - SortRoles(roles, reversed) - - return roles, nil -} - -// PositionDiff : m1 position - m2 position -// PositionDiff returns the difference number between -// the top most role of member m1 and member m2 on -// the specified guild g by subtracting -// m1MaxPos - m2MaxPos. -func PositionDiff(m1 *discordgo.Member, m2 *discordgo.Member, g *discordgo.Guild) int { - if len(g.Roles) == 0 || len(m1.Roles) == 0 && len(m2.Roles) == 0 { - return 0 - } - - m1MaxPos, m2MaxPos := -1, -1 - rolePositions := make(map[string]int) - - for _, rG := range g.Roles { - rolePositions[rG.ID] = rG.Position - } - - for _, r := range m1.Roles { - p := rolePositions[r] - if p > m1MaxPos || m1MaxPos == -1 { - m1MaxPos = p - } - } - - for _, r := range m2.Roles { - p := rolePositions[r] - if p > m2MaxPos || m2MaxPos == -1 { - m2MaxPos = p - } - } - - return m1MaxPos - m2MaxPos -} +// Package roleutil provides general purpose +// utilities for discordgo.Role objects and +// arrays. +package roleutil + +import ( + "sort" + + "github.com/bwmarrin/discordgo" + "github.com/zekroTJA/shinpuru/pkg/discordutil" +) + +// SortRoles sorts a given array of discordgo.Role +// object references by position in ascending order. +// If reversed, the order is descending. +func SortRoles(r []*discordgo.Role, reversed bool) { + var f func(i, j int) bool + + if reversed { + f = func(i, j int) bool { + return r[i].Position > r[j].Position + } + } else { + f = func(i, j int) bool { + return r[i].Position < r[j].Position + } + } + + sort.Slice(r, f) +} + +// GetSortedMemberRoles tries to fetch the roles of a given +// member on a given guild and returns the role array in +// sorted ascending order by position. +// If any error occurs, the error is returned as well. +// If reversed, the order is descending. +func GetSortedMemberRoles(s Session, guildID, memberID string, reversed bool, includeEveryone bool) ([]*discordgo.Role, error) { + member, err := s.GuildMember(guildID, memberID) + if err != nil { + return nil, err + } + + roles, err := s.GuildRoles(guildID) + if err != nil { + return nil, err + } + + rolesMap := make(map[string]*discordgo.Role) + for _, r := range roles { + rolesMap[r.ID] = r + } + + membRoles := make([]*discordgo.Role, len(member.Roles)+1) + applied := 0 + for _, rID := range member.Roles { + if r, ok := rolesMap[rID]; ok { + membRoles[applied] = r + applied++ + } + } + + if includeEveryone { + membRoles[applied] = rolesMap[guildID] + applied++ + } + + membRoles = membRoles[:applied] + + SortRoles(membRoles, reversed) + + return membRoles, nil +} + +// GetSortedGuildRoles tries to fetch the roles of a given +// guild and returns the role array in sorted ascending +// order by position. +// If any error occurs, the error is returned as well. +// If reversed, the order is descending. +func GetSortedGuildRoles(s discordutil.ISession, guildID string, reversed bool) ([]*discordgo.Role, error) { + roles, err := s.GuildRoles(guildID) + if err != nil { + return nil, err + } + + SortRoles(roles, reversed) + + return roles, nil +} + +// PositionDiff : m1 position - m2 position +// PositionDiff returns the difference number between +// the top most role of member m1 and member m2 on +// the specified guild g by subtracting +// m1MaxPos - m2MaxPos. +func PositionDiff(m1 *discordgo.Member, m2 *discordgo.Member, g *discordgo.Guild) int { + if len(g.Roles) == 0 || len(m1.Roles) == 0 && len(m2.Roles) == 0 { + return 0 + } + + m1MaxPos, m2MaxPos := -1, -1 + rolePositions := make(map[string]int) + + for _, rG := range g.Roles { + rolePositions[rG.ID] = rG.Position + } + + for _, r := range m1.Roles { + p := rolePositions[r] + if p > m1MaxPos || m1MaxPos == -1 { + m1MaxPos = p + } + } + + for _, r := range m2.Roles { + p := rolePositions[r] + if p > m2MaxPos || m2MaxPos == -1 { + m2MaxPos = p + } + } + + return m1MaxPos - m2MaxPos +}