Compare commits

...

2 commits

Author SHA1 Message Date
noe
a3a1654030 wtf auth?? 2025-03-26 21:08:15 -07:00
noe
607d7e121c slash roleypoly yay 2025-03-26 16:36:00 -07:00
32 changed files with 731 additions and 67 deletions

View file

@ -4,4 +4,6 @@ DISCORD_PUBLIC_KEY=cc
DISCORD_BOT_TOKEN=dd DISCORD_BOT_TOKEN=dd
LISTEN_ADDR=:8169 LISTEN_ADDR=:8169
STORAGE_DIR=./.storage STORAGE_DIR=./.storage
PUBLIC_BASE_URL=http://localhost:8169

View file

@ -0,0 +1,130 @@
package authmiddleware
import (
"errors"
"fmt"
"time"
"slices"
"git.sapphic.engineer/roleypoly/v4/discord"
"git.sapphic.engineer/roleypoly/v4/types"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/session"
)
type AuthMiddleware struct {
Client discord.IDiscordClient
supportIDs []string
superuserIDs []string
}
type Session struct {
Permissions Permission
User *types.DiscordUser
AccessToken string
LastRefresh time.Time
}
var SessionKey uint8
func New(discordClient discord.IDiscordClient, supportIDs []string, superuserIDs []string) func(fiber.Ctx) error {
am := AuthMiddleware{
Client: discordClient,
supportIDs: supportIDs,
superuserIDs: superuserIDs,
}
return am.Handle
}
var DefaultSession *Session = &Session{Permissions: PermAnonymous}
func (am *AuthMiddleware) Handle(c fiber.Ctx) error {
sc := session.FromContext(c)
sess := am.Init(sc)
am.validateAccessToken(sess) // this will remove AccessToken if its no good
am.setPermissions(sess) // this captures this
am.Commit(sc, sess) // and save our first bits?
return c.Next()
}
func (am *AuthMiddleware) Init(sess *session.Middleware) *Session {
session, ok := sess.Get(SessionKey).(*Session)
if !ok {
am.Commit(sess, DefaultSession)
return DefaultSession
}
return session
}
func (am *AuthMiddleware) Commit(sc *session.Middleware, sess *Session) {
sc.Set(SessionKey, sess)
}
func SessionFrom(c fiber.Ctx) (s *Session) {
sess := session.FromContext(c)
s, _ = sess.Get(SessionKey).(*Session)
return s
}
func (am *AuthMiddleware) isSupport(userID string) bool {
return slices.Contains(am.supportIDs, userID)
}
func (am *AuthMiddleware) isSuperuser(userID string) bool {
return slices.Contains(am.superuserIDs, userID)
}
func (am *AuthMiddleware) setPermissions(sess *Session) {
sess.Permissions = PermAnonymous
if sess.AccessToken != "" {
sess.Permissions = PermUser
}
if am.isSupport(sess.User.ID) {
sess.Permissions = PermSupport
}
if am.isSuperuser(sess.User.ID) {
sess.Permissions = PermSuperuser
}
}
func (am *AuthMiddleware) validateAccessToken(sess *Session) {
if sess.AccessToken == "" {
return
}
if sess.LastRefresh.Add(time.Hour).Before(time.Now()) {
user, err := am.GetCurrentUser(sess.AccessToken)
if err != nil {
if errors.Is(err, discord.ErrUnauthorized) {
sess.AccessToken = ""
return
}
}
sess.User = user
}
}
func (am *AuthMiddleware) GetCurrentUser(accessToken string) (*types.DiscordUser, error) {
req := discord.NewRequest("GET", "/users/@me")
am.Client.ClientAuth(req, accessToken)
resp, err := am.Client.Do(req)
if err != nil {
return nil, fmt.Errorf("authmiddleware.GetCurrentUser: request failed: %w", err)
}
var user types.DiscordUser
err = discord.OutputResponse(resp, &user)
return &user, err
}

View file

@ -0,0 +1,80 @@
package authmiddleware_test
import (
"bytes"
"net/http"
"testing"
"git.sapphic.engineer/roleypoly/v4/authmiddleware"
"git.sapphic.engineer/roleypoly/v4/discord"
"git.sapphic.engineer/roleypoly/v4/discord/clientmock"
"github.com/goccy/go-json"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/session"
)
func TestAnonymous(t *testing.T) {
dc := clientmock.NewDiscordClientMock()
app := getApp(dc)
setSession(app, dc, authmiddleware.Session{
Permissions: authmiddleware.PermAnonymous,
})
}
func getApp(dc discord.IDiscordClient) *fiber.App {
app := fiber.New(fiber.Config{})
sessionMiddleware, sessionStore := session.NewWithStore()
sessionStore.RegisterType(authmiddleware.Session{})
app.Use(sessionMiddleware, authmiddleware.New(dc, []string{}, []string{}))
app.Get("/", authState)
app.Post("/updateSession", updateSession)
return app
}
func setSession(app *fiber.App, dc *clientmock.DiscordClientMock, sess authmiddleware.Session) error {
body := bytes.Buffer{}
json.NewEncoder(&body).Encode(sess)
// do mocks here
req, _ := http.NewRequest("POST", "/updateSession", &body)
_, err := app.Test(req)
return err
}
func authState(c fiber.Ctx) error {
s := authmiddleware.SessionFrom(c)
permList := []string{}
if s.Permissions >= authmiddleware.PermAnonymous {
permList = append(permList, "anonymous")
}
if s.Permissions >= authmiddleware.PermUser {
permList = append(permList, "user")
}
if s.Permissions >= authmiddleware.PermSupport {
permList = append(permList, "support")
}
if s.Permissions >= authmiddleware.PermSuperuser {
permList = append(permList, "superuser")
}
return c.JSON(permList)
}
func updateSession(c fiber.Ctx) error {
var newSession *authmiddleware.Session
c.Bind().JSON(&newSession)
sc := session.FromContext(c)
sc.Set(authmiddleware.SessionKey, newSession)
return c.SendString("ok")
}

10
authmiddleware/const.go Normal file
View file

@ -0,0 +1,10 @@
package authmiddleware
type Permission uint8
const (
PermAnonymous Permission = 1 << iota
PermUser
PermSupport
PermSuperuser
)

View file

@ -13,6 +13,23 @@ import (
type DiscordClientMock struct { type DiscordClientMock struct {
mock.Mock mock.Mock
BotToken string
ClientID string
ClientSecret string
}
func NewDiscordClientMock() *DiscordClientMock {
dcm := &DiscordClientMock{
BotToken: "bot-token",
ClientID: "client-id",
ClientSecret: "client-secret",
}
dcm.On("BotAuth", mock.AnythingOfType("*http.Request"))
dcm.On("GetClientID").Return(dcm.ClientID)
return dcm
} }
func (c *DiscordClientMock) Do(req *http.Request) (*http.Response, error) { func (c *DiscordClientMock) Do(req *http.Request) (*http.Response, error) {
@ -25,6 +42,10 @@ func (c *DiscordClientMock) BotAuth(req *http.Request) {
c.Called(req) c.Called(req)
} }
func (c *DiscordClientMock) ClientAuth(req *http.Request, accessToken string) {
c.Called(req, accessToken)
}
func (c *DiscordClientMock) MockResponse(method, path string, statusCode int, data any) { func (c *DiscordClientMock) MockResponse(method, path string, statusCode int, data any) {
body := bytes.Buffer{} body := bytes.Buffer{}
json.NewEncoder(&body).Encode(data) json.NewEncoder(&body).Encode(data)
@ -40,3 +61,7 @@ func (c *DiscordClientMock) MockResponse(method, path string, statusCode int, da
return req.Method == method && pathMatcher.MatchString(req.URL.Path) return req.Method == method && pathMatcher.MatchString(req.URL.Path)
})).Return(r, nil) })).Return(r, nil)
} }
func (c *DiscordClientMock) GetClientID() string {
return c.Called().String(0)
}

View file

@ -1,6 +1,7 @@
package discord package discord
import ( import (
"errors"
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
@ -11,9 +12,15 @@ import (
const DiscordBaseUrl = "https://discord.com/api/v10" const DiscordBaseUrl = "https://discord.com/api/v10"
var (
ErrUnauthorized = errors.New("discord: got a 401 from discord")
)
type IDiscordClient interface { type IDiscordClient interface {
Do(req *http.Request) (*http.Response, error) Do(req *http.Request) (*http.Response, error)
BotAuth(req *http.Request) BotAuth(req *http.Request)
ClientAuth(req *http.Request, accessToken string)
GetClientID() string
} }
type DiscordClient struct { type DiscordClient struct {
@ -43,6 +50,10 @@ func (d *DiscordClient) BotAuth(req *http.Request) {
req.Header.Set("Authorization", fmt.Sprintf("Bot %s", d.BotToken)) req.Header.Set("Authorization", fmt.Sprintf("Bot %s", d.BotToken))
} }
func (d *DiscordClient) ClientAuth(req *http.Request, accessToken string) {
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", d.BotToken))
}
func NewRequest(method string, path string) *http.Request { func NewRequest(method string, path string) *http.Request {
url, err := url.Parse(fmt.Sprintf("%s%s", DiscordBaseUrl, path)) url, err := url.Parse(fmt.Sprintf("%s%s", DiscordBaseUrl, path))
if err != nil { if err != nil {
@ -61,6 +72,14 @@ func NewRequest(method string, path string) *http.Request {
} }
func OutputResponse(resp *http.Response, dst any) error { func OutputResponse(resp *http.Response, dst any) error {
if resp.StatusCode == 401 {
return ErrUnauthorized
}
// TODO: more checks? // TODO: more checks?
return json.NewDecoder(resp.Body).Decode(dst) return json.NewDecoder(resp.Body).Decode(dst)
} }
func (d *DiscordClient) GetClientID() string {
return d.ClientID
}

View file

@ -6,6 +6,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"git.sapphic.engineer/roleypoly/v4/discord" "git.sapphic.engineer/roleypoly/v4/discord"
"git.sapphic.engineer/roleypoly/v4/discord/clientmock"
"git.sapphic.engineer/roleypoly/v4/types/fixtures" "git.sapphic.engineer/roleypoly/v4/types/fixtures"
"git.sapphic.engineer/roleypoly/v4/utils" "git.sapphic.engineer/roleypoly/v4/utils"
) )
@ -17,7 +18,7 @@ var (
) )
func TestGetMember(t *testing.T) { func TestGetMember(t *testing.T) {
dc := defaultMocks(t) dc := clientmock.NewDiscordClientMock()
g := discord.Guild{ g := discord.Guild{
Client: dc, Client: dc,
DiscordGuild: fixtures.Guild, DiscordGuild: fixtures.Guild,

View file

@ -6,11 +6,6 @@ import (
"git.sapphic.engineer/roleypoly/v4/utils" "git.sapphic.engineer/roleypoly/v4/utils"
) )
type IGuildService interface {
Client() IDiscordClient
GetGuild(guildID string) (IGuild, error)
}
type GuildService struct { type GuildService struct {
client IDiscordClient client IDiscordClient
} }

View file

@ -4,6 +4,7 @@ import (
"testing" "testing"
"git.sapphic.engineer/roleypoly/v4/discord" "git.sapphic.engineer/roleypoly/v4/discord"
"git.sapphic.engineer/roleypoly/v4/discord/clientmock"
"git.sapphic.engineer/roleypoly/v4/types/fixtures" "git.sapphic.engineer/roleypoly/v4/types/fixtures"
"git.sapphic.engineer/roleypoly/v4/utils" "git.sapphic.engineer/roleypoly/v4/utils"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -16,7 +17,7 @@ var (
) )
func TestGetGuild(t *testing.T) { func TestGetGuild(t *testing.T) {
dc := defaultMocks(t) dc := clientmock.NewDiscordClientMock()
gs := discord.NewGuildService(dc) gs := discord.NewGuildService(dc)
dc.MockResponse("GET", utils.J("guilds", fixtures.Guild.ID), 200, fixtureGuild) dc.MockResponse("GET", utils.J("guilds", fixtures.Guild.ID), 200, fixtureGuild)

View file

@ -1,16 +0,0 @@
package discord_test
import (
"testing"
"git.sapphic.engineer/roleypoly/v4/discord/clientmock"
"github.com/stretchr/testify/mock"
)
func defaultMocks(t *testing.T) *clientmock.DiscordClientMock {
dc := &clientmock.DiscordClientMock{}
dc.On("BotAuth", mock.AnythingOfType("*http.Request"))
return dc
}

View file

@ -0,0 +1,52 @@
package interactions
import (
"fmt"
"net/url"
"git.sapphic.engineer/roleypoly/v4/utils"
)
func (i *Interactions) CmdRoleypoly(ix Interaction) (InteractionResponse, error) {
mentions := i.CommandsMap()
hostname := "roleypoly.com"
publicURL, _ := url.Parse(i.PublicBaseURL)
if publicURL != nil {
hostname = publicURL.Host
}
return InteractionResponse{
Type: ResponseChannelMessage,
Data: InteractionResponseData{
Flags: FlagEphemeral,
Embeds: []Embed{{
Color: utils.EmbedColor,
Title: fmt.Sprintf(":beginner: Hey there, %s", ix.GetName()),
Description: "Try these slash commands, or pick roles from a browser!",
Fields: []EmbedField{
{
Name: "See all the roles",
Value: utils.Or(mentions["pickable-roles"], "/pickable-roles"),
},
{
Name: "Pick a role",
Value: utils.Or(mentions["pick-role"], "/pick-role"),
},
{
Name: "Remove a role",
Value: utils.Or(mentions["remove-role"], "/remove-role"),
},
},
}},
Components: []ButtonComponent{
{
Type: 2, // Button
Style: 5, // Link
Label: fmt.Sprintf("Pick roles on %s", hostname),
Emoji: utils.GetRandomEmoji(),
URL: fmt.Sprintf("%s/s/%s", i.PublicBaseURL, ix.GuildID),
},
},
},
}, nil
}

View file

@ -0,0 +1,71 @@
package interactions_test
import (
"fmt"
"net/url"
"testing"
"git.sapphic.engineer/roleypoly/v4/interactions"
"github.com/stretchr/testify/assert"
)
func TestCmdRoleypoly(t *testing.T) {
i, dcm, _ := makeInteractions(t)
ix := mkInteraction("roleypoly")
commands := []interactions.InteractionCommand{
{ID: "1", Name: "pick-role"},
{ID: "2", Name: "pickable-roles"},
{ID: "3", Name: "remove-role"},
}
dcm.MockResponse("GET", "/applications/*/commands", 200, commands)
ir, err := i.CmdRoleypoly(ix)
assert.Nil(t, err)
embed := ir.Data.Embeds[0]
// test that the button is cool
button := ir.Data.Components[0]
assert.Equal(t, fmt.Sprintf("%s/s/guild-id", i.PublicBaseURL), button.URL)
u, _ := url.Parse(i.PublicBaseURL)
hostname := u.Host
assert.Equal(t, fmt.Sprintf("Pick roles on %s", hostname), button.Label)
// test the command mentions
tests := map[string]string{
"See all the roles": "</2:pickable-roles>",
"Pick a role": "</1:pick-role>",
"Remove a role": "</3:remove-role>",
}
for _, field := range embed.Fields {
assert.Equal(t, tests[field.Name], field.Value)
}
// test weird name cases
// not weird
ix.Member.Nick = "Doll"
ix.Member.User.GlobalName = "Dolly"
ix.Member.User.Username = "41666."
ir, err = i.CmdRoleypoly(ix)
assert.Nil(t, err)
assert.Equal(t, ":beginner: Hey there, Doll", ir.Data.Embeds[0].Title)
// no nick
ix.Member.Nick = ""
ix.Member.User.GlobalName = "Dolly"
ix.Member.User.Username = "41666."
ir, err = i.CmdRoleypoly(ix)
assert.Nil(t, err)
assert.Equal(t, ":beginner: Hey there, Dolly", ir.Data.Embeds[0].Title)
// no globalname
ix.Member.Nick = ""
ix.Member.User.GlobalName = ""
ix.Member.User.Username = "41666."
ir, err = i.CmdRoleypoly(ix)
assert.Nil(t, err)
assert.Equal(t, ":beginner: Hey there, 41666.", ir.Data.Embeds[0].Title)
}

View file

@ -2,6 +2,7 @@ package interactions
import ( import (
"crypto/ed25519" "crypto/ed25519"
"fmt"
"log" "log"
"net/http" "net/http"
@ -9,23 +10,30 @@ import (
"git.sapphic.engineer/roleypoly/v4/discord" "git.sapphic.engineer/roleypoly/v4/discord"
"git.sapphic.engineer/roleypoly/v4/types" "git.sapphic.engineer/roleypoly/v4/types"
"git.sapphic.engineer/roleypoly/v4/utils"
) )
type Interactions struct { type Interactions struct {
PublicKey ed25519.PublicKey PublicKey ed25519.PublicKey
Guilds *discord.GuildService PublicBaseURL string
Router *InteractionRouter Guilds *discord.GuildService
Router *InteractionRouter
// if interactions are able to be used // if interactions are able to be used
OK bool OK bool
commandsMap []InteractionCommand
commandsMentions map[string]string
} }
func NewInteractions(publicKey string, guildsService *discord.GuildService) *Interactions { func NewInteractions(publicKey string, publicBaseURL string, guildsService *discord.GuildService) *Interactions {
return &Interactions{ return &Interactions{
PublicKey: ed25519.PublicKey(publicKey), PublicKey: ed25519.PublicKey(publicKey),
Guilds: guildsService, PublicBaseURL: publicBaseURL,
Router: NewInteractionRouter(), Guilds: guildsService,
OK: len(publicKey) != 0, Router: NewInteractionRouter(),
OK: len(publicKey) != 0,
} }
} }
@ -68,6 +76,32 @@ func (i *Interactions) PostHandler(c fiber.Ctx) error {
return types.NewAPIError(http.StatusNotImplemented, "not implemented").Send(c) return types.NewAPIError(http.StatusNotImplemented, "not implemented").Send(c)
} }
// func (i *Interactions) Respond(ix Interaction, ir InteractionResponse) error { func (i *Interactions) CommandsMap() map[string]string {
// i.Guilds if i.commandsMentions != nil {
// } return i.commandsMentions
}
dc := i.Guilds.Client()
req := discord.NewRequest("GET", utils.J("applications", i.Guilds.Client().GetClientID(), "commands"))
dc.BotAuth(req)
resp, err := dc.Do(req)
if err != nil {
log.Println("interactions: commands map fetch failed")
return nil
}
i.commandsMap = []InteractionCommand{}
err = discord.OutputResponse(resp, &i.commandsMap)
if err != nil {
log.Println("interactions: commands map parse failed")
return nil
}
i.commandsMentions = map[string]string{}
for _, command := range i.commandsMap {
i.commandsMentions[command.Name] = fmt.Sprintf("</%s:%s>", command.ID, command.Name)
}
return i.commandsMentions
}

View file

@ -21,7 +21,7 @@ func TestNewInteractions(t *testing.T) {
gs := &discord.GuildService{} gs := &discord.GuildService{}
i := interactions.NewInteractions(key, gs) i := interactions.NewInteractions(key, "http://roleypoly.local", gs)
assert.True(t, i.OK, "interactions OK") assert.True(t, i.OK, "interactions OK")
assert.Equal(t, string(i.PublicKey), key, "public key accepted from args") assert.Equal(t, string(i.PublicKey), key, "public key accepted from args")
} }

View file

@ -12,6 +12,18 @@ type Interaction struct {
Token string `json:"token"` Token string `json:"token"`
} }
func (ix Interaction) GetName() string {
if ix.Member.Nick != "" {
return ix.Member.Nick
}
if ix.Member.User.GlobalName != "" {
return ix.Member.User.GlobalName
}
return ix.Member.User.Username
}
const ( const (
TypePing uint64 = 1 TypePing uint64 = 1
TypeApplicationCommand uint64 = 2 TypeApplicationCommand uint64 = 2
@ -43,9 +55,59 @@ const (
) )
type InteractionResponseData struct { type InteractionResponseData struct {
Content string Content string `json:"content,omitempty"`
Embeds []Embed Components []ButtonComponent `json:"components,omitempty"`
Flags int Embeds []Embed `json:"embeds,omitempty"`
Flags int `json:"flags,omitempty"`
} }
type Embed struct{} type ButtonComponent struct {
Type int `json:"type,omitempty"`
Style int `json:"style,omitempty"`
Label string `json:"label,omitempty"`
Emoji types.Emoji `json:"emoji,omitempty"`
URL string `json:"url,omitempty"`
}
type InteractionCommand struct {
ID string `json:"id"`
Type int `json:"type"`
GuildID string `json:"guild_id,omitempty"`
Name string `json:"name"`
Description string `json:"description"`
Options InteractionCommandOption `json:"options"`
ChannelTypes []int `json:"channel_types,omitempty"`
}
type InteractionCommandOption struct {
Type int `json:"type"`
Name string `json:"name"`
Required bool `json:"required,omitempty"`
}
type Embed struct {
Author EmbedAuthor `json:"author"`
Color uint32 `json:"color"`
Description string `json:"description,omitempty"`
Fields []EmbedField `json:"fields"`
Footer EmbedFooter `json:"footer"`
Timestamp string `json:"timestamp,omitempty"`
Title string `json:"title"`
}
type EmbedField struct {
Name string `json:"name"`
Value string `json:"value"`
Inline bool `json:"inline,omitempty"`
}
type EmbedFooter struct {
Text string `json:"text"`
IconURL string `json:"icon_url,omitempty"`
ProxyIconURL string `json:"proxy_icon_url,omitempty"`
}
type EmbedAuthor struct {
Name string `json:"name"`
IconURL string `json:"icon_url"`
}

View file

@ -10,28 +10,32 @@ import (
"time" "time"
"git.sapphic.engineer/roleypoly/v4/discord" "git.sapphic.engineer/roleypoly/v4/discord"
"git.sapphic.engineer/roleypoly/v4/discord/clientmock"
"git.sapphic.engineer/roleypoly/v4/interactions" "git.sapphic.engineer/roleypoly/v4/interactions"
"git.sapphic.engineer/roleypoly/v4/types/fixtures"
"github.com/goccy/go-json" "github.com/goccy/go-json"
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func makeInteractions(t *testing.T) (*interactions.Interactions, *discord.GuildService, ed25519.PrivateKey) { func makeInteractions(t *testing.T) (*interactions.Interactions, *clientmock.DiscordClientMock, ed25519.PrivateKey) {
pub, priv, err := ed25519.GenerateKey(nil) pub, priv, err := ed25519.GenerateKey(nil)
if err != nil { if err != nil {
t.Errorf("makeInteractions: failed to generate key: %v", err) t.Errorf("makeInteractions: failed to generate key: %v", err)
} }
gs := &discord.GuildService{} dcm := clientmock.NewDiscordClientMock()
gs := discord.NewGuildService(dcm)
i := &interactions.Interactions{ i := &interactions.Interactions{
PublicKey: pub, PublicKey: pub,
Guilds: gs, PublicBaseURL: "http://roleypoly.local",
Router: interactions.NewInteractionRouter(), Guilds: gs,
OK: true, Router: interactions.NewInteractionRouter(),
OK: true,
} }
return i, gs, priv return i, dcm, priv
} }
func cmdHelloWorld(ix interactions.Interaction) (interactions.InteractionResponse, error) { func cmdHelloWorld(ix interactions.Interaction) (interactions.InteractionResponse, error) {
@ -85,7 +89,9 @@ func sendInteraction(t *testing.T, i *interactions.Interactions, key ed25519.Pri
func mkInteraction(name string) interactions.Interaction { func mkInteraction(name string) interactions.Interaction {
return interactions.Interaction{ return interactions.Interaction{
Type: interactions.TypeApplicationCommand, GuildID: "guild-id",
Type: interactions.TypeApplicationCommand,
Member: fixtures.Member,
Data: interactions.InteractionData{ Data: interactions.InteractionData{
Name: name, Name: name,
}, },

26
main.go
View file

@ -2,27 +2,27 @@ package main // import "git.sapphic.engineer/roleypoly/v4"
import ( import (
"log" "log"
"os"
"git.sapphic.engineer/roleypoly/v4/discord" "git.sapphic.engineer/roleypoly/v4/discord"
"git.sapphic.engineer/roleypoly/v4/roleypoly" "git.sapphic.engineer/roleypoly/v4/roleypoly"
"git.sapphic.engineer/roleypoly/v4/utils"
) )
func main() { func main() {
app := roleypoly.CreateFiberApp() app := roleypoly.CreateFiberApp()
clientID := os.Getenv("DISCORD_CLIENT_ID") dc := discord.NewDiscordClient(
clientSecret := os.Getenv("DISCORD_CLIENT_SECRET") utils.DiscordClientID,
botToken := os.Getenv("DISCORD_BOT_TOKEN") utils.DiscordClientSecret,
dc := discord.NewDiscordClient(clientID, clientSecret, botToken) utils.DiscordBotToken,
)
publicKey := os.Getenv("DISCORD_PUBLIC_KEY") roleypoly.SetupControllers(
roleypoly.SetupControllers(app, dc, publicKey) app,
dc,
utils.DiscordPublicKey,
utils.PublicBaseURL,
)
listenAddr := os.Getenv("LISTEN_ADDR") log.Fatal(app.Listen(utils.ListenAddr))
if listenAddr == "" {
listenAddr = ":8169"
}
log.Fatal(app.Listen(listenAddr))
} }

View file

@ -1,15 +1,22 @@
package roleypoly package roleypoly
import ( import (
"log"
"net/http" "net/http"
"strings"
"time"
"github.com/goccy/go-json" "github.com/goccy/go-json"
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/csrf"
"github.com/gofiber/fiber/v3/middleware/session" "github.com/gofiber/fiber/v3/middleware/session"
"github.com/gofiber/fiber/v3/middleware/static"
"github.com/gofiber/template/html/v2" "github.com/gofiber/template/html/v2"
"git.sapphic.engineer/roleypoly/v4/authmiddleware"
"git.sapphic.engineer/roleypoly/v4/discord" "git.sapphic.engineer/roleypoly/v4/discord"
"git.sapphic.engineer/roleypoly/v4/interactions" "git.sapphic.engineer/roleypoly/v4/interactions"
staticfs "git.sapphic.engineer/roleypoly/v4/static"
"git.sapphic.engineer/roleypoly/v4/templates" "git.sapphic.engineer/roleypoly/v4/templates"
"git.sapphic.engineer/roleypoly/v4/testing" "git.sapphic.engineer/roleypoly/v4/testing"
) )
@ -24,19 +31,48 @@ func CreateFiberApp() *fiber.App {
ViewsLayout: "layouts/main", ViewsLayout: "layouts/main",
}) })
app.Use(session.New(session.Config{})) sessionMiddleware, sessionStore := session.NewWithStore()
sessionStore.RegisterType(authmiddleware.Session{})
app.Use(sessionMiddleware)
app.Use(csrf.New(csrf.Config{
Session: sessionStore,
}))
app.Get("/static*", static.New("", static.Config{
FS: staticfs.FS,
Compress: true,
CacheDuration: 24 * 8 * time.Hour,
}))
app.Get("/favicon.ico", oneStatic)
app.Get("/manifest.json", oneStatic)
app.Get("/robots.txt", oneStatic)
app.Get("/humans.txt", oneStatic)
return app return app
} }
func SetupControllers(app *fiber.App, dc *discord.DiscordClient, publicKey string) { func oneStatic(c fiber.Ctx) error {
path := strings.Replace(c.OriginalURL(), "/", "", 1)
f, err := staticfs.FS.Open(path)
if err != nil {
log.Println("oneStatic:", c.OriginalURL(), " failed: ", err)
return c.SendStatus(500)
}
return c.SendStream(f)
}
func SetupControllers(app *fiber.App, dc *discord.DiscordClient, publicKey string, publicBaseURL string) {
gs := discord.NewGuildService(dc) gs := discord.NewGuildService(dc)
(&testing.TestingController{ (&testing.TestingController{
Guilds: gs, Guilds: gs,
}).Routes(app.Group("/testing")) }).Routes(app.Group("/testing"))
interactions.NewInteractions(publicKey, gs).Routes(app.Group("/interactions")) interactions.NewInteractions(publicKey, publicBaseURL, gs).Routes(app.Group("/interactions"))
app.Use(authmiddleware.New(dc, []string{}, []string{}))
} }
// func getStorageDirectory() string { // func getStorageDirectory() string {

9
static/embed.go Normal file
View file

@ -0,0 +1,9 @@
package static
import "embed"
//go:embed *
var FS embed.FS
// hi :3
// dont look its rude :c

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

3
static/main.css Normal file
View file

@ -0,0 +1,3 @@
body {
font-family: "Atkinson Hyperlegible", sans-serif;
}

1
static/robots.txt Normal file
View file

@ -0,0 +1 @@
# Hi cutie

View file

@ -0,0 +1,6 @@
<h1>roleypoly!</h1>
<div>put a role here</div>
<div>put a role here</div>
<div>put a role here</div>
<div>put a role here</div>
<div>put a role here</div>

View file

@ -1,7 +1,14 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<meta charset="utf-8" />
<title>{{ .HeadTitle }}</title> <title>{{ .HeadTitle }}</title>
<link rel="preconnect" href="https://fonts.bunny.net" />
<link
href="https://fonts.bunny.net/css?family=atkinson-hyperlegible:400,400i"
rel="stylesheet"
/>
<link rel="stylesheet" href="/static/main.css" />
</head> </head>
<body> <body>
{{embed}} {{embed}}

View file

@ -5,6 +5,7 @@ import (
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
"git.sapphic.engineer/roleypoly/v4/authmiddleware"
"git.sapphic.engineer/roleypoly/v4/discord" "git.sapphic.engineer/roleypoly/v4/discord"
"git.sapphic.engineer/roleypoly/v4/types" "git.sapphic.engineer/roleypoly/v4/types"
) )
@ -15,8 +16,14 @@ type TestingController struct {
func (t *TestingController) Routes(r fiber.Router) { func (t *TestingController) Routes(r fiber.Router) {
r.Get("/picker/:version?", t.Picker) r.Get("/picker/:version?", t.Picker)
r.Get("/index", t.Index)
r.Get("/m/:server/:user", t.GetMember) r.Get("/m/:server/:user", t.GetMember)
r.Get("/g/:server", t.GetGuild) r.Get("/g/:server", t.GetGuild)
r.Get("/auth", t.AuthState, authmiddleware.New(t.Guilds.Client(), []string{}, []string{}))
}
func (t *TestingController) Index(c fiber.Ctx) error {
return c.Render("index", fiber.Map{})
} }
func (t *TestingController) Picker(c fiber.Ctx) error { func (t *TestingController) Picker(c fiber.Ctx) error {
@ -54,3 +61,27 @@ func (t *TestingController) GetGuild(c fiber.Ctx) error {
return c.JSON(g.DiscordGuild) return c.JSON(g.DiscordGuild)
} }
func (t *TestingController) AuthState(c fiber.Ctx) error {
s := authmiddleware.SessionFrom(c)
permList := []string{}
if s.Permissions >= authmiddleware.PermAnonymous {
permList = append(permList, "anonymous")
}
if s.Permissions >= authmiddleware.PermUser {
permList = append(permList, "user")
}
if s.Permissions >= authmiddleware.PermSupport {
permList = append(permList, "support")
}
if s.Permissions >= authmiddleware.PermSuperuser {
permList = append(permList, "superuser")
}
return c.JSON(permList)
}

7
types/discord.go Normal file
View file

@ -0,0 +1,7 @@
package types
type Emoji struct {
Name string `json:"name,omitempty"`
ID string `json:"id,omitempty"`
Animated bool `json:"animated,omitempty"`
}

View file

@ -1,7 +1,8 @@
package types package types
type DiscordUser struct { type DiscordUser struct {
ID string `json:"id"` ID string `json:"id"`
Username string `json:"username"` Username string `json:"username"`
Avatar string `json:"avatar"` Avatar string `json:"avatar"`
GlobalName string `json:"globalName"`
} }

5
utils/const.go Normal file
View file

@ -0,0 +1,5 @@
package utils
const (
EmbedColor = 0x453e3d
)

61
utils/emojis.go Normal file
View file

@ -0,0 +1,61 @@
package utils
import (
"math/rand/v2"
"git.sapphic.engineer/roleypoly/v4/types"
)
var (
AllEmojis = []types.Emoji{
// Repeated for probability reasons
{Name: "roleypolynext", ID: "785201823945850942"},
{Name: "roleypolynext", ID: "785201823945850942"},
{Name: "roleypolynext", ID: "785201823945850942"},
{Name: "roleypolynext", ID: "785201823945850942"},
{Name: "roleypolynext", ID: "785201823945850942"},
{Name: "roleypolynext", ID: "785201823945850942"},
{Name: "roleypolynext", ID: "785201823945850942"},
{Name: "roleypolynext", ID: "785201823945850942"},
{Name: "roleypolynext", ID: "785201823945850942"},
{Name: "roleypolynext", ID: "785201823945850942"},
{Name: "roleypolynext", ID: "785201823945850942"},
{Name: "roleypolynext_420", ID: "785201824101564466"},
{Name: "roleypolynext_ace", ID: "785201823497715723"},
{Name: "roleypolynext_ace", ID: "785201823497715723"},
{Name: "roleypolynext_ace", ID: "785201823497715723"},
{Name: "roleypolynext_ace", ID: "785201823497715723"},
{Name: "roleypolynext_ace", ID: "785201823497715723"},
{Name: "roleypolynext_ace", ID: "785201823497715723"},
{Name: "roleypolynext_anim", ID: "785203654125682698", Animated: true},
{Name: "roleypolynext_bi", ID: "785201823584616500"},
{Name: "roleypolynext_bi", ID: "785201823584616500"},
{Name: "roleypolynext_bi", ID: "785201823584616500"},
{Name: "roleypolynext_bi", ID: "785201823584616500"},
{Name: "roleypolynext_bi", ID: "785201823584616500"},
{Name: "roleypolynext_bi", ID: "785201823584616500"},
{Name: "roleypolynext_lesbian", ID: "785201823627476993"},
{Name: "roleypolynext_lesbian", ID: "785201823627476993"},
{Name: "roleypolynext_lesbian", ID: "785201823627476993"},
{Name: "roleypolynext_lesbian", ID: "785201823627476993"},
{Name: "roleypolynext_lesbian", ID: "785201823627476993"},
{Name: "roleypolynext_lny", ID: "785201824092651560"},
{Name: "roleypolynext_newyear", ID: "785201824277200956"},
{Name: "roleypolynext_pride", ID: "785201823501385749"},
{Name: "roleypolynext_pride", ID: "785201823501385749"},
{Name: "roleypolynext_pride", ID: "785201823501385749"},
{Name: "roleypolynext_pride", ID: "785201823501385749"},
{Name: "roleypolynext_pride", ID: "785201823501385749"},
{Name: "roleypolynext_spectrum", ID: "785201823526682666"},
{Name: "roleypolynext_trans", ID: "785201823967215617"},
{Name: "roleypolynext_trans", ID: "785201823967215617"},
{Name: "roleypolynext_trans", ID: "785201823967215617"},
{Name: "roleypolynext_trans", ID: "785201823967215617"},
{Name: "roleypolynext_trans", ID: "785201823967215617"},
}
)
func GetRandomEmoji() types.Emoji {
idx := rand.UintN(uint(len(AllEmojis)))
return AllEmojis[idx]
}

12
utils/env.go Normal file
View file

@ -0,0 +1,12 @@
package utils
import "os"
var (
DiscordBotToken = os.Getenv("DISCORD_BOT_TOKEN")
DiscordClientID = os.Getenv("DISCORD_CLIENT_ID")
DiscordClientSecret = os.Getenv("DISCORD_CLIENT_SECRET")
DiscordPublicKey = os.Getenv("DISCORD_PUBLIC_KEY")
ListenAddr = Or(os.Getenv("LISTEN_ADDR"), ":8169")
PublicBaseURL = os.Getenv("PUBLIC_BASE_URL")
)

View file

@ -12,3 +12,11 @@ func HeadTitle(text string) string {
func J(parts ...string) string { func J(parts ...string) string {
return "/" + strings.Join(parts, "/") return "/" + strings.Join(parts, "/")
} }
func Or(input, fallback string) string {
if input == "" {
return fallback
}
return input
}

View file

@ -15,3 +15,8 @@ func TestHeadTitle(t *testing.T) {
func TestJ(t *testing.T) { func TestJ(t *testing.T) {
assert.Equal(t, "/a/b/c", utils.J("a", "b", "c")) assert.Equal(t, "/a/b/c", utils.J("a", "b", "c"))
} }
func TestOr(t *testing.T) {
assert.Equal(t, "a", utils.Or("a", "b"))
assert.Equal(t, "b", utils.Or("", "b"))
}