This commit is contained in:
jaina heartles 2022-09-07 22:10:58 -07:00
parent 075b922ef5
commit a28f9fd38c
8 changed files with 184 additions and 74 deletions

View file

@ -23,6 +23,18 @@ const services = struct {
const communities = @import("./api/communities.zig"); const communities = @import("./api/communities.zig");
const users = @import("./api/users.zig"); const users = @import("./api/users.zig");
const auth = @import("./api/auth.zig"); const auth = @import("./api/auth.zig");
const invites = @import("./api/invites.zig");
};
pub const InviteRequest = struct {
pub const Type = services.invites.InviteType;
name: ?[]const u8 = null,
expires_at: ?DateTime = null, // TODO: Change this to lifespan
max_uses: ?usize = null,
invite_type: Type = .user, // must be user unless the creator is an admin
to_community: ?[]const u8 = null, // only valid on admin community
}; };
// Frees an api struct and its fields allocated from alloc // Frees an api struct and its fields allocated from alloc
@ -70,13 +82,6 @@ pub const LoginResult = struct {
issued_at: DateTime, issued_at: DateTime,
}; };
pub const InviteOptions = struct {
name: []const u8 = "",
max_uses: ?i64 = null,
lifetime: ?i64 = null, // unix seconds, TODO make a TimeSpan type
to_community: ?[]const u8,
};
threadlocal var prng: std.rand.DefaultPrng = undefined; threadlocal var prng: std.rand.DefaultPrng = undefined;
pub fn initThreadPrng(seed: u64) void { pub fn initThreadPrng(seed: u64) void {
@ -183,6 +188,11 @@ fn ApiConn(comptime DbConn: type) type {
self.arena.deinit(); self.arena.deinit();
} }
fn isAdmin(self: *Self) bool {
// TODO
return self.user_id != null and self.community_id == null;
}
pub fn login(self: *Self, username: []const u8, password: []const u8) !LoginResult { pub fn login(self: *Self, username: []const u8, password: []const u8) !LoginResult {
const user_id = (try services.users.lookupByUsername(&self.db, username, self.community_id)) orelse return error.InvalidLogin; const user_id = (try services.users.lookupByUsername(&self.db, username, self.community_id)) orelse return error.InvalidLogin;
try services.auth.passwords.verify(&self.db, user_id, password, self.internal_alloc); try services.auth.passwords.verify(&self.db, user_id, password, self.internal_alloc);
@ -216,67 +226,36 @@ fn ApiConn(comptime DbConn: type) type {
} }
pub fn createCommunity(self: *Self, origin: []const u8) !services.communities.Community { pub fn createCommunity(self: *Self, origin: []const u8) !services.communities.Community {
if (self.community_id != null) { if (!self.isAdmin()) {
return error.NotAdminHost; return error.PermissionDenied;
} }
return services.communities.create(&self.db, origin, null); return services.communities.create(&self.db, origin, null);
} }
pub fn createInvite(self: *Self, options: InviteOptions) !models.Invite { pub fn createInvite(self: *Self, options: InviteRequest) !services.invites.Invite {
const id = Uuid.randV4(prng.random()); // Only logged in users can make invites
const user_id = self.user_id orelse return error.PermissionDenied;
const user = try self.getAuthenticatedUser();
// Users can only make invites to their own community, unless they
// are system users
const community_id = if (options.to_community) |host| blk: { const community_id = if (options.to_community) |host| blk: {
const desired_community = (try self.db.execRow( // You can only specify a different community if you're on the admin domain
&.{Uuid}, if (self.community_id != null) return error.WrongCommunity;
"SELECT id FROM community WHERE host = ?",
.{host},
null,
)) orelse return error.CommunityNotFound;
if (user.community_id != null and !Uuid.eql(desired_community[0], user.community_id.?)) { // Only admins can invite on the admin domain
return error.WrongCommunity; if (!self.isAdmin()) return error.PermissionDenied;
}
break :blk desired_community[0]; break :blk (try services.communities.getByHost(&self.db, host, self.arena.allocator())).id;
} else null; } else self.community_id;
if (user.community_id != null and community_id == null) { // Users can only make user invites
return error.WrongCommunity; if (options.invite_type != .user and !self.isAdmin()) return error.PermissionDenied;
}
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,
.to_community = community_id,
return try services.invites.create(&self.db, user_id, community_id, .{
.name = options.name,
.expires_at = options.expires_at,
.max_uses = options.max_uses, .max_uses = options.max_uses,
.invite_type = options.invite_type,
.created_at = now, }, self.arena.allocator());
.expires_at = expires_at,
};
try self.db.insert(models.Invite, invite);
return invite;
} }
}; };
} }

View file

@ -75,10 +75,21 @@ pub fn create(db: anytype, origin: []const u8, name: ?[]const u8) CreateError!Co
return community; return community;
} }
pub fn firstIndexOf(str: []const u8, ch: u8) ?usize { fn firstIndexOf(str: []const u8, ch: u8) ?usize {
for (str) |c, i| { for (str) |c, i| {
if (c == ch) return i; if (c == ch) return i;
} }
return null; return null;
} }
pub fn getByHost(db: anytype, host: []const u8, alloc: std.mem.Allocator) !Community {
const result = (try db.execRow(&.{ Uuid, []const u8, []const u8, Scheme }, "SELECT id, host, name, scheme FROM community WHERE host = ?", .{host}, alloc)) orelse return error.NotFound;
return Community{
.id = result[0],
.host = result[1],
.name = result[2],
.scheme = result[3],
};
}

101
src/main/api/invites.zig Normal file
View file

@ -0,0 +1,101 @@
const std = @import("std");
const builtin = @import("builtin");
const util = @import("util");
const models = @import("../db/models.zig");
const DbError = @import("../db.zig").ExecError;
const getRandom = @import("../api.zig").getRandom;
const Uuid = util.Uuid;
const DateTime = util.DateTime;
// 9 random bytes = 12 random b64
const rand_len = 8;
const code_len = 12;
const Encoder = std.base64.url_safe.Encoder;
const Decoder = std.base64.url_safe.Decoder;
pub const InviteType = enum {
system,
community_owner,
user,
pub const jsonStringify = defaultJsonStringify(@This());
};
fn defaultJsonStringify(comptime T: type) fn (T, std.json.StringifyOptions, anytype) anyerror!void {
return struct {
pub fn jsonStringify(s: T, _: std.json.StringifyOptions, writer: anytype) !void {
return std.fmt.format(writer, "\"{s}\"", .{@tagName(s)});
}
}.jsonStringify;
}
pub const Invite = struct {
id: Uuid,
created_by: Uuid, // User ID
to_community: ?Uuid,
name: []const u8,
code: []const u8,
created_at: DateTime,
times_used: usize,
expires_at: ?DateTime,
max_uses: ?usize,
invite_type: InviteType,
};
fn cloneStr(str: []const u8, alloc: std.mem.Allocator) ![]const u8 {
const new = try alloc.alloc(u8, str.len);
std.mem.copy(u8, new, str);
return new;
}
pub const InviteOptions = struct {
name: ?[]const u8 = null,
max_uses: ?usize = null,
expires_at: ?DateTime = null,
invite_type: InviteType = .user,
};
pub fn create(db: anytype, created_by: Uuid, to_community: ?Uuid, options: InviteOptions, alloc: std.mem.Allocator) !Invite {
var code_bytes: [rand_len]u8 = undefined;
getRandom().bytes(&code_bytes);
const code = try alloc.alloc(u8, code_len);
errdefer alloc.free(code);
_ = Encoder.encode(code, &code_bytes);
const name = if (options.name) |name|
try cloneStr(name, alloc)
else
try cloneStr(code, alloc);
const id = Uuid.randV4(getRandom());
const created_at = DateTime.now();
const invite = Invite{
.id = id,
.created_by = created_by,
.to_community = to_community,
.name = name,
.code = code,
.created_at = created_at,
.times_used = 0,
.expires_at = options.expires_at,
.max_uses = options.max_uses,
.invite_type = options.invite_type,
};
try db.insert("invite", invite);
return invite;
}

View file

@ -7,6 +7,7 @@ const Uuid = @import("util").Uuid;
pub const auth = @import("./controllers/auth.zig"); pub const auth = @import("./controllers/auth.zig");
pub const communities = @import("./controllers/communities.zig"); pub const communities = @import("./controllers/communities.zig");
pub const invites = @import("./controllers/invites.zig");
pub const utils = struct { pub const utils = struct {
const json_options = if (builtin.mode == .Debug) .{ const json_options = if (builtin.mode == .Debug) .{

View file

@ -2,23 +2,27 @@ const root = @import("root");
const http = @import("http"); const http = @import("http");
const Uuid = @import("util").Uuid; const Uuid = @import("util").Uuid;
const utils = @import("../../controllers.zig").utils; const utils = @import("../controllers.zig").utils;
const InviteOptions = @import("../../api.zig").InviteOptions; const InviteRequest = @import("../api.zig").InviteRequest;
const RequestServer = root.RequestServer; const RequestServer = root.RequestServer;
const RouteArgs = http.RouteArgs; const RouteArgs = http.RouteArgs;
pub fn create(srv: *RequestServer, ctx: *http.server.Context, _: RouteArgs) !void { pub const create = struct {
const opt = try utils.parseRequestBody(InviteOptions, ctx); pub const path = "/invites";
defer utils.freeRequestBody(opt, ctx.alloc); pub const method = .POST;
pub fn handler(srv: *RequestServer, ctx: *http.server.Context, _: RouteArgs) !void {
const opt = try utils.parseRequestBody(InviteRequest, ctx);
defer utils.freeRequestBody(opt, ctx.alloc);
var api = try utils.getApiConn(srv, ctx); var api = try utils.getApiConn(srv, ctx);
defer api.close(); defer api.close();
const invite = try api.createInvite(opt); const invite = try api.createInvite(opt);
try utils.respondJson(ctx, .created, invite); try utils.respondJson(ctx, .created, invite);
} }
};
pub fn get(srv: *RequestServer, ctx: *http.server.Context, args: RouteArgs) !void { pub fn get(srv: *RequestServer, ctx: *http.server.Context, args: RouteArgs) !void {
const id_str = args.get("id") orelse return error.NotFound; const id_str = args.get("id") orelse return error.NotFound;

View file

@ -67,6 +67,7 @@ fn bind(stmt: sql.PreparedStmt, idx: u15, val: anytype) !void {
val.bindToSql(stmt, idx) val.bindToSql(stmt, idx)
else else
@compileError("unsupported type " ++ @typeName(T)), @compileError("unsupported type " ++ @typeName(T)),
.Int => stmt.bindI64(idx, @intCast(i64, val)),
else => @compileError("unsupported type " ++ @typeName(T)), else => @compileError("unsupported type " ++ @typeName(T)),
}, },
}; };
@ -95,7 +96,7 @@ fn getAlloc(row: sql.Row, comptime T: type, idx: u15, alloc: ?std.mem.Allocator)
else else
@compileError("unknown type " ++ @typeName(T)), @compileError("unknown type " ++ @typeName(T)),
.Enum => try getEnum(row, T, idx, alloc), .Enum => try getEnum(row, T, idx),
//else => unreachable, //else => unreachable,
else => @compileError("unknown type " ++ @typeName(T)), else => @compileError("unknown type " ++ @typeName(T)),
@ -103,9 +104,19 @@ fn getAlloc(row: sql.Row, comptime T: type, idx: u15, alloc: ?std.mem.Allocator)
}; };
} }
fn getEnum(row: sql.Row, comptime T: type, idx: u15, alloc: std.mem.Allocator) !T { fn maxTagLen(comptime T: type) usize {
// TODO: do this without dynamic allocation var max: usize = 0;
const tag_name = try row.getTextAlloc(idx, alloc); for (std.meta.fields(T)) |f| {
if (f.name.len > max) {
max = f.name.len;
}
}
return max;
}
fn getEnum(row: sql.Row, comptime T: type, idx: u15) !T {
var tag_buf: [maxTagLen(T)]u8 = undefined;
const tag_name = try row.getText(idx, &tag_buf);
inline for (std.meta.fields(T)) |tag| { inline for (std.meta.fields(T)) |tag| {
if (std.mem.eql(u8, tag_name, tag.name)) return @intToEnum(T, tag.value); if (std.mem.eql(u8, tag_name, tag.name)) return @intToEnum(T, tag.value);
} }
@ -113,7 +124,7 @@ fn getEnum(row: sql.Row, comptime T: type, idx: u15, alloc: std.mem.Allocator) !
return error.UnknownTag; return error.UnknownTag;
} }
pub const ExecError = sql.PrepareError || sql.RowGetError || sql.BindError || std.mem.Allocator.Error || error{AllocatorRequired}; pub const ExecError = sql.PrepareError || sql.RowGetError || sql.BindError || std.mem.Allocator.Error || error{ AllocatorRequired, UnknownTag };
pub const Database = struct { pub const Database = struct {
db: sql.Sqlite, db: sql.Sqlite,

View file

@ -24,7 +24,7 @@ const router = Router{
prepare(c.communities.create), prepare(c.communities.create),
//Route.new(.POST, "/invites", &c.admin.invites.create), prepare(c.invites.create),
//Route.new(.POST, "/notes", &c.notes.create), //Route.new(.POST, "/notes", &c.notes.create),
//Route.new(.GET, "/notes/:id", &c.notes.get), //Route.new(.GET, "/notes/:id", &c.notes.get),

View file

@ -7,7 +7,10 @@ data: u128,
pub const nil = Uuid{ .data = @as(u128, 0) }; pub const nil = Uuid{ .data = @as(u128, 0) };
pub const string_len = 36; pub const string_len = 36;
pub fn eql(lhs: Uuid, rhs: Uuid) bool { pub fn eql(lhs: ?Uuid, rhs: ?Uuid) bool {
if (lhs == null and rhs == null) return true;
if (lhs == null or rhs == null) return false;
return lhs.data == rhs.data; return lhs.data == rhs.data;
} }