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