diff --git a/README.md b/README.md index 42ff1e1..11cf0f0 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,9 @@ This is a work-in-progress project done for fun! Don't expect it to be productio The following XRPC endpoints are implemented: -- `com.atproto.server.describeServer` - `com.atproto.identity.resolveHandle` +- `com.atproto.server.describeServer` +- `com.atproto.sync.listRepos` ## Installation diff --git a/app/routes.php b/app/routes.php index bf202ba..38e2bb0 100644 --- a/app/routes.php +++ b/app/routes.php @@ -4,6 +4,7 @@ declare(strict_types=1); use App\Application\Actions\Pds\Atproto\Identity\ResolveHandleAction; use App\Application\Actions\Pds\Atproto\Server\DescribeServerAction; +use App\Application\Actions\Pds\Atproto\Sync\ListReposAction; use Composer\InstalledVersions; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; @@ -32,8 +33,16 @@ ASCII; $response->getBody()->write("

this is phpds, an atproto personal data server implemented in PHP!

"); $response->getBody()->write("

useful routes are under /xrpc/

"); $response->getBody()->write("

please don't use this in prod!

"); - $response->getBody()->write("

with love, @andresitorresm.com

"); - $response->getBody()->write("

source code: GitHub and tangled.sh

"); + $response + ->getBody() + ->write("

with love, @andresitorresm.com

"); + $response + ->getBody() + ->write( + "

source code: " + . "GitHub and " + . "tangled.sh

" + ); return $response; }); @@ -54,10 +63,13 @@ ASCII; // atproto identity $group->get('/com.atproto.identity.resolveHandle', ResolveHandleAction::class); + // atproto sync + $group->get('/com.atproto.sync.listRepos', ListReposAction::class); + // misc $group->get('/_health', function (Request $request, Response $response) { - $version = InstalledVersions::getRootPackage()['pretty_version'] ?? 'unknown'; - $response->getBody()->write(json_encode(['version' => $version])); + $version = InstalledVersions::getRootPackage()['pretty_version']; + $response->getBody()->write((string) json_encode(['version' => $version])); return $response->withHeader('Content-Type', 'application/json'); }); diff --git a/src/Application/Actions/Pds/Atproto/Sync/ListReposAction.php b/src/Application/Actions/Pds/Atproto/Sync/ListReposAction.php new file mode 100644 index 0000000..47782d7 --- /dev/null +++ b/src/Application/Actions/Pds/Atproto/Sync/ListReposAction.php @@ -0,0 +1,147 @@ +actorRepository = $actorRepository; + $this->actorStoreFactory = $actorStoreFactory; + } + + /** + * {@inheritdoc} + */ + protected function action(): Response + { + $params = $this->request->getQueryParams(); + + $limit = $this->parseLimit($params['limit'] ?? null); + $cursor = $this->parseCursor($params['cursor'] ?? null); + + $actors = $this->actorRepository->findPage($cursor, $limit); + + $repos = []; + foreach ($actors as $actor) { + try { + $root = $this->actorStoreFactory->get($actor->getDid()) + ->getRepoRoot() + ->findByDid($actor->getDid()); + } catch (RepoRootNotFoundException $e) { + // actor exists but its repo hasn't been initialised; skip it + continue; + } + + $status = $this->deriveStatus($actor); + $repos[] = new RepoView( + did: $actor->getDid(), + head: $root->getCid(), + rev: $root->getRev(), + active: $status === null, + status: $status, + ); + } + + // a full page means there may be more results; the cursor is the + // last actor DID returned (whether or not it produced a RepoView) + $nextCursor = null; + if (count($actors) === $limit) { + $last = end($actors); + assert($last !== false); + $nextCursor = $last->getDid(); + } + + return $this->respondWithData(new ListReposResponse($repos, $nextCursor)); + } + + private function parseLimit(mixed $raw): int + { + if ($raw === null || $raw === '') { + return self::DEFAULT_LIMIT; + } + + if (!is_string($raw) && !is_int($raw)) { + throw XrpcException::invalidParam( + $this->actionName, + 'limit must be an integer', + (string) (is_scalar($raw) ? $raw : '') + ); + } + + if (is_string($raw) && !preg_match('/^-?\d+$/', $raw)) { + throw XrpcException::invalidParam($this->actionName, 'limit must be an integer', $raw); + } + + $limit = (int) $raw; + if ($limit < 1 || $limit > self::MAX_LIMIT) { + throw XrpcException::invalidParam( + $this->actionName, + sprintf('limit must be between 1 and %d', self::MAX_LIMIT), + (string) $raw + ); + } + + return $limit; + } + + private function parseCursor(mixed $raw): ?string + { + if ($raw === null) { + return null; + } + + if (!is_string($raw)) { + throw XrpcException::invalidParam($this->actionName, 'cursor must be a string', ''); + } + + $cursor = trim($raw); + return $cursor === '' ? null : $cursor; + } + + /** + * Derive the lex `status` for an actor's repo view. + * + * Returns null when the repo is active, and otherwise + * returns a string indicating a non-active repo status + * (e.g. "takendown" or "deactivated"). + */ + private function deriveStatus(\App\Domain\Actor\Actor $actor): ?string + { + if ($actor->getTakedownRef() !== null) { + return RepoView::STATUS_TAKENDOWN; + } + + if ($actor->getDeactivatedAt() !== null) { + return RepoView::STATUS_DEACTIVATED; + } + + return null; + } +} diff --git a/src/Domain/Actor/ActorRepository.php b/src/Domain/Actor/ActorRepository.php index f018cf4..6335a3d 100644 --- a/src/Domain/Actor/ActorRepository.php +++ b/src/Domain/Actor/ActorRepository.php @@ -11,6 +11,15 @@ interface ActorRepository */ public function findAll(): array; + /** + * Return a page of actors ordered by DID ascending, starting strictly + * after $cursor (exclusive). When $cursor is null, starts from the + * beginning. + * + * @return Actor[] + */ + public function findPage(?string $cursor, int $limit): array; + /** * @throws ActorNotFoundException */ diff --git a/src/Domain/Pds/Atproto/Sync/ListReposResponse.php b/src/Domain/Pds/Atproto/Sync/ListReposResponse.php new file mode 100644 index 0000000..9f1c36f --- /dev/null +++ b/src/Domain/Pds/Atproto/Sync/ListReposResponse.php @@ -0,0 +1,50 @@ + */ + private array $repos; + + private ?string $cursor; + + /** + * @param list $repos + */ + public function __construct(array $repos, ?string $cursor = null) + { + $this->repos = $repos; + $this->cursor = $cursor; + } + + /** + * @return list + */ + public function getRepos(): array + { + return $this->repos; + } + + public function getCursor(): ?string + { + return $this->cursor; + } + + /** + * @return array + */ + #[\ReturnTypeWillChange] + public function jsonSerialize(): array + { + $out = ['repos' => $this->repos]; + if ($this->cursor !== null) { + $out['cursor'] = $this->cursor; + } + return $out; + } +} diff --git a/src/Domain/Pds/Atproto/Sync/RepoView.php b/src/Domain/Pds/Atproto/Sync/RepoView.php new file mode 100644 index 0000000..4a34cde --- /dev/null +++ b/src/Domain/Pds/Atproto/Sync/RepoView.php @@ -0,0 +1,72 @@ +did; + } + + public function getHead(): string + { + return $this->head; + } + + public function getRev(): string + { + return $this->rev; + } + + public function isActive(): bool + { + return $this->active; + } + + public function getStatus(): ?string + { + return $this->status; + } + + /** + * @return array + */ + #[\ReturnTypeWillChange] + public function jsonSerialize(): array + { + $out = [ + 'did' => $this->did, + 'head' => $this->head, + 'rev' => $this->rev, + 'active' => $this->active, + ]; + if ($this->status !== null) { + $out['status'] = $this->status; + } + return $out; + } +} diff --git a/src/Infrastructure/Persistence/Actor/SqliteActorRepository.php b/src/Infrastructure/Persistence/Actor/SqliteActorRepository.php index fe563d3..f805940 100644 --- a/src/Infrastructure/Persistence/Actor/SqliteActorRepository.php +++ b/src/Infrastructure/Persistence/Actor/SqliteActorRepository.php @@ -42,6 +42,34 @@ class SqliteActorRepository implements ActorRepository return $this->hydrate($row); } + /** + * @return Actor[] + */ + public function findPage(?string $cursor, int $limit): array + { + if ($limit <= 0) { + return []; + } + + if ($cursor === null || $cursor === '') { + $rows = $this->db->fetchAll( + 'SELECT * FROM actor ORDER BY did ASC LIMIT ?', + [$limit] + ); + } else { + $rows = $this->db->fetchAll( + 'SELECT * FROM actor WHERE did > ? ORDER BY did ASC LIMIT ?', + [$cursor, $limit] + ); + } + + $result = []; + foreach ($rows as $row) { + $result[] = $this->hydrate($row); + } + return $result; + } + public function findActorByHandle(string $handle): Actor { $handle = strtolower(trim($handle)); diff --git a/tests/Application/Actions/Pds/Atproto/Sync/ListReposActionTest.php b/tests/Application/Actions/Pds/Atproto/Sync/ListReposActionTest.php new file mode 100644 index 0000000..63843f4 --- /dev/null +++ b/tests/Application/Actions/Pds/Atproto/Sync/ListReposActionTest.php @@ -0,0 +1,382 @@ + $root (or throws when $root is null). + * + * @param array $rootsByDid + */ + private function makeFactory(array $rootsByDid): ActorStoreFactory + { + $factoryProphecy = $this->prophesize(ActorStoreFactory::class); + + foreach ($rootsByDid as $did => $root) { + $repoRootProphecy = $this->prophesize(RepoRootRepository::class); + if ($root === null) { + $repoRootProphecy->findByDid($did)->willThrow(new RepoRootNotFoundException()); + } else { + $repoRootProphecy->findByDid($did)->willReturn($root); + } + + $storeProphecy = $this->prophesize(ActorStore::class); + $storeProphecy->getRepoRoot()->willReturn($repoRootProphecy->reveal()); + + $factoryProphecy->get($did)->willReturn($storeProphecy->reveal()); + } + + return $factoryProphecy->reveal(); + } + + public function testActionReturnsAllReposWhenUnderLimit(): void + { + $logger = $this->prophesize(LoggerInterface::class)->reveal(); + $settings = new Settings([]); + + $alice = $this->makeActor('did:web:alice.pds.test'); + $bob = $this->makeActor('did:web:bob.pds.test', new DateTimeImmutable('2026-02-01T00:00:00Z')); + + $repoProphecy = $this->prophesize(ActorRepository::class); + $repoProphecy->findPage(null, ListReposAction::DEFAULT_LIMIT) + ->willReturn([$alice, $bob]) + ->shouldBeCalledOnce(); + + $factory = $this->makeFactory([ + 'did:web:alice.pds.test' => new RepoRoot( + 'did:web:alice.pds.test', + 'bafyAliceHead', + '3kabc', + new DateTimeImmutable('2026-01-02T00:00:00Z') + ), + 'did:web:bob.pds.test' => new RepoRoot( + 'did:web:bob.pds.test', + 'bafyBobHead', + '3kxyz', + new DateTimeImmutable('2026-01-03T00:00:00Z') + ), + ]); + + $action = new ListReposAction($logger, $settings, $repoProphecy->reveal(), $factory); + + $request = $this->createRequest('GET', '/xrpc/com.atproto.sync.listRepos'); + $response = (new ResponseFactory())->createResponse(); + + $actualResponse = $action($request, $response, []); + + $expectedPayload = json_encode([ + 'repos' => [ + [ + 'did' => 'did:web:alice.pds.test', + 'head' => 'bafyAliceHead', + 'rev' => '3kabc', + 'active' => true, + ], + [ + 'did' => 'did:web:bob.pds.test', + 'head' => 'bafyBobHead', + 'rev' => '3kxyz', + 'active' => false, + 'status' => 'deactivated', + ], + ], + ], JSON_PRETTY_PRINT); + + $this->assertSame(200, $actualResponse->getStatusCode()); + $this->assertSame('application/json', $actualResponse->getHeaderLine('Content-Type')); + $this->assertSame($expectedPayload, (string) $actualResponse->getBody()); + } + + public function testActionEmitsCursorWhenPageIsFull(): void + { + $logger = $this->prophesize(LoggerInterface::class)->reveal(); + $settings = new Settings([]); + + $alice = $this->makeActor('did:web:alice.pds.test'); + $bob = $this->makeActor('did:web:bob.pds.test'); + + $repoProphecy = $this->prophesize(ActorRepository::class); + $repoProphecy->findPage(null, 2) + ->willReturn([$alice, $bob]) + ->shouldBeCalledOnce(); + + $factory = $this->makeFactory([ + 'did:web:alice.pds.test' => new RepoRoot( + 'did:web:alice.pds.test', + 'bafyAliceHead', + '3kabc', + new DateTimeImmutable('2026-01-02T00:00:00Z') + ), + 'did:web:bob.pds.test' => new RepoRoot( + 'did:web:bob.pds.test', + 'bafyBobHead', + '3kxyz', + new DateTimeImmutable('2026-01-03T00:00:00Z') + ), + ]); + + $action = new ListReposAction($logger, $settings, $repoProphecy->reveal(), $factory); + + $request = $this->createRequest('GET', '/xrpc/com.atproto.sync.listRepos') + ->withQueryParams(['limit' => '2']); + $response = (new ResponseFactory())->createResponse(); + + $actualResponse = $action($request, $response, []); + + $body = json_decode((string) $actualResponse->getBody(), true); + $this->assertIsArray($body); + $repos = $body['repos'] ?? null; + $this->assertIsArray($repos); + $this->assertSame('did:web:bob.pds.test', $body['cursor']); + $this->assertCount(2, $repos); + } + + public function testActionAdvancesUsingProvidedCursor(): void + { + $logger = $this->prophesize(LoggerInterface::class)->reveal(); + $settings = new Settings([]); + + $bob = $this->makeActor('did:web:bob.pds.test'); + + $repoProphecy = $this->prophesize(ActorRepository::class); + $repoProphecy->findPage('did:web:alice.pds.test', ListReposAction::DEFAULT_LIMIT) + ->willReturn([$bob]) + ->shouldBeCalledOnce(); + + $factory = $this->makeFactory([ + 'did:web:bob.pds.test' => new RepoRoot( + 'did:web:bob.pds.test', + 'bafyBobHead', + '3kxyz', + new DateTimeImmutable('2026-01-03T00:00:00Z') + ), + ]); + + $action = new ListReposAction($logger, $settings, $repoProphecy->reveal(), $factory); + + $request = $this->createRequest('GET', '/xrpc/com.atproto.sync.listRepos') + ->withQueryParams(['cursor' => 'did:web:alice.pds.test']); + $response = (new ResponseFactory())->createResponse(); + + $actualResponse = $action($request, $response, []); + + $body = json_decode((string) $actualResponse->getBody(), true); + $this->assertIsArray($body); + $repos = $body['repos'] ?? null; + $this->assertIsArray($repos); + $this->assertArrayNotHasKey('cursor', $body); + $this->assertCount(1, $repos); + $first = $repos[0] ?? null; + $this->assertIsArray($first); + $this->assertSame('did:web:bob.pds.test', $first['did']); + } + + public function testActionSkipsActorsWithoutRepoRoot(): void + { + $logger = $this->prophesize(LoggerInterface::class)->reveal(); + $settings = new Settings([]); + + $alice = $this->makeActor('did:web:alice.pds.test'); + $bob = $this->makeActor('did:web:bob.pds.test'); + + $repoProphecy = $this->prophesize(ActorRepository::class); + $repoProphecy->findPage(null, ListReposAction::DEFAULT_LIMIT) + ->willReturn([$alice, $bob]); + + $factory = $this->makeFactory([ + 'did:web:alice.pds.test' => null, + 'did:web:bob.pds.test' => new RepoRoot( + 'did:web:bob.pds.test', + 'bafyBobHead', + '3kxyz', + new DateTimeImmutable('2026-01-03T00:00:00Z') + ), + ]); + + $action = new ListReposAction($logger, $settings, $repoProphecy->reveal(), $factory); + + $request = $this->createRequest('GET', '/xrpc/com.atproto.sync.listRepos'); + $response = (new ResponseFactory())->createResponse(); + + $actualResponse = $action($request, $response, []); + + $body = json_decode((string) $actualResponse->getBody(), true); + $this->assertIsArray($body); + $repos = $body['repos'] ?? null; + $this->assertIsArray($repos); + $this->assertCount(1, $repos); + $first = $repos[0] ?? null; + $this->assertIsArray($first); + $this->assertSame('did:web:bob.pds.test', $first['did']); + } + + public function testActionRejectsLimitOutOfRange(): void + { + $logger = $this->prophesize(LoggerInterface::class)->reveal(); + $settings = new Settings([]); + $repo = $this->prophesize(ActorRepository::class); + $repo->findPage(Argument::any(), Argument::any())->shouldNotBeCalled(); + $factory = $this->prophesize(ActorStoreFactory::class)->reveal(); + + $action = new ListReposAction($logger, $settings, $repo->reveal(), $factory); + + $request = $this->createRequest('GET', '/xrpc/com.atproto.sync.listRepos') + ->withQueryParams(['limit' => '0']); + $response = (new ResponseFactory())->createResponse(); + + $this->expectException(XrpcException::class); + $action($request, $response, []); + } + + public function testActionRejectsNonIntegerLimit(): void + { + $logger = $this->prophesize(LoggerInterface::class)->reveal(); + $settings = new Settings([]); + $repo = $this->prophesize(ActorRepository::class); + $repo->findPage(Argument::any(), Argument::any())->shouldNotBeCalled(); + $factory = $this->prophesize(ActorStoreFactory::class)->reveal(); + + $action = new ListReposAction($logger, $settings, $repo->reveal(), $factory); + + $request = $this->createRequest('GET', '/xrpc/com.atproto.sync.listRepos') + ->withQueryParams(['limit' => 'banana']); + $response = (new ResponseFactory())->createResponse(); + + $this->expectException(XrpcException::class); + $action($request, $response, []); + } + + public function testActionMarksTakendownActorsAsInactiveWithTakedownStatus(): void + { + $logger = $this->prophesize(LoggerInterface::class)->reveal(); + $settings = new Settings([]); + + $banned = $this->makeActor('did:web:banned.pds.test', null, 'mod-action-123'); + + $repoProphecy = $this->prophesize(ActorRepository::class); + $repoProphecy->findPage(null, ListReposAction::DEFAULT_LIMIT) + ->willReturn([$banned]); + + $factory = $this->makeFactory([ + 'did:web:banned.pds.test' => new RepoRoot( + 'did:web:banned.pds.test', + 'bafyBannedHead', + '3kbanned', + new DateTimeImmutable('2026-01-04T00:00:00Z') + ), + ]); + + $action = new ListReposAction($logger, $settings, $repoProphecy->reveal(), $factory); + + $request = $this->createRequest('GET', '/xrpc/com.atproto.sync.listRepos'); + $response = (new ResponseFactory())->createResponse(); + + $actualResponse = $action($request, $response, []); + + $body = json_decode((string) $actualResponse->getBody(), true); + $this->assertIsArray($body); + $repos = $body['repos'] ?? null; + $this->assertIsArray($repos); + $first = $repos[0] ?? null; + $this->assertIsArray($first); + $this->assertSame('did:web:banned.pds.test', $first['did']); + $this->assertFalse($first['active']); + $this->assertSame('takendown', $first['status']); + } + + public function testActionPrefersTakedownStatusOverDeactivation(): void + { + $logger = $this->prophesize(LoggerInterface::class)->reveal(); + $settings = new Settings([]); + + $actor = $this->makeActor( + 'did:web:both.pds.test', + new DateTimeImmutable('2026-02-01T00:00:00Z'), + 'mod-action-123' + ); + + $repoProphecy = $this->prophesize(ActorRepository::class); + $repoProphecy->findPage(null, ListReposAction::DEFAULT_LIMIT) + ->willReturn([$actor]); + + $factory = $this->makeFactory([ + 'did:web:both.pds.test' => new RepoRoot( + 'did:web:both.pds.test', + 'bafyHead', + '3kboth', + new DateTimeImmutable('2026-01-05T00:00:00Z') + ), + ]); + + $action = new ListReposAction($logger, $settings, $repoProphecy->reveal(), $factory); + + $request = $this->createRequest('GET', '/xrpc/com.atproto.sync.listRepos'); + $response = (new ResponseFactory())->createResponse(); + + $actualResponse = $action($request, $response, []); + + $body = json_decode((string) $actualResponse->getBody(), true); + $this->assertIsArray($body); + $repos = $body['repos'] ?? null; + $this->assertIsArray($repos); + $first = $repos[0] ?? null; + $this->assertIsArray($first); + $this->assertSame('takendown', $first['status']); + } + + public function testActionReturnsEmptyListWhenNoActors(): void + { + $logger = $this->prophesize(LoggerInterface::class)->reveal(); + $settings = new Settings([]); + + $repoProphecy = $this->prophesize(ActorRepository::class); + $repoProphecy->findPage(null, ListReposAction::DEFAULT_LIMIT)->willReturn([]); + + $factory = $this->prophesize(ActorStoreFactory::class); + $factory->get(Argument::any())->shouldNotBeCalled(); + + $action = new ListReposAction($logger, $settings, $repoProphecy->reveal(), $factory->reveal()); + + $request = $this->createRequest('GET', '/xrpc/com.atproto.sync.listRepos'); + $response = (new ResponseFactory())->createResponse(); + + $actualResponse = $action($request, $response, []); + + $body = json_decode((string) $actualResponse->getBody(), true); + $this->assertSame(['repos' => []], $body); + } +} diff --git a/tests/Infrastructure/Persistence/Actor/SqliteActorRepositoryTest.php b/tests/Infrastructure/Persistence/Actor/SqliteActorRepositoryTest.php index 3bca401..2e69402 100644 --- a/tests/Infrastructure/Persistence/Actor/SqliteActorRepositoryTest.php +++ b/tests/Infrastructure/Persistence/Actor/SqliteActorRepositoryTest.php @@ -83,4 +83,59 @@ class SqliteActorRepositoryTest extends TestCase $this->expectException(ActorNotFoundException::class); $repo->findActorByHandle('headless.pds.test'); } + + public function testFindPageReturnsEmptyArrayWhenNoActors(): void + { + $repo = $this->newRepo(); + $this->assertSame([], $repo->findPage(null, 10)); + } + + public function testFindPageReturnsActorsOrderedByDid(): void + { + $repo = $this->newRepo(); + $repo->save($this->makeActor('did:web:bob.pds.test', 'bob.pds.test')); + $repo->save($this->makeActor('did:web:alice.pds.test', 'alice.pds.test')); + $repo->save($this->makeActor('did:web:carol.pds.test', 'carol.pds.test')); + + $page = $repo->findPage(null, 10); + $this->assertCount(3, $page); + $this->assertSame('did:web:alice.pds.test', $page[0]->getDid()); + $this->assertSame('did:web:bob.pds.test', $page[1]->getDid()); + $this->assertSame('did:web:carol.pds.test', $page[2]->getDid()); + } + + public function testFindPageRespectsLimit(): void + { + $repo = $this->newRepo(); + $repo->save($this->makeActor('did:web:alice.pds.test', 'alice.pds.test')); + $repo->save($this->makeActor('did:web:bob.pds.test', 'bob.pds.test')); + $repo->save($this->makeActor('did:web:carol.pds.test', 'carol.pds.test')); + + $page = $repo->findPage(null, 2); + $this->assertCount(2, $page); + $this->assertSame('did:web:alice.pds.test', $page[0]->getDid()); + $this->assertSame('did:web:bob.pds.test', $page[1]->getDid()); + } + + public function testFindPageStartsStrictlyAfterCursor(): void + { + $repo = $this->newRepo(); + $repo->save($this->makeActor('did:web:alice.pds.test', 'alice.pds.test')); + $repo->save($this->makeActor('did:web:bob.pds.test', 'bob.pds.test')); + $repo->save($this->makeActor('did:web:carol.pds.test', 'carol.pds.test')); + + $page = $repo->findPage('did:web:alice.pds.test', 10); + $this->assertCount(2, $page); + $this->assertSame('did:web:bob.pds.test', $page[0]->getDid()); + $this->assertSame('did:web:carol.pds.test', $page[1]->getDid()); + } + + public function testFindPageReturnsEmptyForNonPositiveLimit(): void + { + $repo = $this->newRepo(); + $repo->save($this->makeActor('did:web:alice.pds.test', 'alice.pds.test')); + + $this->assertSame([], $repo->findPage(null, 0)); + $this->assertSame([], $repo->findPage(null, -5)); + } }