diff --git a/README.md b/README.md
index 514a756e..eac435e7 100644
--- a/README.md
+++ b/README.md
@@ -1,32 +1,41 @@
-# Talo backend
+# Talo backend: self-hostable game dev tools
-Talo's backend is a set of self-hostable services that helps you build games faster and make better decisions.
+Talo is a collection of tools and APIs designed to make game development easier and to help you make better data-driven decisions.
-## Features
-- ⚡️ [Event tracking](https://trytalo.com/events)
-- 👥 [Player management](https://trytalo.com/players) (including cross-session data, groups and identity management)
-- 🎮 [Unity package](https://trytalo.com/unity)
-- 🎮 [Godot plugin](https://trytalo.com/godot)
-- 🗃️ Data exports
-- 🕹️ [Leaderboards](https://trytalo.com/leaderboards)
-- 💾 [Game saves](https://trytalo.com/saves)
-- 📊 [Game stats](https://trytalo.com/stats) (global and per-player)
-- ⚙️ [Live config](https://trytalo.com/live-config) (update your game config from the web, no releases required)
-- 🔧 [Steamworks integration](https://trytalo.com/steamworks-integration)
-- 💬 [Game feedback](https://trytalo.com/feedback)
+From essentials like player management, stats and leaderboards to advanced APIs for game saves, event tracking and player authentication.
-## Docs
+Talo is available to use via our [Godot plugin](https://github.com/TaloDev/godot), [Unity package](https://github.com/TaloDev/unity) or [REST API](https://docs.trytalo.com/docs/http/authentication).
-Our docs are [available here](https://docs.trytalo.com).
+## Talo's key features
-## Self-hosting
+- 👥 [Player management](https://trytalo.com/players): Persist player data across sessions, create segments and handle authentication.
+- ⚡️ [Event tracking](https://trytalo.com/events): Track in-game player actions individually and globally.
+- 🎮 [Godot plugin](https://trytalo.com/godot): Easily integrate Talo into your Godot game.
+- 🎮 [Unity package](https://trytalo.com/unity): Easily integrate Talo into your Unity game.
+- 🗃️ **Data Exports**: Create CSVs of your Talo data like players, events and feedback.
+- 🕹️ [Leaderboards](https://trytalo.com/leaderboards): Highly customisable leaderboards that can sync with Steamworks.
+- 💾 [Game saves](https://trytalo.com/saves): A simple and flexible way to load/save game state; also works offline.
+- 📊 [Game stats](https://trytalo.com/stats): Track global or per-player stats across your game; also syncs with Steamworks.
+- ⚙️ [Live config](https://trytalo.com/live-config): Update game settings from the web with zero downtime.
+- 🔧 [Steamworks integration](https://trytalo.com/steamworks-integration): Hook into Steamworks for authentication and ownership checks.
+- 💬 [Game feedback](https://trytalo.com/feedback): Collect and manage feedback from your players.
-See the [self-hosting docs](https://docs.trytalo.com/docs/selfhosting/overview) and the [self-hosting example repo](https://github.com/TaloDev/hosting).
+## Documentation
-## Contributing
+Check out the [full Talo docs](https://docs.trytalo.com) for setup instructions, detailed API docs/examples and configuration options.
-Looking to contribute? Head over to our [contribution guide](CONTRIBUTING.md) to learn how to install the project, run the tests and generate new services.
+## Self-hosting your own Talo instance
-## Discord
+Talo is designed to be easily self-hosted. Take a look at our [self-hosting guide](https://docs.trytalo.com/docs/selfhosting/overview) and the [GitHub repo](https://github.com/TaloDev/hosting) for examples on how to get started.
-For help and support, [join our Discord](https://discord.gg/2RWwxXVY3v).
+## Contributing to Talo
+
+Thinking about contributing to Talo? We’d love the help! Head over to our [contribution guide](CONTRIBUTING.md) to learn how to set up the project, run tests, and start adding new features.
+
+## Join our community
+
+Have questions, want to share feedback or show off your game? [Join us on Discord](https://trytalo.com/discord) to connect with other developers and get help from the Talo team.
+
+---
+
+Find all the details about Talo on our [website](https://trytalo.com)!
diff --git a/package-lock.json b/package-lock.json
index 6fef59e3..a78c7a92 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "game-services",
- "version": "0.48.0",
+ "version": "0.49.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "game-services",
- "version": "0.48.0",
+ "version": "0.49.0",
"license": "MIT",
"dependencies": {
"@clickhouse/client": "^1.4.1",
diff --git a/package.json b/package.json
index 06002125..489e852e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "game-services",
- "version": "0.48.0",
+ "version": "0.49.0",
"description": "",
"main": "src/index.ts",
"scripts": {
diff --git a/src/emails/mail.ts b/src/emails/mail.ts
index c6adca1b..57a760e3 100644
--- a/src/emails/mail.ts
+++ b/src/emails/mail.ts
@@ -38,7 +38,7 @@ export default class Mail {
this.preheader = preheader
this.footer = 'Need help?'
- this.footerText = 'Our team and community can be found on Discord.'
+ this.footerText = 'Our team and community can be found on Discord.'
this.why = 'You are receiving this email because you have a Talo account'
}
diff --git a/src/entities/player-auth.ts b/src/entities/player-auth.ts
index 6b4c3994..8ac83dbe 100644
--- a/src/entities/player-auth.ts
+++ b/src/entities/player-auth.ts
@@ -15,7 +15,8 @@ export enum PlayerAuthErrorCode {
NEW_PASSWORD_MATCHES_CURRENT_PASSWORD = 'NEW_PASSWORD_MATCHES_CURRENT_PASSWORD',
NEW_EMAIL_MATCHES_CURRENT_EMAIL = 'NEW_EMAIL_MATCHES_CURRENT_EMAIL',
PASSWORD_RESET_CODE_INVALID = 'PASSWORD_RESET_CODE_INVALID',
- VERIFICATION_EMAIL_REQUIRED = 'VERIFICATION_EMAIL_REQUIRED'
+ VERIFICATION_EMAIL_REQUIRED = 'VERIFICATION_EMAIL_REQUIRED',
+ INVALID_EMAIL = 'INVALID_EMAIL'
}
@Entity()
diff --git a/src/lib/lang/emailRegex.ts b/src/lib/lang/emailRegex.ts
new file mode 100644
index 00000000..c0fc10fb
--- /dev/null
+++ b/src/lib/lang/emailRegex.ts
@@ -0,0 +1 @@
+export default new RegExp(/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|.(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/)
diff --git a/src/services/api/player-auth-api.service.ts b/src/services/api/player-auth-api.service.ts
index 7afb3755..79527efc 100644
--- a/src/services/api/player-auth-api.service.ts
+++ b/src/services/api/player-auth-api.service.ts
@@ -15,6 +15,7 @@ import PlayerAuthCode from '../../emails/player-auth-code-mail'
import PlayerAuthResetPassword from '../../emails/player-auth-reset-password-mail'
import createPlayerAuthActivity from '../../lib/logging/createPlayerAuthActivity'
import { PlayerAuthActivityType } from '../../entities/player-auth-activity'
+import emailRegex from '../../lib/lang/emailRegex'
@Routes([
{
@@ -105,7 +106,21 @@ export default class PlayerAuthAPIService extends APIService {
alias.player.auth = new PlayerAuth()
alias.player.auth.password = await bcrypt.hash(password, 10)
- alias.player.auth.email = email || null
+
+ if (email?.trim()) {
+ const sanitisedEmail = email.trim().toLowerCase()
+ if (emailRegex.test(sanitisedEmail)) {
+ alias.player.auth.email = sanitisedEmail
+ } else {
+ req.ctx.throw(400, {
+ message: 'Invalid email address',
+ errorCode: PlayerAuthErrorCode.INVALID_EMAIL
+ })
+ }
+ } else {
+ alias.player.auth.email = null
+ }
+
alias.player.auth.verificationEnabled = Boolean(verificationEnabled)
em.persist(alias.player.auth)
@@ -381,7 +396,23 @@ export default class PlayerAuthAPIService extends APIService {
}
const oldEmail = alias.player.auth.email
- alias.player.auth.email = newEmail
+ const sanitisedEmail = (newEmail as string).trim().toLowerCase()
+ if (emailRegex.test(sanitisedEmail)) {
+ alias.player.auth.email = sanitisedEmail
+ } else {
+ createPlayerAuthActivity(req, alias.player, {
+ type: PlayerAuthActivityType.CHANGE_EMAIL_FAILED,
+ extra: {
+ errorCode: PlayerAuthErrorCode.INVALID_EMAIL
+ }
+ })
+ await em.flush()
+
+ req.ctx.throw(400, {
+ message: 'Invalid email address',
+ errorCode: PlayerAuthErrorCode.INVALID_EMAIL
+ })
+ }
createPlayerAuthActivity(req, alias.player, {
type: PlayerAuthActivityType.CHANGED_EMAIL,
@@ -530,7 +561,24 @@ export default class PlayerAuthAPIService extends APIService {
alias.player.auth.verificationEnabled = Boolean(verificationEnabled)
if (email?.trim()) {
- alias.player.auth.email = email
+ const sanitisedEmail = (email as string).trim().toLowerCase()
+ if (emailRegex.test(sanitisedEmail)) {
+ alias.player.auth.email = sanitisedEmail
+ } else {
+ createPlayerAuthActivity(req, alias.player, {
+ type: PlayerAuthActivityType.TOGGLE_VERIFICATION_FAILED,
+ extra: {
+ errorCode: PlayerAuthErrorCode.INVALID_EMAIL,
+ verificationEnabled: Boolean(verificationEnabled)
+ }
+ })
+ await em.flush()
+
+ req.ctx.throw(400, {
+ message: 'Invalid email address',
+ errorCode: PlayerAuthErrorCode.INVALID_EMAIL
+ })
+ }
}
createPlayerAuthActivity(req, alias.player, {
diff --git a/src/services/public/user-public.service.ts b/src/services/public/user-public.service.ts
index d277444a..3aa7299d 100644
--- a/src/services/public/user-public.service.ts
+++ b/src/services/public/user-public.service.ts
@@ -1,4 +1,4 @@
-import { After, Service, Request, Response, Routes, Validate } from 'koa-clay'
+import { After, Service, Request, Response, Routes, Validate, ValidationCondition } from 'koa-clay'
import User, { UserType } from '../../entities/user'
import jwt from 'jsonwebtoken'
import { promisify } from 'util'
@@ -23,6 +23,7 @@ import handlePricingPlanAction from '../../lib/billing/handlePricingPlanAction'
import { PricingPlanActionType } from '../../entities/pricing-plan-action'
import queueEmail from '../../lib/messaging/queueEmail'
import ResetPassword from '../../emails/reset-password'
+import emailRegex from '../../lib/lang/emailRegex'
async function sendEmailConfirm(req: Request, res: Response): Promise {
const user: User = req.ctx.state.user
@@ -80,7 +81,13 @@ export default class UserPublicService extends Service {
@Validate({
body: {
email: {
- required: true
+ required: true,
+ validation: async (val: string): Promise => [
+ {
+ check: emailRegex.test(val),
+ error: 'Email address is invalid'
+ }
+ ]
},
username: {
required: true
@@ -103,10 +110,10 @@ export default class UserPublicService extends Service {
const userWithEmail = await em.getRepository(User).findOne({ email })
const orgWithEmail = await em.getRepository(Organisation).findOne({ email })
- if (userWithEmail || orgWithEmail) req.ctx.throw(400, 'That email address is already in use')
+ if (userWithEmail || orgWithEmail) req.ctx.throw(400, 'Email address is already in use')
const user = new User()
- user.email = email.toLowerCase()
+ user.email = email.trim().toLowerCase()
user.username = username
user.password = await bcrypt.hash(password, 10)
user.emailConfirmed = process.env.AUTO_CONFIRM_EMAIL === 'true'
diff --git a/tests/services/_api/player-auth-api/changeEmail.test.ts b/tests/services/_api/player-auth-api/changeEmail.test.ts
index 718e74c2..bfd8ad35 100644
--- a/tests/services/_api/player-auth-api/changeEmail.test.ts
+++ b/tests/services/_api/player-auth-api/changeEmail.test.ts
@@ -6,6 +6,7 @@ import { EntityManager } from '@mikro-orm/mysql'
import bcrypt from 'bcrypt'
import PlayerAuthFactory from '../../../fixtures/PlayerAuthFactory'
import PlayerAuthActivity, { PlayerAuthActivityType } from '../../../../src/entities/player-auth-activity'
+import casual from 'casual'
describe('Player auth API service - change email', () => {
it('should change a player\'s email if the current password is correct and the api key has the correct scopes', async () => {
@@ -151,4 +152,44 @@ describe('Player auth API service - change email', () => {
})
expect(activity).not.toBeNull()
})
+
+ it('should not change a player\'s email if the new email is invalid', async () => {
+ const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.READ_PLAYERS, APIKeyScope.WRITE_PLAYERS])
+
+ const player = await new PlayerFactory([apiKey.game]).withTaloAlias().state(async () => ({
+ auth: await new PlayerAuthFactory().state(async () => ({
+ password: await bcrypt.hash('password', 10),
+ email: casual.email,
+ verificationEnabled: casual.boolean
+ })).one()
+ })).one()
+ const alias = player.aliases[0]
+ await (global.em).persistAndFlush(player)
+
+ const sessionToken = await player.auth.createSession(alias)
+ await (global.em).flush()
+
+ const res = await request(global.app)
+ .post('/v1/players/auth/change_email')
+ .send({ currentPassword: 'password', newEmail: 'blah' })
+ .auth(token, { type: 'bearer' })
+ .set('x-talo-player', player.id)
+ .set('x-talo-alias', String(alias.id))
+ .set('x-talo-session', sessionToken)
+ .expect(400)
+
+ expect(res.body).toStrictEqual({
+ message: 'Invalid email address',
+ errorCode: 'INVALID_EMAIL'
+ })
+
+ const activity = await (global.em).getRepository(PlayerAuthActivity).findOne({
+ type: PlayerAuthActivityType.CHANGE_EMAIL_FAILED,
+ player: player.id,
+ extra: {
+ errorCode: 'INVALID_EMAIL'
+ }
+ })
+ expect(activity).not.toBeNull()
+ })
})
diff --git a/tests/services/_api/player-auth-api/register.test.ts b/tests/services/_api/player-auth-api/register.test.ts
index 181a8cb1..69f4b270 100644
--- a/tests/services/_api/player-auth-api/register.test.ts
+++ b/tests/services/_api/player-auth-api/register.test.ts
@@ -104,7 +104,7 @@ describe('Player auth API service - register', () => {
expect(activity).not.toBeNull()
})
- it('should register not register a player if verification is enabled but no email is provided', async () => {
+ it('should not register a player if verification is enabled but no email is provided', async () => {
const [, token] = await createAPIKeyAndToken([APIKeyScope.READ_PLAYERS, APIKeyScope.WRITE_PLAYERS])
const res = await request(global.app)
@@ -119,4 +119,19 @@ describe('Player auth API service - register', () => {
}
})
})
+
+ it('should not register a player if verification is enabled but the email is invalid', async () => {
+ const [, token] = await createAPIKeyAndToken([APIKeyScope.READ_PLAYERS, APIKeyScope.WRITE_PLAYERS])
+
+ const res = await request(global.app)
+ .post('/v1/players/auth/register')
+ .send({ identifier: casual.username, email: 'blah', password: 'password', verificationEnabled: true })
+ .auth(token, { type: 'bearer' })
+ .expect(400)
+
+ expect(res.body).toStrictEqual({
+ message: 'Invalid email address',
+ errorCode: 'INVALID_EMAIL'
+ })
+ })
})
diff --git a/tests/services/_api/player-auth-api/toggleVerification.test.ts b/tests/services/_api/player-auth-api/toggleVerification.test.ts
index 8a487a3a..e09cd338 100644
--- a/tests/services/_api/player-auth-api/toggleVerification.test.ts
+++ b/tests/services/_api/player-auth-api/toggleVerification.test.ts
@@ -230,4 +230,45 @@ describe('Player auth API service - toggle verification', () => {
.set('x-talo-session', sessionToken)
.expect(403)
})
+
+ it('should not enable verification if the provided email is invalid', async () => {
+ const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.READ_PLAYERS, APIKeyScope.WRITE_PLAYERS])
+
+ const player = await new PlayerFactory([apiKey.game]).withTaloAlias().state(async () => ({
+ auth: await new PlayerAuthFactory().state(async () => ({
+ password: await bcrypt.hash('password', 10),
+ email: null,
+ verificationEnabled: false
+ })).one()
+ })).one()
+ const alias = player.aliases[0]
+ await (global.em).persistAndFlush(player)
+
+ const sessionToken = await player.auth.createSession(alias)
+ await (global.em).flush()
+
+ const res = await request(global.app)
+ .patch('/v1/players/auth/toggle_verification')
+ .send({ currentPassword: 'password', verificationEnabled: true, email: 'blah' })
+ .auth(token, { type: 'bearer' })
+ .set('x-talo-player', player.id)
+ .set('x-talo-alias', String(alias.id))
+ .set('x-talo-session', sessionToken)
+ .expect(400)
+
+ expect(res.body).toStrictEqual({
+ message: 'Invalid email address',
+ errorCode: 'INVALID_EMAIL'
+ })
+
+ const activity = await (global.em).getRepository(PlayerAuthActivity).findOne({
+ type: PlayerAuthActivityType.TOGGLE_VERIFICATION_FAILED,
+ player: player.id,
+ extra: {
+ errorCode: 'INVALID_EMAIL',
+ verificationEnabled: true
+ }
+ })
+ expect(activity).not.toBeNull()
+ })
})
diff --git a/tests/services/_public/user-public/register.test.ts b/tests/services/_public/user-public/register.test.ts
index 471875b4..9e19e3b1 100644
--- a/tests/services/_public/user-public/register.test.ts
+++ b/tests/services/_public/user-public/register.test.ts
@@ -40,7 +40,7 @@ describe('User public service - register', () => {
.send({ email, username: casual.username, password: 'password', organisationName: 'Talo' })
.expect(400)
- expect(res.body).toStrictEqual({ message: 'That email address is already in use' })
+ expect(res.body).toStrictEqual({ message: 'Email address is already in use' })
})
it('should create an access code for a new user', async () => {
@@ -113,4 +113,17 @@ describe('User public service - register', () => {
.send({ email, username, password: 'password', inviteToken: 'abc123' })
.expect(404)
})
+
+ it('should not let a user register if their email is invalid', async () => {
+ const res = await request(global.app)
+ .post('/public/users/register')
+ .send({ email: 'bleh', username: casual.username, password: 'password', organisationName: 'Talo' })
+ .expect(400)
+
+ expect(res.body).toStrictEqual({
+ errors: {
+ email: ['Email address is invalid']
+ }
+ })
+ })
})