const std = @import("std"); const util = @import("util"); const sql = @import("sql"); const common = @import("./common.zig"); const files = @import("./files.zig"); const types = @import("./types.zig"); const Uuid = util.Uuid; const DateTime = util.DateTime; pub const Actor = types.actors.Actor; pub const CreateError = error{ UsernameTaken, UsernameContainsInvalidChar, UsernameTooLong, UsernameEmpty, DatabaseFailure, }; pub const LookupError = error{ DatabaseFailure, }; pub fn lookupByUsername( db: anytype, username: []const u8, community_id: Uuid, alloc: std.mem.Allocator, ) LookupError!?Uuid { const row = db.queryRow( std.meta.Tuple(&.{Uuid}), \\SELECT id \\FROM actor \\WHERE username = $1 AND community_id = $2 \\LIMIT 1 , .{ username, community_id }, alloc, ) catch |err| return switch (err) { error.NoRows => null, else => error.DatabaseFailure, }; return row[0]; } pub const max_username_chars = 32; pub const UsernameValidationError = error{ UsernameContainsInvalidChar, UsernameTooLong, UsernameEmpty, }; /// Usernames must satisfy: /// - Be at least 1 character /// - Be no more than 32 characters /// - All characters are in [A-Za-z0-9_] pub fn validateUsername(username: []const u8, lax: bool) UsernameValidationError!void { if (username.len == 0) return error.UsernameEmpty; if (username.len > max_username_chars) return error.UsernameTooLong; for (username) |ch| { const valid = std.ascii.isAlNum(ch) or ch == '_' or (lax and ch == '.'); if (!valid) return error.UsernameContainsInvalidChar; } } pub fn create( db: anytype, username: []const u8, community_id: Uuid, lax_username: bool, alloc: std.mem.Allocator, ) CreateError!Uuid { const id = Uuid.randV4(util.getThreadPrng()); try validateUsername(username, lax_username); db.insert("actor", .{ .id = id, .username = username, .community_id = community_id, .created_at = DateTime.now(), }, alloc) catch |err| return switch (err) { error.UniqueViolation => error.UsernameTaken, else => error.DatabaseFailure, }; return id; } pub const GetError = error{ NotFound, DatabaseFailure }; pub fn get(db: anytype, id: Uuid, alloc: std.mem.Allocator) GetError!Actor { return db.queryRow( Actor, \\SELECT \\ actor.id, \\ actor.username, \\ community.host, \\ actor.display_name, \\ actor.bio, \\ actor.avatar_file_id, \\ actor.header_file_id, \\ actor.profile_fields, \\ actor.community_id, \\ actor.created_at, \\ actor.updated_at \\FROM actor JOIN community \\ ON actor.community_id = community.id \\WHERE actor.id = $1 \\LIMIT 1 , .{id}, alloc, ) catch |err| switch (err) { error.NoRows => return error.NotFound, else => |e| { std.log.err("{}, {?}", .{ e, @errorReturnTrace() }); return error.DatabaseFailure; }, }; } pub const max_fields = 32; pub const max_display_name_len = 128; pub const max_bio = 1 << 16; pub fn updateProfile(db: anytype, id: Uuid, new: types.actors.ProfileUpdateArgs, alloc: std.mem.Allocator) !void { var builder = sql.QueryBuilder.init(alloc); defer builder.deinit(); try builder.appendSlice("UPDATE actor"); if (new.display_name) |_| try builder.set("display_name", "$2"); if (new.bio) |_| try builder.set("bio", "$3"); if (new.avatar_file_id) |_| try builder.set("avatar_file_id", "$4"); if (new.header_file_id) |_| try builder.set("header_file_id", "$5"); if (new.profile_fields) |_| try builder.set("profile_fields", "$6"); if (builder.set_statements_appended == 0) return error.NoChange; try builder.set("updated_at", "$7"); try builder.andWhere("id = $1"); const profile_fields = if (new.profile_fields) |pf| try std.json.stringifyAlloc(alloc, pf, .{}) else null; defer if (profile_fields) |pf| alloc.free(pf); const tx = try db.begin(); errdefer tx.rollback(); if (new.display_name) |maybe_dn| if (maybe_dn) |dn| { if (dn.len > max_display_name_len) return error.DisplayNameTooLong; }; if (new.bio) |b| if (b.len > max_bio) return error.BioTooLong; if (new.avatar_file_id) |maybe_file_id| if (maybe_file_id) |file_id| { const info = try files.get(tx, file_id, alloc); defer util.deepFree(alloc, info); if (!Uuid.eql(id, info.owner_id)) return error.FileAccessDenied; if (info.status != .uploaded) return error.FileNotReady; }; if (new.header_file_id) |maybe_file_id| if (maybe_file_id) |file_id| { const info = try files.get(tx, file_id, alloc); defer util.deepFree(alloc, info); if (!Uuid.eql(id, info.owner_id)) return error.FileAccessDenied; if (info.status != .uploaded) return error.FileNotReady; }; if (new.profile_fields) |f| if (f.len > max_fields) return error.TooManyFields; try tx.execWithOptions(try builder.terminate(), .{ id, new.display_name orelse null, new.bio orelse null, new.avatar_file_id orelse null, new.header_file_id orelse null, profile_fields, DateTime.now(), }, .{ .allocator = alloc, .ignore_unused_arguments = true }); try tx.commit(); }