Skip to content

Commit

Permalink
Merge pull request #359 from TaloDev/develop
Browse files Browse the repository at this point in the history
Release 0.49.0
  • Loading branch information
tudddorrr authored Nov 14, 2024
2 parents 7e72868 + 483d4cd commit aea7c0e
Show file tree
Hide file tree
Showing 12 changed files with 212 additions and 36 deletions.
53 changes: 31 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)!
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "game-services",
"version": "0.48.0",
"version": "0.49.0",
"description": "",
"main": "src/index.ts",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion src/emails/mail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export default class Mail {
this.preheader = preheader

this.footer = 'Need help?'
this.footerText = 'Our team and community can be found <a href="https://discord.gg/2RWwxXVY3v" target="_blank" style="color: #ffffff;">on Discord</a>.'
this.footerText = 'Our team and community can be found <a href="https://trytalo.com/discord" target="_blank" style="color: #ffffff;">on Discord</a>.'

this.why = 'You are receiving this email because you have a Talo account'
}
Expand Down
3 changes: 2 additions & 1 deletion src/entities/player-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions src/lib/lang/emailRegex.ts
Original file line number Diff line number Diff line change
@@ -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,}))$/)
54 changes: 51 additions & 3 deletions src/services/api/player-auth-api.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
{
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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, {
Expand Down
15 changes: 11 additions & 4 deletions src/services/public/user-public.service.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<void> {
const user: User = req.ctx.state.user
Expand Down Expand Up @@ -80,7 +81,13 @@ export default class UserPublicService extends Service {
@Validate({
body: {
email: {
required: true
required: true,
validation: async (val: string): Promise<ValidationCondition[]> => [
{
check: emailRegex.test(val),
error: 'Email address is invalid'
}
]
},
username: {
required: true
Expand All @@ -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'
Expand Down
41 changes: 41 additions & 0 deletions tests/services/_api/player-auth-api/changeEmail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 (<EntityManager>global.em).persistAndFlush(player)

const sessionToken = await player.auth.createSession(alias)
await (<EntityManager>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 (<EntityManager>global.em).getRepository(PlayerAuthActivity).findOne({
type: PlayerAuthActivityType.CHANGE_EMAIL_FAILED,
player: player.id,
extra: {
errorCode: 'INVALID_EMAIL'
}
})
expect(activity).not.toBeNull()
})
})
17 changes: 16 additions & 1 deletion tests/services/_api/player-auth-api/register.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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'
})
})
})
41 changes: 41 additions & 0 deletions tests/services/_api/player-auth-api/toggleVerification.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (<EntityManager>global.em).persistAndFlush(player)

const sessionToken = await player.auth.createSession(alias)
await (<EntityManager>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 (<EntityManager>global.em).getRepository(PlayerAuthActivity).findOne({
type: PlayerAuthActivityType.TOGGLE_VERIFICATION_FAILED,
player: player.id,
extra: {
errorCode: 'INVALID_EMAIL',
verificationEnabled: true
}
})
expect(activity).not.toBeNull()
})
})
15 changes: 14 additions & 1 deletion tests/services/_public/user-public/register.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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']
}
})
})
})

0 comments on commit aea7c0e

Please sign in to comment.