From 6c158498822c3dace935929d2ec2801f37becac0 Mon Sep 17 00:00:00 2001 From: jaina heartles Date: Mon, 1 Aug 2022 21:33:23 -0700 Subject: [PATCH] Add communities --- src/main/api.zig | 71 +++++++++++++++++++++- src/main/controllers.zig | 1 + src/main/controllers/admin/communities.zig | 31 ++++++++++ src/main/db.zig | 49 ++++++++++++--- src/main/db/migrations.zig | 21 +++++++ src/main/db/models.zig | 24 ++++++++ src/main/main.zig | 3 + 7 files changed, 190 insertions(+), 10 deletions(-) create mode 100644 src/main/controllers/admin/communities.zig diff --git a/src/main/api.zig b/src/main/api.zig index e6aa959..4763fe7 100644 --- a/src/main/api.zig +++ b/src/main/api.zig @@ -1,5 +1,6 @@ const std = @import("std"); const util = @import("util"); +const builtin = @import("builtin"); const db = @import("./db.zig"); const models = @import("./db/models.zig"); @@ -73,11 +74,20 @@ pub const NoteCreateInfo = struct { content: []const u8, }; +pub const Scheme = models.Community.Scheme; + +pub const CommunityCreateOptions = struct { + name: []const u8, + host: []const u8, + scheme: Scheme, +}; + pub const RegistrationInfo = struct { username: []const u8, password: []const u8, email: ?[]const u8, invite_code: ?[]const u8, + community_host: ?[]const u8, }; pub const LoginResult = struct { @@ -90,6 +100,7 @@ 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; @@ -158,6 +169,17 @@ fn ApiConn(comptime DbConn: type) type { self.arena.deinit(); } + fn getAuthenticatedUser(self: *Self) !models.User { + if (self.as_user) |id| { + const user = try self.db.getBy(models.User, .id, id, self.arena.allocator()); + if (user == null) return error.NotAuthorized; + + return user.?; + } else { + return error.NotAuthorized; + } + } + fn getAuthenticatedLocalUser(self: *Self) !models.LocalUser { if (self.as_user) |user_id| { const local_user = try self.db.getBy(models.LocalUser, .user_id, user_id, self.arena.allocator()); @@ -215,6 +237,30 @@ fn ApiConn(comptime DbConn: type) type { return try self.db.getWhereEq(models.Reaction, .note_id, note_id, self.arena.allocator()); } + pub fn createCommunity(self: *Self, info: CommunityCreateOptions) !models.Community { + // TODO: Take url as single string and parse it + const id = Uuid.randV4(prng.random()); + const now = DateTime.now(); + + // Require TLS on production builds + if (info.scheme != .https and builtin.mode != .Debug) return error.UnsupportedScheme; + + const community = models.Community{ + .id = id, + .created_at = now, + .name = info.name, + .host = info.host, + .scheme = info.scheme, + }; + try self.db.insert(models.Community, community); + + return community; + } + + pub fn getCommunity(self: *Self, host: []const u8) !?models.Community { + return try self.db.getBy(models.Community, .host, host, self.arena.allocator()); + } + pub fn register(self: *Self, info: RegistrationInfo) !models.Actor { const user_id = Uuid.randV4(prng.random()); // TODO: lock for transaction @@ -239,10 +285,16 @@ fn ApiConn(comptime DbConn: type) type { var buf: [pw_hash_buf_size]u8 = undefined; const hash = try PwHash.strHash(info.password, .{ .allocator = self.internal_alloc, .params = pw_hash_params, .encoding = pw_hash_encoding }, &buf); + const community_id = if (info.community_host) |host| blk: { + const community = (try self.db.getBy(models.Community, .host, host, self.arena.allocator())) orelse return error.CommunityNotFound; + break :blk community.id; + } else null; + const user = models.User{ .id = user_id, .username = info.username, .created_at = now, + .community_id = community_id, }; const actor = models.Actor{ .user_id = user_id, @@ -314,7 +366,21 @@ fn ApiConn(comptime DbConn: type) type { pub fn createInvite(self: *Self, options: InviteOptions) !models.Invite { const id = Uuid.randV4(prng.random()); - const user_id = (try self.getAuthenticatedLocalUser()).user_id; + 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.getBy(models.Community, .host, host, self.arena.allocator())) orelse return error.CommunityNotFound; + if (user.community_id != null and !Uuid.eql(desired_community.id, user.community_id.?)) { + return error.WrongCommunity; + } + + break :blk desired_community.id; + } else null; + if (user.community_id != null and options.to_community == null) { + return error.WrongCommunity; + } var code: [invite_code_len]u8 = undefined; std.crypto.random.bytes(&code); @@ -331,8 +397,9 @@ fn ApiConn(comptime DbConn: type) type { .id = id, .name = try self.arena.allocator().dupe(u8, options.name), - .created_by = user_id, + .created_by = user.id, .invite_code = code_str, + .to_community = community_id, .max_uses = options.max_uses, diff --git a/src/main/controllers.zig b/src/main/controllers.zig index 4c6263e..b2c9716 100644 --- a/src/main/controllers.zig +++ b/src/main/controllers.zig @@ -10,6 +10,7 @@ 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 communities = @import("./controllers/admin/communities.zig"); }; pub const utils = struct { diff --git a/src/main/controllers/admin/communities.zig b/src/main/controllers/admin/communities.zig new file mode 100644 index 0000000..a34a6d7 --- /dev/null +++ b/src/main/controllers/admin/communities.zig @@ -0,0 +1,31 @@ +const root = @import("root"); +const http = @import("http"); +const Uuid = @import("util").Uuid; + +const utils = @import("../../controllers.zig").utils; +const CreateOptions = @import("../../api.zig").CommunityCreateOptions; + +const RequestServer = root.RequestServer; +const RouteArgs = http.RouteArgs; + +pub fn create(srv: *RequestServer, ctx: *http.server.Context, _: RouteArgs) !void { + const opt = try utils.parseRequestBody(CreateOptions, ctx); + defer utils.freeRequestBody(opt, ctx.alloc); + + var api = try utils.getApiConn(srv, ctx); + defer api.close(); + + const invite = try api.createCommunity(opt); + + try utils.respondJson(ctx, .created, invite); +} + +pub fn get(srv: *RequestServer, ctx: *http.server.Context, args: RouteArgs) !void { + const host = args.get("host") orelse return error.NotFound; + var api = try utils.getApiConn(srv, ctx); + defer api.close(); + + const invite = (try api.getCommunity(host)) orelse return utils.respondError(ctx, .not_found, "Community not found"); + + try utils.respondJson(ctx, .ok, invite); +} diff --git a/src/main/db.zig b/src/main/db.zig index 9f1c85e..701616b 100644 --- a/src/main/db.zig +++ b/src/main/db.zig @@ -37,6 +37,7 @@ fn tableName(comptime T: type) String { models.LocalUser => "local_user", models.Token => "token", models.Invite => "invite", + models.Community => "community", else => unreachable, }; } @@ -130,6 +131,16 @@ fn fieldsExcept(comptime T: type, comptime to_ignore: []const String) []const St } } +fn maxTagNameLen(comptime Enum: type) usize { + comptime { + var max_len: usize = 0; + for (std.meta.fieldNames(Enum)) |field| { + if (field.len > max_len) max_len = field.len; + } + return max_len; + } +} + // Binds a value to a parameter in the query. Use this instead of string // concatenation to avoid injection attacks; // If a given type is not supported by this function, you can add support by @@ -145,8 +156,12 @@ fn bind(stmt: sql.PreparedStmt, idx: u15, val: anytype) !void { @TypeOf(null) => stmt.bindNull(idx), 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)), + .Enum => stmt.bindText(idx, @tagName(val)), + .Struct, .Union, .Opaque => if (@hasDecl(T, "bindToSql")) + val.bindToSql(stmt, idx) + else + @compileError("unsupported type " ++ @typeName(T)), + else => @compileError("unsupported Type " ++ @typeName(T)), }, }; } @@ -163,16 +178,34 @@ fn getAlloc(row: sql.Row, comptime T: type, idx: u15, alloc: std.mem.Allocator) Uuid => row.getUuid(idx), DateTime => row.getDateTime(idx), - else => { - switch (@typeInfo(T)) { - .Optional => if (row.isNull(idx)) return null else return try getAlloc(row, std.meta.Child(T), idx, alloc), - .Struct, .Union, .Enum, .Opaque => if (@hasDecl(T, "getFromSql")) T.getFromSql(row, idx, alloc), - else => @compileError("unknown type " ++ @typeName(T)), - } + else => switch (@typeInfo(T)) { + .Optional => if (row.isNull(idx)) + null + else + try getAlloc(row, std.meta.Child(T), idx, alloc), + + .Struct, .Union, .Opaque => if (@hasDecl(T, "getFromSql")) + T.getFromSql(row, idx, alloc) + else + @compileError("unknown type " ++ @typeName(T)), + + .Enum => try getEnum(row, T, idx, alloc), + + else => @compileError("unknown type " ++ @typeName(T)), }, }; } +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); + inline for (std.meta.fields(T)) |tag| { + if (std.mem.eql(u8, tag_name, tag.name)) return @intToEnum(T, tag.value); + } + + return error.UnknownTag; +} + pub const Database = struct { db: sql.Sqlite, diff --git a/src/main/db/migrations.zig b/src/main/db/migrations.zig index 022de11..cb18cba 100644 --- a/src/main/db/migrations.zig +++ b/src/main/db/migrations.zig @@ -172,4 +172,25 @@ const migrations: []const Migration = &.{ \\DROP TABLE invite; , }, + .{ + .name = "communities", + .up = + \\CREATE TABLE community( + \\ id TEXT NOT NULL PRIMARY KEY, + \\ + \\ name TEXT NOT NULL, + \\ host TEXT NOT NULL UNIQUE, + \\ scheme TEXT NOT NULL CHECK (scheme IN ('http', 'https')), + \\ + \\ created_at INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP + \\) STRICT; + \\ALTER TABLE user ADD COLUMN community_id TEXT REFERENCES community(id); + \\ALTER TABLE invite ADD COLUMN to_community TEXT REFERENCES community(id); + , + .down = + \\ALTER TABLE invite DROP COLUMN to_community; + \\ALTER TABLE user DROP COLUMN community_id; + \\DROP TABLE community; + , + }, }; diff --git a/src/main/db/models.zig b/src/main/db/models.zig index 31b29ee..0fa2e4d 100644 --- a/src/main/db/models.zig +++ b/src/main/db/models.zig @@ -56,9 +56,13 @@ fn Ref(comptime _: type) type { return Uuid; } +// TODO: Should created_at / etc refer to the time the object was created? or the time +// the row representing it was created? Matters for federation + pub const User = struct { id: Uuid, username: []const u8, + community_id: ?Ref(Community), created_at: DateTime, }; @@ -111,9 +115,29 @@ pub const Invite = struct { name: []const u8, invite_code: []const u8, created_by: Ref(LocalUser), + to_community: ?Ref(Community), max_uses: ?i64, created_at: DateTime, expires_at: ?DateTime, }; + +pub const Community = struct { + pub const Scheme = enum { + https, + http, + + pub fn jsonStringify(s: Scheme, _: std.json.StringifyOptions, writer: anytype) !void { + return std.fmt.format(writer, "\"{}\"", .{s}); + } + }; + + id: Uuid, + + name: []const u8, + host: []const u8, + scheme: Scheme, + + created_at: DateTime, +}; diff --git a/src/main/main.zig b/src/main/main.zig index bb89525..1d4b292 100644 --- a/src/main/main.zig +++ b/src/main/main.zig @@ -28,6 +28,9 @@ const router = Router{ Route.new(.POST, "/admin/invites", c.admin.invites.create), Route.new(.GET, "/admin/invites/:id", c.admin.invites.get), + + Route.new(.POST, "/admin/communities", c.admin.communities.create), + Route.new(.GET, "/admin/communities/:host", c.admin.communities.get), }, };