const std = @import("std"); const util = @import("util"); const db = @import("./db.zig"); const models = @import("./db/models.zig"); pub const DateTime = util.DateTime; pub const Uuid = util.Uuid; const PwHash = std.crypto.pwhash.scrypt; const pw_hash_params = PwHash.Params.interactive; const pw_hash_encoding = .phc; const pw_hash_buf_size = 128; const token_len = 20; const token_str_len = std.base64.standard.count(token_len); // Frees an api struct and its fields allocated from alloc pub fn free(alloc: std.mem.Allocator, val: anytype) void { switch (@typeInfo(@TypeOf(val))) { .Pointer => |ptr_info| switch (ptr_info.size) { .One => { free(alloc, val.*); alloc.destroy(val); }, .Slice => { for (val) |elem| free(alloc, elem); alloc.free(val); }, else => unreachable, }, .Struct => inline for (std.meta.fields(@TypeOf(val))) |f| free(alloc, @field(val, f.name)), .Array => for (val) |elem| free(alloc, elem), .Optional => if (val) |opt| free(alloc, opt), .Bool, .Int, .Float, .Enum => {}, else => unreachable, } } pub fn CreateInfo(comptime T: type) type { const t_fields = std.meta.fields(T); var fields: [t_fields.len - 1]std.builtin.Type.StructField = undefined; var count = 0; inline for (t_fields) |f| { if (std.mem.eql(u8, f.name, "id")) continue; fields[count] = f; count += 1; } return @Type(.{ .Struct = .{ .layout = .Auto, .fields = &fields, .decls = &[0]std.builtin.Type.Declaration{}, .is_tuple = false, } }); } fn reify(comptime T: type, id: Uuid, val: CreateInfo(T)) T { var result: T = undefined; result.id = id; inline for (std.meta.fields(CreateInfo(T))) |f| { @field(result, f.name) = @field(val, f.name); } return result; } pub const ApiContext = struct { user_context: struct { user: models.LocalUser, }, alloc: std.mem.Allocator, }; pub const NoteCreate = struct { content: []const u8, }; pub const RegistrationInfo = struct { username: []const u8, password: []const u8, email: ?[]const u8, }; pub const ApiServer = struct { prng: std.rand.DefaultPrng, db: db.Database, internal_alloc: std.mem.Allocator, pub fn init(alloc: std.mem.Allocator) !ApiServer { return ApiServer{ .prng = std.rand.DefaultPrng.init(@bitCast(u64, std.time.milliTimestamp())), .db = try db.Database.init(), .internal_alloc = alloc, }; } pub fn makeApiContext(self: *ApiServer, token: []const u8, alloc: std.mem.Allocator) !ApiContext { if (token.len == 0) return error.InvalidToken; const username = token; const user = (try self.db.getBy(models.LocalUser, .username, username, alloc)) orelse return error.InvalidToken; return ApiContext{ .user_context = .{ .user = user, }, .alloc = alloc, }; } pub fn createNoteUser(self: *ApiServer, info: NoteCreate, ctx: ApiContext) !models.Note { const id = Uuid.randV4(self.prng.random()); // TODO: check for dupes const note = models.Note{ .id = id, .author_id = ctx.user_context.user.actor_id.?, .content = info.content, .created_at = DateTime.now(), }; try self.db.insert(models.Note, note); return note; } pub fn register(self: *ApiServer, info: RegistrationInfo) !models.Actor { const actor_id = Uuid.randV4(self.prng.random()); const user_id = Uuid.randV4(self.prng.random()); // TODO: transaction? if (try self.db.existsWhereEq(models.LocalUser, .username, info.username)) { return error.UsernameUnavailable; } if (try self.db.existsWhereEq(models.Actor, .handle, info.username)) { return error.InconsistentDb; } // use internal alloc because necessary buffer is *big* var buf: [pw_hash_buf_size]u8 = undefined; const hash = try PwHash.strHash(info.password, .{ .allocator = self.internal_alloc, .params = pw_hash_params, .encoding = pw_hash_encoding }, &buf); const now = DateTime.now(); const actor = models.Actor{ .id = actor_id, .handle = info.username, .created_at = now, }; const user = models.LocalUser{ .id = user_id, .actor_id = actor_id, .username = info.username, .email = info.email, .hashed_password = hash, .password_changed_at = now, .created_at = now, }; try self.db.insert(models.Actor, actor); try self.db.insert(models.LocalUser, user); // TODO: return token instead return actor; } const LoginResult = struct { user_id: Uuid, token: [token_len]u8, issued_at: DateTime, }; pub fn login(self: *ApiServer, username: []const u8, password: []const u8, alloc: std.mem.Allocator) !LoginResult { // TODO: This gives away the existence of a user through a timing side channel. is that acceptable? const user_info = (try self.db.getBy(models.LocalUser, .username, username, alloc)) orelse return error.InvalidLogin; defer free(alloc, user_info); const Hash = std.crypto.pwhash.scrypt; Hash.strVerify(user_info.hashed_password, password, .{ .allocator = alloc }) catch |err| switch (err) { error.PasswordVerificationFailed => return error.InvalidLogin, else => return err, }; const token = try self.createToken(user_info); return LoginResult{ .user_id = user_info.id, .token = token.value, .issued_at = token.info.issued_at, }; //return (try self.db.getBy(models.Actor, .id, user_info.actor_id.?, alloc)) orelse unreachable; } const TokenResult = struct { info: models.Token, value: [token_len]u8, }; fn createToken(self: *ApiServer, user: models.LocalUser) !TokenResult { var token: [token_len]u8 = undefined; std.crypto.random.bytes(&token); var hash: [models.Token.hash_len]u8 = undefined; models.Token.HashFn.hash(&token, &hash, .{}); const db_token = models.Token{ .id = Uuid.randV4(self.prng.random()), .hash = .{ .data = hash }, .user_id = user.id, .issued_at = DateTime.now(), }; try self.db.insert(models.Token, db_token); return TokenResult{ .info = db_token, .value = token, }; } pub fn getNote(self: *ApiServer, id: Uuid, alloc: std.mem.Allocator) !?models.Note { return self.db.getBy(models.Note, .id, id, alloc); } pub fn getActor(self: *ApiServer, id: Uuid, alloc: std.mem.Allocator) !?models.Actor { return self.db.getBy(models.Actor, .id, id, alloc); } pub fn getActorByHandle(self: *ApiServer, handle: []const u8, alloc: std.mem.Allocator) !?models.Actor { return self.db.getBy(models.Actor, .handle, handle, alloc); } pub fn react(self: *ApiServer, note_id: Uuid, ctx: ApiContext) !void { const id = Uuid.randV4(self.prng.random()); try self.db.insert(models.Reaction, .{ .id = id, .note_id = note_id, .reactor_id = ctx.user_context.user.actor_id.?, .created_at = DateTime.now() }); } pub fn listReacts(self: *ApiServer, note_id: Uuid, ctx: ApiContext) ![]models.Reaction { return try self.db.getWhereEq(models.Reaction, .note_id, note_id, ctx.alloc); } };