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 package api
import ( import (
"errors"
"fmt" "fmt"
"github.com/TicketsBot/GoPanel/app/http/session" "github.com/TicketsBot/GoPanel/app/http/session"
"github.com/TicketsBot/GoPanel/config"
"github.com/TicketsBot/GoPanel/redis" "github.com/TicketsBot/GoPanel/redis"
wrapper "github.com/TicketsBot/GoPanel/redis" wrapper "github.com/TicketsBot/GoPanel/redis"
"github.com/TicketsBot/GoPanel/utils" "github.com/TicketsBot/GoPanel/utils"
"github.com/TicketsBot/GoPanel/utils/discord"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/rxdn/gdl/rest"
"github.com/rxdn/gdl/rest/request"
"net/http"
"time" "time"
) )
func ReloadGuildsHandler(ctx *gin.Context) { func ReloadGuildsHandler(c *gin.Context) {
userId := ctx.Keys["userid"].(uint64) userId := c.Keys["userid"].(uint64)
key := fmt.Sprintf("tickets:dashboard:guildreload:%d", userId) key := fmt.Sprintf("tickets:dashboard:guildreload:%d", userId)
res, err := redis.Client.SetNX(wrapper.DefaultContext(), key, 1, time.Second*10).Result() res, err := redis.Client.SetNX(wrapper.DefaultContext(), key, 1, time.Second*10).Result()
if err != nil { if err != nil {
ctx.JSON(500, utils.ErrorJson(err)) _ = c.AbortWithError(http.StatusInternalServerError, err)
return return
} }
if !res { if !res {
ttl, err := redis.Client.TTL(wrapper.DefaultContext(), key).Result() ttl, err := redis.Client.TTL(wrapper.DefaultContext(), key).Result()
if err != nil { if err != nil {
ctx.JSON(500, utils.ErrorJson(err)) c.JSON(500, utils.ErrorJson(err))
return return
} }
@ -33,19 +37,19 @@ func ReloadGuildsHandler(ctx *gin.Context) {
ttl = 0 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 return
} }
store, err := session.Store.Get(userId) store, err := session.Store.Get(userId)
if err != nil { if err != nil {
if err == session.ErrNoSession { if err == session.ErrNoSession {
ctx.JSON(401, gin.H{ c.JSON(401, gin.H{
"success": false, "success": false,
"auth": true, "auth": true,
}) })
} else { } else {
ctx.JSON(500, utils.ErrorJson(err)) _ = c.AbortWithError(http.StatusInternalServerError, err)
} }
return return
@ -53,9 +57,9 @@ func ReloadGuildsHandler(ctx *gin.Context) {
// What does this do? // What does this do?
if store.Expiry > time.Now().Unix() { 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 if err != nil { // Tell client to re-authenticate
ctx.JSON(200, gin.H{ c.JSON(200, gin.H{
"success": false, "success": false,
"reauthenticate_required": true, "reauthenticate_required": true,
}) })
@ -67,20 +71,31 @@ func ReloadGuildsHandler(ctx *gin.Context) {
store.Expiry = time.Now().Unix() + int64(res.ExpiresIn) store.Expiry = time.Now().Unix() + int64(res.ExpiresIn)
if err := session.Store.Set(userId, store); err != nil { if err := session.Store.Set(userId, store); err != nil {
ctx.JSON(500, utils.ErrorJson(err)) _ = c.AbortWithError(http.StatusInternalServerError, err)
return return
} }
} }
if err := utils.LoadGuilds(store.AccessToken, userId); err != nil { guilds, err := utils.LoadGuilds(c, store.AccessToken, userId)
// TODO: Log to sentry 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 // 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, "success": false,
"reauthenticate_required": true, "reauthenticate_required": true,
}) })
return 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 ( import (
"context" "context"
"errors"
"fmt" "fmt"
"github.com/TicketsBot/GoPanel/app"
"github.com/TicketsBot/GoPanel/app/http/session" "github.com/TicketsBot/GoPanel/app/http/session"
"github.com/TicketsBot/GoPanel/config" "github.com/TicketsBot/GoPanel/config"
"github.com/TicketsBot/GoPanel/utils" "github.com/TicketsBot/GoPanel/utils"
"github.com/TicketsBot/GoPanel/utils/discord"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt" "github.com/golang-jwt/jwt"
"github.com/rxdn/gdl/rest" "github.com/rxdn/gdl/rest"
"github.com/rxdn/gdl/rest/request"
"net/http"
"strconv" "strconv"
"strings"
"time" "time"
) )
type ( func CallbackHandler(c *gin.Context) {
TokenData struct { code, ok := c.GetQuery("code")
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")
if !ok { if !ok {
ctx.JSON(400, utils.ErrorStr("Discord provided invalid Oauth2 code")) c.JSON(400, utils.ErrorStr("Missing code query parameter"))
return 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 { 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 return
} }
// Get ID + name // Get ID + name
currentUser, err := rest.GetCurrentUser(context.Background(), fmt.Sprintf("Bearer %s", res.AccessToken), nil) currentUser, err := rest.GetCurrentUser(context.Background(), fmt.Sprintf("Bearer %s", res.AccessToken), nil)
if err != nil { if err != nil {
ctx.JSON(500, utils.ErrorJson(err)) _ = c.AbortWithError(http.StatusInternalServerError, app.NewServerError(err))
return return
} }
@ -62,31 +61,47 @@ func CallbackHandler(ctx *gin.Context) {
HasGuilds: false, HasGuilds: false,
} }
if err := utils.LoadGuilds(res.AccessToken, currentUser.Id); err == nil { var guilds []utils.GuildDto
store.HasGuilds = true if utils.Contains(scopes, "guilds") {
} else { guilds, err = utils.LoadGuilds(c, res.AccessToken, currentUser.Id)
ctx.JSON(500, utils.ErrorJson(err)) if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, app.NewServerError(err))
return return
} }
store.HasGuilds = true
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"userid": strconv.FormatUint(currentUser.Id, 10), "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)) str, err := token.SignedString([]byte(config.Conf.Server.Secret))
if err != nil { if err != nil {
ctx.JSON(500, utils.ErrorJson(err)) _ = c.AbortWithError(http.StatusInternalServerError, err)
return return
} }
if err := session.Store.Set(currentUser.Id, store); err != nil { if err := session.Store.Set(currentUser.Id, store); err != nil {
ctx.JSON(500, utils.ErrorJson(err)) _ = c.AbortWithError(http.StatusInternalServerError, err)
return return
} }
ctx.JSON(200, gin.H{ resMap := gin.H{
"success": true, "success": true,
"token": str, "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" message = "An error occurred processing your request"
} }
c.Writer = cw.ResponseWriter
c.JSON(-1, ErrorResponse{ c.JSON(-1, ErrorResponse{
Error: message, Error: message,
}) })
@ -44,6 +45,8 @@ func ErrorHandler(c *gin.Context) {
} }
if c.Writer.Status() >= 500 { if c.Writer.Status() >= 500 {
c.Writer = cw.ResponseWriter
c.JSON(-1, ErrorResponse{ c.JSON(-1, ErrorResponse{
Error: "An internal server error occurred", 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 := router.Group("/user", middleware.AuthenticateToken, middleware.UpdateLastSeen)
{ {
userGroup.GET("/guilds", api.GetGuilds)
userGroup.POST("/guilds/reload", api.ReloadGuildsHandler) userGroup.POST("/guilds/reload", api.ReloadGuildsHandler)
userGroup.GET("/permissionlevel", api.GetPermissionLevel) userGroup.GET("/permissionlevel", api.GetPermissionLevel)

View File

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

View File

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

View File

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

View File

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

View File

@ -38,27 +38,7 @@
setDefaultHeaders(); setDefaultHeaders();
export let guilds = []; let guilds = window.localStorage.getItem('guilds') ? JSON.parse(window.localStorage.getItem('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;
})
}
async function refreshGuilds() { async function refreshGuilds() {
await withLoadingScreen(async () => { await withLoadingScreen(async () => {
@ -73,13 +53,12 @@
return; return;
} }
await loadData(); guilds = res.data.guilds;
window.localStorage.setItem('guilds', JSON.stringify(guilds));
}); });
} }
withLoadingScreen(async () => { withLoadingScreen(() => {});
await loadData();
});
</script> </script>
<style> <style>

View File

@ -23,6 +23,10 @@
} }
setToken(res.data.token); 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 = '/'; let path = '/';
@ -33,6 +37,11 @@
if (path === '/callback') { if (path === '/callback') {
path = '/'; path = '/';
} }
try {
new URL(path);
path = '/';
} catch (e) {}
} }
} catch (e) { } catch (e) {
console.log(`Error parsing state: ${e}`) console.log(`Error parsing state: ${e}`)

View File

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

View File

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

4
go.mod
View File

@ -8,7 +8,7 @@ require (
github.com/BurntSushi/toml v1.2.1 github.com/BurntSushi/toml v1.2.1
github.com/TicketsBot/archiverclient v0.0.0-20241012221057-16a920bfb454 github.com/TicketsBot/archiverclient v0.0.0-20241012221057-16a920bfb454
github.com/TicketsBot/common v0.0.0-20241104184641-e39c64bdcf3e 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/logarchiver v0.0.0-20241012220745-5f3ba17a5138
github.com/TicketsBot/worker v0.0.0-20241110222533-ba74e19de868 github.com/TicketsBot/worker v0.0.0-20241110222533-ba74e19de868
github.com/apex/log v1.1.2 github.com/apex/log v1.1.2
@ -29,7 +29,7 @@ require (
github.com/penglongli/gin-metrics v0.1.10 github.com/penglongli/gin-metrics v0.1.10
github.com/pkg/errors v0.9.1 github.com/pkg/errors v0.9.1
github.com/prometheus/client_golang v1.14.0 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/sirupsen/logrus v1.9.3
github.com/stretchr/testify v1.9.0 github.com/stretchr/testify v1.9.0
github.com/weppos/publicsuffix-go v0.20.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/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 h1:9YMD+x2nBQN8SimrrveijYM1f7PSZ92J3h7UqgSeZfs=
github.com/TicketsBot/database v0.0.0-20241110235041-04a08e1a42a4/go.mod h1:mpVkDO8tnnWn1pMGEphVg6YSeGIhDwLAN43lBTkpGmU= 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 h1:wsR5ESeaQKo122qsmzPcblxlJdE0GIQbp2B/7/uX+TA=
github.com/TicketsBot/logarchiver v0.0.0-20241012220745-5f3ba17a5138/go.mod h1:4Rq0CgSCgXVW6uEyEUvWzxOmFp+L57rFfCjPDFPHFiw= 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= 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/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 h1:vSQ8iR4vrrDNchF24oxKMYbdO2D/2HKNqQkx8+v2ZMY=
github.com/rxdn/gdl v0.0.0-20241027214923-02dff700595b/go.mod h1:hDxVWVHzvsO3Mt9d5KIjMLbm3K91Qgqw3LS0FIUxGVo= 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/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 h1:2kZJwZCpb+E/V79kGO7daeq+hUwUJW0A5QD1Wv455dA=
github.com/schollz/progressbar/v3 v3.8.2/go.mod h1:9KHLdyuXczIsyStQwzvW8xiELskmX7fQMaZdN23nAv8= 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 package utils
import ( import (
"cmp"
"context" "context"
"fmt" "fmt"
"github.com/TicketsBot/GoPanel/app"
dbclient "github.com/TicketsBot/GoPanel/database" 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/TicketsBot/database"
"github.com/jackc/pgtype"
"github.com/rxdn/gdl/objects/guild"
"github.com/rxdn/gdl/rest" "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) authHeader := fmt.Sprintf("Bearer %s", accessToken)
data := rest.CurrentUserGuildsData{ data := rest.CurrentUserGuildsData{
Limit: 200, Limit: 200,
} }
ctx, cancel := context.WithTimeout(context.Background(), app.DefaultTimeout)
defer cancel()
guilds, err := rest.GetCurrentUserGuilds(ctx, authHeader, nil, data) 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 { if err != nil {
return err 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 var wrappedGuilds []database.UserGuild
// endpoint's partial guild doesn't includes ownerid // 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
} }