diff --git a/config.json b/config.json index 61b40b055..5e23789d8 100644 --- a/config.json +++ b/config.json @@ -89,6 +89,10 @@ "Enabled": true, "Port": 53312 }, + "NewSign": { + "Enabled": true, + "Port": 8080 + }, "Channel": { "Enabled": true }, diff --git a/config/config.go b/config/config.go index d2804b9fc..8fde5293a 100644 --- a/config/config.go +++ b/config/config.go @@ -25,6 +25,7 @@ type Config struct { Database Database Launcher Launcher Sign Sign + NewSign NewSign Channel Channel Entrance Entrance } @@ -99,6 +100,12 @@ type Sign struct { Port int } +// NewSign holds the new sign server config +type NewSign struct { + Enabled bool + Port int +} + type Channel struct { Enabled bool } diff --git a/main.go b/main.go index 5fdf11d9a..108e32d7d 100644 --- a/main.go +++ b/main.go @@ -13,6 +13,7 @@ import ( "erupe-ce/server/discordbot" "erupe-ce/server/entranceserver" "erupe-ce/server/launcherserver" + "erupe-ce/server/newsignserver" "erupe-ce/server/signserver" "github.com/jmoiron/sqlx" @@ -166,6 +167,22 @@ func main() { logger.Info("Started sign server") } + // New Sign server + var newSignServer *newsignserver.Server + if config.ErupeConfig.NewSign.Enabled { + newSignServer = newsignserver.NewServer( + &newsignserver.Config{ + Logger: logger.Named("sign"), + ErupeConfig: config.ErupeConfig, + DB: db, + }) + err = newSignServer.Start() + if err != nil { + preventClose(fmt.Sprintf("Failed to start new sign server: %s", err.Error())) + } + logger.Info("Started new sign server") + } + var channels []*channelserver.Server if config.ErupeConfig.Channel.Enabled { @@ -229,6 +246,10 @@ func main() { signServer.Shutdown() } + if config.ErupeConfig.NewSign.Enabled { + newSignServer.Shutdown() + } + if config.ErupeConfig.Entrance.Enabled { entranceServer.Shutdown() } diff --git a/server/newsignserver/dbutils.go b/server/newsignserver/dbutils.go new file mode 100644 index 000000000..6b86dedd4 --- /dev/null +++ b/server/newsignserver/dbutils.go @@ -0,0 +1,121 @@ +package newsignserver + +import ( + "context" + "database/sql" + "fmt" + "time" + + "golang.org/x/crypto/bcrypt" +) + +func (s *Server) createNewUser(ctx context.Context, username string, password string) (int, error) { + // Create salted hash of user password + passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return 0, err + } + + var id int + err = s.db.QueryRowContext( + ctx, ` + INSERT INTO users (username, password, return_expires) + VALUES ($1, $2, $3) + RETURNING id + `, + username, string(passwordHash), time.Now().Add(time.Hour*24*30), + ).Scan(&id) + return id, err +} + +func (s *Server) createLoginToken(ctx context.Context, uid int) (string, error) { + token := randSeq(16) + _, err := s.db.ExecContext(ctx, "INSERT INTO sign_sessions (user_id, token) VALUES ($1, $2)", uid, token) + if err != nil { + return "", err + } + return token, nil +} + +func (s *Server) userIDFromToken(ctx context.Context, token string) (int, error) { + var userID int + err := s.db.QueryRowContext(ctx, "SELECT user_id FROM sign_sessions WHERE token = $1", token).Scan(&userID) + if err == sql.ErrNoRows { + return 0, fmt.Errorf("invalid login token") + } else if err != nil { + return 0, err + } + return userID, nil +} + +func (s *Server) createCharacter(ctx context.Context, userID int) (int, error) { + var charID int + err := s.db.QueryRowContext(ctx, + "SELECT id FROM characters WHERE is_new_character = true AND user_id = $1", + userID, + ).Scan(&charID) + if err == sql.ErrNoRows { + err = s.db.QueryRowContext(ctx, ` + INSERT INTO characters ( + user_id, is_female, is_new_character, name, unk_desc_string, + hrp, gr, weapon_type, last_login + ) + VALUES ($1, false, true, '', '', 0, 0, 0, $2) + RETURNING id`, + userID, uint32(time.Now().Unix()), + ).Scan(&charID) + } + return charID, err +} + +func (s *Server) deleteCharacter(ctx context.Context, userID int, charID int) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + _, err = tx.ExecContext( + ctx, ` + DELETE FROM login_boost_state + WHERE char_id = $1`, + charID, + ) + if err != nil { + return err + } + _, err = tx.ExecContext( + ctx, ` + DELETE FROM guild_characters + WHERE character_id = $1`, + charID, + ) + if err != nil { + return err + } + _, err = tx.ExecContext( + ctx, ` + DELETE FROM characters + WHERE user_id = $1 AND id = $2`, + userID, charID, + ) + if err != nil { + return err + } + return tx.Commit() +} + +func (s *Server) getCharactersForUser(ctx context.Context, uid int) ([]Character, error) { + characters := make([]Character, 0) + err := s.db.SelectContext( + ctx, &characters, ` + SELECT id, name, is_female, weapon_type, hrp, gr, last_login + FROM characters + WHERE user_id = $1 AND deleted = false AND is_new_character = false ORDER BY id ASC`, + uid, + ) + if err != nil { + return nil, err + } + return characters, nil +} diff --git a/server/newsignserver/endpoints.go b/server/newsignserver/endpoints.go new file mode 100644 index 000000000..4538f005e --- /dev/null +++ b/server/newsignserver/endpoints.go @@ -0,0 +1,218 @@ +package newsignserver + +import ( + "database/sql" + "encoding/json" + "errors" + "math/rand" + "net/http" + "time" + + "github.com/lib/pq" + "go.uber.org/zap" + "golang.org/x/crypto/bcrypt" +) + +func randSeq(n int) string { + var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + b := make([]rune, n) + for i := range b { + b[i] = letters[rand.Intn(len(letters))] + } + return string(b) +} + +type LauncherMessage struct { + Message string `json:"message"` + Date int64 `json:"date"` + Link string `json:"link"` +} + +type Character struct { + ID int `json:"id"` + Name string `json:"name"` + IsFemale bool `json:"isFemale" db:"is_female"` + Weapon int `json:"weapon" db:"weapon_type"` + HR int `json:"hr" db:"hrp"` + GR int `json:"gr"` + LastLogin int64 `json:"lastLogin" db:"last_login"` +} + +func (s *Server) Launcher(w http.ResponseWriter, r *http.Request) { + var respData struct { + Important []LauncherMessage `json:"important"` + Normal []LauncherMessage `json:"normal"` + } + respData.Important = []LauncherMessage{ + { + Message: "Server Update 9 Released!", + Date: time.Date(2022, 8, 2, 0, 0, 0, 0, time.UTC).Unix(), + Link: "https://discord.com/channels/368424389416583169/929509970624532511/1003985850255818762", + }, + { + Message: "Eng 2.0 & Ravi Patch Released!", + Date: time.Date(2022, 5, 3, 0, 0, 0, 0, time.UTC).Unix(), + Link: "https://discord.com/channels/368424389416583169/929509970624532511/969305400795078656", + }, + { + Message: "Launcher Patch V1.0 Released!", + Date: time.Date(2022, 4, 24, 0, 0, 0, 0, time.UTC).Unix(), + Link: "https://discord.com/channels/368424389416583169/929509970624532511/969286397301248050", + }, + } + respData.Normal = []LauncherMessage{ + { + Message: "Join the community Discord for updates!", + Date: time.Date(2022, 4, 24, 0, 0, 0, 0, time.UTC).Unix(), + Link: "https://discord.gg/CFnzbhQ", + }, + } + w.WriteHeader(200) + w.Header().Add("Content-Type", "application/json") + json.NewEncoder(w).Encode(respData) +} + +func (s *Server) Login(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + var reqData struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&reqData); err != nil { + s.logger.Error("JSON decode error", zap.Error(err)) + w.WriteHeader(400) + w.Write([]byte("Invalid data received")) + return + } + var ( + userID int + password string + ) + err := s.db.QueryRow("SELECT id, password FROM users WHERE username = $1", reqData.Username).Scan(&userID, &password) + if err == sql.ErrNoRows { + w.WriteHeader(400) + w.Write([]byte("Username does not exist")) + return + } else if err != nil { + s.logger.Warn("SQL query error", zap.Error(err)) + w.WriteHeader(500) + return + } + if bcrypt.CompareHashAndPassword([]byte(password), []byte(reqData.Password)) != nil { + w.WriteHeader(400) + w.Write([]byte("Your password is incorrect")) + return + } + + var respData struct { + Token string `json:"token"` + Characters []Character `json:"characters"` + } + respData.Token, err = s.createLoginToken(ctx, userID) + if err != nil { + s.logger.Warn("Error registering login token", zap.Error(err)) + w.WriteHeader(500) + return + } + respData.Characters, err = s.getCharactersForUser(ctx, userID) + if err != nil { + s.logger.Warn("Error getting characters from DB", zap.Error(err)) + w.WriteHeader(500) + return + } + w.WriteHeader(200) + w.Header().Add("Content-Type", "application/json") + json.NewEncoder(w).Encode(respData) +} + +func (s *Server) Register(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + var reqData struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&reqData); err != nil { + s.logger.Error("JSON decode error", zap.Error(err)) + w.WriteHeader(400) + w.Write([]byte("Invalid data received")) + return + } + s.logger.Info("Creating account", zap.String("username", reqData.Username)) + userID, err := s.createNewUser(ctx, reqData.Username, reqData.Password) + if err != nil { + var pqErr *pq.Error + if errors.As(err, &pqErr) && pqErr.Constraint == "users_username_key" { + w.WriteHeader(400) + w.Write([]byte("User already exists")) + return + } + s.logger.Error("Error checking user", zap.Error(err), zap.String("username", reqData.Username)) + w.WriteHeader(500) + return + } + + var respData struct { + Token string `json:"token"` + } + respData.Token, err = s.createLoginToken(ctx, userID) + if err != nil { + s.logger.Error("Error registering login token", zap.Error(err)) + w.WriteHeader(500) + return + } + json.NewEncoder(w).Encode(respData) +} + +func (s *Server) CreateCharacter(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + var reqData struct { + Token string `json:"token"` + } + if err := json.NewDecoder(r.Body).Decode(&reqData); err != nil { + s.logger.Error("JSON decode error", zap.Error(err)) + w.WriteHeader(400) + w.Write([]byte("Invalid data received")) + return + } + + var respData struct { + CharID int `json:"id"` + } + userID, err := s.userIDFromToken(ctx, reqData.Token) + if err != nil { + w.WriteHeader(401) + return + } + respData.CharID, err = s.createCharacter(ctx, userID) + if err != nil { + s.logger.Error("Failed to create character", zap.Error(err), zap.String("token", reqData.Token)) + w.WriteHeader(500) + return + } + json.NewEncoder(w).Encode(respData) +} + +func (s *Server) DeleteCharacter(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + var reqData struct { + Token string `json:"token"` + CharID int `json:"id"` + } + if err := json.NewDecoder(r.Body).Decode(&reqData); err != nil { + s.logger.Error("JSON decode error", zap.Error(err)) + w.WriteHeader(400) + w.Write([]byte("Invalid data received")) + return + } + userID, err := s.userIDFromToken(ctx, reqData.Token) + if err != nil { + w.WriteHeader(401) + return + } + if err := s.deleteCharacter(ctx, userID, reqData.CharID); err != nil { + s.logger.Error("Failed to delete character", zap.Error(err), zap.String("token", reqData.Token), zap.Int("charID", reqData.CharID)) + w.WriteHeader(500) + return + } + json.NewEncoder(w).Encode(struct{}{}) +} diff --git a/server/newsignserver/newsign_server.go b/server/newsignserver/newsign_server.go new file mode 100644 index 000000000..05643c1cb --- /dev/null +++ b/server/newsignserver/newsign_server.go @@ -0,0 +1,89 @@ +package newsignserver + +import ( + "context" + "erupe-ce/config" + "fmt" + "net/http" + "os" + "sync" + "time" + + "github.com/gorilla/handlers" + "github.com/gorilla/mux" + "github.com/jmoiron/sqlx" + "go.uber.org/zap" +) + +type Config struct { + Logger *zap.Logger + DB *sqlx.DB + ErupeConfig *config.Config +} + +// Server is the MHF custom launcher sign server. +type Server struct { + sync.Mutex + logger *zap.Logger + erupeConfig *config.Config + db *sqlx.DB + httpServer *http.Server + isShuttingDown bool +} + +// NewServer creates a new Server type. +func NewServer(config *Config) *Server { + s := &Server{ + logger: config.Logger, + erupeConfig: config.ErupeConfig, + db: config.DB, + httpServer: &http.Server{}, + } + return s +} + +// Start starts the server in a new goroutine. +func (s *Server) Start() error { + // Set up the routes responsible for serving the launcher HTML, serverlist, unique name check, and JP auth. + r := mux.NewRouter() + r.HandleFunc("/launcher", s.Launcher) + r.HandleFunc("/login", s.Login) + r.HandleFunc("/register", s.Register) + r.HandleFunc("/character/create", s.CreateCharacter) + r.HandleFunc("/character/delete", s.DeleteCharacter) + handler := handlers.CORS(handlers.AllowedHeaders([]string{"Content-Type"}))(r) + s.httpServer.Handler = handlers.LoggingHandler(os.Stdout, handler) + s.httpServer.Addr = fmt.Sprintf(":%d", s.erupeConfig.NewSign.Port) + + serveError := make(chan error, 1) + go func() { + if err := s.httpServer.ListenAndServe(); err != nil { + // Send error if any. + serveError <- err + } + }() + + // Get the error from calling ListenAndServe, otherwise assume it's good after 250 milliseconds. + select { + case err := <-serveError: + return err + case <-time.After(250 * time.Millisecond): + return nil + } +} + +// Shutdown exits the server gracefully. +func (s *Server) Shutdown() { + s.logger.Debug("Shutting down") + + s.Lock() + s.isShuttingDown = true + s.Unlock() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := s.httpServer.Shutdown(ctx); err != nil { + // Just warn because we are shutting down the server anyway. + s.logger.Warn("Got error on httpServer shutdown", zap.Error(err)) + } +}