perlsky is a Perl 5 implementation of an AT Protocol Personal Data Server.
13
fork

Configure Feed

Select the types of activity you want to include in your feed.

Keep app preferences and self profile local

alice 2c1f61d9 313b321e

+192 -4
+87 -1
lib/ATProto/PDS/ServiceProxy.pm
··· 6 6 use Mojo::Base -base, -signatures; 7 7 use Mojo::URL; 8 8 use Mojo::UserAgent; 9 + use JSON::PP (); 9 10 10 11 use ATProto::PDS::API::Server qw(require_auth); 11 - use ATProto::PDS::API::Util qw(xrpc_error); 12 + use ATProto::PDS::API::Util qw(iso8601 resolve_repo xrpc_error); 12 13 use ATProto::PDS::Auth::JWT qw(encode_service_jwt); 13 14 14 15 has settings => sub { {} }; ··· 20 21 }; 21 22 22 23 sub proxy_xrpc_request ($self, $c, $nsid) { 24 + if ($nsid eq 'app.bsky.actor.getPreferences') { 25 + return $self->_get_preferences($c); 26 + } 27 + if ($nsid eq 'app.bsky.actor.putPreferences') { 28 + return $self->_put_preferences($c); 29 + } 30 + if ($nsid eq 'app.bsky.actor.getProfile') { 31 + my $status = $self->_get_local_profile($c); 32 + return $status if defined $status; 33 + } 34 + 23 35 my $target = $self->_target_for_request($c, $nsid) or return undef; 24 36 25 37 my $method = $c->req->method; ··· 154 166 155 167 sub _config ($self, $key, $default) { 156 168 return $self->settings->{$key} // $default; 169 + } 170 + 171 + sub _get_preferences ($self, $c) { 172 + xrpc_error(405, 'MethodNotAllowed', 'app.bsky.actor.getPreferences expects GET') 173 + unless $c->req->method eq 'GET'; 174 + 175 + my (undef, $account) = require_auth($c, audience => 'access', allow_refresh => 1); 176 + my $preferences = $c->store->list_preferences($account->{did}, 'app.bsky'); 177 + $c->render(json => { preferences => $preferences }); 178 + return 200; 179 + } 180 + 181 + sub _put_preferences ($self, $c) { 182 + xrpc_error(405, 'MethodNotAllowed', 'app.bsky.actor.putPreferences expects POST') 183 + unless $c->req->method eq 'POST'; 184 + 185 + my (undef, $account) = require_auth($c, audience => 'access', allow_refresh => 1); 186 + my $body = $c->req->json || {}; 187 + my $preferences = $body->{preferences}; 188 + xrpc_error(400, 'InvalidRequest', 'preferences must be an array') 189 + unless ref($preferences) eq 'ARRAY'; 190 + 191 + for my $pref (@$preferences) { 192 + xrpc_error(400, 'InvalidRequest', 'preference entries must be objects') 193 + unless ref($pref) eq 'HASH'; 194 + xrpc_error(400, 'InvalidRequest', 'preference entries must include $type') 195 + unless defined($pref->{'$type'}) && length($pref->{'$type'}); 196 + } 197 + 198 + $c->store->put_preferences($account->{did}, 'app.bsky', $preferences); 199 + $c->render(json => {}); 200 + return 200; 201 + } 202 + 203 + sub _get_local_profile ($self, $c) { 204 + xrpc_error(405, 'MethodNotAllowed', 'app.bsky.actor.getProfile expects GET') 205 + unless $c->req->method eq 'GET'; 206 + 207 + my (undef, $viewer) = require_auth($c, audience => 'access', allow_refresh => 1); 208 + my $actor = $c->param('actor') // q(); 209 + xrpc_error(400, 'InvalidRequest', 'actor is required') unless length $actor; 210 + 211 + my $account = resolve_repo($c, $actor) or return undef; 212 + my $profile = $c->store->get_record($account->{did}, 'app.bsky.actor.profile', 'self'); 213 + my $value = (ref($profile) eq 'HASH' && ref($profile->{value}) eq 'HASH') ? $profile->{value} : {}; 214 + 215 + my $result = { 216 + did => $account->{did}, 217 + handle => $account->{handle}, 218 + associated => { 219 + lists => 0, 220 + feedgens => 0, 221 + starterPacks => 0, 222 + labeler => JSON::PP::false, 223 + activitySubscription => { 224 + allowSubscriptions => 'followers', 225 + }, 226 + }, 227 + viewer => { 228 + muted => JSON::PP::false, 229 + blockedBy => JSON::PP::false, 230 + }, 231 + labels => [], 232 + createdAt => iso8601($account->{created_at}), 233 + followersCount => 0, 234 + followsCount => 0, 235 + postsCount => 0 + $c->store->count_records_by_did($account->{did}), 236 + }; 237 + 238 + $result->{displayName} = $value->{displayName} if defined $value->{displayName}; 239 + $result->{description} = $value->{description} if defined $value->{description}; 240 + 241 + $c->render(json => $result); 242 + return 200; 157 243 } 158 244 159 245 1;
+70
lib/ATProto/PDS/Store/SQLite.pm
··· 1059 1059 return [ map { _row_from_json_columns($_, qw(subject_json takedown_json deactivated_json)) } @$rows ]; 1060 1060 } 1061 1061 1062 + sub put_preferences ($self, $did, $namespace, $preferences, %args) { 1063 + die 'did is required' unless defined $did && length $did; 1064 + die 'namespace is required' unless defined $namespace && length $namespace; 1065 + die 'preferences must be an arrayref' unless ref($preferences) eq 'ARRAY'; 1066 + 1067 + my $now = $args{updated_at} // time; 1068 + $self->txn(sub ($dbh) { 1069 + $dbh->do( 1070 + q{DELETE FROM preferences WHERE did = ? AND namespace = ?}, 1071 + undef, 1072 + $did, 1073 + $namespace, 1074 + ); 1075 + 1076 + for my $pref (@$preferences) { 1077 + next unless ref($pref) eq 'HASH'; 1078 + my $type = $pref->{'$type'} // next; 1079 + $dbh->do( 1080 + q{ 1081 + INSERT INTO preferences ( 1082 + did, namespace, pref_type, pref_json, updated_at 1083 + ) VALUES (?, ?, ?, ?, ?) 1084 + }, 1085 + undef, 1086 + $did, 1087 + $namespace, 1088 + $type, 1089 + encode_json($pref), 1090 + $now, 1091 + ); 1092 + } 1093 + }); 1094 + 1095 + return $self->list_preferences($did, $namespace); 1096 + } 1097 + 1098 + sub list_preferences ($self, $did, $namespace) { 1099 + die 'did is required' unless defined $did && length $did; 1100 + die 'namespace is required' unless defined $namespace && length $namespace; 1101 + 1102 + my $rows = $self->dbh->selectall_arrayref( 1103 + q{ 1104 + SELECT pref_json 1105 + FROM preferences 1106 + WHERE did = ? AND namespace = ? 1107 + ORDER BY pref_type ASC 1108 + }, 1109 + { Slice => {} }, 1110 + $did, 1111 + $namespace, 1112 + ); 1113 + return [ map { decode_json($_->{pref_json}) } @$rows ]; 1114 + } 1115 + 1062 1116 sub put_label ($self, %args) { 1063 1117 return observe_store_operation($self->{metrics}, 'put_label', sub { 1064 1118 my $subject_key = $args{subject_key} // die 'subject_key is required'; ··· 1621 1675 ) 1622 1676 }, 1623 1677 q{CREATE INDEX IF NOT EXISTS labels_lookup_idx ON labels (src, uri, id)}, 1678 + ], 1679 + }, 1680 + { 1681 + version => 6, 1682 + statements => [ 1683 + q{ 1684 + CREATE TABLE IF NOT EXISTS preferences ( 1685 + did TEXT NOT NULL, 1686 + namespace TEXT NOT NULL, 1687 + pref_type TEXT NOT NULL, 1688 + pref_json TEXT NOT NULL, 1689 + updated_at INTEGER NOT NULL, 1690 + PRIMARY KEY (did, namespace, pref_type) 1691 + ) 1692 + }, 1693 + q{CREATE INDEX IF NOT EXISTS preferences_lookup_idx ON preferences (did, namespace, pref_type)}, 1624 1694 ], 1625 1695 }, 1626 1696 );
+35 -3
t/service-proxy.t
··· 61 61 saved => [], 62 62 }]; 63 63 } 64 + if ($nsid eq 'app.bsky.notification.listNotifications') { 65 + $body{notifications} = []; 66 + $body{priority} = JSON::PP::false; 67 + } 64 68 $c->render(json => \%body); 65 69 }); 66 70 ··· 122 126 $t->get_ok('/xrpc/app.bsky.actor.getPreferences' => { 123 127 Authorization => "Bearer $access", 124 128 })->status_is(200) 125 - ->json_is('/preferences/0/$type' => 'app.bsky.actor.defs#savedFeedsPref'); 129 + ->json_is('/preferences' => []); 130 + 131 + $t->post_ok('/xrpc/app.bsky.actor.putPreferences' => { 132 + Authorization => "Bearer $access", 133 + } => json => { 134 + preferences => [{ 135 + '$type' => 'app.bsky.actor.defs#savedFeedsPref', 136 + pinned => ['at://did:plc:feed/app.bsky.feed.generator/demo'], 137 + saved => [], 138 + }], 139 + })->status_is(200) 140 + ->json_is({}); 141 + 142 + $t->get_ok('/xrpc/app.bsky.actor.getPreferences' => { 143 + Authorization => "Bearer $access", 144 + })->status_is(200) 145 + ->json_is('/preferences/0/$type' => 'app.bsky.actor.defs#savedFeedsPref') 146 + ->json_is('/preferences/0/pinned/0' => 'at://did:plc:feed/app.bsky.feed.generator/demo'); 147 + 148 + $t->get_ok("/xrpc/app.bsky.actor.getProfile?actor=$did" => { 149 + Authorization => "Bearer $access", 150 + })->status_is(200) 151 + ->json_is('/did' => $did) 152 + ->json_is('/handle' => $created->{handle}); 153 + 154 + $t->get_ok('/xrpc/app.bsky.notification.listNotifications?limit=40' => { 155 + Authorization => "Bearer $access", 156 + })->status_is(200) 157 + ->json_is('/notifications' => []); 126 158 127 159 my $appview_auth = _decode_bearer($t->tx->res->json->{auth}); 128 160 is($appview_auth->{header}{alg}, 'ES256K', 'appview proxy auth uses ES256K'); 129 161 is($appview_auth->{claims}{iss}, $did, 'appview proxy auth is issued by the account DID'); 130 162 is($appview_auth->{claims}{aud}, 'did:web:appview.test', 'appview proxy auth targets the appview DID'); 131 - is($appview_auth->{claims}{lxm}, 'app.bsky.actor.getPreferences', 'appview proxy auth binds the proxied method'); 163 + is($appview_auth->{claims}{lxm}, 'app.bsky.notification.listNotifications', 'appview proxy auth binds the proxied method'); 132 164 ok(_verify_es256k($account->{public_key}, $appview_auth->{signing_input}, $appview_auth->{signature}), 'appview proxy auth signature verifies'); 133 165 134 166 $t->get_ok('/xrpc/chat.bsky.convo.getLog' => { ··· 146 178 Authorization => "Bearer $access", 147 179 'Atproto-Proxy' => 'did:web:appview.test#bsky_appview', 148 180 })->status_is(200) 149 - ->json_is('/nsid' => 'app.bsky.actor.getPreferences'); 181 + ->json_is('/preferences/0/$type' => 'app.bsky.actor.defs#savedFeedsPref'); 150 182 151 183 $t->get_ok('/xrpc/example.unsupported.method') 152 184 ->status_is(404)