From 8d9e3da2493ec667b5d36a627b57106b879e9d77 Mon Sep 17 00:00:00 2001 From: Pragmatic Software Date: Thu, 23 Apr 2020 16:19:36 -0700 Subject: [PATCH] Major refactor of Users --- PBot/Capabilities.pm | 64 ++-- PBot/HashObject.pm | 2 +- PBot/Users.pm | 373 +++++++++++++--------- PBot/VERSION.pm | 2 +- data/last_update | 2 +- data/users | 4 +- doc/Admin.md | 27 +- doc/FAQ.md | 4 +- doc/QuickStart.md | 6 +- doc/Registry.md | 2 +- updates/3512_update_users.pl | 54 ++++ updates/lib3512/DualIndexHashObject.pm | 426 +++++++++++++++++++++++++ updates/lib3512/HashObject.pm | 251 +++++++++++++++ 13 files changed, 1007 insertions(+), 210 deletions(-) create mode 100755 updates/3512_update_users.pl create mode 100644 updates/lib3512/DualIndexHashObject.pm create mode 100644 updates/lib3512/HashObject.pm diff --git a/PBot/Capabilities.pm b/PBot/Capabilities.pm index a8ae123d..dd0fafa4 100644 --- a/PBot/Capabilities.pm +++ b/PBot/Capabilities.pm @@ -74,7 +74,9 @@ sub exists { $cap = lc $cap; foreach my $c ($self->{caps}->get_keys) { return 1 if $c eq $cap; - foreach my $sub_cap ($self->{caps}->get_keys($c)) { return 1 if $sub_cap eq $cap; } + foreach my $sub_cap ($self->{caps}->get_keys($c)) { + return 1 if $sub_cap eq $cap; + } } return 0; } @@ -82,10 +84,15 @@ sub exists { sub add { my ($self, $cap, $subcap, $dontsave) = @_; if (not defined $subcap) { - if (not $self->{caps}->exists($cap)) { $self->{caps}->add($cap, {}, $dontsave); } + if (not $self->{caps}->exists($cap)) { + $self->{caps}->add($cap, {}, $dontsave); + } } else { - if ($self->{caps}->exists($cap)) { $self->{caps}->set($cap, $subcap, 1, $dontsave); } - else { $self->{caps}->add($cap, {$subcap => 1}, $dontsave); } + if ($self->{caps}->exists($cap)) { + $self->{caps}->set($cap, $subcap, 1, $dontsave); + } else { + $self->{caps}->add($cap, { $subcap => 1 }, $dontsave); + } } } @@ -94,7 +101,9 @@ sub remove { $cap = lc $cap; if (not defined $subcap) { foreach my $c ($self->{caps}->get_keys) { - foreach my $sub_cap ($self->{caps}->get_keys($c)) { $self->{caps}->remove($c, $sub_cap, 1) if $sub_cap eq $cap; } + foreach my $sub_cap ($self->{caps}->get_keys($c)) { + $self->{caps}->remove($c, $sub_cap, 1) if $sub_cap eq $cap; + } $self->{caps}->remove($c, undef, 1) if $c eq $cap; } } else { @@ -154,43 +163,40 @@ sub capcmd { return "Usage: cap whohas ; Lists all users who have " if not defined $cap; return "No such capability $cap." if not $self->exists($cap); my $result = "Users with capability $cap: "; - my $matched = 0; my $users = $self->{pbot}->{users}->{users}; - foreach my $channel (sort $users->get_keys) { - my @matches; - foreach my $hostmask (sort $users->get_keys($channel)) { - my $u = $users->get_data($channel, $hostmask); - push @matches, $u->{name} if $self->userhas($u, $cap); - } - if (@matches) { - $result .= '; ' if $matched; - my $global = $matched ? 'global: ' : ''; - $result .= $users->get_key_name($channel) eq '.*' ? $global : $users->get_key_name($channel) . ': '; - $result .= join ', ', @matches; - $matched = 1; - } + my @matches; + foreach my $name (sort $users->get_keys) { + my $u = $users->get_data($name); + push @matches, $users->get_key_name($name) if $self->userhas($u, $cap); } - $result .= 'nobody' if not $matched; + + if (@matches) { + $result .= join(', ', @matches); + } else { + $result .= 'nobody'; + } + return $result; } when ('userhas') { - my ($hostmask, $cap) = $self->{pbot}->{interpreter}->split_args($stuff->{arglist}, 2); - return "Usage: cap userhas [capability]; Lists capabilities belonging to " if not defined $hostmask; + my ($name, $cap) = $self->{pbot}->{interpreter}->split_args($stuff->{arglist}, 2); + return "Usage: cap userhas [capability]; Lists capabilities belonging to " if not defined $name; $cap = lc $cap if defined $cap; - my $u = $self->{pbot}->{users}->find_user($from, $hostmask, 1); + my $u = $self->{pbot}->{users}->{users}->get_data($name); if (not defined $u) { - $from = 'global' if $from !~ /^#/; - return "No such user $hostmask in $from."; + return "No such user $name."; } + $name = $self->{pbot}->{users}->{users}->get_key_name($name); + if (defined $cap) { return "Try again. No such capability $cap." if not $self->exists($cap); - if ($self->userhas($u, $cap)) { return "Yes. User $u->{name} has capability $cap."; } - else { return "No. User $u->{name} does not have capability $cap."; } + if ($self->userhas($u, $cap)) { return "Yes. User $name has capability $cap."; } + else { return "No. User $name does not have capability $cap."; } } else { - my $result = "User $u->{name} has capabilities: "; + my $result = "User $name has capabilities: "; my @groups; my @single; foreach my $key (sort keys %{$u}) { @@ -201,7 +207,7 @@ sub capcmd { else { push @single, $key; } } if (@groups or @single) { $result .= join ', ', @groups, @single; } - else { $result = "User $u->{name} has no capabilities."; } + else { $result = "User $name has no capabilities."; } return $result; } } diff --git a/PBot/HashObject.pm b/PBot/HashObject.pm index 9262c114..420479bb 100644 --- a/PBot/HashObject.pm +++ b/PBot/HashObject.pm @@ -145,7 +145,7 @@ sub set { my $comma = ''; foreach my $k (sort grep { $_ ne '_name' } keys %{$self->{hash}->{$lc_index}}) { $result .= $comma . "$k => " . $self->{hash}->{$lc_index}->{$k}; - $comma = "; "; + $comma = ";\n"; } $result .= "none" if ($comma eq ''); return $result; diff --git a/PBot/Users.pm b/PBot/Users.pm index 9f3fca47..367f2a89 100644 --- a/PBot/Users.pm +++ b/PBot/Users.pm @@ -15,7 +15,7 @@ use feature 'unicode_strings'; sub initialize { my ($self, %conf) = @_; - $self->{users} = PBot::DualIndexHashObject->new(name => 'Users', filename => $conf{filename}, pbot => $conf{pbot}); + $self->{users} = PBot::HashObject->new(name => 'Users', filename => $conf{filename}, pbot => $conf{pbot}); $self->load; $self->{pbot}->{commands}->register(sub { $self->logincmd(@_) }, "login", 0); @@ -34,6 +34,16 @@ sub initialize { $self->{pbot}->{capabilities}->add('can-modify-admins', undef, 1); $self->{pbot}->{event_dispatcher}->register_handler('irc.join', sub { $self->on_join(@_) }); + $self->{pbot}->{event_dispatcher}->register_handler('irc.part', sub { $self->on_departure(@_) }); + $self->{pbot}->{event_dispatcher}->register_handler('irc.quit', sub { $self->on_departure(@_) }); + $self->{pbot}->{event_dispatcher}->register_handler('irc.kick', sub { $self->on_kick(@_) }); + $self->{pbot}->{event_dispatcher}->register_handler('pbot.part', sub { $self->on_self_part(@_) }); + + + $self->{user_index} = {}; + $self->{user_cache} = {}; + + $self->rebuild_user_index; } sub on_join { @@ -74,16 +84,36 @@ sub on_join { return 0; } +sub on_departure { + my ($self, $event_type, $event) = @_; + my ($nick, $user, $host, $channel) = ($event->{event}->nick, $event->{event}->user, $event->{event}->host, $event->{event}->to); + ($nick, $user, $host) = $self->{pbot}->{irchandlers}->normalize_hostmask($nick, $user, $host); + $self->decache_user($channel, "$nick!$user\@$host"); +} + +sub on_kick { + my ($self, $event_type, $event) = @_; + my ($nick, $user, $host, $channel) = ($event->{event}->nick, $event->{event}->user, $event->{event}->host, $event->{event}->{args}[0]); + ($nick, $user, $host) = $self->{pbot}->{irchandlers}->normalize_hostmask($nick, $user, $host); + $self->decache_user($channel, "$nick!$user\@$host"); +} + +sub on_self_part { + my ($self, $event_type, $event) = @_; + delete $self->{user_cache}->{lc $event->{channel}}; +} + sub add_user { - my ($self, $name, $channel, $hostmask, $capabilities, $password, $dont_save) = @_; - $channel = '.*' if $channel !~ m/^#/; + my ($self, $name, $channels, $hostmasks, $capabilities, $password, $dont_save) = @_; + $channels = 'global' if $channels !~ m/^#/; $capabilities //= 'none'; $password //= $self->{pbot}->random_nick(16); my $data = { - name => $name, - password => $password + channels => $channels, + hostmasks => $hostmasks, + password => $password }; foreach my $cap (split /\s*,\s*/, lc $capabilities) { @@ -91,36 +121,32 @@ sub add_user { $data->{$cap} = 1; } - $self->{pbot}->{logger}->log("Adding new user (caps: $capabilities): name: $name hostmask: $hostmask channel: $channel\n"); - $self->{users}->add($channel, $hostmask, $data, $dont_save); + $self->{pbot}->{logger}->log("Adding new user (caps: $capabilities): name: $name hostmasks: $hostmasks channels: $channels\n"); + $self->{users}->add($name, $data, $dont_save); + $self->rebuild_user_index; return $data; } sub remove_user { - my ($self, $channel, $hostmask) = @_; - return $self->{users}->remove($channel, $hostmask); + my ($self, $name) = @_; + my $result = $self->{users}->remove($name); + $self->rebuild_user_index; + return $result; } sub load { my $self = shift; - my $filename; - if (@_) { $filename = shift; } - else { $filename = $self->{users}->{filename}; } - - if (not defined $filename) { - Carp::carp "No users path specified -- skipping loading of users"; - return; - } $self->{users}->load; my $i = 0; - foreach my $channel (sort $self->{users}->get_keys) { - foreach my $hostmask (sort $self->{users}->get_keys($channel)) { - $i++; - my $name = $self->{users}->get_data($channel, $hostmask, 'name'); - my $password = $self->{users}->get_data($channel, $hostmask, 'password'); - if (not defined $name or not defined $password) { Carp::croak "A user in $filename is missing critical data\n"; } + foreach my $name (sort $self->{users}->get_keys) { + $i++; + my $password = $self->{users}->get_data($name, 'password'); + my $channels = $self->{users}->get_data($name, 'channels'); + my $hostmasks = $self->{users}->get_data($name, 'hostmasks'); + if (not defined $channels or not defined $hostmasks or not defined $password) { + Carp::croak "User $name is missing critical data\n"; } } $self->{pbot}->{logger}->log(" $i users loaded.\n"); @@ -131,81 +157,101 @@ sub save { $self->{users}->save; } +sub rebuild_user_index { + my ($self) = @_; + + $self->{user_index} = {}; + + foreach my $name ($self->{users}->get_keys) { + my $channels = $self->{users}->get_data($name, 'channels'); + my $hostmasks = $self->{users}->get_data($name, 'hostmasks'); + + my @c = split /\s*,\s*/, $channels; + my @h = split /\s*,\s*/, $hostmasks; + + foreach my $channel (@c) { + foreach my $hostmask (@h) { + $self->{user_index}->{lc $channel}->{lc $hostmask} = $name; + } + } + } +} + +sub cache_user { + my ($self, $channel, $hostmask, $username, $account_mask) = @_; + $self->{user_cache}->{lc $channel}->{lc $hostmask} = [ $username, $account_mask ]; +} + +sub decache_user { + my ($self, $channel, $hostmask) = @_; + my $lc_hostmask = lc $hostmask; + delete $self->{user_cache}->{lc $channel}->{$lc_hostmask}; + delete $self->{user_cache}->{global}->{$lc_hostmask}; +} + sub find_user_account { my ($self, $channel, $hostmask, $any_channel) = @_; $channel = lc $channel; $hostmask = lc $hostmask; $any_channel //= 0; - my $sort; - if ($channel =~ m/^#/) { - $sort = sub { $a cmp $b }; - } else { - $sort = sub { $b cmp $a }; + # first try to find an exact match + + if (exists $self->{user_cache}->{$channel} and exists $self->{user_cache}->{$channel}->{$hostmask}) { + my ($username, $account_mask) = @{$self->{user_cache}->{$channel}->{$hostmask}}; + return ($channel, $account_mask); } - foreach my $chan (sort $sort $self->{users}->get_keys) { - if (($channel !~ m/^#/ and $any_channel) or $channel =~ m/^$chan$/i) { - if (not $self->{users}->exists($chan, $hostmask)) { - # find hostmask by account name or wildcard - foreach my $mask ($self->{users}->get_keys($chan)) { - if (lc $self->{users}->get_data($chan, $mask, 'name') eq $hostmask) { return ($chan, $mask); } + if (exists $self->{user_cache}->{global} and exists $self->{user_cache}->{global}->{$hostmask}) { + my ($username, $account_mask) = @{$self->{user_cache}->{global}->{$hostmask}}; + return ('global', $account_mask); + } - if ($mask =~ /[*?]/) { - # contains * or ? so it's converted to a regex - my $mask_quoted = quotemeta $mask; - $mask_quoted =~ s/\\\*/.*?/g; - $mask_quoted =~ s/\\\?/./g; - if ($hostmask =~ m/^$mask_quoted$/i) { return ($chan, $mask); } + if (exists $self->{user_index}->{$channel} and exists $self->{user_index}->{$channel}->{$hostmask}) { + return ($channel, $hostmask); + } + + if (exists $self->{user_index}->{global} and exists $self->{user_index}->{global}->{$hostmask}) { + return ('global', $hostmask); + } + + # no exact matches found -- check for wildcard matches + + my @search_channels; + + if ($any_channel) { + @search_channels = keys %{$self->{user_index}}; + } else { + @search_channels = ($channel, 'global'); + } + + foreach my $search_channel (@search_channels) { + if (exists $self->{user_index}->{$search_channel}) { + foreach my $mask (keys %{$self->{user_index}->{$search_channel}}) { + if ($mask =~ m/[*?]/) { + # contains * or ? so it's converted to a regex + my $mask_quoted = quotemeta $mask; + $mask_quoted =~ s/\\\*/.*?/g; + $mask_quoted =~ s/\\\?/./g; + if ($hostmask =~ m/^$mask_quoted$/i) { + return ($search_channel, $mask); } } - } else { - return ($chan, $hostmask); } } } + return (undef, $hostmask); } sub find_user { my ($self, $channel, $hostmask, $any_channel) = @_; $any_channel //= 0; - ($channel, $hostmask) = $self->find_user_account($channel, $hostmask, $any_channel); - return undef if not $any_channel and not defined $channel; - - $channel = '.*' if not defined $channel; - $hostmask = '.*' if not defined $hostmask; - $hostmask = lc $hostmask; - - my $sort; - if ($channel =~ m/^#/) { - $sort = sub { $a cmp $b }; - } else { - $sort = sub { $b cmp $a }; - } - - my $user = eval { - foreach my $channel_regex (sort $sort $self->{users}->get_keys) { - if (($channel !~ m/^#/ and $any_channel) or $channel =~ m/^$channel_regex$/i) { - foreach my $hostmask_regex ($self->{users}->get_keys($channel_regex)) { - if ($hostmask_regex =~ m/[*?]/) { - # contains * or ? so it's converted to a regex - my $hostmask_quoted = quotemeta $hostmask_regex; - $hostmask_quoted =~ s/\\\*/.*?/g; - $hostmask_quoted =~ s/\\\?/./g; - if ($hostmask =~ m/^$hostmask_quoted$/i) { return $self->{users}->get_data($channel_regex, $hostmask_regex); } - } else { - # direct comparison - if ($hostmask eq lc $hostmask_regex) { return $self->{users}->get_data($channel_regex, $hostmask_regex); } - } - } - } - } - return undef; - }; - - if ($@) { $self->{pbot}->{logger}->log("Error in find_user parameters: $@\n"); } - return $user; + my ($found_channel, $found_hostmask) = $self->find_user_account($channel, $hostmask, $any_channel); + return undef if not defined $found_channel; + my $name = $self->{user_index}->{$found_channel}->{$found_hostmask}; + $self->cache_user($found_channel, $hostmask, $name, $found_hostmask); + return wantarray ? ($self->{users}->get_data($name), $name) : $self->{users}->get_data($name); } sub find_admin { @@ -233,7 +279,7 @@ sub loggedin_admin { sub login { my ($self, $channel, $hostmask, $password) = @_; my $user = $self->find_user($channel, $hostmask); - my $channel_text = $channel eq '.*' ? '' : " for $channel"; + my $channel_text = $channel eq 'global' ? '' : " for $channel"; if (not defined $user) { $self->{pbot}->{logger}->log("Attempt to login non-existent [$channel][$hostmask] failed\n"); @@ -246,8 +292,10 @@ sub login { } $user->{loggedin} = 1; - $self->{pbot}->{logger}->log("$hostmask logged into $user->{name} ($hostmask)$channel_text.\n"); - return "Logged into $user->{name} ($hostmask)$channel_text."; + my ($user_chan, $user_hostmask) = $self->find_user_account($channel, $hostmask); + my $name = $self->{user_index}->{$user_chan}->{$user_hostmask}; + $self->{pbot}->{logger}->log("$hostmask logged into " . $self->{users}->get_key_name($name) . " ($hostmask)$channel_text.\n"); + return "Logged into " . $self->{users}->get_key_name($name) . " ($hostmask)$channel_text."; } sub logout { @@ -283,10 +331,14 @@ sub logincmd { my ($user_channel, $user_hostmask) = $self->find_user_account($channel, "$nick!$user\@$host"); return "/msg $nick You do not have a user account." if not defined $user_channel; - my $u = $self->{users}->get_data($user_channel, $user_hostmask); - my $channel_text = $user_channel eq '.*' ? '' : " for $user_channel"; + my $name = $self->{user_index}->{$user_channel}->{$user_hostmask}; - if ($u->{loggedin}) { return "/msg $nick You are already logged into $u->{name} ($user_hostmask)$channel_text."; } + my $u = $self->{users}->get_data($name); + my $channel_text = $user_channel eq 'global' ? '' : " for $user_channel"; + + if ($u->{loggedin}) { + return "/msg $nick You are already logged into " . $self->{users}->get_key_name($name) . " ($user_hostmask)$channel_text."; + } my $result = $self->login($user_channel, $user_hostmask, $arguments); return "/msg $nick $result"; @@ -298,12 +350,14 @@ sub logoutcmd { my ($user_channel, $user_hostmask) = $self->find_user_account($from, "$nick!$user\@$host"); return "/msg $nick You do not have a user account." if not defined $user_channel; - my $u = $self->{users}->get_data($user_channel, $user_hostmask); - my $channel_text = $user_channel eq '.*' ? '' : " for $user_channel"; - return "/msg $nick You are not logged into $u->{name} ($user_hostmask)$channel_text." if not $u->{loggedin}; + my $name = $self->{user_index}->{$user_channel}->{$user_hostmask}; + + my $u = $self->{users}->get_data($name); + my $channel_text = $user_channel eq 'global' ? '' : " for $user_channel"; + return "/msg $nick You are not logged into " . $self->{users}->get_key_name($name) . " ($user_hostmask)$channel_text." if not $u->{loggedin}; $self->logout($user_channel, $user_hostmask); - return "/msg $nick Logged out of $u->{name} ($user_hostmask)$channel_text."; + return "/msg $nick Logged out of " . $self->{users}->get_key_name($name) . " ($user_hostmask)$channel_text."; } sub users { @@ -313,37 +367,38 @@ sub users { my $include_global = ''; if (not defined $channel) { $channel = $from; - $include_global = '.*'; + $include_global = 'global'; } else { - $channel = '.*' if $channel !~ /^#/; + $channel = 'global' if $channel !~ /^#/; } my $text = "Users: "; my $last_channel = ""; my $sep = ""; - foreach my $chan (sort $self->{users}->get_keys) { + foreach my $chan (sort keys %{$self->{user_index}}) { next if $from =~ m/^#/ and $chan ne $channel and $chan ne $include_global; next if $from !~ m/^#/ and $channel =~ m/^#/ and $chan ne $channel; if ($last_channel ne $chan) { - $text .= $sep . ($chan eq ".*" ? "global" : $chan) . ": "; + $text .= "$sep$chan: "; $last_channel = $chan; $sep = ""; } - foreach my $hostmask (sort { return 0 if $a eq '_name' or $b eq '_name'; $self->{users}->get_data($chan, $a, 'name') cmp $self->{users}->get_data($chan, $b, 'name') } - $self->{users}->get_keys($chan)) + foreach my $hostmask (sort { $self->{user_index}->{$chan}->{$a} cmp $self->{user_index}->{$chan}->{$b} } + keys %{$self->{user_index}->{$chan}}) { + my $name = $self->{user_index}->{$chan}->{$hostmask}; $text .= $sep; my $has_cap = 0; - foreach my $key ($self->{users}->get_keys($chan, $hostmask)) { + foreach my $key ($self->{users}->get_keys($name)) { if ($self->{pbot}->{capabilities}->exists($key)) { $has_cap = 1; last; } } $text .= '+' if $has_cap; - $text .= $self->{users}->get_data($chan, $hostmask, 'name'); + $text .= $self->{users}->get_key_name($name); $sep = " "; } $sep = "; "; @@ -353,18 +408,20 @@ sub users { sub useradd { my ($self, $from, $nick, $user, $host, $arguments, $stuff) = @_; - my ($name, $channel, $hostmask, $capabilities, $password) = $self->{pbot}->{interpreter}->split_args($stuff->{arglist}, 5); + my ($name, $hostmasks, $channels, $capabilities, $password) = $self->{pbot}->{interpreter}->split_args($stuff->{arglist}, 5); $capabilities //= 'none'; - if (not defined $name or not defined $channel or not defined $hostmask) { return "Usage: useradd [capabilities [password]]"; } + if (not defined $name or not defined $hostmasks) { return "Usage: useradd [channels [capabilities [password]]]"; } - $channel = '.*' if $channel !~ /^#/; + $channels = 'global' if $channels !~ /^#/; - my $u = $self->{pbot}->{users}->find_user($channel, "$nick!$user\@$host"); + my $u; + foreach my $channel (sort split /\s*,\s*/, lc $channels) { + $u = $self->{pbot}->{users}->find_user($channel, "$nick!$user\@$host"); - if (not defined $u) { - $channel = 'global' if $channel eq '.*'; - return "You do not have a user account for $channel; cannot add users to that channel.\n"; + if (not defined $u) { + return "You do not have a user account for $channel; cannot add users to that channel.\n"; + } } if ($capabilities ne 'none' and not $self->{pbot}->{capabilities}->userhas($u, 'can-modify-capabilities')) { @@ -372,25 +429,28 @@ sub useradd { } foreach my $cap (split /\s*,\s*/, lc $capabilities) { - next if $cap eq 'none'; + next if $cap eq 'none'; + return "There is no such capability $cap." if not $self->{pbot}->{capabilities}->exists($cap); + if (not $self->{pbot}->{capabilities}->userhas($u, $cap)) { return "To set the $cap capability your user account must also have it."; } + if ($self->{pbot}->{capabilities}->has($cap, 'admin') and not $self->{pbot}->{capabilities}->userhas($u, 'can-modify-admins')) { return "To set the $cap capability your user account must have the can-modify-admins capability."; } } - $self->{pbot}->{users}->add_user($name, $channel, $hostmask, $capabilities, $password); + + $self->{pbot}->{users}->add_user($name, $channels, $hostmasks, $capabilities, $password); return "User added."; } sub userdel { my ($self, $from, $nick, $user, $host, $arguments, $stuff) = @_; - my ($channel, $hostmask) = $self->{pbot}->{interpreter}->split_args($stuff->{arglist}, 2); - if (not defined $channel or not defined $hostmask) { return "Usage: userdel "; } + if (not length $arguments) { return "Usage: userdel "; } - my $u = $self->find_user($channel, "$nick!$user\@$host"); - my $t = $self->find_user($channel, $hostmask); + my $u = $self->find_user($from, "$nick!$user\@$host"); + my $t = $self->{users}->get_data($arguments); if ($self->{pbot}->{capabilities}->userhas($t, 'botowner') and not $self->{pbot}->{capabilities}->userhas($u, 'botowner')) { return "Only botowners may delete botowner user accounts."; @@ -400,31 +460,26 @@ sub userdel { return "To delete admin user accounts your user account must have the can-modify-admins capability."; } - my ($found_channel, $found_hostmask) = $self->find_user_account($channel, $hostmask); - $found_channel = $channel if not defined $found_channel; # let DualIndexHashObject disambiguate - return $self->remove_user($found_channel, $found_hostmask); + return $self->remove_user($arguments); } sub userset { my ($self, $from, $nick, $user, $host, $arguments, $stuff) = @_; - if (length $arguments and $stuff->{arglist}[0] !~ m/^(#|\.\*$|global$)/) { $self->{pbot}->{interpreter}->unshift_arg($stuff->{arglist}, $from) } + my ($name, $key, $value) = $self->{pbot}->{interpreter}->split_args($stuff->{arglist}, 3); - my ($channel, $hostmask, $key, $value) = $self->{pbot}->{interpreter}->split_args($stuff->{arglist}, 4); + if (not defined $name) { return "Usage: userset [key [value]]"; } - if (not defined $hostmask) { return "Usage: userset [channel] [key [value]]"; } - - my $u = $self->find_user($channel, "$nick!$user\@$host"); - my $target = $self->find_user($channel, $hostmask); + my $u = $self->find_user($from, "$nick!$user\@$host", 1); + my $target = $self->{users}->get_data($name); if (not $u) { - $channel = 'global' if $channel eq '.*'; - return "You do not have a user account for $channel; cannot modify their users."; + $from = 'global' if $from !~ /^#/; + return "You do not have a user account for $from; cannot modify their users."; } if (not $target) { - if ($channel !~ /^#/) { return "There is no user account $hostmask."; } - else { return "There is no user account $hostmask for $channel."; } + return "There is no user account $name."; } if (defined $value and not $self->{pbot}->{capabilities}->userhas($u, 'can-modify-capabilities')) { @@ -441,52 +496,58 @@ sub userset { return "To set the $key capability your user account must also have it." unless $self->{pbot}->{capabilities}->userhas($u, 'botowner'); } - my ($found_channel, $found_hostmask) = $self->find_user_account($channel, $hostmask); - $found_channel = $channel if not defined $found_channel; # let DualIndexHashObject disambiguate - my $result = $self->{users}->set($found_channel, $found_hostmask, $key, $value); + my $result = $self->{users}->set($name, $key, $value); + print "result [$result]\n"; $result =~ s/^password => .*;?$/password => ;/m; + + if (defined $key and ($key eq 'channels' or $key eq 'hostmasks') and defined $value) { + $self->rebuild_user_index; + } + return $result; } sub userunset { my ($self, $from, $nick, $user, $host, $arguments, $stuff) = @_; - if (length $arguments and $stuff->{arglist}[0] !~ m/^(#|\.\*$|global$)/) { $self->{pbot}->{interpreter}->unshift_arg($stuff->{arglist}, $from) } + my ($name, $key) = $self->{pbot}->{interpreter}->split_args($stuff->{arglist}, 2); - my ($channel, $hostmask, $key) = $self->{pbot}->{interpreter}->split_args($stuff->{arglist}, 3); + if (not defined $name or not defined $key) { return "Usage: userunset "; } - if (not defined $hostmask) { return "Usage: userunset [channel] "; } + $key = lc $key; - my $u = $self->find_user($channel, "$nick!$user\@$host"); - my $target = $self->find_user($channel, $hostmask); + my @disallowed = qw/channels hostmasks password/; + if (grep { $_ eq $key } @disallowed) { + return "The $key metadata cannot be unset. Use the `userset` command to modify it."; + } + + my $u = $self->find_user($from, "$nick!$user\@$host", 1); + my $target = $self->{users}->get_data($name); if (not $u) { - $channel = 'global' if $channel eq '.*'; - return "You do not have a user account for $channel; cannot modify their users."; + $from = 'global' if $from !~ /^#/; + return "You do not have a user account for $from; cannot modify their users."; } if (not $target) { - if ($channel !~ /^#/) { return "There is no user account $hostmask."; } - else { return "There is no user account $hostmask for $channel."; } + return "There is no user account $name."; } - if (defined $key and not $self->{pbot}->{capabilities}->userhas($u, 'can-modify-capabilities')) { + if (not $self->{pbot}->{capabilities}->userhas($u, 'can-modify-capabilities')) { if ($key =~ m/^can-/i or $self->{pbot}->{capabilities}->exists($key)) { return "The $key metadata requires the can-modify-capabilities capability, which your user account does not have."; } } - if (defined $key and $self->{pbot}->{capabilities}->userhas($target, 'admin') and not $self->{pbot}->{capabilities}->userhas($u, 'can-modify-admins')) { + if ($self->{pbot}->{capabilities}->userhas($target, 'admin') and not $self->{pbot}->{capabilities}->userhas($u, 'can-modify-admins')) { return "To modify admin user accounts your user account must have the can-modify-admins capability."; } - if (defined $key and $self->{pbot}->{capabilities}->exists($key) and not $self->{pbot}->{capabilities}->userhas($u, $key)) { + if ($self->{pbot}->{capabilities}->exists($key) and not $self->{pbot}->{capabilities}->userhas($u, $key)) { return "To unset the $key capability your user account must also have it." unless $self->{pbot}->{capabilities}->userhas($u, 'botowner'); } - my ($found_channel, $found_hostmask) = $self->find_user_account($channel, $hostmask); - $found_channel = $channel if not defined $found_channel; # let DualIndexHashObject disambiguate - return $self->{users}->unset($found_channel, $found_hostmask, $key); + return $self->{users}->unset($name, $key); } sub mycmd { @@ -501,24 +562,25 @@ sub mycmd { my $channel = $from; my $hostmask = "$nick!$user\@$host"; - my $u = $self->find_user($channel, $hostmask, 1); + my ($u, $name) = $self->find_user($channel, $hostmask, 1); if (not $u) { - $channel = '.*'; + $channel = 'global'; $hostmask = "$nick!$user\@" . $self->{pbot}->{antiflood}->address_to_mask($host); - my $name = $nick; + $name = $nick; - my ($existing_channel, $existing_hostmask) = $self->find_user_account($channel, $name); - if ($existing_hostmask ne lc $name) { - # user exists by name - return "There is already an user account named $name but its hostmask ($existing_hostmask) does not match your hostmask ($hostmask). Ask an admin for help."; + $u = $self->{users}->get_data($name); + if ($u) { + $self->{pbot}->{logger}->log("Adding additional hostmask $hostmask to user account $name\n"); + $u->{hostmasks} .= ",$hostmask"; + $self->rebuild_user_index; + } else { + $u = $self->add_user($name, $channel, $hostmask, undef, undef, 1); + $u->{loggedin} = 1; + $u->{stayloggedin} = 1; + $u->{autologin} = 1; + $self->save; } - - $u = $self->add_user($name, $channel, $hostmask, undef, undef, 1); - $u->{loggedin} = 1; - $u->{stayloggedin} = 1; - $u->{autologin} = 1; - $self->save; } my $result = ''; @@ -550,10 +612,7 @@ sub mycmd { $result = "Usage: my [value]; "; } - my ($found_channel, $found_hostmask) = $self->find_user_account($channel, $hostmask, 1); - ($found_channel, $found_hostmask) = $self->find_user_account('.*', $hostmask, 1) if not defined $found_channel; - return "No user account found in $channel." if not defined $found_channel; - $result .= $self->{users}->set($found_channel, $found_hostmask, $key, $value); + $result .= $self->{users}->set($name, $key, $value); $result =~ s/^password => .*;?$/password => ;/m; return $result; } diff --git a/PBot/VERSION.pm b/PBot/VERSION.pm index ed476a17..cba35412 100644 --- a/PBot/VERSION.pm +++ b/PBot/VERSION.pm @@ -19,7 +19,7 @@ use LWP::UserAgent; # These are set automatically by the misc/update_version script use constant { BUILD_NAME => "PBot", - BUILD_REVISION => 3509, + BUILD_REVISION => 3512, BUILD_DATE => "2020-04-22", }; diff --git a/data/last_update b/data/last_update index 0d4336f8..7ae5f23f 100644 --- a/data/last_update +++ b/data/last_update @@ -1 +1 @@ -3509 +3512 diff --git a/data/users b/data/users index d91f2ff2..ea7d7c0f 100644 --- a/data/users +++ b/data/users @@ -1,8 +1,6 @@ { "$metadata$" : { - "$metadata$" : { - "update_version" : 3503 - } + "update_version" : 3512 } } diff --git a/doc/Admin.md b/doc/Admin.md index 99a4e649..9c07be7d 100644 --- a/doc/Admin.md +++ b/doc/Admin.md @@ -90,39 +90,40 @@ Usage: `logout` ### useradd Adds a new user to PBot. -Usage: `useradd [capabilities [password]]` +Usage: `useradd [channels [capabilities [password]]]` Parameter | Description --- | --- -`` | A unique name to identify this account (usually the `nick` of the user, but it can be any identifier). -`` | The channel this user belongs to; use `global` for all channels. This field cannot be changed without removing and re-adding the user. -`` | The hostmask from which this user is recognized/allowed to login from (e.g., `somenick!*@*.somedomain.com` or `*!*@unaffiliated/someuser`). This field cannot be changed without removing and re-adding the user. -`[capabilities]` | A comma-separated list of [user-capabilities](#user-capabilities) for this user. -`[password]` | The password the user will use to login (from `/msg`, obviously). Generates a random password if omitted. Users may view and set their password by using the [`my`](Commands.md#my) command. +`username` | A unique name to identify this account (usually the `nick` of the user, but it can be any identifier). +`hostmasks` | The hostmasks from which this user is recognized/allowed to login from (e.g., `somenick!*@*.somedomain.com` or `*!*@unaffiliated/someuser`). Can be a comma-separated list of values. +`channels` | The channels this user belongs to; use `global` for all channels. Can be a comma-separated list of values. +`capabilities` | A comma-separated list of [user-capabilities](#user-capabilities) for this user. +`password` | The password the user will use to login (from `/msg`, obviously). Generates a random password if omitted. Users may view and set their password by using the [`my`](Commands.md#my) command. ### userdel -Removes a user from PBot. You can use the `account name` field or the `hostmask` field that was set via the [`useradd`](#useradd) command. +Removes a user from PBot. -Usage: `userdel ` +Usage: `userdel ` ### userset -Sets [metadata](#user-metadata-list) or [user-capabilities](#user-capabilities-list) for a user account. You can use the `account name` field or the `hostmask` field that was set via the [`useradd`](#useradd) command. See also: [user metadata list](#user-metadata-list). +Sets [metadata](#user-metadata-list) or [user-capabilities](#user-capabilities-list) for a user account. See also: [user metadata list](#user-metadata-list). If `key` is omitted, it will list all the keys and values that are set. If `value` is omitted, it will show the value for `key`. -Usage: `userset [channel] [ [value]]` +Usage: `userset [ [value]]` ### userunset -Deletes a [metadata](#user-metadata-list) or [user-capability](#user-capabilities-list) from a user account. You can use the `account name` field or the `hostmask` field that was set via the [`useradd`](#useradd) command. +Deletes a [metadata](#user-metadata-list) or [user-capability](#user-capabilities-list) from a user account. -Usage: `userunset [channel] ` +Usage: `userunset ` #### User metadata list This is a list of recognized metadata keys for user accounts. Name | Description --- | --- -`name` | A unique name identifying the user account. +`hostmasks` | A comma-separated list of hostmasks this user is recognized by. +`channels` | A comma-separated list of channels this user belongs to. `password` | The password for the user account. `loggedin` | Whether the user is logged in or not. `stayloggedin` | Do not log the user out when they part/quit. diff --git a/doc/FAQ.md b/doc/FAQ.md index 0a9fa3c7..fb087c62 100644 --- a/doc/FAQ.md +++ b/doc/FAQ.md @@ -58,9 +58,9 @@ To whitelist a user, use the [`useradd`](Admin.md#useradd) command with the `is-whitelisted` capability argument. To whitelist them in all channels, add the user to the global channel. -Usage: `useradd is-whitelisted` +Usage: `useradd is-whitelisted` If the user already exists, use the [`userset`](Admin.md#userset) command to grant them the `is-whitelisted` capability. -Usage: `userset [channel] is-whitelisted 1` +Usage: `userset is-whitelisted 1` diff --git a/doc/QuickStart.md b/doc/QuickStart.md index 864c9469..6770b3fe 100644 --- a/doc/QuickStart.md +++ b/doc/QuickStart.md @@ -241,7 +241,7 @@ commands in the PBot terminal console. Suppose your nick is `Bob` and your hostmask is `Bob!~user@some.domain.com`. - useradd Bob global Bob!~user@*.domain.com botowner + useradd Bob Bob!~user@*.domain.com global botowner This will create a user account named `Bob` with the `botowner` [user-capability](Admin.md#user-capabilities) that can administrate all channels. Note the wildcard replacing `some` in `some.domain.com`. Now as long as @@ -267,7 +267,9 @@ the terminal console. ### Adding other users and admins To add users to PBot, use the [`useradd`](Admin.md#useradd) command. - useradd [capabilities [password]] + useradd [channels [capabilities [password]]] + +The `hostmasks` and `channels` arguments can be a comma-separated list of values. If you omit the `capabilities` argument, the user will be a normal unprivileged user. See [user-capabilities](Admin.md#user-capabilities) for more information about user-capabilities. diff --git a/doc/Registry.md b/doc/Registry.md index 1623bff5..9b60da5e 100644 --- a/doc/Registry.md +++ b/doc/Registry.md @@ -197,7 +197,7 @@ general.send_who_on_join | When joining a channel, send the `WHO` command to get general.show_url_titles_channels | A regular-expression or comma-separated list of channels that should display titles for URLs. | `.*` general.show_url_titles | If set to a true value, PBot will show titles for URLs. | 1 general.show_url_titles_ignore_channels | A regular-expression or comma-separated list of channels that will not display titles for URLs. | -general.strictnamespace | When enabled, factoids belonging to other channels will not show up in current channels unless specifically invoked. +general.strictnamespace | When enabled, factoids belonging to other channels will not show up in current channels unless specifically invoked.| 0 general.trigger | The trigger character(s) or text that will invoke PBot commands. | [!] interpreter.max_recursion | The maximum number of recursions allowed before the command interpreter will abort. | 100 irc.botnick | The IRC nickname of this PBot instance. | diff --git a/updates/3512_update_users.pl b/updates/3512_update_users.pl new file mode 100755 index 00000000..7ffc9b23 --- /dev/null +++ b/updates/3512_update_users.pl @@ -0,0 +1,54 @@ +#!/usr/bin/env perl + +# Updates user JSON file to a better format to support multiple hostmasks and +# easier channel management + +use warnings; use strict; + +BEGIN { + use File::Basename; + my $location = -l __FILE__ ? dirname readlink __FILE__ : dirname __FILE__; + unshift @INC, $location; +} + +use lib3512::HashObject; +use lib3512::DualIndexHashObject; +use lib3503::PBot; + +my ($data_dir, $version, $last_update) = @ARGV; + +print "Updating users... version: $version, last_update: $last_update, data_dir: $data_dir\n"; + +my $pbot = lib3503::PBot->new(); + +my $users = lib3512::DualIndexHashObject->new(name => 'old users', filename => "$data_dir/users", pbot => $pbot); +$users->load; + +my $users2 = lib3512::HashObject->new(name => 'new users', filename => "$data_dir/users_new", pbot => $pbot); + +foreach my $channel (keys %{$users->{hash}}) { + next if $channel eq '$metadata$'; + foreach my $hostmask (keys %{$users->{hash}->{$channel}}) { + next if $hostmask eq '_name'; + + my $data = $users->{hash}->{$channel}->{$hostmask}; + + my $name = delete $data->{name}; + delete $data->{_name}; + my $channels = $channel; + $channels = 'global' if $channels eq '.*'; + $data->{channels} = $channels; + $data->{hostmasks} = $hostmask; + + $users2->add($name, $data, 1); + } +} + +$users2->add('$metadata$', { update_version => 3512 }); + +print "Overwriting users with user_new\n"; + +use File::Copy; +move("$data_dir/users_new", "$data_dir/users") or die "Failed to update users: $!"; + +exit 0; diff --git a/updates/lib3512/DualIndexHashObject.pm b/updates/lib3512/DualIndexHashObject.pm new file mode 100644 index 00000000..256f2ca6 --- /dev/null +++ b/updates/lib3512/DualIndexHashObject.pm @@ -0,0 +1,426 @@ +# File: DualIndexHashObject.pm +# Author: pragma_ +# +# Purpose: Provides a hash-table object with an abstracted API that includes +# setting and deleting values, saving to and loading from files, etc. This +# extends the HashObject with an additional index key. Provides case-insensitive +# access to both index keys, while preserving original case when displaying the +# keys. +# +# Data is stored in working memory for lightning fast performance. If you have +# a huge amount of data, consider DualIndexSQLiteObject instead. + +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package lib3512::DualIndexHashObject; + +use warnings; use strict; +use feature 'unicode_strings'; + +use Text::Levenshtein qw(fastdistance); +use JSON; + +sub new { + my ($proto, %conf) = @_; + my $class = ref($proto) || $proto; + my $self = bless {}, $class; + Carp::croak("Missing pbot reference to " . __FILE__) unless exists $conf{pbot}; + $self->{pbot} = $conf{pbot}; + $self->initialize(%conf); + return $self; +} + +sub initialize { + my ($self, %conf) = @_; + $self->{name} = $conf{name} // 'Dual Index hash object'; + $self->{filename} = $conf{filename} // Carp::carp("Missing filename to DualIndexHashObject, will not be able to save to or load from file."); + $self->{hash} = {}; +} + +sub load { + my ($self, $filename) = @_; + $filename = $self->{filename} if not defined $filename; + + if (not defined $filename) { + Carp::carp "No $self->{name} filename specified -- skipping loading from file"; + return; + } + + $self->{pbot}->{logger}->log("Loading $self->{name} from $filename ...\n"); + + if (not open(FILE, "< $filename")) { + $self->{pbot}->{logger}->log("Skipping loading from file: Couldn't open $filename: $!\n"); + return; + } + + my $contents = do { + local $/; + ; + }; + + $self->{hash} = decode_json $contents if length $contents; + close FILE; + + # update existing entries to use _name to preserve case + # and lowercase any non-lowercased entries + foreach my $primary_index (keys %{$self->{hash}}) { + if (not exists $self->{hash}->{$primary_index}->{_name}) { + if ($primary_index ne lc $primary_index) { + if (exists $self->{hash}->{lc $primary_index}) { + Carp::croak "Cannot update $self->{name} primary index $primary_index; duplicate object found"; + } + + my $data = delete $self->{hash}->{$primary_index}; + $data->{_name} = $primary_index; + $primary_index = lc $primary_index; + $self->{hash}->{$primary_index} = $data; + } + } + + foreach my $secondary_index (grep { $_ ne '_name' } keys %{$self->{hash}->{$primary_index}}) { + if (not exists $self->{hash}->{$primary_index}->{$secondary_index}->{_name}) { + if ($secondary_index ne lc $secondary_index) { + if (exists $self->{hash}->{$primary_index}->{lc $secondary_index}) { + Carp::croak "Cannot update $self->{name} $primary_index sub-object $secondary_index; duplicate object found"; + } + + my $data = delete $self->{hash}->{$primary_index}->{$secondary_index}; + $data->{_name} = $secondary_index; + $secondary_index = lc $secondary_index; + $self->{hash}->{$primary_index}->{$secondary_index} = $data; + } + } + } + } +} + +sub save { + my $self = shift; + my $filename; + if (@_) { $filename = shift; } + else { $filename = $self->{filename}; } + + if (not defined $filename) { + Carp::carp "No $self->{name} filename specified -- skipping saving to file.\n"; + return; + } + + $self->{pbot}->{logger}->log("Saving $self->{name} to $filename\n"); + + if (not $self->get_data('$metadata$', '$metadata$', 'update_version')) { + $self->add('$metadata$', '$metadata$', { update_version => 3512 }); + } + + my $json = JSON->new; + my $json_text = $json->pretty->canonical->utf8->encode($self->{hash}); + + open(FILE, "> $filename") or die "Couldn't open $filename: $!\n"; + print FILE "$json_text\n"; + close FILE; +} + +sub clear { + my $self = shift; + $self->{hash} = {}; +} + +sub levenshtein_matches { + my ($self, $primary_index, $secondary_index, $distance, $strictnamespace) = @_; + my $comma = ''; + my $result = ""; + + $distance = 0.60 if not defined $distance; + + $primary_index = '.*' if not defined $primary_index; + + if (not $secondary_index) { + foreach my $index (sort keys %{$self->{hash}}) { + my $distance_result = fastdistance($primary_index, $index); + my $length = (length $primary_index > length $index) ? length $primary_index : length $index; + + if ($distance_result / $length < $distance) { + my $name = $self->get_key_name($index); + if ($name =~ / /) { $result .= $comma . "\"$name\""; } + else { $result .= $comma . $name; } + $comma = ", "; + } + } + } else { + my $lc_primary_index = lc $primary_index; + if (not exists $self->{hash}->{$lc_primary_index}) { return 'none'; } + + my $last_header = ""; + my $header = ""; + + foreach my $index1 (sort keys %{$self->{hash}}) { + $header = "[" . $self->get_key_name($index1) . "] "; + $header = '[global] ' if $header eq '[.*] '; + + if ($strictnamespace) { + next unless $index1 eq '.*' or $index1 eq $lc_primary_index; + $header = "" unless $header eq '[global] '; + } + + foreach my $index2 (sort keys %{$self->{hash}->{$index1}}) { + my $distance_result = fastdistance($secondary_index, $index2); + my $length = (length $secondary_index > length $index2) ? length $secondary_index : length $index2; + + if ($distance_result / $length < $distance) { + my $name = $self->get_key_name($index1, $index2); + $header = "" if $last_header eq $header; + $last_header = $header; + $comma = '; ' if $comma ne '' and $header ne ''; + if ($name =~ / /) { $result .= $comma . $header . "\"$name\""; } + else { $result .= $comma . $header . $name; } + $comma = ", "; + } + } + } + } + + $result =~ s/(.*), /$1 or /; + $result = 'none' if $comma eq ''; + return $result; +} + +sub set { + my ($self, $primary_index, $secondary_index, $key, $value, $dont_save) = @_; + my $lc_primary_index = lc $primary_index; + my $lc_secondary_index = lc $secondary_index; + + if (not exists $self->{hash}->{$lc_primary_index}) { + my $result = "$self->{name}: $primary_index not found; similiar matches: "; + $result .= $self->levenshtein_matches($primary_index); + return $result; + } + + if (not exists $self->{hash}->{$lc_primary_index}->{$lc_secondary_index}) { + my $secondary_text = $secondary_index =~ / / ? "\"$secondary_index\"" : $secondary_index; + my $result = "$self->{name}: [" . $self->get_key_name($lc_primary_index) . "] $secondary_text not found; similiar matches: "; + $result .= $self->levenshtein_matches($primary_index, $secondary_index); + return $result; + } + + my $name1 = $self->get_key_name($lc_primary_index); + my $name2 = $self->get_key_name($lc_primary_index, $lc_secondary_index); + + $name1 = 'global' if $name1 eq '.*'; + $name2 = "\"$name2\"" if $name2 =~ / /; + + if (not defined $key) { + my $result = "[$name1] $name2 keys:\n"; + my $comma = ''; + foreach my $key (sort keys %{$self->{hash}->{$lc_primary_index}->{$lc_secondary_index}}) { + next if $key eq '_name'; + $result .= $comma . "$key => " . $self->{hash}->{$lc_primary_index}->{$lc_secondary_index}->{$key}; + $comma = ";\n"; + } + $result .= "none" if ($comma eq ''); + return $result; + } + + if (not defined $value) { $value = $self->{hash}->{$lc_primary_index}->{$lc_secondary_index}->{$key}; } + else { + $self->{hash}->{$lc_primary_index}->{$lc_secondary_index}->{$key} = $value; + $self->save unless $dont_save; + } + + return "[$name1] $name2: $key " . (defined $value ? "set to $value" : "is not set."); +} + +sub unset { + my ($self, $primary_index, $secondary_index, $key) = @_; + my $lc_primary_index = lc $primary_index; + my $lc_secondary_index = lc $secondary_index; + + if (not exists $self->{hash}->{$lc_primary_index}) { + my $result = "$self->{name}: $primary_index not found; similiar matches: "; + $result .= $self->levenshtein_matches($primary_index); + return $result; + } + + my $name1 = $self->get_key_name($lc_primary_index); + $name1 = 'global' if $name1 eq '.*'; + + if (not exists $self->{hash}->{$lc_primary_index}->{$lc_secondary_index}) { + my $result = "$self->{name}: [$name1] $secondary_index not found; similiar matches: "; + $result .= $self->levenshtein_matches($primary_index, $secondary_index); + return $result; + } + + my $name2 = $self->get_key_name($lc_primary_index, $lc_secondary_index); + $name2 = "\"$name2\"" if $name2 =~ / /; + + if (defined delete $self->{hash}->{$lc_primary_index}->{$lc_secondary_index}->{$key}) { + $self->save; + return "$self->{name}: [$name1] $name2: $key unset."; + } else { + return "$self->{name}: [$name1] $name2: $key does not exist."; + } + $self->save; +} + +sub exists { + my ($self, $primary_index, $secondary_index, $data_index) = @_; + return 0 if not defined $primary_index; + $primary_index = lc $primary_index; + return 0 if not exists $self->{hash}->{$primary_index}; + return 1 if not defined $secondary_index; + $secondary_index = lc $secondary_index; + return 0 if not exists $self->{hash}->{$primary_index}->{$secondary_index}; + return 1 if not defined $data_index; + return exists $self->{hash}->{$primary_index}->{$secondary_index}->{$data_index}; +} + +sub get_key_name { + my ($self, $primary_index, $secondary_index) = @_; + + my $lc_primary_index = lc $primary_index; + + return $lc_primary_index if not exists $self->{hash}->{$lc_primary_index}; + + if (not defined $secondary_index) { + if (exists $self->{hash}->{$lc_primary_index}->{_name}) { + return $self->{hash}->{$lc_primary_index}->{_name}; + } else { + return $lc_primary_index; + } + } + + my $lc_secondary_index = lc $secondary_index; + + return $lc_secondary_index if not exists $self->{hash}->{$lc_primary_index}->{$lc_secondary_index}; + + if (exists $self->{hash}->{$lc_primary_index}->{$lc_secondary_index}->{_name}) { + return $self->{hash}->{$lc_primary_index}->{$lc_secondary_index}->{_name}; + } else { + return $lc_secondary_index; + } +} + +sub get_keys { + my ($self, $primary_index, $secondary_index) = @_; + return grep { $_ ne '$metadata$' } keys %{$self->{hash}} if not defined $primary_index; + + my $lc_primary_index = lc $primary_index; + + if (not defined $secondary_index) { + return () if not exists $self->{hash}->{$lc_primary_index}; + return grep { $_ ne '_name' and $_ ne '$metadata$' } keys %{$self->{hash}->{$lc_primary_index}}; + } + + my $lc_secondary_index = lc $secondary_index; + + return () if not exists $self->{hash}->{$lc_primary_index} + or not exists $self->{hash}->{$lc_primary_index}->{$lc_secondary_index}; + + return grep { $_ ne '_name' } keys %{$self->{hash}->{lc $primary_index}->{lc $secondary_index}}; +} + +sub get_data { + my ($self, $primary_index, $secondary_index, $data_index) = @_; + $primary_index = lc $primary_index if defined $primary_index; + $secondary_index = lc $secondary_index if defined $secondary_index; + return undef if not exists $self->{hash}->{$primary_index}; + return $self->{hash}->{$primary_index} if not defined $secondary_index; + return $self->{hash}->{$primary_index}->{$secondary_index} if not defined $data_index; + return $self->{hash}->{$primary_index}->{$secondary_index}->{$data_index}; +} + +sub add { + my ($self, $primary_index, $secondary_index, $data, $dont_save, $quiet) = @_; + my $lc_primary_index = lc $primary_index; + my $lc_secondary_index = lc $secondary_index; + + if (not exists $self->{hash}->{$lc_primary_index}) { + # preserve case + if ($primary_index ne $lc_primary_index) { + $self->{hash}->{$lc_primary_index}->{_name} = $primary_index; + } + } + + if ($secondary_index ne $lc_secondary_index) { + # preserve case + $data->{_name} = $secondary_index; + } + + $self->{hash}->{$lc_primary_index}->{$lc_secondary_index} = $data; + $self->save() unless $dont_save; + + my $name1 = $self->get_key_name($lc_primary_index); + my $name2 = $self->get_key_name($lc_primary_index, $lc_secondary_index); + $name1 = 'global' if $name1 eq '.*'; + $name2 = "\"$name2\"" if $name2 =~ / /; + $self->{pbot}->{logger}->log("$self->{name}: [$name1]: $name2 added.\n") unless $dont_save or $quiet; + return "$self->{name}: [$name1]: $name2 added."; +} + +sub remove { + my ($self, $primary_index, $secondary_index, $data_index, $dont_save) = @_; + my $lc_primary_index = lc $primary_index; + my $lc_secondary_index = lc $secondary_index; + + if (not exists $self->{hash}->{$lc_primary_index}) { + my $result = "$self->{name}: $primary_index not found; similiar matches: "; + $result .= $self->levenshtein_matches($primary_index); + return $result; + } + + if (not defined $secondary_index) { + my $data = delete $self->{hash}->{$lc_primary_index}; + if (defined $data) { + my $name = exists $data->{_name} ? $data->{_name} : $lc_primary_index; + $name = 'global' if $name eq '.*'; + $self->save unless $dont_save; + return "$self->{name}: $name removed."; + } else { + return "$self->{name}: $primary_index does not exist."; + } + } + + my $name1 = $self->get_key_name($lc_primary_index); + $name1 = 'global' if $name1 eq '.*'; + + if (not exists $self->{hash}->{$lc_primary_index}->{$lc_secondary_index}) { + my $result = "$self->{name}: [$name1] $secondary_index not found; similiar matches: "; + $result .= $self->levenshtein_matches($primary_index, $secondary_index); + return $result; + } + + if (not defined $data_index) { + my $data = delete $self->{hash}->{$lc_primary_index}->{$lc_secondary_index}; + if (defined $data) { + my $name2 = exists $data->{_name} ? $data->{_name} : $lc_secondary_index; + $name2 = "\"$name2\"" if $name2 =~ / /; + + # remove primary group if no more secondaries + if (grep { $_ ne '_name' } keys %{$self->{hash}->{$lc_primary_index}} == 0) { + delete $self->{hash}->{$lc_primary_index}; + } + + $self->save unless $dont_save; + return "$self->{name}: [$name1] $name2 removed."; + } else { + return "$self->{name}: [$name1] $secondary_index does not exist."; + } + } + + my $name2 = $self->get_key_name($lc_primary_index, $lc_secondary_index); + if (defined delete $self->{hash}->{$lc_primary_index}->{$lc_secondary_index}->{$data_index}) { + return "$self->{name}: [$name1] $name2.$data_index removed."; + } else { + return "$self->{name}: [$name1] $name2.$data_index does not exist."; + } +} + +# for compatibility with DualIndexSQLiteObject +sub create_metadata { } + +# todo: +sub get_each { } +sub get_next { } +sub get_all { } + +1; diff --git a/updates/lib3512/HashObject.pm b/updates/lib3512/HashObject.pm new file mode 100644 index 00000000..be3b7578 --- /dev/null +++ b/updates/lib3512/HashObject.pm @@ -0,0 +1,251 @@ +# File: HashObject.pm +# Author: pragma_ +# +# Purpose: Provides a hash-table object with an abstracted API that includes +# setting and deleting values, saving to and loading from files, etc. Provides +# case-insensitive access to the index key while preserving original case when +# displaying index key. + +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package lib3512::HashObject; + +use warnings; use strict; +use feature 'unicode_strings'; + +use Text::Levenshtein qw(fastdistance); +use JSON; + +sub new { + my ($proto, %conf) = @_; + my $class = ref($proto) || $proto; + my $self = bless {}, $class; + Carp::croak("Missing pbot reference to " . __FILE__) unless exists $conf{pbot}; + $self->{pbot} = $conf{pbot}; + $self->initialize(%conf); + return $self; +} + +sub initialize { + my ($self, %conf) = @_; + $self->{name} = $conf{name} // 'hash object'; + $self->{filename} = $conf{filename} // Carp::carp("Missing filename to HashObject, will not be able to save to or load from file."); + $self->{hash} = {}; +} + +sub load { + my $self = shift; + my $filename; + if (@_) { $filename = shift; } + else { $filename = $self->{filename}; } + + $self->clear; + + if (not defined $filename) { + Carp::carp "No $self->{name} filename specified -- skipping loading from file"; + return; + } + + $self->{pbot}->{logger}->log("Loading $self->{name} from $filename ...\n"); + + if (not open(FILE, "< $filename")) { + $self->{pbot}->{logger}->log("Skipping loading from file: Couldn't open $filename: $!\n"); + return; + } + + my $contents = do { + local $/; + ; + }; + + $self->{hash} = decode_json $contents; + close FILE; + + # update existing entries to use _name to preserve case + # and lowercase any non-lowercased entries + foreach my $index (keys %{$self->{hash}}) { + if (not exists $self->{hash}->{$index}->{_name}) { + if ($index ne lc $index) { + if (exists $self->{hash}->{lc $index}) { + Carp::croak "Cannot update $self->{name} object $index; duplicate object found"; + } + + my $data = delete $self->{hash}->{$index}; + $data->{_name} = $index; + $self->{hash}->{lc $index} = $data; + } + } + } +} + +sub save { + my $self = shift; + my $filename; + if (@_) { $filename = shift; } + else { $filename = $self->{filename}; } + + if (not defined $filename) { + Carp::carp "No $self->{name} filename specified -- skipping saving to file.\n"; + return; + } + + $self->{pbot}->{logger}->log("Saving $self->{name} to $filename\n"); + + if (not $self->get_data('$metadata$', 'update_version')) { + $self->add('$metadata$', { update_version => 3512 }); + } + + my $json = JSON->new; + my $json_text = $json->pretty->canonical->utf8->encode($self->{hash}); + + open(FILE, "> $filename") or die "Couldn't open $filename: $!\n"; + print FILE "$json_text\n"; + close(FILE); +} + +sub clear { + my $self = shift; + $self->{hash} = {}; +} + +sub levenshtein_matches { + my ($self, $keyword) = @_; + my $comma = ''; + my $result = ""; + + foreach my $index (sort keys %{$self->{hash}}) { + my $distance = fastdistance($keyword, $index); + my $length = (length $keyword > length $index) ? length $keyword : length $index; + + if ($length != 0 && $distance / $length < 0.50) { + $result .= $comma . $index; + $comma = ", "; + } + } + + $result =~ s/(.*), /$1 or /; + $result = "none" if $comma eq ''; + return $result; +} + +sub set { + my ($self, $index, $key, $value, $dont_save) = @_; + my $lc_index = lc $index; + + if (not exists $self->{hash}->{$lc_index}) { + my $result = "$self->{name}: $index not found; similiar matches: "; + $result .= $self->levenshtein_matches($index); + return $result; + } + + if (not defined $key) { + my $result = "[$self->{name}] " . $self->get_key_name($lc_index) . " keys: "; + my $comma = ''; + foreach my $k (sort grep { $_ ne '_name' } keys %{$self->{hash}->{$lc_index}}) { + $result .= $comma . "$k => " . $self->{hash}->{$lc_index}->{$k}; + $comma = "; "; + } + $result .= "none" if ($comma eq ''); + return $result; + } + + if (not defined $value) { $value = $self->{hash}->{$lc_index}->{$key}; } + else { + $self->{hash}->{$lc_index}->{$key} = $value; + $self->save unless $dont_save; + } + return "[$self->{name}] " . $self->get_key_name($lc_index) . ": $key " . (defined $value ? "set to $value" : "is not set."); +} + +sub unset { + my ($self, $index, $key) = @_; + my $lc_index = lc $index; + + if (not exists $self->{hash}->{$lc_index}) { + my $result = "$self->{name}: $index not found; similiar matches: "; + $result .= $self->levenshtein_matches($index); + return $result; + } + + if (defined delete $self->{hash}->{$lc_index}->{$key}) { + $self->save; + return "[$self->{name}] " . $self->get_key_name($lc_index) . ": $key unset."; + } else { + return "[$self->{name}] " . $self->get_key_name($lc_index) . ": $key does not exist."; + } +} + +sub exists { + my ($self, $index, $data_index) = @_; + return exists $self->{hash}->{lc $index} if not defined $data_index; + return exists $self->{hash}->{lc $index}->{$data_index}; +} + +sub get_key_name { + my ($self, $index) = @_; + my $lc_index = lc $index; + return $lc_index if not exists $self->{hash}->{$lc_index}; + return exists $self->{hash}->{$lc_index}->{_name} ? $self->{hash}->{$lc_index}->{_name} : $lc_index; +} + +sub get_keys { + my ($self, $index) = @_; + return grep { $_ ne '$metadata$' } keys %{$self->{hash}} if not defined $index; + return grep { $_ ne '_name' } keys %{$self->{hash}->{lc $index}}; +} + +sub get_data { + my ($self, $index, $data_index) = @_; + my $lc_index = lc $index; + return undef if not exists $self->{hash}->{$lc_index}; + return $self->{hash}->{$lc_index} if not defined $data_index; + return $self->{hash}->{$lc_index}->{$data_index}; +} + +sub add { + my ($self, $index, $data, $dont_save) = @_; + my $lc_index = lc $index; + + # preserve case of index + if ($index ne $lc_index) { + $data->{_name} = $index; + } + + $self->{hash}->{$lc_index} = $data; + $self->save unless $dont_save; + return "$index added to $self->{name}."; +} + +sub remove { + my ($self, $index, $data_index, $dont_save) = @_; + my $lc_index = lc $index; + + if (not exists $self->{hash}->{$lc_index}) { + my $result = "$self->{name}: $index not found; similiar matches: "; + $result .= $self->levenshtein_matches($lc_index); + return $result; + } + + if (defined $data_index) { + if (defined delete $self->{hash}->{$lc_index}->{$data_index}) { + delete $self->{hash}->{$lc_index} if keys(%{$self->{hash}->{$lc_index}}) == 1; + $self->save unless $dont_save; + return $self->get_key_name($lc_index) . ".$data_index removed from $self->{name}"; + } else { + return "$self->{name}: " . $self->get_key_name($lc_index) . ".$data_index does not exist."; + } + } + + my $data = delete $self->{hash}->{$lc_index}; + if (defined $data) { + $self->save unless $dont_save; + my $name = exists $data->{_name} ? $data->{_name} : $lc_index; + return "$name removed from $self->{name}."; + } else { + return "$self->{name}: $data_index does not exist."; + } +} + +1;