Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ func main() {
RedirectURI: cfg.Discord.RedirectURI,
Scopes: cfg.Discord.Scopes,
}, cfg.Discord.OAuthTimeout)
discordSvc = service.NewDiscordService(cfg.Discord, discordRepo, userRepo, discordBotClient, discordOAuthClient, redisClient)
discordSvc = service.NewDiscordService(cfg.Discord, discordRepo, userRepo, divisionRepo, discordBotClient, discordOAuthClient, redisClient)
}

bootstrap.BootstrapAdmin(ctx, cfg, database, userRepo, teamRepo, divisionRepo, logger)
Expand Down
58 changes: 57 additions & 1 deletion docs/docs/admin.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,16 +317,25 @@ Request

```json
{
"name": "고등부"
"name": "고등부",
"discord_role_id": "1522163303982563458",
"discord_announce_channel_id": "1522218332806447225"
}
```

`discord_role_id` and `discord_announce_channel_id` are optional. `discord_role_id`
is the guild role granted to this division's members when they link Discord;
`discord_announce_channel_id` is the channel that receives this division's solve /
first-blood announcements. Both are omitted from the response when unset.

Response 201

```json
{
"id": 2,
"name": "고등부",
"discord_role_id": "1522163303982563458",
"discord_announce_channel_id": "1522218332806447225",
"created_at": "2026-01-26T12:00:00Z"
}
```
Expand All @@ -340,6 +349,53 @@ Errors:
Validation notes:

- `name` must be at most 10 characters (counted by Unicode code point).
- `discord_role_id` / `discord_announce_channel_id`, when present, must be numeric
Discord IDs (snowflakes). An empty string clears the value.

---

## Update Division (Admin)

`PUT /api/admin/divisions/:id`

Updates a division's name and per-division Discord configuration. Divisions cannot
be deleted.

Request

```json
{
"name": "고등부",
"discord_role_id": "1522163303982563458",
"discord_announce_channel_id": "1522218332806447225"
}
```

Omitting (or sending an empty string for) `discord_role_id` /
`discord_announce_channel_id` clears that setting.

Response 200

```json
{
"id": 2,
"name": "고등부",
"discord_role_id": "1522163303982563458",
"discord_announce_channel_id": "1522218332806447225",
"created_at": "2026-01-26T12:00:00Z"
}
```

Errors:

- 400 `invalid input`
- 401 `invalid token` or `missing access_token cookie`
- 403 `forbidden`
- 404 `not found`

Validation notes:

- Same as Create Division.

---

Expand Down
27 changes: 23 additions & 4 deletions docs/docs/discord.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,35 @@ nav_order: 12
Notes:

- Discord account linking uses the OAuth2 authorization-code flow on the backend (`identify`, optionally `guilds.join`).
- Guild membership and role changes are performed by a separate **invite-bot** server over an internal HTTP API; the backend never holds the Discord bot token. The bot token, guild id, and verified-role id live on the bot server only.
- Guild membership and role changes are performed by a separate **invite-bot** server over an internal HTTP API; the backend never holds the Discord bot token. The bot token and guild id live on the bot server only; the role and announcement channel are configured per division in the backend and passed to the bot per request.
- Access/refresh tokens are **not** stored. Role granting uses the bot token on the bot server, not the user's OAuth token.
- The acting user is identified by the JWT `access_token` cookie. `connect`, `callback`, and `status` require login; `sync-role` and `unlink` additionally require an unblocked (active) user.
- For authenticated `POST`, `PUT`, `PATCH`, and `DELETE` requests, send both `csrf_token` cookie and matching `X-CSRF-Token` header.
- All endpoints return `503 discord feature disabled` when `DISCORD_ENABLED=false`.

## Per-Division Discord Configuration

The Discord role granted on link and the solve-announcement channel are configured
**per division** (Admin > Divisions, `discord_role_id` and
`discord_announce_channel_id`), not as bot environment variables. Both are
optional. The backend resolves the acting user's division and passes the relevant
role id / channel id to the invite-bot on each request; the bot no longer holds a
`DISCORD_VERIFIED_ROLE_ID` or `DISCORD_ANNOUNCE_CHANNEL_ID`.

- **Role grant** — on link, the member is granted the division's `discord_role_id`.
If the division has no role configured, linking is still marked `VERIFIED` and no
role is granted.
- **Announcement channel** — solves post to the division's
`discord_announce_channel_id`. If unset, that division's announcements are
disabled.

## Solve Announcements & Nickname Sync

When `DISCORD_ENABLED=true`, the backend performs two best-effort side effects
through the invite-bot (failures are logged, never surfaced to the user):

- **Solve announcements** — on a correct flag submission, a message is sent to the
channel `DISCORD_ANNOUNCE_CHANNEL_ID` (no-op when unset):
solving user's division announcement channel (no-op when the division has none):
- Normal solve: `**division_team** — username solved **Challenge Title**`
- First blood: prefixed with 🩸 `First Blood!`

Expand Down Expand Up @@ -229,7 +245,9 @@ Discord service options (backend):
- `DISCORD_BOT_BASE_URL` (default: `http://localhost:8083`)
- `DISCORD_BOT_SECRET` — shared bearer secret for the invite-bot internal API (must equal the bot's `DISCORD_INTERNAL_SECRET`).
- `DISCORD_BOT_TIMEOUT` (default: `5s`)
- `DISCORD_ANNOUNCE_CHANNEL_ID` — target channel for solve / first-blood announcements. Empty disables announcements (configured on the invite-bot server).

The verified role and solve-announcement channel are **not** environment
variables — configure them per division (Admin > Divisions).

Validation rules when `DISCORD_ENABLED=true`:

Expand All @@ -241,4 +259,5 @@ Validation rules when `DISCORD_ENABLED=true`:
- `DISCORD_BOT_TIMEOUT > 0`
- `DISCORD_OAUTH_TIMEOUT > 0`

The Discord bot token, guild id, and verified-role id are configured on the invite-bot server, not here.
The Discord bot token and guild id are configured on the invite-bot server, not here.
The verified role and announcement channel are configured per division in the backend.
5 changes: 5 additions & 0 deletions docs/docs/divisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ Response 200
{
"id": 2,
"name": "고등부",
"discord_role_id": "1522163303982563458",
"discord_announce_channel_id": "1522218332806447225",
"created_at": "2026-01-26T12:00:00Z"
}
]
Expand All @@ -22,3 +24,6 @@ Response 200
Notes:

- Division identifiers are numeric `id` values (slugs are not supported).
- `discord_role_id` and `discord_announce_channel_id` are the per-division Discord
role and announcement channel. They are omitted when unset and are managed by
admins via Create / Update Division. See [Admin](admin.md) and [Discord](discord.md).
26 changes: 16 additions & 10 deletions internal/discord/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,11 @@ type Member struct {
}

type BotAPI interface {
JoinGuild(ctx context.Context, discordUserID, accessToken string) error
GrantRole(ctx context.Context, discordUserID string) error
JoinGuild(ctx context.Context, discordUserID, accessToken, roleID string) error
GrantRole(ctx context.Context, discordUserID, roleID string) error
KickMember(ctx context.Context, discordUserID string) error
GetMember(ctx context.Context, discordUserID string) (*Member, error)
Announce(ctx context.Context, content string) error
Announce(ctx context.Context, channelID, content string) error
SetNickname(ctx context.Context, discordUserID, nickname string) error
}

Expand All @@ -93,30 +93,36 @@ func NewBotClient(baseURL, secret string, timeout time.Duration) *BotClient {

type joinRequest struct {
AccessToken string `json:"access_token"`
RoleID string `json:"role_id,omitempty"`
}

type roleRequest struct {
RoleID string `json:"role_id"`
}

type announceRequest struct {
Content string `json:"content"`
ChannelID string `json:"channel_id"`
Content string `json:"content"`
}

type nicknameRequest struct {
Nickname string `json:"nickname"`
}

func (c *BotClient) JoinGuild(ctx context.Context, discordUserID, accessToken string) error {
return c.doJSON(ctx, http.MethodPut, "/internal/guild/members/"+url.PathEscape(discordUserID), joinRequest{AccessToken: accessToken}, nil)
func (c *BotClient) JoinGuild(ctx context.Context, discordUserID, accessToken, roleID string) error {
return c.doJSON(ctx, http.MethodPut, "/internal/guild/members/"+url.PathEscape(discordUserID), joinRequest{AccessToken: accessToken, RoleID: roleID}, nil)
}

func (c *BotClient) GrantRole(ctx context.Context, discordUserID string) error {
return c.doJSON(ctx, http.MethodPut, "/internal/guild/members/"+url.PathEscape(discordUserID)+"/role", nil, nil)
func (c *BotClient) GrantRole(ctx context.Context, discordUserID, roleID string) error {
return c.doJSON(ctx, http.MethodPut, "/internal/guild/members/"+url.PathEscape(discordUserID)+"/role", roleRequest{RoleID: roleID}, nil)
}

func (c *BotClient) KickMember(ctx context.Context, discordUserID string) error {
return c.doJSON(ctx, http.MethodDelete, "/internal/guild/members/"+url.PathEscape(discordUserID), nil, nil)
}

func (c *BotClient) Announce(ctx context.Context, content string) error {
return c.doJSON(ctx, http.MethodPost, "/internal/announce", announceRequest{Content: content}, nil)
func (c *BotClient) Announce(ctx context.Context, channelID, content string) error {
return c.doJSON(ctx, http.MethodPost, "/internal/announce", announceRequest{ChannelID: channelID, Content: content}, nil)
}

func (c *BotClient) SetNickname(ctx context.Context, discordUserID, nickname string) error {
Expand Down
24 changes: 19 additions & 5 deletions internal/discord/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,17 @@ import (

func TestBotClientGrantRoleSuccess(t *testing.T) {
var gotPath, gotAuth string
var body roleRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotAuth = r.Header.Get("Authorization")
_ = json.NewDecoder(r.Body).Decode(&body)
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()

client := NewBotClient(srv.URL, "secret", time.Second)
if err := client.GrantRole(context.Background(), "123"); err != nil {
if err := client.GrantRole(context.Background(), "123", "role-9"); err != nil {
t.Fatalf("GrantRole: %v", err)
}

Expand All @@ -31,6 +33,10 @@ func TestBotClientGrantRoleSuccess(t *testing.T) {
if gotAuth != "Bearer secret" {
t.Errorf("auth = %q", gotAuth)
}

if body.RoleID != "role-9" {
t.Errorf("role id = %q", body.RoleID)
}
}

func TestBotClientKickMember(t *testing.T) {
Expand Down Expand Up @@ -82,7 +88,7 @@ func TestBotClientGrantRoleMapsCodes(t *testing.T) {
defer srv.Close()

client := NewBotClient(srv.URL, "", time.Second)
err := client.GrantRole(context.Background(), "123")
err := client.GrantRole(context.Background(), "123", "role-1")
if !errors.Is(err, tc.wantErr) {
t.Fatalf("got %v, want %v", err, tc.wantErr)
}
Expand All @@ -103,13 +109,17 @@ func TestBotClientJoinGuildSendsAccessToken(t *testing.T) {
defer srv.Close()

client := NewBotClient(srv.URL, "", time.Second)
if err := client.JoinGuild(context.Background(), "u1", "tok-abc"); err != nil {
if err := client.JoinGuild(context.Background(), "u1", "tok-abc", "role-5"); err != nil {
t.Fatalf("JoinGuild: %v", err)
}

if body.AccessToken != "tok-abc" {
t.Errorf("access token = %q", body.AccessToken)
}

if body.RoleID != "role-5" {
t.Errorf("role id = %q", body.RoleID)
}
}

func TestBotClientGetMember(t *testing.T) {
Expand All @@ -132,7 +142,7 @@ func TestBotClientGetMember(t *testing.T) {

func TestBotClientUnavailableOnDialError(t *testing.T) {
client := NewBotClient("http://127.0.0.1:0", "", 200*time.Millisecond)
err := client.GrantRole(context.Background(), "1")
err := client.GrantRole(context.Background(), "1", "role-1")
if !errors.Is(err, ErrUnavailable) {
t.Fatalf("got %v, want ErrUnavailable", err)
}
Expand All @@ -150,7 +160,7 @@ func TestBotClientAnnounce(t *testing.T) {
defer srv.Close()

client := NewBotClient(srv.URL, "secret", time.Second)
if err := client.Announce(context.Background(), "hello world"); err != nil {
if err := client.Announce(context.Background(), "chan-1", "hello world"); err != nil {
t.Fatalf("Announce: %v", err)
}

Expand All @@ -161,6 +171,10 @@ func TestBotClientAnnounce(t *testing.T) {
if body.Content != "hello world" {
t.Errorf("content = %q", body.Content)
}

if body.ChannelID != "chan-1" {
t.Errorf("channel id = %q", body.ChannelID)
}
}

func TestBotClientSetNickname(t *testing.T) {
Expand Down
8 changes: 4 additions & 4 deletions internal/http/handlers/discord_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ type fakeBot struct {
nickname string
}

func (f *fakeBot) JoinGuild(_ context.Context, _, _ string) error { return nil }
func (f *fakeBot) GrantRole(_ context.Context, _ string) error { return f.grantErr }
func (f *fakeBot) JoinGuild(_ context.Context, _, _, _ string) error { return nil }
func (f *fakeBot) GrantRole(_ context.Context, _, _ string) error { return f.grantErr }
func (f *fakeBot) KickMember(_ context.Context, _ string) error {
f.kicked = true
return nil
Expand All @@ -40,7 +40,7 @@ func (f *fakeBot) GetMember(_ context.Context, _ string) (*discord.Member, error
return &discord.Member{}, nil
}

func (f *fakeBot) Announce(_ context.Context, content string) error {
func (f *fakeBot) Announce(_ context.Context, _, content string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.announced = append(f.announced, content)
Expand Down Expand Up @@ -97,7 +97,7 @@ func discordEnabledHandler(env handlerEnv, bot discord.BotAPI, user *discord.Use
SuccessRedirect: "http://localhost:3000/profile",
InviteURL: "https://discord.gg/invite",
}
discordSvc := service.NewDiscordService(cfg.Discord, repo.NewDiscordRepo(env.db), env.userRepo, bot, fakeOAuth{user: user}, env.redis)
discordSvc := service.NewDiscordService(cfg.Discord, repo.NewDiscordRepo(env.db), env.userRepo, env.divisionRepo, bot, fakeOAuth{user: user}, env.redis)
return New(cfg, env.authSvc, env.ctfSvc, env.appConfigSvc, env.userSvc, env.scoreSvc, env.divisionSvc, env.teamSvc, env.vmSvc, env.redis, discordSvc)
}

Expand Down
23 changes: 22 additions & 1 deletion internal/http/handlers/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -1384,7 +1384,7 @@ func (h *Handler) CreateDivision(ctx *gin.Context) {
return
}

division, err := h.divs.CreateDivision(ctx.Request.Context(), req.Name)
division, err := h.divs.CreateDivision(ctx.Request.Context(), req.Name, req.DiscordRoleID, req.DiscordAnnounceChannelID)
if err != nil {
writeError(ctx, err)
return
Expand All @@ -1393,6 +1393,27 @@ func (h *Handler) CreateDivision(ctx *gin.Context) {
ctx.JSON(http.StatusCreated, division)
}

func (h *Handler) UpdateDivision(ctx *gin.Context) {
id, ok := parseIDParamOrError(ctx, "id")
if !ok {
return
}

var req updateDivisionRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
writeBindError(ctx, err)
return
}

division, err := h.divs.UpdateDivision(ctx.Request.Context(), id, req.Name, req.DiscordRoleID, req.DiscordAnnounceChannelID)
if err != nil {
writeError(ctx, err)
return
}

ctx.JSON(http.StatusOK, division)
}

// Team Handlers

func (h *Handler) CreateTeam(ctx *gin.Context) {
Expand Down
Loading
Loading