bare bones website
This commit is contained in:
commit
d84651a899
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
.vscode
|
||||||
|
bin
|
||||||
|
build
|
||||||
|
local
|
||||||
|
*.log
|
||||||
380
common.go
Normal file
380
common.go
Normal 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
25
config.cfg
Normal 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
|
||||||
44
mail.go
Normal file
44
mail.go
Normal 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
629
main.go
Normal 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
735
query.go
Normal 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
90
res/css/style.css
Normal 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;
|
||||||
|
}
|
||||||
142
session.go
Normal file
142
session.go
Normal 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
223
templates.go
Normal 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
22
templates/_footer.tmpl
Normal 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
31
templates/_header.tmpl
Normal 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 */}}
|
||||||
22
templates/account_create.tmpl
Normal file
22
templates/account_create.tmpl
Normal 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}}
|
||||||
13
templates/account_login.tmpl
Normal file
13
templates/account_login.tmpl
Normal 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}}
|
||||||
11
templates/account_recover.tmpl
Normal file
11
templates/account_recover.tmpl
Normal 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}}
|
||||||
65
templates/account_summary.tmpl
Normal file
65
templates/account_summary.tmpl
Normal 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}}
|
||||||
23
templates/character_create.tmpl
Normal file
23
templates/character_create.tmpl
Normal 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}}
|
||||||
60
templates/character_profile.tmpl
Normal file
60
templates/character_profile.tmpl
Normal 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}}
|
||||||
28
templates/killstatistics.tmpl
Normal file
28
templates/killstatistics.tmpl
Normal 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
6
templates/message.tmpl
Normal 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
62
templates/world_info.tmpl
Normal 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
23
templates/world_list.tmpl
Normal 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}}
|
||||||
Loading…
x
Reference in New Issue
Block a user