Player registration system and admin deletion commands.

This commit is contained in:
Haze Weathers 2024-11-30 21:39:17 -05:00
parent a818d383ec
commit 33f7e16f86
5 changed files with 370 additions and 21 deletions

View file

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

View file

@ -1,5 +1,6 @@
const std = @import("std"); const std = @import("std");
const json = std.json; const json = std.json;
const argon2 = std.crypto.pwhash.argon2;
const clap = @import("clap"); const clap = @import("clap");
const zap = @import("zap"); const zap = @import("zap");
@ -7,6 +8,7 @@ const lmdb = @import("lmdb-zig");
const Scores = @import("scores.zig"); const Scores = @import("scores.zig");
const ScoreWeb = @import("scoreweb.zig"); const ScoreWeb = @import("scoreweb.zig");
const PlayerWeb = @import("userweb.zig");
/// fallback request handler /// fallback request handler
fn onRequest(r: zap.Request) void { fn onRequest(r: zap.Request) void {
@ -17,6 +19,7 @@ fn onRequest(r: zap.Request) void {
pub const Config = struct { pub const Config = struct {
listen_port: usize, listen_port: usize,
db_path: []const u8, db_path: []const u8,
admin_pw_hash: []const u8,
levels: []const []const u8, levels: []const []const u8,
}; };
@ -29,13 +32,50 @@ pub fn main() !void {
// scope everything so we can detect leaks. // scope everything so we can detect leaks.
{ {
// load config file // load config file
var file = try std.fs.cwd().openFile("config.json", .{}); var file = try std.fs.cwd().openFile(
"config.json",
std.fs.File.OpenFlags{
.mode = .read_write,
},
);
defer file.close(); defer file.close();
const json_str = try file.readToEndAlloc(allocator, 2048); const json_str = try file.readToEndAlloc(allocator, 2048);
defer allocator.free(json_str); defer allocator.free(json_str);
const cfg_v = try json.parseFromSlice(Config, allocator, json_str, .{ .ignore_unknown_fields = true }); const cfg_v = try json.parseFromSlice(Config, allocator, json_str, .{ .ignore_unknown_fields = true });
defer cfg_v.deinit(); defer cfg_v.deinit();
const config = cfg_v.value; var config = cfg_v.value;
// generate and store new admin password if one is not stored in config.
var hash_buf: [256]u8 = undefined;
if (config.admin_pw_hash.len == 0) {
try std.io.getStdOut().writeAll("Input admin password: ");
const stdin = std.io.getStdIn().reader();
var buf: [64]u8 = undefined;
if (try stdin.readUntilDelimiterOrEof(buf[0..], '\n')) |input| {
// const salt = std.crypto.random.int(u128);
const hash = try argon2.strHash(
input,
.{
.allocator = allocator,
.mode = .argon2id,
.params = .{
.t = 3,
.m = 1 << 16,
.p = 1,
},
},
hash_buf[0..],
);
config.admin_pw_hash = hash;
try file.seekTo(0);
try json.stringify(
config,
.{ .whitespace = .indent_2 },
file.writer(),
);
}
}
// set up HTTP listener // set up HTTP listener
var listener = zap.Endpoint.Listener.init( var listener = zap.Endpoint.Listener.init(
@ -49,11 +89,17 @@ pub fn main() !void {
); );
defer listener.deinit(); defer listener.deinit();
var score_web = try ScoreWeb.init(allocator, "/api/scores", config); var scores = try Scores.init(allocator, config.db_path, config.levels);
defer score_web.deinit(); defer scores.deinit();
var score_web = try ScoreWeb.init(allocator, "/api/scores", &scores, config.admin_pw_hash);
defer score_web.deinit();
try listener.register(score_web.endpoint()); try listener.register(score_web.endpoint());
var player_web = try PlayerWeb.init(allocator, "/api/players", &scores, config.admin_pw_hash);
defer player_web.deinit();
try listener.register(player_web.endpoint());
try listener.listen(); try listener.listen();
std.debug.print("Listening on 0.0.0.0:{d}\n", .{config.listen_port}); std.debug.print("Listening on 0.0.0.0:{d}\n", .{config.listen_port});

View file

@ -1,7 +1,6 @@
const std = @import("std"); const std = @import("std");
const json = std.json; const json = std.json;
const lmdb = @import("lmdb-zig"); const lmdb = @import("lmdb-zig");
const pwhash = std.crypto.pwhash;
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
env: lmdb.Env, env: lmdb.Env,
@ -34,6 +33,12 @@ pub const Score = packed struct {
}; };
pub fn init(allocator: std.mem.Allocator, db_path: []const u8, level_ids: []const []const u8) !Self { pub fn init(allocator: std.mem.Allocator, db_path: []const u8, level_ids: []const []const u8) !Self {
std.fs.cwd().makeDir(db_path) catch |err| {
if (err != error.PathAlreadyExists) {
return err;
}
};
const env = try lmdb.Env.init(db_path, .{ const env = try lmdb.Env.init(db_path, .{
.max_num_dbs = level_ids.len + 1, .max_num_dbs = level_ids.len + 1,
}); });
@ -115,6 +120,33 @@ pub fn deleteScore(self: *Self, level: []const u8, player: []const u8) !void {
try tx.commit(); try tx.commit();
} }
pub fn clearLevel(self: *Self, level: []const u8) !void {
const tx = try self.env.begin(.{});
errdefer tx.deinit();
const db = self.levels.get(level) orelse {
return error.LevelNotfound;
};
try tx.drop(db, .empty);
try tx.commit();
}
pub fn deletePlayer(self: *Self, player: []const u8) !void {
const tx = try self.env.begin(.{});
errdefer tx.deinit();
try tx.del(self.players, player, .key);
var lvl_iter = self.levels.valueIterator();
while (lvl_iter.next()) |lvl| {
tx.del(lvl.*, player, .key) catch |err| {
if (err != error.not_found) {
return err;
}
};
}
try tx.commit();
}
pub fn listLevels(self: *Self) !std.ArrayList([]const u8) { pub fn listLevels(self: *Self) !std.ArrayList([]const u8) {
var levels = try std.ArrayList([]const u8).initCapacity(self.allocator, self.levels.count()); var levels = try std.ArrayList([]const u8).initCapacity(self.allocator, self.levels.count());
errdefer levels.deinit(); errdefer levels.deinit();

View file

@ -1,5 +1,6 @@
const std = @import("std"); const std = @import("std");
const json = std.json; const json = std.json;
const argon2 = std.crypto.pwhash.argon2;
const zap = @import("zap"); const zap = @import("zap");
@ -8,15 +9,18 @@ const Config = @import("main.zig").Config;
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
parent_path_parts: usize, parent_path_parts: usize,
scores: Scores, scores: *Scores,
ep: zap.Endpoint, ep: zap.Endpoint,
admin_pw_hash: []const u8,
const Self = @This(); const Self = @This();
pub fn init(allocator: std.mem.Allocator, path: []const u8, config: Config) !Self { const MAX_PLAYER_NAME = 10;
pub fn init(allocator: std.mem.Allocator, path: []const u8, scores: *Scores, admin_pw_hash: []const u8) !Self {
return Self{ return Self{
.allocator = allocator, .allocator = allocator,
.scores = try Scores.init(allocator, config.db_path, config.levels), .scores = scores,
.parent_path_parts = blk: { .parent_path_parts = blk: {
var parts: usize = 0; var parts: usize = 0;
var split = std.mem.splitScalar(u8, path, '/'); var split = std.mem.splitScalar(u8, path, '/');
@ -31,11 +35,12 @@ pub fn init(allocator: std.mem.Allocator, path: []const u8, config: Config) !Sel
.post = postScore, .post = postScore,
.delete = deleteScore, .delete = deleteScore,
}), }),
.admin_pw_hash = admin_pw_hash,
}; };
} }
pub fn deinit(self: *Self) void { pub fn deinit(self: *Self) void {
self.scores.deinit(); _ = self;
} }
pub fn endpoint(self: *Self) *zap.Endpoint { pub fn endpoint(self: *Self) *zap.Endpoint {
@ -71,6 +76,18 @@ fn badRequest(r: zap.Request, body: []const u8) void {
r.sendBody(body) catch return; r.sendBody(body) catch return;
} }
fn isAuthorized(allocator: std.mem.Allocator, r: zap.Request, hash: []const u8) bool {
if (r.getHeader("authorization")) |pw| {
argon2.strVerify(hash, pw, .{ .allocator = allocator }) catch {
return false;
};
return true;
}
return false;
}
fn getScores(e: *zap.Endpoint, r: zap.Request) void { fn getScores(e: *zap.Endpoint, r: zap.Request) void {
const self: *Self = @fieldParentPtr("ep", e); const self: *Self = @fieldParentPtr("ep", e);
@ -108,6 +125,10 @@ fn postScore(e: *zap.Endpoint, r: zap.Request) void {
const time = parts.items[3]; const time = parts.items[3];
const difficulty = parts.items[4]; const difficulty = parts.items[4];
if (player.len > MAX_PLAYER_NAME) {
return badRequest(r, "Player name too long.");
}
const new_score = Scores.Score{ const new_score = Scores.Score{
.score = std.fmt.parseInt(u64, score, 10) catch { .score = std.fmt.parseInt(u64, score, 10) catch {
return badRequest(r, "Invalid score value."); return badRequest(r, "Invalid score value.");
@ -153,11 +174,10 @@ fn postScore(e: *zap.Endpoint, r: zap.Request) void {
return; return;
} }
if (new_score.score == old_score.score) { if (new_score.score == old_score.score) {
if (@intFromEnum(new_score.difficulty) < @intFromEnum(old_score.difficulty)) { if (@intFromEnum(new_score.difficulty) <= @intFromEnum(old_score.difficulty)) {
return; if (new_score.time >= old_score.time) {
} return;
if (new_score.time >= old_score.time) { }
return;
} }
} }
@ -170,8 +190,35 @@ fn postScore(e: *zap.Endpoint, r: zap.Request) void {
} }
fn deleteScore(e: *zap.Endpoint, r: zap.Request) void { fn deleteScore(e: *zap.Endpoint, r: zap.Request) void {
_ = e; const self: *Self = @fieldParentPtr("ep", e);
_ = r;
if (!isAuthorized(self.allocator, r, self.admin_pw_hash)) {
r.setStatus(.unauthorized);
return r.sendBody("Not authorized.") catch return;
}
if (r.path) |path| {
const parts = self.partsOfPath(path) catch {
return serverError(r, "Path processing error.");
};
defer parts.deinit();
switch (parts.items.len) {
1 => {
self.scores.clearLevel(parts.items[0]) catch {
return serverError(r, "Error clearing scores.");
};
r.sendBody("Level's scores cleared!") catch return;
},
2 => {
self.scores.deleteScore(parts.items[0], parts.items[1]) catch {
return serverError(r, "Error deleting score.");
};
r.sendBody("Score deleted!") catch return;
},
else => return badRequest(r, "Invalid request."),
}
}
} }
fn listLevels(self: *Self, r: zap.Request) void { fn listLevels(self: *Self, r: zap.Request) void {
@ -207,6 +254,10 @@ fn listScores(self: *Self, r: zap.Request, level: []const u8) void {
} }
fn getScore(self: *Self, r: zap.Request, level: []const u8, player: []const u8) void { fn getScore(self: *Self, r: zap.Request, level: []const u8, player: []const u8) void {
if (player.len > MAX_PLAYER_NAME) {
return badRequest(r, "Player name too long.");
}
const score = self.scores.getScore(level, player) catch |err| switch (err) { const score = self.scores.getScore(level, player) catch |err| switch (err) {
error.LevelNotfound => return notFound(r, "Level not found."), error.LevelNotfound => return notFound(r, "Level not found."),
error.NoScoreForPlayer => return notFound(r, "No score for player."), error.NoScoreForPlayer => return notFound(r, "No score for player."),

223
src/userweb.zig Normal file
View file

@ -0,0 +1,223 @@
const std = @import("std");
const json = std.json;
const argon2 = std.crypto.pwhash.argon2;
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,
admin_pw_hash: []const u8,
const Self = @This();
const MAX_PLAYER_NAME = 10;
pub fn init(allocator: std.mem.Allocator, path: []const u8, scores: *Scores, admin_pw_hash: []const u8) !Self {
return Self{
.allocator = allocator,
.scores = scores,
.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 = getPlayers,
.post = postPlayer,
.delete = deletePlayer,
}),
.admin_pw_hash = admin_pw_hash,
};
}
pub fn deinit(self: *Self) void {
_ = self;
}
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 isAuthorized(allocator: std.mem.Allocator, r: zap.Request, hash: []const u8) bool {
if (r.getHeader("authorization")) |pw| {
argon2.strVerify(hash, pw, .{ .allocator = allocator }) catch {
return false;
};
return true;
}
return false;
}
fn getPlayers(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.listPlayers(r) catch {
return serverError(r, "Error listing players.");
},
1 => return self.getPlayerId(r, parts.items[0]) catch {
return serverError(r, "Error geting player ID");
},
else => return badRequest(r, "Invalid request."),
}
}
}
fn postPlayer(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) {
2 => return self.touchPlayer(r, parts.items[0], parts.items[1]) catch {
return serverError(r, "Error registering player.");
},
else => return badRequest(r, "Invalid request."),
}
}
}
fn deletePlayer(e: *zap.Endpoint, r: zap.Request) void {
const self: *Self = @fieldParentPtr("ep", e);
if (!isAuthorized(self.allocator, r, self.admin_pw_hash)) {
r.setStatus(.unauthorized);
return r.sendBody("Not authorized.") catch return;
}
if (r.path) |path| {
const parts = self.partsOfPath(path) catch {
return serverError(r, "Path processing error.");
};
defer parts.deinit();
if (parts.items.len != 1) {
return badRequest(r, "Invalid request.");
}
const player = parts.items[0];
if (player.len > MAX_PLAYER_NAME) {
return badRequest(r, "Player name too long.");
}
self.scores.deletePlayer(player) catch |err| {
if (err != error.not_found) {
return serverError(r, "Error deleting player.");
}
};
r.sendBody("Player deleted!") catch return;
}
}
fn listPlayers(self: *Self, r: zap.Request) !void {
var players = std.ArrayList([]const u8).init(self.allocator);
defer players.deinit();
const tx = try self.scores.env.begin(.{ .read_only = true });
errdefer tx.deinit();
const db = self.scores.players;
var cursor = try tx.cursor(db);
while (try cursor.next_key()) |entry| {
try players.append(entry.key);
}
try tx.commit();
const str = try json.stringifyAlloc(self.allocator, players.items, .{});
defer self.allocator.free(str);
r.sendJson(str) catch return;
}
fn getPlayerId(self: *Self, r: zap.Request, player: []const u8) !void {
if (player.len > MAX_PLAYER_NAME) {
return badRequest(r, "Player name too long.");
}
const tx = try self.scores.env.begin(.{ .read_only = true });
errdefer tx.deinit();
const db = self.scores.players;
const id = std.mem.bytesToValue(
u32,
tx.get(db, player) catch |err| switch (err) {
error.not_found => {
try tx.commit();
return notFound(r, "Player not registered.");
},
else => return err,
},
);
try tx.commit();
var str = std.ArrayList(u8).init(self.allocator);
defer str.deinit();
try std.fmt.formatInt(id, 10, .lower, .{}, str.writer());
r.sendJson(str.items) catch return;
}
fn touchPlayer(self: *Self, r: zap.Request, player: []const u8, id_str: []const u8) !void {
if (player.len > MAX_PLAYER_NAME) {
return badRequest(r, "Player name too long.");
}
const tx = try self.scores.env.begin(.{});
errdefer tx.deinit();
const db = self.scores.players;
const id = std.fmt.parseInt(u32, id_str, 10) catch {
tx.deinit();
return badRequest(r, "Invalid player id.");
};
try tx.put(db, player, std.mem.asBytes(&id), .{});
try tx.commit();
r.setStatus(.accepted);
r.sendBody("Player registered!") catch return;
}