web/main.go

624 lines
18 KiB
Go

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_WorldRefreshInterval = 15 * 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, "MaxCachedAccounts") {
g_MaxCachedAccounts = ParseInteger(Value)
} else if strings.EqualFold(Key, "MaxCachedCharacters") {
g_MaxCachedCharacters = ParseInteger(Value)
} else if strings.EqualFold(Key, "CharacterRefreshInterval") {
g_CharacterRefreshInterval = ParseDuration(Value)
} else if strings.EqualFold(Key, "WorldRefreshInterval") {
g_WorldRefreshInterval = ParseDuration(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.2")
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))
}
}