const std = @import("std"); const util = @import("util"); const builtin = @import("builtin"); const sql = @import("sql"); const models = @import("./db/models.zig"); const migrations = @import("./migrations.zig"); pub const DateTime = util.DateTime; pub const Uuid = util.Uuid; const Config = @import("./main.zig").Config; const services = struct { const communities = @import("./api/communities.zig"); const users = @import("./api/users.zig"); const auth = @import("./api/auth.zig"); const invites = @import("./api/invites.zig"); const notes = @import("./api/notes.zig"); }; pub const RegistrationRequest = struct { username: []const u8, password: []const u8, invite_code: []const u8, email: ?[]const u8 = null, }; 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: ?u16 = null, invite_type: Type = .user, // must be user unless the creator is an admin to_community: ?[]const u8 = null, // only valid on admin community }; pub const LoginResponse = struct { token: services.auth.tokens.Token.Value, user_id: Uuid, issued_at: DateTime, }; pub const UserResponse = struct { id: Uuid, username: []const u8, host: []const u8, created_at: DateTime, }; pub const NoteResponse = struct { id: Uuid, author: struct { id: Uuid, username: []const u8, host: []const u8, }, content: []const u8, created_at: DateTime, }; // Frees an api struct and its fields allocated from alloc pub fn free(alloc: std.mem.Allocator, val: anytype) void { switch (@typeInfo(@TypeOf(val))) { .Pointer => |ptr_info| switch (ptr_info.size) { .One => { free(alloc, val.*); alloc.destroy(val); }, .Slice => { for (val) |elem| free(alloc, elem); alloc.free(val); }, else => unreachable, }, .Struct => inline for (std.meta.fields(@TypeOf(val))) |f| free(alloc, @field(val, f.name)), .Array => for (val) |elem| free(alloc, elem), .Optional => if (val) |opt| free(alloc, opt), .Bool, .Int, .Float, .Enum => {}, else => unreachable, } } threadlocal var prng: std.rand.DefaultPrng = undefined; pub fn initThreadPrng(seed: u64) void { prng = std.rand.DefaultPrng.init(seed +% std.Thread.getCurrentId()); } pub fn getRandom() std.rand.Random { return prng.random(); } pub const ApiSource = struct { db: sql.Db, internal_alloc: std.mem.Allocator, config: Config, pub const Conn = ApiConn(sql.Db); const root_username = "root"; pub fn init(alloc: std.mem.Allocator, cfg: Config, root_password: ?[]const u8, db_conn: sql.Db) !ApiSource { var self = ApiSource{ .db = db_conn, .internal_alloc = alloc, .config = cfg, }; try migrations.up(db_conn); if ((try services.users.lookupByUsername(&self.db, root_username, null)) == null) { std.log.info("No cluster root user detected. Creating...", .{}); // TODO: Fix this const password = root_password orelse return error.NeedRootPassword; var arena = std.heap.ArenaAllocator.init(alloc); defer arena.deinit(); const user_id = try services.users.create(&self.db, root_username, password, null, .{}, arena.allocator()); std.log.debug("Created {s} ID {}", .{ root_username, user_id }); } return self; } fn getCommunityFromHost(self: *ApiSource, host: []const u8) !?Uuid { if (try self.db.queryRow( &.{Uuid}, "SELECT id FROM community WHERE host = $1", .{host}, null, )) |result| return result[0]; // Test for cluster admin community if (util.ciutf8.eql(self.config.cluster_host, host)) { return null; } return error.NoCommunity; } pub fn connectUnauthorized(self: *ApiSource, host: []const u8, alloc: std.mem.Allocator) !Conn { const community_id = try self.getCommunityFromHost(host); return Conn{ .db = self.db, .internal_alloc = self.internal_alloc, .user_id = null, .community_id = community_id, .arena = std.heap.ArenaAllocator.init(alloc), }; } pub fn connectToken(self: *ApiSource, host: []const u8, token: []const u8, alloc: std.mem.Allocator) !Conn { const community_id = try self.getCommunityFromHost(host); const token_info = try services.auth.tokens.verify(&self.db, token, community_id); return Conn{ .db = self.db, .internal_alloc = self.internal_alloc, .user_id = token_info.user_id, .community_id = community_id, .arena = std.heap.ArenaAllocator.init(alloc), }; } }; fn ApiConn(comptime DbConn: type) type { return struct { const Self = @This(); db: DbConn, internal_alloc: std.mem.Allocator, // used *only* for large, internal buffers user_id: ?Uuid, community_id: ?Uuid, arena: std.heap.ArenaAllocator, pub fn close(self: *Self) void { 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) !LoginResponse { 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); const token = try services.auth.tokens.create(&self.db, user_id); return LoginResponse{ .user_id = user_id, .token = token.value, .issued_at = token.info.issued_at, }; } const TokenInfo = struct { username: []const u8, }; pub fn getTokenInfo(self: *Self) !TokenInfo { if (self.user_id) |user_id| { const result = (try self.db.queryRow( &.{[]const u8}, "SELECT username FROM user WHERE id = $1", .{user_id}, self.arena.allocator(), )) orelse { return error.UserNotFound; }; return TokenInfo{ .username = result[0] }; } return error.Unauthorized; } pub fn createCommunity(self: *Self, origin: []const u8) !services.communities.Community { if (!self.isAdmin()) { return error.PermissionDenied; } return services.communities.create(&self.db, origin, null); } pub fn createInvite(self: *Self, options: InviteRequest) !services.invites.Invite { // Only logged in users can make invites const user_id = self.user_id orelse return error.TokenRequired; const community_id = if (options.to_community) |host| blk: { // You can only specify a different community if you're on the admin domain if (self.community_id != null) return error.WrongCommunity; // Only admins can invite on the admin domain if (!self.isAdmin()) return error.PermissionDenied; break :blk (try services.communities.getByHost(&self.db, host, self.arena.allocator())).id; } else self.community_id; // Users can only make user invites if (options.invite_type != .user and !self.isAdmin()) return error.PermissionDenied; return try services.invites.create(&self.db, user_id, community_id, .{ .name = options.name, .expires_at = options.expires_at, .max_uses = options.max_uses, .invite_type = options.invite_type, }, self.arena.allocator()); } pub fn register(self: *Self, request: RegistrationRequest) !UserResponse { std.log.debug("registering user {s} with code {s}", .{ request.username, request.invite_code }); const invite = try services.invites.getByCode(&self.db, request.invite_code, self.arena.allocator()); if (!Uuid.eql(invite.to_community, self.community_id)) return error.NotFound; if (invite.max_uses != null and invite.times_used >= invite.max_uses.?) return error.InviteExpired; if (invite.expires_at != null and DateTime.now().isAfter(invite.expires_at.?)) return error.InviteExpired; if (self.community_id == null) @panic("Unimplmented"); const user_id = try services.users.create(&self.db, request.username, request.password, self.community_id, .{ .invite_id = invite.id, .email = request.email }, self.internal_alloc); switch (invite.invite_type) { .user => {}, .system => @panic("System user invites unimplemented"), .community_owner => { try services.communities.transferOwnership(&self.db, self.community_id.?, user_id); }, } return self.getUser(user_id) catch |err| switch (err) { error.NotFound => error.Unexpected, else => err, }; } pub fn getUser(self: *Self, user_id: Uuid) !UserResponse { const user = try services.users.get(&self.db, user_id, self.arena.allocator()); if (self.user_id == null) { if (!Uuid.eql(self.community_id, user.community_id)) return error.NotFound; } return UserResponse{ .id = user.id, .username = user.username, .host = user.host, .created_at = user.created_at, }; } pub fn createNote(self: *Self, content: []const u8) !NoteResponse { if (self.community_id == null) return error.WrongCommunity; const user_id = self.user_id orelse return error.TokenRequired; const note_id = try services.notes.create(&self.db, user_id, content); return self.getNote(note_id) catch |err| switch (err) { error.NotFound => error.Unexpected, else => err, }; } pub fn getNote(self: *Self, note_id: Uuid) !NoteResponse { const note = try services.notes.get(&self.db, note_id, self.arena.allocator()); const user = try services.users.get(&self.db, note.author_id, self.arena.allocator()); // Only serve community-specific notes on unauthenticated requests if (self.user_id == null) { if (!Uuid.eql(self.community_id, user.community_id)) return error.NotFound; } return NoteResponse{ .id = note.id, .author = .{ .id = user.id, .username = user.username, .host = user.host, }, .content = note.content, .created_at = note.created_at, }; } pub fn queryCommunities(self: *Self, args: services.communities.QueryArgs) ![]services.communities.Community { if (!self.isAdmin()) return error.PermissionDenied; return services.communities.query(&self.db, args, self.arena.allocator()); } }; }