diff --git a/config.json b/config.json index de0718a..cd20b33 100644 --- a/config.json +++ b/config.json @@ -1,9 +1,6 @@ { "listen_port": 3000, "db_path": "scores/", - "levels": [ - "hills", - "canopy", - "mountain" - ] + "admin_pw_hash": "", + "levels": [] } diff --git a/src/main.zig b/src/main.zig index 465c635..8186e07 100644 --- a/src/main.zig +++ b/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}); diff --git a/src/scores.zig b/src/scores.zig index 2e6fca2..3c9eb66 100644 --- a/src/scores.zig +++ b/src/scores.zig @@ -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(); diff --git a/src/scoreweb.zig b/src/scoreweb.zig index f4e82ee..80d1cad 100644 --- a/src/scoreweb.zig +++ b/src/scoreweb.zig @@ -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,11 +174,10 @@ 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 (new_score.time >= old_score.time) { - return; + if (@intFromEnum(new_score.difficulty) <= @intFromEnum(old_score.difficulty)) { + 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 { - _ = 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."), diff --git a/src/userweb.zig b/src/userweb.zig new file mode 100644 index 0000000..e631f37 --- /dev/null +++ b/src/userweb.zig @@ -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; +}