commit a818d383eca6d280d6c02ba869411da34c46d52b Author: Haze Weathers Date: Fri Nov 29 06:33:08 2024 -0500 Initial commit, basic unauthenticated score system diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..35fb496 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# zig build artifacts +.zig-cache/ +zig-out/ + +# testing artifacts +scores/ diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..b19c122 --- /dev/null +++ b/build.zig @@ -0,0 +1,97 @@ +const std = @import("std"); + +// Although this function looks imperative, note that its job is to +// declaratively construct a build graph that will be executed by an external +// runner. +pub fn build(b: *std.Build) void { + // Standard target options allows the person running `zig build` to choose + // what target to build for. Here we do not override the defaults, which + // means any target is allowed, and the default is native. Other options + // for restricting supported target set are available. + const target = b.standardTargetOptions(.{}); + + // Standard optimization options allow the person running `zig build` to select + // between Debug, ReleaseSafe, ReleaseFast, and ReleaseSmall. Here we do not + // set a preferred release mode, allowing the user to decide how to optimize. + const optimize = b.standardOptimizeOption(.{}); + + const exe = b.addExecutable(.{ + .name = "revo-scores", + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }); + + const clap = b.dependency("clap", .{}); + exe.root_module.addImport("clap", clap.module("clap")); + + const zap = b.dependency("zap", .{ + .target = target, + .optimize = optimize, + .openssl = false, + }); + exe.root_module.addImport("zap", zap.module("zap")); + + const lmdb_zig = b.dependency("lmdb-zig", .{ + .target = target, + .optimize = optimize, + }); + exe.root_module.addImport("lmdb-zig", lmdb_zig.module("lmdb-zig-mod")); + exe.linkLibrary(lmdb_zig.artifact("lmdb-zig")); + + // This declares intent for the executable to be installed into the + // standard location when the user invokes the "install" step (the default + // step when running `zig build`). + b.installArtifact(exe); + + // This *creates* a Run step in the build graph, to be executed when another + // step is evaluated that depends on it. The next line below will establish + // such a dependency. + const run_cmd = b.addRunArtifact(exe); + + // By making the run step depend on the install step, it will be run from the + // installation directory rather than directly from within the cache directory. + // This is not necessary, however, if the application depends on other installed + // files, this ensures they will be present and in the expected location. + run_cmd.step.dependOn(b.getInstallStep()); + + // This allows the user to pass arguments to the application in the build + // command itself, like this: `zig build run -- arg1 arg2 etc` + if (b.args) |args| { + run_cmd.addArgs(args); + } + + // This creates a build step. It will be visible in the `zig build --help` menu, + // and can be selected like this: `zig build run` + // This will evaluate the `run` step rather than the default, which is "install". + const run_step = b.step("run", "Run the app"); + run_step.dependOn(&run_cmd.step); + + const exe_unit_tests = b.addTest(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }); + + const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests); + + // Similar to creating the run step earlier, this exposes a `test` step to + // the `zig build --help` menu, providing a way for the user to request + // running the unit tests. + const test_step = b.step("test", "Run unit tests"); + test_step.dependOn(&run_exe_unit_tests.step); + + // make zls work better + const exe_check = b.addExecutable(.{ + .name = "revo-scores", + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }); + exe_check.root_module.addImport("zap", zap.module("zap")); + exe_check.root_module.addImport("lmdb-zig", lmdb_zig.module("lmdb-zig-mod")); + exe_check.root_module.addImport("clap", clap.module("clap")); + + const check = b.step("check", "Check if exe compiles"); + check.dependOn(&exe_check.step); +} diff --git a/build.zig.zon b/build.zig.zon new file mode 100644 index 0000000..276c3d9 --- /dev/null +++ b/build.zig.zon @@ -0,0 +1,47 @@ +.{ + // This is the default name used by packages depending on this one. For + // example, when a user runs `zig fetch --save `, this field is used + // as the key in the `dependencies` table. Although the user can choose a + // different name, most users will stick with this provided value. + // + // It is redundant to include "zig" in this name because it is already + // within the Zig package namespace. + .name = "revo-scores", + + // This is a [Semantic Version](https://semver.org/). + // In a future version of Zig it will be used for package deduplication. + .version = "0.0.0", + + // This field is optional. + // This is currently advisory only; Zig does not yet do anything + // with this value. + //.minimum_zig_version = "0.11.0", + + // This field is optional. + // Each dependency must either provide a `url` and `hash`, or a `path`. + // `zig build --fetch` can be used to fetch all dependencies of a package, recursively. + // Once all dependencies are fetched, `zig build` no longer requires + // internet connectivity. + .dependencies = .{ + .zap = .{ + .url = "git+https://github.com/zigzap/zap?ref=v0.9.0#9543ede15fc9ad038ec4839628c886b124580983", + .hash = "1220bb12de9055d585ee0b19c8c9da41ad49a660f5da8be1372c7b85ba43f6e47372", + }, + .@"lmdb-zig" = .{ + .url = "git+https://github.com/john-g4lt/lmdb-zig?ref=v0.1.0#3ec55b07424ed1f4faba18a6d7d7b4360936d4c5", + .hash = "1220a44f2880d150589664ee936da3b856674d732cbf02b4714a7282fa752facad97", + }, + .clap = .{ + .url = "git+https://github.com/hejsil/zig-clap?ref=0.9.1#d71cc39a94f3e6ccbad00c25d350c9147de4df9f", + .hash = "122062d301a203d003547b414237229b09a7980095061697349f8bef41be9c30266b", + }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + // For example... + //"LICENSE", + //"README.md", + }, +} diff --git a/config.json b/config.json new file mode 100644 index 0000000..de0718a --- /dev/null +++ b/config.json @@ -0,0 +1,9 @@ +{ + "listen_port": 3000, + "db_path": "scores/", + "levels": [ + "hills", + "canopy", + "mountain" + ] +} diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..465c635 --- /dev/null +++ b/src/main.zig @@ -0,0 +1,68 @@ +const std = @import("std"); +const json = std.json; + +const clap = @import("clap"); +const zap = @import("zap"); +const lmdb = @import("lmdb-zig"); + +const Scores = @import("scores.zig"); +const ScoreWeb = @import("scoreweb.zig"); + +/// fallback request handler +fn onRequest(r: zap.Request) void { + r.setStatus(.not_found); + r.sendBody("Invalid path!") catch return; +} + +pub const Config = struct { + listen_port: usize, + db_path: []const u8, + levels: []const []const u8, +}; + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{ + .thread_safe = true, + }){}; + const allocator = gpa.allocator(); + + // scope everything so we can detect leaks. + { + // load config file + var file = try std.fs.cwd().openFile("config.json", .{}); + defer file.close(); + const json_str = try file.readToEndAlloc(allocator, 2048); + defer allocator.free(json_str); + const cfg_v = try json.parseFromSlice(Config, allocator, json_str, .{ .ignore_unknown_fields = true }); + defer cfg_v.deinit(); + const config = cfg_v.value; + + // set up HTTP listener + var listener = zap.Endpoint.Listener.init( + allocator, + .{ + .port = config.listen_port, + .on_request = onRequest, + .max_clients = 100_000, + .max_body_size = 100 * 1024 * 1024, + }, + ); + defer listener.deinit(); + + var score_web = try ScoreWeb.init(allocator, "/api/scores", config); + defer score_web.deinit(); + + try listener.register(score_web.endpoint()); + + try listener.listen(); + std.debug.print("Listening on 0.0.0.0:{d}\n", .{config.listen_port}); + + zap.start(.{ + .threads = 2, + .workers = 1, + }); + } + + const has_leaked = gpa.detectLeaks(); + std.log.debug("Has leaked: {}\n", .{has_leaked}); +} diff --git a/src/scores.zig b/src/scores.zig new file mode 100644 index 0000000..2e6fca2 --- /dev/null +++ b/src/scores.zig @@ -0,0 +1,147 @@ +const std = @import("std"); +const json = std.json; +const lmdb = @import("lmdb-zig"); +const pwhash = std.crypto.pwhash; + +allocator: std.mem.Allocator, +env: lmdb.Env, +players: lmdb.Db, +levels: std.StringHashMap(lmdb.Db), + +pub const Self = @This(); + +const ScoreAccessError = error{ + LevelNotfound, + NoScoreForPlayer, +}; + +pub const Difficulty = enum(u8) { + sweet, + salty, + spicy, + pungent, + + pub fn jsonStringify(self: *const Difficulty, jw: anytype) !void { + const n: u8 = @intFromEnum(self.*); + try jw.write(n); + } +}; + +pub const Score = packed struct { + score: u64 = 0, + time: u64 = 0, + difficulty: Difficulty = .salty, +}; + +pub fn init(allocator: std.mem.Allocator, db_path: []const u8, level_ids: []const []const u8) !Self { + const env = try lmdb.Env.init(db_path, .{ + .max_num_dbs = level_ids.len + 1, + }); + errdefer env.deinit(); + + const tx = try env.begin(.{}); + + const players = try tx.open("players", .{ .create_if_not_exists = true }); + + var levels = std.StringHashMap(lmdb.Db).init(allocator); + errdefer levels.deinit(); + + errdefer tx.deinit(); + for (level_ids) |lvl| { + if (levels.contains(lvl)) { + continue; + } + + const db_name = try std.mem.concatWithSentinel(allocator, u8, &.{ "level/", lvl }, 0); + defer allocator.free(db_name); + + const db = try tx.open(db_name, .{ .create_if_not_exists = true }); + errdefer db.close(env); + try levels.put(lvl, db); + } + try tx.commit(); + + return Self{ + .allocator = allocator, + .env = env, + .players = players, + .levels = levels, + }; +} + +pub fn deinit(self: *Self) void { + self.levels.deinit(); + + self.players.close(self.env); + + self.env.deinit(); +} + +/// Get a player's score for the given level. +pub fn getScore(self: *Self, level: []const u8, player: []const u8) !Score { + const tx = try self.env.begin(.{ .read_only = true }); + errdefer tx.deinit(); + const db = self.levels.get(level) orelse { + return error.LevelNotfound; + }; + const v = tx.get(db, player) catch |err| switch (err) { + error.not_found => return error.NoScoreForPlayer, + else => return err, + }; + const score = std.mem.bytesToValue(Score, v); + try tx.commit(); + return score; +} + +/// Set a player's score for the given level. +pub fn setScore(self: *Self, level: []const u8, player: []const u8, score: Score) !void { + const tx = try self.env.begin(.{}); + errdefer tx.deinit(); + const db = self.levels.get(level) orelse { + return error.LevelNotFound; + }; + try tx.put(db, player, std.mem.asBytes(&score), .{}); + try tx.commit(); +} + +/// Delete a player's score for the given level. +pub fn deleteScore(self: *Self, level: []const u8, player: []const u8) !void { + const tx = try self.env.begin(.{}); + errdefer tx.deinit(); + const db = self.levels.get(level) orelse { + return error.LevelNotFound; + }; + try tx.del(db, player, .key); + try tx.commit(); +} + +pub fn listLevels(self: *Self) !std.ArrayList([]const u8) { + var levels = try std.ArrayList([]const u8).initCapacity(self.allocator, self.levels.count()); + errdefer levels.deinit(); + + var lvl_iter = self.levels.keyIterator(); + while (lvl_iter.next()) |lvl| { + try levels.append(lvl.*); + } + return levels; +} + +pub fn listScores(self: *Self, level: []const u8) !std.StringArrayHashMap(Score) { + var scores = std.StringArrayHashMap(Score).init(self.allocator); + errdefer scores.deinit(); + + const tx = try self.env.begin(.{ .read_only = true }); + errdefer tx.deinit(); + const db = self.levels.get(level) orelse { + return error.LevelNotfound; + }; + + var cursor = try tx.cursor(db); + while (try cursor.next_key()) |entry| { + const score = std.mem.bytesToValue(Score, entry.val); + try scores.put(entry.key, score); + } + + try tx.commit(); + return scores; +} diff --git a/src/scoreweb.zig b/src/scoreweb.zig new file mode 100644 index 0000000..f4e82ee --- /dev/null +++ b/src/scoreweb.zig @@ -0,0 +1,222 @@ +const std = @import("std"); +const json = std.json; + +const zap = @import("zap"); + +const Scores = @import("scores.zig"); +const Config = @import("main.zig").Config; + +allocator: std.mem.Allocator, +parent_path_parts: usize, +scores: Scores, +ep: zap.Endpoint, + +const Self = @This(); + +pub fn init(allocator: std.mem.Allocator, path: []const u8, config: Config) !Self { + return Self{ + .allocator = allocator, + .scores = try Scores.init(allocator, config.db_path, config.levels), + .parent_path_parts = blk: { + var parts: usize = 0; + var split = std.mem.splitScalar(u8, path, '/'); + while (split.next()) |_| { + parts += 1; + } + break :blk parts; + }, + .ep = zap.Endpoint.init(.{ + .path = path, + .get = getScores, + .post = postScore, + .delete = deleteScore, + }), + }; +} + +pub fn deinit(self: *Self) void { + self.scores.deinit(); +} + +pub fn endpoint(self: *Self) *zap.Endpoint { + return &self.ep; +} + +fn partsOfPath(self: *Self, path: []const u8) !std.ArrayList([]const u8) { + var parts = std.ArrayList([]const u8).init(self.allocator); + errdefer parts.deinit(); + var split = std.mem.splitScalar(u8, path, '/'); + var n: usize = 0; + while (split.next()) |part| { + if (n >= self.parent_path_parts and part.len > 0) { + try parts.append(part); + } + n += 1; + } + return parts; +} + +fn serverError(r: zap.Request, body: []const u8) void { + r.setStatus(.internal_server_error); + r.sendBody(body) catch return; +} + +fn notFound(r: zap.Request, body: []const u8) void { + r.setStatus(.not_found); + r.sendBody(body) catch return; +} + +fn badRequest(r: zap.Request, body: []const u8) void { + r.setStatus(.bad_request); + r.sendBody(body) catch return; +} + +fn getScores(e: *zap.Endpoint, r: zap.Request) void { + const self: *Self = @fieldParentPtr("ep", e); + + if (r.path) |path| { + const parts = self.partsOfPath(path) catch { + return serverError(r, "Path processing error."); + }; + defer parts.deinit(); + + switch (parts.items.len) { + 0 => return self.listLevels(r), + 1 => return self.listScores(r, parts.items[0]), + 2 => return self.getScore(r, parts.items[0], parts.items[1]), + else => return badRequest(r, "Invalid request."), + } + } +} + +fn postScore(e: *zap.Endpoint, r: zap.Request) void { + const self: *Self = @fieldParentPtr("ep", e); + + if (r.path) |path| { + const parts = self.partsOfPath(path) catch { + return serverError(r, "Path processing error."); + }; + defer parts.deinit(); + + if (parts.items.len != 5) { + return badRequest(r, "Invalid request."); + } + + const level = parts.items[0]; + const player = parts.items[1]; + const score = parts.items[2]; + const time = parts.items[3]; + const difficulty = parts.items[4]; + + const new_score = Scores.Score{ + .score = std.fmt.parseInt(u64, score, 10) catch { + return badRequest(r, "Invalid score value."); + }, + .time = std.fmt.parseInt(u64, time, 10) catch { + return badRequest(r, "Invalid time value."); + }, + .difficulty = blk: { + const n = std.fmt.parseInt(u8, difficulty, 10) catch { + return badRequest(r, "Invalid difficulty value."); + }; + + if (n > @intFromEnum(Scores.Difficulty.pungent)) { + return badRequest(r, "Invalid difficulty value."); + } + + break :blk @enumFromInt(n); + }, + }; + + // const old_score = switch (self.scores.getScore(level, player)) { + // .ok => |s| s, + // .err => |err| switch (err) { + // error.NoScoreForPlayer => Scores.Score{}, + // error.LevelNotfound => return notFound(r, "Level not found."), + // else => return serverError(r, "Error getting score."), + // }, + // }; + + const old_score = self.scores.getScore(level, player) catch |err| switch (err) { + error.NoScoreForPlayer => { + self.scores.setScore(level, player, new_score) catch { + return serverError(r, "Error setting score."); + }; + r.setStatus(.accepted); + return r.sendBody("Score set!") catch return; + }, + error.LevelNotfound => return notFound(r, "Level not found."), + else => return serverError(r, "Error getting score."), + }; + + if (new_score.score < old_score.score) { + return; + } + if (new_score.score == old_score.score) { + if (@intFromEnum(new_score.difficulty) < @intFromEnum(old_score.difficulty)) { + return; + } + if (new_score.time >= old_score.time) { + return; + } + } + + self.scores.setScore(level, player, new_score) catch { + return serverError(r, "Error setting score."); + }; + r.setStatus(.accepted); + return r.sendBody("Score set!") catch return; + } +} + +fn deleteScore(e: *zap.Endpoint, r: zap.Request) void { + _ = e; + _ = r; +} + +fn listLevels(self: *Self, r: zap.Request) void { + const levels = self.scores.listLevels() catch { + return serverError(r, "Error listing levels."); + }; + defer levels.deinit(); + + const str = json.stringifyAlloc(self.allocator, levels.items, .{}) catch { + return serverError(r, "Error listing levels."); + }; + defer self.allocator.free(str); + + r.sendJson(str) catch return; +} + +fn listScores(self: *Self, r: zap.Request, level: []const u8) void { + var scores = self.scores.listScores(level) catch |err| switch (err) { + error.LevelNotfound => return notFound(r, "Level not found."), + else => return serverError(r, "Error listing scores."), + }; + defer scores.deinit(); + + var scores_json = json.ArrayHashMap(Scores.Score){ .map = scores.unmanaged }; + var str = std.ArrayList(u8).init(self.allocator); + defer str.deinit(); + var write_stream = json.writeStream(str.writer(), .{}); + scores_json.jsonStringify(&write_stream) catch { + return serverError(r, "Error listing scores."); + }; + + r.sendJson(str.items) catch return; +} + +fn getScore(self: *Self, r: zap.Request, level: []const u8, player: []const u8) void { + const score = self.scores.getScore(level, player) catch |err| switch (err) { + error.LevelNotfound => return notFound(r, "Level not found."), + error.NoScoreForPlayer => return notFound(r, "No score for player."), + else => return serverError(r, "Error getting score."), + }; + + const str = json.stringifyAlloc(self.allocator, score, .{}) catch { + return serverError(r, "Error getting score."); + }; + defer self.allocator.free(str); + + r.sendJson(str) catch return; +}