aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore5
-rw-r--r--build.bat1
-rw-r--r--common.go380
-rw-r--r--config.cfg25
-rw-r--r--go.mod3
-rw-r--r--mail.go44
-rw-r--r--main.go629
-rw-r--r--query.go735
-rw-r--r--res/css/style.css90
-rw-r--r--run.bat1
-rw-r--r--session.go142
-rw-r--r--templates.go223
-rw-r--r--templates/_footer.tmpl22
-rw-r--r--templates/_header.tmpl31
-rw-r--r--templates/account_create.tmpl22
-rw-r--r--templates/account_login.tmpl13
-rw-r--r--templates/account_recover.tmpl11
-rw-r--r--templates/account_summary.tmpl65
-rw-r--r--templates/character_create.tmpl23
-rw-r--r--templates/character_profile.tmpl60
-rw-r--r--templates/killstatistics.tmpl28
-rw-r--r--templates/message.tmpl6
-rw-r--r--templates/world_info.tmpl62
-rw-r--r--templates/world_list.tmpl23
24 files changed, 2644 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d680a30
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+.vscode
+bin
+build
+local
+*.log
diff --git a/build.bat b/build.bat
new file mode 100644
index 0000000..4636d2d
--- /dev/null
+++ b/build.bat
@@ -0,0 +1 @@
+@go build -o build/ %* \ No newline at end of file
diff --git a/common.go b/common.go
new file mode 100644
index 0000000..0676487
--- /dev/null
+++ b/common.go
@@ -0,0 +1,380 @@
+package main
+
+import (
+ "bufio"
+ "encoding/binary"
+ "net"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+ "unicode"
+)
+
+// TReadBuffer
+// ==============================================================================
+type TReadBuffer struct {
+ Buffer []byte
+ Position int
+}
+
+func (ReadBuffer *TReadBuffer) CanRead(Bytes int) bool {
+ return ReadBuffer.Position+Bytes <= len(ReadBuffer.Buffer)
+}
+
+func (ReadBuffer *TReadBuffer) Overflowed() bool {
+ return ReadBuffer.Position > len(ReadBuffer.Buffer)
+}
+
+func (ReadBuffer *TReadBuffer) ReadFlag() bool {
+ return ReadBuffer.Read8() != 0
+}
+
+func (ReadBuffer *TReadBuffer) Read8() uint8 {
+ Result := uint8(0)
+ if ReadBuffer.CanRead(1) {
+ Result = ReadBuffer.Buffer[ReadBuffer.Position]
+ }
+ ReadBuffer.Position += 1
+ return Result
+}
+
+func (ReadBuffer *TReadBuffer) Read16() uint16 {
+ Result := uint16(0)
+ if ReadBuffer.CanRead(2) {
+ Result = binary.LittleEndian.Uint16(ReadBuffer.Buffer[ReadBuffer.Position:])
+ }
+ ReadBuffer.Position += 2
+ return Result
+}
+
+func (ReadBuffer *TReadBuffer) Read16BE() uint16 {
+ Result := uint16(0)
+ if ReadBuffer.CanRead(2) {
+ Result = binary.BigEndian.Uint16(ReadBuffer.Buffer[ReadBuffer.Position:])
+ }
+ ReadBuffer.Position += 2
+ return Result
+}
+
+func (ReadBuffer *TReadBuffer) Read32() uint32 {
+ Result := uint32(0)
+ if ReadBuffer.CanRead(4) {
+ Result = binary.LittleEndian.Uint32(ReadBuffer.Buffer[ReadBuffer.Position:])
+ }
+ ReadBuffer.Position += 4
+ return Result
+}
+
+func (ReadBuffer *TReadBuffer) Read32BE() uint32 {
+ Result := uint32(0)
+ if ReadBuffer.CanRead(4) {
+ Result = binary.BigEndian.Uint32(ReadBuffer.Buffer[ReadBuffer.Position:])
+ }
+ ReadBuffer.Position += 4
+ return Result
+}
+
+func (ReadBuffer *TReadBuffer) ReadString() string {
+ Length := int(ReadBuffer.Read16())
+ if Length == 0xFFFF {
+ Length = int(ReadBuffer.Read32())
+ }
+
+ Result := ""
+ if ReadBuffer.CanRead(Length) {
+ Result = string(ReadBuffer.Buffer[ReadBuffer.Position:][:Length])
+ }
+ ReadBuffer.Position += Length
+ return Result
+}
+
+func (ReadBuffer *TReadBuffer) ReadBytes(Count int) []byte {
+ Result := []byte(nil)
+ if ReadBuffer.CanRead(Count) {
+ Result = ReadBuffer.Buffer[ReadBuffer.Position:][:Count]
+ }
+ ReadBuffer.Position += Count
+ return Result
+}
+
+// TWriteBuffer
+// ==============================================================================
+type TWriteBuffer struct {
+ Buffer []byte
+ Position int
+}
+
+func (WriteBuffer *TWriteBuffer) CanWrite(Bytes int) bool {
+ return (WriteBuffer.Position + Bytes) <= len(WriteBuffer.Buffer)
+}
+
+func (WriteBuffer *TWriteBuffer) Overflowed() bool {
+ return WriteBuffer.Position > len(WriteBuffer.Buffer)
+}
+
+func (WriteBuffer *TWriteBuffer) WriteFlag(Value bool) {
+ Value8 := uint8(0)
+ if Value {
+ Value8 = uint8(1)
+ }
+ WriteBuffer.Write8(Value8)
+}
+
+func (WriteBuffer *TWriteBuffer) Write8(Value uint8) {
+ if WriteBuffer.CanWrite(1) {
+ WriteBuffer.Buffer[WriteBuffer.Position] = Value
+ }
+ WriteBuffer.Position += 1
+}
+
+func (WriteBuffer *TWriteBuffer) Write16(Value uint16) {
+ if WriteBuffer.CanWrite(2) {
+ binary.LittleEndian.PutUint16(WriteBuffer.Buffer[WriteBuffer.Position:], Value)
+ }
+ WriteBuffer.Position += 2
+}
+
+func (WriteBuffer *TWriteBuffer) Write16BE(Value uint16) {
+ if WriteBuffer.CanWrite(2) {
+ binary.BigEndian.PutUint16(WriteBuffer.Buffer[WriteBuffer.Position:], Value)
+ }
+ WriteBuffer.Position += 2
+}
+
+func (WriteBuffer *TWriteBuffer) Write32(Value uint32) {
+ if WriteBuffer.CanWrite(4) {
+ binary.LittleEndian.PutUint32(WriteBuffer.Buffer[WriteBuffer.Position:], Value)
+ }
+ WriteBuffer.Position += 4
+}
+
+func (WriteBuffer *TWriteBuffer) Write32BE(Value uint32) {
+ if WriteBuffer.CanWrite(4) {
+ binary.BigEndian.PutUint32(WriteBuffer.Buffer[WriteBuffer.Position:], Value)
+ }
+ WriteBuffer.Position += 4
+}
+
+func (WriteBuffer *TWriteBuffer) WriteString(String string) {
+ Length := len(String)
+ if Length < 0xFFFF {
+ WriteBuffer.Write16(uint16(Length))
+ } else {
+ WriteBuffer.Write16(0xFFFF)
+ WriteBuffer.Write32(uint32(Length))
+ }
+
+ if WriteBuffer.CanWrite(Length) {
+ copy(WriteBuffer.Buffer[WriteBuffer.Position:], String)
+ }
+ WriteBuffer.Position += Length
+}
+
+func (WriteBuffer *TWriteBuffer) WriteBytes(Bytes []byte) {
+ Count := len(Bytes)
+ if WriteBuffer.CanWrite(Count) {
+ copy(WriteBuffer.Buffer[WriteBuffer.Position:], Bytes)
+ }
+ WriteBuffer.Position += Count
+}
+
+func (WriteBuffer *TWriteBuffer) Rewrite16(Position int, Value uint16) {
+ if (Position+2) <= WriteBuffer.Position && !WriteBuffer.Overflowed() {
+ binary.LittleEndian.PutUint16(WriteBuffer.Buffer[Position:], Value)
+ }
+}
+
+func (WriteBuffer *TWriteBuffer) Insert32(Position int, Value uint32) {
+ if Position <= WriteBuffer.Position {
+ if WriteBuffer.CanWrite(4) {
+ copy(WriteBuffer.Buffer[Position+4:], WriteBuffer.Buffer[Position:])
+ binary.LittleEndian.PutUint32(WriteBuffer.Buffer[Position:], Value)
+ }
+ WriteBuffer.Position += 4
+ }
+}
+
+// Config
+// ==============================================================================
+func ParseBoolean(String string) bool {
+ return strings.EqualFold(String, "true")
+}
+
+func ParseInteger(String string) int {
+ Result, Err := strconv.Atoi(String)
+ if Err != nil {
+ g_LogErr.Printf("Failed to parse integer \"%v\": %v", String, Err)
+ }
+ return Result
+}
+
+func ParseIntegerSuffix(String string) (int, string) {
+ SuffixStart := -1
+ for Index, Rune := range String {
+ if !unicode.IsDigit(Rune) {
+ SuffixStart = Index
+ break
+ }
+ }
+
+ var Value, Suffix string
+ if SuffixStart != -1 {
+ Value = strings.TrimSpace(String[:SuffixStart])
+ Suffix = strings.TrimSpace(String[SuffixStart:])
+ } else {
+ Value = String
+ Suffix = ""
+ }
+
+ Result, Err := strconv.Atoi(Value)
+ if Err != nil {
+ g_LogErr.Printf("Failed to parse duration \"%v\": %v", String, Err)
+ }
+
+ return Result, Suffix
+}
+
+func ParseDuration(String string) time.Duration {
+ Value, Suffix := ParseIntegerSuffix(String)
+ Result := time.Duration(Value) * time.Millisecond
+ if len(Suffix) > 0 {
+ switch Suffix[0] {
+ case 'S', 's':
+ Result = time.Duration(Value) * time.Second
+ case 'M', 'm':
+ Result = time.Duration(Value) * time.Minute
+ case 'H', 'h':
+ Result = time.Duration(Value) * time.Hour
+ }
+ }
+ return Result
+}
+
+func ParseSize(String string) int {
+ Result, Suffix := ParseIntegerSuffix(String)
+ if len(Suffix) > 0 {
+ switch Suffix[0] {
+ case 'K', 'k':
+ Result *= 1024
+ case 'M', 'm':
+ Result *= (1024 * 1024)
+ }
+ }
+ return Result
+}
+
+func ParseString(String string) string {
+ if len(String) > 2 {
+ if String[0] == '"' && String[len(String)-1] == '"' ||
+ String[0] == '\'' && String[len(String)-1] == '\'' ||
+ String[0] == '`' && String[len(String)-1] == '`' {
+ String = String[1 : len(String)-1]
+ }
+ }
+ return String
+}
+
+func ReadConfig(FileName string, KVCallback func(string, string)) bool {
+ File, Err := os.Open(FileName)
+ if Err != nil {
+ g_LogErr.Print(Err)
+ return false
+ }
+ defer File.Close()
+
+ Scanner := bufio.NewScanner(File)
+ for LineNumber := 1; Scanner.Scan(); LineNumber += 1 {
+ Line := strings.TrimSpace(Scanner.Text())
+ if len(Line) == 0 || Line[0] == '#' {
+ continue
+ }
+
+ Key, Value, Ok := strings.Cut(Scanner.Text(), "=")
+ if !Ok {
+ g_LogErr.Printf("%v:%v: No assignment found on non empty line", FileName, LineNumber)
+ continue
+ }
+
+ Key = strings.TrimSpace(Key)
+ if len(Key) == 0 {
+ g_LogErr.Printf("%v:%v: Empty key", FileName, LineNumber)
+ continue
+ }
+
+ Value = strings.TrimSpace(Value)
+ if len(Value) == 0 {
+ g_LogErr.Printf("%v:%v: Empty value", FileName, LineNumber)
+ continue
+ }
+
+ KVCallback(Key, Value)
+ }
+
+ return true
+}
+
+// Utility
+// ==============================================================================
+func FileExists(FileName string) bool {
+ _, Err := os.Stat(FileName)
+ return Err == nil
+}
+
+func JoinHostPort(Host string, Port int) string {
+ PortString := strconv.Itoa(Port)
+ return net.JoinHostPort(Host, PortString)
+}
+
+func SplitDiscardEmpty(String string, Sep string) []string {
+ var Result []string
+ for String != "" {
+ SubStr := ""
+ Index := strings.Index(String, Sep)
+ if Index == -1 {
+ SubStr = String
+ String = ""
+ } else {
+ SubStr = String[:Index]
+ String = String[Index+1:]
+ }
+
+ if SubStr != "" {
+ Result = append(Result, SubStr)
+ }
+ }
+ return Result
+}
+
+func SwapAndPop[T any](Slice []T, Index int) []T {
+ if len(Slice) == 0 {
+ panic("slice is empty")
+ }
+
+ var Zero T
+ Slice[Index] = Slice[len(Slice)-1]
+ Slice[len(Slice)-1] = Zero
+ return Slice[:len(Slice)-1]
+}
+
+func WorldTypeString(Type int) string {
+ switch Type {
+ case 0:
+ return "Normal"
+ case 1:
+ return "Non-PvP"
+ case 2:
+ return "PvP-Enforced"
+ default:
+ return "Unknown"
+ }
+}
+
+func TimestampString(Timestamp int) string {
+ String := "Never"
+ if Timestamp > 0 {
+ Time := time.Unix(int64(Timestamp), 0)
+ String = Time.Format("Jan 02 2006, 15:04:05 MST")
+ }
+ return String
+}
diff --git a/config.cfg b/config.cfg
new file mode 100644
index 0000000..627088a
--- /dev/null
+++ b/config.cfg
@@ -0,0 +1,25 @@
+# HTTP/HTTPS Config
+HttpPort = 80
+HttpsPort = 443
+HttpsCertFile = "https/cert.pem"
+HttpsKeyFile = "https/key.pem"
+
+# SMTP Config
+SmtpHost = "smtp.domain.com"
+SmtpPort = 587
+SmtpUser = "username"
+SmtpPassword = ""
+SmtpSender = "support@domain.com"
+
+# Query Manager Config
+QueryManagerHost = "127.0.0.1"
+QueryManagerPort = 7174
+QueryManagerPassword = "a6glaf0c"
+
+# Query Manager Cache Config
+MaxCachedAccounts = 4096
+MaxCachedCharacters = 4096
+CharacterRefreshInterval = 15m
+WorldsRefreshInterval = 15m
+OnlineCharactersRefreshInterval = 15m
+KillStatisticsRefreshInterval = 30m
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..93afbe6
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module tibia-web
+
+go 1.24.5
diff --git a/mail.go b/mail.go
new file mode 100644
index 0000000..faf5d9a
--- /dev/null
+++ b/mail.go
@@ -0,0 +1,44 @@
+package main
+
+import (
+ "fmt"
+ "net/smtp"
+ "strings"
+)
+
+var (
+ g_SmtpAuth smtp.Auth
+)
+
+func InitMail() bool {
+ // TODO(fusion): I'm not entirely sure we can safely reuse smtp.Auth across
+ // many threads. We might need to build a new auth struct everytime we need
+ // to send an e-mail.
+ g_SmtpAuth = smtp.PlainAuth("", g_SmtpUser, g_SmtpPassword, g_SmtpHost)
+ return true
+}
+
+func ExitMail() {
+ // no-op
+}
+
+func BuildMailMessage(From, To, Subject, Body string) string {
+ Message := strings.Builder{}
+ Message.WriteString("MIME-Version: 1.0\r\n")
+ Message.WriteString("Content-Type: text/html; charset=utf-8\r\n")
+ if From != "" {
+ fmt.Fprintf(&Message, "From: %v\r\n", From)
+ }
+ if To != "" {
+ fmt.Fprintf(&Message, "To: %v\r\n", To)
+ }
+ fmt.Fprintf(&Message, "Subject: %v\r\n", Subject)
+ fmt.Fprintf(&Message, "\r\n%v\r\n", Body)
+ return Message.String()
+}
+
+func SendMail(To, Subject, Body string) error {
+ Message := BuildMailMessage(g_SmtpSender, To, Subject, Body)
+ return smtp.SendMail(JoinHostPort(g_SmtpHost, g_SmtpPort),
+ g_SmtpAuth, g_SmtpSender, []string{To}, []byte(Message))
+}
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))
+ }
+}
diff --git a/query.go b/query.go
new file mode 100644
index 0000000..baab921
--- /dev/null
+++ b/query.go
@@ -0,0 +1,735 @@
+package main
+
+import (
+ "encoding/binary"
+ "net"
+ "strings"
+ "sync"
+ "time"
+)
+
+// TQueryManagerConnection
+// ==============================================================================
+const (
+ APPLICATION_TYPE_GAME = 1
+ APPLICATION_TYPE_LOGIN = 2
+ APPLICATION_TYPE_WEB = 3
+)
+
+const (
+ QUERY_STATUS_OK = 0
+ QUERY_STATUS_ERROR = 1
+ QUERY_STATUS_FAILED = 3
+)
+
+const (
+ // TODO(fusion): There are newly created queries to support basic account
+ // management. A production ready website would need even more queries to
+ // allow account activation, recovery, deletion, password change, character
+ // deletion, etc...
+
+ QUERY_LOGIN = 0
+ QUERY_CHECK_ACCOUNT_PASSWORD = 10
+ QUERY_CREATE_ACCOUNT = 100
+ QUERY_CREATE_CHARACTER = 101
+ QUERY_GET_ACCOUNT_SUMMARY = 102
+ QUERY_GET_CHARACTER_PROFILE = 103
+ QUERY_GET_WORLDS = 150
+ QUERY_GET_ONLINE_CHARACTERS = 151
+ QUERY_GET_KILL_STATISTICS = 152
+)
+
+type (
+ TWorld struct {
+ Name string
+ Type string
+ NumPlayers int
+ MaxPlayers int
+ OnlineRecord int
+ OnlineRecordTime string
+ }
+
+ TAccountSummary struct {
+ AccountID int
+ Email string
+ PremiumDays int
+ PendingPremiumDays int
+ Deleted bool
+ Characters []TCharacterSummary
+ }
+
+ TCharacterSummary struct {
+ Name string
+ World string
+ Level int
+ Profession string
+ Online bool
+ Deleted bool
+ }
+
+ TCharacterProfile struct {
+ Name string
+ World string
+ Sex int
+ Guild string
+ Rank string
+ Title string
+ Level int
+ Profession string
+ Residence string
+ LastLogin string
+ PremiumDays int
+ Online bool
+ Deleted bool
+ }
+
+ TKillStatistics struct {
+ RaceName string
+ TimesKilled int
+ PlayersKilled int
+ }
+
+ TOnlineCharacter struct {
+ Name string
+ Level int
+ Profession string
+ }
+
+ TAccountCacheEntry struct {
+ AccountID int
+ Result int
+ Data TAccountSummary
+ LastAccess time.Time
+ }
+
+ TCharacterCacheEntry struct {
+ CharacterName string
+ Result int
+ Data TCharacterProfile
+ LastAccess time.Time
+ }
+
+ TKillStatisticsCacheEntry struct {
+ World string
+ Data []TKillStatistics
+ RefreshTime time.Time
+ }
+
+ TOnlineCharactersCacheEntry struct {
+ World string
+ Data []TOnlineCharacter
+ RefreshTime time.Time
+ }
+
+ TQueryManagerConnection struct {
+ Handle net.Conn
+ }
+)
+
+func (Connection *TQueryManagerConnection) Connect() bool {
+ if Connection.Handle != nil {
+ g_LogErr.Print("Already connected")
+ return false
+ }
+
+ var Err error
+ QueryManagerAddress := JoinHostPort(g_QueryManagerHost, g_QueryManagerPort)
+ Connection.Handle, Err = net.Dial("tcp4", QueryManagerAddress)
+ if Err != nil {
+ g_LogErr.Print(Err)
+ return false
+ }
+
+ var LoginBuffer [1024]byte
+ WriteBuffer := Connection.PrepareQuery(QUERY_LOGIN, LoginBuffer[:])
+ WriteBuffer.Write8(APPLICATION_TYPE_WEB)
+ WriteBuffer.WriteString(g_QueryManagerPassword)
+ Status, _ := Connection.ExecuteQuery(false, &WriteBuffer)
+ if Status != QUERY_STATUS_OK {
+ Connection.Disconnect()
+ g_LogErr.Printf("Failed to login to query manager (%v)", Status)
+ return false
+ }
+
+ return true
+}
+
+func (Connection *TQueryManagerConnection) Disconnect() {
+ if Connection.Handle != nil {
+ if Err := Connection.Handle.Close(); Err != nil {
+ g_LogErr.Print(Err)
+ }
+ Connection.Handle = nil
+ }
+}
+
+func (Connection *TQueryManagerConnection) PrepareQuery(QueryType int, Buffer []byte) TWriteBuffer {
+ WriteBuffer := TWriteBuffer{Buffer: Buffer, Position: 0}
+ WriteBuffer.Write16(0) // Request Size
+ WriteBuffer.Write8(uint8(QueryType))
+ return WriteBuffer
+}
+
+func (Connection *TQueryManagerConnection) ExecuteQuery(AutoReconnect bool, WriteBuffer *TWriteBuffer) (Status int, ReadBuffer TReadBuffer) {
+ // IMPORTANT(fusion): Different from the C++ version, there is no connection
+ // buffer, and the response is read into the same buffer used by `WriteBuffer`,
+ // to avoid moving data around when reconnecting in the middle of a query.
+ // TODO(fusion): Maybe join `TWriteBuffer` and `TReadBuffer` into `TQueryBuffer`
+ // to avoid confusion on how this function operates?
+ if WriteBuffer == nil || WriteBuffer.Position <= 2 {
+ panic("write buffer is empty")
+ }
+
+ RequestSize := WriteBuffer.Position - 2
+ if RequestSize < 0xFFFF {
+ WriteBuffer.Rewrite16(0, uint16(RequestSize))
+ } else {
+ WriteBuffer.Rewrite16(0, 0xFFFF)
+ WriteBuffer.Insert32(2, uint32(RequestSize))
+ }
+
+ Status = QUERY_STATUS_FAILED
+ if WriteBuffer.Overflowed() {
+ g_LogErr.Print("Write buffer overflowed")
+ return
+ }
+
+ const MaxAttempts = 2
+ Buffer := WriteBuffer.Buffer
+ WriteSize := WriteBuffer.Position
+ for Attempt := 1; true; Attempt += 1 {
+ if Connection.Handle == nil && (!AutoReconnect || !Connection.Connect()) {
+ return
+ }
+
+ if _, Err := Connection.Handle.Write(Buffer[:WriteSize]); Err != nil {
+ Connection.Disconnect()
+ if Attempt >= MaxAttempts {
+ g_LogErr.Printf("Failed to write request: %v", Err)
+ return
+ }
+ continue
+ }
+
+ var Help [4]byte
+ if _, Err := Connection.Handle.Read(Help[:2]); Err != nil {
+ Connection.Disconnect()
+ if Attempt >= MaxAttempts {
+ g_LogErr.Printf("Failed to read response size: %v", Err)
+ return
+ }
+ continue
+ }
+
+ ResponseSize := int(binary.LittleEndian.Uint16(Help[:2]))
+ if ResponseSize == 0xFFFF {
+ if _, Err := Connection.Handle.Read(Help[:]); Err != nil {
+ Connection.Disconnect()
+ g_LogErr.Printf("Failed to read response extended size: %v", Err)
+ return
+ }
+
+ ResponseSize = int(binary.LittleEndian.Uint32(Help[:]))
+ }
+
+ if ResponseSize <= 0 || ResponseSize > len(Buffer) {
+ Connection.Disconnect()
+ g_LogErr.Printf("Invalid response size %v (BufferSize: %v)",
+ ResponseSize, len(Buffer))
+ return
+ }
+
+ if _, Err := Connection.Handle.Read(Buffer[:ResponseSize]); Err != nil {
+ Connection.Disconnect()
+ g_LogErr.Printf("Failed to read response: %v", Err)
+ return
+ }
+
+ ReadBuffer = TReadBuffer{
+ Buffer: Buffer,
+ Position: 0,
+ }
+ Status = int(ReadBuffer.Read8())
+ return
+ }
+
+ // NOTE(fusion): The compiler complains there is no return statement here
+ // but the loop above can only exit by returning from the function which
+ // make anything after it UNREACHABLE.
+ return
+}
+
+func (Connection *TQueryManagerConnection) CheckAccountPassword(AccountID int, Password, IPAddress string) (Result int) {
+ var Buffer [1024]byte
+ WriteBuffer := Connection.PrepareQuery(QUERY_CHECK_ACCOUNT_PASSWORD, Buffer[:])
+ WriteBuffer.Write32(uint32(AccountID))
+ WriteBuffer.WriteString(Password)
+ WriteBuffer.WriteString(IPAddress)
+ Status, ReadBuffer := Connection.ExecuteQuery(true, &WriteBuffer)
+ Result = -1
+ switch Status {
+ case QUERY_STATUS_OK:
+ Result = 0
+ case QUERY_STATUS_ERROR:
+ ErrorCode := int(ReadBuffer.Read8())
+ if ErrorCode >= 1 && ErrorCode <= 4 {
+ Result = ErrorCode
+ } else {
+ g_LogErr.Printf("Invalid error code %v", ErrorCode)
+ }
+ default:
+ g_LogErr.Printf("Request failed (%v)", Status)
+ }
+ return
+}
+
+func (Connection *TQueryManagerConnection) CreateAccount(AccountID int, Email string, Password string) (Result int) {
+ var Buffer [1024]byte
+ WriteBuffer := Connection.PrepareQuery(QUERY_CREATE_ACCOUNT, Buffer[:])
+ WriteBuffer.Write32(uint32(AccountID))
+ WriteBuffer.WriteString(Email)
+ WriteBuffer.WriteString(Password)
+ Status, ReadBuffer := Connection.ExecuteQuery(true, &WriteBuffer)
+ Result = -1
+ switch Status {
+ case QUERY_STATUS_OK:
+ Result = 0
+ case QUERY_STATUS_ERROR:
+ ErrorCode := int(ReadBuffer.Read8())
+ if ErrorCode >= 1 && ErrorCode <= 2 {
+ Result = ErrorCode
+ } else {
+ g_LogErr.Printf("Invalid error code %v", ErrorCode)
+ }
+ default:
+ g_LogErr.Printf("Request failed (%v)", Status)
+ }
+ return
+}
+
+func (Connection *TQueryManagerConnection) CreateCharacter(World string, AccountID int, Name string, Sex int) (Result int) {
+ var Buffer [1024]byte
+ WriteBuffer := Connection.PrepareQuery(QUERY_CREATE_CHARACTER, Buffer[:])
+ WriteBuffer.WriteString(World)
+ WriteBuffer.Write32(uint32(AccountID))
+ WriteBuffer.WriteString(Name)
+ WriteBuffer.Write8(uint8(Sex))
+ Status, ReadBuffer := Connection.ExecuteQuery(true, &WriteBuffer)
+ Result = -1
+ switch Status {
+ case QUERY_STATUS_OK:
+ Result = 0
+ case QUERY_STATUS_ERROR:
+ ErrorCode := int(ReadBuffer.Read8())
+ if ErrorCode >= 1 && ErrorCode <= 3 {
+ Result = ErrorCode
+ } else {
+ g_LogErr.Printf("Invalid error code %v", ErrorCode)
+ }
+ default:
+ g_LogErr.Printf("Request failed (%v)", Status)
+ }
+ return
+}
+
+func (Connection *TQueryManagerConnection) GetAccountSummary(AccountID int) (Result int, Account TAccountSummary) {
+ var Buffer [16384]byte
+ WriteBuffer := Connection.PrepareQuery(QUERY_GET_ACCOUNT_SUMMARY, Buffer[:])
+ WriteBuffer.Write32(uint32(AccountID))
+ Status, ReadBuffer := Connection.ExecuteQuery(true, &WriteBuffer)
+ Result = -1
+ switch Status {
+ case QUERY_STATUS_OK:
+ Result = 0
+ Account.AccountID = AccountID
+ Account.Email = ReadBuffer.ReadString()
+ Account.PremiumDays = int(ReadBuffer.Read16())
+ Account.PendingPremiumDays = int(ReadBuffer.Read16())
+ Account.Deleted = ReadBuffer.ReadFlag()
+ NumCharacters := int(ReadBuffer.Read8())
+ if NumCharacters > 0 {
+ Account.Characters = make([]TCharacterSummary, NumCharacters)
+ for Index := range Account.Characters {
+ Account.Characters[Index].Name = ReadBuffer.ReadString()
+ Account.Characters[Index].World = ReadBuffer.ReadString()
+ Account.Characters[Index].Level = int(ReadBuffer.Read16())
+ Account.Characters[Index].Profession = ReadBuffer.ReadString()
+ Account.Characters[Index].Online = ReadBuffer.ReadFlag()
+ Account.Characters[Index].Deleted = ReadBuffer.ReadFlag()
+ }
+ }
+ case QUERY_STATUS_ERROR:
+ ErrorCode := int(ReadBuffer.Read8())
+ if ErrorCode >= 1 && ErrorCode <= 4 {
+ Result = ErrorCode
+ } else {
+ g_LogErr.Printf("Invalid error code %v", ErrorCode)
+ }
+ default:
+ g_LogErr.Printf("Request failed (%v)", Status)
+ }
+ return
+}
+
+func (Connection *TQueryManagerConnection) GetCharacterProfile(CharacterName string) (Result int, Character TCharacterProfile) {
+ var Buffer [16384]byte
+ WriteBuffer := Connection.PrepareQuery(QUERY_GET_CHARACTER_PROFILE, Buffer[:])
+ WriteBuffer.WriteString(CharacterName)
+ Status, ReadBuffer := Connection.ExecuteQuery(true, &WriteBuffer)
+ Result = -1
+ switch Status {
+ case QUERY_STATUS_OK:
+ Result = 0
+ Character.Name = ReadBuffer.ReadString()
+ Character.World = ReadBuffer.ReadString()
+ Character.Sex = int(ReadBuffer.Read8())
+ Character.Guild = ReadBuffer.ReadString()
+ Character.Rank = ReadBuffer.ReadString()
+ Character.Title = ReadBuffer.ReadString()
+ Character.Level = int(ReadBuffer.Read16())
+ Character.Profession = ReadBuffer.ReadString()
+ Character.Residence = ReadBuffer.ReadString()
+ Character.LastLogin = TimestampString(int(ReadBuffer.Read32()))
+ Character.PremiumDays = int(ReadBuffer.Read16())
+ Character.Online = ReadBuffer.ReadFlag()
+ Character.Deleted = ReadBuffer.ReadFlag()
+ case QUERY_STATUS_ERROR:
+ ErrorCode := int(ReadBuffer.Read8())
+ if ErrorCode == 1 {
+ Result = ErrorCode
+ } else {
+ g_LogErr.Printf("Invalid error code %v", ErrorCode)
+ }
+ default:
+ g_LogErr.Printf("Request failed (%v)", Status)
+ }
+ return
+}
+
+func (Connection *TQueryManagerConnection) GetWorlds() (Result int, Worlds []TWorld) {
+ var Buffer [16384]byte
+ WriteBuffer := Connection.PrepareQuery(QUERY_GET_WORLDS, Buffer[:])
+ Status, ReadBuffer := Connection.ExecuteQuery(true, &WriteBuffer)
+ Result = -1
+ switch Status {
+ case QUERY_STATUS_OK:
+ Result = 0
+ NumWorlds := int(ReadBuffer.Read8())
+ if NumWorlds > 0 {
+ Worlds = make([]TWorld, NumWorlds)
+ for Index := range Worlds {
+ Worlds[Index].Name = ReadBuffer.ReadString()
+ Worlds[Index].Type = WorldTypeString(int(ReadBuffer.Read8()))
+ Worlds[Index].NumPlayers = int(ReadBuffer.Read16())
+ Worlds[Index].MaxPlayers = int(ReadBuffer.Read16())
+ Worlds[Index].OnlineRecord = int(ReadBuffer.Read16())
+ Worlds[Index].OnlineRecordTime = TimestampString(int(ReadBuffer.Read32()))
+ }
+ }
+ default:
+ g_LogErr.Printf("Request failed (%v)", Status)
+ }
+ return
+}
+
+func (Connection *TQueryManagerConnection) GetOnlineCharacters(World string) (Result int, Characters []TOnlineCharacter) {
+ var Buffer [65536]byte
+ WriteBuffer := Connection.PrepareQuery(QUERY_GET_ONLINE_CHARACTERS, Buffer[:])
+ WriteBuffer.WriteString(World)
+ Status, ReadBuffer := Connection.ExecuteQuery(true, &WriteBuffer)
+ Result = -1
+ switch Status {
+ case QUERY_STATUS_OK:
+ Result = 0
+ NumCharacters := int(ReadBuffer.Read16())
+ if NumCharacters > 0 {
+ Characters = make([]TOnlineCharacter, NumCharacters)
+ for Index := 0; Index < NumCharacters; Index += 1 {
+ Characters[Index].Name = ReadBuffer.ReadString()
+ Characters[Index].Level = int(ReadBuffer.Read16())
+ Characters[Index].Profession = ReadBuffer.ReadString()
+ }
+ }
+ default:
+ g_LogErr.Printf("Request failed (%v)", Status)
+ }
+ return
+}
+
+func (Connection *TQueryManagerConnection) GetKillStatistics(World string) (Result int, Stats []TKillStatistics) {
+ var Buffer [65536]byte
+ WriteBuffer := Connection.PrepareQuery(QUERY_GET_KILL_STATISTICS, Buffer[:])
+ WriteBuffer.WriteString(World)
+ Status, ReadBuffer := Connection.ExecuteQuery(true, &WriteBuffer)
+ Result = -1
+ switch Status {
+ case QUERY_STATUS_OK:
+ Result = 0
+ NumStats := int(ReadBuffer.Read16())
+ if NumStats > 0 {
+ Stats = make([]TKillStatistics, NumStats)
+ for Index := 0; Index < NumStats; Index += 1 {
+ Stats[Index].RaceName = ReadBuffer.ReadString()
+ Stats[Index].PlayersKilled = int(ReadBuffer.Read32())
+ Stats[Index].TimesKilled = int(ReadBuffer.Read32())
+ }
+ }
+ default:
+ g_LogErr.Printf("Request failed (%v)", Status)
+ }
+ return
+}
+
+// Query Subsystem
+// ==============================================================================
+var (
+ g_QueryManagerMutex sync.Mutex
+ g_QueryManagerConnection TQueryManagerConnection
+
+ g_AccountCache []TAccountCacheEntry
+ g_CharacterCache []TCharacterCacheEntry
+ g_WorldCache []TWorld
+ g_WorldCacheRefreshTime time.Time
+ g_OnlineCharactersCache []TOnlineCharactersCacheEntry
+ g_KillStatisticsCache []TKillStatisticsCacheEntry
+)
+
+func InitQuery() bool {
+ g_Log.Printf("QueryManagerHost: %v", g_QueryManagerHost)
+ g_Log.Printf("QueryManagerPort: %v", g_QueryManagerPort)
+ g_Log.Printf("MaxCachedAccounts: %v", g_MaxCachedAccounts)
+ g_Log.Printf("MaxCachedCharacters: %v", g_MaxCachedCharacters)
+ g_Log.Printf("CharacterRefreshInterval: %v", g_CharacterRefreshInterval)
+ g_Log.Printf("WorldsRefreshInterval: %v", g_WorldsRefreshInterval)
+ g_Log.Printf("OnlineCharactersRefreshInterval: %v", g_OnlineCharactersRefreshInterval)
+ g_Log.Printf("KillStatisticsRefreshInterval: %v", g_KillStatisticsRefreshInterval)
+
+ Result := g_QueryManagerConnection.Connect()
+ if !Result {
+ g_LogErr.Print("Failed to connect to query manager")
+ }
+ return Result
+}
+
+func ExitQuery() {
+ g_QueryManagerConnection.Disconnect()
+}
+
+func CheckAccountPassword(AccountID int, Password, IPAddress string) int {
+ g_QueryManagerMutex.Lock()
+ defer g_QueryManagerMutex.Unlock()
+ return g_QueryManagerConnection.CheckAccountPassword(AccountID, Password, IPAddress)
+}
+
+func CreateAccount(AccountID int, Email string, Password string) int {
+ g_QueryManagerMutex.Lock()
+ defer g_QueryManagerMutex.Unlock()
+ return g_QueryManagerConnection.CreateAccount(AccountID, Email, Password)
+}
+
+func CreateCharacter(World string, AccountID int, Name string, Sex int) int {
+ g_QueryManagerMutex.Lock()
+ defer g_QueryManagerMutex.Unlock()
+ return g_QueryManagerConnection.CreateCharacter(World, AccountID, Name, Sex)
+}
+
+func GetAccountSummary(AccountID int) (Result int, Account TAccountSummary) {
+ g_QueryManagerMutex.Lock()
+ defer g_QueryManagerMutex.Unlock()
+
+ if g_AccountCache == nil {
+ g_AccountCache = make([]TAccountCacheEntry, g_MaxCachedAccounts)
+ }
+
+ var Entry *TAccountCacheEntry
+ LeastRecentlyUsedIndex := 0
+ LeastRecentlyUsedTime := g_AccountCache[0].LastAccess
+ for Index := 0; Index < len(g_AccountCache); Index += 1 {
+ Current := &g_AccountCache[Index]
+
+ if Current.LastAccess.Before(LeastRecentlyUsedTime) {
+ LeastRecentlyUsedIndex = Index
+ LeastRecentlyUsedTime = Current.LastAccess
+ }
+
+ if Current.AccountID == AccountID {
+ Entry = Current
+ break
+ }
+ }
+
+ if Entry == nil {
+ Result, Account = g_QueryManagerConnection.GetAccountSummary(AccountID)
+ if Result == 0 {
+ Entry = &g_AccountCache[LeastRecentlyUsedIndex]
+ Entry.AccountID = AccountID
+ Entry.Data = Account
+ Entry.LastAccess = time.Now()
+ }
+ } else {
+ Result = 0
+ Account = Entry.Data
+ Entry.LastAccess = time.Now()
+ }
+
+ return
+}
+
+// NOTE(fusion): Account data is different from other cached data because it
+// shouldn't change over time unless we do it ourselves, in which case we know
+// exactly when to invalidate cached data.
+func InvalidateAccountCachedData(AccountID int) {
+ g_QueryManagerMutex.Lock()
+ defer g_QueryManagerMutex.Unlock()
+ for Index := 0; Index < len(g_AccountCache); Index += 1 {
+ if g_AccountCache[Index].AccountID == AccountID {
+ g_AccountCache[Index] = TAccountCacheEntry{}
+ break
+ }
+ }
+}
+
+func GetCharacterProfile(CharacterName string) (Result int, Character TCharacterProfile) {
+ g_QueryManagerMutex.Lock()
+ defer g_QueryManagerMutex.Unlock()
+
+ if g_CharacterCache == nil {
+ g_CharacterCache = make([]TCharacterCacheEntry, g_MaxCachedCharacters)
+ }
+
+ var Entry *TCharacterCacheEntry
+ LeastRecentlyUsedIndex := 0
+ LeastRecentlyUsedTime := g_CharacterCache[0].LastAccess
+ for Index := 0; Index < len(g_CharacterCache); Index += 1 {
+ Current := &g_CharacterCache[Index]
+
+ if time.Since(Current.LastAccess) >= g_CharacterRefreshInterval {
+ *Current = TCharacterCacheEntry{}
+ }
+
+ if Current.LastAccess.Before(LeastRecentlyUsedTime) {
+ LeastRecentlyUsedIndex = Index
+ LeastRecentlyUsedTime = Current.LastAccess
+ }
+
+ if strings.EqualFold(Current.CharacterName, CharacterName) {
+ Entry = Current
+ break
+ }
+ }
+
+ if Entry == nil {
+ Result, Character = g_QueryManagerConnection.GetCharacterProfile(CharacterName)
+ Entry = &g_CharacterCache[LeastRecentlyUsedIndex]
+ Entry.CharacterName = CharacterName
+ Entry.Result = Result
+ Entry.Data = Character
+ Entry.LastAccess = time.Now()
+ } else {
+ Result = Entry.Result
+ Character = Entry.Data
+ Entry.LastAccess = time.Now()
+ }
+
+ return
+}
+
+func GetWorlds() []TWorld {
+ g_QueryManagerMutex.Lock()
+ defer g_QueryManagerMutex.Unlock()
+ if time.Until(g_WorldCacheRefreshTime) <= 0 {
+ // IMPORTANT(fusion): `GetWorlds` will return a FRESH slice. This will
+ // prevent race conditions regarding any previous world slice, assuming
+ // we're only reading from them.
+ Result, Worlds := g_QueryManagerConnection.GetWorlds()
+ if Result == 0 {
+ g_WorldCache = Worlds
+ g_WorldCacheRefreshTime = time.Now().Add(g_WorldsRefreshInterval)
+ }
+ }
+ return g_WorldCache
+}
+
+func GetWorld(World string) *TWorld {
+ Worlds := GetWorlds()
+ for Index := range Worlds {
+ if strings.EqualFold(Worlds[Index].Name, World) {
+ return &Worlds[Index]
+ }
+ }
+ return nil
+}
+
+func GetOnlineCharacters(World string) []TOnlineCharacter {
+ g_QueryManagerMutex.Lock()
+ defer g_QueryManagerMutex.Unlock()
+
+ var Entry *TOnlineCharactersCacheEntry
+ for Index := 0; Index < len(g_OnlineCharactersCache); Index += 1 {
+ Current := &g_OnlineCharactersCache[Index]
+ if time.Until(Current.RefreshTime) <= 0 {
+ g_OnlineCharactersCache = SwapAndPop(g_OnlineCharactersCache, Index)
+ Index -= 1
+ continue
+ }
+
+ if strings.EqualFold(Current.World, World) {
+ Entry = Current
+ break
+ }
+ }
+
+ if Entry == nil {
+ Result, Characters := g_QueryManagerConnection.GetOnlineCharacters(World)
+ if Result == 0 {
+ g_OnlineCharactersCache = append(g_OnlineCharactersCache, TOnlineCharactersCacheEntry{})
+ Entry = &g_OnlineCharactersCache[len(g_OnlineCharactersCache)-1]
+ Entry.World = World
+ Entry.Data = Characters
+ Entry.RefreshTime = time.Now().Add(g_OnlineCharactersRefreshInterval)
+ }
+ }
+
+ if Entry != nil {
+ return Entry.Data
+ } else {
+ return nil
+ }
+}
+
+func GetKillStatistics(World string) []TKillStatistics {
+ g_QueryManagerMutex.Lock()
+ defer g_QueryManagerMutex.Unlock()
+
+ var Entry *TKillStatisticsCacheEntry
+ for Index := 0; Index < len(g_KillStatisticsCache); Index += 1 {
+ Current := &g_KillStatisticsCache[Index]
+ if time.Until(Current.RefreshTime) <= 0 {
+ g_KillStatisticsCache = SwapAndPop(g_KillStatisticsCache, Index)
+ Index -= 1
+ continue
+ }
+
+ if strings.EqualFold(Current.World, World) {
+ Entry = Current
+ break
+ }
+ }
+
+ if Entry == nil {
+ Result, Stats := g_QueryManagerConnection.GetKillStatistics(World)
+ if Result == 0 {
+ g_KillStatisticsCache = append(g_KillStatisticsCache, TKillStatisticsCacheEntry{})
+ Entry = &g_KillStatisticsCache[len(g_KillStatisticsCache)-1]
+ Entry.World = World
+ Entry.Data = Stats
+ Entry.RefreshTime = time.Now().Add(g_KillStatisticsRefreshInterval)
+ }
+ }
+
+ if Entry != nil {
+ return Entry.Data
+ } else {
+ return nil
+ }
+}
diff --git a/res/css/style.css b/res/css/style.css
new file mode 100644
index 0000000..158cc38
--- /dev/null
+++ b/res/css/style.css
@@ -0,0 +1,90 @@
+
+body {
+ background-color: #111;
+ color: #BBB;
+}
+
+p {
+ font-size: 1.2em
+}
+
+a {
+ color: #BEC4FF;
+ text-decoration: none;
+}
+
+a:hover {
+ color: #8F94C0;
+ text-decoration: underline;
+}
+
+a.button {
+ margin: 5px 0px;
+ padding: 10px;
+ border: 3px solid #123;
+ border-radius: 5px;
+ display: inline-block;
+}
+
+table {
+ margin: 10px 0px;
+ padding: 5px;
+ width: 100%;
+ text-align: left;
+}
+
+th {
+ font-size: 1em;
+ font-weight: bold;
+}
+
+td {
+ word-break: break-all;
+}
+
+table.info th {
+ width: 40%;
+}
+
+.nav {
+ width: 700px;
+ margin: 10px 0px;
+ text-align: center;
+}
+
+.box {
+ width: 500px;
+ margin: 10px 0px;
+ padding: 5px;
+ border: 3px solid #222;
+ border-radius: 5px;
+}
+
+.box h1 {
+ margin: 10px 0px;
+}
+
+.box label {
+ margin: 20px 0px 5px 0px;
+ display: block;
+ font-size: 1.3em;
+ text-transform: uppercase;
+}
+
+.box input[type=text],
+.box input[type=password],
+.box select {
+ width: 80%;
+ padding: 3px;
+ display: block;
+ border-radius: 5px;
+ font-size: 1.1em;
+}
+
+.box input[type=submit] {
+ width: 60%;
+ height: 30px;
+ margin: 20px 0px;
+ display: block;
+ font-size: 1.2em;
+}
diff --git a/run.bat b/run.bat
new file mode 100644
index 0000000..f944f0a
--- /dev/null
+++ b/run.bat
@@ -0,0 +1 @@
+@CALL build.bat %* && CALL build\tibia-web \ No newline at end of file
diff --git a/session.go b/session.go
new file mode 100644
index 0000000..a06a344
--- /dev/null
+++ b/session.go
@@ -0,0 +1,142 @@
+package main
+
+import (
+ "bytes"
+ "crypto/rand"
+ "encoding/hex"
+ "net/http"
+ "sync"
+ "time"
+)
+
+type TSession struct {
+ SessionID []byte
+ IPAddress string
+ Expires time.Time
+ AccountID int
+}
+
+// IMPORTANT(fusion): Ideally you'd save sessions in a database to reduce memory
+// usage and to make them persistent with server restarts. In reality, we should
+// have a low amount of sessions and a high server uptime, making the memory usage
+// here minimal. We can always turn this into a LRU cache with a set maximum number
+// of sessions.
+
+var (
+ g_SessionsMutex sync.Mutex
+ g_Sessions []TSession
+)
+
+func GenerateSessionID() []byte {
+ var SessionID [32]byte
+ _, Err := rand.Read(SessionID[:])
+ if Err != nil {
+ g_LogErr.Printf("Failed to generate session id: %v", Err)
+ return nil
+ }
+
+ return SessionID[:]
+}
+
+func GetRequestSessionID(Request *http.Request) []byte {
+ Cookie, Err := Request.Cookie("GOSESSID")
+ if Err != nil {
+ return nil
+ }
+
+ SessionID, Err := hex.DecodeString(Cookie.Value)
+ if Err != nil {
+ g_LogErr.Printf("Failed to decode session id: %v", Err)
+ return nil
+ }
+
+ if len(SessionID) != 32 {
+ g_LogErr.Printf("Invalid session id size %v (expected 32)", len(SessionID))
+ return nil
+ }
+
+ return SessionID
+}
+
+func SessionLookup(SessionID []byte, IPAddress string) int {
+ AccountID := 0
+ if SessionID != nil && IPAddress != "" {
+ g_SessionsMutex.Lock()
+ defer g_SessionsMutex.Unlock()
+ for Index := 0; Index < len(g_Sessions); Index += 1 {
+ Session := &g_Sessions[Index]
+
+ if time.Until(Session.Expires) <= 0 {
+ g_Sessions = SwapAndPop(g_Sessions, Index)
+ Index -= 1
+ continue
+ }
+
+ if bytes.Equal(Session.SessionID, SessionID) && Session.IPAddress == IPAddress {
+ AccountID = Session.AccountID
+ break
+ }
+ }
+ }
+ return AccountID
+}
+
+func SessionStart(Context *THttpRequestContext, AccountID int) {
+ if AccountID <= 0 {
+ g_LogErr.Printf("Trying to start session with invalid account id %v", AccountID)
+ return
+ }
+
+ SessionID := make([]byte, 32)
+ if _, Err := rand.Read(SessionID); Err != nil {
+ g_LogErr.Printf("Failed to generate session id: %v", Err)
+ return
+ }
+
+ Context.SessionID = SessionID
+ Context.AccountID = AccountID
+ Expires := time.Now().Add(time.Hour)
+ http.SetCookie(Context.Writer, &http.Cookie{
+ Name: "GOSESSID",
+ Value: hex.EncodeToString(SessionID),
+ Path: "/",
+ Expires: Expires,
+ Secure: false, // TODO(fusion): Enable this when HTTPS is enabled (?).
+ HttpOnly: true,
+ })
+
+ g_SessionsMutex.Lock()
+ defer g_SessionsMutex.Unlock()
+ g_Sessions = append(g_Sessions,
+ TSession{
+ SessionID: SessionID,
+ IPAddress: Context.IPAddress,
+ Expires: Expires,
+ AccountID: AccountID,
+ })
+}
+
+func SessionEnd(Context *THttpRequestContext) {
+ if Context.SessionID == nil {
+ return
+ }
+
+ http.SetCookie(Context.Writer, &http.Cookie{
+ Name: "GOSESSID",
+ Value: "",
+ Path: "/",
+ Expires: time.Unix(0, 0),
+ })
+
+ g_SessionsMutex.Lock()
+ defer g_SessionsMutex.Unlock()
+ for Index := 0; Index < len(g_Sessions); Index += 1 {
+ Session := &g_Sessions[Index]
+ if bytes.Equal(Session.SessionID, Context.SessionID) && Session.IPAddress == Context.IPAddress {
+ g_Sessions[Index] = g_Sessions[len(g_Sessions)-1]
+ g_Sessions[len(g_Sessions)-1] = TSession{}
+ g_Sessions = g_Sessions[:len(g_Sessions)-1]
+ break
+ }
+ }
+}
diff --git a/templates.go b/templates.go
new file mode 100644
index 0000000..1f47782
--- /dev/null
+++ b/templates.go
@@ -0,0 +1,223 @@
+package main
+
+import (
+ "fmt"
+ "html/template"
+ "io"
+ "net/http"
+ "strconv"
+)
+
+type (
+ CommonTmplData struct {
+ Title string
+ AccountID int
+ }
+
+ GenericTmplData struct {
+ Common CommonTmplData
+ }
+
+ AccountTmplData struct {
+ Common CommonTmplData
+ Account *TAccountSummary
+ }
+
+ CharacterTmplData struct {
+ Common CommonTmplData
+ Character *TCharacterProfile
+ }
+
+ KillStatisticsTmplData struct {
+ Common CommonTmplData
+ World *TWorld
+ KillStatistics []TKillStatistics
+ }
+
+ WorldTmplData struct {
+ Common CommonTmplData
+ World *TWorld
+ OnlineCharacters []TOnlineCharacter
+ }
+
+ WorldListTmplData struct {
+ Common CommonTmplData
+ Worlds []TWorld
+ }
+
+ MessageTmplData struct {
+ Common CommonTmplData
+ Heading string
+ Message string
+ }
+)
+
+var (
+ g_Templates *template.Template
+)
+
+func InitTemplates() bool {
+ var Err error
+ g_Templates, Err = template.ParseGlob("templates/*.tmpl")
+ if Err != nil {
+ g_LogErr.Printf("Failed to parse templates: %v", Err)
+ return false
+ }
+ return true
+}
+
+func ExitTemplates() {
+ g_Templates = nil
+}
+
+func ExecuteTemplate(Writer io.Writer, FileName string, Data any) {
+ Err := g_Templates.ExecuteTemplate(Writer, FileName, Data)
+ if Err != nil {
+ g_LogErr.Printf("Failed to execute template \"%v\": %v", FileName, Err)
+ }
+}
+
+func RenderRequestError(Context *THttpRequestContext, Status int) {
+ StatusText := http.StatusText(Status)
+ ExecuteTemplate(Context.Writer, "message.tmpl",
+ MessageTmplData{
+ Common: CommonTmplData{
+ Title: StatusText,
+ AccountID: Context.AccountID,
+ },
+ Heading: strconv.Itoa(Status),
+ Message: StatusText,
+ })
+}
+
+func RenderMessage(Context *THttpRequestContext, Heading string, Message string) {
+ ExecuteTemplate(Context.Writer, "message.tmpl",
+ MessageTmplData{
+ Common: CommonTmplData{
+ Title: Heading,
+ AccountID: Context.AccountID,
+ },
+ Heading: Heading,
+ Message: Message,
+ })
+}
+
+func RenderAccountSummary(Context *THttpRequestContext) {
+ Data := AccountTmplData{
+ Common: CommonTmplData{
+ Title: "Account Summary",
+ AccountID: Context.AccountID,
+ },
+ Account: nil,
+ }
+
+ Result, Account := GetAccountSummary(Context.AccountID)
+ if Result == 0 {
+ Data.Account = &Account
+ }
+
+ ExecuteTemplate(Context.Writer, "account_summary.tmpl", Data)
+}
+
+func RenderAccountLogin(Context *THttpRequestContext) {
+ ExecuteTemplate(Context.Writer, "account_login.tmpl",
+ GenericTmplData{
+ Common: CommonTmplData{
+ Title: "Login",
+ AccountID: Context.AccountID,
+ },
+ })
+}
+
+func RenderAccountCreate(Context *THttpRequestContext) {
+ ExecuteTemplate(Context.Writer, "account_create.tmpl",
+ GenericTmplData{
+ Common: CommonTmplData{
+ Title: "Create Account",
+ AccountID: Context.AccountID,
+ },
+ })
+}
+
+func RenderAccountRecover(Context *THttpRequestContext) {
+ ExecuteTemplate(Context.Writer, "account_recover.tmpl",
+ GenericTmplData{
+ Common: CommonTmplData{
+ Title: "Recover Account",
+ AccountID: Context.AccountID,
+ },
+ })
+}
+
+func RenderCharacterCreate(Context *THttpRequestContext) {
+ ExecuteTemplate(Context.Writer, "character_create.tmpl",
+ WorldListTmplData{
+ Common: CommonTmplData{
+ Title: "Create Character",
+ AccountID: Context.AccountID,
+ },
+ Worlds: GetWorlds(),
+ })
+}
+
+func RenderCharacterProfile(Context *THttpRequestContext, Character *TCharacterProfile) {
+ Title := "Search Character"
+ if Character != nil {
+ Title = fmt.Sprintf("%v's Profile", Character.Name)
+ }
+
+ ExecuteTemplate(Context.Writer, "character_profile.tmpl",
+ CharacterTmplData{
+ Common: CommonTmplData{
+ Title: Title,
+ AccountID: Context.AccountID,
+ },
+ Character: Character,
+ })
+}
+
+func RenderKillStatisticsList(Context *THttpRequestContext) {
+ ExecuteTemplate(Context.Writer, "killstatistics_list.tmpl",
+ WorldListTmplData{
+ Common: CommonTmplData{
+ Title: "Kill Statistics",
+ AccountID: Context.AccountID,
+ },
+ Worlds: GetWorlds(),
+ })
+}
+
+func RenderKillStatistics(Context *THttpRequestContext, WorldName string) {
+ ExecuteTemplate(Context.Writer, "killstatistics.tmpl",
+ KillStatisticsTmplData{
+ Common: CommonTmplData{
+ Title: fmt.Sprintf("Kill Statistics - %v", WorldName),
+ AccountID: Context.AccountID,
+ },
+ World: GetWorld(WorldName),
+ KillStatistics: GetKillStatistics(WorldName),
+ })
+}
+
+func RenderWorldList(Context *THttpRequestContext) {
+ ExecuteTemplate(Context.Writer, "world_list.tmpl",
+ WorldListTmplData{
+ Common: CommonTmplData{
+ Title: "Worlds",
+ AccountID: Context.AccountID,
+ },
+ Worlds: GetWorlds(),
+ })
+}
+
+func RenderWorldInfo(Context *THttpRequestContext, WorldName string) {
+ ExecuteTemplate(Context.Writer, "world_info.tmpl",
+ WorldTmplData{
+ Common: CommonTmplData{
+ Title: "Worlds",
+ AccountID: Context.AccountID,
+ },
+ World: GetWorld(WorldName),
+ OnlineCharacters: GetOnlineCharacters(WorldName),
+ })
+}
diff --git a/templates/_footer.tmpl b/templates/_footer.tmpl
new file mode 100644
index 0000000..21a47e3
--- /dev/null
+++ b/templates/_footer.tmpl
@@ -0,0 +1,22 @@
+{{/* FOOTER START */}}
+ <div class="box">
+ <h1>About</h1>
+ <table class="info">
+ <tr>
+ <th>Client:</th>
+ <td>Tibia Client Version 7.7</td>
+ </tr>
+ <tr>
+ <th>Login Server:</th>
+ <td>localhost:7171</td>
+ </tr>
+ <tr>
+ <th>RSA Public Key:</th>
+ <td>142996239624163995200701773828988955507954033454661532174705160829347375827760388829672133862046006741453928458538592179906264509724520840657286865659265687630979195970404721891201847792002125535401292779123937207447574596692788513647179235335529307251350570728407373705564708871762033017096809910315212883967</td>
+ </tr>
+ </table>
+ </div>
+ </center>
+ </body>
+</html>
+{{/* FOOTER END */}} \ No newline at end of file
diff --git a/templates/_header.tmpl b/templates/_header.tmpl
new file mode 100644
index 0000000..062c681
--- /dev/null
+++ b/templates/_header.tmpl
@@ -0,0 +1,31 @@
+{{/* HEADER START */}}
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="UTF-8"/>
+ <meta name="description" content=""/>
+ <meta name="keywords" content=""/>
+ <meta name="author" content=""/>
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
+ <link rel="stylesheet" type="text/css" href="/res/css/style.css"/>
+ <title>{{.Title}}</title>
+ </head>
+
+ <body>
+ <center>
+ <div class="nav">
+ {{if .AccountID}}
+ <a class="button" href="/account">Account Summary</a>
+ <a class="button" href="/character/create">Create Character</a>
+ <a class="button" href="/account/logout">Logout</a>
+ {{else}}
+ <a class="button" href="/account">Login</a>
+ <a class="button" href="/account/create">Create Account</a>
+ <a class="button" href="/account/recover">Recover Account</a>
+ {{end}}
+ <br>
+ <a class="button" href="/character">Characters</a>
+ <a class="button" href="#">Houses</a>
+ <a class="button" href="/world">Worlds</a>
+ </div>
+{{/* HEADER END */}} \ No newline at end of file
diff --git a/templates/account_create.tmpl b/templates/account_create.tmpl
new file mode 100644
index 0000000..6ade0bf
--- /dev/null
+++ b/templates/account_create.tmpl
@@ -0,0 +1,22 @@
+{{template "_header.tmpl" .Common}}
+ <form class="box" action="/account/create" method="POST">
+ <h1>Create Account</h1>
+
+ <label for="create_account">ACCOUNT NUMBER</label>
+ <input id="create_account" type="password" name="account"/>
+
+ <label for="create_email">EMAIL</label>
+ <input id="create_email" type="text" name="email"/>
+
+ <label for="create_email_confirm">CONFIRM EMAIL</label>
+ <input id="create_email_confirm" type="text" name="email_confirm"/>
+
+ <label for="create_password">PASSWORD</label>
+ <input id="create_password" type="password" name="password"/>
+
+ <label for="create_password_confirm">CONFIRM PASSWORD</label>
+ <input id="create_password_confirm" type="password" name="password_confirm"/>
+
+ <input type="submit" value="Create"/>
+ </form>
+{{template "_footer.tmpl" .Common}} \ No newline at end of file
diff --git a/templates/account_login.tmpl b/templates/account_login.tmpl
new file mode 100644
index 0000000..2b9fbfa
--- /dev/null
+++ b/templates/account_login.tmpl
@@ -0,0 +1,13 @@
+{{template "_header.tmpl" .Common}}
+ <form class="box" action="/account" method="POST">
+ <h1>Login</h1>
+
+ <label for="login_account">ACCOUNT NUMBER</label>
+ <input id="login_account" type="password" name="account"/>
+
+ <label for="login_password">PASSWORD</label>
+ <input id="login_password" type="password" name="password"/>
+
+ <input type="submit" value="Login"/>
+ </form>
+{{template "_footer.tmpl" .Common}} \ No newline at end of file
diff --git a/templates/account_recover.tmpl b/templates/account_recover.tmpl
new file mode 100644
index 0000000..ee0d750
--- /dev/null
+++ b/templates/account_recover.tmpl
@@ -0,0 +1,11 @@
+{{template "_header.tmpl" .Common}}
+ <form class="box" action="#">
+ <h1>Recover Account</h1>
+ <p style="color: #A11;">Account recovery not currently implemented.</p>
+
+ <label for="recover_email">EMAIL</label>
+ <input id="recover_email" type="text" name="email"/>
+
+ <input type="submit" value="Recover" disabled/>
+ </form>
+{{template "_footer.tmpl" .Common}} \ No newline at end of file
diff --git a/templates/account_summary.tmpl b/templates/account_summary.tmpl
new file mode 100644
index 0000000..ae42479
--- /dev/null
+++ b/templates/account_summary.tmpl
@@ -0,0 +1,65 @@
+{{template "_header.tmpl" .Common}}
+ {{with .Account}}
+ <div class="box">
+ <h1>Account Information</h1>
+ <table class="info">
+ <tr>
+ <th>Email:</th>
+ <td>{{.Email}}</td>
+ </tr>
+ <tr>
+ <th>Status:</th>
+ {{if eq .PremiumDays 1}}
+ <td style="color: #A11;">Premium Account (last day)</td>
+ {{else if gt .PremiumDays 1}}
+ <td>Premium Account ({{.PremiumDays}} days left)</td>
+ {{else}}
+ <td>Free Account</td>
+ {{end}}
+ </tr>
+ {{if .PendingPremiumDays}}
+ <tr>
+ <th>Pending Premium:</th>
+ {{if eq .PendingPremiumDays 1}}
+ <td>1 day</td>
+ {{else}}
+ <td>{{.PendingPremiumDays}} days</td>
+ {{end}}
+ </tr>
+ {{end}}
+ </table>
+ </div>
+ {{if .Characters}}
+ <div class="box">
+ <h1>Characters</h1>
+ <table>
+ <tr>
+ <th>Name</th>
+ <th>Level</th>
+ <th>Vocation</th>
+ <th>World</th>
+ <th>Status</th>
+ </tr>
+ {{range .Characters}}
+ <tr>
+ <td><a href="/character?name={{.Name}}">{{.Name}}</a></td>
+ <td>{{or .Level 1}}</td>
+ <td>{{or .Profession "None"}}</td>
+ <td>{{.World}}</td>
+ {{if .Online}}
+ <td style="color: #1A1;">Online</td>
+ {{else}}
+ <td style="color: #A11;">Offline</td>
+ {{end}}
+ </tr>
+ {{end}}
+ </table>
+ </div>
+ {{end}}
+ {{else}}
+ <div class="box">
+ <h1>Oops</h1>
+ <p>Something went wrong when loading your account's summary. Wait a few moments and try again.</p>
+ </div>
+ {{end}}
+{{template "_footer.tmpl" .Common}} \ No newline at end of file
diff --git a/templates/character_create.tmpl b/templates/character_create.tmpl
new file mode 100644
index 0000000..c243f2d
--- /dev/null
+++ b/templates/character_create.tmpl
@@ -0,0 +1,23 @@
+{{template "_header.tmpl" .Common}}
+ <form class="box" action="/character/create" method="POST">
+ <h1>Create Account</h1>
+
+ <label for="character_name">NAME</label>
+ <input id="character_name" type="text" name="name"/>
+
+ <label for="character_sex">SEX</label>
+ <select id="character_sex" name="sex">
+ <option value="1">MALE</option>
+ <option value="2">FEMALE</option>
+ </select>
+
+ <label for="character_world">WORLD</label>
+ <select id="character_world" name="world">
+ {{range .Worlds}}
+ <option value="{{.Name}}">{{.Name}} ({{.Type}})</option>
+ {{end}}
+ </select>
+
+ <input type="submit" value="Create"/>
+ </form>
+{{template "_footer.tmpl" .Common}} \ No newline at end of file
diff --git a/templates/character_profile.tmpl b/templates/character_profile.tmpl
new file mode 100644
index 0000000..824e908
--- /dev/null
+++ b/templates/character_profile.tmpl
@@ -0,0 +1,60 @@
+{{template "_header.tmpl" .Common}}
+ {{with .Character}}
+ <div class="box">
+ <h1>Character Information</h1>
+ <table class="info">
+ <tr>
+ <th>Name:</th>
+ <td>{{.Name}}</td>
+ </tr>
+ <tr>
+ <th>Sex:</th>
+ {{if eq .Sex 1}}
+ <td>male</td>
+ {{else if eq .Sex 2}}
+ <td>female</td>
+ {{else}}
+ <td>unknown</td>
+ {{end}}
+ </tr>
+ <tr>
+ <th>Vocation:</th>
+ <td>{{or .Profession "None"}}</td>
+ </tr>
+ <tr>
+ <th>Level:</th>
+ <td>{{or .Level 1}}</td>
+ </tr>
+ <tr>
+ <th>World:</th>
+ <td>{{.World}}</td>
+ </tr>
+ <tr>
+ <th>Residence:</th>
+ <td>{{or .Residence "Rookgaard"}}</td>
+ </tr>
+ <tr>
+ <th>Last Login:</th>
+ <td>{{.LastLogin}}</td>
+ </tr>
+ <tr>
+ <th>Account Status:</th>
+ {{if .PremiumDays}}
+ <td>Premium Account</td>
+ {{else}}
+ <td>Free Account</td>
+ {{end}}
+ </tr>
+ </table>
+ </div>
+ {{end}}
+
+ <form class="box" action="/character" method="GET">
+ <h1>Search Character</h1>
+
+ <label for="search_name">CHARACTER NAME</label>
+ <input id="search_name" type="text" name="name"/>
+
+ <input type="submit" value="Search"/>
+ </form>
+{{template "_footer.tmpl" .Common}} \ No newline at end of file
diff --git a/templates/killstatistics.tmpl b/templates/killstatistics.tmpl
new file mode 100644
index 0000000..451059e
--- /dev/null
+++ b/templates/killstatistics.tmpl
@@ -0,0 +1,28 @@
+{{template "_header.tmpl" .Common}}
+ <div class="box">
+ {{with .World}}
+ <h1>Kill Statistics - <a href="/world?name={{.Name}}">{{.Name}}</a></h1>
+ {{else}}
+ <h1>Kill Statistics</h1>
+ {{end}}
+
+ {{if .KillStatistics}}
+ <table>
+ <tr>
+ <th>Creature</th>
+ <th>Times Killed</th>
+ <th>Players Killed</th>
+ </tr>
+ {{range .KillStatistics}}
+ <tr>
+ <td>{{.RaceName}}</td>
+ <td>{{.TimesKilled}}</td>
+ <td>{{.PlayersKilled}}</td>
+ </tr>
+ {{end}}
+ </table>
+ {{else}}
+ <p>There are no kill statistics.</p>
+ {{end}}
+ </div>
+{{template "_footer.tmpl" .Common}} \ No newline at end of file
diff --git a/templates/message.tmpl b/templates/message.tmpl
new file mode 100644
index 0000000..847be8f
--- /dev/null
+++ b/templates/message.tmpl
@@ -0,0 +1,6 @@
+{{template "_header.tmpl" .Common}}
+ <div class="box">
+ <h1>{{.Heading}}</h1>
+ <p>{{.Message}}</p>
+ </div>
+{{template "_footer.tmpl" .Common}} \ No newline at end of file
diff --git a/templates/world_info.tmpl b/templates/world_info.tmpl
new file mode 100644
index 0000000..c0f9c24
--- /dev/null
+++ b/templates/world_info.tmpl
@@ -0,0 +1,62 @@
+{{template "_header.tmpl" .Common}}
+ <div class="box">
+ <h1>World Information</h1>
+ {{with .World}}
+ <table class="info">
+ <tr>
+ <th>Name:</th>
+ <td>{{.Name}}</td>
+ </tr>
+ <tr>
+ <th>Type:</th>
+ <td>{{.Type}}</td>
+ </tr>
+ <tr>
+ <th>Players Online:</th>
+ <td>{{.NumPlayers}}</td>
+ </tr>
+ <tr>
+ <th>Online Record:</th>
+ {{if .OnlineRecord}}
+ <td>{{.OnlineRecord}} players (on {{.OnlineRecordTime}})</td>
+ {{else}}
+ <td>None</td>
+ {{end}}
+ </tr>
+ </table>
+ <a class="button" href="/killstatistics?world={{.Name}}">Kill Statistics</a>
+ {{else}}
+ <p>No information available.</p>
+ {{end}}
+ </div>
+
+ <div class="box">
+ <h1>Players Online</h1>
+ {{if .OnlineCharacters}}
+ <table>
+ <tr>
+ <th>Name</th>
+ <th>Level</th>
+ <th>Vocation</th>
+ <th>World</th>
+ <th>Status</th>
+ </tr>
+ {{range .OnlineCharacters}}
+ <tr>
+ <td><a href="/character?name={{.Name}}">{{.Name}}</a></td>
+ <td>{{or .Level 1}}</td>
+ <td>{{or .Profession "None"}}</td>
+ <td>{{.World}}</td>
+ {{if .Online}}
+ <td style="color: #1A1;">Online</td>
+ {{else}}
+ <td style="color: #A11;">Offline</td>
+ {{end}}
+ </tr>
+ {{end}}
+ </table>
+ {{else}}
+ <p>There are no players online.</p>
+ {{end}}
+ </div>
+{{template "_footer.tmpl" .Common}} \ No newline at end of file
diff --git a/templates/world_list.tmpl b/templates/world_list.tmpl
new file mode 100644
index 0000000..de9786c
--- /dev/null
+++ b/templates/world_list.tmpl
@@ -0,0 +1,23 @@
+{{template "_header.tmpl" .Common}}
+ <div class="box">
+ <h1>Worlds</h1>
+ {{if .Worlds}}
+ <table>
+ <tr>
+ <th>Name</th>
+ <th>Type</th>
+ <th>Players Online</th>
+ </tr>
+ {{range .Worlds}}
+ <tr>
+ <td><a href="/world?name={{.Name}}">{{.Name}}</a></td>
+ <td>{{.Type}}</td>
+ <td>{{.NumPlayers}}</td>
+ </tr>
+ {{end}}
+ </table>
+ {{else}}
+ <p>Something went wrong when loading world data. Wait a few moments and try again.</p>
+ {{end}}
+ </div>
+{{template "_footer.tmpl" .Common}} \ No newline at end of file