diff options
Diffstat (limited to 'lib/Travelynx/Model/Users.pm')
-rw-r--r-- | lib/Travelynx/Model/Users.pm | 756 |
1 files changed, 729 insertions, 27 deletions
diff --git a/lib/Travelynx/Model/Users.pm b/lib/Travelynx/Model/Users.pm index 535b938..4602fa2 100644 --- a/lib/Travelynx/Model/Users.pm +++ b/lib/Travelynx/Model/Users.pm @@ -1,6 +1,6 @@ package Travelynx::Model::Users; -# Copyright (C) 2020 Daniel Friesel +# Copyright (C) 2020-2023 Birte Kristina Friesel # # SPDX-License-Identifier: AGPL-3.0-or-later @@ -8,7 +8,53 @@ use strict; use warnings; use 5.020; +use Crypt::Eksblowfish::Bcrypt qw(bcrypt en_base64); use DateTime; +use JSON; + +my %visibility_itoa = ( + 100 => 'public', + 80 => 'travelynx', + 60 => 'followers', + 30 => 'unlisted', + 10 => 'private', +); + +my %visibility_atoi = ( + public => 100, + travelynx => 80, + followers => 60, + unlisted => 30, + private => 10, +); + +my %predicate_itoa = ( + 1 => 'follows', + 2 => 'requests_follow', + 3 => 'is_blocked_by', +); + +my %predicate_atoi = ( + follows => 1, + requests_follow => 2, + is_blocked_by => 3, +); + +my @sb_templates = ( + undef, + [ 'DBF', 'https://dbf.finalrewind.org/{name}?rt=1#{tt}{tn}' ], + [ 'bahn.expert', 'https://bahn.expert/{name}#{id}' ], + [ 'DBF HAFAS', 'https://dbf.finalrewind.org/{name}?rt=1&hafas=1#{tt}{tn}' ], + [ 'bahn.expert/regional', 'https://bahn.expert/regional/{name}#{id}' ], +); + +my %token_id = ( + status => 1, + history => 2, + travel => 3, + import => 4, +); +my @token_types = (qw(status history travel import)); sub new { my ( $class, %opt ) = @_; @@ -16,6 +62,20 @@ sub new { return bless( \%opt, $class ); } +sub hash_password { + my ( $self, $password ) = @_; + my @salt_bytes = map { int( rand(255) ) + 1 } ( 1 .. 16 ); + my $salt = en_base64( pack( 'C[16]', @salt_bytes ) ); + + return bcrypt( substr( $password, 0, 10000 ), '$2a$12$' . $salt ); +} + +sub get_token_id { + my ( $self, $type ) = @_; + + return $token_id{$type}; +} + sub mark_seen { my ( $self, %opt ) = @_; my $uid = $opt{uid}; @@ -23,8 +83,25 @@ sub mark_seen { $db->update( 'users', - { last_seen => DateTime->now( time_zone => 'Europe/Berlin' ) }, - { id => $uid } + { + last_seen => DateTime->now( time_zone => 'Europe/Berlin' ), + deletion_notified => undef + }, + { id => $uid } + ); +} + +sub mark_deletion_notified { + my ( $self, %opt ) = @_; + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + + $db->update( + 'users', + { + deletion_notified => DateTime->now( time_zone => 'Europe/Berlin' ), + }, + { id => $uid } ); } @@ -34,7 +111,10 @@ sub verify_registration_token { my $token = $opt{token}; my $db = $opt{db} // $self->{pg}->db; - my $tx = $db->begin; + my $tx; + if ( not $opt{in_transaction} ) { + $tx = $db->begin; + } my $res = $db->select( 'pending_registrations', @@ -48,12 +128,30 @@ sub verify_registration_token { if ( $res->hash->{count} ) { $db->update( 'users', { status => 1 }, { id => $uid } ); $db->delete( 'pending_registrations', { user_id => $uid } ); - $tx->commit; + if ($tx) { + $tx->commit; + } return 1; } return; } +sub get_api_token { + my ( $self, %opt ) = @_; + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + + my $token = {}; + my $res = $db->select( 'tokens', [ 'type', 'token' ], { user_id => $uid } ); + + for my $entry ( $res->hashes->each ) { + $token->{ $token_types[ $entry->{type} - 1 ] } + = $entry->{token}; + } + + return $token; +} + sub get_uid_by_name_and_mail { my ( $self, %opt ) = @_; my $db = $opt{db} // $self->{pg}->db; @@ -76,22 +174,41 @@ sub get_uid_by_name_and_mail { return; } -sub get_privacy_by_name { +sub get_privacy_by { my ( $self, %opt ) = @_; - my $db = $opt{db} // $self->{pg}->db; - my $name = $opt{name}; + my $db = $opt{db} // $self->{pg}->db; + + my %where; + + if ( $opt{name} ) { + $where{name} = $opt{name}; + } + else { + $where{id} = $opt{uid}; + } my $res = $db->select( 'users', - [ 'id', 'public_level' ], - { - name => $name, - status => 1 - } + [ 'id', 'name', 'public_level', 'accept_follows' ], + { %where, status => 1 } ); if ( my $user = $res->hash ) { - return $user; + return { + id => $user->{id}, + name => $user->{name}, + default_visibility => $user->{public_level} & 0x7f, + default_visibility_str => + $visibility_itoa{ $user->{public_level} & 0x7f }, + comments_visible => $user->{public_level} & 0x80 ? 1 : 0, + past_visibility => ( $user->{public_level} & 0x7f00 ) >> 8, + past_visibility_str => + $visibility_itoa{ ( $user->{public_level} & 0x7f00 ) >> 8 }, + past_status => $user->{public_level} & 0x08000 ? 1 : 0, + past_all => $user->{public_level} & 0x10000 ? 1 : 0, + accept_follows => $user->{accept_follows} == 2 ? 1 : 0, + accept_follow_requests => $user->{accept_follows} == 1 ? 1 : 0, + }; } return; } @@ -102,9 +219,39 @@ sub set_privacy { my $uid = $opt{uid}; my $public_level = $opt{level}; + if ( not defined $public_level and defined $opt{default_visibility} ) { + $public_level + = ( $opt{default_visibility} & 0x7f ) + | ( $opt{comments_visible} ? 0x80 : 0 ) + | ( ( $opt{past_visibility} & 0x7f ) << 8 ) + | ( $opt{past_status} ? 0x08000 : 0 ) + | ( $opt{past_all} ? 0x10000 : 0 ); + } + $db->update( 'users', { public_level => $public_level }, { id => $uid } ); } +sub set_social { + my ( $self, %opt ) = @_; + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + + my $accept_follows = 0; + + if ( $opt{accept_follows} ) { + $accept_follows = 2; + } + elsif ( $opt{accept_follow_requests} ) { + $accept_follows = 1; + } + + $db->update( + 'users', + { accept_follows => $accept_follows }, + { id => $uid } + ); +} + sub mark_for_password_reset { my ( $self, %opt ) = @_; my $db = $opt{db} // $self->{pg}->db; @@ -256,7 +403,7 @@ sub remove_password_token { ); } -sub get_data { +sub get { my ( $self, %opt ) = @_; my $db = $opt{db} // $self->{pg}->db; my $uid = $opt{uid}; @@ -264,6 +411,7 @@ sub get_data { my $user = $db->select( 'users', 'id, name, status, public_level, email, ' + . 'external_services, accept_follows, notifications, ' . 'extract(epoch from registered_at) as registered_at_ts, ' . 'extract(epoch from last_seen) as last_seen_ts, ' . 'extract(epoch from deletion_requested) as deletion_requested_ts', @@ -271,11 +419,28 @@ sub get_data { )->hash; if ($user) { return { - id => $user->{id}, - name => $user->{name}, - status => $user->{status}, - is_public => $user->{public_level}, - email => $user->{email}, + id => $user->{id}, + name => $user->{name}, + status => $user->{status}, + notifications => $user->{notifications}, + accept_follows => $user->{accept_follows} == 2 ? 1 : 0, + accept_follow_requests => $user->{accept_follows} == 1 ? 1 : 0, + default_visibility => $user->{public_level} & 0x7f, + default_visibility_str => + $visibility_itoa{ $user->{public_level} & 0x7f }, + comments_visible => $user->{public_level} & 0x80 ? 1 : 0, + past_visibility => ( $user->{public_level} & 0x7f00 ) >> 8, + past_visibility_str => + $visibility_itoa{ ( $user->{public_level} & 0x7f00 ) >> 8 }, + past_status => $user->{public_level} & 0x08000 ? 1 : 0, + past_all => $user->{public_level} & 0x10000 ? 1 : 0, + email => $user->{email}, + sb_name => $user->{external_services} + ? $sb_templates[ $user->{external_services} & 0x07 ][0] + : undef, + sb_template => $user->{external_services} + ? $sb_templates[ $user->{external_services} & 0x07 ][1] + : undef, registered_at => DateTime->from_epoch( epoch => $user->{registered_at_ts}, time_zone => 'Europe/Berlin' @@ -309,13 +474,13 @@ sub get_login_data { return $res_h; } -sub add_user { +sub add { my ( $self, %opt ) = @_; my $db = $opt{db} // $self->{pg}->db; my $user_name = $opt{name}; my $email = $opt{email}; my $token = $opt{token}; - my $password = $opt{password_hash}; + my $password = $self->hash_password( $opt{password} ); # This helper must be called during a transaction, as user creation # may fail even after the database entry has been generated, e.g. if @@ -328,9 +493,10 @@ sub add_user { my $res = $db->insert( 'users', { - name => $user_name, - status => 0, - public_level => 0, + name => $user_name, + status => 0, + public_level => $visibility_atoi{unlisted} + | ( $visibility_atoi{unlisted} << 8 ), email => $email, password => $password, registered_at => $now, @@ -383,11 +549,49 @@ sub unflag_deletion { ); } -sub set_password_hash { +sub delete { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + my $tx; + if ( not $opt{in_transaction} ) { + $tx = $db->begin; + } + + my %res; + + $res{tokens} = $db->delete( 'tokens', { user_id => $uid } ); + $res{stats} = $db->delete( 'journey_stats', { user_id => $uid } ); + $res{journeys} = $db->delete( 'journeys', { user_id => $uid } ); + $res{transit} = $db->delete( 'in_transit', { user_id => $uid } ); + $res{hooks} = $db->delete( 'webhooks', { user_id => $uid } ); + $res{trwl} = $db->delete( 'traewelling', { user_id => $uid } ); + $res{password} = $db->delete( 'pending_passwords', { user_id => $uid } ); + $res{relations} = $db->delete( 'relations', + [ { subject_id => $uid }, { object_id => $uid } ] ); + $res{users} = $db->delete( 'users', { id => $uid } ); + + for my $key ( keys %res ) { + $res{$key} = $res{$key}->rows; + } + + if ( $res{users} != 1 ) { + die("Deleted $res{users} rows from users, expected 1. Rolling back.\n"); + } + + if ($tx) { + $tx->commit; + } + + return \%res; +} + +sub set_password { my ( $self, %opt ) = @_; my $db = $opt{db} // $self->{pg}->db; my $uid = $opt{uid}; - my $password = $opt{password_hash}; + my $password = $self->hash_password( $opt{password} ); $db->update( 'users', { password => $password }, { id => $uid } ); } @@ -455,4 +659,502 @@ sub use_history { } } +sub use_external_services { + my ( $self, %opt ) = @_; + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + my $value = $opt{set}; + + if ( defined $value ) { + if ( $value < 0 or $value > 4 ) { + $value = 0; + } + $db->update( 'users', { external_services => $value }, { id => $uid } ); + } + else { + return $db->select( 'users', ['external_services'], { id => $uid } ) + ->hash->{external_services}; + } +} + +sub get_webhook { + my ( $self, %opt ) = @_; + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + + my $res_h = $db->select( 'webhooks_str', '*', { user_id => $uid } )->hash; + + $res_h->{latest_run} = DateTime->from_epoch( + epoch => $res_h->{latest_run_ts} // 0, + time_zone => 'Europe/Berlin', + locale => 'de-DE', + ); + + return $res_h; +} + +sub set_webhook { + my ( $self, %opt ) = @_; + my $db = $opt{db} // $self->{pg}->db; + + if ( $opt{token} ) { + $opt{token} =~ tr{\r\n}{}d; + } + + my $res = $db->insert( + 'webhooks', + { + user_id => $opt{uid}, + enabled => $opt{enabled}, + url => $opt{url}, + token => $opt{token} + }, + { + on_conflict => \ +'(user_id) do update set enabled = EXCLUDED.enabled, url = EXCLUDED.url, token = EXCLUDED.token, errored = null, latest_run = null, output = null' + } + ); +} + +sub update_webhook_status { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + my $url = $opt{url}; + my $success = $opt{success}; + my $text = $opt{text}; + + if ( length($text) > 1000 ) { + $text = substr( $text, 0, 1000 ) . '…'; + } + + $db->update( + 'webhooks', + { + errored => $success ? 0 : 1, + latest_run => DateTime->now( time_zone => 'Europe/Berlin' ), + output => $text, + }, + { + user_id => $uid, + url => $url + } + ); +} + +sub set_profile { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + my $profile = $opt{profile}; + + $db->update( + 'users', + { profile => JSON->new->encode($profile) }, + { id => $uid } + ); +} + +sub get_profile { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + + return $db->select( 'users', ['profile'], { id => $uid } ) + ->expand->hash->{profile}; +} + +sub get_relation { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + my $subject = $opt{subject}; + my $object = $opt{object}; + + my $res_h = $db->select( + 'relations', + ['predicate'], + { + subject_id => $subject, + object_id => $object, + } + )->hash; + + if ($res_h) { + return $predicate_itoa{ $res_h->{predicate} }; + } + return; + + #my $res_h = $db->select( 'relations', ['subject_id', 'predicate'], + # { subject_id => [$uid, $target], object_id => [$target, $target] } )->hash; +} + +sub update_notifications { + my ( $self, %opt ) = @_; + + # must be called inside a transaction, so $opt{db} is mandatory. + my $db = $opt{db}; + my $uid = $opt{uid}; + + my $has_follow_requests = $opt{has_follow_requests} + // $self->has_follow_requests( + db => $db, + uid => $uid + ); + + my $notifications + = $db->select( 'users', ['notifications'], { id => $uid } ) + ->hash->{notifications}; + if ($has_follow_requests) { + $notifications |= 0x01; + } + else { + $notifications &= ~0x01; + } + $db->update( 'users', { notifications => $notifications }, { id => $uid } ); +} + +sub follow { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + my $target = $opt{target}; + + $db->insert( + 'relations', + { + subject_id => $uid, + predicate => $predicate_atoi{follows}, + object_id => $target, + ts => DateTime->now( time_zone => 'Europe/Berlin' ), + } + ); +} + +sub request_follow { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + my $target = $opt{target}; + + my $tx; + if ( not $opt{in_transaction} ) { + $tx = $db->begin; + } + + $db->insert( + 'relations', + { + subject_id => $uid, + predicate => $predicate_atoi{requests_follow}, + object_id => $target, + ts => DateTime->now( time_zone => 'Europe/Berlin' ), + } + ); + $self->update_notifications( + db => $db, + uid => $target, + has_follow_requests => 1, + ); + + if ($tx) { + $tx->commit; + } +} + +sub accept_follow_request { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + my $applicant = $opt{applicant}; + + my $tx; + if ( not $opt{in_transaction} ) { + $tx = $db->begin; + } + + $db->update( + 'relations', + { + predicate => $predicate_atoi{follows}, + ts => DateTime->now( time_zone => 'Europe/Berlin' ), + }, + { + subject_id => $applicant, + predicate => $predicate_atoi{requests_follow}, + object_id => $uid + } + ); + $self->update_notifications( + db => $db, + uid => $uid + ); + + if ($tx) { + $tx->commit; + } +} + +sub reject_follow_request { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + my $applicant = $opt{applicant}; + + my $tx; + if ( not $opt{in_transaction} ) { + $tx = $db->begin; + } + + $db->delete( + 'relations', + { + subject_id => $applicant, + predicate => $predicate_atoi{requests_follow}, + object_id => $uid + } + ); + $self->update_notifications( + db => $db, + uid => $uid + ); + + if ($tx) { + $tx->commit; + } +} + +sub cancel_follow_request { + my ( $self, %opt ) = @_; + + $self->reject_follow_request( + db => $opt{db}, + uid => $opt{target}, + applicant => $opt{uid}, + ); +} + +sub unfollow { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + my $target = $opt{target}; + + $db->delete( + 'relations', + { + subject_id => $uid, + predicate => $predicate_atoi{follows}, + object_id => $target + } + ); +} + +sub remove_follower { + my ( $self, %opt ) = @_; + + $self->unfollow( + db => $opt{db}, + uid => $opt{follower}, + target => $opt{uid}, + ); +} + +sub block { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + my $target = $opt{target}; + + my $tx; + if ( not $opt{in_transaction} ) { + $tx = $db->begin; + } + + $db->insert( + 'relations', + { + subject_id => $target, + predicate => $predicate_atoi{is_blocked_by}, + object_id => $uid, + ts => DateTime->now( time_zone => 'Europe/Berlin' ), + }, + { + on_conflict => \ +'(subject_id, object_id) do update set predicate = EXCLUDED.predicate' + }, + ); + $self->update_notifications( + db => $db, + uid => $uid + ); + + if ($tx) { + $tx->commit; + } +} + +sub unblock { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + my $target = $opt{target}; + + $db->delete( + 'relations', + { + subject_id => $target, + predicate => $predicate_atoi{is_blocked_by}, + object_id => $uid + }, + ); +} + +sub get_followers { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + + my $res = $db->select( + 'followers', + [ 'id', 'name', 'accept_follows', 'inverse_predicate' ], + { self_id => $uid } + ); + + my @ret; + while ( my $row = $res->hash ) { + push( + @ret, + { + id => $row->{id}, + name => $row->{name}, + following_back => ( + $row->{inverse_predicate} + and $row->{inverse_predicate} == $predicate_atoi{follows} + ) ? 1 : 0, + followback_requested => ( + $row->{inverse_predicate} + and $row->{inverse_predicate} + == $predicate_atoi{requests_follow} + ) ? 1 : 0, + can_follow_back => ( + not $row->{inverse_predicate} + and $row->{accept_follows} == 2 + ) ? 1 : 0, + can_request_follow_back => ( + not $row->{inverse_predicate} + and $row->{accept_follows} == 1 + ) ? 1 : 0, + } + ); + } + return @ret; +} + +sub has_followers { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + + return $db->select( 'followers', 'count(*) as count', { self_id => $uid } ) + ->hash->{count}; +} + +sub get_follow_requests { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + my $table = $opt{sent} ? 'tx_follow_requests' : 'rx_follow_requests'; + + my $res + = $db->select( $table, [ 'id', 'name' ], { self_id => $uid } ); + + return $res->hashes->each; +} + +sub has_follow_requests { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + my $table = $opt{sent} ? 'tx_follow_requests' : 'rx_follow_requests'; + + return $db->select( $table, 'count(*) as count', { self_id => $uid } ) + ->hash->{count}; +} + +sub get_followees { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + + my $res = $db->select( + 'followees', + [ 'id', 'name', 'inverse_predicate' ], + { self_id => $uid } + ); + + my @ret; + while ( my $row = $res->hash ) { + push( + @ret, + { + id => $row->{id}, + name => $row->{name}, + following_back => ( + $row->{inverse_predicate} + and $row->{inverse_predicate} == $predicate_atoi{follows} + ) ? 1 : 0, + } + ); + } + return @ret; +} + +sub has_followees { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + + return $db->select( 'followees', 'count(*) as count', { self_id => $uid } ) + ->hash->{count}; +} + +sub get_blocked_users { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + + my $res + = $db->select( 'blocked_users', [ 'id', 'name' ], { self_id => $uid } ); + + return $res->hashes->each; +} + +sub has_blocked_users { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + + return $db->select( 'blocked_users', 'count(*) as count', + { self_id => $uid } )->hash->{count}; +} + 1; |