aboutsummaryrefslogtreecommitdiff
path: root/main.go
diff options
context:
space:
mode:
Diffstat (limited to 'main.go')
-rw-r--r--main.go629
1 files changed, 629 insertions, 0 deletions
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..05b6529
--- /dev/null
+++ b/main.go
@@ -0,0 +1,629 @@
+package main
+
+import (
+ "fmt"
+ "io"
+ "log"
+ "net"
+ "net/http"
+ "os"
+ "path"
+ "slices"
+ "strconv"
+ "strings"
+ "time"
+)
+
+type (
+ THttpHandler = func(Context *THttpRequestContext)
+
+ THttpRoute struct {
+ Method string
+ Prefix string
+ AllowParams bool
+ Handler THttpHandler
+ }
+
+ THttpRouter struct {
+ Routes []THttpRoute
+ NotFound THttpHandler
+ }
+
+ THttpRequestContext struct {
+ Request *http.Request
+ Writer http.ResponseWriter
+ Prefix string
+ Params []string
+ IPAddress string
+ SessionID []byte
+ AccountID int
+ }
+)
+
+var (
+ // HTTP/HTTPS Config
+ g_HttpPort int = 80
+ g_HttpsPort int = 443
+ g_HttpsCertFile string = ""
+ g_HttpsKeyFile string = ""
+
+ // SMTP Config
+ g_SmtpHost string = "smtp.domain.com"
+ g_SmtpPort int = 587
+ g_SmtpUser string = "username"
+ g_SmtpPassword string = ""
+ g_SmtpSender string = "support@domain.com"
+
+ // Query Manager Config
+ g_QueryManagerHost string = "localhost"
+ g_QueryManagerPort int = 7174
+ g_QueryManagerPassword string = ""
+
+ // Query Manager Cache Config
+ g_MaxCachedAccounts = 4096
+ g_MaxCachedCharacters = 4096
+ g_CharacterRefreshInterval = 15 * time.Minute
+ g_WorldsRefreshInterval = 15 * time.Minute
+ g_OnlineCharactersRefreshInterval = 15 * time.Minute
+ g_KillStatisticsRefreshInterval = 30 * time.Minute
+
+ // Loggers
+ g_Log = log.New(os.Stderr, "INFO ", log.Ldate|log.Ltime|log.Lmsgprefix)
+ g_LogWarn = log.New(os.Stderr, "WARN ", log.Ldate|log.Ltime|log.Lshortfile|log.Lmsgprefix)
+ g_LogErr = log.New(os.Stderr, "ERR ", log.Ldate|log.Ltime|log.Lshortfile|log.Lmsgprefix)
+)
+
+func WebKVCallback(Key string, Value string) {
+ if strings.EqualFold(Key, "HttpPort") {
+ g_HttpPort = ParseInteger(Value)
+ } else if strings.EqualFold(Key, "HttpsPort") {
+ g_HttpsPort = ParseInteger(Value)
+ } else if strings.EqualFold(Key, "HttpsCertFile") {
+ g_HttpsCertFile = ParseString(Value)
+ } else if strings.EqualFold(Key, "HttpsKeyFile") {
+ g_HttpsKeyFile = ParseString(Value)
+ } else if strings.EqualFold(Key, "SmtpHost") {
+ g_SmtpHost = ParseString(Value)
+ } else if strings.EqualFold(Key, "SmtpPort") {
+ g_SmtpPort = ParseInteger(Value)
+ } else if strings.EqualFold(Key, "SmtpUser") {
+ g_SmtpUser = ParseString(Value)
+ } else if strings.EqualFold(Key, "SmtpPassword") {
+ g_SmtpPassword = ParseString(Value)
+ } else if strings.EqualFold(Key, "SmtpSender") {
+ g_SmtpSender = ParseString(Value)
+ } else if strings.EqualFold(Key, "QueryManagerHost") {
+ g_QueryManagerHost = ParseString(Value)
+ } else if strings.EqualFold(Key, "QueryManagerPort") {
+ g_QueryManagerPort = ParseInteger(Value)
+ } else if strings.EqualFold(Key, "QueryManagerPassword") {
+ g_QueryManagerPassword = ParseString(Value)
+ } else if strings.EqualFold(Key, "CharacterRefreshInterval") {
+ g_CharacterRefreshInterval = ParseDuration(Value)
+ } else if strings.EqualFold(Key, "WorldsRefreshInterval") {
+ g_WorldsRefreshInterval = ParseDuration(Value)
+ } else if strings.EqualFold(Key, "OnlineCharactersRefreshInterval") {
+ g_OnlineCharactersRefreshInterval = ParseDuration(Value)
+ } else if strings.EqualFold(Key, "KillStatisticsRefreshInterval") {
+ g_KillStatisticsRefreshInterval = ParseDuration(Value)
+ } else if strings.EqualFold(Key, "MaxCachedAccounts") {
+ g_MaxCachedAccounts = ParseInteger(Value)
+ } else if strings.EqualFold(Key, "MaxCachedCharacters") {
+ g_MaxCachedCharacters = ParseInteger(Value)
+ } else {
+ g_LogWarn.Printf("Unknown config \"%v\"", Key)
+ }
+}
+
+func (Route *THttpRoute) LessThan(Method string, Prefix string, AllowParams bool) bool {
+ if Route.Method != Method {
+ return Route.Method < Method
+ } else if Route.Prefix != Prefix {
+ return Route.Prefix < Prefix
+ } else {
+ // NOTE(fusion): Use `false < true` convention.
+ return !Route.AllowParams && AllowParams
+ }
+}
+
+func (Router *THttpRouter) Add(Method string, Prefix string, Handler THttpHandler) {
+ AllowParams := false
+ if Prefix != "/" && Prefix[len(Prefix)-1] == '/' {
+ AllowParams = true
+ Prefix = Prefix[:len(Prefix)-1]
+ }
+
+ Index := 0
+ for ; Index < len(Router.Routes); Index += 1 {
+ if !Router.Routes[Index].LessThan(Method, Prefix, AllowParams) {
+ break
+ }
+ }
+
+ if Index < len(Router.Routes) &&
+ Router.Routes[Index].Method == Method &&
+ Router.Routes[Index].Prefix == Prefix &&
+ Router.Routes[Index].AllowParams == AllowParams {
+ if AllowParams {
+ g_LogErr.Printf("Discarding duplicate route \"%v %v[/params]\"",
+ Method, Prefix)
+ } else {
+ g_LogErr.Printf("Discarding duplicate route \"%v %v\"",
+ Method, Prefix)
+ }
+ return
+ }
+
+ Router.Routes = slices.Insert(Router.Routes, Index,
+ THttpRoute{
+ Method: Method,
+ Prefix: Prefix,
+ AllowParams: AllowParams,
+ Handler: Handler,
+ })
+}
+
+func GetRequestIPAddress(Request *http.Request) string {
+ // NOTE(fusion): `Request.RemoteAddr` should to be in the exact format
+ // expected by `net.SplitHostPort` so I expect this to NEVER fail.
+ IPAddress, _, Err := net.SplitHostPort(Request.RemoteAddr)
+ if Err != nil {
+ g_LogErr.Printf("net.SplitHostPort(\"%v\") failed: %v", Request.RemoteAddr, Err)
+ return ""
+ }
+
+ return IPAddress
+}
+
+func (Router *THttpRouter) ServeHTTP(Writer http.ResponseWriter, Request *http.Request) {
+ Path := Request.URL.Path
+ if Path == "" {
+ Path = "/"
+ }
+
+ IPAddress := GetRequestIPAddress(Request)
+ if IPAddress == "" {
+ http.Error(Writer, "", http.StatusBadRequest)
+ return
+ }
+
+ SessionID := GetRequestSessionID(Request)
+ Context := THttpRequestContext{
+ Request: Request,
+ Writer: Writer,
+ Prefix: Path,
+ Params: nil,
+ IPAddress: IPAddress,
+ SessionID: SessionID,
+ AccountID: SessionLookup(SessionID, IPAddress),
+ }
+
+ for Index := len(Router.Routes) - 1; Index >= 0; Index -= 1 {
+ Route := &Router.Routes[Index]
+ if Route.Method != "" && Route.Method != Request.Method {
+ continue
+ }
+
+ Suffix, Found := strings.CutPrefix(Path, Route.Prefix)
+ if Found && (Suffix == "" || Suffix[0] == '/') {
+ Params := SplitDiscardEmpty(Suffix, "/")
+ if Route.AllowParams || len(Params) == 0 {
+ Context.Prefix = Route.Prefix
+ Context.Params = Params
+ Route.Handler(&Context)
+ return
+ }
+ }
+ }
+
+ Router.NotFound(&Context)
+}
+
+func Redirect(Context *THttpRequestContext, Path string) {
+ Context.Writer.Header().Set("Location", Path)
+ Context.Writer.WriteHeader(http.StatusTemporaryRedirect)
+}
+
+func RequestError(Context *THttpRequestContext, Status int) {
+ g_LogErr.Printf("Failed to serve request \"%v %v\" to \"%v\": (%v) %v",
+ Context.Request.Method, Context.Request.URL.Path, Context.Request.RemoteAddr,
+ Status, http.StatusText(Status))
+ RenderRequestError(Context, Status)
+}
+
+func BadRequest(Context *THttpRequestContext) {
+ RequestError(Context, http.StatusBadRequest)
+}
+
+func Forbidden(Context *THttpRequestContext) {
+ RequestError(Context, http.StatusForbidden)
+}
+
+func NotFound(Context *THttpRequestContext) {
+ RequestError(Context, http.StatusNotFound)
+}
+
+func InternalError(Context *THttpRequestContext) {
+ RequestError(Context, http.StatusInternalServerError)
+}
+
+func ResourceError(Context *THttpRequestContext, Status int) {
+ // IMPORTANT(fusion): This is used for resource errors in which case we
+ // don't want to render any HTML to avoid pointless traffic. `http.Error`
+ // should send a minimal response with the appropriate status code.
+ g_LogErr.Printf("Failed to fetch resource \"%v %v\" to \"%v\": (%v) %v",
+ Context.Request.Method, Context.Request.URL.Path, Context.Request.RemoteAddr,
+ Status, http.StatusText(Status))
+ http.Error(Context.Writer, "", Status)
+}
+
+func HandleResource(Context *THttpRequestContext) {
+ if len(Context.Params) == 0 {
+ ResourceError(Context, http.StatusNotFound)
+ return
+ }
+
+ FileName := path.Join(Context.Params...)
+ File, Err := os.OpenInRoot("./res", FileName)
+ if Err != nil {
+ g_LogErr.Printf("Failed to open file (%v): %v", FileName, Err)
+ ResourceError(Context, http.StatusNotFound)
+ return
+ }
+ defer File.Close()
+
+ Stat, Err := File.Stat()
+ if Err != nil {
+ g_LogErr.Printf("Failed to retrieve file description (%v): %v", FileName, Err)
+ ResourceError(Context, http.StatusInternalServerError)
+ return
+ }
+
+ // NOTE(fusion): File headers.
+ switch path.Ext(FileName) {
+ case ".css":
+ Context.Writer.Header().Set("Content-Type", "text/css")
+ case ".jpg", ".jpeg":
+ Context.Writer.Header().Set("Content-Type", "image/jpeg")
+ case ".js":
+ Context.Writer.Header().Set("Content-Type", "text/javascript")
+ case ".png":
+ Context.Writer.Header().Set("Content-Type", "image/png")
+ default:
+ Context.Writer.Header().Set("Content-Disposition",
+ fmt.Sprintf("attachment; filename=\"%v\"", FileName))
+ Context.Writer.Header().Set("Content-Type", "application/octet-stream")
+ }
+ Context.Writer.Header().Set("Content-Length", strconv.FormatInt(Stat.Size(), 10))
+ Context.Writer.Header().Set("Date", time.Now().UTC().Format(http.TimeFormat))
+ Context.Writer.Header().Set("Last-Modified", Stat.ModTime().UTC().Format(http.TimeFormat))
+
+ // NOTE(fusion): File contents.
+ TotalRead := 0
+ TotalWritten := 0
+ for {
+ var Buffer [1024 * 1024]byte
+ BytesRead, Err := File.Read(Buffer[:])
+ if Err != nil && Err != io.EOF {
+ g_LogErr.Printf("Failed to read resource (%v:%v): %v", FileName, TotalRead, Err)
+ return
+ }
+
+ if BytesRead == 0 {
+ return
+ }
+
+ BytesWritten, Err := Context.Writer.Write(Buffer[:BytesRead])
+ if Err != nil || BytesWritten != BytesRead {
+ g_LogErr.Printf("Failed to write resource (%v:%v): %v", FileName, TotalWritten, Err)
+ return
+ }
+
+ TotalRead += BytesRead
+ TotalWritten += BytesWritten
+ }
+}
+
+func HandleFavicon(Context *THttpRequestContext) {
+ if len(Context.Params) != 0 {
+ ResourceError(Context, http.StatusNotFound)
+ return
+ }
+
+ Context.Params = []string{"favicon.ico"}
+ HandleResource(Context)
+}
+
+func HandleIndex(Context *THttpRequestContext) {
+ Redirect(Context, "/account")
+}
+
+func HandleAccount(Context *THttpRequestContext) {
+ if Context.AccountID > 0 {
+ RenderAccountSummary(Context)
+ return
+ }
+
+ switch Context.Request.Method {
+ case http.MethodGet:
+ RenderAccountLogin(Context)
+ case http.MethodPost:
+ Account := Context.Request.FormValue("account")
+ Password := Context.Request.FormValue("password")
+
+ // TODO(fusion): Other input checks?
+ if Account == "" || Password == "" {
+ RenderMessage(Context, "Login Error", "Account or password is not correct.")
+ return
+ }
+
+ AccountID, Err := strconv.Atoi(Account)
+ if Err != nil {
+ g_LogErr.Printf("Failed to parse account id: %d", Err)
+ RenderMessage(Context, "Login Error", "Account or password is not correct.")
+ return
+ }
+
+ Result := CheckAccountPassword(AccountID, Password, Context.IPAddress)
+ switch Result {
+ case 0:
+ // NOTE(fusion): Invalidate account's cached data just in case.
+ InvalidateAccountCachedData(AccountID)
+ SessionStart(Context, AccountID)
+ RenderAccountSummary(Context)
+ case 1, 2:
+ RenderMessage(Context, "Login Error", "Account or password is not correct.")
+ case 3:
+ RenderMessage(Context, "Login Error", "Account disabled for five minutes.")
+ case 4:
+ RenderMessage(Context, "Login Error", "IP address blocked for 30 minutes.")
+ case 5:
+ RenderMessage(Context, "Login Error", "Your account is banished.")
+ case 6:
+ RenderMessage(Context, "Login Error", "Your IP address is banished.")
+ default:
+ RenderMessage(Context, "Login Error", "Internal error.")
+ }
+ default:
+ NotFound(Context)
+ }
+}
+
+func HandleAccountLogout(Context *THttpRequestContext) {
+ SessionEnd(Context)
+ Redirect(Context, "/account")
+}
+
+func HandleAccountCreate(Context *THttpRequestContext) {
+ if Context.AccountID > 0 {
+ Redirect(Context, "/account")
+ return
+ }
+
+ switch Context.Request.Method {
+ case http.MethodGet:
+ RenderAccountCreate(Context)
+ case http.MethodPost:
+ Account := Context.Request.FormValue("account")
+ Email := Context.Request.FormValue("email")
+ Password := Context.Request.FormValue("password")
+
+ if Account == "" || Email == "" || Password == "" {
+ RenderMessage(Context, "Create Account Error", "All inputs are REQUIRED.")
+ return
+ }
+
+ AccountID, Err := strconv.Atoi(Account)
+ if Err != nil {
+ g_LogErr.Printf("Failed to parse account id: %d", Err)
+ RenderMessage(Context, "Create Account Error", "Invalid account number.")
+ return
+ }
+
+ if Email != Context.Request.FormValue("email_confirm") {
+ RenderMessage(Context, "Create Account Error", "Emails don't match.")
+ return
+ }
+
+ if Password != Context.Request.FormValue("password_confirm") {
+ RenderMessage(Context, "Create Account Error", "Passwords don't match.")
+ return
+ }
+
+ if AccountID < 100000 || AccountID > 999999999 {
+ RenderMessage(Context, "Create Account Error", "Account number must contain 6-9 digits.")
+ return
+ }
+
+ // TODO(fusion): Proper email and password checking.
+ if len(Password) < 8 {
+ RenderMessage(Context, "Create Account Error", "Password must contain at least 8 characters.")
+ return
+ }
+
+ Result := CreateAccount(AccountID, Email, Password)
+ switch Result {
+ case 0:
+ RenderMessage(Context, "Account Created",
+ "Your account has been created. Head back to the login page to access it.")
+ case 1:
+ RenderMessage(Context, "Create Account Error", "An account with that number already exists.")
+ case 2:
+ RenderMessage(Context, "Create Account Error", "An account with that email already exists.")
+ default:
+ RenderMessage(Context, "Create Account Error", "Internal error.")
+ }
+ default:
+ NotFound(Context)
+ }
+}
+
+func HandleAccountRecover(Context *THttpRequestContext) {
+ if Context.AccountID > 0 {
+ Redirect(Context, "/account")
+ return
+ }
+
+ switch Context.Request.Method {
+ case http.MethodGet:
+ RenderAccountRecover(Context)
+ default:
+ NotFound(Context)
+ }
+}
+
+func HandleCharacterCreate(Context *THttpRequestContext) {
+ if Context.AccountID <= 0 {
+ Redirect(Context, "/account")
+ return
+ }
+
+ switch Context.Request.Method {
+ case http.MethodGet:
+ RenderCharacterCreate(Context)
+ case http.MethodPost:
+ World := strings.TrimSpace(Context.Request.FormValue("world"))
+ if World == "" || GetWorld(World) == nil {
+ RenderMessage(Context, "Create Character Error", "Invalid world.")
+ return
+ }
+
+ // TODO(fusion): Proper name checking.
+ Name := strings.TrimSpace(Context.Request.FormValue("name"))
+ if len(Name) < 4 || len(Name) > 25 {
+ RenderMessage(Context, "Create Character Error", "Name must contain between 8 and 25 characters.")
+ return
+ }
+
+ Sex, Err := strconv.Atoi(Context.Request.FormValue("sex"))
+ if Err != nil || (Sex != 1 && Sex != 2) {
+ if Err != nil {
+ g_LogErr.Printf("Failed to parse character sex: %v", Err)
+ }
+ RenderMessage(Context, "Create Character Error", "Invalid sex.")
+ return
+ }
+
+ Result := CreateCharacter(World, Context.AccountID, Name, Sex)
+ switch Result {
+ case 0:
+ // NOTE(fusion): Invalidate account's cached data so the new character
+ // is displayed in the account's summary.
+ InvalidateAccountCachedData(Context.AccountID)
+ RenderMessage(Context, "Character Created",
+ fmt.Sprintf("And so, %v was brought into this world. May fortune"+
+ " favor your blade and guide your steps through the trials ahead.",
+ Name))
+ case 1:
+ RenderMessage(Context, "Create Character Error",
+ "Weirdly enough, the selected world doesn't exist. What have you been up to?")
+ case 2:
+ RenderMessage(Context, "Create Character Error",
+ "Weirdly enough, your account doesn't exist. What have you been up to?")
+ case 3:
+ RenderMessage(Context, "Create Character Error", "A character with that name already exists.")
+ default:
+ RenderMessage(Context, "Create Character Error", "Internal error.")
+ }
+ default:
+ NotFound(Context)
+ }
+}
+
+func HandleCharacterProfile(Context *THttpRequestContext) {
+ QueryValues := Context.Request.URL.Query()
+ CharacterName := QueryValues.Get("name")
+ if CharacterName == "" {
+ RenderCharacterProfile(Context, nil)
+ } else {
+ Result, Character := GetCharacterProfile(CharacterName)
+ switch Result {
+ case 0:
+ RenderCharacterProfile(Context, &Character)
+ case 1:
+ RenderMessage(Context, "Search Error", "A character with that name doesn't exist.")
+ default:
+ RenderMessage(Context, "Search Error", "Internal error.")
+ }
+ }
+}
+
+func HandleKillStatistics(Context *THttpRequestContext) {
+ QueryValues := Context.Request.URL.Query()
+ WorldName := QueryValues.Get("world")
+ if WorldName == "" || GetWorld(WorldName) == nil {
+ Redirect(Context, "/world")
+ } else {
+ RenderKillStatistics(Context, WorldName)
+ }
+}
+
+func HandleWorld(Context *THttpRequestContext) {
+ QueryValues := Context.Request.URL.Query()
+ WorldName := QueryValues.Get("name")
+ if WorldName == "" || GetWorld(WorldName) == nil {
+ RenderWorldList(Context)
+ } else {
+ RenderWorldInfo(Context, WorldName)
+ }
+}
+
+func main() {
+ g_Log.Print("Tibia Web Server v0.1")
+ if !ReadConfig("config.cfg", WebKVCallback) {
+ return
+ }
+
+ defer ExitQuery()
+ defer ExitMail()
+ defer ExitTemplates()
+ if !InitQuery() || !InitMail() || !InitTemplates() {
+ return
+ }
+
+ Router := THttpRouter{}
+ Router.Add("GET", "/res/", HandleResource)
+ Router.Add("GET", "/favicon.ico", HandleFavicon)
+ Router.Add("GET", "/", HandleIndex)
+ Router.Add("GET", "/index", HandleIndex)
+ Router.Add("GET", "/account", HandleAccount)
+ Router.Add("POST", "/account", HandleAccount)
+ Router.Add("GET", "/account/logout", HandleAccountLogout)
+ Router.Add("GET", "/account/create", HandleAccountCreate)
+ Router.Add("POST", "/account/create", HandleAccountCreate)
+ Router.Add("GET", "/account/recover", HandleAccountRecover)
+ Router.Add("POST", "/account/recover", HandleAccountRecover)
+ Router.Add("GET", "/character/create", HandleCharacterCreate)
+ Router.Add("POST", "/character/create", HandleCharacterCreate)
+ Router.Add("GET", "/character", HandleCharacterProfile)
+ Router.Add("GET", "/killstatistics", HandleKillStatistics)
+ Router.Add("GET", "/world", HandleWorld)
+ Router.NotFound = NotFound
+
+ // NOTE(fusion): Force the server to run on IPv4 because that is the only
+ // format the query manager currently handles. Trying to use IPv6 will cause
+ // queries to fail.
+ if FileExists(g_HttpsCertFile) && FileExists(g_HttpsKeyFile) {
+ Listener, Err := net.Listen("tcp4", JoinHostPort("", g_HttpsPort))
+ if Err != nil {
+ g_LogErr.Printf("Failed to listen to HTTPS port %v: %v", g_HttpsPort, Err)
+ return
+ }
+
+ g_Log.Printf("Running over HTTPS on port %v", g_HttpsPort)
+ g_Log.Print(http.ServeTLS(Listener, &Router, g_HttpsCertFile, g_HttpsKeyFile))
+ } else {
+ g_LogWarn.Print("The server is setup to run over HTTP which is NOT SECURE" +
+ " and prone to a man-in-the-middle or eavesdropping attack. This setup" +
+ " may only be used for TESTING.")
+
+ Listener, Err := net.Listen("tcp4", JoinHostPort("", g_HttpPort))
+ if Err != nil {
+ g_LogErr.Printf("Failed to listen to HTTP port %v: %v", g_HttpPort, Err)
+ return
+ }
+
+ g_Log.Printf("Running over HTTP on port %v", g_HttpPort)
+ g_Log.Print(http.Serve(Listener, &Router))
+ }
+}