Komentarze na blogu z użyciem Mastodona - część 2.

15 czerwca 2023
Jakub Rojek Jakub Rojek
Zdjęcie autorstwa Sincerely Media na Unsplash (https://unsplash.com/photos/ylveRpZ8L1s)
Kategorie: Programowanie, Web, Media społecznościowe, Poradniki

Poprzednio omówiliśmy koncepcję stworzenia systemu komentarzy na blogu lub stronie internetowej przy użyciu Mastodona. Konkretnie zrobiliśmy wprowadzenie w temat i wyświetlaniem takich postów wraz z wyjaśnieniem, dlaczego ta koncepcja może być kusząca. Jeśli przeoczyliście ten tekst lub chcecie go sobie odświeżyć, to link do niego znajdziecie tutaj. Tymczasem, bez zbędnej zwłoki, zapraszamy Was do drugiej części, w której zajmiemy się dodawaniem komentarzy z poziomu blogu, oczywiście, nadal za pośrednictwem Mastodona. Przy okazji opowiemy sobie trochę więcej o federacyjności, jaka cechuje to medium społecznościowe.

Na początku omówimy parę kwestii teoretycznych, także jeżeli nie czujecie się nowicjuszami w kwestii obsługi Fediverse lub OAuth 2.0, to śmiało możecie pominąć te sekcje i przejść od razu do przykładu implementacji.

Dlaczego dodanie komentarzy nie jest łatwe?

W większości funkcji realizowanych w przeróżnych aplikacjach, wyświetlanie jest łatwiejsze od wprowadzania danych. Jest to związane z tym, że pokazanie informacji na ekranie zazwyczaj wymaga jedynie wywołania żądania GET i odpowiedniego ustrukturyzowania oraz ostylowania wyników. Z kolei przy dodawaniu lub aktualizowaniu danych potrzebne są m.in. obsługa formularza, walidacja, wyświetlenie błędów oraz w końcu zapisanie wartości w konkretnej formie. A teraz wyobraźmy sobie, że to ostatnie musimy robić korzystając z zewnętrznego API.

Tak jest właśnie z Mastodonem, ale oczywiście, nie jest on wyjątkiem - każdy system stojący na jego miejscu byłby odbierany tak samo. Kolejną rzeczą, którą musimy się zająć, jest autoryzacja użytkownika, który chce dodać wpis. W tak dużych systemach, które pozwalają na SSO (ang. Single Sign-On - jeszcze dzisiaj do tego wrócimy) nie ma mowy, aby podczas korzystania z API w żądaniu wysyłać po prostu hasło. Raz, że mogą ogarnąć nas wówczas wątpliwości związane z bezpieczeństwem (choć protokół HTTPS jest tutaj naszym przyjacielem), a dwa, że nie ma potrzeby, aby użytkownik podawał swoje hasło do zewnętrznego serwisu na np. naszej stronie i się tym stresował. Dlatego czeka nas nauka korzystania z OAuth 2.0.

Najpierw jednak musimy zmierzyć się z jeszcze jedną kwestią, która już wprost odnosi się do Mastodona, a raczej całego Fediverse - federacyjną, rozproszoną konstrukcją całego systemu. W przypadku np. Twittera odniesienie się do serwisu byłoby względnie proste - istnieje jeden serwer (ponownie - wiadomo, że to farma serwerów, ale nie jest to interesujące z naszego punktu widzenia) i jeśli ktoś chce skorzystać ze swojego konta na Ćwierkaczu, to mamy jeden punkt dostępowy, do którego musimy się udać (twitter.com). W przypadku Mastodona nie jest już to takie oczywiste - każdy użytkownik odwiedzający nasz blog, który chce skomentować artykuł, może mieć konto na innej instancji. Na szczęście, posiadają one to samo API, jednak musimy być świadomi tego, że token autoryzacyjny pobrany dla jednego serwera nie zadziała na innym. Tym problemem też się dzisiaj zajmiemy.

Federacyjna natura pokazuje też inny problem - dystrybucję postów pomiędzy instancjami. Tutaj musimy na chwilę się zatrzymać.

Jak rozgłaszany jest wpis w Mastodonie?

W scentralizowanym systemie wszystko jest w miarę proste - użytkownik loguje się do serwisu, uzupełnia treść wpisu, klika "wyślij" i post jest wysyłany na serwer. Każdy użytkownik, który go zobaczy, widzi dokładnie tę paczką danych, która została oryginalnie opublikowana oraz pod tym samym identyfikatorem, co wszyscy inni odbiorcy. Dzięki temu łatwo można komuś wysłać do link do wiadomości, gdyż taki odnośnik jest jeden. A jak jest w systemie sfederowanym? Sprawa się trochę komplikuje i ma to dla nas znaczenie.

Idea federacji opiera się na tym, że poszczególne instancje są niezależnymi bytami, ale pozwalają komunikować się użytkownikom ze sobą tak, jakby był to jeden organizm. Czyli jeśli użytkownik z instancji A napisze post, to mogą zobaczyć go nie tylko użytkownicy instancji A, ale także te osoby z instancji B, które obserwują tego użytkownika (w uproszczeniu). Działa tutaj normalna zasada serwisów społecznościowych - jeśli kogoś obserwujemy lub ktoś, kogo obserwujemy, poda dalej toota lub wejdziemy wprost do danej rozmowy, to możemy zobaczyć wpisy z innej instancji. Co prawda, czasem odczuwalne jest pewne opóźnienie, ale w konsekwencji będzie to działać. Tylko że technicznie te wpisy nie są tymi samymi, co oryginalne.

Wyobraźmy sobie sytuację, w której jesteśmy na instancji A i przeglądamy jakiś post z naszego macierzystego serwera kub zwyczajnie wyświetlamy naszą oś czasu (ang. timeline). Możemy robić to wiele razy dziennie i najczęściej widzimy tam wpisy nie tylko opublikowane na instancji A, ale też takie, które trafiły do nas z instancji B, C, D itd. W podstawowym przypadku nasza kopia Mastodona powinna za każdym razem sprawdzać inne instancje, aby zorientować się, które posty pobrać. Jednak bez żadnych eksperymentów możemy powiedzieć, że jest to bez sensu - przy systemie, który obsługuje tysiące użytkowników (największe instancje mają po kilkaset tysięcy), pobieranie dla każdej osoby w każdej sytuacji danych z zewnątrz to prosta droga do kłopotów z wydajnością (a wręcz uniemożliwienia normalnego działania i to nie tylko "naszej" maszyny). Z tego powodu tooty oraz informacje o profilach z innych instancji są kopiowanie na każdej instancji, na której zostały wyświetlone. Oczywiście, nie działa to idealnie, jednak takie cache'owanie jest dużo lepszym rozwiązaniem niż odwoływanie się za każdym razem do źródła. Wraz z kopią zapisywane są odnośniki do oryginału, dzięki czemu w określonych momentach następuje aktualizacja danych, jeśli np. oryginalny wpis uległ edycji.

Najważniejsza nauka, która z tego płynie i przyda nam się w dalszych działaniach, to wiedza o tym, że toot na oryginalnej instancji i na innych może mieć zupełnie inne identyfikatory. Przykładem niech będzie ten, zapoznawczy wpis Wilda Software na naszej instancji. W oryginalne link do niego wygląda następująco:

https://social.wildasoftware.pl/@wilda/110010384660147893

Natomiast gdy ten wpis ogląda ktoś np. z instancji 101010.pl, to widzi już inny adres:

https://101010.pl/@wilda@social.wildasoftware.pl/110010384696884554

Łatwo można poznać, że nie jest to toot z obecnego serwera - do nazwy użytkownika została doklejona domena, natomiast widzimy również inny identyfikator na końcu. Nie jest to dziwne, ponieważ różne instancje mają swoje osobne bazy danych, a więc także inną sekwencję numeracji - jeśli na instancji A post dostał ID = 10, to nie możemy oczekiwać, że po pobraniu na instancję B, C czy D tam również ten ID będzie dostępny lub następny w kolejności. Na każdym serwerze numerek będzie inny, ale treść pozostanie taka sama.

A kiedy w ogóle nasze wpisy są kopiowane na instancję A? W uproszczeniu są to momenty, kiedy są odpowiedziami na toot z instancji A (nawet na kopię tego toota), kiedy ktoś na instancji A nas obserwuje lub jest to prywatna wiadomość. Nie zawsze to działa od razu - swego czasu, gdy mastodon.social (największa instancja Mastodona z opieką samego twórcy) miał potężne problemy wydajnościowe, wpisy stamtąd pojawiały się na innych instancjach z dużym opóźnieniem. Oczywiście, samo kopiowanie nie jest ideą związaną wyłącznie z Mastodonem - to koncepcja wywodząca się z ActivityPub, a więc interfejsu wiążącego wszystkie aplikacje fediwersowe, jak choćby Pixelfed, /kbin czy Friendica.

Jak widać, federacyjność nie jest prostą koncepcją i będzie stanowić pewną trudność w naszym zadaniu, jednak jak najbardziej działa i pozwala na skalowanie poziome całego systemu, zwiększając jego dostępność, a także wydajność (problemy z tym ostatnim dotyczą głównie bardzo dużych instancji, a więc w pewien sposób punktów starających sie wszystko centralizować). Czas teraz przejść do drugiego "kłopotu" - skorzystania z API z poziomu danego użytkownika, co oznacza zapoznanie się ze sposobem działania pewnego protokołu.

OAuth 2.0

W razie czego od razu zaznaczam - OAuth 2.0 nie ma nic wspólnego z ideą federacyjności. Jest to standardowy protokół autoryzacji użytkowników, który jest stosowany w wielu API oferowanych przez różne systemy informatyczne. Nie wszystkie - istnieją inne podejścia, w tym prostsze oparte o tokeny JWT (ang. JSON Web Token), które jest również szeroko stosowane i ma swoje zalety i wady, zresztą podobnie jak bohater tej sekcji. OAuth 2.0 zazwyczaj stosowany jest w sytuacji, gdy pragnie się osiągnąć tzw. logowanie jednokrotne, w skrócie SSO (ang. Single Sign-On), a więc logowanie się za pomocą jednego konta do wielu usług. W tym momencie niektórym może zapalić się lampka w głowie - przecież wiele stron oferuje uwierzytelnianie za pomocą konta Google'a, Facebooka czy Apple'a. Tak, tam też mamy do czynienia z OAuth 2.0 i chociaż zawsze będą istnieć różnice w integracji z takimi systemami (gdyż różni się implementacja samego protokołu), to ogólna zasada będzie podobna.

Formalnie ten protokół może być traktowany jako mechanizm autoryzacji przekazania dostępu do zasobów danego użytkownika. Mówiąc prościej - w ten sposób możemy zewnętrznej stronie (np. blogowi) udostępnić możliwość zapisu danych na Mastodonie za pośrednictwem naszego konta. Oczywiście, zastosowanie jest dużo szersze, jednak w naszym przypadku będzie to głównie możliwość dodawania tootów oraz odczytu wyników wyszukiwania i danych profilu.

Jak przebiega cały proces?

  1. Użytkownik na blogu wybiera opcję wykonania akcji za pomocą konta w zewnętrznym serwisie (Mastodonie). Najczęściej jest to zalogowanie się, ale w naszym przypadku będzie to udzielenie odpowiedzi na komentarz.
  2. Strona przenosi użytkownika do zewnętrznego dostawcy (konkretnej instancji Mastodona) z prośbą o autoryzację konkretnych typów operacji (tzw. scope'a). Jeśli osoba nie jest zalogowana, musi wpisać swoje dane uwierzytelniające (najczęściej adres e-mail oraz hasło).
  3. Zewnętrzny dostawca wyświetla informację o typach operacji, jakie blog potrzebuje do swoich celów (np. możliwość dodawania wpisów). Użytkownik potwierdza.
  4. Zewnętrzny dostawca generuje kod i wraca z nim do blogu na wyznaczony wcześniej adres (przesłany razem z prośbą o autoryzację).
  5. Blog na podstawie kodu (tokenu) może wykonywać kolejne operacje w imieniu konta użytkownika.

Tak, jak pisałem, to procedura ogólna i może się zmienić w zależności od potrzeb oraz zewnętrznego serwisu. Nieco inaczej będzie to wyglądało dla Google'a, a inaczej dla Mastodona. Tym niemniej, zasada działania jest podobna i prowadzi do potwierdzenia, że użytkownik zgadza się na użycie swojego konta w obrębie danego blogu czy strony internetowej w konkretnych celach. W naszym przypadku - potwierdza, że wpis, który dokona z poziomu naszego blogu, pojawi się w Fediverse pod jego kontem. Warto również zauważyć, że w większości przypadków udzielenie autoryzacji na dany zestaw operacji jest zapamiętywane na pewien czas i nie ma potrzeby np. potwierdzać swojej woli za każdym razem, gdy logujemy się kontem Google'a - w większości wypadków, jeśli robimy to regularnie, dzieje się to automatycznie.

Jeśli pragniecie poznać ten protokół bardziej szczegółowo lub skupić się na kwestiach związanych z żądaniami HTTP i bezpieczeństwem, a także dowiedzieć się, dlaczego nie powinien on służyć do samego uwierzytelnienia, to polecamy ten artykuł od Sekuraka.

Przykładowa implementacja

Uzbrojeni w wiedzę o tym, jak "roznoszą się" posty na Mastodonie i jak autoryzować w nim operacje, możemy przejść do przykładowej implementacji. Tak, jak poprzednio, skorzystamy z przykładu, który sobie wcześniej przygotowaliśmy w PHP, Laravelu oraz Bootstrapie i który dostępny jest w tym miejscu:

https://github.com/WildaSoftware/MastodonCommentSystem

Jeszcze raz podkreślę, że jest to przykładowa implementacja. Nie jest to biblioteka przygotowana do natychmiastowego zaczerpnięcia jej do swojego projektu ani ideał do którego należy dążyć. Jak najbardziej istnieje tutaj miejsce na swoją inwencję, poprawki, a przede wszystkim dostosowanie do systemu, w których system komentarzy ma zostać osadzony. Z miłą chęcią zobaczymy, w jaki sposób zaimplementowaliście swoje komentarze oparte o Mastodona na swoich blogach czy stronie - zawsze jest to okazja do wymiany doświadczeń.

Kroków przy dodawaniu komentarza jest trochę więcej niż przy ich wyświetlaniu. Dlatego nie będziemy na początku umieszczać planu ramowego całego procesu, tylko przejdziemy etap po etapie, aż do osiągnięcia celu.

Wywołanie procedury i wybór instancji

Na początku musimy pamiętać o tym, że w przeciwieństwie np. do Twittera, integracja z Mastodonem nie ma jasno przypisanego serwera - mogą trafić do nas użytkownicy korzystający z mastodon.social, pol.social, 101010.pl, wspanialy.eu czy z zupełnie innej instancji. Jest to ważne, ponieważ determinuje to serwer, na którym znajduje się konto danej osoby, a co za tym idzie - miejsce wywołania API. Dlatego na początku użytkownik musi wybrać instancję. Zrealizujemy to poprzez wywołanie okna modalnego w reakcji na kliknięty przycisk odpowiedzi.

// fragment pliku blog-post-comment-box.blade.php

<div class="col-12 comment" ...>
    ...
    <div class="comment-reply-to row">
        <div class="col-12 text-right">
            <i class="comment-reply-link fa-solid fa-reply fa-lg" data-src="{{ $comment['url'] }}" title="{{ __('replyToThisCommentWithMastodon') }}"></i>
        </div>
    </div>
</div>

// fragment pliku blog-post.blade.php

<body>
	...
	<div class="modal fade" id="mastodon-reply-modal" tabindex="-1" role="dialog">
		<div class="modal-dialog modal-dialog-centered" role="document">
			<div class="modal-content">
				<div class="modal-header">
					<h5 class="modal-title">{{ __('enterMastodonDomain') }}</h5>
					<button type="button" class="close" data-dismiss="modal" aria-label="{{ __('closeModal') }}">
						<span aria-hidden="true">×</span>
					</button>
				</div>
				<div class="modal-body">
					<p>{!! __('provideMastodonInstanceToReply') !!}</p>
					<input type="hidden" id="mastodon-src-reply"/>
					<input type="text" class="form-control" id="mastodon-domain-reply" placeholder="{{ __('provideMastodonInstanceShort') }}">
				</div>
				<div class="modal-footer">
					<button type="button" id="mastodon-reply-modal-confirm" class="btn btn-primary">{{ __('confirmModal') }}</button>
					<button type="button" id="mastodon-reply-modal-cancel" class="btn btn-secondary" data-dismiss="modal">{{ __('cancelModal') }}</button>
				</div>
			</div>
		</div>
	</div>

	<div class="modal fade" id="mastodon-reply-modal-failure" tabindex="-1" role="dialog">
		<div class="modal-dialog modal-dialog-centered" role="document">
			<div class="modal-content">
				<div class="modal-header">
					<h5 class="modal-title">{{ __('mastodonFailure') }}</h5>
					<button type="button" class="close" data-dismiss="modal" aria-label="{{ __('closeModal') }}">
						<span aria-hidden="true">×</span>
					</button>
				</div>
				<div class="modal-body">
					<p>{{ __('noMastodonConnectionDescription') }}</p>
				</div>
				<div class="modal-footer">
					<button type="button" id="mastodon-reply-modal-ok" class="btn btn-primary">{{ __('ok') }}</button>
				</div>
			</div>
		</div>
	</div>
	
	...
	
	<script src="<?= URL::to('/') ?>/js/post.js"></script>
	<script>
		(new PostScript(
			"<?= URL::to('/') ?>",
			<?= $post->id ?>,
			<?= json_encode($commentSourceIds) ?>,
			<?= json_encode([
				'enterMastodonDomain' => __('enterMastodonDomain'),
			]) ?>,
			<?= !empty($mastodonCode) ? '"'.$mastodonCode.'"' : '' ?>
		)).init();
	</script>
</body>

// fragment pliku post.js

class PostScript {

    constructor(baseUrl, postId, commentSourceIds, translations, mastodonCode) {
        this.baseUrl = baseUrl + '/';
        this.postId = postId;
        this.commentSourceIds = commentSourceIds;
        this.translations = translations;
        this.mastodonCode = mastodonCode;

        this.isCommentContainerLoaded = false;
    }

    init() {
        $('#comment-container').on('click', '.comment-reply-link', (event) => {
            const savedDomain = localStorage.getItem('lastMastodonDomain');
            if(savedDomain) {
                $('#mastodon-domain-reply').val(savedDomain);
            }
            
            const src = $(event.target).data('src');
            $('#mastodon-src-reply').val(src);

            $('#mastodon-domain-reply').removeClass('error');
            $('#mastodon-reply-modal').modal('show');
        });

        $('#mastodon-reply-modal-confirm').on('click', (event) => {
            $('#mastodon-domain-reply').removeClass('error');
            
            const domain = $('#mastodon-domain-reply').val();
            const src = $('#mastodon-src-reply').val();
            if(domain && src) {
                $('#mastodon-reply-modal').modal('hide');
                localStorage.setItem('lastMastodonDomain', domain);
                localStorage.setItem('lastMastodonSrc', src);

                $.ajax({
						...
                    },
                    error: (err) => {
                        $('#mastodon-reply-modal-failure').modal('show');
                    }
                });
            }
            else {
                $('#mastodon-domain-reply').addClass('error');
            }
        });

        $('#mastodon-reply-modal-ok').click((event) => {
            $('#mastodon-reply-modal-failure').modal('hide');
        });
    }
	
	...
}

To tylko fragmenty plików, które interesują nas w danym momencie. Pierwsze, co widzimy, to fakt, że pod każdym komentarzem znajduje się ikonka, od której zaczyna się cała operacja - ma ona klasę HTML-ową comment-reply-link i w reakcji na nią pokazuje się okno modalne.

Zrzut ekranu komentarza na blogu wildasoftware.pl, gdzie w prawym dolnym rogu znajduje się zaznaczona ikonka rozpoczynająca udzielenie odpowiedzi
Okno modalne, w którym użytkownik jest proszony o podanie instancji Mastodona, na której posiada konto

Od razu chciałem zaznaczyć, że można to rozwiązać w inny sposób - zamiast okna modalnego można zdecydować się na listę rozwijaną, normalne pole tekstowe lub jeszcze inną implementację. Nie ma to żadnego znaczenia - my wykonaliśmy to taką metodą, ale równie dobra jest każda inna, która doprowadzi do wskazania poprawnej instancji. Poprawnej, ponieważ musimy się przygotować na to, że użytkownik może podać nieistniejący adres lub niebędący Mastodonem. Stąd u nas taka skomplikowana logika i obecność aż dwóch okien modalnych, z których jedno pozwala wpisać instancję, a drugie - poinformować o błędzie na etapie połączenia (do czego przejdziemy w kolejnym kroku). Dodatkowo, jeśli nie jest to pierwsza styczność użytkownika z systemem komentarzy na naszym blogu, zostanie przywołana wartość wpisana wcześniej i zachowana w local storage, czyli lokalnym magazynie danych przeglądarki. W reakcji na przycisk sprawdzamy, czy taka instancja została poprzednio podana i jeśli tak, odtwarzamy ją. Następnie, po potwierdzeniu przez użytkownika podania domeny, sprawdzamy ewentualne błędy (głównie pusty ciąg znaków) i jeśli wszystko jest w porządku - zapisujemy zaktualizowaną wartość oraz przechodzimy już do integracji z Mastodonem.

Uzyskanie aplikacji

W tym miejscu musimy powiedzieć sobie o trzech kwestiach. Po pierwsze, od tej pory będziemy intensywnie korzystać z API Mastodona, które zostało opisane tutaj, a konkretnie połączenie się z kontem Fediverse - tutaj. Po drugie, wszystkie żądania są wywoływane z poziomu JavaScriptu, ale przechodzą przez PHP-owy kontroler MastodonController, który będzie towarzyszył nam aż do końca. Wynika to z faktu, że po drodze będziemy korzystać z paru sztuczek optymalizacyjnych, które wymagają skorzystania z naszej bazy danych, a z tą pozwala się komunikować Laravel. W teorii jednak nic nie stoi na przeszkodzie, aby te żądania wywoływać bezpośrednio z poziomu JS-a.

Po trzecie, aby korzystać z API Mastodona, musimy mieć tzw. aplikację. Pod tym pojęciem kryje się zbiór danych uwierzytelniających (tzw. sekretów), które służą nam do wywołania kolejnych żądań. W przypadku jednej instancji można te dane utworzyć również ręcznie, w panelu swojego konta w Mastodonie. Jednak w naszej sytuacji nie wiemy, z którą instancją przyjdzie nam się komunikować, a przede wszystkim nie w każdej będziemy posiadać konto. Na szczęście, istnieje możliwość utworzenia takiej aplikacji "zdalnie", właśnie przez API.

// fragment pliku post.js

$('#mastodon-reply-modal-confirm').on('click', (event) => {
	$('#mastodon-domain-reply').removeClass('error');
	
	const domain = $('#mastodon-domain-reply').val();
	const src = $('#mastodon-src-reply').val();
	if(domain && src) {
		$('#mastodon-reply-modal').modal('hide');
		localStorage.setItem('lastMastodonDomain', domain);
		localStorage.setItem('lastMastodonSrc', src);

		$.ajax({
			type: 'GET',
			url: this.baseUrl + 'mastodon/app/' + domain,
			data: { redirectUri: window.location.href },
			cache: true,
			success: (res) => {
				const json = JSON.parse(res);

				const authorizeUrl = 'https://' + domain + '/oauth/authorize?' 
					+ 'client_id=' + json.clientId 
					+ '&scope=' + json.scope
					+ '&redirect_uri=' + json.redirectUri
					+ '&response_type=code';
				window.location.href = authorizeUrl;
			},
			error: (err) => {
				$('#mastodon-reply-modal-failure').modal('show');
			}
		});
	}
	else {
		$('#mastodon-domain-reply').addClass('error');
	}
});

// fragment pliku web.php z routingiem

Route::get('/mastodon/app/{instance}', [MastodonController::class, 'getAppCredentials']);
Route::get('/mastodon/token/{instance}/{code}', [MastodonController::class, 'getToken']);
Route::get('/mastodon/post/{instance}', [MastodonController::class, 'getPostInfoOnInstance']);
Route::post('/mastodon/toot/{instance}', [MastodonController::class, 'sendToot']);

// fragment pliku MastodonController.php

class MastodonController extends Controller {

    private $presenter;
    private $mastodonInstanceCredentialsRepository;
    private $postCommentSourceRepository;

    public function __construct() {
        parent::__construct();

        $this->presenter = new MastodonPresenter();
        $this->mastodonInstanceCredentialsRepository = new MastodonInstanceCredentialsRepository();
        $this->postCommentSourceRepository = new PostCommentSourceRepository();
    }

    public function getAppCredentials(Request $request, $instance) {
        $redirectUri = $request->get('redirectUri', env('MASTODON_APP_REDIRECT_URI'));
        if(App::environment('local')) {
            $redirectUri = env('MASTODON_APP_REDIRECT_URI');
        }

        $credentials = $this->mastodonInstanceCredentialsRepository->getByInstance($instance);
        if(empty($credentials)) {
            $url = 'https://'.$instance.'/api/v1/apps';
            $body = [
                'client_name' => env('MASTODON_APP_NAME'),
                'redirect_uris' => $redirectUri,
                'scopes' => str_replace('+', ' ', env('MASTODON_APP_SCOPES')),
                'website' => URL::to('/'),
            ];
            $response = Http::asForm()->post($url, $body);

            if($response->successful()) {
                $rawOutput = $response->body();
                $output = json_decode($response->body(), true);
                $now = (new DateTime())->format('Y-m-d H:i:s');

                $credentials = new MastodonInstanceCredentials();
                $credentials->instance = $instance;
                $credentials->client_id = $output['client_id'];
                $credentials->client_secret = $output['client_secret'];
                $credentials->vapid_key = $output['vapid_key'];
                $credentials->response = $rawOutput;
                $credentials->save();
            }
            else {
                $message = 'Error during calling '.$url.' with body '.print_r($body, true);
                Log::error($message);
                Log::error($response->status().': '.print_r($response->body(), true));
                return response(json_encode(['message' => $message], 500));
            }
        }

        $response = [
            'clientId' => $credentials->client_id,
            'redirectUri' => $redirectUri,
            'scope' => env('MASTODON_APP_SCOPES'),
        ];

        return response(json_encode($response), 200);
    }
	
	...
}

// fragment pliku .env

MASTODON_APP_NAME=MastodonCommentSystem_test
MASTODON_APP_REDIRECT_URI=urn:ietf:wg:oauth:2.0:oob
MASTODON_APP_SCOPES="write:statuses+read:search+read:accounts"

Dosyć dużo tego kodu i na początku warto wyjaśnić, jak to przebiega z "lotu ptaka". Pierwsza rzecz to kontynuacja procesu podawania instancji, co powoduje wywołanie wewnętrznej usługi GET /mastodon/app/{instance}. Skąd wiemy, gdzie jest ona w kodzie? Za to odpowiada już plik web.php, który jest laravelowym punktem "tłumaczącym" adresy URL na konkretne kontrolery i ich metody. W tym przypadku widzimy, że wywołanie np. GET /mastodon/app/mastodon.social spowoduje dopasowanie adresu do pierwszego wpisu i przekierowanie do MastodonController->getAppCredentials('mastodon.social').

W samej metodzie w kontrolerze najpierw musimy pobrać przesłany również w tle adres przekierowania (redirectUri). Jest to nic innego, jak adres naszego artykułu, do którego Mastodon będzie musiał zawrócić użytkownika, gdy zostanie udzielona autoryzacja. I tutaj uwaga - w przypadku testów na lokalnej maszynie (localhost) nie możemy podać adresu powrotu wprost. Dlatego w tym przypadku korzystamy ze specjalnego ciągu znaków ukrytego pod zmienną środowiskową MASTODON_APP_REDIRECT_URI, a więc urn:ietf:wg:oauth:2.0:oob.

Moglibyśmy w tym miejscu już wprost wywołać odpowiednie żądanie do wybranej przez użytkownika instancji Mastodona, ale zatrzymajmy się na chwilę. Tworzenie aplikacji na danym serwerze może nastąpić raz na jakiś czas i uzyskane w ten sposób sekrety możemy zapisać. Oczywiście, moglibyśmy je generować za każdym razem, jednak podobnie, jak miało to miejsce w poprzednim odcinku, powinniśmy czuć pewną solidarność z administratorami instancji, którzy utrzymują je za własne pieniądze lub z datków swoich użytkowników. Pamiętajmy, że każdorazowe żądanie utworzenia aplikacji to nie tylko samo żądanie HTTP, ale też nowy wpis w bazie danych instancji. Jeśli wielu użytkowników z danego serwera rozpoczyna pisanie u nas komentarzy, na dłuższą metę może to oznaczać niepotrzebne obciążanie konkretnego serwera. Dlatego tak samo, jak to miało miejsce w przypadku odczytanych komentarzy, także tutaj będziemy te dane cache'ować.

Skorzystamy z tabeli mastodon_instance_credentials:

CREATE TABLE mastodon_instance_credentials (
	id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
	instance VARCHAR(512) NOT NULL,
	client_id VARCHAR(1024) NULL DEFAULT NULL,
	client_secret VARCHAR(1024) NULL DEFAULT NULL,
	vapid_key VARCHAR(1024) NULL DEFAULT NULL,
	response TEXT NULL,
	created_at TIMESTAMP NULL DEFAULT NULL,
	updated_at TIMESTAMP NULL DEFAULT NULL,
	PRIMARY KEY (id),
	UNIQUE INDEX mastodon_instance_credentials_instance_unique (instance)
);

Jak widać z kodu, odpytujemy ją (za pomocą klasy repozytorium, czyli MastodonInstanceCredentialsRepository.php), czy dla danej instancji posiadamy odpowiednie dane. Chodzi tutaj o CLIENT_ID, CLIENT_SECRET oraz VAPID_KEY. Jeśli tak, to z nich korzystamy i po prostu zwracamy do dalszej obróbki. Jeśli nie - faktycznie wywołujemy żądanie do konkretnej instancji, wykorzystując nasze dane (w tym scope, o czym za chwilę) i zapisujemy je do bazy danych na późniejszy użytek. W ten sposób aplikację w danej instancji utworzymy tylko i wyłącznie raz na jakiś czas, co oszczędzi nie tylko transferu pomiędzy serwerami, ale też czas.

Uwaga: danych nie możemy przechowywać bezterminowo - istnieje pewien okres, przez które są one ważne. Dlatego zaleca się co jakiś czas czyszczenie zawartości tabelki mastodon_instance_credentials, np. poprzez zadania crona. Zostawiamy to jako ćwiczenie dla czytelników.

W kodzie widać kilkukrotne użycie zmiennej środowiskowej MASTODON_APP_SCOPES. Są to uprawnienia, których wymagany przy okazji komunikacji za pomocą protokołu OAuth 2.0. Z pewnością kojarzymy sytuację, kiedy przy instalacji danej aplikacji na urządzeniu mobilnym jesteśmy pytani przez sklep o to, czy zezwalamy na dostęp np. do swojej listy kontaktów. Tutaj mamy do czynienia z czymś podobnym - każda aplikacja i późniejsze żądanie autoryzacyjne do API Mastodona przebiega dla konkretnego zakresu operacji. W tym przypadku są to:

  • write:statuses - będziemy chcieli dodawać tooty za pośrednictwem konta użytkownika.
  • read:search - będziemy chcieli wyszukiwać tooty lub konta na danej instancji.
  • read:accounts - będziemy chcieli odczytać dane profilu przechowywane na danej instancji.

Trzy ważne uwagi, zanim przejdziemy dalej. Po pierwsze, słusznie możecie zauważyć, że w poprzednim odcinku odczyt treści tootów oraz komentarzy był publiczny i to prawda - nie wszystkie usługi w API są dostępne dopiero po autoryzacji dokonanej przez użytkownika. Tutaj jednak mamy do czynienia właśnie z potrzebą takiego potwierdzenia, stąd te wszystkie działania związane z OAuth 2.0 i wprowadzenie tego tematu. Po drugie, jeśli chcemy się dowiedzieć, jakie uprawnienia (składające się na scope) będą nam potrzebne, to należy spojrzeć do dokumentacji konkretnych usług API Mastodona - tam znajdują się odpowiednie informacje. Po trzecie, jeśli w trakcie rozwoju aplikacji okaże się, że musimy poszerzyć zakres przyznanych uprawnień, to niestety, ale wszystkie utworzone do tej pory aplikacje musimy utworzyć na nowo, a wygenerowane tokeny - zresetować.

Na końcu widzimy, że zwrotnie dostajemy wymagany CLIENT_ID, a także scope oraz adres do przekierowania. Te dane będą nam potrzebne już w samym Mastodonie.

Przekierowanie do Mastodona i uzyskanie kodu

Na pewno zauważyliście ten fragment w post.js:

$.ajax({
	type: 'GET',
	url: this.baseUrl + 'mastodon/app/' + domain,
	data: { redirectUri: window.location.href },
	cache: true,
	success: (res) => {
		const json = JSON.parse(res);

		const authorizeUrl = 'https://' + domain + '/oauth/authorize?' 
			+ 'client_id=' + json.clientId 
			+ '&scope=' + json.scope
			+ '&redirect_uri=' + json.redirectUri
			+ '&response_type=code';
		window.location.href = authorizeUrl;
	},
	error: (err) => {
		$('#mastodon-reply-modal-failure').modal('show');
	}
});

Mając odpowiednie dane, musimy przekierować użytkownika do zewnętrznego dostawcy (w tym przypadku - Mastodona, a raczej konkretnej instancji Mastodona) i poprosić użytkownika o udzielenie autoryzacji, a w konsekwencji - o kod (stąd parametr response_type=code). To ten moment, w którym komentujący opuści na chwilę nasz blog i ukaże mu się widok podobny do tego:

Fragment okna z Mastodona o treści:

Na tym ekranie użytkownik jest pytany o potwierdzenie operacji. Gdy się zgodzi, Mastodon automatycznie przekieruje użytkownika z powrotem do naszego blogu, jednak tym razem doda kod do parametrów GET-owych adresu URL.

Wykorzystanie kodu do dalszych operacji

Teraz będzie się działo dużo rzeczy. Po pierwsze, widzimy, że jeśli podaliśmy adres https://naszblog/artykul, to nowy URL będzie wyglądał mniej więcej tak:

https://naszblog.pl/artykul?code=dlugiKod

Wykorzystamy to do tego, aby:

  1. od razu wczytać komentarze (nie czekając na ruch użytkownika),
  2. pobrać dodatkowy token uwierzytelniający (potwierdzający, że identyfikujemy się jako dany użytkownik),
  3. pobrać dane toota i naszego konta,
  4. przygotować formularz do wpisania komentarza,
  5. wyświetlić formularz i skierować do niego użytkownika.

Jak widać, jest to dość sporo pracy. Na początku zobaczmy, że w BlogController i w widoku dokonaliśmy małej zmiany, która dodatkowo odczyta nasz kod, jeśli ten został przekazany.

// fragment BlogController.php

public function post(Request $request, $id, $lang = null) {
	$post = $this->postRepository->getById($id, App::currentLocale());

	if(!empty($post)) {			
		$commentSourceIds = $this->postCommentSourceRepository->getIdsByPostId($post->id, App::currentLocale());

		// code after Mastodon authorization
		$mastodonCode = $request->input('code');

		return view('blog-post', [
			'post' => $post, 'commentSourceIds' => $commentSourceIds, 'mastodonCode' => $mastodonCode,
		]);
	}
	else {
		abort(404);
	}
}

...

// fragment blog-post.blade.php

<script>
	(new PostScript(
		"<?= URL::to('/') ?>",
		<?= $post->id ?>,
		<?= json_encode($commentSourceIds) ?>,
		<?= json_encode([
			'enterMastodonDomain' => __('enterMastodonDomain'),
		]) ?>,
		<?= !empty($mastodonCode) ? '"'.$mastodonCode.'"' : '' ?>
	)).init();
</script>

Jednak dużo więcej dzieje się w pliku post.js. Teraz będziemy mogli zobaczyć, do czego potrzebna jest zmienna withFormInitiating, którą ostatnio pominęliśmy przy omawianiu odczytu komentarzy.

init() {
	...

	if(this.mastodonCode) {
		if(this.commentSourceIds.length > 0) {
			this.loadComments(true);
		}
	}
	
	...
}

...

loadComments(withFormInitiating = false) {
	if(this.isCommentContainerLoaded) {
		return;
	}

	$.get(this.baseUrl + 'post/comment', { sourceIds: this.commentSourceIds }, (page) => {
		$('#comment-container-internal').html(page);
		this.isCommentContainerLoaded = true;

		if(withFormInitiating) {
			const instance = localStorage.getItem('lastMastodonDomain');
			const postSrc = localStorage.getItem('lastMastodonSrc');

			let accessToken = localStorage.getItem('mastodonAccessToken');
			let accessTokenCreatedBy = localStorage.getItem('mastodonCreatedBy');
			const accessTokenLifespan = 4 * 3600 * 1000;

			if(!accessToken || !accessTokenCreatedBy || instance != localStorage.getItem('mastodonAccessTokenInstance')
				|| (Date.now - accessTokenLifespan > accessTokenCreatedBy)        
			) {
				$.ajax({
					type: 'GET',
					url: this.baseUrl + 'mastodon/token/' + instance + '/' + this.mastodonCode,
					data: { redirectUri: window.location.href },
					cache: false,
					success: (res) => {
						const json = JSON.parse(res);
						accessToken = json.accessToken;
						accessTokenCreatedBy = json.createdBy;
						localStorage.setItem('mastodonAccessToken', accessToken);
						localStorage.setItem('mastodonCreatedBy', Date.now());
						localStorage.setItem('mastodonAccessTokenInstance', instance);

						this.prepareCommentBox(instance, postSrc, accessToken);
					},
					error: (err) => {
						$('#mastodon-reply-modal-failure').modal('show');
					}
				});                
			}
			else {
				this.prepareCommentBox(instance, postSrc, accessToken);
			}
		}
	});
}

Jeśli kod jest dostępny, to nie czekamy i od razu ładujemy komentarze w taki sam sposób, jak do tej pory, ale poprzez flagę withFormInitiating informujemy, że po wczytaniu wpisów musimy pobrać dane instancji (zapisanej w local storage) i wykorzystać je do pobrania tokenu uwierzytelniającego. Jednak zanim to się stanie, sprawdzamy, czy czasem już takiego tokenu nie posiadamy - może bowiem się zdarzyć, że dany użytkownik odpowiada na wiele komentarzy na naszym blogu w krótkim czasie. Nie trzeba zatem co chwilę go uwierzytelniać - można zrobić to np. raz na 4 godziny (co zostało zapisane w stałej accessTokenLifespan). Jeśli tokenu nie mamy lub jest on starszy niż wyznaczony czas, to musimy ponownie udać się do API Mastodona po wymaganą informację. Wróćmy do MastodonControllera:

public function getToken(Request $request, $instance, $code) {
	$redirectUri = $request->get('redirectUri', env('MASTODON_APP_REDIRECT_URI'));
	if(App::environment('local')) {
		$redirectUri = env('MASTODON_APP_REDIRECT_URI');
	}

	$credentials = $this->mastodonInstanceCredentialsRepository->getByInstance($instance);
	$accessToken = null;
	$createdAt = null;
	if(!empty($credentials)) {
		$url = 'https://'.$instance.'/oauth/token';
		$body = [
			'client_id' => $credentials->client_id,
			'client_secret' => $credentials->client_secret,
			'redirect_uri' => $redirectUri,
			'grant_type' => 'authorization_code',
			'code' => $code,
			'scope' => str_replace('+', ' ', env('MASTODON_APP_SCOPES')),
		];
		$response = Http::asForm()->post($url, $body);

		if($response->successful()) {
			$body = json_decode($response->body(), true);
			$accessToken = $body['access_token'];
			$createdAt = $body['created_at'];
		}
		else {
			$message = 'Error during calling '.$url.' with body '.print_r($body, true);
			Log::error($message);
			Log::error($response->status().': '.print_r($response->body(), true));
			return response(json_encode(['message' => $message], 500));
		}
	}
	else {
		$message = 'mastodon/getToken fail - there is no instance like '.$instance;
		Log::error($message);
		return response(json_encode(['message' => $message], 500));
	}

	$response = [
		'accessToken' => $accessToken,
		'createdAt' => $createdAt,
	];

	return response(json_encode($response), 200);
}

Kod wydaje się bardzo "długi" z tego powodu, iż na tym etapie musimy sprawdzić każdą ewentualność nieuprawnionego wywołania żądania takiego tokenu i zapobiec wystąpieniu dalszych błędów. To, co nas najbardziej interesuje, to ponowne uzyskanie danych aplikacji związanych z odpowiednią instancją oraz wywołanie POST /oauth/token. Co ciekawe, widać tutaj niekonsekwencję twórców Mastodona, gdyż tym razem scope podawany jest w innej formie - nie z użyciem plusów, ale spacji.

Wynikiem tego żądania będzie kilka informacji, z których najważniejszy dla nas jest token dostępowy (accessToken) oraz data utworzenia (createdAt). Te dane zwracamy i zapisujemy w JS-ie w local storage. Następnie wykorzystujemy je do rozpoczęcia generacji pola, w którym użytkownik będzie mógł wpisać komentarz.

prepareCommentBox(instance, postSrc, accessToken) {
	$.ajax({
		type: 'GET',
		url: this.baseUrl + 'mastodon/post/' + instance,
		data: { postUrl: postSrc, accessToken},
		cache: false,
		success: (resPost) => {
			const postJson = JSON.parse(resPost);
			
			$.get(this.baseUrl + 'post/comment/form', postJson, (form) => {
				$('#comment-form').remove();
				const commentBox = $('.comment-reply-link[data-src="' + postSrc + '"]').closest('.comment');
				commentBox.after(form);

				document.querySelector('#comment-form').scrollIntoView({
					behavior: 'smooth'
				});
			});
		},
		error: (errPost) => {
			$('#mastodon-reply-modal-failure').modal('show');
		}
	});
}

Także tutaj czeka nas wywołanie nawet nie jednego, ale dwóch żądań do Mastodona. Dobrze byłoby użytkownikowi wyświetlić jego avatar i nick, aby poczuł, że korzysta ze swojego konta. Jednak ważniejsze jest pobranie identyfikatora toota, na który następuje odpowiedź. Jednak "zaraz" - powiedzą uważni czytelnicy i wskażą na zmienną postSrc, która ten identyfikator powinna przechowywać już od momentu, kiedy użytkownik kliknął na ikonkę odpowiedzi przy danym toocie. Czy to nie jest właśnie ta wartość? Otóż - nie i w tym momencie potrzebna jest wcześniej dostarczona wiedza dotycząca federacji.

Zmienna postSrc to rzeczywiście numer wpisu, ale w obrębie oryginalnej instancji danego toota. Łatwo to zrozumieć na przykładzie - wyobraźmy sobie, że naszym tootem źródłowym jest wpis z instancji social.wildasoftware.pl, natomiast jeden użytkownik odpowiedział na niego z instancji wspanialy.eu. Na naszym blogu wyświetlamy oba wpisy, jednak identyfikator pierwszego wpisu jest z bazy social.wildasoftware.pl, a drugiego ze wspanialy.eu. Natomiast nasz nowy użytkownik chce odpowiedzieć na ten wpis z instancji pol.social i już autoryzował dostęp ze swojego konta. Aby móc potem dodać wysłać toot będący odpowiedzią na wybranego toota, musimy znać identyfikator tego toota na instancji nowego użytkownika, a więc pol.social. Czyli w szczególnym przypadku - wpis, na który odpowiada ta osoba, musi zostać skopiowany na instancję pol.social.

A zatem za pomocą mastodon/post i tym samym kontrolera MastodonController pobierzemy zarówno informacje o profilu, jak i wyszukamy toot w celu pobrania z niego właściwego identyfikatora. Wyszukanie wpisu automatycznie pobierze jego kopię na instancję pol.social, jeśli jeszcze jej tam nie było.

public function getPostInfoOnInstance(Request $request, $instance) {
	$postUrl = $request->input('postUrl');
	$accessToken = $request->input('accessToken');

	$output = [
		'postId' => null,
		'instance' => $instance,
		'acct' => null,
		'avatarStatic' => null,
		'displayName' => null,
		'emojis' => null,
		'accountUrl' => null,
	];

	$url = 'https://'.$instance.'/api/v2/search';
	$params = [
		'q' => $postUrl,
		'type' => 'statuses',
		'resolve' => true,
		'limit' => 1,
	];

	$response = Http::withHeaders([
		'Authorization' => 'Bearer '.$accessToken,
	])->get($url, $params);

	if($response->successful()) {
		$body = json_decode($response->body(), true);
		if(!empty($body['statuses'])) {
			$output['postId'] = $body['statuses'][0]['id'];
		}
		else {
			$message = 'mastodon/getPostIdOnInstance fail - there is no post '.$url.' on instance '.$instance;
			Log::error($message);
			return response(json_encode(['message' => $message], 500));
		}
	}
	else {
		$message = 'Error during calling '.$url.' with body '.print_r($params, true);
		Log::error($message);
		Log::error($response->status().': '.print_r($response->body(), true));
		return response(json_encode(['message' => $message], 500));
	}

	//--

	$urlAccount = 'https://'.$instance.'/api/v1/accounts/verify_credentials';

	$responseAccount = Http::withHeaders([
		'Authorization' => 'Bearer '.$accessToken,
	])->get($urlAccount);

	if($responseAccount->successful()) {
		$body = json_decode($responseAccount->body(), true);

		$output['acct'] = $body['acct'];
		$output['displayName'] = $body['display_name'];
		$output['avatarStatic'] = $body['avatar_static'];
		$output['emojis'] = $body['emojis'];
		$output['accountUrl'] = $body['url'];
	}
	else {
		$message = 'Error during calling '.$url;
		Log::error($message);
		Log::error($responseAccount->status().': '.print_r($responseAccount->body(), true));
		return response(json_encode(['message' => $message], 500));
	}

	return response(json_encode($this->presenter->formatGetPostInfoOnInstance($output)), 200);
}

Warto zwrócić uwagę na dwie rzeczy. Po pierwsze, ponieważ te dwa żądania są niezależne od siebie, można je wywołać równolegle, a nie tak, jak to zrobiliśmy, synchronicznie. Biblioteka Guzzle umożliwia taką operację, jednak zostawiamy to jako zadanie dla chętnych czytelników. Po drugie, po pobraniu danych użytkownika musimy je przeformatować podobnie, jak to robiliśmy przy okazji wyświetlania samych komentarzy. Także tutaj robimy to za pomocą klasy prezentera, jednak pozwolicie, że pominę tutaj dokładny opis - po szczegóły zapraszam do kodu i poprzedniej części naszego artykułu.

Na końcu musimy wyświetlić samo miejsce na wpisanie treści komentarza. Uzbrojeni w informację o profilu użytkownika, udajemy się do kontrolera BlogController i odpowiedniego widoku, aby AJAXowo pobrać odpowiedni bloczek, wyświetlić go i przewinąć użytkownikowi stronę do tego miejsca. W tym momencie pominę opis, gdyż sam formularz korzysta z kodu omawianego w poprzednim artykule i jest po prostu nudny - warto przejrzeć źródła na GitHubie. Efektem końcowym jest taki widok:

Fragment okna na naszym blogu, gdzie użytkownik widzi swój avatar i nick z Mastodona oraz miejsce do wpisania komentarza

Wysłanie toota, odświeżenie komentarzy

W momencie, kiedy użytkownik wpisze treść swojego komentarza i naciśnie "Wyślij", oczywiście, musimy wysłać tę treść do wybranej wcześniej instancji Mastodona za pomocą API. Zaczynamy od post.js:

$('#comment-container').on('click', '.comment-form-send-button', (event) => {
	const answer = $(event.target).closest('.answer');
	const answeredTootId = answer.find('.comment-form-postid').val();
	const message = answer.find('.comment-form-message').val();

	const instance = localStorage.getItem('lastMastodonDomain');
	const accessToken = localStorage.getItem('mastodonAccessToken');

	if(message) {
		$.ajax({
			type: 'POST',
			url: this.baseUrl + 'mastodon/toot/' + instance,
			data: { message: message, answeredTootId: answeredTootId, accessToken: accessToken, postId: this.postId },
			cache: false,
			success: (res) => {
				answer.remove();
				this.isCommentContainerLoaded = false;
				this.loadComments();
			},
			error: (err) => {
				$('#mastodon-reply-modal-failure').modal('show');
			}
		});
	}
});

Jak widać, w samym formularzu zachowana była też informacja o identyfikatorze toota, na który odpowiadamy (na wybranej przez siebie instancji), a dane samego serwera i token pobieramy z local storage. Wówczas wywołujemy naszą metodę w MastodonController:

// fragment MastodonController.php

public function sendToot(Request $request, $instance) {
	$message = $request->post('message');
	$answeredTootId = $request->post('answeredTootId');
	$accessToken = $request->post('accessToken');
	$postId = $request->post('postId');

	$url = 'https://'.$instance.'/api/v1/statuses';
	$params = [
		'status' => $message,
		'in_reply_to_id' => $answeredTootId,
	];

	$response = Http::withHeaders([
		'Authorization' => 'Bearer '.$accessToken,
	])->post($url, $params);

	if($response->successful()) {
		$this->postCommentSourceRepository->resetCache($postId);
	}
	else {
		$message = 'Error during calling '.$url.' with body '.print_r($params, true);
		Log::error($message);
		Log::error($response->status().': '.print_r($response->body(), true));
		return response(json_encode(['message' => $message], 500));
	}

	return response('', 200);
}

// fragment PostCommentSourceRepository.php

public function resetCache(int $postId) {
	PostCommentSource::where('post_id', $postId)->update(['data_received' => null]);
}

To, co jest tutaj ciekawe, to zresetowanie cache'a w post_comment_source. Po co to robimy? Pamiętamy z poprzedniego odcinka, że pobrane raz komentarze, w celu optymalizacji transferu i czasu, zachowujemy u siebie na pewien czas. Jednak po dodaniu komentarza chcemy je wyświetlić zaktualizowane i nie korzystać z pamięci podręcznej. Najprościej jest to zrobić właśnie poprzez usunięcie zachowanych wcześniej danych dla danego posta. W tym miejscu wkradła się mała nieoptymalność - nie wiemy, w ramach jakiego toota źródłowego odpowiadaliśmy na komentarz (w końcu mogliśmy komentować wpis innego użytkownika, a nie oryginalny), więc musimy usunąć cache dla wszystkich tootów źródłowych danego artykułu. Nie jest to jednak duży problem.

Po pomyślnym dodaniu komentarza na Mastodonie, możemy zaktualizować nasz wątek wpisów. Stąd we wcześniej podanym kodzie JS ponowne wywołanie loadComments().

Miejsce na rozwój

Podczas lektury na pewno zauważyliście miejsca, w których można usprawnić cały mechanizm. O niektórych z nich wspomniałem przy poszczególnych punktach, w innych każdemu przyszły do głowy jego własne pomysły. Na pewno w oczy rzuca się potrzeba jeszcze lepszej obsługi błędów, np. w sytuacji, kiedy użytkownik po uwierzytelnieniu zbyt długo zwleka z napisaniem i wysłaniem komentarza. Jednak można też pomyśleć o rozwoju samego systemu, np. poprzez wyświetlanie boostów czy polubień, a także możliwość zaobserwowania innego użytkownika z poziomu samego blogu. Jak najbardziej jest to możliwe z poziomu API i byłoby jeszcze mocniejszym związaniem blogu ze światem serwisów społecznościowych. Pytanie, czy jest to dobre, zostawiamy do Waszej oceny.

Podsumowanie

Uff, dotarliśmy do końca. Dziękuję wszystkim, którzy przeczytali obie części artykułu i mam nadzieję, że nie była to podróż nudna. Zdaję sobie sprawę, że nie każdego może interesować sam kod. Dlatego liczę na to, że wielu osobom podobał się wstęp i wytłumaczenie mechaniki działania poszczególnych protokołów czy sposób obsługi konkretnych operacji. A przede wszystkim będę usatysfakcjonowany jeśli okaże się, że za sprawą tego dwuczęściowego tekst ktoś rzeczywiście zaimplementuje system komentarzy oparty o Mastodona na swoim blogu czy stronie. Będzie to dla mnie oznaka, że trud się opłacił.

Pozdrawiam i dziękuję - Jakub Rojek.

Lubimy pisać, nawet bardzo, ale na co dzień tworzymy aplikacje webowe i mobilne. Sprawdź niektóre z wykonanych przez programów.

Komentarze

Wczytywanie komentarzy...

O autorze

Jakub Rojek

Główny programista i współwłaściciel Wilda Software, z wieloletnim doświadczeniem w tworzeniu i rozwoju oprogramowania, ale także w pisaniu tekstów na różnorakich blogach. Zaprawiony w boju analityk i architekt systemów IT. Jednocześnie absolwent Politechniki Poznańskiej i okazjonalny prowadzący zajęcia na tej uczelni. W wolnych chwilach oddaje się graniu w gry wideo (głównie w karcianki), czytaniu książek, oglądaniu futbolu amerykańskiego i e-sportu, odkrywaniu cięższej muzyki oraz wytykaniu innym błędów językowych.

Jakub Rojek