Compare commits

..

14 commits

Author SHA1 Message Date
c6e5002ebd
feat: Replace main instance URLs with instance-specific URLs in Email class 2025-03-11 10:44:17 +01:00
0a310e8a34
chore: Proxy Google Fonts request in MJML through googledonts.private.coffee 2025-03-11 10:05:30 +01:00
9e72bba7ca
feat: Make MJML API endpoint configurable
Replaces qferr/mjml-php with kumi/mjml-php fork.
Adds field to settings for specifying API URL.
Prevents sending API requests if neither credentials nor custom URL specified.
2025-03-10 17:41:04 +01:00
003abbbf98
chore: Add composer.phar to .gitignore 2025-03-10 16:07:23 +01:00
48b45ef2ff
Merge branch 'develop' into stable 2025-03-10 15:58:10 +01:00
85a336a265
fix: Missing underscore 2025-03-10 12:15:37 +01:00
f8fc96c1bb
fix?: Add sanitizer call for planet name in error output 2025-03-08 07:50:03 +01:00
408c9f9e19
chore: Fix substring handling in multibyte strtoupper 2025-03-07 21:46:31 +01:00
a87653d50d
fix: Use mb_ string operations in planet captcha
Updates register.php to use multibyte versions of strtolower/strtoupper for compatibility with non-Latin alphabets.
Simplifies the comparison by removing a Sanitise call.
2025-03-07 11:26:56 +01:00
17af718f84
feat: Prevent usage of database-test API after installation 2025-03-07 11:05:59 +01:00
3c36b7a66d
Revert "fix: database test input sanitization"
This reverts commit 5104f0205b.
2025-03-07 10:45:24 +01:00
9273c597ca
Merge remote-tracking branch 'upstream' into stable 2025-03-07 06:45:23 +01:00
db4b1193cf
fix: bypass affiliate logic
Removed affiliate link generation and verification logic from Wish class methods.
2024-11-04 09:54:31 +01:00
368c8767f0
feat(profile): disable ads toggle on certain domains
Introduced a condition to disable the advertisements toggle for specific domains, enhancing user experience by clearly distinguishing environments where ad configurations are not applicable.

By explicitly disabling the toggle on unsupported domains, it guides users towards intended interactions within the application, reducing potential support inquiries and user frustration related to advertisement settings in non-production environments.
2024-05-31 18:20:12 +02:00
43 changed files with 233 additions and 4090 deletions

2
.gitignore vendored
View file

@ -18,3 +18,5 @@
/.vscode
/src/config/config.php
/composer.phar

View file

@ -3,6 +3,7 @@
### Added
- Support for PHP 8.3
- Support for custom MJML API URLs (Private.coffee fork only)
### Fixed

View file

@ -1,12 +1,18 @@
{
"repositories": [
{
"type": "vcs",
"url": "https://git.private.coffee/kumi/mjml-php.git"
}
],
"require": {
"embed/embed": "^4.4",
"qferr/mjml-php": "^2.0",
"gettext/gettext": "^5.6",
"gettext/translator": "^1.1",
"jaybizzle/crawler-detect": "^1.2",
"slim/psr7": "^1.6",
"erusev/parsedown": "^1.7"
"erusev/parsedown": "^1.7",
"kumi/mjml-php": "dev-main"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "^1.0",

104
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "d2667894e4108f2ea9ece2a5b6a85243",
"content-hash": "939c1896caf454544982bfefec1310cc",
"packages": [
{
"name": "composer/ca-bundle",
@ -551,6 +551,55 @@
},
"time": "2024-06-07T07:58:43+00:00"
},
{
"name": "kumi/mjml-php",
"version": "dev-main",
"source": {
"type": "git",
"url": "https://git.private.coffee/kumi/mjml-php.git",
"reference": "b8ce0c35cc3376b5fc5ecd913a6ea701d2de762d"
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"php": ">=7.2"
},
"require-dev": {
"phpunit/phpunit": "^8.5"
},
"default-branch": true,
"type": "library",
"autoload": {
"psr-4": {
"Qferrer\\Mjml\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Qferrer\\Tests\\Mjml\\": "tests/"
}
},
"scripts": {
"test": [
"phpunit tests/"
]
},
"license": [
"MIT"
],
"authors": [
{
"name": "Quentin",
"email": "qferrer@outook.com"
},
{
"name": "Kumi",
"email": "mjml-php@kumi.email"
}
],
"description": "A simple PHP library to render MJML to HTML.",
"time": "2025-03-10T15:03:58+00:00"
},
{
"name": "ml/iri",
"version": "1.1.4",
@ -868,51 +917,6 @@
},
"time": "2023-04-04T09:54:51+00:00"
},
{
"name": "qferr/mjml-php",
"version": "2.0.0",
"source": {
"type": "git",
"url": "https://github.com/qferr/mjml-php.git",
"reference": "c7185024d8b561bd3b532b29f46cba0f4b789b70"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/qferr/mjml-php/zipball/c7185024d8b561bd3b532b29f46cba0f4b789b70",
"reference": "c7185024d8b561bd3b532b29f46cba0f4b789b70",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"php": ">=7.2"
},
"require-dev": {
"phpunit/phpunit": "^8.5"
},
"type": "library",
"autoload": {
"psr-4": {
"Qferrer\\Mjml\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Quentin",
"email": "qferrer@outook.com"
}
],
"description": "A simple PHP library to render MJML to HTML.",
"support": {
"issues": "https://github.com/qferr/mjml-php/issues",
"source": "https://github.com/qferr/mjml-php/tree/2.0.0"
},
"time": "2022-04-09T21:34:38+00:00"
},
{
"name": "ralouphie/getallheaders",
"version": "3.0.3",
@ -3917,10 +3921,12 @@
],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": [],
"stability-flags": {
"kumi/mjml-php": 20
},
"prefer-stable": false,
"prefer-lowest": false,
"platform": [],
"platform-dev": [],
"platform": {},
"platform-dev": {},
"plugin-api-version": "2.6.0"
}

View file

@ -29,6 +29,10 @@ class Email
$this->mjml = str_replace('<mj-include path="MJML_PART" />', $this->contentsPart, $this->contentsTemplate);
/** Replace references to wishthis.online with instance-specific information */
$baseurl = $_SERVER['REQUEST_SCHEME'] . '://' . $_SERVER['HTTP_HOST'];
$this->mjml = str_replace('https://wishthis.online', $baseurl, $this->mjml);
/** Set Locale */
global $locale;
@ -44,9 +48,16 @@ class Email
{
global $options;
// Break if MJML API is not configured
if (!$options->getOption('mjml_api_application_id') && !$options->getOption('mjml_api_url')) {
return false;
}
$api = new \Qferrer\Mjml\Http\CurlApi(
$options->getOption('mjml_api_application_id'),
$options->getOption('mjml_api_secret_key')
$options->getOption('mjml_api_secret_key'),
null,
$options->getOption('mjml_api_url')
);
$renderer = new \Qferrer\Mjml\Renderer\ApiRenderer($api);

View file

@ -74,43 +74,13 @@ class Wish
public static function getAffiliateLink(string $url): string
{
$urlParts = parse_url($url);
if (isset($urlParts['query'])) {
\parse_str($urlParts['query'], $urlParameters);
} else {
$urlParameters = [];
}
foreach (self::$affiliates as $host => $tagId) {
if (\str_contains($urlParts['host'], $host)) {
$urlParameters['tag'] = $tagId;
$urlParts['query'] = \http_build_query($urlParameters);
$url = $urlParts['scheme'] . '://' . $urlParts['host'] . $urlParts['path'] . '?' . $urlParts['query'];
break;
}
}
// Bypass the link generation entirely for Private.coffee fork
return $url;
}
public static function hasAffiliateLink(string $url): bool
{
$urlParts = parse_url($url);
if (isset($urlParts['query'])) {
\parse_str($urlParts['query'], $urlParameters);
} else {
$urlParameters = [];
}
foreach (self::$affiliates as $host => $tagId) {
if (\str_contains($urlParts['host'], $host) && isset($urlParameters['tag'])) {
return true;
}
}
// Just bypass the check entirely for Private.coffee fork
return false;
}

View file

@ -17,7 +17,7 @@
/>
</mj-attributes>
<mj-font name="Raleway" href="https://fonts.googleapis.com/css?family=Raleway" />
<mj-font name="Raleway" href="https://googledonts.private.coffee/css?family=Raleway" />
<mj-style>
a {

View file

@ -348,13 +348,23 @@ $page->navigation();
<label><?= __('Advertisements') ?></label>
<div class="ui toggle checkbox advertisements">
<?php if (true === $user->getAdvertisements()) { ?>
<input type="checkbox" name="enable-advertisements" checked="checked" />
<?php } else { ?>
<input type="checkbox" name="enable-advertisements" />
<?php } ?>
<?php
$wishthis_hosts = [
'wishthis.localhost',
'wishthis.online',
'rc.wishthis.online',
'dev.wishthis.online',
];
<label><?= __('Enable advertisements') ?></label>
if (!in_array($_SERVER['HTTP_HOST'], $wishthis_hosts, true)) { ?>
<input type="checkbox" name="enable-advertisements" disabled="disabled" />
<?php } elseif (true === $user->getAdvertisements()) { ?>
<input type="checkbox" name="enable-advertisements" checked="checked" />
<?php } else { ?>
<input type="checkbox" name="enable-advertisements" />
<?php } ?>
<label><?= __('Enable advertisements') ?></label>
</div>
</div>

View file

@ -18,11 +18,11 @@ $page = new Page(__FILE__, $pageTitle);
if (isset($_POST['email'], $_POST['password']) && !empty($_POST['planet']) && !$registrationDisabled) {
$users = $database
->query(
'SELECT *
->query(
'SELECT *
FROM `users`;'
)
->fetchAll();
)
->fetchAll();
$emails = array_map(
function ($user) {
return $user['email'];
@ -71,16 +71,16 @@ if (isset($_POST['email'], $_POST['password']) && !empty($_POST['planet']) && !$
* Password reset
*/
$userQuery = $database
->query(
'SELECT *
->query(
'SELECT *
FROM `users`
WHERE `email` = :user_email
AND `password_reset_token` = :user_password_reset_token',
[
'user_email' => $user_email,
'user_password_reset_token' => $user_token,
]
);
[
'user_email' => $user_email,
'user_password_reset_token' => $user_token,
]
);
if (false !== $userQuery) {
$user = new User($userQuery->fetch());
@ -88,15 +88,15 @@ if (isset($_POST['email'], $_POST['password']) && !empty($_POST['planet']) && !$
echo \date('d.m.Y H:i') . ' <= ' . \date('d.m.Y H:i', $user->getPasswordResetValidUntil()) . '.';
if (time() <= $user->getPasswordResetValidUntil()) {
$database
->query(
'UPDATE `users`
->query(
'UPDATE `users`
SET `password` = :user_password
WHERE `id` = :user_id;',
[
'user_password' => User::passwordToHash($_POST['password']),
'user_id' => $user->getId(),
]
);
[
'user_password' => User::passwordToHash($_POST['password']),
'user_id' => $user->getId(),
]
);
$page->messages[] = Page::success(
'Password has been successfully reset for <strong>' . $user_email . '</strong>.',
@ -179,8 +179,8 @@ if (isset($_POST['email'], $_POST['password']) && !empty($_POST['planet']) && !$
$wishlist_hash = sha1(time() . $user_id . $wishlist_name);
$database
->query(
'INSERT INTO `wishlists` (
->query(
'INSERT INTO `wishlists` (
`user`,
`name`,
`hash`
@ -189,12 +189,12 @@ if (isset($_POST['email'], $_POST['password']) && !empty($_POST['planet']) && !$
:wishlist_name,
:wishlist_hash
);',
[
'wishlist_user_id' => $user_id,
'wishlist_name' => $wishlist_name,
'wishlist_hash' => $wishlist_hash,
]
);
[
'wishlist_user_id' => $user_id,
'wishlist_name' => $wishlist_name,
'wishlist_hash' => $wishlist_hash,
]
);
}
} else {
$page->messages[] = Page::error(
@ -236,8 +236,7 @@ $page->navigation();
name="email"
placeholder="john.doe@domain.tld"
value="<?= $_GET['password-reset'] ?>"
readonly
/>
readonly />
<?php } else { ?>
<input type="email" name="email" placeholder="john.doe@domain.tld" />
<?php } ?>
@ -278,12 +277,10 @@ $page->navigation();
<input class="ui primary button"
type="submit"
value="<?= $buttonSubmit ?>"
title="<?= $buttonSubmit ?>"
/>
title="<?= $buttonSubmit ?>" />
<a class="ui tertiary button"
href="<?= Page::PAGE_LOGIN ?>"
title="<?= __('Login') ?>"
>
href="<?= Page::PAGE_LOGIN ?>"
title="<?= __('Login') ?>">
<?= __('Login') ?>
</a>
</div>

View file

@ -17,6 +17,11 @@ if (isset($_POST['mjml_api'], $_POST['api_application_id'], $_POST['mjml_api_sec
$options->setOption('mjml_api_application_id', $_POST['api_application_id']);
$options->setOption('mjml_api_secret_key', $_POST['mjml_api_secret_key']);
}
if (isset($_POST['mjml_api'])) {
// In contrast to the two before, this one may reasonably be set to an empty string.
$options->setOption('mjml_api_url', $_POST['api_url']);
}
?>
<main>
@ -29,6 +34,15 @@ if (isset($_POST['mjml_api'], $_POST['api_application_id'], $_POST['mjml_api_sec
<h3 class="ui header"><?= __('API') ?></h3>
<form class="ui form" method="POST">
<div class="field">
<label><?=__('API URL') ?></label>
<input type="text"
name="api_url"
placeholder="https://api.mjml.io/v1/"
value="<?= $options->getOption('mjml_api_url'); ?>"
/>
</div>
<div class="field">
<label><?= __('Application ID') ?></label>
<input type="text"

View file

@ -32,6 +32,11 @@ class InstalledVersions
*/
private static $installed;
/**
* @var bool
*/
private static $installedIsLocalDir;
/**
* @var bool|null
*/
@ -309,6 +314,12 @@ class InstalledVersions
{
self::$installed = $data;
self::$installedByVendor = array();
// when using reload, we disable the duplicate protection to ensure that self::$installed data is
// always returned, but we cannot know whether it comes from the installed.php in __DIR__ or not,
// so we have to assume it does not, and that may result in duplicate data being returned when listing
// all installed packages for example
self::$installedIsLocalDir = false;
}
/**
@ -322,19 +333,27 @@ class InstalledVersions
}
$installed = array();
$copiedLocalDir = false;
if (self::$canGetVendors) {
$selfDir = strtr(__DIR__, '\\', '/');
foreach (ClassLoader::getRegisteredLoaders() as $vendorDir => $loader) {
$vendorDir = strtr($vendorDir, '\\', '/');
if (isset(self::$installedByVendor[$vendorDir])) {
$installed[] = self::$installedByVendor[$vendorDir];
} elseif (is_file($vendorDir.'/composer/installed.php')) {
/** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
$required = require $vendorDir.'/composer/installed.php';
$installed[] = self::$installedByVendor[$vendorDir] = $required;
if (null === self::$installed && strtr($vendorDir.'/composer', '\\', '/') === strtr(__DIR__, '\\', '/')) {
self::$installed = $installed[count($installed) - 1];
self::$installedByVendor[$vendorDir] = $required;
$installed[] = $required;
if (self::$installed === null && $vendorDir.'/composer' === $selfDir) {
self::$installed = $required;
self::$installedIsLocalDir = true;
}
}
if (self::$installedIsLocalDir && $vendorDir.'/composer' === $selfDir) {
$copiedLocalDir = true;
}
}
}
@ -350,7 +369,7 @@ class InstalledVersions
}
}
if (self::$installed !== array()) {
if (self::$installed !== array() && !$copiedLocalDir) {
$installed[] = self::$installed;
}

View file

@ -15,8 +15,8 @@ return array(
'Symfony\\Component\\String\\' => array($vendorDir . '/symfony/string'),
'Symfony\\Component\\Console\\' => array($vendorDir . '/symfony/console'),
'Slim\\Psr7\\' => array($vendorDir . '/slim/psr7/src'),
'Qferrer\\Mjml\\' => array($vendorDir . '/qferr/mjml-php/src'),
'Psr\\Http\\Message\\' => array($vendorDir . '/psr/http-message/src', $vendorDir . '/psr/http-factory/src'),
'Qferrer\\Mjml\\' => array($vendorDir . '/kumi/mjml-php/src'),
'Psr\\Http\\Message\\' => array($vendorDir . '/psr/http-factory/src', $vendorDir . '/psr/http-message/src'),
'Psr\\Http\\Client\\' => array($vendorDir . '/psr/http-client/src'),
'Psr\\Container\\' => array($vendorDir . '/psr/container/src'),
'PhpParser\\' => array($vendorDir . '/nikic/php-parser/lib/PhpParser'),

View file

@ -120,12 +120,12 @@ class ComposerStaticInit5f3db9fc1d0cf1dd6a77a1d84501b4b1
),
'Qferrer\\Mjml\\' =>
array (
0 => __DIR__ . '/..' . '/qferr/mjml-php/src',
0 => __DIR__ . '/..' . '/kumi/mjml-php/src',
),
'Psr\\Http\\Message\\' =>
array (
0 => __DIR__ . '/..' . '/psr/http-message/src',
1 => __DIR__ . '/..' . '/psr/http-factory/src',
0 => __DIR__ . '/..' . '/psr/http-factory/src',
1 => __DIR__ . '/..' . '/psr/http-message/src',
),
'Psr\\Http\\Client\\' =>
array (

View file

@ -650,6 +650,58 @@
},
"install-path": "../jaybizzle/crawler-detect"
},
{
"name": "kumi/mjml-php",
"version": "dev-main",
"version_normalized": "dev-main",
"source": {
"type": "git",
"url": "https://git.private.coffee/kumi/mjml-php.git",
"reference": "b8ce0c35cc3376b5fc5ecd913a6ea701d2de762d"
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"php": ">=7.2"
},
"require-dev": {
"phpunit/phpunit": "^8.5"
},
"time": "2025-03-10T15:03:58+00:00",
"default-branch": true,
"type": "library",
"installation-source": "source",
"autoload": {
"psr-4": {
"Qferrer\\Mjml\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Qferrer\\Tests\\Mjml\\": "tests/"
}
},
"scripts": {
"test": [
"phpunit tests/"
]
},
"license": [
"MIT"
],
"authors": [
{
"name": "Quentin",
"email": "qferrer@outook.com"
},
{
"name": "Kumi",
"email": "mjml-php@kumi.email"
}
],
"description": "A simple PHP library to render MJML to HTML.",
"install-path": "../kumi/mjml-php"
},
{
"name": "marcocesarato/php-conventional-changelog",
"version": "1.17.2",
@ -1982,54 +2034,6 @@
},
"install-path": "../psr/http-message"
},
{
"name": "qferr/mjml-php",
"version": "2.0.0",
"version_normalized": "2.0.0.0",
"source": {
"type": "git",
"url": "https://github.com/qferr/mjml-php.git",
"reference": "c7185024d8b561bd3b532b29f46cba0f4b789b70"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/qferr/mjml-php/zipball/c7185024d8b561bd3b532b29f46cba0f4b789b70",
"reference": "c7185024d8b561bd3b532b29f46cba0f4b789b70",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"php": ">=7.2"
},
"require-dev": {
"phpunit/phpunit": "^8.5"
},
"time": "2022-04-09T21:34:38+00:00",
"type": "library",
"installation-source": "dist",
"autoload": {
"psr-4": {
"Qferrer\\Mjml\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Quentin",
"email": "qferrer@outook.com"
}
],
"description": "A simple PHP library to render MJML to HTML.",
"support": {
"issues": "https://github.com/qferr/mjml-php/issues",
"source": "https://github.com/qferr/mjml-php/tree/2.0.0"
},
"install-path": "../qferr/mjml-php"
},
{
"name": "ralouphie/getallheaders",
"version": "3.0.3",

View file

@ -100,6 +100,17 @@
'aliases' => array(),
'dev_requirement' => false,
),
'kumi/mjml-php' => array(
'pretty_version' => 'dev-main',
'version' => 'dev-main',
'reference' => 'b8ce0c35cc3376b5fc5ecd913a6ea701d2de762d',
'type' => 'library',
'install_path' => __DIR__ . '/../kumi/mjml-php',
'aliases' => array(
0 => '9999999-dev',
),
'dev_requirement' => false,
),
'marcocesarato/php-conventional-changelog' => array(
'pretty_version' => '1.17.2',
'version' => '1.17.2.0',
@ -298,15 +309,6 @@
0 => '1.0|2.0|3.0',
),
),
'qferr/mjml-php' => array(
'pretty_version' => '2.0.0',
'version' => '2.0.0.0',
'reference' => 'c7185024d8b561bd3b532b29f46cba0f4b789b70',
'type' => 'library',
'install_path' => __DIR__ . '/../qferr/mjml-php',
'aliases' => array(),
'dev_requirement' => false,
),
'ralouphie/getallheaders' => array(
'pretty_version' => '3.0.3',
'version' => '3.0.3.0',

1
vendor/kumi/mjml-php vendored Submodule

@ -0,0 +1 @@
Subproject commit b8ce0c35cc3376b5fc5ecd913a6ea701d2de762d

View file

@ -1,53 +0,0 @@
name: PHPUnit
on: ['push']
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
php-versions: ['7.2', '7.3', '7.4', '8.0', '8.1']
steps:
- uses: actions/checkout@v3
# https://github.com/shivammathur/setup-php (community)
- name: Setup PHP, extensions and composer with shivammathur/setup-php
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-versions }}
extensions: json, curl
- uses: actions/setup-node@v3
with:
node-version: '14'
- name: Install JS dependencies
run: npm ci
- name: Validate composer.json and composer.lock
run: composer validate --strict
- name: Cache Composer packages
id: composer-cache
uses: actions/cache@v3
with:
path: vendor
key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-php-
- name: Install dependencies
run: composer update --no-interaction --no-scripts --no-progress
- name: Run test suite
run: composer run-script test

View file

@ -1,6 +0,0 @@
.idea/
vendor/
node_modules/
phpunit.xml
.phpunit.result.cache
.php-cs-fixer.cache

View file

@ -1,78 +0,0 @@
MJML in PHP
===========
![PHPUnit](https://github.com/qferr/mjml-php/actions/workflows/php.yml/badge.svg)
A simple PHP library to render MJML to HTML.
There are two ways for integrating MJML in PHP:
* using the MJML API
* using the MJML library
### Installation
```shell script
composer require qferr/mjml-php
```
### Using MJML library
Install the MJML library:
```shell script
npm install mjml --save
```
If you want a specific version, use the following syntax: `npm install mjml@4.7.1 --save`
```php
<?php
require_once 'vendor/autoload.php';
$renderer = new \Qferrer\Mjml\Renderer\BinaryRenderer(__DIR__ . '/node_modules/.bin/mjml');
$html = $renderer->render('
<mjml>
<mj-body>
<mj-section>
<mj-column>
<mj-text>Hello world</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>
');
```
### Using MJML API
```php
<?php
require_once 'vendor/autoload.php';
$apiId = 'abcdef-1234-5678-ghijkl';
$secretKey = 'ghijkl-5678-1234-abcdef';
$api = new \Qferrer\Mjml\Http\CurlApi($apiId, $secretKey);
$renderer = new \Qferrer\Mjml\Renderer\ApiRenderer($api);
$html = $renderer->render('
<mjml>
<mj-body>
<mj-section>
<mj-column>
<mj-text>Hello world</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>
');
```
You can get the version of MJML used by the API to transpile:
```php
$api->getMjmlVersion();
```
More details in the API documentation: [https://mjml.io/api/documentation](https://mjml.io/api/documentation)

View file

@ -1,38 +0,0 @@
{
"name": "qferr/mjml-php",
"description": "A simple PHP library to render MJML to HTML.",
"type": "library",
"license": "MIT",
"authors": [
{
"name": "Quentin",
"email": "qferrer@outook.com"
}
],
"require": {
"php": ">=7.2",
"ext-curl": "*",
"ext-json": "*"
},
"require-dev": {
"phpunit/phpunit": "^8.5"
},
"autoload": {
"psr-4": {
"Qferrer\\Mjml\\" : "src/"
}
},
"autoload-dev": {
"psr-4": {
"Qferrer\\Tests\\Mjml\\" : "tests/"
}
},
"scripts": {
"test": "phpunit tests/"
},
"config": {
"platform": {
"php": "7.2.30"
}
}
}

1806
vendor/qferr/mjml-php/composer.lock generated vendored

File diff suppressed because it is too large Load diff

1248
vendor/qferr/mjml-php/package-lock.json generated vendored

File diff suppressed because it is too large Load diff

View file

@ -1,12 +0,0 @@
{
"name": "mjml-php",
"description": "MJML in PHP",
"author": "Quentin Ferrer",
"bugs": {
"url": "https://github.com/qferr/mjml-php/issues"
},
"homepage": "https://github.com/qferr/mjml-php#readme",
"devDependencies": {
"mjml": "^4.12.0"
}
}

View file

@ -1,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/6.3/phpunit.xsd"
backupGlobals="false"
colors="true"
>
<php>
<ini name="error_reporting" value="-1" />
</php>
<testsuites>
<testsuite name="Project Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>

View file

@ -1,26 +0,0 @@
<?php
namespace Qferrer\Mjml;
use Qferrer\Mjml\Exception\ApiException;
interface ApiInterface
{
/**
* Renders a MJML to HTML content.
*
* @param string $mjml The MJML content
*
* @return string The generated HTML
*
* @throws ApiException
*/
public function getHtml(string $mjml): string;
/**
* Returns the version of MJML used by the API to transpile.
*
* @return string
*/
public function getMjmlVersion(): string;
}

View file

@ -1,7 +0,0 @@
<?php
namespace Qferrer\Mjml\Exception;
class ApiException extends \RuntimeException
{
}

View file

@ -1,7 +0,0 @@
<?php
namespace Qferrer\Mjml\Exception;
class CurlException extends \RuntimeException
{
}

View file

@ -1,7 +0,0 @@
<?php
namespace Qferrer\Mjml\Exception;
class ProcessException extends \RuntimeException
{
}

View file

@ -1,31 +0,0 @@
<?php
namespace Qferrer\Mjml\Http;
use Qferrer\Mjml\Exception\CurlException;
class Curl implements CurlInterface
{
public function request(string $url, array $options = []): CurlResponseInterface
{
$request = @curl_init($url);
if (false === $request) {
throw new CurlException('Unable to initialize Curl.');
}
curl_setopt_array($request, $options);
$data = curl_exec($request);
if (false === $data) {
throw new CurlException(sprintf('Curl Error: "%s"', curl_error($request)));
}
$response = new CurlResponse($data, curl_getinfo($request, CURLINFO_HTTP_CODE));
curl_close($request);
return $response;
}
}

View file

@ -1,88 +0,0 @@
<?php
declare(strict_types=1);
namespace Qferrer\Mjml\Http;
use Qferrer\Mjml\ApiInterface;
use Qferrer\Mjml\Exception\ApiException;
/**
* @see https://mjml.io/api/documentation/
*/
final class CurlApi implements ApiInterface
{
protected $apiEndpoint = "https://api.mjml.io/v1";
protected $appId;
protected $secretKey;
protected $credentials;
protected $curl;
public function __construct(string $appId, string $secretKey, ?CurlInterface $curl = null)
{
if (!\extension_loaded('curl')) {
throw new \LogicException(sprintf(
'You cannot use the "%s" as the "curl" extension is not installed.',
CurlApi::class
));
}
$this->appId = $appId;
$this->secretKey = $secretKey;
$this->credentials = base64_encode("$appId:$secretKey");
$this->curl = $curl ?? new Curl();
}
public function getHtml(string $mjml): string
{
$data = $this->getResult($mjml);
return $data['html'] ?? '';
}
public function getMjmlVersion(): string
{
$data = $this->getResult('<mjml></mjml>');
return $data['mjml_version'] ?? 'unknown';
}
private function getResult(string $mjml): array
{
$response = $this->curl->request($this->apiEndpoint . '/render', [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "UTF-8",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 0,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['mjml' => $mjml]),
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"Authorization: Basic {$this->credentials}"
]
]);
$data = @json_decode($response->getContent(), true);
if (false === $data || null === $data) {
throw new ApiException(sprintf(
'Unable to decode the JSON response: "%s".',
json_last_error_msg()
));
}
$httpCode = $response->getStatusCode();
if ($httpCode !== 200) {
throw new ApiException(sprintf(
'Unexpected HTTP code: %s. Api Error Message: "%s".',
$httpCode,
$data['message'] ?? 'Unknown Error'
));
}
return $data;
}
}

View file

@ -1,8 +0,0 @@
<?php
namespace Qferrer\Mjml\Http;
interface CurlInterface
{
public function request(string $url, array $options = []): CurlResponseInterface;
}

View file

@ -1,25 +0,0 @@
<?php
namespace Qferrer\Mjml\Http;
class CurlResponse implements CurlResponseInterface
{
private $data;
private $statusCode;
public function __construct($data, int $statusCode)
{
$this->data = $data;
$this->statusCode = $statusCode;
}
public function getContent()
{
return $this->data;
}
public function getStatusCode(): int
{
return $this->statusCode;
}
}

View file

@ -1,13 +0,0 @@
<?php
namespace Qferrer\Mjml\Http;
interface CurlResponseInterface
{
/**
* @return mixed
*/
public function getContent();
public function getStatusCode(): int;
}

View file

@ -1,91 +0,0 @@
<?php
namespace Qferrer\Mjml\Process;
use Qferrer\Mjml\Exception\ProcessException;
class Process
{
private $command;
private $input;
private $output;
private $errorMessage = '';
public const STDIN = 0;
public const STDOUT = 1;
public const STDERR = 2;
private static $descriptors = [
self::STDIN => ['pipe', 'r'],
self::STDOUT => ['pipe', 'w'],
self::STDERR => ['pipe', 'w']
];
public function __construct(string $command, string $input)
{
$this->command = $command;
$this->input = $input;
}
public function run(): void
{
$this->initialize();
$pipes = [];
$process = $this->createProcess($pipes);
$this->setInput($pipes);
$this->setOutput($pipes);
$this->setErrorMessage($pipes);
if (0 !== proc_close($process)) {
throw new ProcessException($this->errorMessage);
}
}
public function getOutput()
{
return $this->output;
}
private function initialize(): void
{
$this->output = null;
$this->errorMessage = '';
}
/**
* @return resource
*/
private function createProcess(array &$pipes)
{
$process = proc_open($this->command, self::$descriptors, $pipes);
if (!is_resource($process)) {
throw new ProcessException('Unable to create a process.');
}
return $process;
}
private function setInput(array $pipes): void
{
$stdin = $pipes[self::STDIN];
fwrite($stdin, $this->input);
fclose($stdin);
}
private function setOutput(array $pipes): void
{
$stdout = $pipes[self::STDOUT];
$this->output = stream_get_contents($stdout);
fclose($stdout);
}
private function setErrorMessage(array $pipes): void
{
$stderr = $pipes[self::STDERR];
$this->errorMessage = stream_get_contents($stderr);
fclose($stderr);
}
}

View file

@ -1,37 +0,0 @@
<?php
namespace Qferrer\Mjml\Renderer;
use Qferrer\Mjml\ApiInterface;
use Qferrer\Mjml\RendererInterface;
/**
* Class ApiRenderer
*/
class ApiRenderer implements RendererInterface
{
/**
* The HTTP client.
*
* @var ApiInterface
*/
private $client;
/**
* ApiRenderer constructor.
*
* @param ApiInterface $httpClient
*/
public function __construct(ApiInterface $httpClient)
{
$this->client = $httpClient;
}
/**
* @inheritDoc
*/
public function render(string $content): string
{
return $this->client->getHtml($content);
}
}

View file

@ -1,46 +0,0 @@
<?php
namespace Qferrer\Mjml\Renderer;
use Qferrer\Mjml\Process\Process;
use Qferrer\Mjml\RendererInterface;
/**
* Class BinaryRenderer
*/
class BinaryRenderer implements RendererInterface
{
/**
* The MJML CLI path.
*
* @var string
*/
private $bin;
/**
* @var string
*/
private $command;
/**
* BinaryRenderer constructor.
*
* @param string $bin
*/
public function __construct(string $bin)
{
$this->bin = $bin;
$this->command = "{$this->bin} -i -s --config.validationLevel --config.minify";
}
/**
* @inheritDoc
*/
public function render(string $content): string
{
$process = new Process($this->command, $content);
$process->run();
return $process->getOutput();
}
}

View file

@ -1,18 +0,0 @@
<?php
namespace Qferrer\Mjml;
/**
* Interface RendererInterface
*/
interface RendererInterface
{
/**
* Renders MJML to HTML content.
*
* @param string $content The MJML content
*
* @return string The generated HTML
*/
public function render(string $content): string;
}

View file

@ -1,94 +0,0 @@
<?php
namespace Qferrer\Tests\Mjml\Http;
use PHPUnit\Framework\TestCase;
use Qferrer\Mjml\Http\CurlApi;
use Qferrer\Mjml\Exception\ApiException;
use Qferrer\Mjml\Http\CurlInterface;
use Qferrer\Mjml\Http\CurlResponseInterface;
class CurlApiTest extends TestCase
{
/**
* @var \PHPUnit\Framework\MockObject\MockObject|CurlInterface
*/
private $curl;
private $api;
private $response;
protected function setUp(): void
{
$this->response = $this->createMock(CurlResponseInterface::class);
$this->curl = $this->createMock(CurlInterface::class);
$this->curl->method('request')->willReturn($this->response);
$this->api = new CurlApi('test', 'mysecret', $this->curl);
}
public function testGetHtml()
{
$html = '<p>Hello World</p>';
$this->configureResponseMock(['html' => $html], 200);
$this->curl->method('request')->with('https://api.mjml.io/v1/render', [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "UTF-8",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 0,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['mjml' => 'test']),
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"Authorization: Basic " . base64_encode("test:mysecret")
]
]);
$this->assertEquals($html, $this->api->getHtml('test'));
}
public function testGetHtmlWithInvalidStatusCode()
{
$this->configureResponseMock([], 400);
$this->expectException(ApiException::class);
$this->expectExceptionMessage('Unexpected HTTP code: 400. Api Error Message: "Unknown Error"');
$this->api->getHtml('test');
}
public function testGetHtmlWithInvalidStatusCodeAndErrorMessage()
{
$this->configureResponseMock(['message' => 'Invalid JSON'], 400);
$this->expectException(ApiException::class);
$this->expectExceptionMessage('Unexpected HTTP code: 400. Api Error Message: "Invalid JSON"');
$this->api->getHtml('test');
}
public function testGetHtmlWithInvalidContent()
{
$this->configureResponseMock('{invalid json}', 200);
$this->expectException(ApiException::class);
$this->expectExceptionMessage('Unable to decode the JSON response: "Syntax error".');
$this->api->getHtml('test');
}
public function testGetMjmlVersion()
{
$this->configureResponseMock(['mjml_version' => '4.4.0'], 200);
$this->assertEquals('4.4.0', $this->api->getMjmlVersion());
}
private function configureResponseMock($data, int $statusCode)
{
if (is_array($data)) {
$data = json_encode($data);
}
$this->response->method('getContent')->willReturn($data);
$this->response->method('getStatusCode')->willReturn($statusCode);
}
}

View file

@ -1,30 +0,0 @@
<?php
namespace Qferrer\Tests\Mjml\Renderer;
use PHPUnit\Framework\TestCase;
use Qferrer\Mjml\ApiInterface;
use Qferrer\Mjml\Http\CurlApi;
use Qferrer\Mjml\Renderer\ApiRenderer;
class ApiRendererTests extends TestCase
{
private $httpClient;
private $apiRenderer;
protected function setUp(): void
{
$this->httpClient = $this->createMock(ApiInterface::class);
$this->apiRenderer = new ApiRenderer($this->httpClient);
}
public function testRender()
{
$expectedHtml = '<p>Test</p>';
$this->httpClient->expects($this->once())->method('getHtml')->willReturn($expectedHtml);
$html = $this->apiRenderer->render('<mjml>Test</mjml>');
$this->assertEquals($expectedHtml, $html);
}
}

View file

@ -1,40 +0,0 @@
<?php
namespace Qferrer\Tests\Mjml\Renderer;
use PHPUnit\Framework\TestCase;
use Qferrer\Mjml\Exception\ProcessException;
use Qferrer\Mjml\Renderer\BinaryRenderer;
use Qferrer\Tests\Mjml\ResourcesTrait;
class BinaryRendererTest extends TestCase
{
use ResourcesTrait;
public function testRender()
{
$renderer = new BinaryRenderer(__DIR__ . '/../../node_modules/.bin/mjml');
$html = $renderer->render($this->loadResource('hello_world.mjml'));
$this->assertEquals($this->loadResource('hello_world.min.html.text'), $html);
}
public function testRenderWithInvalidBinaryThrowException()
{
$this->expectException(ProcessException::class);
$this->expectExceptionMessage('unknown');
$renderer = new BinaryRenderer('unknown');
$renderer->render($this->loadResource('hello_world.mjml'));
}
public function testRenderWithMalformedMjml()
{
$this->expectException(ProcessException::class);
$this->expectExceptionMessage('Malformed MJML. Check that your structure is correct and enclosed in <mjml> tags.');
$renderer = new BinaryRenderer(__DIR__ . '/../../node_modules/.bin/mjml');
$renderer->render('<mljm></mljm>');
}
}

View file

@ -1,53 +0,0 @@
<!-- FILE: undefined -->
<!doctype html><html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"><head><title></title><!--[if !mso]><!--><meta http-equiv="X-UA-Compatible" content="IE=edge"><!--<![endif]--><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style type="text/css">#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}</style><!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]--><!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]--><!--[if !mso]><!--><link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css"><style type="text/css">@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);</style><!--<![endif]--><style type="text/css">@media only screen and (min-width:480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
}</style><style media="screen and (min-width:480px)">.moz-text-html .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}</style><style type="text/css"></style><style type="text/css"></style></head><body style="word-spacing:normal;"><div><!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--><div style="margin:0px auto;max-width:600px;"><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;"><tbody><tr><td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;"><!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--><div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"><table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"><tbody><tr><td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"><div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1;text-align:left;color:#000000;">Hello world</div></td></tr></tbody></table></div><!--[if mso | IE]></td></tr></table><![endif]--></td></tr></tbody></table></div><!--[if mso | IE]></td></tr></table><![endif]--></div></body></html>

View file

@ -1,9 +0,0 @@
<mjml>
<mj-body>
<mj-section>
<mj-column>
<mj-text>Hello world</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>

View file

@ -1,11 +0,0 @@
<?php
namespace Qferrer\Tests\Mjml;
trait ResourcesTrait
{
private function loadResource($filename)
{
return file_get_contents(__DIR__ . '/Resources/' . $filename);
}
}