From dc8c7bd2b6ed7facf8d829c1620bc519052c78e0 Mon Sep 17 00:00:00 2001 From: Manuel Date: Mon, 2 Oct 2023 13:46:51 +0200 Subject: [PATCH] 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 --- .editorconfig | 4 + Dockerfile | 6 +- config.example.toml | 8 ++ src/api.v | 184 ++++++++++++++++++++++++++++++++++++++++++++ src/config.v | 36 ++++++--- src/database.v | 58 ++++++++++---- src/main.v | 57 +++++++------- src/types.v | 42 ++++++++++ src/web.v | 121 +---------------------------- 9 files changed, 342 insertions(+), 174 deletions(-) create mode 100644 src/api.v create mode 100644 src/types.v diff --git a/.editorconfig b/.editorconfig index 498a6ff..6bf891a 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,6 +10,10 @@ trim_trailing_whitespace = true indent_style = tab indent_size = 4 +[{Dockerfile,Dockerfile.*}] +indent_style = space +indent_size = 4 + [*.{yml,yaml,hcl,toml}] indent_style = space indent_size = 2 diff --git a/Dockerfile b/Dockerfile index b6f26cc..3bce5a7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ -FROM git.snrd.eu/sunred/vlang:alpine AS build +FROM thevlang/vlang:alpine AS build WORKDIR /tmp/app COPY . . -RUN v -prod -o highscore-server . +RUN v -prod -ldflags "-s -w" -o highscore-server . FROM alpine:latest @@ -10,6 +10,6 @@ RUN apk --no-cache add sqlite-libs WORKDIR /app 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" ] diff --git a/config.example.toml b/config.example.toml index 5822065..80e2c93 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,9 +1,17 @@ host = "" port = 8080 token = "asd" +valkey = "asd" redirect = false redirect_url = "" db_path = "./db/app.db" +loglevel = "warn" origins = [ "*" ] +games = [ + "mygame" +] + +[valid_keys] +mygame = ["level1", "level2", "level3"] diff --git a/src/api.v b/src/api.v new file mode 100644 index 0000000..6235afa --- /dev/null +++ b/src/api.v @@ -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) +} diff --git a/src/config.v b/src/config.v index 45ef11c..c91f60e 100644 --- a/src/config.v +++ b/src/config.v @@ -11,32 +11,50 @@ struct Config { host string port int token string + valkey string redirect bool redirect_url string db_path string + loglevel string origins []string + games []string + valid_keys map[string][]string } -fn (config Config) copy() Config { - return config -} - -fn load_config() !Config { +fn load_config() !&Config { if !os.is_file(path) { return error('Config file does not exist or is not a file') } - config := toml.parse_file(path) or { - return error('Error while parsing config file:\n' + err.msg()) + config := toml.parse_file(path) or { return error('Parsing error:\n' + err.str()) } + + 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() port: config.value('port').int() - token: config.value('token').string() + token: token + valkey: valkey redirect: config.value('redirect').bool() redirect_url: config.value('redirect_url').string() db_path: config.value('db_path').string() + loglevel: config.value('loglevel').string() origins: config.value('origins').array().as_strings() + games: config.value('games').array().as_strings() + valid_keys: valid_keys } } diff --git a/src/database.v b/src/database.v index e0cc7ab..2c28257 100644 --- a/src/database.v +++ b/src/database.v @@ -1,38 +1,66 @@ module main [table: 'Score'] -struct ScoreRes { +struct ScoreTable { mut: - id i64 [primary; sql: serial] + id int [primary; sql: serial] player string [nonull] + game string [nonull] + key string [nonull] score int [nonull] time i64 [nonull] } fn (mut app App) create_tables() ! { sql app.db { - create table ScoreRes + create table ScoreTable }! } -fn (mut app App) insert_score(score ScoreRes) !ScoreRes { - sql app.db { - insert score into ScoreRes - }! - last_id := app.db.last_id() as int - return sql app.db { - select from ScoreRes where id == last_id - }![0] or { 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 { + insert score into ScoreTable + } or { + app.db.exec('ROLLBACK')! + return err + } + last_id := app.db.last_id() as int + score_tables := sql app.db { + select from ScoreTable where id == last_id + } 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 { - return sql app.db { - select from ScoreRes order by score desc limit 1000 +fn (mut app App) get_scores(game string, key string) ![]ScoreRes { + score_table := sql app.db { + 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) ! { sql app.db { - delete from ScoreRes where id == score_id + delete from ScoreTable where id == score_id }! } diff --git a/src/main.v b/src/main.v index 6409910..7bc49b1 100644 --- a/src/main.v +++ b/src/main.v @@ -4,61 +4,64 @@ import vweb import db.sqlite import time import os +import log struct App { vweb.Context + config Config [vweb_global] mut: - config shared Config + logger log.Logger [vweb_global] is_admin bool pub mut: db sqlite.DB } fn main() { - mut app := &App{} - mut app_config := Config{} + config := load_config() or { panic('Could not load config file: ${err.str()}') } - lock app.config { - app.config = load_config() or { panic('Could not load config file: ' + err.msg()) } - app_config = app.config.copy() + mut logger := &log.Log{ + level: log.level_from_tag(config.loglevel.to_upper()) or { log.Level.info } + output_target: log.LogTarget.console } - if !os.exists(app_config.db_path) { - println('Creating database file at ' + app_config.db_path) - mut file := os.create(app_config.db_path) or { panic(err) } + log.set_logger(logger) + + 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() } - app.db = sqlite.connect(app_config.db_path) or { - println('Database error!') - panic(err) + db := sqlite.connect(config.db_path) or { logger.fatal('Database error! ${err.str()}') } + + mut app := &App{ + config: config + logger: logger + db: db } - app.create_tables() or { - println('Could not create database tables!') - panic(err) - } + app.create_tables() or { logger.fatal('Could not create database tables! ${err.str()}') } - mut host := '::' - if app_config.host.len != 0 { - host = app_config.host + mut host := '0.0.0.0' + if config.host.len != 0 { + host = config.host } // Handle graceful shutdown for Docker - os.signal_opt(os.Signal.int, app.shutdown) or { panic(err) } - os.signal_opt(os.Signal.term, 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 { logger.fatal(err.str()) } vweb.run_at(app, vweb.RunParams{ host: host - port: app_config.port - family: .ip6 - }) or { panic(err) } + port: config.port + family: .ip + }) or { logger.fatal(err.str()) } } fn (mut app App) shutdown(sig os.Signal) { - app.db.close() or { panic(err) } - println('Shut down database gracefully') - println('Exiting...') + app.db.close() or { app.logger.fatal(err.str()) } + app.logger.info('Shut down database gracefully') + app.logger.info('Exiting...') time.sleep(1e+9) // Sleep one second exit(0) } diff --git a/src/types.v b/src/types.v new file mode 100644 index 0000000..6d402da --- /dev/null +++ b/src/types.v @@ -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 +} diff --git a/src/web.v b/src/web.v index b74b368..b7f18e0 100644 --- a/src/web.v +++ b/src/web.v @@ -1,122 +1,3 @@ module main -import vweb -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 -} +// TODO: Webinterface using oauth2 with htmx