diff options
| -rw-r--r-- | .gitignore | 5 | ||||
| -rw-r--r-- | build.bat | 1 | ||||
| -rw-r--r-- | common.go | 380 | ||||
| -rw-r--r-- | config.cfg | 25 | ||||
| -rw-r--r-- | go.mod | 3 | ||||
| -rw-r--r-- | mail.go | 44 | ||||
| -rw-r--r-- | main.go | 629 | ||||
| -rw-r--r-- | query.go | 735 | ||||
| -rw-r--r-- | res/css/style.css | 90 | ||||
| -rw-r--r-- | run.bat | 1 | ||||
| -rw-r--r-- | session.go | 142 | ||||
| -rw-r--r-- | templates.go | 223 | ||||
| -rw-r--r-- | templates/_footer.tmpl | 22 | ||||
| -rw-r--r-- | templates/_header.tmpl | 31 | ||||
| -rw-r--r-- | templates/account_create.tmpl | 22 | ||||
| -rw-r--r-- | templates/account_login.tmpl | 13 | ||||
| -rw-r--r-- | templates/account_recover.tmpl | 11 | ||||
| -rw-r--r-- | templates/account_summary.tmpl | 65 | ||||
| -rw-r--r-- | templates/character_create.tmpl | 23 | ||||
| -rw-r--r-- | templates/character_profile.tmpl | 60 | ||||
| -rw-r--r-- | templates/killstatistics.tmpl | 28 | ||||
| -rw-r--r-- | templates/message.tmpl | 6 | ||||
| -rw-r--r-- | templates/world_info.tmpl | 62 | ||||
| -rw-r--r-- | templates/world_list.tmpl | 23 |
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 @@ -0,0 +1,3 @@ +module tibia-web + +go 1.24.5 @@ -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)) +} @@ -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; +} @@ -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 |
