From d84651a899a6104fce73d95b9c571627c55e5d16 Mon Sep 17 00:00:00 2001 From: fusion32 Date: Fri, 1 Aug 2025 19:48:26 -0300 Subject: bare bones website --- .gitignore | 5 + build.bat | 1 + common.go | 380 ++++++++++++++++++++ config.cfg | 25 ++ go.mod | 3 + mail.go | 44 +++ main.go | 629 +++++++++++++++++++++++++++++++++ query.go | 735 +++++++++++++++++++++++++++++++++++++++ res/css/style.css | 90 +++++ run.bat | 1 + session.go | 142 ++++++++ templates.go | 223 ++++++++++++ templates/_footer.tmpl | 22 ++ templates/_header.tmpl | 31 ++ templates/account_create.tmpl | 22 ++ templates/account_login.tmpl | 13 + templates/account_recover.tmpl | 11 + templates/account_summary.tmpl | 65 ++++ templates/character_create.tmpl | 23 ++ templates/character_profile.tmpl | 60 ++++ templates/killstatistics.tmpl | 28 ++ templates/message.tmpl | 6 + templates/world_info.tmpl | 62 ++++ templates/world_list.tmpl | 23 ++ 24 files changed, 2644 insertions(+) create mode 100644 .gitignore create mode 100644 build.bat create mode 100644 common.go create mode 100644 config.cfg create mode 100644 go.mod create mode 100644 mail.go create mode 100644 main.go create mode 100644 query.go create mode 100644 res/css/style.css create mode 100644 run.bat create mode 100644 session.go create mode 100644 templates.go create mode 100644 templates/_footer.tmpl create mode 100644 templates/_header.tmpl create mode 100644 templates/account_create.tmpl create mode 100644 templates/account_login.tmpl create mode 100644 templates/account_recover.tmpl create mode 100644 templates/account_summary.tmpl create mode 100644 templates/character_create.tmpl create mode 100644 templates/character_profile.tmpl create mode 100644 templates/killstatistics.tmpl create mode 100644 templates/message.tmpl create mode 100644 templates/world_info.tmpl create mode 100644 templates/world_list.tmpl 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 */}} +
+

About

+ + + + + + + + + + + + + +
Client:Tibia Client Version 7.7
Login Server:localhost:7171
RSA Public Key:142996239624163995200701773828988955507954033454661532174705160829347375827760388829672133862046006741453928458538592179906264509724520840657286865659265687630979195970404721891201847792002125535401292779123937207447574596692788513647179235335529307251350570728407373705564708871762033017096809910315212883967
+
+ + + +{{/* 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 */}} + + + + + + + + + + {{.Title}} + + + +
+ +{{/* 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}} +
+

Create Account

+ + + + + + + + + + + + + + + + + +
+{{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}} +
+

Login

+ + + + + + + + +
+{{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}} +
+

Recover Account

+

Account recovery not currently implemented.

+ + + + + +
+{{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}} +
+

Account Information

+ + + + + + + + {{if eq .PremiumDays 1}} + + {{else if gt .PremiumDays 1}} + + {{else}} + + {{end}} + + {{if .PendingPremiumDays}} + + + {{if eq .PendingPremiumDays 1}} + + {{else}} + + {{end}} + + {{end}} +
Email:{{.Email}}
Status:Premium Account (last day)Premium Account ({{.PremiumDays}} days left)Free Account
Pending Premium:1 day{{.PendingPremiumDays}} days
+
+ {{if .Characters}} +
+

Characters

+ + + + + + + + + {{range .Characters}} + + + + + + {{if .Online}} + + {{else}} + + {{end}} + + {{end}} +
NameLevelVocationWorldStatus
{{.Name}}{{or .Level 1}}{{or .Profession "None"}}{{.World}}OnlineOffline
+
+ {{end}} + {{else}} +
+

Oops

+

Something went wrong when loading your account's summary. Wait a few moments and try again.

+
+ {{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}} +
+

Create Account

+ + + + + + + + + + + +
+{{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}} +
+

Character Information

+ + + + + + + + {{if eq .Sex 1}} + + {{else if eq .Sex 2}} + + {{else}} + + {{end}} + + + + + + + + + + + + + + + + + + + + + + + + {{if .PremiumDays}} + + {{else}} + + {{end}} + +
Name:{{.Name}}
Sex:malefemaleunknown
Vocation:{{or .Profession "None"}}
Level:{{or .Level 1}}
World:{{.World}}
Residence:{{or .Residence "Rookgaard"}}
Last Login:{{.LastLogin}}
Account Status:Premium AccountFree Account
+
+ {{end}} + +
+

Search Character

+ + + + + +
+{{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}} +
+ {{with .World}} +

Kill Statistics - {{.Name}}

+ {{else}} +

Kill Statistics

+ {{end}} + + {{if .KillStatistics}} + + + + + + + {{range .KillStatistics}} + + + + + + {{end}} +
CreatureTimes KilledPlayers Killed
{{.RaceName}}{{.TimesKilled}}{{.PlayersKilled}}
+ {{else}} +

There are no kill statistics.

+ {{end}} +
+{{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}} +
+

{{.Heading}}

+

{{.Message}}

+
+{{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}} +
+

World Information

+ {{with .World}} + + + + + + + + + + + + + + + + {{if .OnlineRecord}} + + {{else}} + + {{end}} + +
Name:{{.Name}}
Type:{{.Type}}
Players Online:{{.NumPlayers}}
Online Record:{{.OnlineRecord}} players (on {{.OnlineRecordTime}})None
+ Kill Statistics + {{else}} +

No information available.

+ {{end}} +
+ +
+

Players Online

+ {{if .OnlineCharacters}} + + + + + + + + + {{range .OnlineCharacters}} + + + + + + {{if .Online}} + + {{else}} + + {{end}} + + {{end}} +
NameLevelVocationWorldStatus
{{.Name}}{{or .Level 1}}{{or .Profession "None"}}{{.World}}OnlineOffline
+ {{else}} +

There are no players online.

+ {{end}} +
+{{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}} +
+

Worlds

+ {{if .Worlds}} + + + + + + + {{range .Worlds}} + + + + + + {{end}} +
NameTypePlayers Online
{{.Name}}{{.Type}}{{.NumPlayers}}
+ {{else}} +

Something went wrong when loading world data. Wait a few moments and try again.

+ {{end}} +
+{{template "_footer.tmpl" .Common}} \ No newline at end of file -- cgit v1.2.3