╭ changed ────╮ │src/event.zig│ ╰─────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1511 return std.meta.stringToEnum(EventKind, try cursor.readBytes(&buffer)) orelse error.InvalidEventKind; │ │1512 } │ │1513 │ │1514 +// the email of the event's author, or null when the id names no authored event │ │1515 +// of this kind │ │1516 +pub fn readAuthorEmail( │ │1517 + comptime repo_kind: rp.RepoKind, │ │1518 + comptime repo_opts: rp.RepoOpts(repo_kind), │ │1519 + io: std.Io, │ │1520 + allocator: std.mem.Allocator, │ │1521 + arena: *std.heap.ArenaAllocator, │ │1522 + repo: *rp.Repo(repo_kind, repo_opts), │ │1523 + kind: EventKind, │ │1524 + id: *const [event_id_size]u8, │ │1525 +) !?[]const u8 { │ │1526 + const record_map_key = switch (kind) { │ │1527 + .issue => Issue.record_map_key, │ │1528 + .discuss => Discussion.record_map_key, │ │1529 + .comment => Comment.record_map_key, │ │1530 + .attach => Attachment.record_map_key, │ │1531 + .patchrev => PatchRev.record_map_key, │ │1532 + .patch => Patch.record_map_key, │ │1533 + .user, .repo, .fork => return null, │ │1534 + }; │ │1535 + │ │1536 + const DB = EventDB(repo_opts.hash); │ │1537 + var event_db_maybe: ?LocalEventDB(repo_opts.hash) = if (repo_kind == .git) try LocalEventDB(repo_opts.hash).openReadOnly(io, allocator, repo.core.repo_dir) else null;│ │1538 + defer if (event_db_maybe) |*event_db| event_db.deinit(io, allocator); │ │1539 + const moment = (if (event_db_maybe) |*event_db| │ │1540 + currentMomentFromDb(repo_opts.hash, event_db.db) │ │1541 + else if (repo_kind == .git) │ │1542 + return null │ │1543 + else │ │1544 + currentMoment(repo_opts, repo)) catch return null; │ │1545 + │ │1546 + const records_cursor = (try moment.getCursor(hash.hashInt(repo_opts.hash, record_map_key))) orelse return null; │ │1547 + const records = try DB.HashMap(.read_only).init(records_cursor); │ │1548 + const record_cursor = (try records.getCursor(hash.hashInt(repo_opts.hash, id))) orelse return null; │ │1549 + const record = try DB.HashMap(.read_only).init(record_cursor); │ │1550 + return try readField(?[]const u8, DB, repo_opts.hash, arena, try record.getCursor(hash.hashInt(repo_opts.hash, "author_email"))); │ │1551 +} │ │1552 + │ │1553 // the event id embedded in a set entry's order key │ │1554 pub fn readOrderKeyId(comptime DB: type, kv_pair_cursor: DB.Cursor(.read_only)) ![event_id_size]u8 { │ │1555 var cursor = kv_pair_cursor; │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ─────────╮ │src/event/Repo.zig│ ╰──────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────╮ │41 │ │42 pub fn atLeast(self: Role, min: Role) bool { │ │43 return @intFromEnum(self) >= @intFromEnum(min); │ │44 + } │ │45 + │ │46 + // whether a user may change an event: anyone's with the write role, │ │47 + // otherwise only one they authored │ │48 + pub fn canModify(self: Role, user_email: []const u8, author_email_maybe: ?[]const u8) bool {│ │49 + if (self.atLeast(.write)) return true; │ │50 + const author_email = author_email_maybe orelse return false; │ │51 + return self.atLeast(.read) and std.mem.eql(u8, user_email, author_email); │ │52 } │ │53 }; │ │54 │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──╮ │src/try.zig│ ╰───────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────╮ │225 }, │ │226 }; │ │227 } │ │228 + // admin collaborates on every repo, so it can write everywhere while the│ │229 + // other users can only write to their own │ │230 + const admin_user_id_hex = std.fmt.bytesToHex(admin_user_id, .lower); │ │231 for (repo_data, 0..) |r, i| { │ │232 events_to_consume[user_data.len + i] = .{ │ │233 .id = std.fmt.bytesToHex(repo_event_ids[i], .lower), │ ╰──────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────╮ │239 .name = r.name, │ │240 .description = r.description, │ │241 .read_access = .public, │ │239 - .write_access = .public, │ │242 + .write_user_ids = &admin_user_id_hex,│ │243 }, │ │244 }, │ │245 }; │ ╰──────────────────────────────────────────────────────────────────╯ ╭ changed ─╮ │src/ui.zig│ ╰──────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1708 defer any_repo.deinit(io, allocator); │ │1709 break :blk switch (any_repo) { │ │1710 inline else => |*repo| (try repo.readRef(io, .{ .kind = .head, .name = branch })) != null, │ │1711 + }; │ │1712 + }, │ │1713 + }; │ │1714 + } │ │1715 + │ │1716 + // whether the actor may change the event `id` names │ │1717 + pub fn canModify( │ │1718 + self: RepoSource, │ │1719 + io: std.Io, │ │1720 + allocator: std.mem.Allocator, │ │1721 + actor: Actor, │ │1722 + kind: evt.EventKind, │ │1723 + id: *const [evt.event_id_size]u8, │ │1724 + ) !bool { │ │1725 + // a role that may change an event with no author needs no read │ │1726 + if (actor.role.canModify(actor.author.email, null)) return true; │ │1727 + │ │1728 + var arena = std.heap.ArenaAllocator.init(allocator); │ │1729 + defer arena.deinit(); │ │1730 + return switch (self.repo_kind) { │ │1731 + inline else => |repo_kind| blk: { │ │1732 + var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, self.localInitOpts()); │ │1733 + defer any_repo.deinit(io, allocator); │ │1734 + break :blk switch (any_repo) { │ │1735 + inline else => |*repo| actor.role.canModify(actor.author.email, try evt.readAuthorEmail(repo_kind, repo.self_repo_opts, io, allocator, &arena, repo, kind, id)),│ │1736 }; │ │1737 }, │ │1738 }; │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1739 } │ │1740 +}; │ │1741 + │ │1742 +// who a write acts as and the role they hold in its repo │ │1743 +pub const Actor = struct { │ │1744 + author: evt.CommitAuthor, │ │1745 + // local mode has no accounts, so it has no user id │ │1746 + user_id: ?[evt.event_id_size]u8 = null, │ │1747 + role: evt.Repo.Role, │ │1748 }; │ │1749 + │ │1750 +pub const Authorization = union(enum) { │ │1751 + actor: Actor, │ │1752 + login_required, │ │1753 + repo_not_found, │ │1754 + forbidden, │ │1755 +}; │ │1756 + │ │1757 +// whether the user may write to the repo `identity` names with at least │ │1758 +// `min_role` │ │1759 +pub fn authorizeUser( │ │1760 + moment: evt.AdminDB.HashMap(.read_only), │ │1761 + arena: *std.heap.ArenaAllocator, │ │1762 + user_id: [evt.event_id_size]u8, │ │1763 + identity: []const u8, │ │1764 + min_role: evt.Repo.Role, │ │1765 +) !Authorization { │ │1766 + const user = (try evt.User.readById(evt.AdminDB, evt.admin_repo_opts.hash, moment, arena, &user_id)) orelse return .login_required; │ │1767 + // a removed account's open sessions stop writing │ │1768 + if (user.removed) return .login_required; │ │1769 + const owner_repo = evt.parseOwnerRepoPath(identity) orelse return .repo_not_found; │ │1770 + const repo = (try evt.Repo.readByOwnerAndName(evt.AdminDB, evt.admin_repo_opts.hash, moment, arena, owner_repo.owner, owner_repo.name)) orelse return .repo_not_found;│ │1771 │ │1772 + const role = evt.Repo.roleOf(repo.repo, user_id); │ │1773 + if (!role.atLeast(min_role)) return .forbidden; │ │1774 + return .{ .actor = .{ │ │1775 + .author = .{ .name = user.event.name, .email = user.event.email }, │ │1776 + .user_id = user_id, │ │1777 + .role = role, │ │1778 + } }; │ │1779 +} │ │1780 + │ │1781 // events created in local mode have no account behind them, so the commit │ │1782 // author comes from the repo's config, which the global config feeds into. │ │1783 pub const local_author_fallback = evt.CommitAuthor{ .name = "haxy", .email = "user@haxy" }; │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1997 self.data.form_feedback = null; │ │1998 } │ │1999 │ │1936 - // the commit author for an event this session creates, or null when it may │ │1937 - // not create one │ │1938 - pub fn eventAuthor(self: *Self) !?evt.CommitAuthor { │ │2000 + // the actor for a write to the repo `identity` names, or null when the │ │2001 + // session may not write there with at least `min_role`. local mode always │ │2002 + // writes. │ │2003 + pub fn authorize(self: *Self, identity: []const u8, min_role: evt.Repo.Role) !?Actor { │ │2004 if (self.data.host_kind == .local) { │ │1940 - const src = self.local orelse return local_author_fallback; │ │1941 - const io = self.io orelse return local_author_fallback; │ │1942 - return try localAuthor(src, io, self.page_arena.child_allocator, self.page_arena); │ │2005 + const author = blk: { │ │2006 + const src = self.local orelse break :blk local_author_fallback; │ │2007 + const io = self.io orelse break :blk local_author_fallback; │ │2008 + break :blk try localAuthor(src, io, self.page_arena.child_allocator, self.page_arena); │ │2009 + }; │ │2010 + return .{ .author = author, .role = .owner }; │ │2011 } │ │1944 - const user_id = self.data.user_id orelse return null; │ │1945 - const moment = self.haxy_moment orelse return null; │ │1946 - const user = (try evt.User.readById(evt.AdminDB, evt.admin_repo_opts.hash, moment, self.page_arena, user_id)) orelse return null;│ │1947 - return .{ .name = user.event.name, .email = user.event.email }; │ │2012 + const user_id = self.userId() orelse return null; │ │2013 + // the session's moment predates this input, so read the roles afresh │ │2014 + const admin_repo = self.admin_repo orelse return null; │ │2015 + const moment = try evt.currentMoment(evt.admin_repo_opts, admin_repo); │ │2016 + return switch (try authorizeUser(moment, self.page_arena, user_id, identity, min_role)) { │ │2017 + .actor => |actor| actor, │ │2018 + .login_required, .repo_not_found, .forbidden => null, │ │2019 + }; │ │2020 } │ │2021 │ │2022 // queue an action for the host to drain this frame. │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ─────────────────╮ │src/ui/Repo/Attachment.zig│ ╰──────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │65 tb.getFocus().kind = .{ .custom = try std.fmt.allocPrint(pa, "{s}{s}", .{ ui.raw_link_prefix, try url(session.page_arena, identity, &entry.id) }) }; │ │66 try row.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); │ │67 │ │68 - if (session.data.host_kind == .local or session.data.user_id != null) { │ │69 - row.getFocus().kind = .{ .custom = try std.fmt.allocPrint(pa, "form:{s}/attachment:{s}/remove", .{ parent_url, &entry.id }) }; │ │70 - var remove = try wgt.TextBox.init(allocator, "✕", .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); │ │71 - errdefer remove.deinit(allocator); │ │72 - remove.getFocus().mode = .all; │ │73 - remove.getFocus().kind = .{ .custom = "submit" }; │ │74 - try row.children.put(allocator, remove.getFocus().id, .{ .widget = .{ .text_box = remove }, .rect = null, .min_size = .{ .width = 3, .height = null } });│ │75 - } │ │68 + row.getFocus().kind = .{ .custom = try std.fmt.allocPrint(pa, "form:{s}/attachment:{s}/remove", .{ parent_url, &entry.id }) }; │ │69 + var remove = try wgt.TextBox.init(allocator, "✕", .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); │ │70 + errdefer remove.deinit(allocator); │ │71 + remove.getFocus().mode = .all; │ │72 + remove.getFocus().kind = .{ .custom = "submit" }; │ │73 + try row.children.put(allocator, remove.getFocus().id, .{ .widget = .{ .text_box = remove }, .rect = null, .min_size = .{ .width = 3, .height = null } }); │ │74 │ │75 row.getFocus().child_id = row.children.keys()[0]; │ │76 try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .box = row }, .rect = null, .min_size = null }); │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──────────────╮ │src/ui/Repo/Comment.zig│ ╰───────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │192 try bar.children.put(allocator, parent.getFocus().id, .{ .widget = .{ .text_box = parent }, .rect = null, .min_size = .{ .width = @max(parent_text.len, " replying to ".len) + 2, .height = null } });│ │193 } │ │194 │ │195 - if (!entry.comment.removed and (session.data.host_kind == .local or session.data.user_id != null)) { │ │195 + if (!entry.comment.removed) { │ │196 var remove = try linkBox(allocator, session, "✕", removeRoute(thread_kind, identity, &entry.comment.event.thread_id, &entry.id) orelse return error.RouteTooLong); │ │197 errdefer remove.deinit(allocator); │ │198 try bar.children.put(allocator, remove.getFocus().id, .{ .widget = .{ .text_box = remove }, .rect = null, .min_size = .{ .width = 3, .height = null } }); │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──────────────╮ │src/ui/Repo/Patches.zig│ ╰───────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │307 !entry.conflicted and │ │308 entry.record.event.revision != null and │ │309 entry.record.event.status.kind() == .open and│ │310 - session.data.host_kind == .server and │ │311 - session.data.user_id != null; │ │310 + session.data.host_kind == .server; │ │311 } │ │312 │ │313 pub fn create( │ ╰──────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯