# This code was forked from the LiveJournal project owned and operated # by Live Journal, Inc. The code has been modified and expanded by # Dreamwidth Studios, LLC. These files were originally licensed under # the terms of the license supplied by Live Journal, Inc, which can # currently be found at: # # http://code.livejournal.org/trac/livejournal/browser/trunk/LICENSE-LiveJournal.txt # # In accordance with the original license, this code and all its # modifications are provided under the GNU General Public License. # A copy of that license can be found in the LICENSE file included as # part of this distribution. package LJ::Subscription; use strict; use v5.10; use Log::Log4perl; my $log = Log::Log4perl->get_logger(__PACKAGE__); use Carp qw/ croak confess /; use LJ::Event; use LJ::NotificationMethod; use LJ::Subscription::Pending; use LJ::Typemap; use constant { INACTIVE => 1 << 0, # user has deactivated DISABLED => 1 << 1, # system has disabled TRACKING => 1 << 2, # subs in the "notices" category }; my @subs_fields = qw(userid subid is_dirty journalid etypeid arg1 arg2 ntypeid createtime expiretime flags); sub new_by_id { my ( $class, $u, $subid ) = @_; croak "new_by_id requires a valid 'u' object" unless LJ::isu($u); return if $u->is_expunged; croak "invalid subscription id passed" unless defined $subid && int($subid) > 0; my $row = $u->selectrow_hashref( "SELECT userid, subid, is_dirty, journalid, etypeid, " . "arg1, arg2, ntypeid, createtime, expiretime, flags " . "FROM subs WHERE userid=? AND subid=?", undef, $u->{userid}, $subid ); die $u->errstr if $u->err; return $class->new_from_row($row); } sub freeze { my $self = shift; return "subid-" . $self->owner->{userid} . '-' . $self->id; } # can return either a LJ::Subscription or LJ::Subscription::Pending object sub thaw { my ( $class, $data, $u, $POST ) = @_; # valid format? return undef unless ( $data =~ /^(pending|subid) - $u->{userid} .+ ?(-old)?$/x ); my ( $type, $userid, $subid ) = split( "-", $data ); return LJ::Subscription::Pending->thaw( $data, $u, $POST ) if $type eq 'pending'; die "Invalid subscription data type: $type" unless $type eq 'subid'; unless ($u) { my $subuser = LJ::load_userid($userid); die "no user" unless $subuser; $u = LJ::get_authas_user($subuser); die "Invalid user $subuser->{user}" unless $u; } return $class->new_by_id( $u, $subid ); } sub pending { 0 } sub default_selected { $_[0]->active && $_[0]->enabled } sub subscriptions_of_user { my ( $class, $u ) = @_; croak "subscriptions_of_user requires a valid 'u' object" unless LJ::isu($u); return if $u->is_expunged; return @{ $u->{_subscriptions} } if defined $u->{_subscriptions}; my $sth = $u->prepare( "SELECT userid, subid, is_dirty, journalid, etypeid, " . "arg1, arg2, ntypeid, createtime, expiretime, flags " . "FROM subs WHERE userid=?" ); $sth->execute( $u->{userid} ); die $u->errstr if $u->err; my @subs; while ( my $row = $sth->fetchrow_hashref ) { push @subs, LJ::Subscription->new_from_row($row); } $u->{_subscriptions} = \@subs; return @subs; } # Class method # Look for a subscription matching the parameters: journalu/journalid, # ntypeid/method, event/etypeid, arg1, arg2 # Returns a list of subscriptions for this user matching the parameters sub find { my ( $class, $u, %params ) = @_; my ( $etypeid, $ntypeid, $arg1, $arg2, $flags ); if ( my $evt = delete $params{event} ) { $etypeid = LJ::Event->event_to_etypeid($evt); } if ( my $nmeth = delete $params{method} ) { $ntypeid = LJ::NotificationMethod->method_to_ntypeid($nmeth); } $etypeid ||= delete $params{etypeid}; $ntypeid ||= delete $params{ntypeid}; $flags = delete $params{flags}; my $journalid = delete $params{journalid}; $journalid ||= LJ::want_userid( delete $params{journal} ) if defined $params{journal}; $arg1 = delete $params{arg1}; $arg2 = delete $params{arg2}; my $require_active = delete $params{require_active} ? 1 : 0; croak "Invalid parameters passed to ${class}->find" if keys %params; return () if defined $arg1 && $arg1 =~ /\D/; return () if defined $arg2 && $arg2 =~ /\D/; my @subs = $u->subscriptions; @subs = grep { $_->active && $_->enabled } @subs if $require_active; # filter subs on each parameter @subs = grep { $_->journalid == $journalid } @subs if defined $journalid; @subs = grep { $_->ntypeid == $ntypeid } @subs if $ntypeid; @subs = grep { $_->etypeid == $etypeid } @subs if $etypeid; if ( defined $flags ) { # check DISABLED and TRACKING flags, but not INACTIVE flag. @subs = grep { ( $flags & DISABLED ) == $_->disabled } @subs; @subs = grep { ( $flags & TRACKING ) == $_->is_tracking_category } @subs; } @subs = grep { $_->arg1 == $arg1 } @subs if defined $arg1; @subs = grep { $_->arg2 == $arg2 } @subs if defined $arg2; return @subs; } # Instance method # Deactivates a subscription. If this is not a "tracking" subscription, # it will delete it instead. Does nothing to disabled subscriptions. sub deactivate { my $self = shift; my %opts = @_; my $force = delete $opts{force}; # force-delete croak "Invalid args" if scalar keys %opts; my $subid = $self->id or croak "Invalid subsciption"; my $u = $self->owner; # don't care about disabled subscriptions return if $self->disabled; # if it's the inbox method, deactivate/delete the other notification methods too my @to_remove = (); my @subs = $self->corresponding_subs; foreach my $subscr (@subs) { # Don't deactivate if the Inbox is always subscribed to my $always_checked = $subscr->event_class->always_checked ? 1 : 0; if ( $subscr->is_tracking_category && !$force ) { # delete non-inbox methods if we're deactivating if ( $subscr->method eq 'LJ::NotificationMethod::Inbox' && !$always_checked ) { $subscr->_deactivate; } else { $subscr->delete; } } else { $subscr->delete; } } } # deletes a subscription sub delete { my $self = shift; my $u = $self->owner; my @subs = $self->corresponding_subs; foreach my $subscr (@subs) { $u->do( "DELETE FROM subs WHERE subid=? AND userid=?", undef, $subscr->id, $u->id ); } # delete from cache in user undef $u->{_subscriptions}; return 1; } # class method, nukes all subs for a user sub delete_all_subs { my ( $class, $u ) = @_; return if $u->is_expunged; $u->do( "DELETE FROM subs WHERE userid = ?", undef, $u->id ); undef $u->{_subscriptions}; return 1; } # class method, nukes all inactive subs for a user sub delete_all_inactive_subs { my ( $class, $u, $dryrun ) = @_; return if $u->is_expunged; my @subs = $class->find($u); @subs = grep { !( $_->active && $_->enabled ) } @subs; my $count = scalar @subs; if ( $count > 0 && !$dryrun ) { $_->delete foreach (@subs); undef $u->{_subscriptions}; } return $count; } # find matching subscriptions with different notification methods sub corresponding_subs { my $self = shift; my @subs = ($self); if ( $self->method eq 'LJ::NotificationMethod::Inbox' ) { push @subs, $self->owner->find_subscriptions( journalid => $self->journalid, etypeid => $self->etypeid, arg1 => $self->arg1, arg2 => $self->arg2, ); } return @subs; } # Class method sub new_from_row { my ( $class, $row ) = @_; return undef unless $row; my $self = bless {%$row}, $class; # TODO validate keys of row. return $self; } sub create { my ( $class, $u, %args ) = @_; # easier way for eveenttype if ( my $evt = delete $args{'event'} ) { $args{etypeid} = LJ::Event->event_to_etypeid($evt); } # easier way to specify ntypeid if ( my $ntype = delete $args{'method'} ) { $args{ntypeid} = LJ::NotificationMethod->method_to_ntypeid($ntype); } # easier way to specify journal if ( my $ju = delete $args{'journal'} ) { $args{journalid} = $ju->{userid} if $ju; } $args{arg1} ||= 0; $args{arg2} ||= 0; $args{journalid} ||= 0; foreach (qw(ntypeid etypeid)) { croak "Required field '$_' not found in call to $class->create" unless defined $args{$_}; } foreach (qw(userid subid createtime)) { croak "Can't specify field '$_'" if defined $args{$_}; } # load current subscription, check if subscription already exists $class->subscriptions_of_user($u) unless $u->{_subscriptions}; my ($existing) = grep { $args{etypeid} == $_->{etypeid} && $args{ntypeid} == $_->{ntypeid} && $args{journalid} == $_->{journalid} && $args{arg1} == $_->{arg1} && $args{arg2} == $_->{arg2} && ( $args{flags} & DISABLED ) == $_->disabled && ( $args{flags} & TRACKING ) == $_->is_tracking_category } @{ $u->{_subscriptions} }; # allow matches if the activation state is unequal if ( defined $existing ) { $existing->activate; return $existing; } my $subid = LJ::alloc_user_counter( $u, 'E' ) or die "Could not alloc subid for user $u->{user}"; $args{subid} = $subid; $args{userid} = $u->{userid}; $args{createtime} = time(); my $self = $class->new_from_row( \%args ); my @columns; my @values; foreach (@subs_fields) { if ( exists( $args{$_} ) ) { push @columns, $_; push @values, delete $args{$_}; } } croak( "Extra args defined, (" . join( ', ', keys(%args) ) . ")" ) if keys %args; my $sth = $u->prepare( 'INSERT INTO subs (' . join( ',', @columns ) . ')' . 'VALUES (' . join( ',', map { '?' } @values ) . ')' ); LJ::errobj($u)->throw if $u->err; $sth->execute(@values); die $sth->errstr if $sth->err; $self->subscriptions_of_user($u) unless $u->{_subscriptions}; push @{ $u->{_subscriptions} }, $self; return $self; } # returns a hash of arguments representing this subscription (useful for passing to # other functions, such as find) sub sub_info { my $self = shift; return ( journalid => $self->journalid, etypeid => $self->etypeid, ntypeid => $self->ntypeid, arg1 => $self->arg1, arg2 => $self->arg2, flags => $self->flags, ); } # returns a nice HTML description of this current subscription sub as_html { my $self = shift; my $evtclass = LJ::Event->class( $self->etypeid ); return undef unless $evtclass; return $evtclass->subscription_as_html($self); } sub set_tracking { my $self = shift; $self->set_flag(TRACKING); } sub activate { my $self = shift; $self->clear_flag(INACTIVE); } sub _deactivate { my $self = shift; $self->set_flag(INACTIVE); } sub enable { my $self = shift; $_->clear_flag(DISABLED) foreach $self->corresponding_subs; } sub disable { my $self = shift; $_->set_flag(DISABLED) foreach $self->corresponding_subs; } sub set_flag { my ( $self, $flag ) = @_; my $flags = $self->flags; # don't bother if flag already set return if $flags & $flag; $flags |= $flag; if ( $self->owner && !$self->pending ) { $self->owner->do( "UPDATE subs SET flags = flags | ? WHERE userid=? AND subid=?", undef, $flag, $self->owner->userid, $self->id ); die $self->owner->errstr if $self->owner->errstr; $self->{flags} = $flags; delete $self->owner->{_subscriptions}; } } sub clear_flag { my ( $self, $flag ) = @_; my $flags = $self->flags; # don't bother if flag already cleared return unless $flags & $flag; # clear the flag $flags &= ~$flag; if ( $self->owner && !$self->pending ) { $self->owner->do( "UPDATE subs SET flags = flags & ~? WHERE userid=? AND subid=?", undef, $flag, $self->owner->userid, $self->id ); die $self->owner->errstr if $self->owner->errstr; $self->{flags} = $flags; delete $self->owner->{_subscriptions}; } } sub id { my $self = shift; return $self->{subid}; } sub createtime { my $self = shift; return $self->{createtime}; } sub flags { my $self = shift; return $self->{flags} || 0; } sub active { my $self = shift; return !( $self->flags & INACTIVE ); } sub enabled { my $self = shift; return !( $self->flags & DISABLED ); } sub disabled { my $self = shift; return !$self->enabled; } sub is_tracking_category { my $self = shift; return $self->flags & TRACKING; } sub expiretime { my $self = shift; return $self->{expiretime}; } sub journalid { my $self = shift; return $self->{journalid}; } sub journal { my $self = shift; return LJ::load_userid( $self->{journalid} ); } sub arg1 { my $self = shift; return $self->{arg1}; } sub arg2 { my $self = shift; return $self->{arg2}; } sub ntypeid { my $self = shift; return $self->{ntypeid}; } sub method { my $self = shift; return LJ::NotificationMethod->class( $self->ntypeid ); } sub notify_class { my $self = shift; return LJ::NotificationMethod->class( $self->{ntypeid} ); } sub etypeid { my $self = shift; return $self->{etypeid}; } sub event_class { my $self = shift; return LJ::Event->class( $self->{etypeid} ); } # returns the owner (userid) of the subscription sub userid { my $self = shift; return $self->{userid}; } sub owner { my $self = shift; return LJ::load_userid( $self->{userid} ); } sub dirty { my $self = shift; return $self->{is_dirty}; } sub notification { my $subscr = shift; my $class = LJ::NotificationMethod->class( $subscr->{ntypeid} ); my $note = $class->new_from_subscription($subscr); return $note; } sub process { my ( $self, @events ) = @_; my $note = $self->notification or return; return 1 if $self->etypeid == LJ::Event::OfficialPost->etypeid && !LJ::is_enabled('officialpost_esn'); # significant events (such as SecurityAttributeChanged) must be processed even for inactive users. return 1 unless $self->notify_class->configured_for_user( $self->owner ) || LJ::Event->class( $self->etypeid )->is_significant; return $note->notify(@events); } sub unique { my $self = shift; my $note = $self->notification or return undef; return $note->unique . ':' . $self->owner->{user}; } # returns true if two subscriptions are equivalent sub equals { my ( $self, $other ) = @_; return 1 if defined $other->id && $self->id == $other->id; my $match = $self->ntypeid == $other->ntypeid && $self->etypeid == $other->etypeid && $self->flags == $other->flags; $match &&= $other->arg1 && ( $self->arg1 == $other->arg1 ) if $self->arg1; $match &&= $other->arg2 && ( $self->arg2 == $other->arg2 ) if $self->arg2; $match &&= $self->journalid == $other->journalid; return $match; } sub available_for_user { my ( $self, $u ) = @_; $u ||= $self->owner; return $self->event_class->available_for_user( $u, $self ); } package LJ::Error::Subscription::TooMany; sub fields { qw(subscr u); } sub as_html { $_[0]->as_string } sub as_string { my $self = shift; my $max = $self->field('u')->count_max_subscriptions; return 'The notification tracking "' . $self->field('subscr')->as_html . '" was not saved because you have' . " reached your limit of $max active notifications. Notifications need to be deactivated before more can be added."; } # Too many subscriptions exist, not necessarily active package LJ::Error::Subscription::TooManySystemMax; sub fields { qw(subscr u max); } sub as_html { $_[0]->as_string } sub as_string { my $self = shift; my $max = $self->field('max'); return 'The notification tracking "' . $self->field('subscr')->as_html . '" was not saved because you have' . " more than $max existing notifications. Notifications need to be completely removed before more can be added."; } 1;