Initial commit, basic unauthenticated score system

This commit is contained in:
Haze Weathers 2024-11-29 06:33:08 -05:00
commit a818d383ec
7 changed files with 596 additions and 0 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
# zig build artifacts
.zig-cache/
zig-out/
# testing artifacts
scores/

97
build.zig Normal file
View file

@ -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);
}

47
build.zig.zon Normal file
View file

@ -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 <url>`, 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",
},
}

9
config.json Normal file
View file

@ -0,0 +1,9 @@
{
"listen_port": 3000,
"db_path": "scores/",
"levels": [
"hills",
"canopy",
"mountain"
]
}

68
src/main.zig Normal file
View file

@ -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});
}

147
src/scores.zig Normal file
View file

@ -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;
}

222
src/scoreweb.zig Normal file
View file

@ -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;
}