const std = @import("std"); const builtin = @import("builtin"); const util = @import("util"); const sql = @import("sql"); const common = @import("./common.zig"); const actors = @import("./actors.zig"); const Uuid = util.Uuid; const DateTime = util.DateTime; pub const Community = struct { pub const Kind = enum { admin, local, pub const jsonStringify = util.jsonSerializeEnumAsString; }; pub const Scheme = enum { https, http, pub const jsonStringify = util.jsonSerializeEnumAsString; }; id: Uuid, owner_id: ?Uuid, host: []const u8, name: []const u8, scheme: Scheme, kind: Kind, created_at: DateTime, }; pub const CreateOptions = struct { name: ?[]const u8 = null, kind: Community.Kind = .local, }; pub const CreateError = error{ UnsupportedScheme, InvalidOrigin, CommunityExists, } || sql.DatabaseError; pub fn create(db: anytype, origin: []const u8, options: CreateOptions, alloc: std.mem.Allocator) CreateError!Uuid { const scheme_len = std.mem.indexOfScalar(u8, origin, ':') orelse return error.InvalidOrigin; const scheme_str = origin[0..scheme_len]; const scheme = std.meta.stringToEnum(Community.Scheme, scheme_str) orelse return error.UnsupportedScheme; // host must be in the format "{scheme}://{host}" if (origin.len <= scheme_len + ("://").len or origin[scheme_len] != ':' or origin[scheme_len + 1] != '/' or origin[scheme_len + 2] != '/') return error.InvalidOrigin; const host = origin[scheme_len + 3 ..]; // community cannot use non-default ports (except for testing) // NOTE: Do not add, say localhost and localhost:80 or bugs may happen. // Avoid using non-default ports unless a test can't be conducted without it. if (std.mem.indexOfScalar(u8, host, ':') != null and builtin.mode != .Debug) return error.InvalidOrigin; // community cannot be hosted on a path if (std.mem.indexOfScalar(u8, host, '/') != null) return error.InvalidOrigin; // Require TLS on production builds if (scheme != .https and builtin.mode != .Debug) return error.UnsupportedScheme; const id = Uuid.randV4(util.getThreadPrng()); // TODO: wrap this in TX var tx = try db.beginOrSavepoint(); errdefer tx.rollback(); if (tx.queryRow( std.meta.Tuple(&.{Uuid}), "SELECT id FROM community WHERE host = $1", .{host}, alloc, )) |_| { return error.CommunityExists; } else |err| switch (err) { error.NoRows => {}, else => |e| return e, } const name = options.name orelse host; try tx.insert("community", .{ .id = id, .owner_id = null, .host = host, .name = name, .scheme = scheme, .kind = options.kind, .created_at = DateTime.now(), }, alloc); if (options.kind == .local) { const actor_id = actors.create(tx, "community.actor", id, true, alloc) catch |err| switch (err) { error.UsernameContainsInvalidChar, error.UsernameTooLong, error.UsernameEmpty, error.UsernameTaken, => unreachable, else => @panic("TODO"), }; try tx.exec( \\UPDATE community \\SET community_actor_id = $1 \\WHERE id = $2 , .{ actor_id, id }, alloc); } try tx.commitOrRelease(); return id; } pub const GetError = error{ NotFound, DatabaseFailure, }; fn getWhere( db: anytype, comptime where: []const u8, args: anytype, alloc: std.mem.Allocator, ) GetError!Community { return db.queryRow( Community, std.fmt.comptimePrint( \\SELECT {s} \\FROM community \\WHERE {s} \\LIMIT 1 , .{ comptime util.comptimeJoin(",", std.meta.fieldNames(Community)), where, }, ), args, alloc, ) catch |err| switch (err) { error.NoRows => error.NotFound, else => error.DatabaseFailure, }; } pub fn get(db: anytype, id: Uuid, alloc: std.mem.Allocator) GetError!Community { return getWhere(db, "id = $1", .{id}, alloc); } pub fn getByHost(db: anytype, host: []const u8, alloc: std.mem.Allocator) GetError!Community { return getWhere(db, "host = $1", .{host}, alloc); } pub fn transferOwnership(db: anytype, community_id: Uuid, new_owner: Uuid) !void { // TODO: check that this actually found/updated the row (needs update to sql lib) db.exec( "UPDATE community SET owner_id = $1 WHERE id = $2", .{ new_owner, community_id }, null, ) catch return error.DatabaseFailure; } pub const QueryArgs = struct { pub const OrderBy = enum { name, host, created_at, pub const jsonStringify = util.jsonSerializeEnumAsString; }; pub const Direction = common.Direction; pub const PageDirection = common.PageDirection; pub const Prev = std.meta.Child(std.meta.fieldInfo(QueryArgs, .prev).field_type); pub const OrderVal = std.meta.fieldInfo(Prev, .order_val).field_type; // Max items to fetch max_items: usize = 20, // Selection filters owner_id: ?Uuid = null, // searches for communities owned by this user like: ?[]const u8 = null, // searches for communities with host or name LIKE '%?%' created_before: ?DateTime = null, created_after: ?DateTime = null, // Ordering parameter order_by: OrderBy = .created_at, direction: Direction = .ascending, // Page start parameter // This struct is a reference to the last value scanned // If prev is present, then prev.order_val must have the same tag as order_by // "prev" here refers to it being the previous value returned. It may be that // prev refers to the item directly after the results you are about to recieve, // if you are querying the previous page. prev: ?struct { id: Uuid, order_val: union(OrderBy) { name: []const u8, host: []const u8, created_at: DateTime, }, } = null, // What direction to scan the page window // If "forward", then "prev" is interpreted as the item directly before the items // to query, in the direction of "direction" above. If "backward", then the opposite page_direction: PageDirection = .forward, }; pub const QueryResult = struct { items: []const Community, prev_page: QueryArgs, next_page: QueryArgs, }; const max_max_items = 100; pub const QueryError = error{ PageArgMismatch, DatabaseError, }; // Retrieves up to `args.max_items` Community entries matching the given query // arguments. // `args.max_items` is only a request, and fewer entries may be returned. pub fn query(db: anytype, args: QueryArgs, alloc: std.mem.Allocator) !QueryResult { var builder = sql.QueryBuilder.init(alloc); defer builder.deinit(); try builder.array.appendSlice( std.fmt.comptimePrint( \\SELECT {s} \\FROM community \\ , .{comptime util.comptimeJoin(",", std.meta.fieldNames(Community))}), ); const max_items = if (args.max_items > max_max_items) max_max_items else args.max_items; if (args.owner_id != null) try builder.andWhere("owner_id = $1"); if (args.like != null) try builder.andWhere("(host LIKE ('%' || $2 || '%') OR name LIKE ('%' || $2 || '%'))"); if (args.created_before != null) try builder.andWhere("created_at < $3"); if (args.created_after != null) try builder.andWhere("created_at > $4"); if (args.prev) |prev| { if (prev.order_val != args.order_by) return error.PageArgMismatch; switch (args.order_by) { .name => try builder.andWhere("(name, id)"), .host => try builder.andWhere("(host, id)"), .created_at => try builder.andWhere("(created_at, id)"), } switch (args.direction) { .ascending => switch (args.page_direction) { .forward => try builder.appendSlice(" > "), .backward => try builder.appendSlice(" < "), }, .descending => switch (args.page_direction) { .forward => try builder.appendSlice(" < "), .backward => try builder.appendSlice(" > "), }, } _ = try builder.array.appendSlice("($5, $6)"); } const direction_string = switch (args.direction) { .ascending => " ASC ", .descending => " DESC ", }; _ = try builder.array.appendSlice("\nORDER BY "); _ = try builder.array.appendSlice(@tagName(args.order_by)); _ = try builder.array.appendSlice(direction_string); _ = try builder.array.appendSlice(", id "); _ = try builder.array.appendSlice(direction_string); _ = try builder.array.appendSlice("\nLIMIT $7"); const query_args = blk: { const ord_val = if (args.prev) |prev| @as(?QueryArgs.OrderVal, prev.order_val) else null; const id = if (args.prev) |prev| @as(?Uuid, prev.id) else null; break :blk .{ args.owner_id, args.like, args.created_before, args.created_after, ord_val, id, max_items, }; }; try builder.array.append(0); var results = try db.queryRowsWithOptions( Community, std.meta.assumeSentinel(builder.array.items, 0), query_args, max_items, .{ .allocator = alloc, .ignore_unused_arguments = true }, ); errdefer util.deepFree(alloc, results); var next_page = args; var prev_page = args; prev_page.page_direction = .backward; next_page.page_direction = .forward; if (results.len != 0) { prev_page.prev = .{ .id = results[0].id, .order_val = getOrderVal(results[0], args.order_by), }; next_page.prev = .{ .id = results[results.len - 1].id, .order_val = getOrderVal(results[results.len - 1], args.order_by), }; } // TODO: This will give incorrect links on an empty page return QueryResult{ .items = results, .next_page = next_page, .prev_page = prev_page, }; } fn getOrderVal(community: Community, order_val: QueryArgs.OrderBy) QueryArgs.OrderVal { return switch (order_val) { .name => .{ .name = community.name }, .host => .{ .host = community.host }, .created_at => .{ .created_at = community.created_at }, }; } pub fn adminCommunityId(db: anytype) !Uuid { const row = db.queryRow( std.meta.Tuple(&.{Uuid}), "SELECT id FROM community WHERE kind = 'admin' LIMIT 1", {}, null, ) catch |err| return switch (err) { error.NoRows => error.NotFound, else => error.DatabaseFailure, }; return row[0]; }