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 drive = @import("./services/files.zig"); const invites = @import("./services/invites.zig"); const notes = @import("./services/notes.zig"); const follows = @import("./services/follows.zig"); }; pub const ClusterMeta = struct { community_count: usize, user_count: usize, note_count: usize, }; 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.NoteDetailed, prev_page: TimelineArgs, next_page: TimelineArgs, }; const FollowQueryArgs = struct { pub const OrderBy = services.follows.QueryArgs.OrderBy; pub const Direction = services.follows.QueryArgs.Direction; pub const PageDirection = services.follows.QueryArgs.PageDirection; pub const Prev = services.follows.QueryArgs.Prev; max_items: usize = 20, order_by: OrderBy = .created_at, direction: Direction = .descending, prev: ?Prev = null, page_direction: PageDirection = .forward, fn from(args: services.follows.QueryArgs) FollowQueryArgs { return .{ .max_items = args.max_items, .order_by = args.order_by, .direction = args.direction, .prev = args.prev, .page_direction = args.page_direction, }; } }; const FollowQueryResult = struct { items: []services.follows.Follow, prev_page: FollowQueryArgs, next_page: FollowQueryArgs, }; pub const FollowerQueryArgs = FollowQueryArgs; pub const FollowerQueryResult = FollowQueryResult; pub const FollowingQueryArgs = FollowQueryArgs; pub const FollowingQueryResult = FollowQueryResult; pub const UploadFileArgs = struct { filename: []const u8, dir: ?[]const u8, description: ?[]const u8, content_type: []const u8, sensitive: bool, }; 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 { const db = try self.db_conn_pool.acquire(); errdefer db.releaseConnection(); const community = try services.communities.getByHost(db, host, alloc); return Conn{ .db = db, .user_id = null, .community = community, .allocator = alloc, }; } pub fn connectToken(self: *ApiSource, host: []const u8, token: []const u8, alloc: std.mem.Allocator) !Conn { const db = try self.db_conn_pool.acquire(); errdefer db.releaseConnection(); const community = try services.communities.getByHost(db, host, alloc); const token_info = try services.auth.verifyToken( db, token, community.id, alloc, ); return Conn{ .db = db, .token_info = token_info, .user_id = token_info.user_id, .community = community, .allocator = alloc, }; } }; 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, allocator: std.mem.Allocator, pub fn close(self: *Self) void { util.deepFree(self.allocator, self.community); 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.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.allocator); defer util.deepFree(self.allocator, user); return AuthorizationInfo{ .id = user.id, .username = try util.deepClone(self.allocator, 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.allocator, ); const community = services.communities.get( tx, community_id, self.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.allocator); return try services.invites.get(self.db, invite_id, self.allocator); } pub fn register(self: *Self, username: []const u8, password: []const u8, opt: RegistrationOptions) !UserResponse { const tx = try self.db.beginOrSavepoint(); const maybe_invite = if (opt.invite_code) |code| try services.invites.getByCode(tx, code, self.community.id, self.allocator) else null; defer if (maybe_invite) |inv| util.deepFree(self.allocator, inv); 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( tx, username, password, self.community.id, .{ .invite_id = if (maybe_invite) |inv| @as(?Uuid, inv.id) else null, .email = opt.email, }, self.allocator, ); switch (invite_kind) { .user => {}, .system => @panic("System user invites unimplemented"), .community_owner => { try services.communities.transferOwnership(tx, 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.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.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.allocator); const user = try services.actors.get(self.db, note.author_id, self.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.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.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.allocator); return TimelineResult{ .items = result.items, .prev_page = TimelineArgs.from(result.prev_page), .next_page = TimelineArgs.from(result.next_page), }; } pub fn homeTimeline(self: *Self, args: TimelineArgs) !TimelineResult { if (self.user_id == null) return error.NoToken; var all_args = std.mem.zeroInit(services.notes.QueryArgs, args); all_args.followed_by = self.user_id; const result = try services.notes.query(self.db, all_args, self.allocator); return TimelineResult{ .items = result.items, .prev_page = TimelineArgs.from(result.prev_page), .next_page = TimelineArgs.from(result.next_page), }; } pub fn queryFollowers(self: *Self, user_id: Uuid, args: FollowerQueryArgs) !FollowerQueryResult { var all_args = std.mem.zeroInit(services.follows.QueryArgs, args); all_args.followee_id = user_id; const result = try services.follows.query(self.db, all_args, self.allocator); return FollowerQueryResult{ .items = result.items, .prev_page = FollowQueryArgs.from(result.prev_page), .next_page = FollowQueryArgs.from(result.next_page), }; } pub fn queryFollowing(self: *Self, user_id: Uuid, args: FollowingQueryArgs) !FollowingQueryResult { var all_args = std.mem.zeroInit(services.follows.QueryArgs, args); all_args.followed_by_id = user_id; const result = try services.follows.query(self.db, all_args, self.allocator); return FollowingQueryResult{ .items = result.items, .prev_page = FollowQueryArgs.from(result.prev_page), .next_page = FollowQueryArgs.from(result.next_page), }; } pub fn follow(self: *Self, followee: Uuid) !void { const result = try services.follows.create(self.db, self.user_id orelse return error.NoToken, followee, self.allocator); defer util.deepFree(self.allocator, result); } pub fn unfollow(self: *Self, followee: Uuid) !void { const result = try services.follows.delete(self.db, self.user_id orelse return error.NoToken, followee, self.allocator); defer util.deepFree(self.allocator, result); } pub fn getClusterMeta(self: *Self) !ClusterMeta { return try self.db.queryRow( ClusterMeta, \\SELECT \\ COUNT(DISTINCT note.id) AS note_count, \\ COUNT(DISTINCT actor.id) AS user_count, \\ COUNT(DISTINCT community.id) AS community_count \\FROM note, actor, community \\WHERE \\ actor.community_id = community.id AND \\ community.kind != 'admin' , .{}, self.allocator, ); } pub fn uploadFile(self: *Self, meta: UploadFileArgs, body: []const u8) !void { const user_id = self.user_id orelse return error.NoToken; return try services.drive.createFile(self.db, .{ .dir = meta.dir orelse "/", .filename = meta.filename, .owner = .{ .user_id = user_id }, .created_by = user_id, .description = meta.description, .content_type = meta.content_type, .sensitive = meta.sensitive, }, body, self.allocator); } pub fn driveMkdir(self: *Self, path: []const u8) !void { const user_id = self.user_id orelse return error.NoToken; try services.drive.mkdir(self.db, .{ .user_id = user_id }, path, self.allocator); } }; }