Initial commit, basic unauthenticated score system
This commit is contained in:
commit
a818d383ec
7 changed files with 596 additions and 0 deletions
68
src/main.zig
Normal file
68
src/main.zig
Normal 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
147
src/scores.zig
Normal 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
222
src/scoreweb.zig
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue