Generate invite codes

This commit is contained in:
jaina heartles 2022-07-26 22:02:09 -07:00
parent a020199773
commit 01ef4427f5
7 changed files with 128 additions and 2 deletions

View File

@ -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());
}
};
}

View File

@ -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) .{

View File

@ -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);
}

View File

@ -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 {

View File

@ -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,
};

View File

@ -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),
},
};

View File

@ -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,
};