Add communities
This commit is contained in:
parent
c31633cade
commit
6c15849882
7 changed files with 190 additions and 10 deletions
|
@ -1,5 +1,6 @@
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
const util = @import("util");
|
const util = @import("util");
|
||||||
|
const builtin = @import("builtin");
|
||||||
|
|
||||||
const db = @import("./db.zig");
|
const db = @import("./db.zig");
|
||||||
const models = @import("./db/models.zig");
|
const models = @import("./db/models.zig");
|
||||||
|
@ -73,11 +74,20 @@ pub const NoteCreateInfo = struct {
|
||||||
content: []const u8,
|
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 {
|
pub const RegistrationInfo = struct {
|
||||||
username: []const u8,
|
username: []const u8,
|
||||||
password: []const u8,
|
password: []const u8,
|
||||||
email: ?[]const u8,
|
email: ?[]const u8,
|
||||||
invite_code: ?[]const u8,
|
invite_code: ?[]const u8,
|
||||||
|
community_host: ?[]const u8,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const LoginResult = struct {
|
pub const LoginResult = struct {
|
||||||
|
@ -90,6 +100,7 @@ pub const InviteOptions = struct {
|
||||||
name: []const u8 = "",
|
name: []const u8 = "",
|
||||||
max_uses: ?i64 = null,
|
max_uses: ?i64 = null,
|
||||||
lifetime: ?i64 = null, // unix seconds, TODO make a TimeSpan type
|
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;
|
||||||
|
@ -158,6 +169,17 @@ fn ApiConn(comptime DbConn: type) type {
|
||||||
self.arena.deinit();
|
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 {
|
fn getAuthenticatedLocalUser(self: *Self) !models.LocalUser {
|
||||||
if (self.as_user) |user_id| {
|
if (self.as_user) |user_id| {
|
||||||
const local_user = try self.db.getBy(models.LocalUser, .user_id, user_id, self.arena.allocator());
|
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());
|
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 {
|
pub fn register(self: *Self, info: RegistrationInfo) !models.Actor {
|
||||||
const user_id = Uuid.randV4(prng.random());
|
const user_id = Uuid.randV4(prng.random());
|
||||||
// TODO: lock for transaction
|
// TODO: lock for transaction
|
||||||
|
@ -239,10 +285,16 @@ fn ApiConn(comptime DbConn: type) type {
|
||||||
var buf: [pw_hash_buf_size]u8 = undefined;
|
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 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{
|
const user = models.User{
|
||||||
.id = user_id,
|
.id = user_id,
|
||||||
.username = info.username,
|
.username = info.username,
|
||||||
.created_at = now,
|
.created_at = now,
|
||||||
|
.community_id = community_id,
|
||||||
};
|
};
|
||||||
const actor = models.Actor{
|
const actor = models.Actor{
|
||||||
.user_id = user_id,
|
.user_id = user_id,
|
||||||
|
@ -314,7 +366,21 @@ fn ApiConn(comptime DbConn: type) type {
|
||||||
pub fn createInvite(self: *Self, options: InviteOptions) !models.Invite {
|
pub fn createInvite(self: *Self, options: InviteOptions) !models.Invite {
|
||||||
const id = Uuid.randV4(prng.random());
|
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;
|
var code: [invite_code_len]u8 = undefined;
|
||||||
std.crypto.random.bytes(&code);
|
std.crypto.random.bytes(&code);
|
||||||
|
@ -331,8 +397,9 @@ fn ApiConn(comptime DbConn: type) type {
|
||||||
.id = id,
|
.id = id,
|
||||||
|
|
||||||
.name = try self.arena.allocator().dupe(u8, options.name),
|
.name = try self.arena.allocator().dupe(u8, options.name),
|
||||||
.created_by = user_id,
|
.created_by = user.id,
|
||||||
.invite_code = code_str,
|
.invite_code = code_str,
|
||||||
|
.to_community = community_id,
|
||||||
|
|
||||||
.max_uses = options.max_uses,
|
.max_uses = options.max_uses,
|
||||||
|
|
||||||
|
|
|
@ -10,6 +10,7 @@ pub const notes = @import("./controllers/notes.zig");
|
||||||
pub const actors = @import("./controllers/actors.zig");
|
pub const actors = @import("./controllers/actors.zig");
|
||||||
pub const admin = struct {
|
pub const admin = struct {
|
||||||
pub const invites = @import("./controllers/admin/invites.zig");
|
pub const invites = @import("./controllers/admin/invites.zig");
|
||||||
|
pub const communities = @import("./controllers/admin/communities.zig");
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const utils = struct {
|
pub const utils = struct {
|
||||||
|
|
31
src/main/controllers/admin/communities.zig
Normal file
31
src/main/controllers/admin/communities.zig
Normal 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);
|
||||||
|
}
|
|
@ -37,6 +37,7 @@ fn tableName(comptime T: type) String {
|
||||||
models.LocalUser => "local_user",
|
models.LocalUser => "local_user",
|
||||||
models.Token => "token",
|
models.Token => "token",
|
||||||
models.Invite => "invite",
|
models.Invite => "invite",
|
||||||
|
models.Community => "community",
|
||||||
else => unreachable,
|
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
|
// Binds a value to a parameter in the query. Use this instead of string
|
||||||
// concatenation to avoid injection attacks;
|
// concatenation to avoid injection attacks;
|
||||||
// If a given type is not supported by this function, you can add support by
|
// 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),
|
@TypeOf(null) => stmt.bindNull(idx),
|
||||||
else => |T| switch (@typeInfo(T)) {
|
else => |T| switch (@typeInfo(T)) {
|
||||||
.Optional => if (val) |v| bind(stmt, idx, v) else stmt.bindNull(idx),
|
.Optional => if (val) |v| bind(stmt, idx, v) else stmt.bindNull(idx),
|
||||||
.Struct, .Union, .Enum, .Opaque => if (@hasDecl(T, "bindToSql")) val.bindToSql(stmt, idx),
|
.Enum => stmt.bindText(idx, @tagName(val)),
|
||||||
else => @compileError("Unknown Type " ++ @typeName(T)),
|
.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),
|
Uuid => row.getUuid(idx),
|
||||||
DateTime => row.getDateTime(idx),
|
DateTime => row.getDateTime(idx),
|
||||||
|
|
||||||
else => {
|
else => switch (@typeInfo(T)) {
|
||||||
switch (@typeInfo(T)) {
|
.Optional => if (row.isNull(idx))
|
||||||
.Optional => if (row.isNull(idx)) return null else return try getAlloc(row, std.meta.Child(T), idx, alloc),
|
null
|
||||||
.Struct, .Union, .Enum, .Opaque => if (@hasDecl(T, "getFromSql")) T.getFromSql(row, idx, alloc),
|
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)),
|
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 {
|
pub const Database = struct {
|
||||||
db: sql.Sqlite,
|
db: sql.Sqlite,
|
||||||
|
|
||||||
|
|
|
@ -172,4 +172,25 @@ const migrations: []const Migration = &.{
|
||||||
\\DROP TABLE invite;
|
\\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;
|
||||||
|
,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -56,9 +56,13 @@ fn Ref(comptime _: type) type {
|
||||||
return Uuid;
|
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 {
|
pub const User = struct {
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
username: []const u8,
|
username: []const u8,
|
||||||
|
community_id: ?Ref(Community),
|
||||||
|
|
||||||
created_at: DateTime,
|
created_at: DateTime,
|
||||||
};
|
};
|
||||||
|
@ -111,9 +115,29 @@ pub const Invite = struct {
|
||||||
name: []const u8,
|
name: []const u8,
|
||||||
invite_code: []const u8,
|
invite_code: []const u8,
|
||||||
created_by: Ref(LocalUser),
|
created_by: Ref(LocalUser),
|
||||||
|
to_community: ?Ref(Community),
|
||||||
|
|
||||||
max_uses: ?i64,
|
max_uses: ?i64,
|
||||||
|
|
||||||
created_at: DateTime,
|
created_at: DateTime,
|
||||||
expires_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,
|
||||||
|
};
|
||||||
|
|
|
@ -28,6 +28,9 @@ const router = Router{
|
||||||
|
|
||||||
Route.new(.POST, "/admin/invites", c.admin.invites.create),
|
Route.new(.POST, "/admin/invites", c.admin.invites.create),
|
||||||
Route.new(.GET, "/admin/invites/:id", c.admin.invites.get),
|
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),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue