Compare commits

...

10 Commits

Author SHA1 Message Date
fusion32
edea08d11c fix small issue with TransferHouses query 2026-02-09 14:25:21 -03:00
fusion32
6052f24524 fix problem with EvictExGuildleaders query 2026-01-17 00:50:58 -03:00
fusion32
8c2a8461db fix problem with connection input + allow login server to query worlds
There was a small problem with how we computed the size of the
packet "header". It was unlikely to manifest tho, because it's at
the beginning of a TCP message and TCP segments aren't that small,
unless the whole message is that small.
  I'm also allowing login servers to query for world information
to allow a basic form of `STATUS` to be implemented.
2025-11-18 15:17:29 -03:00
fusion32
093e1563df turn error codes into enums + check empty credentials before login
Using enums instead of hard coded numbers for error codes should
help with readability, even though they're local to the functions
that use them to avoid flooding the namespace.
  Checking whether credentials are empty before login will prevent
error messages such as "account disabled" from showing up when no
account is specified, which may look weird. It'll also avoid any
database reads although this shouldn't really make a difference
torwards performance or security since it's only an edge case.
2025-10-30 22:15:57 -03:00
fusion32
d36b26307d prevent password brute force (#4)
Prevent blocked IP addresses and accounts from returning anything
other than a blocked error message. Previously, further attempts
would first check the password which enabled brute force attacks
to use the "invalid account/password" message as an oracle.
2025-10-30 01:03:01 -03:00
fusion32
6aa13394a1 adjust character death indexes for common queries 2025-10-27 21:44:13 -03:00
fusion32
27a0df43b3 change how guild data is stored 2025-10-23 04:26:11 -03:00
fusion32
0ff6217227 add include path for PostgreSQL + overall cleanup
Some distributions place libpq headers under /usr/include/postgresql
which is why it's added to the include path. And even though we don't
support it yet, the same happens with MariaDB, with headers being
placed under /usr/include/mariadb.
2025-10-19 19:17:37 -03:00
fusion32
eba55f8361 update documentation + wrap the few SQLite scripts 2025-10-18 18:21:31 -03:00
fusion32
ebf536a791 properly handle NULL values with postgres result helpers 2025-10-18 03:40:40 -03:00
23 changed files with 836 additions and 278 deletions

View File

@ -26,11 +26,11 @@ ifeq ($(DATABASE), sqlite)
CFLAGS += -DDATABASE_SQLITE=1
else ifeq ($(DATABASE), postgres)
DATABASEOBJ = $(BUILDDIR)/database_postgres.obj
CFLAGS += -DDATABASE_POSTGRESQL=1
CFLAGS += -DDATABASE_POSTGRESQL=1 -I/usr/include/postgresql
LFLAGS += -lpq
else ifeq ($(DATABASE), mariadb)
DATABASEOBJ = $(BUILDDIR)/database_mysql.obj
CFLAGS += -DDATABASE_MYSQL=1 -DDATABASE_MARIADB=1
DATABASEOBJ = $(BUILDDIR)/database_mariadb.obj
CFLAGS += -DDATABASE_MARIADB=1 -I/usr/include/mariadb
LFLAGS += -lmariadb
else
$(error Unsupported DATABASE: `$(DATABASE)`. Valid options are `sqlite`, `postgres`, or `mariadb`)
@ -68,7 +68,7 @@ $(BUILDDIR)/database_postgres.obj: $(SRCDIR)/database_postgres.cc $(SRCDIR)/quer
@mkdir -p $(@D)
$(CXX) -c $(CXXFLAGS) -o $@ $<
$(BUILDDIR)/database_mysql.obj: $(SRCDIR)/database_mysql.cc $(SRCDIR)/querymanager.hh
$(BUILDDIR)/database_mariadb.obj: $(SRCDIR)/database_mariadb.cc $(SRCDIR)/querymanager.hh
@mkdir -p $(@D)
$(CXX) -c $(CXXFLAGS) -o $@ $<

View File

@ -16,7 +16,7 @@ make clean # remove `build` directory
```
## Running (SQLite)
The query manager becomes the database, automatically initializing and maintaining the schema, based on the files in `sqlite/` (see `sqlite/README.txt`). The default schema file won't automatically insert any initial data (see `sqlite/init.sql`), although you could put some insertions at the end to avoid having to manually run something like `sqlite/init.sql`. There are a few configuration options, and in particular `SQLite.*` options that can be adjusted in `config.cfg` but the defaults should work for most use cases.
The query manager becomes the database, automatically initializing and maintaining the schema, based on the files in `sqlite/` (see `sqlite/README.txt`). The default schema file won't automatically insert any initial data but that can be changed by using a patch (again, see `sqlite/README.txt`). There are a few configuration options, and in particular `SQLite.*` options that can be adjusted in `config.cfg` but the defaults should work for most use cases.
## Running (PostgreSQL)
The query manager becomes a relay to the actual database. And with PostgreSQL being a distributed database system, it makes no sense to have individual clients managing the schema, since there could be multiple, each with their own assumptions. For that reason there is a `SchemaInfo` table with a `VERSION` row that will be queried at startup and compared against `POSTGRESQL_SCHEMA_VERSION`, defined in `src/database_postgres.cc`, to make sure there is an agreement on the schema version. It is hardcoded because schema changes will usually result in query changes.

View File

@ -11,8 +11,8 @@ SQLite.MaxCachedStatements = 100
# Empty values are ignored, meaning their defaults will be used instead.
# For more information see:
# https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-PARAMKEYWORDS
PostgreSQL.Host = "localhost"
PostgreSQL.Port = "5432"
PostgreSQL.Host = ""
PostgreSQL.Port = ""
PostgreSQL.DBName = "tibia"
PostgreSQL.User = "tibia"
PostgreSQL.Password = ""
@ -22,14 +22,14 @@ PostgreSQL.SSLMode = ""
PostgreSQL.SSLRootCert = ""
PostgreSQL.MaxCachedStatements = 100
# MySQL/MariaDB Config
MySQL.Host = "localhost"
MySQL.Port = "3306"
MySQL.DBName = "tibia"
MySQL.User = "tibia"
MySQL.Password = ""
MySQL.UnixSocket = ""
MySQL.MaxCachedStatements = 100
# MariaDB Config
MariaDB.Host = "localhost"
MariaDB.Port = "3306"
MariaDB.DBName = "tibia"
MariaDB.User = "tibia"
MariaDB.Password = ""
MariaDB.UnixSocket = ""
MariaDB.MaxCachedStatements = 100
# Connection Config
QueryManagerPort = 7173

146
postgres/README.txt Normal file
View File

@ -0,0 +1,146 @@
WARNING: This is not meant to be a complete guide on PostgreSQL, but rather a
"first steps" kind of guide. It'll only cover things on the surface level. For
a deep dive into how the database operates, how to properly configure it, and
ultimately properly administrate it, you MUST refer to the PostgreSQL manual
for your version. The most current version of the manual will describe the most
recent features, but not all features are present in all versions.
If anything, you should absolutely consult the section that regards server
administration "III. Server Administration".
MANUAL https://www.postgresql.org/docs/current/index.html
Installation
------------
PostgreSQL is available in most Linux distributions as a package which is
the preferred way to get it installed. Some will automatically setup a service,
create service users, initialize the database cluster, etc... If not, you might
need to do one or more steps manually. If you're having trouble, most systems
will have specific instructions on how to set everything up. Just as an example
here are a few links for common systems:
DEBIAN https://www.postgresql.org/download/linux/debian/
REDHAT https://www.postgresql.org/download/linux/redhat/
SUSE https://www.postgresql.org/download/linux/suse/
UBUNTU https://www.postgresql.org/download/linux/ubuntu/
ARCH https://wiki.archlinux.org/title/PostgreSQL
Configuration
-------------
By default, configuration files will be in the `data` directory which can
change locations depending on how the server was installed but is usually in
`/var/lib/postgres/data`.
All files in the `data` directory are OWNED by the *postgres* SYSTEM user,
meaning you'll only be able to modify them if you're logged in as *postgres*,
by using *sudo* privileges, or both with `sudo su postgres`.
The bulk of the configuration is inside `postgresql.conf` which has multiple
options, but of particular interest are the "CONNECTIONS AND AUTHENTICATION"
options. I won't go over specifics here but if you're planning on accepting
remote connections, you MUST properly configure SSL communication.
Access to the database is controlled with `pg_hba.conf`. This is different
from MySQL where you'd specify users as 'user'@'host' with SQL to restrict
them to certain hosts. Instead you need to specify how certain users/roles
may connect to the database in this file. Properly configuring it is probably
the most important step in securing the database, aside from configuring SSL
communication.
The last file is `pg_ident.conf` which declares mappings from system users
to database users. These mappings alone don't do anything. They must be
explicitly referenced as `map=MAPNAME` in `pg_hba.conf` for supported
authentication methods.
Here is an example of a `pg_hba.conf` + `pg_ident.conf` local access config.
It'll allow *systemuser* to connect as *postgres* to any database using the
*peer* method which checks the system user name. It'll also allow the *tibia*
user to connect to the *tibia* database using the *scram-sha-256* password
authentication scheme. Local connections will use UNIX-domain sockets and for
that matter you'd leave `PostgreSQL.Host` empty.
```
# pg_hba.conf
# TYPE DATABASE USER ADDRESS METHOD
local all postgres peer map=super
local tibia tibia scram-sha-256
# pg_ident.conf
# MAPNAME SYSTEM-USERNAME PG-USERNAME
super systemuser postgres
```
MANUAL https://www.postgresql.org/docs/current/runtime-config.html
MANUAL https://www.postgresql.org/docs/current/client-authentication.html
Database Setup
--------------
It is highly advised to not use a SUPERUSER when connecting to the database
from the query manager, or any other service for that matter. This warrants the
creation of a secondary user that has access, but not administrative privileges.
I figured it would be simpler to have a sequence of *PSQL* commands with their
descriptions. Having a database minimaly ready for the query manager should be
a matter of following this sequence.
Unless a database is specified, *PSQL* will connect to one with the same name
as the specified user. If the user is not explicitly specified, the system user
name will be used. Running `psql -U postgres` will connect to *postgres* as the
user *postgres*. Note that you can't connect without a database, so you'd connect
to *postgres* in order to create new databases.
1 - Create and connect to a new database. Note that the `OWNER = postgres` clause
is redundant here but it's just to show that having the database owned by the
super user is intended.
```
psql -U postgres -c "CREATE DATABASE tibia OWNER = postgres;"
psql -U postgres tibia
```
2 - Set default privileges. Newly created databases may have some default PUBLIC
privileges that we'll want to revoke to make sure the set of users that are able
to connect is tighly controlled. Then, for users that are able to connect, we
want to grant default access privileges on tables, while revoking the ability to
create or rename objects (tables, views, sequences, indexes). Note that a schema
in PostgreSQL is just a namespace for objects and new databases should have the
*public* schema created by default.
```
REVOKE ALL ON DATABASE tibia FROM PUBLIC;
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE
ON TABLES TO PUBLIC;
```
3 - Initialize schema. This is done by executing commands from `postgres/schema.sql`,
and optionally `postgres/initial-data.sql`. Note that since we set default privileges
before creating any tables, they should already have the approppriate privileges.
If done the other way around, we'd need to manually update table privileges.
```
\i postgres/schema.sql
\i postgres/initial-data.sql
```
4 - Create secondary user. This is straighforward. Create a user with *LOGIN*
privileges and a *PASSWORD*. Then grant *CONNECT* privileges to the database.
```
CREATE ROLE tibia WITH LOGIN PASSWORD '********';
GRANT CONNECT ON DATABASE tibia TO tibia;
```
This is just one way. There are probably other, more optimal setups, but
for a small testing bench, it will do. And don't take my word on anything.
You should always check the manual for a complete description on how things
work.
To wrap, here is a list of helpful commands available in *PSQL*. They'll
show up along with a lot of other commands when running `\?`.
```
\q # quit
\l # list databases (will show database privileges)
\du # list users (will show user privileges)
\dO # list collations
\dt # list tables
\dv # list views
\ds # list sequences
\di # list indexes
\d NAME # describe table/view/sequence/index
\dp # list privileges
\ddp # list default privileges
```
MANUAL https://www.postgresql.org/docs/current/sql-commands.html

View File

@ -2,6 +2,8 @@
-- the database in some partial state. Also, notice we don't use `IF NOT EXISTS`
-- because it could also leave the database in a bad state if for example, some
-- old version of a table already existed.
--==============================================================================
BEGIN;
-- IMPORTANT(fusion): PosgreSQL doesn't have a defined `NOCASE` collation like
@ -78,9 +80,6 @@ CREATE TABLE Characters (
AccountID INTEGER NOT NULL,
Name TEXT NOT NULL COLLATE NOCASE,
Sex SMALLINT NOT NULL,
Guild TEXT NOT NULL COLLATE NOCASE DEFAULT '',
Rank TEXT NOT NULL COLLATE NOCASE DEFAULT '',
Title TEXT NOT NULL DEFAULT '',
Level SMALLINT NOT NULL DEFAULT 0,
Profession TEXT NOT NULL DEFAULT '',
Residence TEXT NOT NULL DEFAULT '',
@ -93,7 +92,6 @@ CREATE TABLE Characters (
);
CREATE INDEX CharactersWorldIndex ON Characters(WorldID, IsOnline);
CREATE INDEX CharactersAccountIndex ON Characters(AccountID, IsOnline);
CREATE INDEX CharactersGuildIndex ON Characters(Guild, Rank);
-- NOTE(fusion): It seems `RIGHT` is a reserved keyword and trying to use
-- it as a column name will generate an error.
@ -111,8 +109,9 @@ CREATE TABLE CharacterDeaths (
Unjustified BOOLEAN NOT NULL,
Timestamp TIMESTAMPTZ NOT NULL
);
CREATE INDEX CharacterDeathsCharacterIndex ON CharacterDeaths(CharacterID, Level);
CREATE INDEX CharacterDeathsOffenderIndex ON CharacterDeaths(OffenderID, Unjustified);
CREATE INDEX CharacterDeathsCharacterIndex ON CharacterDeaths(CharacterID, Timestamp);
CREATE INDEX CharacterDeathsOffenderIndex ON CharacterDeaths(OffenderID, Timestamp);
CREATE INDEX CharacterDeathsTimeIndex ON CharacterDeaths(Timestamp);
CREATE TABLE Buddies (
WorldID INTEGER NOT NULL,
@ -136,6 +135,46 @@ CREATE TABLE LoginAttempts (
CREATE INDEX LoginAttemptsAccountIndex ON LoginAttempts(AccountID, Timestamp);
CREATE INDEX LoginAttemptsAddressIndex ON LoginAttempts(IPAddress, Timestamp);
-- Guild Tables
--==============================================================================
CREATE TABLE Guilds (
WorldID INTEGER NOT NULL,
GuildID INTEGER NOT NULL GENERATED ALWAYS AS IDENTITY,
Name TEXT NOT NULL COLLATE NOCASE,
LeaderID INTEGER NOT NULL,
Created TIMESTAMPTZ NOT NULL,
PRIMARY KEY (GuildID),
UNIQUE (Name),
UNIQUE (LeaderID)
);
CREATE TABLE GuildRanks (
GuildID INTEGER NOT NULL,
Rank SMALLINT NOT NULL,
Name TEXT NOT NULL,
PRIMARY KEY (GuildID, Rank)
);
CREATE TABLE GuildMembers (
GuildID INTEGER NOT NULL,
CharacterID INTEGER NOT NULL,
Rank SMALLINT NOT NULL,
Title TEXT NOT NULL,
Joined TIMESTAMPTZ NOT NULL,
PRIMARY KEY (CharacterID)
);
CREATE INDEX GuildMembersGuildIndex ON GuildMembers(GuildID, Rank);
CREATE TABLE GuildInvites (
GuildID INTEGER NOT NULL,
CharacterID INTEGER NOT NULL,
RecruiterID INTEGER NOT NULL,
Timestamp TIMESTAMPTZ NOT NULL,
PRIMARY KEY (GuildID, CharacterID)
);
CREATE INDEX GuildInvitesCharacterIndex ON GuildInvites(CharacterID);
CREATE INDEX GuildInvitesRecruiterIndex ON GuildInvites(RecruiterID);
-- House Tables
--==============================================================================
CREATE TABLE Houses (

View File

@ -0,0 +1,54 @@
-- NOTE(fusion): This file contains the migration script from v0.2 to v0.3. It's
-- put inside a transaction to avoid errors from leaving the database in some
-- partial state.
-- These changes are already present in the latest `schema.sql`, so trying to
-- apply them on a newly created database will result in errors.
--==============================================================================
BEGIN;
DROP INDEX CharactersGuildIndex;
ALTER TABLE Characters DROP COLUMN Guild;
ALTER TABLE Characters DROP COLUMN Rank;
ALTER TABLE Characters DROP COLUMN Title;
CREATE TABLE Guilds (
WorldID INTEGER NOT NULL,
GuildID INTEGER NOT NULL GENERATED ALWAYS AS IDENTITY,
Name TEXT NOT NULL COLLATE NOCASE,
LeaderID INTEGER NOT NULL,
Created TIMESTAMPTZ NOT NULL,
PRIMARY KEY (GuildID),
UNIQUE (Name),
UNIQUE (LeaderID)
);
CREATE TABLE GuildRanks (
GuildID INTEGER NOT NULL,
Rank SMALLINT NOT NULL,
Name TEXT NOT NULL,
PRIMARY KEY (GuildID, Rank)
);
CREATE TABLE GuildMembers (
GuildID INTEGER NOT NULL,
CharacterID INTEGER NOT NULL,
Rank SMALLINT NOT NULL,
Title TEXT NOT NULL,
Joined TIMESTAMPTZ NOT NULL,
PRIMARY KEY (CharacterID)
);
CREATE INDEX GuildMembersGuildIndex ON GuildMembers(GuildID, Rank);
CREATE TABLE GuildInvites (
GuildID INTEGER NOT NULL,
CharacterID INTEGER NOT NULL,
RecruiterID INTEGER NOT NULL,
Timestamp TIMESTAMPTZ NOT NULL,
PRIMARY KEY (GuildID, CharacterID)
);
CREATE INDEX GuildInvitesCharacterIndex ON GuildInvites(CharacterID);
CREATE INDEX GuildInvitesRecruiterIndex ON GuildInvites(RecruiterID);
COMMIT;

View File

@ -0,0 +1,20 @@
-- NOTE(fusion): This file contains index adjustments for the `CharacterDeaths`
-- table. It is not strictly necessary as there are no modified tables but should
-- help with queries such as the most recent deaths/kills from a specific character
-- or overall.
-- It's inside a transaction to avoid errors from leaving the database in some
-- partial state. The changes are already present in the latest `schema.sql`, so
-- trying to apply them on a newly created database will result in errors.
--==============================================================================
BEGIN;
DROP INDEX CharacterDeathsCharacterIndex;
DROP INDEX CharacterDeathsOffenderIndex;
CREATE INDEX CharacterDeathsCharacterIndex ON CharacterDeaths(CharacterID, Timestamp);
CREATE INDEX CharacterDeathsOffenderIndex ON CharacterDeaths(OffenderID, Timestamp);
CREATE INDEX CharacterDeathsTimeIndex ON CharacterDeaths(Timestamp);
COMMIT;

View File

@ -1,5 +1,7 @@
-- NOTE(fusion): This file contains sample initial data. It's wrapped into a
-- NOTE(fusion): This file contains sample initial data. It's put inside a
-- transaction to avoid errors from leaving the database in some partial state.
--==============================================================================
BEGIN;
-- 111111/tibia

View File

@ -7,5 +7,6 @@ files will be executed exactly ONCE. If multiple patches are pending at startup,
they're executed in alphabetical order.
As for statement restrictions, the only thing prohibited is the presence of
transaction statements "BEGIN", "ROLLBACK", and "COMMIT". This is because all
patches will be bundled into the same transaction, to ensure atomicity.
patches will be bundled into the same transaction, to ensure atomicity, and
there can't be nested transactions.

2
sqlite/patches/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

View File

@ -57,9 +57,6 @@ CREATE TABLE Characters (
AccountID INTEGER NOT NULL,
Name TEXT NOT NULL COLLATE NOCASE,
Sex INTEGER NOT NULL,
Guild TEXT NOT NULL COLLATE NOCASE DEFAULT '',
Rank TEXT NOT NULL COLLATE NOCASE DEFAULT '',
Title TEXT NOT NULL DEFAULT '',
Level INTEGER NOT NULL DEFAULT 0,
Profession TEXT NOT NULL DEFAULT '',
Residence TEXT NOT NULL DEFAULT '',
@ -72,7 +69,6 @@ CREATE TABLE Characters (
);
CREATE INDEX CharactersWorldIndex ON Characters(WorldID, IsOnline);
CREATE INDEX CharactersAccountIndex ON Characters(AccountID, IsOnline);
CREATE INDEX CharactersGuildIndex ON Characters(Guild, Rank);
/*
-- TODO(fusion): Have group rights instead of adding individual rights to characters?
@ -98,8 +94,9 @@ CREATE TABLE CharacterDeaths (
Unjustified INTEGER NOT NULL,
Timestamp INTEGER NOT NULL
);
CREATE INDEX CharacterDeathsCharacterIndex ON CharacterDeaths(CharacterID, Level);
CREATE INDEX CharacterDeathsOffenderIndex ON CharacterDeaths(OffenderID, Unjustified);
CREATE INDEX CharacterDeathsCharacterIndex ON CharacterDeaths(CharacterID, Timestamp);
CREATE INDEX CharacterDeathsOffenderIndex ON CharacterDeaths(OffenderID, Timestamp);
CREATE INDEX CharacterDeathsTimeIndex ON CharacterDeaths(Timestamp);
CREATE TABLE Buddies (
WorldID INTEGER NOT NULL,
@ -123,6 +120,46 @@ CREATE TABLE LoginAttempts (
CREATE INDEX LoginAttemptsAccountIndex ON LoginAttempts(AccountID, Timestamp);
CREATE INDEX LoginAttemptsAddressIndex ON LoginAttempts(IPAddress, Timestamp);
-- Guild Tables
--==============================================================================
CREATE TABLE Guilds (
WorldID INTEGER NOT NULL,
GuildID INTEGER NOT NULL,
Name TEXT NOT NULL COLLATE NOCASE,
LeaderID INTEGER NOT NULL,
Created INTEGER NOT NULL,
PRIMARY KEY (GuildID),
UNIQUE (Name),
UNIQUE (LeaderID)
);
CREATE TABLE GuildRanks (
GuildID INTEGER NOT NULL,
Rank INTEGER NOT NULL,
Name TEXT NOT NULL,
PRIMARY KEY (GuildID, Rank)
);
CREATE TABLE GuildMembers (
GuildID INTEGER NOT NULL,
CharacterID INTEGER NOT NULL,
Rank INTEGER NOT NULL,
Title TEXT NOT NULL,
Joined INTEGER NOT NULL,
PRIMARY KEY (CharacterID)
);
CREATE INDEX GuildMembersGuildIndex ON GuildMembers(GuildID, Rank);
CREATE TABLE GuildInvites (
GuildID INTEGER NOT NULL,
CharacterID INTEGER NOT NULL,
RecruiterID INTEGER NOT NULL,
Timestamp INTEGER NOT NULL,
PRIMARY KEY (GuildID, CharacterID)
);
CREATE INDEX GuildInvitesCharacterIndex ON GuildInvites(CharacterID);
CREATE INDEX GuildInvitesRecruiterIndex ON GuildInvites(RecruiterID);
-- House Tables
--==============================================================================
CREATE TABLE Houses (

View File

@ -1,14 +1,30 @@
-- NOTE(fusion): This file contains the migration script from v0.1 to v0.2. It
-- can be placed into `patches/` to upgrade an existing database. Note that these
-- changes are already present in the latest `schema.sql`, so trying to patch a
-- newly created database will probably result in errors. See `sqlite/README.txt`
-- for more details.
-- must be manually executed as `sqlite3 -bail -echo tibia.db < migration.sql`
-- because the original schema didn't have a `Patches` table which is necessary
-- with the new automatic patching system. Future migration scripts can be placed
-- in `patches/` for automatic execution but not this one, unfortunately. For
-- more details see `sqlite/README.txt`.
-- These changes are already present in the latest `schema.sql`, so trying to
-- apply them to a newly created database will result in errors.
--==============================================================================
ALTER TABLE Worlds RENAME COLUMN OnlineRecord TO OnlinePeak;
ALTER TABLE Worlds RENAME COLUMN OnlineRecordTimestamp TO OnlinePeakTimestamp;
ALTER TABLE CharacterRights RENAME COLUMN Right TO Name;
BEGIN;
PRAGMA application_id = 0x54694442;
PRAGMA user_version = 1;
CREATE TABLE Patches (
FileName TEXT NOT NULL COLLATE NOCASE,
Timestamp INTEGER NOT NULL,
UNIQUE (FileName)
);
ALTER TABLE Worlds RENAME COLUMN OnlineRecord TO OnlinePeak;
ALTER TABLE Worlds RENAME COLUMN OnlineRecordTimestamp TO OnlinePeakTimestamp;
ALTER TABLE Worlds ADD COLUMN LastStartup INTEGER NOT NULL DEFAULT 0;
ALTER TABLE Worlds ADD COLUMN LastShutdown INTEGER NOT NULL DEFAULT 0;
ALTER TABLE CharacterRights RENAME COLUMN Right TO Name;
COMMIT;

View File

@ -0,0 +1,50 @@
-- NOTE(fusion): This file contains the migration script from v0.2 to v0.3. It
-- can be executed automatically as a patch if placed at `sqlite/patches`. For
-- more details see `sqlite/README.txt`.
-- These changes are already present in the latest `schema.sql`, so trying to
-- apply them to a newly created database will result in errors.
--==============================================================================
DROP INDEX CharactersGuildIndex;
ALTER TABLE Characters DROP COLUMN Guild;
ALTER TABLE Characters DROP COLUMN Rank;
ALTER TABLE Characters DROP COLUMN Title;
CREATE TABLE Guilds (
WorldID INTEGER NOT NULL,
GuildID INTEGER NOT NULL,
Name TEXT NOT NULL COLLATE NOCASE,
LeaderID INTEGER NOT NULL,
Created INTEGER NOT NULL,
PRIMARY KEY (GuildID),
UNIQUE (Name),
UNIQUE (LeaderID)
);
CREATE TABLE GuildRanks (
GuildID INTEGER NOT NULL,
Rank INTEGER NOT NULL,
Name TEXT NOT NULL,
PRIMARY KEY (GuildID, Rank)
);
CREATE TABLE GuildMembers (
GuildID INTEGER NOT NULL,
CharacterID INTEGER NOT NULL,
Rank INTEGER NOT NULL,
Title TEXT NOT NULL,
Joined INTEGER NOT NULL,
PRIMARY KEY (CharacterID)
);
CREATE INDEX GuildMembersGuildIndex ON GuildMembers(GuildID, Rank);
CREATE TABLE GuildInvites (
GuildID INTEGER NOT NULL,
CharacterID INTEGER NOT NULL,
RecruiterID INTEGER NOT NULL,
Timestamp INTEGER NOT NULL,
PRIMARY KEY (GuildID, CharacterID)
);
CREATE INDEX GuildInvitesCharacterIndex ON GuildInvites(CharacterID);
CREATE INDEX GuildInvitesRecruiterIndex ON GuildInvites(RecruiterID);

View File

@ -0,0 +1,17 @@
-- NOTE(fusion): This file contains index adjustments for the `CharacterDeaths`
-- table. It is not strictly necessary as there are no modified tables but should
-- help with queries such as the most recent deaths/kills from a specific character
-- or overall.
-- It can be executed automatically as a patch if placed at `sqlite/patches`. For
-- more details see `sqlite/README.txt`. The changes are already present in the
-- latest `schema.sql`, so trying to apply them on a newly created database will
-- result in errors.
--==============================================================================
DROP INDEX CharacterDeathsCharacterIndex;
DROP INDEX CharacterDeathsOffenderIndex;
CREATE INDEX CharacterDeathsCharacterIndex ON CharacterDeaths(CharacterID, Timestamp);
CREATE INDEX CharacterDeathsOffenderIndex ON CharacterDeaths(OffenderID, Timestamp);
CREATE INDEX CharacterDeathsTimeIndex ON CharacterDeaths(Timestamp);

View File

@ -1,6 +1,6 @@
-- NOTE(fusion): This file contains sample initial data and will be executed
-- automatically as a patch by the query manager. See `sqlite/README.txt` for
-- more details.
-- NOTE(fusion): This file contains sample initial data and can be executed
-- automatically as a patch by the query manager if placed at `sqlite/patches`.
-- See `sqlite/README.txt` for more details.
--==============================================================================
INSERT INTO Worlds (WorldID, Name, Type, RebootTime, Host, Port, MaxPlayers,

View File

@ -189,9 +189,9 @@ void CheckConnectionInput(TConnection *Connection, int Events){
int ReadSize = Connection->RWSize;
if(ReadSize == 0){
if(Connection->RWPosition < 2){
ReadSize = 2 - Connection->RWPosition;
ReadSize = 2;
}else{
ReadSize = 6 - Connection->RWPosition;
ReadSize = 6;
}
ASSERT(ReadSize > 0);
}
@ -392,7 +392,8 @@ void CheckConnectionQueryRequest(TConnection *Connection){
SendQueryFailed(Connection);
}
}else if(Connection->ApplicationType == APPLICATION_TYPE_LOGIN){
if(QueryType == QUERY_LOGIN_ACCOUNT){
// TODO(fusion): Probably have a custom query for querying world status?
if(QueryType == QUERY_LOGIN_ACCOUNT || QueryType == QUERY_GET_WORLDS){
ProcessQuery(Connection);
}else{
LOG_ERR("Invalid LOGIN query %d (%s) from %s",
@ -599,7 +600,7 @@ void ProcessConnections(void){
}
for(int i = 0; i < g_Config.MaxConnections; i += 1){
if(g_Connections[i].State == CONNECTION_FREE || g_Connections[i].Socket == -1){
if(g_Connections[i].State == CONNECTION_FREE){
continue;
}

10
src/database_mariadb.cc Normal file
View File

@ -0,0 +1,10 @@
#if DATABASE_MARIADB
#include "querymanager.hh"
// TODO(fusion): If we decide to implement MySQL, we should use MariaDB instead.
// It is a better alternative overall, and targeting a single database system
// should help us leverage its strongest features, which may not always be
// supported by both.
#error "MariaDB is not currently supported."
#endif //DATABASE_MARIADB

View File

@ -1,6 +0,0 @@
#if DATABASE_MYSQL
#include "querymanager.hh"
// TODO
#endif //DATABASE_MYSQL

View File

@ -285,10 +285,20 @@ static int ResultAffectedRows(PGresult *Result){
return AffectedRows;
}
// IMPORTANT(fusion): `GetResult*` helpers need to properly handle NULL values.
// In text format, they're represented as empty strings which could cause parsing
// errors. In binary format, they're represented as zero length blobs which could
// cause assertions to fire.
static bool GetResultBool(PGresult *Result, int Row, int Col){
bool Value = false;
if(PQgetisnull(Result, Row, Col)){
return Value;
}
int Format = PQfformat(Result, Col);
Oid Type = PQftype(Result, Col);
ASSERT(Format == 0 || Format == 1);
if(Format == 0){ // TEXT FORMAT
if(!ParseBoolean(&Value, PQgetvalue(Result, Row, Col))){
LOG_ERR("Failed to properly parse column (%d) %s as BOOLEAN",
@ -341,6 +351,10 @@ static bool GetResultBool(PGresult *Result, int Row, int Col){
static int GetResultInt(PGresult *Result, int Row, int Col){
int Value = 0;
if(PQgetisnull(Result, Row, Col)){
return Value;
}
int Format = PQfformat(Result, Col);
Oid Type = PQftype(Result, Col);
ASSERT(Format == 0 || Format == 1);
@ -402,6 +416,10 @@ static int GetResultInt(PGresult *Result, int Row, int Col){
static const char *GetResultText(PGresult *Result, int Row, int Col){
const char *Text = "";
if(PQgetisnull(Result, Row, Col)){
return Text;
}
int Format = PQfformat(Result, Col);
Oid Type = PQftype(Result, Col);
ASSERT(Format == 0 || Format == 1);
@ -433,6 +451,10 @@ static const char *GetResultText(PGresult *Result, int Row, int Col){
static int GetResultByteA(PGresult *Result, int Row, int Col, uint8 *Buffer, int BufferSize){
int Size = -1;
if(PQgetisnull(Result, Row, Col)){
return Size;
}
int Format = PQfformat(Result, Col);
ASSERT(Format == 0 || Format == 1);
if(Format == 0){ // TEXT FORMAT
@ -459,6 +481,10 @@ static int GetResultByteA(PGresult *Result, int Row, int Col, uint8 *Buffer, int
// off to avoid compiler warnings.
static int GetResultIPAddress(PGresult *Result, int Row, int Col){
int IPAddress = 0;
if(PQgetisnull(Result, Row, Col)){
return IPAddress;
}
int Format = PQfformat(Result, Col);
Oid Type = PQftype(Result, Col);
ASSERT(Format == 0 || Format == 1);
@ -573,6 +599,10 @@ static bool ParseTimestamp(int *Dest, const char *String){
static int GetResultTimestamp(PGresult *Result, int Row, int Col){
int Timestamp = 0;
if(PQgetisnull(Result, Row, Col)){
return Timestamp;
}
int Format = PQfformat(Result, Col);
Oid Type = PQftype(Result, Col);
ASSERT(Format == 0 || Format == 1);
@ -842,6 +872,10 @@ static bool ParseInterval(int *Dest, const char *String){
static int GetResultInterval(PGresult *Result, int Row, int Col){
int Interval = 0;
if(PQgetisnull(Result, Row, Col)){
return Interval;
}
int Format = PQfformat(Result, Col);
Oid Type = PQftype(Result, Col);
ASSERT(Format == 0 || Format == 1);
@ -1237,37 +1271,6 @@ TDatabase *DatabaseOpen(void){
return NULL;
}
#if 0
{
// TODO(fusion): REMOVE. This is for testing query input/output, to make sure
// they're consitent across different formats (text/binary).
const char *Stmt = PrepareQuery(Database,
"SELECT $1::INTERVAL, $2::INTERVAL");
ASSERT(Stmt != NULL);
for(int i = 0; i <= 1; i += 1)
for(int j = 0; j <= 1; j += 1){
LOG("TEST (%d, %d)", i, j);
ParamBuffer Params;
ParamBegin(&Params, 2, i);
ParamInterval(&Params, 86400 + 3600);
ParamInterval(&Params, - 86400 * 4 + 7 * 3600);
PGresult *Result = PQexecPrepared(Database->Handle, Stmt, Params.NumParams,
Params.Values, Params.Lengths, Params.Formats, j);
AutoResultClear ResultGuard(Result);
if(PQresultStatus(Result) == PGRES_TUPLES_OK){
LOG("0: %d", GetResultInterval(Result, 0, 0));
LOG("1: %d", GetResultInterval(Result, 0, 1));
}else{
LOG_ERR("Failed to execute query: %s", PQerrorMessage(Database->Handle));
}
}
DatabaseClose(Database);
return NULL;
}
#endif
return Database;
}
@ -1768,8 +1771,7 @@ bool GetCharacterID(TDatabase *Database, int WorldID, const char *CharacterName,
bool GetCharacterLoginData(TDatabase *Database, const char *CharacterName, TCharacterLoginData *Character){
ASSERT(Database != NULL && CharacterName != NULL && Character != NULL);
const char *Stmt = PrepareQuery(Database,
"SELECT WorldID, CharacterID, AccountID, Name,"
" Sex, Guild, Rank, Title, Deleted"
"SELECT WorldID, CharacterID, AccountID, Name, Sex, Deleted"
" FROM Characters WHERE Name = $1::TEXT");
if(Stmt == NULL){
LOG_ERR("Failed to prepare query");
@ -1794,10 +1796,7 @@ bool GetCharacterLoginData(TDatabase *Database, const char *CharacterName, TChar
Character->AccountID = GetResultInt(Result, 0, 2);
StringBufCopy(Character->Name, GetResultText(Result, 0, 3));
Character->Sex = GetResultInt(Result, 0, 4);
StringBufCopy(Character->Guild, GetResultText(Result, 0, 5));
StringBufCopy(Character->Rank, GetResultText(Result, 0, 6));
StringBufCopy(Character->Title, GetResultText(Result, 0, 7));
Character->Deleted = GetResultBool(Result, 0, 8);
Character->Deleted = GetResultBool(Result, 0, 5);
}
return true;
@ -1806,7 +1805,7 @@ bool GetCharacterLoginData(TDatabase *Database, const char *CharacterName, TChar
bool GetCharacterProfile(TDatabase *Database, const char *CharacterName, TCharacterProfile *Character){
ASSERT(Database != NULL && CharacterName != NULL && Character != NULL);
const char *Stmt = PrepareQuery(Database,
"SELECT C.Name, W.Name, C.Sex, C.Guild, C.Rank, C.Title, C.Level,"
"SELECT C.CharacterID, C.Name, W.Name, C.Sex, C.Level,"
" C.Profession, C.Residence, C.LastLoginTime, C.IsOnline,"
" C.Deleted, GREATEST(A.PremiumEnd - CURRENT_TIMESTAMP, '0')"
" FROM Characters AS C"
@ -1814,7 +1813,7 @@ bool GetCharacterProfile(TDatabase *Database, const char *CharacterName, TCharac
" LEFT JOIN Accounts AS A ON A.AccountID = C.AccountID"
" LEFT JOIN CharacterRights AS R"
" ON R.CharacterID = C.CharacterID"
" AND R.Name = 'NO_STATISTICS'"
" AND R.Name = 'NO_STATISTICS'"
" WHERE C.Name = $1::TEXT AND R.Name IS NULL");
if(Stmt == NULL){
LOG_ERR("Failed to prepare query");
@ -1834,19 +1833,17 @@ bool GetCharacterProfile(TDatabase *Database, const char *CharacterName, TCharac
memset(Character, 0, sizeof(TCharacterProfile));
if(PQntuples(Result) > 0){
StringBufCopy(Character->Name, GetResultText(Result, 0, 0));
StringBufCopy(Character->World, GetResultText(Result, 0, 1));
Character->Sex = GetResultInt(Result, 0, 2);
StringBufCopy(Character->Guild, GetResultText(Result, 0, 3));
StringBufCopy(Character->Rank, GetResultText(Result, 0, 4));
StringBufCopy(Character->Title, GetResultText(Result, 0, 5));
Character->Level = GetResultInt(Result, 0, 6);
StringBufCopy(Character->Profession, GetResultText(Result, 0, 7));
StringBufCopy(Character->Residence, GetResultText(Result, 0, 8));
Character->LastLogin = GetResultTimestamp(Result, 0, 9);
Character->Online = GetResultBool(Result, 0, 10);
Character->Deleted = GetResultBool(Result, 0, 11);
Character->PremiumDays = RoundSecondsToDays(GetResultInterval(Result, 0, 12));
Character->CharacterID = GetResultInt(Result, 0, 0);
StringBufCopy(Character->Name, GetResultText(Result, 0, 1));
StringBufCopy(Character->World, GetResultText(Result, 0, 2));
Character->Sex = GetResultInt(Result, 0, 3);
Character->Level = GetResultInt(Result, 0, 4);
StringBufCopy(Character->Profession, GetResultText(Result, 0, 5));
StringBufCopy(Character->Residence, GetResultText(Result, 0, 6));
Character->LastLogin = GetResultTimestamp(Result, 0, 7);
Character->Online = GetResultBool(Result, 0, 8);
Character->Deleted = GetResultBool(Result, 0, 9);
Character->PremiumDays = RoundSecondsToDays(GetResultInterval(Result, 0, 10));
}
return true;
@ -1912,8 +1909,8 @@ bool GetGuildLeaderStatus(TDatabase *Database, int WorldID, int CharacterID, boo
ASSERT(Database != NULL && GuildLeader != NULL);
// NOTE(fusion): Same as `DecrementIsOnline`.
const char *Stmt = PrepareQuery(Database,
"SELECT Guild, Rank FROM Characters"
" WHERE WorldID = $1::INTEGER AND CharacterID = $2::INTEGER");
"SELECT 1 FROM Guilds"
" WHERE WorldID = $1::INTEGER AND LeaderID = $2::INTEGER");
if(Stmt == NULL){
LOG_ERR("Failed to prepare query");
return false;
@ -1931,16 +1928,8 @@ bool GetGuildLeaderStatus(TDatabase *Database, int WorldID, int CharacterID, boo
return false;
}
*GuildLeader = false;
if(PQntuples(Result) > 0){
const char *Guild = GetResultText(Result, 0, 0);
const char *Rank = GetResultText(Result, 0, 1);
if(Guild != NULL && !StringEmpty(Guild) && Rank != NULL && StringEqCI(Rank, "Leader")){
*GuildLeader = true;
}
}
return false;
*GuildLeader = (PQntuples(Result) > 0);
return true;
}
bool IncrementIsOnline(TDatabase *Database, int WorldID, int CharacterID){
@ -2338,6 +2327,46 @@ bool GetIPAddressFailedLoginAttempts(TDatabase *Database, int IPAddress, int Tim
return true;
}
// Guild Tables
//==============================================================================
bool GetCharacterGuildData(TDatabase *Database, int CharacterID, TCharacterGuildData *GuildData){
ASSERT(Database != NULL && GuildData != NULL);
const char *Stmt = PrepareQuery(Database,
"SELECT G.GuildID, R.Rank, G.Name, R.Name, M.Title"
" FROM Characters AS C"
" LEFT JOIN GuildMembers AS M ON M.CharacterID = C.CharacterID"
" LEFT JOIN Guilds AS G ON G.GuildID = M.GuildID"
" LEFT JOIN GuildRanks AS R"
" ON R.GuildID = M.GuildID AND R.Rank = M.Rank"
" WHERE C.CharacterID = $1::INTEGER");
if(Stmt == NULL){
LOG_ERR("Failed to prepare query");
return false;
}
ParamBuffer Params = {};
ParamBegin(&Params, 1, 1);
ParamInt(&Params, CharacterID);
PGresult *Result = PQexecPrepared(Database->Handle, Stmt, Params.NumParams,
Params.Values, Params.Lengths, Params.Formats, 1);
AutoResultClear ResultGuard(Result);
if(PQresultStatus(Result) != PGRES_TUPLES_OK){
LOG_ERR("Failed to execute query: %s", PQerrorMessage(Database->Handle));
return false;
}
memset(GuildData, 0, sizeof(TCharacterGuildData));
if(PQntuples(Result) > 0){
GuildData->GuildID = GetResultInt(Result, 0, 0);
GuildData->Rank = GetResultInt(Result, 0, 1);
StringBufCopy(GuildData->GuildName, GetResultText(Result, 0, 2));
StringBufCopy(GuildData->RankName, GetResultText(Result, 0, 3));
StringBufCopy(GuildData->Title, GetResultText(Result, 0, 4));
}
return true;
}
// House Tables
//==============================================================================
bool FinishHouseAuctions(TDatabase *Database, int WorldID, DynamicArray<THouseAuction> *Auctions){
@ -2412,7 +2441,7 @@ bool FinishHouseTransfers(TDatabase *Database, int WorldID, DynamicArray<THouseT
Transfer.HouseID = GetResultInt(Result, Row, 0);
Transfer.NewOwnerID = GetResultInt(Result, Row, 1);
Transfer.Price = GetResultInt(Result, Row, 2);
StringBufCopy(Transfer.NewOwnerName, GetResultText(Result, Row, 4));
StringBufCopy(Transfer.NewOwnerName, GetResultText(Result, Row, 3));
Transfers->Push(Transfer);
}

View File

@ -412,9 +412,8 @@ static bool CheckDatabaseSchema(TDatabase *Database){
return false;
}
// IMPORTANT(fusion): After checking the application id, we want to apply
// patches before checking user version, just in case some migration needs
// to take place.
// IMPORTANT(fusion): We want to apply patches before checking user version,
// just in case some migration needs to take place.
if(!ApplyDatabasePatches(Database, "sqlite/patches")){
LOG_ERR("Failed to apply database patches");
return false;
@ -1023,8 +1022,7 @@ bool GetCharacterID(TDatabase *Database, int WorldID, const char *CharacterName,
bool GetCharacterLoginData(TDatabase *Database, const char *CharacterName, TCharacterLoginData *Character){
ASSERT(Database != NULL && CharacterName != NULL && Character != NULL);
sqlite3_stmt *Stmt = PrepareQuery(Database,
"SELECT WorldID, CharacterID, AccountID, Name,"
" Sex, Guild, Rank, Title, Deleted"
"SELECT WorldID, CharacterID, AccountID, Name, Sex, Deleted"
" FROM Characters WHERE Name = ?1");
if(Stmt == NULL){
LOG_ERR("Failed to prepare query");
@ -1050,10 +1048,7 @@ bool GetCharacterLoginData(TDatabase *Database, const char *CharacterName, TChar
Character->AccountID = sqlite3_column_int(Stmt, 2);
StringBufCopy(Character->Name, (const char*)sqlite3_column_text(Stmt, 3));
Character->Sex = sqlite3_column_int(Stmt, 4);
StringBufCopy(Character->Guild, (const char*)sqlite3_column_text(Stmt, 5));
StringBufCopy(Character->Rank, (const char*)sqlite3_column_text(Stmt, 6));
StringBufCopy(Character->Title, (const char*)sqlite3_column_text(Stmt, 7));
Character->Deleted = (sqlite3_column_int(Stmt, 8) != 0);
Character->Deleted = (sqlite3_column_int(Stmt, 5) != 0);
}
return true;
@ -1062,7 +1057,7 @@ bool GetCharacterLoginData(TDatabase *Database, const char *CharacterName, TChar
bool GetCharacterProfile(TDatabase *Database, const char *CharacterName, TCharacterProfile *Character){
ASSERT(Database != NULL && CharacterName != NULL && Character != NULL);
sqlite3_stmt *Stmt = PrepareQuery(Database,
"SELECT C.Name, W.Name, C.Sex, C.Guild, C.Rank, C.Title, C.Level,"
"SELECT C.CharacterID, C.Name, W.Name, C.Sex, C.Level,"
" C.Profession, C.Residence, C.LastLoginTime, C.IsOnline,"
" C.Deleted, MAX(A.PremiumEnd - UNIXEPOCH(), 0)"
" FROM Characters AS C"
@ -1070,7 +1065,7 @@ bool GetCharacterProfile(TDatabase *Database, const char *CharacterName, TCharac
" LEFT JOIN Accounts AS A ON A.AccountID = C.AccountID"
" LEFT JOIN CharacterRights AS R"
" ON R.CharacterID = C.CharacterID"
" AND R.Name = 'NO_STATISTICS'"
" AND R.Name = 'NO_STATISTICS'"
" WHERE C.Name = ?1 AND R.Name IS NULL");
if(Stmt == NULL){
LOG_ERR("Failed to prepare query");
@ -1091,19 +1086,17 @@ bool GetCharacterProfile(TDatabase *Database, const char *CharacterName, TCharac
memset(Character, 0, sizeof(TCharacterProfile));
if(ErrorCode == SQLITE_ROW){
StringBufCopy(Character->Name, (const char*)sqlite3_column_text(Stmt, 0));
StringBufCopy(Character->World, (const char*)sqlite3_column_text(Stmt, 1));
Character->Sex = sqlite3_column_int(Stmt, 2);
StringBufCopy(Character->Guild, (const char*)sqlite3_column_text(Stmt, 3));
StringBufCopy(Character->Rank, (const char*)sqlite3_column_text(Stmt, 4));
StringBufCopy(Character->Title, (const char*)sqlite3_column_text(Stmt, 5));
Character->Level = sqlite3_column_int(Stmt, 6);
StringBufCopy(Character->Profession, (const char*)sqlite3_column_text(Stmt, 7));
StringBufCopy(Character->Residence, (const char*)sqlite3_column_text(Stmt, 8));
Character->LastLogin = sqlite3_column_int(Stmt, 9);
Character->Online = (sqlite3_column_int(Stmt, 10) != 0);
Character->Deleted = (sqlite3_column_int(Stmt, 11) != 0);
Character->PremiumDays = RoundSecondsToDays(sqlite3_column_int(Stmt, 12));
Character->CharacterID = sqlite3_column_int(Stmt, 0);
StringBufCopy(Character->Name, (const char*)sqlite3_column_text(Stmt, 1));
StringBufCopy(Character->World, (const char*)sqlite3_column_text(Stmt, 2));
Character->Sex = sqlite3_column_int(Stmt, 3);
Character->Level = sqlite3_column_int(Stmt, 4);
StringBufCopy(Character->Profession, (const char*)sqlite3_column_text(Stmt, 5));
StringBufCopy(Character->Residence, (const char*)sqlite3_column_text(Stmt, 6));
Character->LastLogin = sqlite3_column_int(Stmt, 7);
Character->Online = (sqlite3_column_int(Stmt, 8) != 0);
Character->Deleted = (sqlite3_column_int(Stmt, 9) != 0);
Character->PremiumDays = RoundSecondsToDays(sqlite3_column_int(Stmt, 10));
}
return true;
@ -1169,8 +1162,8 @@ bool GetGuildLeaderStatus(TDatabase *Database, int WorldID, int CharacterID, boo
ASSERT(Database != NULL && GuildLeader != NULL);
// NOTE(fusion): Same as `DecrementIsOnline`.
sqlite3_stmt *Stmt = PrepareQuery(Database,
"SELECT Guild, Rank FROM Characters"
" WHERE WorldID = ?1 AND CharacterID = ?2");
"SELECT 1 FROM Guilds"
" WHERE WorldID = ?1 AND LeaderID = ?2");
if(Stmt == NULL){
LOG_ERR("Failed to prepare query");
return false;
@ -1189,15 +1182,7 @@ bool GetGuildLeaderStatus(TDatabase *Database, int WorldID, int CharacterID, boo
return false;
}
*GuildLeader = false;
if(ErrorCode == SQLITE_ROW){
const char *Guild = (const char*)sqlite3_column_text(Stmt, 0);
const char *Rank = (const char*)sqlite3_column_text(Stmt, 1);
if(Guild != NULL && !StringEmpty(Guild) && Rank != NULL && StringEqCI(Rank, "Leader")){
*GuildLeader = true;
}
}
*GuildLeader = (ErrorCode == SQLITE_ROW);
return true;
}
@ -1593,6 +1578,47 @@ bool GetIPAddressFailedLoginAttempts(TDatabase *Database, int IPAddress, int Tim
return true;
}
// Guild Tables
//==============================================================================
bool GetCharacterGuildData(TDatabase *Database, int CharacterID, TCharacterGuildData *GuildData){
ASSERT(Database != NULL && GuildData != NULL);
sqlite3_stmt *Stmt = PrepareQuery(Database,
"SELECT G.GuildID, R.Rank, G.Name, R.Name, M.Title"
" FROM Characters AS C"
" LEFT JOIN GuildMembers AS M ON M.CharacterID = C.CharacterID"
" LEFT JOIN Guilds AS G ON G.GuildID = M.GuildID"
" LEFT JOIN GuildRanks AS R"
" ON R.GuildID = M.GuildID AND R.Rank = M.Rank"
" WHERE C.CharacterID = ?1");
if(Stmt == NULL){
LOG_ERR("Failed to prepare query");
return false;
}
AutoStmtReset StmtReset(Stmt);
if(sqlite3_bind_int(Stmt, 1, CharacterID) != SQLITE_OK){
LOG_ERR("Failed to bind CharacterID: %s", sqlite3_errmsg(Database->Handle));
return false;
}
int ErrorCode = sqlite3_step(Stmt);
if(ErrorCode != SQLITE_ROW && ErrorCode != SQLITE_DONE){
LOG_ERR("Failed to execute query: %s", sqlite3_errmsg(Database->Handle));
return false;
}
memset(GuildData, 0, sizeof(TCharacterGuildData));
if(ErrorCode == SQLITE_ROW){
GuildData->GuildID = sqlite3_column_int(Stmt, 0);
GuildData->Rank = sqlite3_column_int(Stmt, 1);
StringBufCopy(GuildData->GuildName, (const char*)sqlite3_column_text(Stmt, 2));
StringBufCopy(GuildData->RankName, (const char*)sqlite3_column_text(Stmt, 3));
StringBufCopy(GuildData->Title, (const char*)sqlite3_column_text(Stmt, 4));
}
return true;
}
// House Tables
//==============================================================================
bool FinishHouseAuctions(TDatabase *Database, int WorldID, DynamicArray<THouseAuction> *Auctions){
@ -1660,7 +1686,7 @@ bool FinishHouseTransfers(TDatabase *Database, int WorldID, DynamicArray<THouseT
Transfer.HouseID = sqlite3_column_int(Stmt, 0);
Transfer.NewOwnerID = sqlite3_column_int(Stmt, 1);
Transfer.Price = sqlite3_column_int(Stmt, 2);
StringBufCopy(Transfer.NewOwnerName, (const char*)sqlite3_column_text(Stmt, 4));
StringBufCopy(Transfer.NewOwnerName, (const char*)sqlite3_column_text(Stmt, 3));
Transfers->Push(Transfer);
}

View File

@ -515,19 +515,35 @@ void ProcessInternalResolveWorld(TDatabase *Database, TQuery *Query){
static void CheckAccountPasswordTx(TDatabase *Database, TQuery *Query,
int AccountID, const char *Password, int IPAddress){
enum{
E_ACCOUNT_NOT_FOUND = 1,
E_PASSWORD_MISMATCH = 2,
E_ACCOUNT_DISABLED = 3,
E_IPADDRESS_BLOCKED = 4,
};
// IMPORTANT(fusion): Check whether credentials are empty, in which case
// we can skip accessing the database altogether, but also prevent errors
// such as "ip-address blocked" or "account disabled" for account id ZERO.
QUERY_ERROR_IF(AccountID == 0, E_ACCOUNT_NOT_FOUND);
QUERY_ERROR_IF(StringEmpty(Password), E_PASSWORD_MISMATCH);
TransactionScope Tx("CheckAccountPassword");
QUERY_STOP_IF(!Tx.Begin(Database));
// IMPORTANT(fusion): Disallow blocked IP addresses and accounts before
// checking credentials to prevent error messages from being used as an
// oracle for brute force attacks.
int FailedLoginAttempts;
QUERY_STOP_IF(!GetIPAddressFailedLoginAttempts(Database, IPAddress, 30 * 60, &FailedLoginAttempts));
QUERY_ERROR_IF(FailedLoginAttempts >= 20, E_IPADDRESS_BLOCKED);
QUERY_STOP_IF(!GetAccountFailedLoginAttempts(Database, AccountID, 5 * 60, &FailedLoginAttempts));
QUERY_ERROR_IF(FailedLoginAttempts >= 10, E_ACCOUNT_DISABLED);
TAccount Account;
QUERY_STOP_IF(!GetAccountData(Database, AccountID, &Account));
QUERY_ERROR_IF(Account.AccountID == 0, 1);
QUERY_ERROR_IF(!TestPassword(Account.Auth, sizeof(Account.Auth), Password), 2);
int FailedLoginAttempts;
QUERY_STOP_IF(!GetAccountFailedLoginAttempts(Database, Account.AccountID, 5 * 60, &FailedLoginAttempts));
QUERY_ERROR_IF(FailedLoginAttempts > 10, 3);
QUERY_STOP_IF(!GetIPAddressFailedLoginAttempts(Database, IPAddress, 30 * 60, &FailedLoginAttempts));
QUERY_ERROR_IF(FailedLoginAttempts > 20, 4);
QUERY_ERROR_IF(Account.AccountID == 0, E_ACCOUNT_NOT_FOUND);
QUERY_ERROR_IF(!TestPassword(Account.Auth, sizeof(Account.Auth), Password), E_PASSWORD_MISMATCH);
QUERY_STOP_IF(!Tx.Commit());
QueryOk(Query);
@ -555,25 +571,43 @@ void ProcessCheckAccountPassword(TDatabase *Database, TQuery *Query){
static void LoginAccountTx(TDatabase *Database, TQuery *Query,
int AccountID, const char *Password, int IPAddress){
enum{
E_ACCOUNT_NOT_FOUND = 1,
E_PASSWORD_MISMATCH = 2,
E_ACCOUNT_DISABLED = 3,
E_IPADDRESS_BLOCKED = 4,
E_ACCOUNT_BANISHED = 5,
E_IPADDRESS_BANISHED = 6,
};
// IMPORTANT(fusion): Check whether credentials are empty, in which case
// we can skip accessing the database altogether, but also prevent errors
// such as "ip-address blocked" or "account disabled" for account id ZERO.
QUERY_ERROR_IF(AccountID == 0, E_ACCOUNT_NOT_FOUND);
QUERY_ERROR_IF(StringEmpty(Password), E_PASSWORD_MISMATCH);
TransactionScope Tx("LoginAccount");
QUERY_STOP_IF(!Tx.Begin(Database));
// IMPORTANT(fusion): Disallow blocked IP addresses and accounts before
// checking credentials to prevent error messages from being used as an
// oracle for brute force attacks.
int FailedLoginAttempts;
QUERY_STOP_IF(!GetIPAddressFailedLoginAttempts(Database, IPAddress, 30 * 60, &FailedLoginAttempts));
QUERY_ERROR_IF(FailedLoginAttempts >= 20, E_IPADDRESS_BLOCKED);
QUERY_STOP_IF(!GetAccountFailedLoginAttempts(Database, AccountID, 5 * 60, &FailedLoginAttempts));
QUERY_ERROR_IF(FailedLoginAttempts >= 10, E_ACCOUNT_DISABLED);
TAccount Account;
QUERY_STOP_IF(!GetAccountData(Database, AccountID, &Account));
QUERY_ERROR_IF(Account.AccountID == 0, 1);
QUERY_ERROR_IF(!TestPassword(Account.Auth, sizeof(Account.Auth), Password), 2);
int FailedLoginAttempts;
QUERY_STOP_IF(!GetAccountFailedLoginAttempts(Database, Account.AccountID, 5 * 60, &FailedLoginAttempts));
QUERY_ERROR_IF(FailedLoginAttempts > 10, 3);
QUERY_STOP_IF(!GetIPAddressFailedLoginAttempts(Database, IPAddress, 30 * 60, &FailedLoginAttempts));
QUERY_ERROR_IF(FailedLoginAttempts > 20, 4);
QUERY_ERROR_IF(Account.AccountID == 0, E_ACCOUNT_NOT_FOUND);
QUERY_ERROR_IF(!TestPassword(Account.Auth, sizeof(Account.Auth), Password), E_PASSWORD_MISMATCH);
bool IsBanished;
QUERY_STOP_IF(!IsAccountBanished(Database, Account.AccountID, &IsBanished));
QUERY_ERROR_IF(IsBanished, 5);
QUERY_ERROR_IF(IsBanished, E_ACCOUNT_BANISHED);
QUERY_STOP_IF(!IsIPBanished(Database, IPAddress, &IsBanished));
QUERY_ERROR_IF(IsBanished, 6);
QUERY_ERROR_IF(IsBanished, E_IPADDRESS_BANISHED);
DynamicArray<TCharacterEndpoint> Characters;
QUERY_STOP_IF(!GetCharacterEndpoints(Database, Account.AccountID, &Characters));
@ -625,40 +659,66 @@ void ProcessLoginAccount(TDatabase *Database, TQuery *Query){
static void LoginGameTx(TDatabase *Database, TQuery *Query,
int AccountID, const char *CharacterName, const char *Password,
int IPAddress, bool PrivateWorld, bool GamemasterRequired){
enum{
E_CHARACTER_NOT_FOUND = 1,
E_CHARACTER_DELETED = 2,
E_CHARACTER_WORLD_MISMATCH = 3,
E_CHARACTER_NOT_INVITED = 4,
E_PASSWORD_MISMATCH = 6,
E_ACCOUNT_DISABLED = 7,
E_ACCOUNT_DELETED = 8,
E_IPADDRESS_BLOCKED = 9,
E_ACCOUNT_BANISHED = 10,
E_CHARACTER_BANISHED = 11,
E_IPADDRESS_BANISHED = 12,
E_ACCOUNT_BUSY = 13,
E_ACCOUNT_NOT_GAMEMASTER = 14,
E_ACCOUNT_INVALID = 15,
};
// IMPORTANT(fusion): Check whether credentials are empty, in which case
// we can skip accessing the database altogether, but also prevent errors
// such as "ip-address blocked" or "account disabled" for account id ZERO.
QUERY_ERROR_IF(AccountID == 0, E_ACCOUNT_INVALID);
QUERY_ERROR_IF(StringEmpty(CharacterName), E_CHARACTER_NOT_FOUND);
QUERY_ERROR_IF(StringEmpty(Password), E_PASSWORD_MISMATCH);
TransactionScope Tx("LoginGame");
QUERY_STOP_IF(!Tx.Begin(Database));
// IMPORTANT(fusion): Disallow blocked IP addresses and accounts before
// checking credentials to prevent error messages from being used as an
// oracle for brute force attacks.
int FailedLoginAttempts;
QUERY_STOP_IF(!GetIPAddressFailedLoginAttempts(Database, IPAddress, 30 * 60, &FailedLoginAttempts));
QUERY_ERROR_IF(FailedLoginAttempts >= 20, E_IPADDRESS_BLOCKED);
QUERY_STOP_IF(!GetAccountFailedLoginAttempts(Database, AccountID, 5 * 60, &FailedLoginAttempts));
QUERY_ERROR_IF(FailedLoginAttempts >= 10, E_ACCOUNT_DISABLED);
TCharacterLoginData Character;
QUERY_STOP_IF(!GetCharacterLoginData(Database, CharacterName, &Character));
QUERY_ERROR_IF(Character.CharacterID == 0, 1);
QUERY_ERROR_IF(Character.Deleted, 2);
QUERY_ERROR_IF(Character.WorldID != Query->WorldID, 3);
QUERY_ERROR_IF(Character.CharacterID == 0, E_CHARACTER_NOT_FOUND);
QUERY_ERROR_IF(Character.Deleted, E_CHARACTER_DELETED);
QUERY_ERROR_IF(Character.WorldID != Query->WorldID, E_CHARACTER_WORLD_MISMATCH);
if(PrivateWorld){
bool Invited;
QUERY_STOP_IF(!GetWorldInvitation(Database, Query->WorldID, Character.CharacterID, &Invited));
QUERY_ERROR_IF(!Invited, 4);
QUERY_ERROR_IF(!Invited, E_CHARACTER_NOT_INVITED);
}
TAccount Account;
QUERY_STOP_IF(!GetAccountData(Database, AccountID, &Account));
// NOTE(fusion): This is correct, there is no error code 5.
QUERY_ERROR_IF(Account.AccountID == 0 || Account.AccountID != Character.AccountID, 15);
QUERY_ERROR_IF(Account.Deleted, 8);
QUERY_ERROR_IF(!TestPassword(Account.Auth, sizeof(Account.Auth), Password), 6);
int FailedLoginAttempts;
QUERY_STOP_IF(!GetAccountFailedLoginAttempts(Database, Account.AccountID, 5 * 60, &FailedLoginAttempts));
QUERY_ERROR_IF(FailedLoginAttempts > 10, 7);
QUERY_STOP_IF(!GetIPAddressFailedLoginAttempts(Database, IPAddress, 30 * 60, &FailedLoginAttempts));
QUERY_ERROR_IF(FailedLoginAttempts > 20, 9);
QUERY_ERROR_IF(Account.AccountID == 0 || Account.AccountID != Character.AccountID, E_ACCOUNT_INVALID);
QUERY_ERROR_IF(Account.Deleted, E_ACCOUNT_DELETED);
QUERY_ERROR_IF(!TestPassword(Account.Auth, sizeof(Account.Auth), Password), E_PASSWORD_MISMATCH);
bool IsBanished;
QUERY_STOP_IF(!IsAccountBanished(Database, Account.AccountID, &IsBanished));
QUERY_ERROR_IF(IsBanished, 10);
QUERY_ERROR_IF(IsBanished, E_ACCOUNT_BANISHED);
QUERY_STOP_IF(!IsCharacterNamelocked(Database, Character.CharacterID, &IsBanished));
QUERY_ERROR_IF(IsBanished, 11);
QUERY_ERROR_IF(IsBanished, E_CHARACTER_BANISHED);
QUERY_STOP_IF(!IsIPBanished(Database, IPAddress, &IsBanished));
QUERY_ERROR_IF(IsBanished, 12);
QUERY_ERROR_IF(IsBanished, E_IPADDRESS_BANISHED);
// TODO(fusion): Probably merge these into a single operation?
bool MultiClient;
@ -669,16 +729,22 @@ static void LoginGameTx(TDatabase *Database, TQuery *Query,
if(OnlineCharacters > 0){
bool IsOnline;
QUERY_STOP_IF(!IsCharacterOnline(Database, Character.CharacterID, &IsOnline));
QUERY_ERROR_IF(!IsOnline, 13);
QUERY_ERROR_IF(!IsOnline, E_ACCOUNT_BUSY);
}
}
// TODO(fusion): This may have been related to account rights but at the moment
// we only have character specific rights. May also use `READ_GAMEMASTER_CHANNEL`
// rather than `GAMEMASTER_OUTFIT`.
if(GamemasterRequired){
bool GamemasterOutfit;
QUERY_STOP_IF(!GetCharacterRight(Database, Character.CharacterID, "GAMEMASTER_OUTFIT", &GamemasterOutfit));
QUERY_ERROR_IF(!GamemasterOutfit, 14);
QUERY_ERROR_IF(!GamemasterOutfit, E_ACCOUNT_NOT_GAMEMASTER);
}
TCharacterGuildData GuildData;
QUERY_STOP_IF(!GetCharacterGuildData(Database, Character.CharacterID, &GuildData));
DynamicArray<TAccountBuddy> Buddies;
QUERY_STOP_IF(!GetBuddies(Database, Query->WorldID, Account.AccountID, &Buddies));
@ -704,9 +770,9 @@ static void LoginGameTx(TDatabase *Database, TQuery *Query,
Response->Write32((uint32)Character.CharacterID);
Response->WriteString(Character.Name);
Response->Write8((uint8)Character.Sex);
Response->WriteString(Character.Guild);
Response->WriteString(Character.Rank);
Response->WriteString(Character.Title);
Response->WriteString(GuildData.GuildName);
Response->WriteString(GuildData.RankName);
Response->WriteString(GuildData.Title);
int NumBuddies = std::min<int>(Buddies.Length(), UINT8_MAX);
Response->Write8((uint8)NumBuddies);
@ -773,6 +839,13 @@ void ProcessLogoutGame(TDatabase *Database, TQuery *Query){
}
void ProcessSetNamelock(TDatabase *Database, TQuery *Query){
enum{
E_NOT_FOUND = 1,
E_NO_BANISHMENT = 2,
E_NAME_LOCKED = 3,
E_NAME_APPROVED = 4,
};
TReadBuffer Request = Query->Request;
char CharacterName[30];
@ -793,16 +866,15 @@ void ProcessSetNamelock(TDatabase *Database, TQuery *Query){
int CharacterID;
QUERY_STOP_IF(!GetCharacterID(Database, Query->WorldID, CharacterName, &CharacterID));
QUERY_ERROR_IF(CharacterID == 0, 1);
QUERY_ERROR_IF(CharacterID == 0, E_NOT_FOUND);
// TODO(fusion): Might be `NO_BANISHMENT`.
bool Namelock;
QUERY_STOP_IF(!GetCharacterRight(Database, CharacterID, "NAMELOCK", &Namelock));
QUERY_ERROR_IF(Namelock, 2);
bool NoBanishment;
QUERY_STOP_IF(!GetCharacterRight(Database, CharacterID, "NO_BANISHMENT", &NoBanishment));
QUERY_ERROR_IF(NoBanishment, E_NO_BANISHMENT);
TNamelockStatus Status;
QUERY_STOP_IF(!GetNamelockStatus(Database, CharacterID, &Status));
QUERY_ERROR_IF(Status.Namelocked, (Status.Approved ? 4 : 3));
QUERY_ERROR_IF(Status.Namelocked, (Status.Approved ? E_NAME_APPROVED : E_NAME_LOCKED));
QUERY_STOP_IF(!InsertNamelock(Database, CharacterID, IPAddress, GamemasterID, Reason, Comment));
QUERY_STOP_IF(!Tx.Commit());
@ -810,6 +882,12 @@ void ProcessSetNamelock(TDatabase *Database, TQuery *Query){
}
void ProcessBanishAccount(TDatabase *Database, TQuery *Query){
enum{
E_NOT_FOUND = 1,
E_NO_BANISHMENT = 2,
E_BANISHED = 3,
};
TReadBuffer Request = Query->Request;
char CharacterName[30];
@ -831,16 +909,15 @@ void ProcessBanishAccount(TDatabase *Database, TQuery *Query){
int CharacterID;
QUERY_STOP_IF(!GetCharacterID(Database, Query->WorldID, CharacterName, &CharacterID));
QUERY_ERROR_IF(CharacterID == 0, 1);
QUERY_ERROR_IF(CharacterID == 0, E_NOT_FOUND);
// TODO(fusion): Might be `NO_BANISHMENT`.
bool Banishment;
QUERY_STOP_IF(!GetCharacterRight(Database, CharacterID, "BANISHMENT", &Banishment));
QUERY_ERROR_IF(Banishment, 2);
bool NoBanishment;
QUERY_STOP_IF(!GetCharacterRight(Database, CharacterID, "NO_BANISHMENT", &NoBanishment));
QUERY_ERROR_IF(NoBanishment, E_NO_BANISHMENT);
TBanishmentStatus Status;
QUERY_STOP_IF(!GetBanishmentStatus(Database, CharacterID, &Status));
QUERY_ERROR_IF(Status.Banished, 3);
QUERY_ERROR_IF(Status.Banished, E_BANISHED);
int Days = 7;
int BanishmentID = 0;
@ -858,6 +935,11 @@ void ProcessBanishAccount(TDatabase *Database, TQuery *Query){
}
void ProcessSetNotation(TDatabase *Database, TQuery *Query){
enum{
E_NOT_FOUND = 1,
E_NO_BANISHMENT = 2,
};
TReadBuffer Request = Query->Request;
char CharacterName[30];
@ -878,12 +960,11 @@ void ProcessSetNotation(TDatabase *Database, TQuery *Query){
int CharacterID;
QUERY_STOP_IF(!GetCharacterID(Database, Query->WorldID, CharacterName, &CharacterID));
QUERY_ERROR_IF(CharacterID == 0, 1);
QUERY_ERROR_IF(CharacterID == 0, E_NOT_FOUND);
// TODO(fusion): Might be `NO_BANISHMENT`.
bool Notation;
QUERY_STOP_IF(!GetCharacterRight(Database, CharacterID, "NOTATION", &Notation));
QUERY_ERROR_IF(Notation, 2);
bool NoBanishment;
QUERY_STOP_IF(!GetCharacterRight(Database, CharacterID, "NO_BANISHMENT", &NoBanishment));
QUERY_ERROR_IF(NoBanishment, E_NO_BANISHMENT);
int Notations = 0;
int BanishmentID = 0;
@ -908,6 +989,11 @@ void ProcessSetNotation(TDatabase *Database, TQuery *Query){
}
void ProcessReportStatement(TDatabase *Database, TQuery *Query){
enum{
E_NOT_FOUND = 1,
E_REPORTED = 2,
};
TReadBuffer Request = Query->Request;
char CharacterName[30];
@ -962,7 +1048,7 @@ void ProcessReportStatement(TDatabase *Database, TQuery *Query){
int CharacterID;
QUERY_STOP_IF(!GetCharacterID(Database, Query->WorldID, CharacterName, &CharacterID));
QUERY_ERROR_IF(CharacterID == 0, 1);
QUERY_ERROR_IF(CharacterID == 0, E_NOT_FOUND);
if(ReportedStatement->CharacterID != CharacterID){
LOG_ERR("Reported statement character mismatch");
@ -972,7 +1058,7 @@ void ProcessReportStatement(TDatabase *Database, TQuery *Query){
bool IsReported;
QUERY_STOP_IF(!IsStatementReported(Database, Query->WorldID, ReportedStatement, &IsReported));
QUERY_ERROR_IF(IsReported, 2);
QUERY_ERROR_IF(IsReported, E_REPORTED);
QUERY_STOP_IF(!InsertStatements(Database, Query->WorldID, NumStatements, Statements));
QUERY_STOP_IF(!InsertReportedStatement(Database, Query->WorldID, ReportedStatement,
@ -982,6 +1068,11 @@ void ProcessReportStatement(TDatabase *Database, TQuery *Query){
}
void ProcessBanishIpAddress(TDatabase *Database, TQuery *Query){
enum{
E_NOT_FOUND = 1,
E_NO_BANISHMENT = 2,
};
TReadBuffer Request = Query->Request;
char CharacterName[30];
@ -1002,12 +1093,11 @@ void ProcessBanishIpAddress(TDatabase *Database, TQuery *Query){
int CharacterID;
QUERY_STOP_IF(!GetCharacterID(Database, Query->WorldID, CharacterName, &CharacterID));
QUERY_ERROR_IF(CharacterID == 0, 1);
QUERY_ERROR_IF(CharacterID == 0, E_NOT_FOUND);
// TODO(fusion): Might be `NO_BANISHMENT`.
bool IPBanishment;
QUERY_STOP_IF(!GetCharacterRight(Database, CharacterID, "IP_BANISHMENT", &IPBanishment));
QUERY_ERROR_IF(IPBanishment, 2);
bool NoBanishment;
QUERY_STOP_IF(!GetCharacterRight(Database, CharacterID, "NO_BANISHMENT", &NoBanishment));
QUERY_ERROR_IF(NoBanishment, E_NO_BANISHMENT);
// IMPORTANT(fusion): It is not a good idea to ban IP addresses, specially V4,
// as they may be dynamically assigned or represent the address of a public ISP
@ -1131,7 +1221,7 @@ void ProcessEvictExGuildleaders(TDatabase *Database, TQuery *Query){
bool IsGuildLeader;
QUERY_STOP_IF(!GetGuildLeaderStatus(Database, Query->WorldID, OwnerID, &IsGuildLeader));
if(IsGuildLeader){
if(!IsGuildLeader){
Evictions.Push(HouseID);
}
}
@ -1371,6 +1461,11 @@ void ProcessLoadWorldConfig(TDatabase *Database, TQuery *Query){
}
void ProcessCreateAccount(TDatabase *Database, TQuery *Query){
enum{
E_ACCOUNT_NUMBER_EXISTS = 1,
E_ACCOUNT_EMAIL_EXISTS = 2,
};
// TODO(fusion): We'd ideally want to automatically generate an account number
// and return it in case of success but that would also require a more robust
// website infrastructure with verification e-mails, etc...
@ -1394,9 +1489,9 @@ void ProcessCreateAccount(TDatabase *Database, TQuery *Query){
bool AccountExists;
QUERY_STOP_IF(!AccountNumberExists(Database, AccountID, &AccountExists));
QUERY_ERROR_IF(AccountExists, 1);
QUERY_ERROR_IF(AccountExists, E_ACCOUNT_NUMBER_EXISTS);
QUERY_STOP_IF(!AccountEmailExists(Database, Email, &AccountExists));
QUERY_ERROR_IF(AccountExists, 2);
QUERY_ERROR_IF(AccountExists, E_ACCOUNT_EMAIL_EXISTS);
QUERY_STOP_IF(!CreateAccount(Database, AccountID, Email, Auth, sizeof(Auth)));
QUERY_STOP_IF(!Tx.Commit());
@ -1404,6 +1499,12 @@ void ProcessCreateAccount(TDatabase *Database, TQuery *Query){
}
void ProcessCreateCharacter(TDatabase *Database, TQuery *Query){
enum{
E_WORLD_NOT_FOUND = 1,
E_ACCOUNT_NOT_FOUND = 2,
E_CHARACTER_NAME_EXISTS = 3,
};
char WorldName[30];
char CharacterName[30];
TReadBuffer Request = Query->Request;
@ -1423,15 +1524,15 @@ void ProcessCreateCharacter(TDatabase *Database, TQuery *Query){
int WorldID;
QUERY_STOP_IF(!GetWorldID(Database, WorldName, &WorldID));
QUERY_ERROR_IF(WorldID == 0, 1);
QUERY_ERROR_IF(WorldID == 0, E_WORLD_NOT_FOUND);
bool AccountExists;
QUERY_STOP_IF(!AccountNumberExists(Database, AccountID, &AccountExists));
QUERY_ERROR_IF(!AccountExists, 2);
QUERY_ERROR_IF(!AccountExists, E_ACCOUNT_NOT_FOUND);
bool CharacterExists;
QUERY_STOP_IF(!CharacterNameExists(Database, CharacterName, &CharacterExists));
QUERY_ERROR_IF(CharacterExists, 3);
QUERY_ERROR_IF(CharacterExists, E_CHARACTER_NAME_EXISTS);
QUERY_STOP_IF(!CreateCharacter(Database, WorldID, AccountID, CharacterName, Sex));
QUERY_STOP_IF(!Tx.Commit());
@ -1470,6 +1571,10 @@ void ProcessGetAccountSummary(TDatabase *Database, TQuery *Query){
}
void ProcessGetCharacterProfile(TDatabase *Database, TQuery *Query){
enum{
E_CHARACTER_NOT_FOUND = 1,
};
char CharacterName[30];
TReadBuffer Request = Query->Request;
Request.ReadString(CharacterName, sizeof(CharacterName));
@ -1478,15 +1583,18 @@ void ProcessGetCharacterProfile(TDatabase *Database, TQuery *Query){
TCharacterProfile Character;
QUERY_STOP_IF(!GetCharacterProfile(Database, CharacterName, &Character));
QUERY_ERROR_IF(!StringEqCI(Character.Name, CharacterName), 1);
QUERY_ERROR_IF(Character.CharacterID == 0, E_CHARACTER_NOT_FOUND);
TCharacterGuildData GuildData;
QUERY_STOP_IF(!GetCharacterGuildData(Database, Character.CharacterID, &GuildData));
TWriteBuffer *Response = QueryBeginResponse(Query, QUERY_STATUS_OK);
Response->WriteString(Character.Name);
Response->WriteString(Character.World);
Response->Write8((uint8)Character.Sex);
Response->WriteString(Character.Guild);
Response->WriteString(Character.Rank);
Response->WriteString(Character.Title);
Response->WriteString(GuildData.GuildName);
Response->WriteString(GuildData.RankName);
Response->WriteString(GuildData.Title);
Response->Write16((uint16)Character.Level);
Response->WriteString(Character.Profession);
Response->WriteString(Character.Residence);

View File

@ -240,12 +240,12 @@ bool StringFormat(char *Dest, int DestCapacity, const char *Format, ...){
}
bool StringFormatTime(char *Dest, int DestCapacity, const char *Format, int Timestamp){
struct tm tm = GetLocalTime((int)Timestamp);
struct tm tm = GetLocalTime((time_t)Timestamp);
int Result = (int)strftime(Dest, DestCapacity, Format, &tm);
// NOTE(fusion): `strftime` will should return ZERO if it's unable to fit
// the result in the supplied buffer, which is annoying because ZERO may
// not represent a failure if the result is an empty string.
// NOTE(fusion): `strftime` will return ZERO if it's unable to fit the result
// in the supplied buffer, which is annoying because ZERO may not represent a
// failure if the result is an empty string.
ASSERT(Result >= 0 && Result < DestCapacity);
if(Result == 0){
memset(Dest, 0, DestCapacity);
@ -704,20 +704,20 @@ bool ReadConfig(const char *FileName, TConfig *Config){
ParseStringBuf(Config->PostgreSQL.SSLRootCert, Val);
}else if(StringEqCI(Key, "PostgreSQL.MaxCachedStatements")){
ParseInteger(&Config->PostgreSQL.MaxCachedStatements, Val);
}else if(StringEqCI(Key, "MySQL.Host")){
ParseStringBuf(Config->MySQL.Host, Val);
}else if(StringEqCI(Key, "MySQL.Port")){
ParseStringBuf(Config->MySQL.Port, Val);
}else if(StringEqCI(Key, "MySQL.DBName")){
ParseStringBuf(Config->MySQL.DBName, Val);
}else if(StringEqCI(Key, "MySQL.User")){
ParseStringBuf(Config->MySQL.User, Val);
}else if(StringEqCI(Key, "MySQL.Password")){
ParseStringBuf(Config->MySQL.Password, Val);
}else if(StringEqCI(Key, "MySQL.UnixSocket")){
ParseStringBuf(Config->MySQL.UnixSocket, Val);
}else if(StringEqCI(Key, "MySQL.MaxCachedStatements")){
ParseInteger(&Config->MySQL.MaxCachedStatements, Val);
}else if(StringEqCI(Key, "MariaDB.Host")){
ParseStringBuf(Config->MariaDB.Host, Val);
}else if(StringEqCI(Key, "MariaDB.Port")){
ParseStringBuf(Config->MariaDB.Port, Val);
}else if(StringEqCI(Key, "MariaDB.DBName")){
ParseStringBuf(Config->MariaDB.DBName, Val);
}else if(StringEqCI(Key, "MariaDB.User")){
ParseStringBuf(Config->MariaDB.User, Val);
}else if(StringEqCI(Key, "MariaDB.Password")){
ParseStringBuf(Config->MariaDB.Password, Val);
}else if(StringEqCI(Key, "MariaDB.UnixSocket")){
ParseStringBuf(Config->MariaDB.UnixSocket, Val);
}else if(StringEqCI(Key, "MariaDB.MaxCachedStatements")){
ParseInteger(&Config->MariaDB.MaxCachedStatements, Val);
}else if(StringEqCI(Key, "QueryManagerPort")){
ParseInteger(&Config->QueryManagerPort, Val);
}else if(StringEqCI(Key, "QueryManagerPassword")){
@ -780,8 +780,8 @@ int main(int argc, const char **argv){
g_Config.SQLite.MaxCachedStatements = 100;
// PostgreSQL Config
StringBufCopy(g_Config.PostgreSQL.Host, "localhost");
StringBufCopy(g_Config.PostgreSQL.Port, "5432");
StringBufCopy(g_Config.PostgreSQL.Host, "");
StringBufCopy(g_Config.PostgreSQL.Port, "");
StringBufCopy(g_Config.PostgreSQL.DBName, "tibia");
StringBufCopy(g_Config.PostgreSQL.User, "tibia");
StringBufCopy(g_Config.PostgreSQL.Password, "");
@ -791,17 +791,17 @@ int main(int argc, const char **argv){
StringBufCopy(g_Config.PostgreSQL.SSLRootCert, "");
g_Config.PostgreSQL.MaxCachedStatements = 100;
// MySQL/MariaDB Config
StringBufCopy(g_Config.MySQL.Host, "localhost");
StringBufCopy(g_Config.MySQL.Port, "3306");
StringBufCopy(g_Config.MySQL.DBName, "tibia");
StringBufCopy(g_Config.MySQL.User, "tibia");
StringBufCopy(g_Config.MySQL.Password, "");
StringBufCopy(g_Config.MySQL.UnixSocket, "");
g_Config.MySQL.MaxCachedStatements = 100;
// MariaDB Config
StringBufCopy(g_Config.MariaDB.Host, "localhost");
StringBufCopy(g_Config.MariaDB.Port, "3306");
StringBufCopy(g_Config.MariaDB.DBName, "tibia");
StringBufCopy(g_Config.MariaDB.User, "tibia");
StringBufCopy(g_Config.MariaDB.Password, "");
StringBufCopy(g_Config.MariaDB.UnixSocket, "");
g_Config.MariaDB.MaxCachedStatements = 100;
// Connection Config
g_Config.QueryManagerPort = 7174;
g_Config.QueryManagerPort = 7173;
StringBufCopy(g_Config.QueryManagerPassword, "");
g_Config.QueryWorkerThreads = 1;
g_Config.QueryBufferSize = (int)MB(1);
@ -809,7 +809,7 @@ int main(int argc, const char **argv){
g_Config.MaxConnections = 25;
g_Config.MaxConnectionIdleTime = 60 * 5; // seconds
LOG("Tibia Query Manager v0.2 (%s)", DATABASE_SYSTEM_NAME);
LOG("Tibia Query Manager v0.3 (%s)", DATABASE_SYSTEM_NAME);
if(!ReadConfig("config.cfg", &g_Config)){
return EXIT_FAILURE;
}
@ -830,13 +830,13 @@ int main(int argc, const char **argv){
LOG("PostgreSQL sslmode: \"%s\"", g_Config.PostgreSQL.SSLMode);
LOG("PostgreSQL sslrootcert: \"%s\"", g_Config.PostgreSQL.SSLRootCert);
LOG("PostgreSQL max cached statements: %d", g_Config.PostgreSQL.MaxCachedStatements);
#elif DATABASE_MYSQL
LOG("MySQL host: \"%s\"", g_Config.MySQL.Host);
LOG("MySQL port: \"%s\"", g_Config.MySQL.Port);
LOG("MySQL dbname: \"%s\"", g_Config.MySQL.DBName);
LOG("MySQL user: \"%s\"", g_Config.MySQL.User);
LOG("MySQL unix socket: \"%s\"", g_Config.MySQL.UnixSocket);
LOG("MySQL max cached statements: %d", g_Config.MySQL.MaxCachedStatements);
#elif DATABASE_MARIADB
LOG("MariaDB host: \"%s\"", g_Config.MariaDB.Host);
LOG("MariaDB port: \"%s\"", g_Config.MariaDB.Port);
LOG("MariaDB dbname: \"%s\"", g_Config.MariaDB.DBName);
LOG("MariaDB user: \"%s\"", g_Config.MariaDB.User);
LOG("MariaDB unix socket: \"%s\"", g_Config.MariaDB.UnixSocket);
LOG("MariaDB max cached statements: %d", g_Config.MariaDB.MaxCachedStatements);
#endif
LOG("Query manager port: %d", g_Config.QueryManagerPort);
LOG("Query worker threads: %d", g_Config.QueryWorkerThreads);

View File

@ -76,9 +76,9 @@ typedef size_t usize;
TRAP(); \
}while(0)
#if (DATABASE_SQLITE + DATABASE_POSTGRESQL + DATABASE_MYSQL) == 0
#if (DATABASE_SQLITE + DATABASE_POSTGRESQL + DATABASE_MARIADB) == 0
# error "No database system defined."
#elif (DATABASE_SQLITE + DATABASE_POSTGRESQL + DATABASE_MYSQL) > 1
#elif (DATABASE_SQLITE + DATABASE_POSTGRESQL + DATABASE_MARIADB) > 1
# error "Multiple database systems defined."
#endif
@ -86,8 +86,8 @@ typedef size_t usize;
# define DATABASE_SYSTEM_NAME "SQLite"
#elif DATABASE_POSTGRESQL
# define DATABASE_SYSTEM_NAME "PostgreSQL"
#elif DATABASE_MYSQL
# define DATABASE_SYSTEM_NAME "MySQL"
#elif DATABASE_MARIADB
# define DATABASE_SYSTEM_NAME "MariaDB"
#endif
struct TConfig{
@ -117,7 +117,7 @@ struct TConfig{
int MaxCachedStatements;
} PostgreSQL;
// MySQL/MariaDB Config
// MariaDB Config
struct{
char Host[100];
char Port[30];
@ -126,7 +126,7 @@ struct TConfig{
char Password[30];
char UnixSocket[100];
int MaxCachedStatements;
} MySQL;
} MariaDB;
// Connection Config
int QueryManagerPort;
@ -884,19 +884,22 @@ struct TCharacterLoginData{
int AccountID;
char Name[30];
int Sex;
char Guild[30];
char Rank[30];
char Title[30];
bool Deleted;
};
struct TCharacterGuildData{
int GuildID;
int Rank;
char GuildName[30];
char RankName[30];
char Title[30];
};
struct TCharacterProfile{
int CharacterID;
char Name[30];
char World[30];
int Sex;
char Guild[30];
char Rank[30];
char Title[30];
int Level;
char Profession[30];
char Residence[30];
@ -1048,6 +1051,9 @@ bool InsertLoginAttempt(TDatabase *Database, int AccountID, int IPAddress, bool
bool GetAccountFailedLoginAttempts(TDatabase *Database, int AccountID, int TimeWindow, int *FailedAttempts);
bool GetIPAddressFailedLoginAttempts(TDatabase *Database, int IPAddress, int TimeWindow, int *FailedAttempts);
// NOTE(fusion): Guild Tables
bool GetCharacterGuildData(TDatabase *Database, int CharacterID, TCharacterGuildData *GuildData);
// NOTE(fusion): House Tables
bool FinishHouseAuctions(TDatabase *Database, int WorldID, DynamicArray<THouseAuction> *Auctions);
bool FinishHouseTransfers(TDatabase *Database, int WorldID, DynamicArray<THouseTransfer> *Transfers);
@ -1228,11 +1234,11 @@ enum : int {
};
enum ConnectionState: int {
CONNECTION_FREE = 0,
CONNECTION_READING = 1,
CONNECTION_REQUEST = 2,
CONNECTION_RESPONSE = 3,
CONNECTION_WRITING = 4,
CONNECTION_FREE = 0,
CONNECTION_READING = 1,
CONNECTION_REQUEST = 2,
CONNECTION_RESPONSE = 3,
CONNECTION_WRITING = 4,
};
struct TConnection{