bare bones website

This commit is contained in:
fusion32 2025-08-01 19:48:26 -03:00
commit d84651a899
24 changed files with 2644 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
.vscode
bin
build
local
*.log

1
build.bat Normal file
View File

@ -0,0 +1 @@
@go build -o build/ %*

380
common.go Normal file
View File

@ -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
}

25
config.cfg Normal file
View File

@ -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

3
go.mod Normal file
View File

@ -0,0 +1,3 @@
module tibia-web
go 1.24.5

44
mail.go Normal file
View File

@ -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))
}

629
main.go Normal file
View File

@ -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))
}
}

735
query.go Normal file
View File

@ -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
}
}

90
res/css/style.css Normal file
View File

@ -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;
}

1
run.bat Normal file
View File

@ -0,0 +1 @@
@CALL build.bat %* && CALL build\tibia-web

142
session.go Normal file
View File

@ -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
}
}
}

223
templates.go Normal file
View File

@ -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),
})
}

22
templates/_footer.tmpl Normal file
View File

@ -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 */}}

31
templates/_header.tmpl Normal file
View File

@ -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 */}}

View File

@ -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}}

View File

@ -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}}

View File

@ -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}}

View File

@ -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}}

View File

@ -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}}

View File

@ -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}}

View File

@ -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}}

6
templates/message.tmpl Normal file
View File

@ -0,0 +1,6 @@
{{template "_header.tmpl" .Common}}
<div class="box">
<h1>{{.Heading}}</h1>
<p>{{.Message}}</p>
</div>
{{template "_footer.tmpl" .Common}}

62
templates/world_info.tmpl Normal file
View File

@ -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}}

23
templates/world_list.tmpl Normal file
View File

@ -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}}