Support for multiple games and keys per game, refactor, tweaks

* Update editorconfig for Dockerfile formatting
* Update Dockerfile to strip debug symbols
* Support pushing multiple highscores and use of Options to support traditional API mode
* Support sending and retrieving different highscore lists for a game based on a key
* Support retrieving partial highscore lists for client side pagination
* Use of vweb_global attribute for better performance (No mutex/shared variable) and immutability
* Support for a verification key that is used for checksumming score data with a private key to raise the difficulty for potential cheaters
* Add Logger for nicer console output and severity distinction
* Use of transactions in database for higher reliability
* Additional error checking for improved error recovery and reliability
This commit is contained in:
Manuel 2023-10-02 13:46:51 +02:00
parent 674aa90c49
commit dc8c7bd2b6
Signed by: Manuel
GPG key ID: 4085037435E1F07A
9 changed files with 342 additions and 174 deletions

View file

@ -10,6 +10,10 @@ trim_trailing_whitespace = true
indent_style = tab indent_style = tab
indent_size = 4 indent_size = 4
[{Dockerfile,Dockerfile.*}]
indent_style = space
indent_size = 4
[*.{yml,yaml,hcl,toml}] [*.{yml,yaml,hcl,toml}]
indent_style = space indent_style = space
indent_size = 2 indent_size = 2

View file

@ -1,8 +1,8 @@
FROM git.snrd.eu/sunred/vlang:alpine AS build FROM thevlang/vlang:alpine AS build
WORKDIR /tmp/app WORKDIR /tmp/app
COPY . . COPY . .
RUN v -prod -o highscore-server . RUN v -prod -ldflags "-s -w" -o highscore-server .
FROM alpine:latest FROM alpine:latest
@ -10,6 +10,6 @@ RUN apk --no-cache add sqlite-libs
WORKDIR /app WORKDIR /app
COPY --from=build /tmp/app/highscore-server . COPY --from=build /tmp/app/highscore-server .
COPY --from=build /tmp/app/config.example.toml ./config.toml COPY ./config.example.toml ./config.toml
ENTRYPOINT [ "./highscore-server" ] ENTRYPOINT [ "./highscore-server" ]

View file

@ -1,9 +1,17 @@
host = "" host = ""
port = 8080 port = 8080
token = "asd" token = "asd"
valkey = "asd"
redirect = false redirect = false
redirect_url = "" redirect_url = ""
db_path = "./db/app.db" db_path = "./db/app.db"
loglevel = "warn"
origins = [ origins = [
"*" "*"
] ]
games = [
"mygame"
]
[valid_keys]
mygame = ["level1", "level2", "level3"]

184
src/api.v Normal file
View file

@ -0,0 +1,184 @@
module main
// import profanity
import vweb
import json
import time
import crypto.sha256
[get; head]
pub fn (mut app App) index() vweb.Result {
if app.config.redirect {
return app.redirect(app.config.redirect_url)
}
return app.not_found()
}
['/api/v2/score/list/:game'; get; head]
pub fn (mut app App) score_list(game string) vweb.Result {
app.add_cors_headers()
if !app.auth() {
return app.status(Status{401, 'Access token is wrong or missing'})
}
key := app.query['key'] or { default_score_key }
offset := (app.query['offset'] or { '0' }).int()
limit := (app.query['limit'] or { '-1' }).int()
scores := app.get_scores_limit(game, key, offset, limit) or {
app.logger.error('A database error occurred: ${err.str()}')
return app.status(Status{500, 'An error occurred while retrieving score list'})
}
return app.json(scores)
}
['/api/v2/score/submit/:game'; post]
pub fn (mut app App) score_submit(game string) vweb.Result {
app.add_cors_headers()
if !app.auth() {
return app.status(Status{401, 'Access token is wrong or missing'})
}
body := json.decode(Score, app.req.data) or {
app.logger.error('Bad JSON object was retrieved: ${err.str()}')
return app.status(Status{400, 'Bad JSON object'})
}
if (body.score != none && body.scores != none) || (body.score == none && body.scores == none) {
return app.status(Status{400, 'Either score field or scores field should be used'})
}
if !app.valid_key(game, body) {
app.logger.error('${body.player} tried to use invalid verification key for game ${game}')
return app.status(Status{401, 'Verification key is wrong or missing'})
}
if game !in app.config.games {
app.logger.error('${body.player} tried to submit score(s) for invalid game ${game}')
return app.status(Status{400, 'Unknown game'})
}
if body.player.trim_space().len == 0 {
app.logger.error('Somebody (${body.player}) tried to submit an empty name for game ${game}')
return app.status(Status{400, 'Name cannot be empty'})
}
// TODO: Bad word filter
mut scores := []ScoreTable{}
if score_num := body.score {
if score_num <= 0 {
app.logger.error('${body.player} tried to submit an illegal score for game ${game}')
return app.status(Status{400, 'Illegal score'})
}
scores << ScoreTable{
player: body.player
game: game
key: default_score_key
score: score_num
time: time.now().unix_time()
}
} else if scores_option := body.scores {
score_list := scores_option.clone()
for key, score in score_list {
valid_key := app.config.valid_keys[game] or { [key] }
if key !in valid_key {
app.logger.error('${body.player} tried to use an invalid key for game ${game}')
return app.status(Status{400, 'Unknown key'})
}
if score <= 0 {
app.logger.error('${body.player} tried to submit illegal score for game ${game}')
return app.status(Status{400, 'Illegal score for "${key}"'})
}
scores << ScoreTable{
player: body.player
game: game
key: key
score: score
time: time.now().unix_time()
}
}
} else {
app.logger.error('Bad JSON object was retrieved (Neither score and scores is an Option)')
return app.status(Status{400, 'Bad JSON object'})
}
score_res := app.insert_score_res(scores) or {
app.logger.error('A database error occurred: ${err.str()}')
return app.status(Status{500, 'An error occurred while inserting score'})
}
app.logger.info('New score(s) submitted by ${body.player} for game ${game}')
return app.json(score_res)
}
['/api/v2/score/list'; options]
pub fn (mut app App) handle_score_list_options() vweb.Result {
return app.handle_options()
}
['/api/v2/score/submit'; options]
pub fn (mut app App) handle_score_submit_options() vweb.Result {
return app.handle_options()
}
fn (mut app App) handle_options() vweb.Result {
app.set_status(204, '')
app.add_cors_headers()
return app.ok('')
}
fn (mut app App) add_cors_headers() {
origin := app.get_header('origin')
// Only return headers if actual cross-origin request
if origin.len == 0 {
return
}
origins := app.config.origins
default_origin := origins[0] or { '*' }
allowed_origin := if origins.any(it == origin) { origin } else { default_origin }
app.add_header('Access-Control-Allow-Origin', allowed_origin)
app.add_header('Access-Control-Allow-Methods', 'OPTIONS, HEAD, GET, POST')
app.add_header('Access-Control-Allow-Headers', 'Authorization, Content-Type')
app.add_header('Access-Control-Max-Age', '86400')
}
fn (mut app App) auth() bool {
auth_header := app.get_header('Authorization')
token := auth_header.after('Bearer ')
return token == app.config.token
}
fn (mut app App) valid_key(game string, score &Score) bool {
mut scorestr := ''
if score_num := score.score {
scorestr += score_num.str()
} else if scores_option := score.scores {
score_list := scores_option.clone()
for k, v in score_list {
scorestr += k + v.str()
}
} else {
return false
}
valid_key := sha256.hexhash(game + score.player + scorestr + app.config.valkey)
return score.valkey == valid_key
}
fn (mut app App) status(status &Status) vweb.Result {
app.set_status(status.status, '')
return app.json(status)
}

View file

@ -11,32 +11,50 @@ struct Config {
host string host string
port int port int
token string token string
valkey string
redirect bool redirect bool
redirect_url string redirect_url string
db_path string db_path string
loglevel string
origins []string origins []string
games []string
valid_keys map[string][]string
} }
fn (config Config) copy() Config { fn load_config() !&Config {
return config
}
fn load_config() !Config {
if !os.is_file(path) { if !os.is_file(path) {
return error('Config file does not exist or is not a file') return error('Config file does not exist or is not a file')
} }
config := toml.parse_file(path) or { config := toml.parse_file(path) or { return error('Parsing error:\n' + err.str()) }
return error('Error while parsing config file:\n' + err.msg())
token := config.value('token').string()
if token.len == 0 {
return error('Token cannot be empty')
} }
return Config{ valkey := config.value('valkey').string()
if valkey.len == 0 {
return error('Verification key cannot be empty')
}
valid_keys_toml := config.value('valid_keys').as_map()
mut valid_keys := map[string][]string{}
for key, arr in valid_keys_toml {
valid_keys[key] = arr.array().as_strings()
}
return &Config{
host: config.value('host').string() host: config.value('host').string()
port: config.value('port').int() port: config.value('port').int()
token: config.value('token').string() token: token
valkey: valkey
redirect: config.value('redirect').bool() redirect: config.value('redirect').bool()
redirect_url: config.value('redirect_url').string() redirect_url: config.value('redirect_url').string()
db_path: config.value('db_path').string() db_path: config.value('db_path').string()
loglevel: config.value('loglevel').string()
origins: config.value('origins').array().as_strings() origins: config.value('origins').array().as_strings()
games: config.value('games').array().as_strings()
valid_keys: valid_keys
} }
} }

View file

@ -1,38 +1,66 @@
module main module main
[table: 'Score'] [table: 'Score']
struct ScoreRes { struct ScoreTable {
mut: mut:
id i64 [primary; sql: serial] id int [primary; sql: serial]
player string [nonull] player string [nonull]
game string [nonull]
key string [nonull]
score int [nonull] score int [nonull]
time i64 [nonull] time i64 [nonull]
} }
fn (mut app App) create_tables() ! { fn (mut app App) create_tables() ! {
sql app.db { sql app.db {
create table ScoreRes create table ScoreTable
}! }!
} }
fn (mut app App) insert_score(score ScoreRes) !ScoreRes { fn (mut app App) insert_score_res(scores []ScoreTable) ![]ScoreRes {
mut score_res := []ScoreRes{}
app.db.exec('BEGIN')!
for _, score in scores {
sql app.db { sql app.db {
insert score into ScoreRes insert score into ScoreTable
}! } or {
app.db.exec('ROLLBACK')!
return err
}
last_id := app.db.last_id() as int last_id := app.db.last_id() as int
return sql app.db { score_tables := sql app.db {
select from ScoreRes where id == last_id select from ScoreTable where id == last_id
}![0] or { ScoreRes{} } } or {
app.db.exec('ROLLBACK')!
return err
}
score_res << (score_tables[0] or {
app.db.exec('ROLLBACK')!
return error('Result returned zero')
}).convert_to_res()
}
app.db.exec('COMMIT')!
return score_res
} }
fn (mut app App) get_scores() ![]ScoreRes { fn (mut app App) get_scores(game string, key string) ![]ScoreRes {
return sql app.db { score_table := sql app.db {
select from ScoreRes order by score desc limit 1000 select from ScoreTable where game == game && key == key order by score desc
}! }!
return score_table.convert_to_res()
}
fn (mut app App) get_scores_limit(game string, key string, offset int, rows int) ![]ScoreRes {
score_table := sql app.db {
select from ScoreTable where game == game && key == key order by score desc limit rows offset offset
}!
return score_table.convert_to_res()
} }
fn (mut app App) delete_score(score_id int) ! { fn (mut app App) delete_score(score_id int) ! {
sql app.db { sql app.db {
delete from ScoreRes where id == score_id delete from ScoreTable where id == score_id
}! }!
} }

View file

@ -4,61 +4,64 @@ import vweb
import db.sqlite import db.sqlite
import time import time
import os import os
import log
struct App { struct App {
vweb.Context vweb.Context
config Config [vweb_global]
mut: mut:
config shared Config logger log.Logger [vweb_global]
is_admin bool is_admin bool
pub mut: pub mut:
db sqlite.DB db sqlite.DB
} }
fn main() { fn main() {
mut app := &App{} config := load_config() or { panic('Could not load config file: ${err.str()}') }
mut app_config := Config{}
lock app.config { mut logger := &log.Log{
app.config = load_config() or { panic('Could not load config file: ' + err.msg()) } level: log.level_from_tag(config.loglevel.to_upper()) or { log.Level.info }
app_config = app.config.copy() output_target: log.LogTarget.console
} }
if !os.exists(app_config.db_path) { log.set_logger(logger)
println('Creating database file at ' + app_config.db_path)
mut file := os.create(app_config.db_path) or { panic(err) } if !os.exists(config.db_path) {
logger.info('Creating database file at ' + config.db_path)
mut file := os.create(config.db_path) or { logger.fatal(err.str()) }
file.close() file.close()
} }
app.db = sqlite.connect(app_config.db_path) or { db := sqlite.connect(config.db_path) or { logger.fatal('Database error! ${err.str()}') }
println('Database error!')
panic(err) mut app := &App{
config: config
logger: logger
db: db
} }
app.create_tables() or { app.create_tables() or { logger.fatal('Could not create database tables! ${err.str()}') }
println('Could not create database tables!')
panic(err)
}
mut host := '::' mut host := '0.0.0.0'
if app_config.host.len != 0 { if config.host.len != 0 {
host = app_config.host host = config.host
} }
// Handle graceful shutdown for Docker // Handle graceful shutdown for Docker
os.signal_opt(os.Signal.int, app.shutdown) or { panic(err) } os.signal_opt(os.Signal.int, app.shutdown) or { logger.fatal(err.str()) }
os.signal_opt(os.Signal.term, app.shutdown) or { panic(err) } os.signal_opt(os.Signal.term, app.shutdown) or { logger.fatal(err.str()) }
vweb.run_at(app, vweb.RunParams{ vweb.run_at(app, vweb.RunParams{
host: host host: host
port: app_config.port port: config.port
family: .ip6 family: .ip
}) or { panic(err) } }) or { logger.fatal(err.str()) }
} }
fn (mut app App) shutdown(sig os.Signal) { fn (mut app App) shutdown(sig os.Signal) {
app.db.close() or { panic(err) } app.db.close() or { app.logger.fatal(err.str()) }
println('Shut down database gracefully') app.logger.info('Shut down database gracefully')
println('Exiting...') app.logger.info('Exiting...')
time.sleep(1e+9) // Sleep one second time.sleep(1e+9) // Sleep one second
exit(0) exit(0)
} }

42
src/types.v Normal file
View file

@ -0,0 +1,42 @@
module main
pub const default_score_key = '_default'
struct Score {
player string
score ?int
scores ?map[string]int
valkey string
}
struct ScoreRes {
id int
player string
score int
time i64
}
struct Status {
status int
message string
}
fn (score ScoreTable) convert_to_res() &ScoreRes {
return &ScoreRes{
id: score.id
player: score.player
score: score.score
time: score.time
}
}
// More efficient than array.map(it.convert_to_res())?
fn (scores []ScoreTable) convert_to_res() []ScoreRes {
mut score_res := []ScoreRes{}
for _, score in scores {
score_res << score.convert_to_res()
}
return score_res
}

121
src/web.v
View file

@ -1,122 +1,3 @@
module main module main
import vweb // TODO: Webinterface using oauth2 with htmx
import json
import time
struct Status {
status int
message string
}
[get; head]
pub fn (mut app App) index() vweb.Result {
rlock app.config {
if app.config.redirect {
return app.redirect(app.config.redirect_url)
}
}
return app.not_found()
}
['/api/v1/score/list'; get; head]
pub fn (mut app App) score_list() vweb.Result {
app.add_cors_headers()
if !app.auth() {
app.set_status(401, '')
return app.json(Status{401, 'Access token is wrong or missing'})
}
scores := app.get_scores() or {
return app.json(Status{500, 'An error occurred while retrieving score list'})
}
return app.json(scores)
}
['/api/v1/score/submit'; post]
pub fn (mut app App) score_submit() vweb.Result {
app.add_cors_headers()
if !app.auth() {
app.set_status(401, '')
return app.json(Status{401, 'Access token is wrong or missing'})
}
body := json.decode(Score, app.req.data) or {
app.set_status(400, '')
return app.json(Status{400, 'Bad JSON object'})
}
if body.player.trim_space().len == 0 {
app.set_status(400, '')
return app.json(Status{400, 'Name cannot be empty'})
}
if body.score <= 0 {
app.set_status(400, '')
return app.json(Status{400, 'Illegal score'})
}
// TODO: Bad word filter
score := app.insert_score(ScoreRes{
player: body.player
score: body.score
time: time.now().unix_time()
}) or { return app.json(Status{500, 'A database error occurred while inserting score'}) }
return app.json(score)
}
['/api/v1/score/list'; options]
pub fn (mut app App) handle_score_list_options() vweb.Result {
return app.handle_options()
}
['/api/v1/score/submit'; options]
pub fn (mut app App) handle_score_submit_options() vweb.Result {
return app.handle_options()
}
fn (mut app App) handle_options() vweb.Result {
app.set_status(204, '')
app.add_cors_headers()
return app.ok('')
}
fn (mut app App) add_cors_headers() {
origin := app.get_header('origin')
// Only return headers if actual cross-origin request
if origin.len == 0 {
return
}
rlock app.config {
origins := app.config.origins
default_origin := origins[0] or { '*' }
allowed_origin := if origins.any(it == origin) { origin } else { default_origin }
app.add_header('Access-Control-Allow-Origin', allowed_origin)
}
app.add_header('Access-Control-Allow-Methods', 'OPTIONS, HEAD, GET, POST')
app.add_header('Access-Control-Allow-Headers', 'Authorization, Content-Type')
app.add_header('Access-Control-Max-Age', '86400')
}
fn (mut app App) auth() bool {
auth_header := app.get_header('Authorization')
token := auth_header.after('Bearer ')
mut config_token := ''
rlock app.config {
config_token = app.config.token
}
return config_token.len != 0 && token == config_token
}