Player registration system and admin deletion commands.
This commit is contained in:
parent
a818d383ec
commit
33f7e16f86
5 changed files with 370 additions and 21 deletions
|
@ -1,9 +1,6 @@
|
|||
{
|
||||
"listen_port": 3000,
|
||||
"db_path": "scores/",
|
||||
"levels": [
|
||||
"hills",
|
||||
"canopy",
|
||||
"mountain"
|
||||
]
|
||||
"admin_pw_hash": "",
|
||||
"levels": []
|
||||
}
|
||||
|
|
54
src/main.zig
54
src/main.zig
|
@ -1,5 +1,6 @@
|
|||
const std = @import("std");
|
||||
const json = std.json;
|
||||
const argon2 = std.crypto.pwhash.argon2;
|
||||
|
||||
const clap = @import("clap");
|
||||
const zap = @import("zap");
|
||||
|
@ -7,6 +8,7 @@ const lmdb = @import("lmdb-zig");
|
|||
|
||||
const Scores = @import("scores.zig");
|
||||
const ScoreWeb = @import("scoreweb.zig");
|
||||
const PlayerWeb = @import("userweb.zig");
|
||||
|
||||
/// fallback request handler
|
||||
fn onRequest(r: zap.Request) void {
|
||||
|
@ -17,6 +19,7 @@ fn onRequest(r: zap.Request) void {
|
|||
pub const Config = struct {
|
||||
listen_port: usize,
|
||||
db_path: []const u8,
|
||||
admin_pw_hash: []const u8,
|
||||
levels: []const []const u8,
|
||||
};
|
||||
|
||||
|
@ -29,13 +32,50 @@ pub fn main() !void {
|
|||
// scope everything so we can detect leaks.
|
||||
{
|
||||
// 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();
|
||||
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;
|
||||
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
|
||||
var listener = zap.Endpoint.Listener.init(
|
||||
|
@ -49,11 +89,17 @@ pub fn main() !void {
|
|||
);
|
||||
defer listener.deinit();
|
||||
|
||||
var score_web = try ScoreWeb.init(allocator, "/api/scores", config);
|
||||
defer score_web.deinit();
|
||||
var scores = try Scores.init(allocator, config.db_path, config.levels);
|
||||
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());
|
||||
|
||||
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();
|
||||
std.debug.print("Listening on 0.0.0.0:{d}\n", .{config.listen_port});
|
||||
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
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,
|
||||
|
@ -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 {
|
||||
std.fs.cwd().makeDir(db_path) catch |err| {
|
||||
if (err != error.PathAlreadyExists) {
|
||||
return err;
|
||||
}
|
||||
};
|
||||
|
||||
const env = try lmdb.Env.init(db_path, .{
|
||||
.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();
|
||||
}
|
||||
|
||||
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) {
|
||||
var levels = try std.ArrayList([]const u8).initCapacity(self.allocator, self.levels.count());
|
||||
errdefer levels.deinit();
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
const std = @import("std");
|
||||
const json = std.json;
|
||||
const argon2 = std.crypto.pwhash.argon2;
|
||||
|
||||
const zap = @import("zap");
|
||||
|
||||
|
@ -8,15 +9,18 @@ const Config = @import("main.zig").Config;
|
|||
|
||||
allocator: std.mem.Allocator,
|
||||
parent_path_parts: usize,
|
||||
scores: Scores,
|
||||
scores: *Scores,
|
||||
ep: zap.Endpoint,
|
||||
admin_pw_hash: []const u8,
|
||||
|
||||
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{
|
||||
.allocator = allocator,
|
||||
.scores = try Scores.init(allocator, config.db_path, config.levels),
|
||||
.scores = scores,
|
||||
.parent_path_parts = blk: {
|
||||
var parts: usize = 0;
|
||||
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,
|
||||
.delete = deleteScore,
|
||||
}),
|
||||
.admin_pw_hash = admin_pw_hash,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Self) void {
|
||||
self.scores.deinit();
|
||||
_ = self;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
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 difficulty = parts.items[4];
|
||||
|
||||
if (player.len > MAX_PLAYER_NAME) {
|
||||
return badRequest(r, "Player name too long.");
|
||||
}
|
||||
|
||||
const new_score = Scores.Score{
|
||||
.score = std.fmt.parseInt(u64, score, 10) catch {
|
||||
return badRequest(r, "Invalid score value.");
|
||||
|
@ -153,13 +174,12 @@ fn postScore(e: *zap.Endpoint, r: zap.Request) void {
|
|||
return;
|
||||
}
|
||||
if (new_score.score == old_score.score) {
|
||||
if (@intFromEnum(new_score.difficulty) < @intFromEnum(old_score.difficulty)) {
|
||||
return;
|
||||
}
|
||||
if (@intFromEnum(new_score.difficulty) <= @intFromEnum(old_score.difficulty)) {
|
||||
if (new_score.time >= old_score.time) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.scores.setScore(level, player, new_score) catch {
|
||||
return serverError(r, "Error setting score.");
|
||||
|
@ -170,8 +190,35 @@ fn postScore(e: *zap.Endpoint, r: zap.Request) void {
|
|||
}
|
||||
|
||||
fn deleteScore(e: *zap.Endpoint, r: zap.Request) void {
|
||||
_ = e;
|
||||
_ = r;
|
||||
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();
|
||||
|
||||
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 {
|
||||
|
@ -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 {
|
||||
if (player.len > MAX_PLAYER_NAME) {
|
||||
return badRequest(r, "Player name too long.");
|
||||
}
|
||||
|
||||
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."),
|
||||
|
|
223
src/userweb.zig
Normal file
223
src/userweb.zig
Normal 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;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue