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 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;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
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 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) .{
|
||||||
|
|
|
@ -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;
|
|
@ -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,
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue