Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
# Auto detect text files and perform LF normalization
* text=auto

# The release zip is the GitHub archive of the tag, so keep the tests out of it:
# they are for developing the module, not for running it.
/tests export-ignore
33 changes: 33 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Tests

on:
push:
branches-ignore:
- 'gh-pages'
pull_request:

permissions:
contents: read

jobs:
tests:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# The lowest version Joomla 4 supports, and a current one.
php: ['7.4', '8.3']
name: PHP ${{ matrix.php }}
steps:
- uses: actions/checkout@v4

- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
coverage: none

- name: Lint
run: find . -name '*.php' -not -path './.git/*' -print0 | xargs -0 -n1 -P4 php -l

- name: Run tests
run: php tests/run.php
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
# User-specific stuff
.idea
.DS_Store
.claude
.claude

# Runtime cache, written when this repo is symlinked into a Joomla installation
media/data-*.json
media/images/*/
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Pretty Reviews is a Joomla site module that displays Google business reviews in

- Display Google rating, review count, review text, author names, profile photos, star ratings, and a "View all reviews" link.
- Optional "Leave a review" button that opens Google's review form for the configured Place ID.
- Reviewer profile photos are stored on your own site and served from your own domain, so displaying the reviews sends no visitor data to Google. Reviewers whose photo could not be downloaded get a locally generated avatar with their initials.
- Choose from Bootstrap layouts (Default, Card Carousel, Compact), UIkit/YOOtheme layouts (UIkit Default, UIkit Compact, UIkit List), plus a simple List and a classic Legacy layout.
- Set responsive column counts per breakpoint — 1 to 6 reviews side by side for mobile, tablet, desktop, and wide screens — in the carousel layouts.
- Bootstrap layouts use a native CSS scroll-snap carousel: native touch swipe, one-column-at-a-time navigation, a usable scrollable fallback without JavaScript, and no inline styles (CSP-friendly). UIkit layouts use UIkit's native slider.
Expand Down Expand Up @@ -118,6 +119,10 @@ The refresh action:
- requires edit permission for the module;
- reads the Google credentials from the saved module parameters;
- writes the raw review cache to `media/mod_prettyreviews/data-{module_id}.json`;
- downloads any reviewer photos it does not have yet to `media/mod_prettyreviews/images/{module_id}/`, and
removes the ones no cached review uses any more;
- spends at most about fifteen seconds downloading photos per run to keep the request quick, and reports in
the success message how many are still waiting, so you know to press the button again on a slow connection;
- returns clear feedback when Google rejects the API request.

Save the module before the first refresh so Joomla has a module ID and stored credentials to use.
Expand All @@ -130,6 +135,22 @@ https://github.com/TLWebdesignNL/Pretty-Reviews-Task-Scheduler-Plugin

Use version 1.1.0 or newer of the task plugin with Pretty Reviews module 1.2.0 or newer. The task plugin calls the module helper directly and no longer sends credentials through an HTTP request.

## Profile Photos

Reviewer photos are never loaded from Google by your visitors' browsers. Each photo is downloaded once and
stored under `media/mod_prettyreviews/images/{module_id}/`, and the layouts point at that copy.

Photos are collected when the reviews are refreshed — the **Update Reviews** button, or the task plugin. A
reviewer whose photo could not be downloaded gets an avatar generated from their initials instead, so no
request ever leaves for Google. A cache created before this feature existed has no stored photo to point at
yet, so its reviewers are shown the same initials avatar until the next refresh replaces them with the real
ones; nothing is ever downloaded while a page is being viewed.

Only images from Google's own photo hosts are accepted, over HTTPS, up to 2 MB, and only after the downloaded
bytes are confirmed to be a JPEG, PNG, GIF or WebP. Anything else is refused and the initials avatar is kept.

**Purge Reviews** removes the stored photos along with the cached reviews.

## Troubleshooting

### The JSON cache file is empty
Expand Down Expand Up @@ -170,6 +191,22 @@ The backend refresh now uses:
- Joomla's HTTP client for Google API requests;
- escaped review output in the frontend layout.

Downloaded profile photos are restricted to Google's photo hosts over HTTPS, capped in size, and stored only
when the downloaded bytes decode as a known image format — the file extension comes from that check rather
than from the URL, and a remote SVG is always refused. Stored filenames are validated again before they are
put into a page.

## Development

The module has no build step and no Composer dependencies. To run the tests:

```bash
php tests/run.php
```

Each test file runs in its own process against a temporary site root, standing up just enough of Joomla's
filesystem, HTTP and URI classes to exercise the helpers. The tests are excluded from the release archive.

## Releases and Updates

The module includes a Joomla update server:
Expand Down
17 changes: 17 additions & 0 deletions changelog.xml
Original file line number Diff line number Diff line change
@@ -1,4 +1,21 @@
<changelogs>
<changelog>
<element>mod_prettyreviews</element>
<type>module</type>
<version>2.2.0</version>
<addition>
<item>Reviewer profile photos are now stored on your own website and served from your own domain, so no visitor data is sent to Google when the reviews are displayed. Photos are downloaded whenever the reviews are updated; nothing is ever downloaded while a page is being viewed.</item>
<item>Reviewers whose photo has not been stored yet, or could not be downloaded, are shown a locally generated avatar with their initials instead.</item>
</addition>
<change>
<item>Photos are stored per module under media/mod_prettyreviews/images/, and are removed together with the reviews when a module is purged. Photos belonging to reviews that are no longer cached are cleaned up automatically.</item>
<item>To keep the Update Reviews button responsive, an update spends at most about fifteen seconds downloading photos: a fast connection fills a large cache in one go, a slow one leaves the remainder for the next update. When photos are left waiting, the message after the update says how many, so you know to press the button again.</item>
<item>A photo that will not download is tried again on the next update; one that fails twice in a row is left alone for an hour, so an avatar that is gone for good stops using up the download time the others need.</item>
</change>
<note>
<item>Existing modules keep working unchanged. Until the reviews are next updated there is no stored photo to show, so reviewers appear with an initials avatar; press Fetch Google Reviews once, or wait for the Pretty Reviews task plugin's next run, to replace them with the real photos.</item>
</note>
</changelog>
<changelog>
<element>mod_prettyreviews</element>
<type>module</type>
Expand Down
1 change: 1 addition & 0 deletions language/de-DE/de-DE.mod_prettyreviews.ini
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ MOD_PRETTYREVIEWS_OVERALL_RATING_HEADING="Unsere Kunden bewerten uns durchschnit
MOD_PRETTYREVIEWS_UPDATE_REVIEWS="Bewertungen aktualisieren"
MOD_PRETTYREVIEWS_UPDATE_MISSING_MODULE_OR_TOKEN="Speichern Sie das Modul, bevor Sie Daten von Google abrufen."
MOD_PRETTYREVIEWS_UPDATE_SUCCESS="Bewertungen wurden aktualisiert."
MOD_PRETTYREVIEWS_UPDATE_SUCCESS_PENDING_PHOTOS="Bewertungen wurden aktualisiert. Noch herunterzuladende Profilbilder: %s. Klicken Sie erneut auf Bewertungen aktualisieren, um die nächsten abzurufen."
MOD_PRETTYREVIEWS_UPDATE_AJAX_ERROR="Beim Ajax-Aufruf ist etwas schiefgelaufen."
MOD_PRETTYREVIEWS_PURGE_REVIEWS="Bewertungen löschen"
MOD_PRETTYREVIEWS_PURGE_CONFIRM="Alle gespeicherten Bewertungen für dieses Modul entfernen? Dies kann nicht rückgängig gemacht werden."
Expand Down
1 change: 1 addition & 0 deletions language/en-GB/en-GB.mod_prettyreviews.ini
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ MOD_PRETTYREVIEWS_OVERALL_RATING_HEADING="Our customers rate us with an average
MOD_PRETTYREVIEWS_UPDATE_REVIEWS="Update Reviews"
MOD_PRETTYREVIEWS_UPDATE_MISSING_MODULE_OR_TOKEN="Save the module before retrieving data from Google."
MOD_PRETTYREVIEWS_UPDATE_SUCCESS="Reviews have been updated."
MOD_PRETTYREVIEWS_UPDATE_SUCCESS_PENDING_PHOTOS="Reviews have been updated. Profile photos still to download: %s. Press Update Reviews again to fetch the next batch."
MOD_PRETTYREVIEWS_UPDATE_AJAX_ERROR="Something went wrong with the Ajax request."
MOD_PRETTYREVIEWS_PURGE_REVIEWS="Purge Reviews"
MOD_PRETTYREVIEWS_PURGE_CONFIRM="Remove all stored reviews for this module? This cannot be undone."
Expand Down
1 change: 1 addition & 0 deletions language/fr-FR/fr-FR.mod_prettyreviews.ini
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ MOD_PRETTYREVIEWS_OVERALL_RATING_HEADING="Nos clients nous attribuent une note m
MOD_PRETTYREVIEWS_UPDATE_REVIEWS="Mettre à jour les avis"
MOD_PRETTYREVIEWS_UPDATE_MISSING_MODULE_OR_TOKEN="Enregistrez le module avant de récupérer les données depuis Google."
MOD_PRETTYREVIEWS_UPDATE_SUCCESS="Les avis ont été mis à jour."
MOD_PRETTYREVIEWS_UPDATE_SUCCESS_PENDING_PHOTOS="Les avis ont été mis à jour. Photos de profil restant à télécharger : %s. Cliquez à nouveau sur Mettre à jour les avis pour récupérer la série suivante."
MOD_PRETTYREVIEWS_UPDATE_AJAX_ERROR="Une erreur est survenue lors de la requête Ajax."
MOD_PRETTYREVIEWS_PURGE_REVIEWS="Purger les avis"
MOD_PRETTYREVIEWS_PURGE_CONFIRM="Supprimer tous les avis stockés pour ce module ? Cette action est irréversible."
Expand Down
1 change: 1 addition & 0 deletions language/it-IT/it-IT.mod_prettyreviews.ini
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ MOD_PRETTYREVIEWS_OVERALL_RATING_HEADING="I nostri clienti ci valutano con una m
MOD_PRETTYREVIEWS_UPDATE_REVIEWS="Aggiorna recensioni"
MOD_PRETTYREVIEWS_UPDATE_MISSING_MODULE_OR_TOKEN="Salva il modulo prima di recuperare i dati da Google."
MOD_PRETTYREVIEWS_UPDATE_SUCCESS="Le recensioni sono state aggiornate."
MOD_PRETTYREVIEWS_UPDATE_SUCCESS_PENDING_PHOTOS="Le recensioni sono state aggiornate. Foto profilo ancora da scaricare: %s. Fai di nuovo clic su Aggiorna recensioni per scaricare il gruppo successivo."
MOD_PRETTYREVIEWS_UPDATE_AJAX_ERROR="Si è verificato un errore durante la richiesta Ajax."
MOD_PRETTYREVIEWS_PURGE_REVIEWS="Elimina recensioni"
MOD_PRETTYREVIEWS_PURGE_CONFIRM="Eliminare tutte le recensioni memorizzate per questo modulo? L'operazione non può essere annullata."
Expand Down
1 change: 1 addition & 0 deletions language/nl-NL/nl-NL.mod_prettyreviews.ini
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ MOD_PRETTYREVIEWS_OVERALL_RATING_HEADING="Onze klanten beoordelen ons met een ge
MOD_PRETTYREVIEWS_UPDATE_REVIEWS="Reviews bijwerken"
MOD_PRETTYREVIEWS_UPDATE_MISSING_MODULE_OR_TOKEN="Sla de module op voordat je gegevens bij Google ophaalt."
MOD_PRETTYREVIEWS_UPDATE_SUCCESS="Reviews zijn bijgewerkt."
MOD_PRETTYREVIEWS_UPDATE_SUCCESS_PENDING_PHOTOS="Reviews zijn bijgewerkt. Nog te downloaden profielfoto's: %s. Klik nogmaals op Reviews bijwerken om de volgende reeks op te halen."
MOD_PRETTYREVIEWS_UPDATE_AJAX_ERROR="Er ging iets mis met het Ajax-verzoek."
MOD_PRETTYREVIEWS_PURGE_REVIEWS="Reviews wissen"
MOD_PRETTYREVIEWS_PURGE_CONFIRM="Alle opgeslagen reviews voor deze module verwijderen? Dit kan niet ongedaan worden gemaakt."
Expand Down
7 changes: 7 additions & 0 deletions media/images/.htaccess
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
## Reviewer profile photos are downloaded here from Google. Nothing in this folder
## is ever executed, so refuse to serve anything that a server might try to run.
<IfModule mod_authz_core.c>
<FilesMatch "\.(?i:php|phtml|phar|php[3-8]|pl|py|cgi|asp|sh)$">
Require all denied
</FilesMatch>
</IfModule>
Empty file added media/images/index.html
Empty file.
2 changes: 1 addition & 1 deletion media/joomla.asset.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://developer.joomla.org/schemas/json-schema/web_assets.json",
"name": "mod_prettyreviews",
"version": "2.0.0",
"version": "2.2.0",
"description": "Pretty Reviews module assets",
"license": "GPL-2.0-or-later",
"assets": [
Expand Down
16 changes: 14 additions & 2 deletions media/js/prettyreviews.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,20 @@ async function postAction(el, action, successKey, successFallback) {
const response = await fetch(prettyReviewsOptions.endpoint, {method: 'POST', body});
const resp = await response.json();

if (response.ok && resp.success === true && resp.data === true) {
renderMessage('success', [translate(successKey, successFallback)]);
// Purge answers with a plain true; update answers with {updated, pendingPhotos}.
const data = resp.data;
const done = data === true || (!!data && typeof data === 'object' && data.updated === true);
const pending = (!!data && typeof data === 'object') ? (Number(data.pendingPhotos) || 0) : 0;

if (response.ok && resp.success === true && done) {
if (pending > 0) {
renderMessage('warning', [translate(
'MOD_PRETTYREVIEWS_UPDATE_SUCCESS_PENDING_PHOTOS',
'Reviews have been updated. Profile photos still to download: %s. Press Update Reviews again to fetch the next batch.'
).replace('%s', pending)]);
} else {
renderMessage('success', [translate(successKey, successFallback)]);
}
} else {
console.error(resp);
renderMessage('error', [resp.message || translate(
Expand Down
5 changes: 3 additions & 2 deletions mod_prettyreviews.xml
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<extension type="module" client="site" method="upgrade">
<name>MOD_PRETTYREVIEWS</name>
<creationDate>2026-06-23</creationDate>
<creationDate>2026-08-23</creationDate>
<author>Tom van der Laan</author>
<authorEmail>info@tlwebdesign.nl</authorEmail>
<authorUrl>https://tlwebdesign.nl</authorUrl>
<copyright>TLWebdesign</copyright>
<license>GNU General Public License version 2 or later; see LICENSE.txt</license>
<version>2.1.0</version>
<version>2.2.0</version>
<description>MOD_PRETTYREVIEWS_XML_DESCRIPTION</description>
<scriptfile>script.php</scriptfile>
<namespace path="src">TLWeb\Module\Prettyreviews</namespace>
Expand All @@ -22,6 +22,7 @@
<filename>joomla.asset.json</filename>
<folder>js</folder>
<folder>css</folder>
<folder>images</folder>
</media>
<config>
<fields name="params">
Expand Down
6 changes: 6 additions & 0 deletions src/Dispatcher/Dispatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ protected function getLayoutData(): array

$timestamp = (int) ($review['time'] ?? 0);
$review['time_ago'] = $timestamp > 0 ? $helper->timeAgo($timestamp) : '';

// Point every layout at the copy stored on this site, so reviewer photos
// are always served from our own domain and never fetched from Google by
// the visitor's browser. Replacing the URL in place keeps all layouts —
// and any template override — working unchanged.
$review['profile_photo_url'] = $helper->localPhotoUrl($moduleId, $review);
}

unset($review);
Expand Down
1 change: 1 addition & 0 deletions src/Field/CustomPrettyField.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ protected function getLabel(): string
$wa = $app->getDocument()->getWebAssetManager();
Text::script('MOD_PRETTYREVIEWS_UPDATE_MISSING_MODULE_OR_TOKEN');
Text::script('MOD_PRETTYREVIEWS_UPDATE_SUCCESS');
Text::script('MOD_PRETTYREVIEWS_UPDATE_SUCCESS_PENDING_PHOTOS');
Text::script('MOD_PRETTYREVIEWS_UPDATE_AJAX_ERROR');
Text::script('MOD_PRETTYREVIEWS_PURGE_SUCCESS');
Text::script('MOD_PRETTYREVIEWS_PURGE_CONFIRM');
Expand Down
Loading
Loading