Add communities

This commit is contained in:
jaina heartles 2022-08-01 21:33:23 -07:00
parent c31633cade
commit 6c15849882
7 changed files with 190 additions and 10 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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