Skip to content
Open
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 backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM golang:1.24 AS build
FROM golang:1.25 AS build

ENV CGO_ENABLED=1

Expand Down
236 changes: 236 additions & 0 deletions backend/api/handler/admin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
package handler

import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/Rastaiha/bermudia/internal/config"
"github.com/Rastaiha/bermudia/internal/domain"
"github.com/Rastaiha/bermudia/internal/service"
"github.com/go-chi/chi/v5"
"log/slog"
"net/http"
"strings"
)

type admin struct {
cfg config.Config
adminService *service.Admin
}

type adminLoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}

func (a *admin) Login(w http.ResponseWriter, r *http.Request) {
var req adminLoginRequest
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
sendDecodeError(w)
return
}
token, err := a.adminService.Login(r.Context(), req.Username, req.Password)
if err != nil {
if errors.Is(err, domain.ErrUserNotFound) {
sendError(w, http.StatusNotFound, "نام کاربری یا کلمه عبور اشتباه است")
return
}
a.handleAdminError(w, err)
return
}
sendResult(w, map[string]any{
"token": token,
})
}

func (a *admin) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tokenStr := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if tokenStr == "" {
sendError(w, http.StatusUnauthorized, "Missing auth token")
return
}
ok := a.adminService.ValidateToken(r.Context(), tokenStr)
if !ok {
sendError(w, http.StatusUnauthorized, "Invalid auth token")
return
}

next.ServeHTTP(w, r)
})
}

func (a *admin) GetTerritories(w http.ResponseWriter, r *http.Request) {
result, err := a.adminService.GetTerritories(r.Context())
if err != nil {
a.handleAdminError(w, err)
return
}
sendResult(w, result)
}

func (a *admin) SetTerritory(w http.ResponseWriter, r *http.Request) {
var territory domain.Territory
if err := json.NewDecoder(r.Body).Decode(&territory); err != nil {
sendDecodeError(w)
return
}
if err := a.adminService.SetTerritory(r.Context(), territory); err != nil {
a.handleAdminError(w, err)
return
}
sendResult(w, territory)
}

func (a *admin) GetBook(w http.ResponseWriter, r *http.Request) {
bookID := chi.URLParam(r, "bookID")
result, err := a.adminService.GetBook(r.Context(), bookID)
if err != nil {
a.handleAdminError(w, err)
return
}
sendResult(w, result)
}

func (a *admin) GetIslandHeader(w http.ResponseWriter, r *http.Request) {
islandID := chi.URLParam(r, "islandID")
result, err := a.adminService.GetIslandHeader(r.Context(), islandID)
if err != nil {
a.handleAdminError(w, err)
return
}
sendResult(w, result)
}

func (a *admin) SetBookAndBindToIsland(w http.ResponseWriter, r *http.Request) {
islandID := chi.URLParam(r, "islandID")
var input service.BookInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
sendDecodeError(w)
return
}
result, err := a.adminService.SetBookAndBindToIsland(r.Context(), islandID, input)
if err != nil {
a.handleAdminError(w, err)
return
}
sendResult(w, result)
}

func (a *admin) GetPools(w http.ResponseWriter, r *http.Request) {
result, err := a.adminService.GetPools(r.Context())
if err != nil {
a.handleAdminError(w, err)
return
}
sendResult(w, result)
}

func (a *admin) SetBookAndBindToPool(w http.ResponseWriter, r *http.Request) {
poolID := chi.URLParam(r, "poolID")
var input service.BookInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
sendDecodeError(w)
return
}
result, err := a.adminService.SetBookAndBindToPool(r.Context(), poolID, input)
if err != nil {
a.handleAdminError(w, err)
return
}
sendResult(w, result)
}

func (a *admin) GetTerritoryIslandBindings(w http.ResponseWriter, r *http.Request) {
territoryID := chi.URLParam(r, "territoryID")
result, err := a.adminService.GetTerritoryIslandBindings(r.Context(), territoryID)
if err != nil {
a.handleAdminError(w, err)
return
}
sendResult(w, result)
}

func (a *admin) SetTerritoryIslandBindings(w http.ResponseWriter, r *http.Request) {
var bindings service.TerritoryIslandBindings
if err := json.NewDecoder(r.Body).Decode(&bindings); err != nil {
sendDecodeError(w)
return
}
result, err := a.adminService.SetTerritoryIslandBindings(r.Context(), bindings)
if err != nil {
a.handleAdminError(w, err)
return
}
sendResult(w, result)
}

func (a *admin) CreateUser(w http.ResponseWriter, r *http.Request) {
var user service.User
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
sendDecodeError(w)
return
}
result, err := a.adminService.CreateUser(r.Context(), user)
if err != nil {
a.handleAdminError(w, err)
return
}
sendResult(w, result)
}

func (a *admin) handleAdminError(w http.ResponseWriter, err error) {
if errors.Is(err, context.Canceled) {
sendError(w, http.StatusRequestTimeout, "Request cancelled")
return
}
var adminError service.AdminError
if errors.As(err, &adminError) {
sendError(w, http.StatusBadRequest, adminError.Error())
return
}
var domainError domain.Error
if errors.As(err, &domainError) {
switch domainError.Reason() {
case domain.ErrorReasonResourceNotFound:
sendError(w, http.StatusNotFound, domainError.Error())
case domain.ErrorReasonRuleViolation:
sendError(w, http.StatusConflict, domainError.Error())
default:
sendError(w, http.StatusInternalServerError, domainError.Error())
}
return
}
switch {
case errors.Is(err, domain.ErrTerritoryNotFound):
sendError(w, http.StatusNotFound, err.Error())
return
case errors.Is(err, domain.ErrIslandNotFound):
sendError(w, http.StatusNotFound, err.Error())
return
case errors.Is(err, domain.ErrBookNotFound):
sendError(w, http.StatusNotFound, err.Error())
return
case errors.Is(err, domain.ErrPoolSettingsNotFound):
sendError(w, http.StatusNotFound, err.Error())
return
}

errText := "<nil>"
if err != nil {
errText = err.Error()
}
slog.Error("internal admin error", slog.String("error", errText))
sendError(w, http.StatusInternalServerError, fmt.Sprintf("Internal server error: %s", errText))
}

func (a *admin) GetUsers(w http.ResponseWriter, r *http.Request) {
result, err := a.adminService.GetUsers(r.Context())
if err != nil {
a.handleAdminError(w, err)
return
}
sendResult(w, result)
}
28 changes: 26 additions & 2 deletions backend/api/handler/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (

type Handler struct {
cfg config.Config
adminHandler *admin
server *http.Server
wsUpgrader websocket.Upgrader
authService *service.Auth
Expand All @@ -30,9 +31,13 @@ type Handler struct {
inboxHub *hub.Hub
}

func New(cfg config.Config, authService *service.Auth, territoryService *service.Territory, islandService *service.Island, playerService *service.Player) *Handler {
func New(cfg config.Config, authService *service.Auth, adminService *service.Admin, territoryService *service.Territory, islandService *service.Island, playerService *service.Player) *Handler {
return &Handler{
cfg: cfg,
cfg: cfg,
adminHandler: &admin{
cfg: cfg,
adminService: adminService,
},
authService: authService,
territoryService: territoryService,
islandService: islandService,
Expand Down Expand Up @@ -105,6 +110,25 @@ func (h *Handler) Start() {
})
})

r.Route("/admin", func(r chi.Router) {
r.Post("/login", h.adminHandler.Login)

r.Group(func(r chi.Router) {
r.Use(h.adminHandler.authMiddleware)
r.Get("/territories", h.adminHandler.GetTerritories)
r.Post("/territories", h.adminHandler.SetTerritory)
r.Get("/territories/{territoryID}/island_bindings", h.adminHandler.GetTerritoryIslandBindings)
r.Post("/territories/{territoryID}/island_bindings", h.adminHandler.SetTerritoryIslandBindings)
r.Get("/books/{bookID}", h.adminHandler.GetBook)
r.Get("/islands/{islandID}", h.adminHandler.GetIslandHeader)
r.Post("/islands/{islandID}/book", h.adminHandler.SetBookAndBindToIsland)
r.Get("/pools", h.adminHandler.GetPools)
r.Post("/pools/{poolID}/books", h.adminHandler.SetBookAndBindToPool)
r.Get("/users", h.adminHandler.GetUsers)
r.Post("/users", h.adminHandler.CreateUser)
})
})

// Health check
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
Expand Down
6 changes: 3 additions & 3 deletions backend/go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/Rastaiha/bermudia

go 1.24.3
go 1.25

require (
github.com/go-chi/chi/v5 v5.2.2
Expand All @@ -10,18 +10,19 @@ require (
require (
github.com/go-co-op/gocron/v2 v2.16.3
github.com/go-telegram/bot v1.17.0
github.com/go-viper/mapstructure/v2 v2.3.0
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.7.5
github.com/knadh/koanf/providers/env v1.1.0
github.com/knadh/koanf/providers/structs v1.0.0
github.com/knadh/koanf/v2 v2.2.2
github.com/mattn/go-sqlite3 v1.14.32
github.com/patrickmn/go-cache v2.1.0+incompatible
)

require (
github.com/fatih/structs v1.1.0 // indirect
github.com/go-viper/mapstructure/v2 v2.3.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
Expand All @@ -30,7 +31,6 @@ require (
github.com/knadh/koanf/maps v0.1.2 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/text v0.28.0 // indirect
Expand Down
2 changes: 2 additions & 0 deletions backend/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ type Config struct {
CorrectionRevertWindow time.Duration `config:"correction_revert_window"`
CreateMock bool `config:"create_mock"`
AdminsGroup int64 `config:"admins_group"`
AdminUsername string `config:"admin_username"`
AdminPassword string `config:"admin_password"`
}

func (c Config) TokenSigningKeyBytes() []byte {
Expand Down
13 changes: 10 additions & 3 deletions backend/internal/domain/island.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,15 @@ type Island struct {
type Book struct {
ID string `json:"id"`
Components []BookComponent `json:"components"`
Treasures []Treasure `json:"treasures"`
}

type BookComponent struct {
IFrame *IslandIFrame `json:"iframe,omitempty"`
Question *Question `json:"question,omitempty"`
IFrame *IslandIFrame `json:"iframe,omitempty"`
Question *QuestionPlaceholder `json:"question,omitempty"`
}

type QuestionPlaceholder struct {
ID string `json:"id"`
}

type IslandContent struct {
Expand Down Expand Up @@ -126,6 +129,10 @@ func IsPoolIdValid(poolId string) bool {
return poolId == PoolEasy || poolId == PoolMedium || poolId == PoolHard
}

func PoolIds() []string {
return []string{PoolEasy, PoolMedium, PoolHard}
}

type TerritoryPoolSettings struct {
Easy int32 `json:"easy"`
Medium int32 `json:"medium"`
Expand Down
9 changes: 2 additions & 7 deletions backend/internal/domain/question.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,12 @@ import (
"time"
)

type Question struct {
ID string `json:"id"`
Text string `json:"text"`
InputType string `json:"inputType"`
InputAccept []string `json:"inputAccept"`
}

type BookQuestion struct {
QuestionID string
BookID string
Text string
InputType string
InputAccept []string
KnowledgeAmount int32
RewardSource string
Context string
Expand Down
Loading