const std = @import("std"); const util = @import("util"); const sql = @import("sql"); const DateTime = util.DateTime; const Uuid = util.Uuid; const services = struct { const communities = @import("./services/communities.zig"); const actors = @import("./services/actors.zig"); const auth = @import("./services/auth.zig"); const invites = @import("./services/invites.zig"); const notes = @import("./services/notes.zig"); }; pub const RegistrationOptions = struct { invite_code: ?[]const u8 = null, email: ?[]const u8 = null, }; pub const InviteOptions = struct { pub const Kind = services.invites.Kind; name: ?[]const u8 = null, lifespan: ?DateTime.Duration = null, max_uses: ?u16 = null, // admin only options kind: Kind = .user, to_community: ?Uuid = null, }; pub const LoginResponse = services.auth.LoginResult; 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, }; pub const Community = services.communities.Community; pub const CommunityQueryArgs = services.communities.QueryArgs; pub const CommunityQueryResult = services.communities.QueryResult; pub const NoteQueryArgs = services.notes.QueryArgs; pub const TimelineArgs = struct { pub const PageDirection = NoteQueryArgs.PageDirection; pub const Prev = NoteQueryArgs.Prev; max_items: usize = 20, created_before: ?DateTime = null, created_after: ?DateTime = null, prev: ?Prev = null, page_direction: PageDirection = .forward, fn from(args: NoteQueryArgs) TimelineArgs { return .{ .max_items = args.max_items, .created_before = args.created_before, .created_after = args.created_after, .prev = args.prev, .page_direction = args.page_direction, }; } }; pub const TimelineResult = struct { items: []services.notes.Note, prev_page: TimelineArgs, next_page: TimelineArgs, }; pub fn isAdminSetup(db: sql.Db) !bool { _ = services.communities.adminCommunityId(db) catch |err| switch (err) { error.NotFound => return false, else => return err, }; return true; } pub fn setupAdmin(db: sql.Db, origin: []const u8, username: []const u8, password: []const u8, allocator: std.mem.Allocator) anyerror!void { const tx = try db.begin(); errdefer tx.rollback(); var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); try tx.setConstraintMode(.deferred); const community_id = try services.communities.create( tx, origin, .{ .name = "Cluster Admin", .kind = .admin }, arena.allocator(), ); const user = try services.auth.register(tx, username, password, community_id, .{ .kind = .admin }, arena.allocator()); try services.communities.transferOwnership(tx, community_id, user); try tx.commit(); std.log.info( "Created admin user {s} (id {}) with cluster admin origin {s} (id {})", .{ username, user, origin, community_id }, ); } pub const ApiSource = struct { db_conn_pool: *sql.ConnPool, pub const Conn = ApiConn(sql.Db); const root_username = "root"; pub fn init(pool: *sql.ConnPool) !ApiSource { return ApiSource{ .db_conn_pool = pool, }; } pub fn connectUnauthorized(self: *ApiSource, host: []const u8, alloc: std.mem.Allocator) !Conn { var arena = std.heap.ArenaAllocator.init(alloc); errdefer arena.deinit(); const db = try self.db_conn_pool.acquire(); errdefer db.releaseConnection(); const community = try services.communities.getByHost(db, host, arena.allocator()); return Conn{ .db = db, .user_id = null, .community = community, .arena = arena, }; } pub fn connectToken(self: *ApiSource, host: []const u8, token: []const u8, alloc: std.mem.Allocator) !Conn { var arena = std.heap.ArenaAllocator.init(alloc); errdefer arena.deinit(); const db = try self.db_conn_pool.acquire(); errdefer db.releaseConnection(); const community = try services.communities.getByHost(db, host, arena.allocator()); const token_info = try services.auth.verifyToken( db, token, community.id, arena.allocator(), ); return Conn{ .db = db, .token_info = token_info, .user_id = token_info.user_id, .community = community, .arena = arena, }; } }; fn ApiConn(comptime DbConn: type) type { return struct { const Self = @This(); db: DbConn, token_info: ?services.auth.TokenInfo = null, user_id: ?Uuid = null, community: services.communities.Community, arena: std.heap.ArenaAllocator, pub fn close(self: *Self) void { self.arena.deinit(); self.db.releaseConnection(); } fn isAdmin(self: *Self) bool { // TODO return self.user_id != null and self.community.kind == .admin; } pub fn login(self: *Self, username: []const u8, password: []const u8) !LoginResponse { return services.auth.login( self.db, username, self.community.id, password, self.arena.allocator(), ); } pub const AuthorizationInfo = struct { id: Uuid, username: []const u8, community_id: Uuid, host: []const u8, issued_at: DateTime, }; pub fn verifyAuthorization(self: *Self) !AuthorizationInfo { if (self.token_info) |info| { const user = try services.actors.get(self.db, info.user_id, self.arena.allocator()); return AuthorizationInfo{ .id = user.id, .username = user.username, .community_id = self.community.id, .host = self.community.host, .issued_at = info.issued_at, }; } return error.TokenRequired; } pub fn createCommunity(self: *Self, origin: []const u8) !services.communities.Community { if (!self.isAdmin()) { return error.PermissionDenied; } const tx = try self.db.begin(); errdefer tx.rollback(); const community_id = try services.communities.create( tx, origin, .{}, self.arena.allocator(), ); const community = services.communities.get( tx, community_id, self.arena.allocator(), ) catch |err| return switch (err) { error.NotFound => error.DatabaseError, else => |err2| err2, }; try tx.commit(); return community; } pub fn createInvite(self: *Self, options: InviteOptions) !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) |id| blk: { // Only admins can send invites for other communities if (!self.isAdmin()) return error.PermissionDenied; break :blk id; } else self.community.id; // Users can only make user invites if (options.kind != .user and !self.isAdmin()) return error.PermissionDenied; const invite_id = try services.invites.create(self.db, user_id, community_id, .{ .name = options.name, .lifespan = options.lifespan, .max_uses = options.max_uses, .kind = options.kind, }, self.arena.allocator()); return try services.invites.get(self.db, invite_id, self.arena.allocator()); } pub fn register(self: *Self, username: []const u8, password: []const u8, opt: RegistrationOptions) !UserResponse { std.log.debug("registering user {s} with code {?s}", .{ username, opt.invite_code }); const maybe_invite = if (opt.invite_code) |code| try services.invites.getByCode(self.db, code, self.community.id, self.arena.allocator()) else null; if (maybe_invite) |invite| { if (!Uuid.eql(invite.community_id, self.community.id)) return error.WrongCommunity; 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; } const invite_kind = if (maybe_invite) |inv| inv.kind else .user; if (self.community.kind == .admin) @panic("Unimplmented"); const user_id = try services.auth.register( self.db, username, password, self.community.id, .{ .invite_id = if (maybe_invite) |inv| @as(?Uuid, inv.id) else null, .email = opt.email, }, self.arena.allocator(), ); switch (invite_kind) { .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.actors.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 { // You cannot post on admin accounts if (self.community.kind == .admin) return error.WrongCommunity; // Only authenticated users can post const user_id = self.user_id orelse return error.TokenRequired; const note_id = try services.notes.create(self.db, user_id, content, self.arena.allocator()); 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.actors.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) !CommunityQueryResult { if (!self.isAdmin()) return error.PermissionDenied; return try services.communities.query(self.db, args, self.arena.allocator()); } pub fn globalTimeline(self: *Self, args: TimelineArgs) !TimelineResult { const all_args = std.mem.zeroInit(NoteQueryArgs, args); const result = try services.notes.query(self.db, all_args, self.arena.allocator()); return TimelineResult{ .items = result.items, .prev_page = TimelineArgs.from(result.prev_page), .next_page = TimelineArgs.from(result.next_page), }; } pub fn localTimeline(self: *Self, args: TimelineArgs) !TimelineResult { var all_args = std.mem.zeroInit(NoteQueryArgs, args); all_args.community_id = self.community.id; const result = try services.notes.query(self.db, all_args, self.arena.allocator()); return TimelineResult{ .items = result.items, .prev_page = TimelineArgs.from(result.prev_page), .next_page = TimelineArgs.from(result.next_page), }; } }; }