Invites
This commit is contained in:
parent
075b922ef5
commit
a28f9fd38c
8 changed files with 184 additions and 74 deletions
|
@ -23,6 +23,18 @@ const services = struct {
|
|||
const communities = @import("./api/communities.zig");
|
||||
const users = @import("./api/users.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
|
||||
|
@ -70,13 +82,6 @@ pub const LoginResult = struct {
|
|||
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;
|
||||
|
||||
pub fn initThreadPrng(seed: u64) void {
|
||||
|
@ -183,6 +188,11 @@ fn ApiConn(comptime DbConn: type) type {
|
|||
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 {
|
||||
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);
|
||||
|
@ -216,67 +226,36 @@ fn ApiConn(comptime DbConn: type) type {
|
|||
}
|
||||
|
||||
pub fn createCommunity(self: *Self, origin: []const u8) !services.communities.Community {
|
||||
if (self.community_id != null) {
|
||||
return error.NotAdminHost;
|
||||
if (!self.isAdmin()) {
|
||||
return error.PermissionDenied;
|
||||
}
|
||||
|
||||
return services.communities.create(&self.db, origin, null);
|
||||
}
|
||||
|
||||
pub fn createInvite(self: *Self, options: InviteOptions) !models.Invite {
|
||||
const id = Uuid.randV4(prng.random());
|
||||
pub fn createInvite(self: *Self, options: InviteRequest) !services.invites.Invite {
|
||||
// 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 desired_community = (try self.db.execRow(
|
||||
&.{Uuid},
|
||||
"SELECT id FROM community WHERE host = ?",
|
||||
.{host},
|
||||
null,
|
||||
)) orelse return error.CommunityNotFound;
|
||||
// You can only specify a different community if you're on the admin domain
|
||||
if (self.community_id != null) return error.WrongCommunity;
|
||||
|
||||
if (user.community_id != null and !Uuid.eql(desired_community[0], user.community_id.?)) {
|
||||
return error.WrongCommunity;
|
||||
}
|
||||
// Only admins can invite on the admin domain
|
||||
if (!self.isAdmin()) return error.PermissionDenied;
|
||||
|
||||
break :blk desired_community[0];
|
||||
} else null;
|
||||
break :blk (try services.communities.getByHost(&self.db, host, self.arena.allocator())).id;
|
||||
} else self.community_id;
|
||||
|
||||
if (user.community_id != null and community_id == null) {
|
||||
return error.WrongCommunity;
|
||||
}
|
||||
|
||||
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,
|
||||
// Users can only make user invites
|
||||
if (options.invite_type != .user and !self.isAdmin()) return error.PermissionDenied;
|
||||
|
||||
return try services.invites.create(&self.db, user_id, community_id, .{
|
||||
.name = options.name,
|
||||
.expires_at = options.expires_at,
|
||||
.max_uses = options.max_uses,
|
||||
|
||||
.created_at = now,
|
||||
.expires_at = expires_at,
|
||||
};
|
||||
|
||||
try self.db.insert(models.Invite, invite);
|
||||
|
||||
return invite;
|
||||
.invite_type = options.invite_type,
|
||||
}, self.arena.allocator());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -75,10 +75,21 @@ pub fn create(db: anytype, origin: []const u8, name: ?[]const u8) CreateError!Co
|
|||
return community;
|
||||
}
|
||||
|
||||
pub fn firstIndexOf(str: []const u8, ch: u8) ?usize {
|
||||
fn firstIndexOf(str: []const u8, ch: u8) ?usize {
|
||||
for (str) |c, i| {
|
||||
if (c == ch) return i;
|
||||
}
|
||||
|
||||
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
101
src/main/api/invites.zig
Normal 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;
|
||||
}
|
|
@ -7,6 +7,7 @@ const Uuid = @import("util").Uuid;
|
|||
|
||||
pub const auth = @import("./controllers/auth.zig");
|
||||
pub const communities = @import("./controllers/communities.zig");
|
||||
pub const invites = @import("./controllers/invites.zig");
|
||||
|
||||
pub const utils = struct {
|
||||
const json_options = if (builtin.mode == .Debug) .{
|
||||
|
|
|
@ -2,23 +2,27 @@ const root = @import("root");
|
|||
const http = @import("http");
|
||||
const Uuid = @import("util").Uuid;
|
||||
|
||||
const utils = @import("../../controllers.zig").utils;
|
||||
const InviteOptions = @import("../../api.zig").InviteOptions;
|
||||
const utils = @import("../controllers.zig").utils;
|
||||
const InviteRequest = @import("../api.zig").InviteRequest;
|
||||
|
||||
const RequestServer = root.RequestServer;
|
||||
const RouteArgs = http.RouteArgs;
|
||||
|
||||
pub fn create(srv: *RequestServer, ctx: *http.server.Context, _: RouteArgs) !void {
|
||||
const opt = try utils.parseRequestBody(InviteOptions, ctx);
|
||||
defer utils.freeRequestBody(opt, ctx.alloc);
|
||||
pub const create = struct {
|
||||
pub const path = "/invites";
|
||||
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);
|
||||
defer api.close();
|
||||
var api = try utils.getApiConn(srv, ctx);
|
||||
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 {
|
||||
const id_str = args.get("id") orelse return error.NotFound;
|
|
@ -67,6 +67,7 @@ fn bind(stmt: sql.PreparedStmt, idx: u15, val: anytype) !void {
|
|||
val.bindToSql(stmt, idx)
|
||||
else
|
||||
@compileError("unsupported type " ++ @typeName(T)),
|
||||
.Int => stmt.bindI64(idx, @intCast(i64, val)),
|
||||
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
|
||||
@compileError("unknown type " ++ @typeName(T)),
|
||||
|
||||
.Enum => try getEnum(row, T, idx, alloc),
|
||||
.Enum => try getEnum(row, T, idx),
|
||||
|
||||
//else => unreachable,
|
||||
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 {
|
||||
// TODO: do this without dynamic allocation
|
||||
const tag_name = try row.getTextAlloc(idx, alloc);
|
||||
fn maxTagLen(comptime T: type) usize {
|
||||
var max: usize = 0;
|
||||
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| {
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
db: sql.Sqlite,
|
||||
|
|
|
@ -24,7 +24,7 @@ const router = Router{
|
|||
|
||||
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(.GET, "/notes/:id", &c.notes.get),
|
||||
|
|
|
@ -7,7 +7,10 @@ data: u128,
|
|||
pub const nil = Uuid{ .data = @as(u128, 0) };
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue