Create interface for selecting premium guilds

This commit is contained in:
rxdn 2024-09-01 17:00:56 +01:00
parent dbdccb2e3d
commit 18e81f4936
7 changed files with 442 additions and 9 deletions

View File

@ -0,0 +1,162 @@
package api
import (
dbclient "github.com/TicketsBot/GoPanel/database"
"github.com/TicketsBot/GoPanel/utils"
"github.com/TicketsBot/GoPanel/utils/types"
"github.com/TicketsBot/common/model"
"github.com/TicketsBot/common/permission"
"github.com/TicketsBot/common/premium"
"github.com/gin-gonic/gin"
"net/http"
)
type setActiveGuildsBody struct {
SelectedGuilds types.UInt64StringSlice `json:"selected_guilds"`
}
func SetActiveGuilds(ctx *gin.Context) {
userId := ctx.Keys["userid"].(uint64)
var body setActiveGuildsBody
if err := ctx.ShouldBindJSON(&body); err != nil {
ctx.JSON(http.StatusBadRequest, utils.ErrorJson(err))
return
}
legacyEntitlement, err := dbclient.Client.LegacyPremiumEntitlements.GetUserTier(ctx, userId, premium.PatreonGracePeriod)
if err != nil {
ctx.JSON(http.StatusInternalServerError, utils.ErrorJson(err))
return
}
if legacyEntitlement == nil || legacyEntitlement.IsLegacy {
ctx.JSON(http.StatusBadRequest, utils.ErrorStr("Not a premium user"))
return
}
tx, err := dbclient.Client.BeginTx(ctx)
if err != nil {
ctx.JSON(http.StatusInternalServerError, utils.ErrorJson(err))
return
}
defer tx.Rollback(ctx)
// Validate under the limit
limit, ok, err := dbclient.Client.MultiServerSkus.GetPermittedServerCount(ctx, tx, legacyEntitlement.SkuId)
if err != nil {
ctx.JSON(http.StatusInternalServerError, utils.ErrorJson(err))
return
}
if !ok {
ctx.JSON(http.StatusBadRequest, utils.ErrorStr("Not a multi-server subscription"))
return
}
if len(body.SelectedGuilds) > limit {
ctx.JSON(http.StatusBadRequest, utils.ErrorStr("Too many guilds selected"))
return
}
// Validate has admin in each server
for _, guildId := range body.SelectedGuilds {
permissionLevel, err := utils.GetPermissionLevel(ctx, guildId, userId)
if err != nil {
ctx.JSON(http.StatusInternalServerError, utils.ErrorJson(err))
return
}
if permissionLevel < permission.Admin {
ctx.JSON(http.StatusForbidden, utils.ErrorStr("Missing permissions in guild %d", guildId))
return
}
}
existingGuildEntitlements, err := dbclient.Client.LegacyPremiumEntitlementGuilds.ListForUser(ctx, tx, userId)
if err != nil {
ctx.JSON(http.StatusInternalServerError, utils.ErrorJson(err))
return
}
// Remove entitlements from guilds that are no longer selected
for _, existingEntitlement := range existingGuildEntitlements {
if !utils.Contains(body.SelectedGuilds, existingEntitlement.GuildId) {
if err := dbclient.Client.LegacyPremiumEntitlementGuilds.DeleteByEntitlement(ctx, tx, existingEntitlement.EntitlementId); err != nil {
ctx.JSON(http.StatusInternalServerError, utils.ErrorJson(err))
return
}
if err := dbclient.Client.Entitlements.DeleteById(ctx, tx, existingEntitlement.EntitlementId); err != nil {
ctx.JSON(http.StatusInternalServerError, utils.ErrorJson(err))
return
}
}
}
// Create entitlements for guilds that were not previously selected, but now are
existingGuildIds := make([]uint64, len(existingGuildEntitlements))
for i, existingEntitlement := range existingGuildEntitlements {
existingGuildIds[i] = existingEntitlement.GuildId
}
for _, guildId := range body.SelectedGuilds {
if !utils.Contains(existingGuildIds, guildId) {
created, err := dbclient.Client.Entitlements.Create(ctx, tx, &guildId, &userId, legacyEntitlement.SkuId, model.EntitlementSourcePatreon, nil)
if err != nil {
ctx.JSON(http.StatusInternalServerError, utils.ErrorJson(err))
return
}
if err := dbclient.Client.LegacyPremiumEntitlementGuilds.Insert(ctx, tx, userId, guildId, created.Id); err != nil {
ctx.JSON(http.StatusInternalServerError, utils.ErrorJson(err))
return
}
}
}
// Update entitlements for guilds that were previously selected and still are. This may involve recreating the
// entitlement if the SKU has changed.
for _, existingEntitlement := range existingGuildEntitlements {
if utils.Contains(body.SelectedGuilds, existingEntitlement.GuildId) {
entitlement, err := dbclient.Client.Entitlements.GetById(ctx, tx, existingEntitlement.EntitlementId)
if err != nil {
ctx.JSON(http.StatusInternalServerError, utils.ErrorJson(err))
return
}
if entitlement == nil {
ctx.JSON(http.StatusInternalServerError, utils.ErrorStr("Entitlement %s not found", existingEntitlement.EntitlementId.String()))
return
}
if entitlement.SkuId == legacyEntitlement.SkuId {
continue
} else {
// If we need to switch the SKU, then delete and recreate the entitlement
if err := dbclient.Client.LegacyPremiumEntitlementGuilds.DeleteByEntitlement(ctx, tx, existingEntitlement.EntitlementId); err != nil {
ctx.JSON(http.StatusInternalServerError, utils.ErrorJson(err))
return
}
if err := dbclient.Client.Entitlements.DeleteById(ctx, tx, existingEntitlement.EntitlementId); err != nil {
ctx.JSON(http.StatusInternalServerError, utils.ErrorJson(err))
return
}
if _, err := dbclient.Client.Entitlements.Create(ctx, tx, &existingEntitlement.GuildId, &userId, legacyEntitlement.SkuId, model.EntitlementSourcePatreon, entitlement.ExpiresAt); err != nil {
ctx.JSON(http.StatusInternalServerError, utils.ErrorJson(err))
return
}
}
}
}
if err := tx.Commit(ctx); err != nil {
ctx.JSON(http.StatusInternalServerError, utils.ErrorJson(err))
return
}
ctx.Status(http.StatusNoContent)
}

View File

@ -0,0 +1,70 @@
package api
import (
dbclient "github.com/TicketsBot/GoPanel/database"
"github.com/TicketsBot/GoPanel/utils"
"github.com/TicketsBot/GoPanel/utils/types"
"github.com/TicketsBot/common/premium"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v4"
"net/http"
)
func GetEntitlements(ctx *gin.Context) {
userId := ctx.Keys["userid"].(uint64)
entitlements, err := dbclient.Client.Entitlements.ListUserSubscriptions(ctx, userId, premium.GracePeriod)
if err != nil {
ctx.JSON(http.StatusInternalServerError, utils.ErrorJson(err))
return
}
legacyEntitlement, err := dbclient.Client.LegacyPremiumEntitlements.GetUserTier(ctx, userId, premium.GracePeriod)
if err != nil {
ctx.JSON(http.StatusInternalServerError, utils.ErrorJson(err))
return
}
res := gin.H{
"entitlements": entitlements,
"legacy_entitlement": legacyEntitlement,
}
if legacyEntitlement == nil || legacyEntitlement.IsLegacy {
ctx.JSON(http.StatusOK, res)
return
}
// If it's a multi-server subscription, fetch more data
var permitted *int
guildIds := make([]uint64, 0)
if err := dbclient.Client.WithTx(ctx, func(tx pgx.Tx) error {
tmp, ok, err := dbclient.Client.MultiServerSkus.GetPermittedServerCount(ctx, tx, legacyEntitlement.SkuId)
if err != nil {
return err
}
if ok {
permitted = &tmp
}
activeEntitlements, err := dbclient.Client.LegacyPremiumEntitlementGuilds.ListForUser(ctx, tx, userId)
if err != nil {
return err
}
for _, entitlement := range activeEntitlements {
guildIds = append(guildIds, entitlement.GuildId)
}
return nil
}); err != nil {
ctx.JSON(http.StatusInternalServerError, utils.ErrorJson(err))
return
}
res["permitted_server_count"] = permitted
res["selected_guilds"] = types.UInt64StringSlice(guildIds)
ctx.JSON(http.StatusOK, res)
}

View File

@ -7,6 +7,7 @@ import (
api_forms "github.com/TicketsBot/GoPanel/app/http/endpoints/api/forms"
api_integrations "github.com/TicketsBot/GoPanel/app/http/endpoints/api/integrations"
api_panels "github.com/TicketsBot/GoPanel/app/http/endpoints/api/panel"
api_premium "github.com/TicketsBot/GoPanel/app/http/endpoints/api/premium"
api_settings "github.com/TicketsBot/GoPanel/app/http/endpoints/api/settings"
api_override "github.com/TicketsBot/GoPanel/app/http/endpoints/api/staffoverride"
api_tags "github.com/TicketsBot/GoPanel/app/http/endpoints/api/tags"
@ -79,15 +80,23 @@ func StartServer(sm *livechat.SocketManager) {
{
apiGroup.GET("/session", api.SessionHandler)
integrationGroup := apiGroup.Group("/integrations")
{
integrationGroup := apiGroup.Group("/integrations")
integrationGroup.GET("/self", api_integrations.GetOwnedIntegrationsHandler)
integrationGroup.GET("/view/:integrationid", api_integrations.GetIntegrationHandler)
integrationGroup.GET("/view/:integrationid/detail", api_integrations.GetIntegrationDetailedHandler)
integrationGroup.POST("/:integrationid/public", api_integrations.SetIntegrationPublicHandler)
integrationGroup.PATCH("/:integrationid", api_integrations.UpdateIntegrationHandler)
integrationGroup.DELETE("/:integrationid", api_integrations.DeleteIntegrationHandler)
apiGroup.POST("/integrations", api_integrations.CreateIntegrationHandler)
integrationGroup.GET("/self", api_integrations.GetOwnedIntegrationsHandler)
integrationGroup.GET("/view/:integrationid", api_integrations.GetIntegrationHandler)
integrationGroup.GET("/view/:integrationid/detail", api_integrations.GetIntegrationDetailedHandler)
integrationGroup.POST("/:integrationid/public", api_integrations.SetIntegrationPublicHandler)
integrationGroup.PATCH("/:integrationid", api_integrations.UpdateIntegrationHandler)
integrationGroup.DELETE("/:integrationid", api_integrations.DeleteIntegrationHandler)
apiGroup.POST("/integrations", api_integrations.CreateIntegrationHandler)
}
{
premiumGroup := apiGroup.Group("/premium/@me")
premiumGroup.GET("/entitlements", api_premium.GetEntitlements)
premiumGroup.PUT("/active-guilds", api_premium.SetActiveGuilds)
}
}
guildAuthApiAdmin := apiGroup.Group("/:id", middleware.AuthenticateGuild(permission.Admin))

View File

@ -34,6 +34,7 @@ import CreatePanel from "./views/panels/CreatePanel.svelte";
import CreateMultiPanel from "./views/panels/CreateMultiPanel.svelte";
import EditPanel from "./views/panels/EditPanel.svelte";
import EditMultiPanel from "./views/panels/EditMultiPanel.svelte";
import SelectServers from "./views/premium/SelectServers.svelte";
export const routes = [
{name: '/', component: Index, layout: IndexLayout},
@ -43,6 +44,12 @@ export const routes = [
{name: '/logout', component: Logout},
{name: '/error', component: Error, layout: ErrorLayout},
{name: '/whitelabel', component: Whitelabel, layout: IndexLayout},
{
name: 'premium',
nestedRoutes: [
{name: 'select-servers', component: SelectServers, layout: IndexLayout}
]
},
{
name: 'admin',
nestedRoutes: [

View File

@ -0,0 +1,183 @@
<main>
<Card fill={false} footer={false}>
<span slot="title">Choose Premium Servers</span>
<div slot="body" class="card-body">
<div class="explanation">
<span>Your premium subscription allows you to choose {@html limit === 1 ? "<b>one</b> server" : `up to <b>${limit}</b> servers`}
to apply premium to.
</span>
<span>
Currently selected: <b>{selected.length} / {limit}</b> server{limit > 1 ? "s" : ""}.
</span>
</div>
<div class="servers">
{#each getAdminGuilds(guilds) as guild}
<div class="server" class:active={selected.includes(guild.id)} class:pointer={selected.length < limit || selected.includes(guild.id)}
on:click={() => toggleSelected(guild.id)}>
<img src="{getIconUrl(guild.id, guild.icon)}" alt="Guild Icon" on:error={(e) => handleImgLoadError(e, guild.id)} />
<span class="name">{guild.name}</span>
</div>
{/each}
</div>
<div class="submit-wrapper">
<Button on:click={submitServers}>Save</Button>
</div>
</div>
</Card>
</main>
<style>
main {
width: 100%;
padding: 30px;
}
.card-body {
display: flex;
flex-direction: column;
gap: 1em;
padding-bottom: 1em;
width: 100%;
}
.explanation {
display: flex;
flex-direction: column;
gap: 1em;
}
.servers {
display: flex;
flex-wrap: wrap;
gap: 1em;
row-gap: 1em;
}
.server {
display: flex;
align-items: center;
flex: 1 0 21%;
gap: 1em;
padding: 8px 10px;
border-radius: 4px;
user-select: none;
background-color: #121212;
border: 1px solid #121212;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.server.pointer {
cursor: pointer;
}
.server.active {
border-color: var(--primary);
box-shadow: 0 0 10px var(--primary);
}
.server > img {
width: 50px;
height: 50px;
border-radius: 50%;
}
.submit-wrapper {
display: flex;
justify-content: center;
}
</style>
<script>
import {withLoadingScreen, notifyError, notifySuccess} from '../../js/util';
import {setDefaultHeaders} from '../../includes/Auth.svelte'
import Card from "../../components/Card.svelte";
import {getIconUrl, getDefaultIcon} from "../../js/icons";
import {API_URL} from "../../js/constants";
import Button from "../../components/Button.svelte";
import axios from "axios";
let limit = 1;
let selected = [];
let guilds = [];
function getAdminGuilds(guilds) {
return guilds.filter(g => g.permission_level === 2);
}
let failed = [];
function handleImgLoadError(e, guildId) {
if (!failed.includes(guildId)) {
failed = [...failed, guildId];
e.target.src = getDefaultIcon(guildId);
}
}
function toggleSelected(guildId) {
if (selected.includes(guildId)) {
selected = selected.filter(id => id !== guildId);
} else {
if (selected.length < limit) {
selected = [...selected, guildId];
}
}
}
setDefaultHeaders();
async function loadEntitlements() {
const res = await axios.get(`${API_URL}/api/premium/@me/entitlements`)
if (res.status !== 200) {
notifyError(`Failed to load entitlements: ${res.data.error}`)
return;
}
if (res.data.legacy_entitlement === null || res.data.legacy_entitlement.is_legacy) {
notifyError('This feature is only available to users with a server-specific premium subscription via Patreon.');
return;
}
limit = res.data.permitted_server_count;
selected = res.data.selected_guilds;
}
async function loadGuilds() {
const res = await axios.get(`${API_URL}/user/guilds`)
if (res.status !== 200) {
notifyError(`Failed to load guilds: ${res.data.error}`)
return;
}
guilds = [...guilds, ...res.data];
}
async function submitServers() {
const res = await axios.put(`${API_URL}/api/premium/@me/active-guilds`, {
selected_guilds: selected
});
if (res.status !== 204) {
notifyError(`Failed to save servers: ${res.data.error}`);
return;
}
notifySuccess('Your premium server selection has been saved.')
}
withLoadingScreen(async () => {
await Promise.all([
loadEntitlements(),
loadGuilds()
]);
for (const id of selected) {
if (!guilds.find(g => g.id === id)) {
guilds = [{
id,
name: `Unknown Server ${id}`,
icon: "",
permission_level: 2
}, ...guilds];
}
}
});
</script>

2
go.mod
View File

@ -8,7 +8,7 @@ require (
github.com/BurntSushi/toml v1.2.1
github.com/TicketsBot/archiverclient v0.0.0-20240613013458-accc062facc2
github.com/TicketsBot/common v0.0.0-20240829163809-6f60869d8941
github.com/TicketsBot/database v0.0.0-20240829163737-86b657b7e506
github.com/TicketsBot/database v0.0.0-20240901155918-d0c56594a09a
github.com/TicketsBot/logarchiver v0.0.0-20220326162808-cdf0310f5e1c
github.com/TicketsBot/worker v0.0.0-20240829163848-84556c59ee72
github.com/apex/log v1.1.2

2
go.sum
View File

@ -70,6 +70,8 @@ github.com/TicketsBot/database v0.0.0-20240829155519-6b47874c9775 h1:4BhqGSlvI+t
github.com/TicketsBot/database v0.0.0-20240829155519-6b47874c9775/go.mod h1:XhV3kI4ogTxBJATmO20kxmaynUcqCf3PCnbVbxkYNyQ=
github.com/TicketsBot/database v0.0.0-20240829163737-86b657b7e506 h1:mU2wx9pyb7258RYyh9ZpTkDDFZMtP3C8YFphFEjxgWE=
github.com/TicketsBot/database v0.0.0-20240829163737-86b657b7e506/go.mod h1:tnSNh3i0tPw2xmTzF8JnQPPpKY9QdwbeH2d4xzYcqcM=
github.com/TicketsBot/database v0.0.0-20240901155918-d0c56594a09a h1:kNDfpVimz3kEBYpiIql1rJDDUHiBKZEdw+JLyH4Ne9w=
github.com/TicketsBot/database v0.0.0-20240901155918-d0c56594a09a/go.mod h1:tnSNh3i0tPw2xmTzF8JnQPPpKY9QdwbeH2d4xzYcqcM=
github.com/TicketsBot/logarchiver v0.0.0-20220326162808-cdf0310f5e1c h1:OqGjFH6mbE6gd+NqI2ARJdtH3UUvhiAkD0r0fhGJK2s=
github.com/TicketsBot/logarchiver v0.0.0-20220326162808-cdf0310f5e1c/go.mod h1:jgi2OXQKsd5nUnTIRkwvPmeuD/i7OhN68LKMssuQY1c=
github.com/TicketsBot/ttlcache v1.6.1-0.20200405150101-acc18e37b261 h1:NHD5GB6cjlkpZFjC76Yli2S63/J2nhr8MuE6KlYJpQM=