diff --git a/src/main/api.zig b/src/main/api.zig index a846b97..8c99df5 100644 --- a/src/main/api.zig +++ b/src/main/api.zig @@ -15,6 +15,9 @@ const pw_hash_buf_size = 128; const token_len = 20; const token_str_len = std.base64.standard.Encoder.calcSize(token_len); +const invite_code_len = 16; +const invite_code_str_len = std.base64.url_safe.Encoder.calcSize(invite_code_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))) { @@ -291,5 +294,49 @@ fn ApiConn(comptime DbConn: type) type { .value = token, }; } + + const InviteOptions = struct { + name: []const u8 = "", + max_uses: ?i64 = null, + lifetime: ?i64 = null, // unix seconds, TODO make a TimeSpan type + }; + pub fn createInvite(self: *Self, options: InviteOptions) !models.Invite { + const id = Uuid.randV4(prng.random()); + + const user_id = (try self.getAuthenticatedUser()).id; + + var code: [invite_code_len]u8 = undefined; + std.crypto.random.bytes(&code); + + var code_str = try self.arena.allocator().alloc(u8, invite_code_str_len); + _ = std.base64.url_safe.Encoder.encode(code_str, &code); + + const now = DateTime.now(); + const expires_at = if (options.lifetime) |lifetime| DateTime{ + .seconds_since_epoch = lifetime + now.seconds_since_epoch, + } else null; + + const invite = models.Invite{ + .id = id, + + .name = try self.arena.allocator().dupe(u8, options.name), + .created_by = user_id, + .invite_code = code_str, + + .uses = 0, + .max_uses = options.max_uses, + + .created_at = now, + .expires_at = expires_at, + }; + + try self.db.insert(models.Invite, invite); + + return invite; + } + + pub fn getInvite(self: *Self, id: Uuid) !?models.Invite { + return self.db.getBy(models.Invite, .id, id, self.arena.allocator()); + } }; } diff --git a/src/main/controllers.zig b/src/main/controllers.zig index 9411cc7..4c6263e 100644 --- a/src/main/controllers.zig +++ b/src/main/controllers.zig @@ -8,6 +8,9 @@ const Uuid = @import("util").Uuid; pub const auth = @import("./controllers/auth.zig"); pub const notes = @import("./controllers/notes.zig"); pub const actors = @import("./controllers/actors.zig"); +pub const admin = struct { + pub const invites = @import("./controllers/admin/invites.zig"); +}; pub const utils = struct { const json_options = if (builtin.mode == .Debug) .{ diff --git a/src/main/controllers/admin/invites.zig b/src/main/controllers/admin/invites.zig new file mode 100644 index 0000000..9e15a90 --- /dev/null +++ b/src/main/controllers/admin/invites.zig @@ -0,0 +1,29 @@ +const root = @import("root"); +const http = @import("http"); +const Uuid = @import("util").Uuid; + +const utils = @import("../../controllers.zig").utils; +const NoteCreateInfo = @import("../../api.zig").NoteCreateInfo; + +const RequestServer = root.RequestServer; +const RouteArgs = http.RouteArgs; + +pub fn create(srv: *RequestServer, ctx: *http.server.Context, _: RouteArgs) !void { + var api = try utils.getApiConn(srv, ctx); + defer api.close(); + + const invite = try api.createInvite(.{}); + + try utils.respondJson(ctx, .created, invite); +} + +pub fn get(srv: *RequestServer, ctx: *http.server.Context, args: RouteArgs) !void { + const id_str = args.get("id") orelse return error.NotFound; + const id = Uuid.parse(id_str) catch return utils.respondError(ctx, .bad_request, "Invalid UUID"); + var api = try utils.getApiConn(srv, ctx); + defer api.close(); + + const invite = (try api.getInvite(id)) orelse return utils.respondError(ctx, .not_found, "Invite not found"); + + try utils.respondJson(ctx, .ok, invite); +} diff --git a/src/main/db.zig b/src/main/db.zig index ee0d7ae..a18850a 100644 --- a/src/main/db.zig +++ b/src/main/db.zig @@ -15,6 +15,7 @@ fn tableName(comptime T: type) String { models.Reaction => "reaction", models.LocalUser => "local_user", models.Token => "token", + models.Invite => "invite", else => unreachable, }; } @@ -111,7 +112,7 @@ fn bind(stmt: sql.PreparedStmt, idx: u15, val: anytype) !void { else => |T| switch (@typeInfo(T)) { .Optional => if (val) |v| bind(stmt, idx, v) else stmt.bindNull(idx), .Struct, .Union, .Enum, .Opaque => if (@hasDecl(T, "bindToSql")) val.bindToSql(stmt, idx), - else => @compileError("Unknown Type" ++ @typeName(T)), + else => @compileError("Unknown Type " ++ @typeName(T)), }, }; } @@ -203,6 +204,21 @@ pub const Database = struct { \\ \\ PRIMARY KEY(id) \\) STRICT; + , + \\CREATE TABLE IF NOT EXISTS + \\invite( + \\ id TEXT NOT NULL PRIMARY KEY, + \\ + \\ name TEXT NOT NULL, + \\ invite_code TEXT NOT NULL, + \\ created_by TEXT NOT NULL REFERENCES local_user(id), + \\ + \\ uses INTEGER NOT NULL, + \\ max_uses INTEGER, + \\ + \\ created_at INTEGER NOT NULL, + \\ expires_at INTEGER + \\) STRICT; }; pub fn init() !Database { diff --git a/src/main/db/models.zig b/src/main/db/models.zig index e3261f0..a885726 100644 --- a/src/main/db/models.zig +++ b/src/main/db/models.zig @@ -20,6 +20,18 @@ pub fn ByteArray(comptime n: usize) type { _ = try row.getBlob(idx, &self.data); return self; } + + pub fn format(self: Self, comptime _: []const u8, _: std.fmt.FormatOptions, writer: anytype) !void { + const Encoder = std.base64.standard.Encoder; + const buf_len = comptime Encoder.calcSize(n); + var buf: [buf_len]u8 = undefined; + const str = Encoder.encode(&buf, &self.data); + try std.fmt.format(writer, "{s}", .{str}); + } + + pub fn stringifyJson(self: Self, _: std.json.StringifyOptions, writer: anytype) !void { + try self.format("{}", .{}, writer); + } }; } @@ -90,3 +102,17 @@ pub const Token = struct { user_id: Ref(LocalUser), issued_at: DateTime, }; + +pub const Invite = struct { + id: Uuid, + + name: []const u8, + invite_code: []const u8, + created_by: Ref(LocalUser), + + uses: i64, + max_uses: ?i64, + + created_at: DateTime, + expires_at: ?DateTime, +}; diff --git a/src/main/main.zig b/src/main/main.zig index ecfb6e8..ef329b6 100644 --- a/src/main/main.zig +++ b/src/main/main.zig @@ -25,6 +25,9 @@ const router = Router{ Route.new(.POST, "/notes/:id/reacts", c.notes.reacts.create), Route.new(.GET, "/actors/:id", c.actors.get), + + Route.new(.POST, "/admin/invites", c.admin.invites.create), + Route.new(.GET, "/admin/invites/:id", c.admin.invites.get), }, }; diff --git a/src/sql/lib.zig b/src/sql/lib.zig index d6295f5..5a928d5 100644 --- a/src/sql/lib.zig +++ b/src/sql/lib.zig @@ -113,7 +113,9 @@ pub const PreparedStmt = struct { } pub fn bindText(self: PreparedStmt, idx: u15, str: []const u8) !void { - return switch (c.sqlite3_bind_text(self.stmt, idx, str.ptr, @intCast(c_int, str.len), c.SQLITE_TRANSIENT)) { + // Work around potential null pointer in empty string + const eff_str = if (str.len == 0) (" ")[0..0] else str; + return switch (c.sqlite3_bind_text(self.stmt, idx, eff_str.ptr, @intCast(c_int, eff_str.len), c.SQLITE_TRANSIENT)) { c.SQLITE_OK => {}, else => error.UnknownError, };