Make use of local storage

This commit is contained in:
rxdn 2024-11-13 22:45:37 +00:00
parent 1796ec9cb5
commit 16fadb8663
21 changed files with 293 additions and 579 deletions

View File

@ -1,151 +0,0 @@
package api
import (
"cmp"
"context"
dbclient "github.com/TicketsBot/GoPanel/database"
"github.com/TicketsBot/GoPanel/rpc/cache"
"github.com/TicketsBot/GoPanel/utils"
"github.com/TicketsBot/common/collections"
"github.com/TicketsBot/common/permission"
syncutils "github.com/TicketsBot/common/utils"
"github.com/TicketsBot/database"
"github.com/gin-gonic/gin"
"github.com/jackc/pgtype"
"golang.org/x/sync/errgroup"
"slices"
"sync"
"time"
)
type wrappedGuild struct {
Id uint64 `json:"id,string"`
Name string `json:"name"`
Icon string `json:"icon"`
PermissionLevel permission.PermissionLevel `json:"permission_level"`
}
func GetGuilds(c *gin.Context) {
userId := c.Keys["userid"].(uint64)
// Get the guilds that the user is in, that the bot is also in
userGuilds, err := getGuildIntersection(userId)
if err != nil {
c.JSON(500, utils.ErrorJson(err))
return
}
wg := syncutils.NewChannelWaitGroup()
wg.Add(len(userGuilds))
ctx, cancel := context.WithTimeout(c, time.Second*10)
defer cancel()
group, ctx := errgroup.WithContext(ctx)
var mu sync.Mutex
guilds := make([]wrappedGuild, 0, len(userGuilds))
for _, guild := range userGuilds {
guild := guild
group.Go(func() error {
defer wg.Done()
permLevel, err := utils.GetPermissionLevel(ctx, guild.GuildId, userId)
if err != nil {
return err
}
mu.Lock()
guilds = append(guilds, wrappedGuild{
Id: guild.GuildId,
Name: guild.Name,
Icon: guild.Icon,
PermissionLevel: permLevel,
})
mu.Unlock()
return nil
})
}
if err := group.Wait(); err != nil {
c.JSON(500, utils.ErrorJson(err))
return
}
// Sort the guilds by name, but put the guilds with permission_level=0 last
slices.SortFunc(guilds, func(a, b wrappedGuild) int {
if a.PermissionLevel == 0 && b.PermissionLevel > 0 {
return 1
} else if a.PermissionLevel > 0 && b.PermissionLevel == 0 {
return -1
}
return cmp.Compare(a.Name, b.Name)
})
c.JSON(200, guilds)
}
func getGuildIntersection(userId uint64) ([]database.UserGuild, error) {
// Get all the guilds that the user is in
userGuilds, err := dbclient.Client.UserGuilds.Get(context.Background(), userId)
if err != nil {
return nil, err
}
guildIds := make([]uint64, len(userGuilds))
for i, guild := range userGuilds {
guildIds[i] = guild.GuildId
}
// Restrict the set of guilds to guilds that the bot is also in
botGuilds, err := getExistingGuilds(guildIds)
if err != nil {
return nil, err
}
botGuildIds := collections.NewSet[uint64]()
for _, guildId := range botGuilds {
botGuildIds.Add(guildId)
}
// Get the intersection of the two sets
intersection := make([]database.UserGuild, 0, len(botGuilds))
for _, guild := range userGuilds {
if botGuildIds.Contains(guild.GuildId) {
intersection = append(intersection, guild)
}
}
return intersection, nil
}
func getExistingGuilds(userGuilds []uint64) ([]uint64, error) {
query := `SELECT "guild_id" from guilds WHERE "guild_id" = ANY($1);`
userGuildsArray := &pgtype.Int8Array{}
if err := userGuildsArray.Set(userGuilds); err != nil {
return nil, err
}
rows, err := cache.Instance.Query(context.Background(), query, userGuildsArray)
if err != nil {
return nil, err
}
defer rows.Close()
var existingGuilds []uint64
for rows.Next() {
var guildId uint64
if err := rows.Scan(&guildId); err != nil {
return nil, err
}
existingGuilds = append(existingGuilds, guildId)
}
return existingGuilds, nil
}

View File

@ -1,30 +1,34 @@
package api
import (
"errors"
"fmt"
"github.com/TicketsBot/GoPanel/app/http/session"
"github.com/TicketsBot/GoPanel/config"
"github.com/TicketsBot/GoPanel/redis"
wrapper "github.com/TicketsBot/GoPanel/redis"
"github.com/TicketsBot/GoPanel/utils"
"github.com/TicketsBot/GoPanel/utils/discord"
"github.com/gin-gonic/gin"
"github.com/rxdn/gdl/rest"
"github.com/rxdn/gdl/rest/request"
"net/http"
"time"
)
func ReloadGuildsHandler(ctx *gin.Context) {
userId := ctx.Keys["userid"].(uint64)
func ReloadGuildsHandler(c *gin.Context) {
userId := c.Keys["userid"].(uint64)
key := fmt.Sprintf("tickets:dashboard:guildreload:%d", userId)
res, err := redis.Client.SetNX(wrapper.DefaultContext(), key, 1, time.Second*10).Result()
if err != nil {
ctx.JSON(500, utils.ErrorJson(err))
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
if !res {
ttl, err := redis.Client.TTL(wrapper.DefaultContext(), key).Result()
if err != nil {
ctx.JSON(500, utils.ErrorJson(err))
c.JSON(500, utils.ErrorJson(err))
return
}
@ -33,19 +37,19 @@ func ReloadGuildsHandler(ctx *gin.Context) {
ttl = 0
}
ctx.JSON(429, utils.ErrorStr("You're doing this too quickly: try again in %d seconds", int(ttl.Seconds())))
c.JSON(429, utils.ErrorStr("You're doing this too quickly: try again in %d seconds", int(ttl.Seconds())))
return
}
store, err := session.Store.Get(userId)
if err != nil {
if err == session.ErrNoSession {
ctx.JSON(401, gin.H{
c.JSON(401, gin.H{
"success": false,
"auth": true,
})
} else {
ctx.JSON(500, utils.ErrorJson(err))
_ = c.AbortWithError(http.StatusInternalServerError, err)
}
return
@ -53,9 +57,9 @@ func ReloadGuildsHandler(ctx *gin.Context) {
// What does this do?
if store.Expiry > time.Now().Unix() {
res, err := discord.RefreshToken(store.RefreshToken)
res, err := rest.RefreshToken(c, nil, config.Conf.Oauth.Id, config.Conf.Oauth.Secret, store.RefreshToken)
if err != nil { // Tell client to re-authenticate
ctx.JSON(200, gin.H{
c.JSON(200, gin.H{
"success": false,
"reauthenticate_required": true,
})
@ -67,20 +71,31 @@ func ReloadGuildsHandler(ctx *gin.Context) {
store.Expiry = time.Now().Unix() + int64(res.ExpiresIn)
if err := session.Store.Set(userId, store); err != nil {
ctx.JSON(500, utils.ErrorJson(err))
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
}
if err := utils.LoadGuilds(store.AccessToken, userId); err != nil {
// TODO: Log to sentry
guilds, err := utils.LoadGuilds(c, store.AccessToken, userId)
if err != nil {
var oauthError request.OAuthError
if errors.As(err, &oauthError) {
if oauthError.ErrorCode == "invalid_grant" {
// Tell client to reauth, needs a 200 or client will display error
ctx.JSON(200, gin.H{
c.JSON(http.StatusOK, gin.H{
"success": false,
"reauthenticate_required": true,
})
return
}
}
ctx.JSON(200, utils.SuccessResponse)
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.JSON(200, gin.H{
"success": true,
"guilds": guilds,
})
}

View File

@ -2,54 +2,53 @@ package root
import (
"context"
"errors"
"fmt"
"github.com/TicketsBot/GoPanel/app"
"github.com/TicketsBot/GoPanel/app/http/session"
"github.com/TicketsBot/GoPanel/config"
"github.com/TicketsBot/GoPanel/utils"
"github.com/TicketsBot/GoPanel/utils/discord"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt"
"github.com/rxdn/gdl/rest"
"github.com/rxdn/gdl/rest/request"
"net/http"
"strconv"
"strings"
"time"
)
type (
TokenData struct {
ClientId string `qs:"client_id"`
ClientSecret string `qs:"client_secret"`
GrantType string `qs:"grant_type"`
Code string `qs:"code"`
RedirectUri string `qs:"redirect_uri"`
Scope string `qs:"scope"`
}
TokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
Scope string `json:"scope"`
}
)
func CallbackHandler(ctx *gin.Context) {
code, ok := ctx.GetQuery("code")
func CallbackHandler(c *gin.Context) {
code, ok := c.GetQuery("code")
if !ok {
ctx.JSON(400, utils.ErrorStr("Discord provided invalid Oauth2 code"))
c.JSON(400, utils.ErrorStr("Missing code query parameter"))
return
}
res, err := discord.AccessToken(code)
res, err := rest.ExchangeCode(c, nil, config.Conf.Oauth.Id, config.Conf.Oauth.Secret, config.Conf.Oauth.RedirectUri, code)
if err != nil {
ctx.JSON(500, utils.ErrorJson(err))
var oauthError request.OAuthError
if errors.As(err, &oauthError) {
if oauthError.ErrorCode == "invalid_grant" {
c.JSON(400, utils.ErrorStr("Invalid code: try logging in again"))
return
}
}
_ = c.AbortWithError(http.StatusInternalServerError, app.NewServerError(err))
return
}
scopes := strings.Split(res.Scope, " ")
if !utils.Contains(scopes, "identify") {
c.JSON(400, utils.ErrorStr("Missing identify scope"))
return
}
// Get ID + name
currentUser, err := rest.GetCurrentUser(context.Background(), fmt.Sprintf("Bearer %s", res.AccessToken), nil)
if err != nil {
ctx.JSON(500, utils.ErrorJson(err))
_ = c.AbortWithError(http.StatusInternalServerError, app.NewServerError(err))
return
}
@ -62,31 +61,47 @@ func CallbackHandler(ctx *gin.Context) {
HasGuilds: false,
}
if err := utils.LoadGuilds(res.AccessToken, currentUser.Id); err == nil {
store.HasGuilds = true
} else {
ctx.JSON(500, utils.ErrorJson(err))
var guilds []utils.GuildDto
if utils.Contains(scopes, "guilds") {
guilds, err = utils.LoadGuilds(c, res.AccessToken, currentUser.Id)
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, app.NewServerError(err))
return
}
store.HasGuilds = true
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"userid": strconv.FormatUint(currentUser.Id, 10),
"timestamp": time.Now(),
"sub": strconv.FormatUint(currentUser.Id, 10),
"iat": time.Now().Unix(),
})
str, err := token.SignedString([]byte(config.Conf.Server.Secret))
if err != nil {
ctx.JSON(500, utils.ErrorJson(err))
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
if err := session.Store.Set(currentUser.Id, store); err != nil {
ctx.JSON(500, utils.ErrorJson(err))
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
ctx.JSON(200, gin.H{
resMap := gin.H{
"success": true,
"token": str,
})
"user_data": gin.H{
"id": strconv.FormatUint(currentUser.Id, 10),
"username": currentUser.Username,
"avatar": currentUser.Avatar,
"admin": utils.Contains(config.Conf.Admins, currentUser.Id),
},
}
if len(guilds) > 0 {
resMap["guilds"] = guilds
}
c.JSON(http.StatusOK, resMap)
}

View File

@ -36,6 +36,7 @@ func ErrorHandler(c *gin.Context) {
message = "An error occurred processing your request"
}
c.Writer = cw.ResponseWriter
c.JSON(-1, ErrorResponse{
Error: message,
})
@ -44,6 +45,8 @@ func ErrorHandler(c *gin.Context) {
}
if c.Writer.Status() >= 500 {
c.Writer = cw.ResponseWriter
c.JSON(-1, ErrorResponse{
Error: "An internal server error occurred",
})

View File

@ -196,7 +196,6 @@ func StartServer(logger *zap.Logger, sm *livechat.SocketManager) {
userGroup := router.Group("/user", middleware.AuthenticateToken, middleware.UpdateLastSeen)
{
userGroup.GET("/guilds", api.GetGuilds)
userGroup.POST("/guilds/reload", api.ReloadGuildsHandler)
userGroup.GET("/permissionlevel", api.GetPermissionLevel)

View File

@ -1,6 +1,6 @@
<script context="module">
import axios from 'axios';
import {API_URL, OAUTH} from "../js/constants";
import {OAUTH} from "../js/constants";
const _tokenKey = 'token';
@ -31,17 +31,19 @@
axios.defaults.headers.common['Authorization'] = getToken();
axios.defaults.headers.common['x-tickets'] = 'true'; // arbitrary header name and value
axios.defaults.validateStatus = (s) => true;
addRefreshInterceptor();
}
function addRefreshInterceptor() {
axios.interceptors.response.use(async (res) => { // we set validateStatus to false
if (res.status === 401) {
await _refreshToken();
redirectLogin();
}
return res;
}, async (err) => {
if (err.response.status === 401) {
await _refreshToken();
redirectLogin();
}
return err.response;
});

View File

@ -1,11 +1,17 @@
<script>
import {Navigate} from 'svelte-router-spa'
import {getAvatarUrl, getDefaultIcon} from "../js/icons";
export let name;
export let avatar;
export let userData;
let hasFailed = false;
function handleAvatarLoadError(e, userId) {
if (!hasFailed) {
hasFailed = true;
e.target.src = getDefaultIcon(userId);
}
}
export let isWhitelabel = false;
export let isAdmin = false;
</script>
<div class="sidebar">
@ -17,22 +23,13 @@
<span class="sidebar-text">Servers</span>
</div>
</Navigate>
{#if isWhitelabel}
<Navigate to="/whitelabel" styles="sidebar-link">
<div class="sidebar-element">
<i class="fas fa-edit sidebar-icon"></i>
<span class="sidebar-text">Whitelabel</span>
</div>
</Navigate>
{:else}
<a href="https://ticketsbot.net/premium" class="sidebar-link">
<div class="sidebar-element">
<i class="fas fa-edit sidebar-icon"></i>
<span class="sidebar-text">Whitelabel</span>
</div>
</a>
{/if}
{#if isAdmin}
{#if userData.admin}
<Navigate to="/admin/bot-staff" styles="sidebar-link">
<div class="sidebar-element">
<i class="fa-solid fa-user-secret sidebar-icon"></i>
@ -51,10 +48,10 @@
</div>
<div class="sidebar-element user-element">
<a class="sidebar-link">
<i id="avatar-sidebar" style="background: url('{avatar}') center center;"></i>
{#if name !== undefined}
<span class="sidebar-text">{name}</span>
{/if}
<img class="avatar" src={getAvatarUrl(userData.id, userData.avatar)}
on:error={(e) => handleAvatarLoadError(e, userData.id)} alt="Avatar"/>
<span class="sidebar-text">{userData.username}</span>
</a>
</div>
</div>
@ -133,7 +130,7 @@
margin: 0 !important
}
#avatar-sidebar {
.avatar {
width: 32px;
height: 32px;
display: block;

View File

@ -23,37 +23,24 @@
import {loadingScreen} from "../js/stores"
import {redirectLogin, setDefaultHeaders} from '../includes/Auth.svelte'
import AdminSidebar from "../includes/AdminSidebar.svelte";
import {onMount} from "svelte";
export let currentRoute;
export let params = {};
setDefaultHeaders()
setDefaultHeaders();
let name;
let avatar;
let isWhitelabel = false;
onMount(() => {
let isAdmin = false;
async function loadData() {
const res = await axios.get(`${API_URL}/api/session`);
if (res.status !== 200) {
if (res.data.auth === true) {
redirectLogin();
}
notifyError(res.data.error);
return;
}
isAdmin = res.data.admin;
try {
const userData = JSON.parse(window.localStorage.getItem('user_data'));
isAdmin = userData.admin;
} finally {
if (!isAdmin) {
navigateTo(`/`);
}
}
loadData();
});
</script>
<style>

View File

@ -1,7 +1,7 @@
<Head/>
<div class="wrapper">
<Sidebar {name} {avatar} {isWhitelabel} {isAdmin} />
<Sidebar {userData} />
<div class="super-container">
<LoadingScreen/>
<NotifyModal/>
@ -22,36 +22,26 @@
import {notifyError} from '../js/util'
import {loadingScreen} from "../js/stores"
import {redirectLogin, setDefaultHeaders} from '../includes/Auth.svelte'
import {onMount} from "svelte";
export let currentRoute;
export let params = {};
setDefaultHeaders()
let name;
let avatar;
let userData = {
id: 0,
username: 'Unknown',
avatar: '',
admin: false
};
let isWhitelabel = false;
let isAdmin = false;
async function loadData() {
const res = await axios.get(`${API_URL}/api/session`);
if (res.status !== 200) {
if (res.data.auth === true) {
redirectLogin();
onMount(() => {
const retrieved = window.localStorage.getItem('user_data');
if (retrieved) {
userData = JSON.parse(retrieved);
}
notifyError(res.data.error);
return;
}
name = res.data.username;
avatar = res.data.avatar;
isWhitelabel = res.data.whitelabel;
isAdmin = res.data.admin;
}
loadData();
});
</script>
<style>

View File

@ -38,27 +38,7 @@
setDefaultHeaders();
export let guilds = [];
async function loadData() {
const res = await axios.get(`${API_URL}/user/guilds`);
if (res.status !== 200) {
notifyError(res.data.error);
return;
}
guilds = res.data;
permissionLevelCache.update(cache => {
for (const guild of guilds) {
cache[guild.id] = {
permission_level: guild.permission_level,
last_updated: new Date(),
};
}
return cache;
})
}
let guilds = window.localStorage.getItem('guilds') ? JSON.parse(window.localStorage.getItem('guilds')) : [];
async function refreshGuilds() {
await withLoadingScreen(async () => {
@ -73,13 +53,12 @@
return;
}
await loadData();
guilds = res.data.guilds;
window.localStorage.setItem('guilds', JSON.stringify(guilds));
});
}
withLoadingScreen(async () => {
await loadData();
});
withLoadingScreen(() => {});
</script>
<style>

View File

@ -23,6 +23,10 @@
}
setToken(res.data.token);
window.localStorage.setItem('user_data', JSON.stringify(res.data.user_data));
if (res.data.guilds) {
window.localStorage.setItem('guilds', JSON.stringify(res.data.guilds));
}
let path = '/';
@ -33,6 +37,11 @@
if (path === '/callback') {
path = '/';
}
try {
new URL(path);
path = '/';
} catch (e) {}
}
} catch (e) {
console.log(`Error parsing state: ${e}`)

View File

@ -18,7 +18,7 @@
<tbody>
{#each staff as user}
<tr>
<td>{user.username}#{user.discriminator} ({user.id})</td>
<td>{user.username} ({user.id})</td>
<td>
<Button type="button" danger on:click={() => removeStaff(user.id)}>
Delete

View File

@ -141,13 +141,13 @@
}
async function loadGuilds() {
const res = await axios.get(`${API_URL}/user/guilds`)
if (res.status !== 200) {
notifyError(`Failed to load guilds: ${res.data.error}`)
const fromLocalStorage = window.localStorage.getItem('guilds');
if (!fromLocalStorage) {
notifyError('Failed to load guilds from local storage.');
return;
}
guilds = [...guilds, ...res.data];
guilds = [...guilds, ...JSON.parse(fromLocalStorage)];
}
async function submitServers() {

4
go.mod
View File

@ -8,7 +8,7 @@ require (
github.com/BurntSushi/toml v1.2.1
github.com/TicketsBot/archiverclient v0.0.0-20241012221057-16a920bfb454
github.com/TicketsBot/common v0.0.0-20241104184641-e39c64bdcf3e
github.com/TicketsBot/database v0.0.0-20241110235041-04a08e1a42a4
github.com/TicketsBot/database v0.0.0-20241113215509-c84281e50a7e
github.com/TicketsBot/logarchiver v0.0.0-20241012220745-5f3ba17a5138
github.com/TicketsBot/worker v0.0.0-20241110222533-ba74e19de868
github.com/apex/log v1.1.2
@ -29,7 +29,7 @@ require (
github.com/penglongli/gin-metrics v0.1.10
github.com/pkg/errors v0.9.1
github.com/prometheus/client_golang v1.14.0
github.com/rxdn/gdl v0.0.0-20241027214923-02dff700595b
github.com/rxdn/gdl v0.0.0-20241113224447-d578afa35bd3
github.com/sirupsen/logrus v1.9.3
github.com/stretchr/testify v1.9.0
github.com/weppos/publicsuffix-go v0.20.0

4
go.sum
View File

@ -49,6 +49,8 @@ github.com/TicketsBot/common v0.0.0-20241104184641-e39c64bdcf3e h1:cYfBjPX/FhD/M
github.com/TicketsBot/common v0.0.0-20241104184641-e39c64bdcf3e/go.mod h1:N7zwetwx8B3RK/ZajWwMroJSyv2ZJ+bIOZWv/z8DhaM=
github.com/TicketsBot/database v0.0.0-20241110235041-04a08e1a42a4 h1:9YMD+x2nBQN8SimrrveijYM1f7PSZ92J3h7UqgSeZfs=
github.com/TicketsBot/database v0.0.0-20241110235041-04a08e1a42a4/go.mod h1:mpVkDO8tnnWn1pMGEphVg6YSeGIhDwLAN43lBTkpGmU=
github.com/TicketsBot/database v0.0.0-20241113215509-c84281e50a7e h1:8nvgWvmFAJfjuAUhaGdOO9y7puPAOHRjgGWn+/DI/HA=
github.com/TicketsBot/database v0.0.0-20241113215509-c84281e50a7e/go.mod h1:mpVkDO8tnnWn1pMGEphVg6YSeGIhDwLAN43lBTkpGmU=
github.com/TicketsBot/logarchiver v0.0.0-20241012220745-5f3ba17a5138 h1:wsR5ESeaQKo122qsmzPcblxlJdE0GIQbp2B/7/uX+TA=
github.com/TicketsBot/logarchiver v0.0.0-20241012220745-5f3ba17a5138/go.mod h1:4Rq0CgSCgXVW6uEyEUvWzxOmFp+L57rFfCjPDFPHFiw=
github.com/TicketsBot/ttlcache v1.6.1-0.20200405150101-acc18e37b261 h1:NHD5GB6cjlkpZFjC76Yli2S63/J2nhr8MuE6KlYJpQM=
@ -489,6 +491,8 @@ github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OK
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
github.com/rxdn/gdl v0.0.0-20241027214923-02dff700595b h1:vSQ8iR4vrrDNchF24oxKMYbdO2D/2HKNqQkx8+v2ZMY=
github.com/rxdn/gdl v0.0.0-20241027214923-02dff700595b/go.mod h1:hDxVWVHzvsO3Mt9d5KIjMLbm3K91Qgqw3LS0FIUxGVo=
github.com/rxdn/gdl v0.0.0-20241113224447-d578afa35bd3 h1:ScHj6weZ5Eg4OqNH/8VT0VUxquywJ/Y8Ft/nanztbzI=
github.com/rxdn/gdl v0.0.0-20241113224447-d578afa35bd3/go.mod h1:hDxVWVHzvsO3Mt9d5KIjMLbm3K91Qgqw3LS0FIUxGVo=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/schollz/progressbar/v3 v3.8.2 h1:2kZJwZCpb+E/V79kGO7daeq+hUwUJW0A5QD1Wv455dA=
github.com/schollz/progressbar/v3 v3.8.2/go.mod h1:9KHLdyuXczIsyStQwzvW8xiELskmX7fQMaZdN23nAv8=

View File

@ -1,119 +0,0 @@
package discord
import (
"bytes"
"encoding/json"
"github.com/TicketsBot/GoPanel/config"
"github.com/pasztorpisti/qs"
"io/ioutil"
"net/http"
"strconv"
"time"
)
type (
TokenData struct {
ClientId string `qs:"client_id"`
ClientSecret string `qs:"client_secret"`
GrantType string `qs:"grant_type"`
Code string `qs:"code"`
RedirectUri string `qs:"redirect_uri"`
Scope string `qs:"scope"`
}
RefreshData struct {
ClientId string `qs:"client_id"`
ClientSecret string `qs:"client_secret"`
GrantType string `qs:"grant_type"`
RefreshToken string `qs:"refresh_token"`
RedirectUri string `qs:"redirect_uri"`
Scope string `qs:"scope"`
}
TokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
Scope string `json:"scope"`
}
)
const TokenEndpoint = "https://discord.com/api/oauth2/token"
func AccessToken(code string) (TokenResponse, error) {
data := TokenData{
ClientId: strconv.FormatUint(config.Conf.Oauth.Id, 10),
ClientSecret: config.Conf.Oauth.Secret,
GrantType: "authorization_code",
Code: code,
RedirectUri: config.Conf.Oauth.RedirectUri,
Scope: "identify guilds",
}
res, err := tokenPost(data)
if err != nil {
return TokenResponse{}, err
}
var unmarshalled TokenResponse
if err = json.Unmarshal(res, &unmarshalled); err != nil {
return TokenResponse{}, err
}
return unmarshalled, nil
}
func RefreshToken(refreshToken string) (TokenResponse, error) {
data := RefreshData{
ClientId: strconv.FormatUint(config.Conf.Oauth.Id, 10),
ClientSecret: config.Conf.Oauth.Secret,
GrantType: "refresh_token",
RefreshToken: refreshToken,
RedirectUri: config.Conf.Oauth.RedirectUri,
Scope: "identify guilds",
}
res, err := tokenPost(data)
if err != nil {
return TokenResponse{}, err
}
var unmarshalled TokenResponse
if err = json.Unmarshal(res, &unmarshalled); err != nil {
return TokenResponse{}, err
}
return unmarshalled, nil
}
func tokenPost(body ...interface{}) ([]byte, error) {
str, err := qs.Marshal(body[0])
if err != nil {
return nil, err
}
encoded := []byte(str)
req, err := http.NewRequest("POST", TokenEndpoint, bytes.NewBuffer([]byte(encoded)))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", string(ApplicationFormUrlEncoded))
client := &http.Client{}
client.Timeout = 3 * time.Second
res, err := client.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
content, err := ioutil.ReadAll(res.Body)
if err != nil {
return nil, err
}
return content, nil
}

View File

@ -1,121 +0,0 @@
package discord
import (
"bytes"
"encoding/json"
"github.com/TicketsBot/GoPanel/config"
"github.com/gin-gonic/contrib/sessions"
"github.com/pasztorpisti/qs"
"github.com/pkg/errors"
"io/ioutil"
"net/http"
"time"
)
type RequestType string
type ContentType string
type AuthorizationType string
const (
GET RequestType = "GET"
POST RequestType = "POST"
PATCH RequestType = "PATCH"
BEARER AuthorizationType = "Bearer"
BOT AuthorizationType = "BOT"
NONE AuthorizationType = "NONE"
ApplicationJson ContentType = "application/json"
ApplicationFormUrlEncoded ContentType = "application/x-www-form-urlencoded"
BASE_URL = "https://discordapp.com/api/v8"
)
type Endpoint struct {
RequestType RequestType
AuthorizationType AuthorizationType
Endpoint string
}
func (e *Endpoint) Request(store sessions.Session, contentType *ContentType, body interface{}, response interface{}) (error, *http.Response) {
url := BASE_URL + e.Endpoint
// Create req
var req *http.Request
var err error
if body == nil || contentType == nil {
req, err = http.NewRequest(string(e.RequestType), url, nil)
} else {
// Encode body
var encoded []byte
if *contentType == ApplicationJson {
raw, err := json.Marshal(body)
if err != nil {
return err, nil
}
encoded = raw
} else if *contentType == ApplicationFormUrlEncoded {
str, err := qs.Marshal(body)
if err != nil {
return err, nil
}
encoded = []byte(str)
}
// Create req
req, err = http.NewRequest(string(e.RequestType), url, bytes.NewBuffer(encoded))
}
if err != nil {
return err, nil
}
// Set content type and user agent
if contentType != nil {
req.Header.Set("Content-Type", string(*contentType))
}
req.Header.Set("User-Agent", "DiscordBot (https://github.com/TicketsBot/GoPanel, 1.0.0)")
// Auth
accessToken := store.Get("access_token").(string)
expiry := store.Get("expiry").(int64)
refreshToken := store.Get("refresh_token").(string)
// Check if needs refresh
if (time.Now().UnixNano() / int64(time.Second)) > expiry {
res, err := RefreshToken(refreshToken)
if err != nil {
store.Clear()
_ = store.Save()
return errors.New("Please login again!"), nil
}
store.Set("access_token", res.AccessToken)
store.Set("expiry", (time.Now().UnixNano()/int64(time.Second))+int64(res.ExpiresIn))
store.Set("refresh_token", res.RefreshToken)
accessToken = res.AccessToken
}
switch e.AuthorizationType {
case BEARER:
req.Header.Set("Authorization", "Bearer "+accessToken)
case BOT:
req.Header.Set("Authorization", "Bot "+config.Conf.Bot.Token)
}
client := &http.Client{}
client.Timeout = 3 * time.Second
res, err := client.Do(req)
if err != nil {
return err, nil
}
defer res.Body.Close()
content, err := ioutil.ReadAll(res.Body)
if err != nil {
return err, nil
}
return json.Unmarshal(content, response), res
}

View File

@ -1,9 +0,0 @@
package user
import "github.com/TicketsBot/GoPanel/utils/discord"
var CurrentUser = discord.Endpoint{
RequestType: discord.GET,
AuthorizationType: discord.BEARER,
Endpoint: "/users/@me",
}

View File

@ -1,9 +0,0 @@
package user
import "github.com/TicketsBot/GoPanel/utils/discord"
var CurrentUserGuilds = discord.Endpoint{
RequestType: discord.GET,
AuthorizationType: discord.BEARER,
Endpoint: "/users/@me/guilds",
}

View File

@ -1,29 +1,96 @@
package utils
import (
"cmp"
"context"
"fmt"
"github.com/TicketsBot/GoPanel/app"
dbclient "github.com/TicketsBot/GoPanel/database"
"github.com/TicketsBot/GoPanel/rpc/cache"
"github.com/TicketsBot/common/collections"
"github.com/TicketsBot/common/permission"
"github.com/TicketsBot/database"
"github.com/jackc/pgtype"
"github.com/rxdn/gdl/objects/guild"
"github.com/rxdn/gdl/rest"
errgroup "golang.org/x/sync/errgroup"
"slices"
"sync"
)
func LoadGuilds(accessToken string, userId uint64) error {
type GuildDto struct {
Id uint64 `json:"id,string"`
Name string `json:"name"`
Icon string `json:"icon"`
PermissionLevel permission.PermissionLevel `json:"permission_level"`
}
func LoadGuilds(ctx context.Context, accessToken string, userId uint64) ([]GuildDto, error) {
authHeader := fmt.Sprintf("Bearer %s", accessToken)
data := rest.CurrentUserGuildsData{
Limit: 200,
}
ctx, cancel := context.WithTimeout(context.Background(), app.DefaultTimeout)
defer cancel()
guilds, err := rest.GetCurrentUserGuilds(ctx, authHeader, nil, data)
if err != nil {
return nil, err
}
if err := storeGuildsInDb(ctx, userId, guilds); err != nil {
return nil, err
}
userGuilds, err := getGuildIntersection(ctx, userId, guilds)
if err != nil {
return nil, err
}
group, ctx := errgroup.WithContext(ctx)
var mu sync.Mutex
dtos := make([]GuildDto, 0, len(userGuilds))
for _, guild := range userGuilds {
guild := guild
group.Go(func() error {
permLevel, err := GetPermissionLevel(ctx, guild.Id, userId)
if err != nil {
return err
}
mu.Lock()
dtos = append(dtos, GuildDto{
Id: guild.Id,
Name: guild.Name,
Icon: guild.Icon,
PermissionLevel: permLevel,
})
mu.Unlock()
return nil
})
}
if err := group.Wait(); err != nil {
return nil, err
}
// Sort the guilds by name, but put the guilds with permission_level=0 last
slices.SortFunc(dtos, func(a, b GuildDto) int {
if a.PermissionLevel == 0 && b.PermissionLevel > 0 {
return 1
} else if a.PermissionLevel > 0 && b.PermissionLevel == 0 {
return -1
}
return cmp.Compare(a.Name, b.Name)
})
return dtos, nil
}
// TODO: Remove this function!
func storeGuildsInDb(ctx context.Context, userId uint64, guilds []guild.Guild) error {
var wrappedGuilds []database.UserGuild
// endpoint's partial guild doesn't includes ownerid
@ -39,5 +106,61 @@ func LoadGuilds(accessToken string, userId uint64) error {
})
}
return dbclient.Client.UserGuilds.Set(context.Background(), userId, wrappedGuilds)
return dbclient.Client.UserGuilds.Set(ctx, userId, wrappedGuilds)
}
func getGuildIntersection(ctx context.Context, userId uint64, userGuilds []guild.Guild) ([]guild.Guild, error) {
guildIds := make([]uint64, len(userGuilds))
for i, guild := range userGuilds {
guildIds[i] = guild.Id
}
// Restrict the set of guilds to guilds that the bot is also in
botGuilds, err := getExistingGuilds(ctx, guildIds)
if err != nil {
return nil, err
}
botGuildIds := collections.NewSet[uint64]()
for _, guildId := range botGuilds {
botGuildIds.Add(guildId)
}
// Get the intersection of the two sets
intersection := make([]guild.Guild, 0, len(botGuilds))
for _, guild := range userGuilds {
if botGuildIds.Contains(guild.Id) {
intersection = append(intersection, guild)
}
}
return intersection, nil
}
func getExistingGuilds(ctx context.Context, userGuilds []uint64) ([]uint64, error) {
query := `SELECT "guild_id" from guilds WHERE "guild_id" = ANY($1);`
userGuildsArray := &pgtype.Int8Array{}
if err := userGuildsArray.Set(userGuilds); err != nil {
return nil, err
}
rows, err := cache.Instance.Query(ctx, query, userGuildsArray)
if err != nil {
return nil, err
}
defer rows.Close()
var existingGuilds []uint64
for rows.Next() {
var guildId uint64
if err := rows.Scan(&guildId); err != nil {
return nil, err
}
existingGuilds = append(existingGuilds, guildId)
}
return existingGuilds, nil
}