const std = @import("std"); const util = @import("util"); const sql = @import("sql"); const DateTime = util.DateTime; const Uuid = util.Uuid; const default_avatar = "static/default_avi.png"; const services = struct { pub const communities = @import("./services/communities.zig"); pub const actors = @import("./services/actors.zig"); pub const auth = @import("./services/auth.zig"); pub const drive = @import("./services/drive.zig"); pub const files = @import("./services/files.zig"); pub const invites = @import("./services/invites.zig"); pub const notes = @import("./services/notes.zig"); pub const follows = @import("./services/follows.zig"); }; test { _ = @import("./methods/auth.zig"); } const types = @import("./types.zig"); pub const QueryResult = types.QueryResult; pub const Account = types.Account; pub const Actor = types.Actor; pub const Community = types.Community; pub const Invite = types.Invite; pub const Note = types.Note; pub const Token = types.Token; 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 = Invite.Kind; name: ?[]const u8 = null, lifespan: ?DateTime.Duration = null, max_uses: ?usize = null, // admin only options kind: Kind = .user, to_community: ?Uuid = null, }; pub const UserResponse = struct { id: Uuid, username: []const u8, host: []const u8, display_name: ?[]const u8, bio: []const u8, avatar_file_id: ?Uuid, avatar_url: []const u8, header_file_id: ?Uuid, header_url: ?[]const u8, profile_fields: []const Actor.ProfileField, community_id: Uuid, created_at: DateTime, updated_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 TimelineArgs = struct { pub const PageDirection = Note.QueryArgs.PageDirection; pub const Prev = Note.QueryArgs.Prev; max_items: usize = 20, created_before: ?DateTime = null, created_after: ?DateTime = null, prev: ?Prev = null, page_direction: PageDirection = .forward, fn from(args: Note.QueryArgs) 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: []Note, 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 const DriveEntry = union(enum) { const Kind = services.drive.Kind; dir: struct { id: Uuid, owner_id: Uuid, name: ?[]const u8, path: []const u8, parent_directory_id: ?Uuid, kind: Kind = .dir, // If null = not enumerated children: ?[]const DriveEntry, }, file: struct { id: Uuid, owner_id: Uuid, name: ?[]const u8, path: []const u8, parent_directory_id: ?Uuid, kind: Kind = .file, meta: FileUpload, }, }; pub const FileUpload = types.FileUpload; pub const DriveGetResult = union(services.drive.Kind) { dir: struct { entry: DriveEntry, children: []DriveEntry, }, file: struct { entry: DriveEntry, file: FileUpload, }, }; pub const FileResult = struct { meta: FileUpload, data: []const u8, }; pub const InviteResponse = struct { code: []const u8, kind: Invite.Kind, name: []const u8, creator: UserResponse, url: []const u8, community_id: Uuid, created_at: DateTime, expires_at: ?DateTime, times_used: usize, max_uses: ?usize, }; 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, services); 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, .context = .{ .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, .context = .{ .community = community, .token_info = token_info, }, .allocator = alloc, }; } }; pub const ApiContext = struct { token_info: ?Token.Info = null, community: Community, pub fn userId(self: ApiContext) ?Uuid { if (self.token_info) |t| return t.user_id else return null; } }; fn ApiConn(comptime DbConn: type, comptime models: anytype) type { return struct { const Self = @This(); db: DbConn, context: ApiContext, allocator: std.mem.Allocator, pub fn close(self: *Self) void { util.deepFree(self.allocator, self.context.community); if (self.context.token_info) |info| util.deepFree(self.allocator, info); self.db.releaseConnection(); } fn isAdmin(self: *Self) bool { // TODO return self.context.userId() != null and self.context.community.kind == .admin; } 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.context.token_info) |info| { const user = try models.actors.get(self.db, info.user_id, self.allocator); defer util.deepFree(self.allocator, user); const username = try util.deepClone(self.allocator, user.username); errdefer util.deepFree(self.allocator, username); return AuthorizationInfo{ .id = user.id, .username = username, .community_id = self.context.community.id, .host = try util.deepClone(self.allocator, self.context.community.host), .issued_at = info.issued_at, }; } return error.TokenRequired; } pub fn createCommunity(self: *Self, origin: []const u8, name: ?[]const u8) !Community { if (!self.isAdmin()) { return error.PermissionDenied; } const tx = try self.db.begin(); errdefer tx.rollback(); const community_id = try models.communities.create( tx, origin, .{ .name = name }, self.allocator, ); const community = models.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) !InviteResponse { // Only logged in users can make invites const user_id = self.context.userId() 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.context.community.id; // Users can only make user invites if (options.kind != .user and !self.isAdmin()) return error.PermissionDenied; const invite_id = try models.invites.create(self.db, user_id, community_id, options.name orelse "", .{ .lifespan = options.lifespan, .max_uses = options.max_uses, .kind = options.kind, }, self.allocator); const invite = try models.invites.get(self.db, invite_id, self.allocator); errdefer util.deepFree(self.allocator, invite); const url = if (options.to_community) |cid| blk: { const community = try models.communities.get(self.db, cid, self.allocator); defer util.deepFree(self.allocator, community); break :blk try std.fmt.allocPrint( self.allocator, "{s}://{s}/invite/{s}", .{ @tagName(community.scheme), community.host, invite.code }, ); } else try std.fmt.allocPrint( self.allocator, "{s}://{s}/invite/{s}", .{ @tagName(self.context.community.scheme), self.context.community.host, invite.code }, ); errdefer util.deepFree(self.allocator, url); const user = try self.getUserUnchecked(self.db, user_id); return InviteResponse{ .code = invite.code, .kind = invite.kind, .name = invite.name, .creator = user, .url = url, .community_id = invite.community_id, .created_at = invite.created_at, .expires_at = invite.expires_at, .times_used = invite.times_used, .max_uses = invite.max_uses, }; } fn isInviteValid(invite: Invite) bool { if (invite.max_uses != null and invite.times_used >= invite.max_uses.?) return false; if (invite.expires_at != null and DateTime.now().isAfter(invite.expires_at.?)) return false; return true; } pub usingnamespace @import("./methods/auth.zig").methods(models); // 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 models.invites.getByCode(tx, code, self.context.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.context.community.id)) return error.WrongCommunity; // if (!isInviteValid(invite)) return error.InvalidInvite; // } // const invite_kind = if (maybe_invite) |inv| inv.kind else .user; // if (self.context.community.kind == .admin) @panic("Unimplmented"); // const user_id = try models.auth.register( // tx, // username, // password, // self.context.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 models.communities.transferOwnership(tx, self.context.community.id, user_id); // }, // } // const user = self.getUserUnchecked(tx, user_id) catch |err| switch (err) { // error.NotFound => return error.Unexpected, // else => |e| return e, // }; // errdefer util.deepFree(self.allocator, user); // try tx.commit(); // return user; // } fn getUserUnchecked(self: *Self, db: anytype, user_id: Uuid) !UserResponse { const user = try models.actors.get(db, user_id, self.allocator); const avatar_url = if (user.avatar_file_id) |fid| try std.fmt.allocPrint( self.allocator, "{s}://{s}/media/{}", .{ @tagName(self.context.community.scheme), self.context.community.host, fid }, ) else try std.fmt.allocPrint( self.allocator, "{s}://{s}/{s}", .{ @tagName(self.context.community.scheme), self.context.community.host, default_avatar }, ); errdefer self.allocator.free(avatar_url); const header_url = if (user.header_file_id) |fid| try std.fmt.allocPrint( self.allocator, "{s}://{s}/media/{}", .{ @tagName(self.context.community.scheme), self.context.community.host, fid }, ) else null; return UserResponse{ .id = user.id, .username = user.username, .host = user.host, .display_name = user.display_name, .bio = user.bio, .avatar_file_id = user.avatar_file_id, .avatar_url = avatar_url, .header_file_id = user.header_file_id, .header_url = header_url, .profile_fields = user.profile_fields, .community_id = user.community_id, .created_at = user.created_at, .updated_at = user.updated_at, }; } pub fn getUser(self: *Self, user_id: Uuid) !UserResponse { const user = try self.getUserUnchecked(self.db, user_id); errdefer util.deepFree(self.allocator, user); if (self.context.userId() == null) { if (!Uuid.eql(self.context.community.id, user.community_id)) return error.NotFound; } return user; } pub fn createNote(self: *Self, content: []const u8) !Note { // You cannot post on admin accounts if (self.context.community.kind == .admin) return error.WrongCommunity; // Only authenticated users can post const user_id = self.context.userId() orelse return error.TokenRequired; const note_id = try models.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) !Note { const note = try models.notes.get(self.db, note_id, self.allocator); errdefer util.deepFree(self.allocator, note); // Only serve community-specific notes on unauthenticated requests if (self.context.userId() == null) { if (!Uuid.eql(self.context.community.id, note.author.community_id)) return error.NotFound; } return note; } pub fn queryCommunities(self: *Self, args: Community.QueryArgs) !QueryResult(Community) { if (!self.isAdmin()) return error.PermissionDenied; return try models.communities.query(self.db, args, self.allocator); } pub fn globalTimeline(self: *Self, args: TimelineArgs) !TimelineResult { const all_args = std.mem.zeroInit(Note.QueryArgs, args); const result = try models.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(Note.QueryArgs, args); all_args.community_id = self.context.community.id; const result = try models.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.context.userId() == null) return error.NoToken; var all_args = std.mem.zeroInit(Note.QueryArgs, args); all_args.followed_by = self.context.userId(); const result = try models.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(models.follows.QueryArgs, args); all_args.followee_id = user_id; const result = try models.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(models.follows.QueryArgs, args); all_args.followed_by_id = user_id; const result = try models.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 models.follows.create(self.db, self.context.userId() orelse return error.NoToken, followee, self.allocator); defer util.deepFree(self.allocator, result); } pub fn unfollow(self: *Self, followee: Uuid) !void { const result = try models.follows.delete(self.db, self.context.userId() 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, ); } fn backendDriveEntryToFrontend(self: *Self, entry: models.drive.Entry, recurse: bool) !DriveEntry { return if (entry.file_id) |file_id| .{ .file = .{ .id = entry.id, .owner_id = entry.owner_id, .name = entry.name, .path = entry.path, .parent_directory_id = entry.parent_directory_id, .meta = try models.files.get(self.db, file_id, self.allocator), }, } else .{ .dir = .{ .id = entry.id, .owner_id = entry.owner_id, .name = entry.name, .path = entry.path, .parent_directory_id = entry.parent_directory_id, .children = blk: { if (!recurse) break :blk null; const children = try models.drive.list(self.db, entry.id, self.allocator); const result = self.allocator.alloc(DriveEntry, children.len) catch |err| { util.deepFree(self.allocator, children); return err; }; var count: usize = 0; errdefer for (children) |child, i| { if (i < count) util.deepFree(self.allocator, result[i]) else util.deepFree(self.allocator, child); }; defer self.allocator.free(children); errdefer self.allocator.free(result); for (children) |child, i| { result[i] = try backendDriveEntryToFrontend(self, child, false); count += 1; } break :blk result; }, }, }; } pub fn driveUpload(self: *Self, meta: UploadFileArgs, body: []const u8) !DriveEntry { const user_id = self.context.userId() orelse return error.NoToken; const file_id = try models.files.create(self.db, user_id, .{ .filename = meta.filename, .description = meta.description, .content_type = meta.content_type, .sensitive = meta.sensitive, }, body, self.allocator); const entry = entry: { errdefer models.files.delete(self.db, file_id, self.allocator) catch |err| { std.log.err("Unable to delete file {}: {}", .{ file_id, err }); }; break :entry models.drive.create( self.db, user_id, meta.dir, meta.filename, file_id, self.allocator, ) catch |err| switch (err) { error.PathAlreadyExists => { var buf: [256]u8 = undefined; var split = std.mem.splitBackwards(u8, meta.filename, "."); const ext = split.first(); const name = split.rest(); const new_name = try std.fmt.bufPrint(&buf, "{s}.{s}.{s}", .{ name, file_id, ext }); break :entry try models.drive.create( self.db, user_id, meta.dir, new_name, file_id, self.allocator, ); }, else => |e| return e, }; }; errdefer util.deepFree(self.allocator, entry); return try self.backendDriveEntryToFrontend(entry, true); } pub fn driveMkdir(self: *Self, parent_path: []const u8, name: []const u8) !DriveEntry { const user_id = self.context.userId() orelse return error.NoToken; const entry = try models.drive.create(self.db, user_id, parent_path, name, null, self.allocator); errdefer util.deepFree(self.allocator, entry); return try self.backendDriveEntryToFrontend(entry, true); } pub fn driveDelete(self: *Self, path: []const u8) !void { const user_id = self.context.userId() orelse return error.NoToken; const entry = try models.drive.stat(self.db, user_id, path, self.allocator); defer util.deepFree(self.allocator, entry); try models.drive.delete(self.db, entry.id, self.allocator); if (entry.file_id) |file_id| try models.files.delete(self.db, file_id, self.allocator); } pub fn driveMove(self: *Self, src: []const u8, dest: []const u8) !DriveEntry { const user_id = self.context.userId() orelse return error.NoToken; try models.drive.move(self.db, user_id, src, dest, self.allocator); return try self.driveGet(dest); } pub fn driveGet(self: *Self, path: []const u8) !DriveEntry { const user_id = self.context.userId() orelse return error.NoToken; const entry = try models.drive.stat(self.db, user_id, path, self.allocator); errdefer util.deepFree(self.allocator, entry); return try self.backendDriveEntryToFrontend(entry, true); } pub fn driveUpdate(self: *Self, path: []const u8, meta: FileUpload.UpdateArgs) !DriveEntry { const user_id = self.context.userId() orelse return error.NoToken; std.log.debug("{s}", .{path}); const entry = try models.drive.stat(self.db, user_id, path, self.allocator); defer util.deepFree(self.allocator, entry); std.log.debug("{}", .{entry.id}); try models.files.update(self.db, entry.file_id orelse return error.NotAFile, meta, self.allocator); return try self.driveGet(path); } pub fn fileDereference(self: *Self, id: Uuid) !FileResult { const meta = try models.files.get(self.db, id, self.allocator); errdefer util.deepFree(self.allocator, meta); return FileResult{ .meta = meta, .data = try models.files.deref(self.allocator, id), }; } pub fn updateUserProfile(self: *Self, id: Uuid, data: Actor.ProfileUpdateArgs) !void { if (!Uuid.eql(id, self.context.userId() orelse return error.NoToken)) return error.AccessDenied; try models.actors.updateProfile(self.db, id, data, self.allocator); } pub fn validateInvite(self: *Self, code: []const u8) !InviteResponse { const invite = models.invites.getByCode( self.db, code, self.context.community.id, self.allocator, ) catch |err| switch (err) { error.NotFound => return error.InvalidInvite, else => return error.DatabaseFailure, }; errdefer util.deepFree(self.allocator, invite); if (!Uuid.eql(invite.community_id, self.context.community.id)) return error.InvalidInvite; if (!isInviteValid(invite)) return error.InvalidInvite; const url = try std.fmt.allocPrint( self.allocator, "{s}://{s}/invite/{s}", .{ @tagName(self.context.community.scheme), self.context.community.host, invite.code }, ); errdefer util.deepFree(self.allocator, url); const creator = self.getUserUnchecked(self.db, invite.created_by) catch |err| switch (err) { error.NotFound => return error.Unexpected, else => return error.DatabaseFailure, }; return InviteResponse{ .code = invite.code, .name = invite.name, .kind = invite.kind, .creator = creator, .url = url, .community_id = invite.community_id, .created_at = invite.created_at, .expires_at = invite.expires_at, .times_used = invite.times_used, .max_uses = invite.max_uses, }; } }; }