1<?php
2/**
3 * DokuWiki Avatar Plugin: displays avatar images with syntax, see:
4 * <https://www.dokuwiki.org/plugin:avatar>.
5 *
6 * Copyright (C) 2005-2007 by Esther Brunner <wikidesign@gmail.com>
7 * Copyright (C) 2008-2009 by Gina Häußge, Michael Klier <dokuwiki@chimeric.de>
8 * Copyright (C) 2013 by Michael Hamann <michael@content-space.de>
9 * Copyright (C) 2023 by Daniel Dias Rodrigues <danieldiasr@gmail.com>
10 *
11 * This program is free software; you can redistribute it and/or modify
12 * it under the terms of the GNU General Public License as published by
13 * the Free Software Foundation; either version 2 of the License, or
14 * (at your option) any later version.
15 *
16 * This program is distributed in the hope that it will be useful,
17 * but WITHOUT ANY WARRANTY; without even the implied warranty of
18 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
19 * GNU General Public License for more details.
20 *
21 * You should have received a copy of the GNU General Public License along
22 * with this program; if not, see <https://www.gnu.org/licenses/>.
23 */
24
25declare(strict_types=1);
26
27if (!defined('DOKU_INC')) die();
28
29class helper_plugin_avatar extends DokuWiki_Plugin
30{
31    private const ALLOWED_FORMATS = ['.png', '.jpg', '.gif', '.webp'];
32    private const GRAVATAR_BASE = 'https://secure.gravatar.com/avatar/';
33
34    private array $avatarCache = [];
35
36    public function getMethods(): array
37    {
38        return [
39            [
40                'name'   => 'renderXhtml',
41                'desc'   => 'Returns the XHTML to display an avatar',
42                'params' => [
43                    'user'  => 'string|array',
44                    'title' => 'string',
45                    'align' => 'string',
46                    'size'  => 'int'
47                ],
48                'return' => ['xhtml' => 'string']
49            ]
50        ];
51    }
52
53    /**
54     * Renders the avatar as XHTML <img>
55     */
56    public function renderXhtml(string|array $user, string $title = '', ?string $align = '', ?int $size = null): string
57    {
58        $src = $this->resolveAvatarUrl($user, $title, $size);
59
60        $title = hsc($title);
61
62        return '<img src="' . $src . '" ' .
63               'class="media' . $align . ' photo fn" ' .
64               'title="' . $title . '" ' .
65               'alt="' . $title . '" ' .
66               'width="' . (string) $size . '" ' .
67               'height="' . (string) $size . '" />';
68    }
69
70    /**
71     * Gets or generates the avatar URL for a user/email
72     */
73    public function resolveAvatarUrl(string|array $user, string &$title, int &$size): string
74    {
75        $cacheKey = $this->getCacheKey($user, $size);
76
77        if (isset($this->avatarCache[$cacheKey])) {
78            return $this->avatarCache[$cacheKey];
79        }
80
81        $mail = $this->extractUserData($user, $title);
82        $isEmail = mail_isvalid($mail) && (!is_array($user) || !isset($user['user']));
83
84        // For emails (Gravatar)
85        if ($isEmail) {
86            $src = $this->getGravatarUrl($mail, $size);
87        }
88        // For local users
89        else {
90            $src = $this->tryLocalAvatar($user, $title, $size);
91
92            if (!$src) {
93                // Apply fallback configured for local users only
94                if ($this->getConf('local_default') === 'monsterid' && function_exists('imagecreatetruecolor')) {
95                    $seed = md5(dokuwiki\Utf8\PhpString::strtolower(is_array($user) ? ($user['user'] ?? '') : $user));
96                    $src = $this->getMonsterIdUrl($seed, $size);
97                } else {
98                    $src = $this->getDefaultImageUrl($size);
99                }
100            }
101        }
102
103        if (empty($title)) {
104            $title = obfuscate($mail);
105        }
106
107        $this->avatarCache[$cacheKey] = $src;
108        return $src;
109    }
110
111    private function resolveTokens(string $template): string
112    {
113        global $INFO;
114
115        $vars = [
116            '@USER@' => cleanID($INFO['client'] ?? ''),
117        ];
118
119        return strtr($template, $vars);
120    }
121
122    private function getCacheKey(string|array $user, int $size): string
123    {
124        $userKey = is_array($user) ? ($user['mail'] ?? ($user['user'] ?? '')) : $user;
125        return md5($userKey . $size);
126    }
127
128    private function extractUserData(string|array $user, ?string &$title): string
129    {
130        if (is_array($user)) {
131            if (empty($title) && !empty($user['name'])) {
132                $title = hsc($user['name']);
133            }
134            return $user['mail'] ?? '';
135        }
136        return $user;
137    }
138
139    private function tryLocalAvatar(string|array $user, ?string &$title, int $size): ?string
140    {
141        global $auth;
142
143        $username = is_array($user) ? ($user['user'] ?? '') : $user;
144        $userinfo = $auth->getUserData($username);
145
146        if (!$userinfo) return null;
147        if (empty($title) && !empty($userinfo['name'])) $title = hsc($userinfo['name']);
148
149        $ns = $this->resolveTokens($this->getConf('namespace'));
150        $existingFiles = [];
151
152        // Scan all allowed formats.
153        foreach (self::ALLOWED_FORMATS as $format) {
154            $imagePath = $ns . ':' . $username . $format;
155            $imageFile = mediaFN($imagePath);
156
157            if (file_exists($imageFile)) {
158                $existingFiles[$imagePath] = filesize($imageFile);
159            }
160        }
161
162        if ($existingFiles) {
163            // Returns the file with the smallest size.
164            asort($existingFiles);
165            $bestPath = key($existingFiles);
166            return ml($bestPath, ['w' => $size, 'h' => $size], true, '&', false);
167        }
168
169        // No local files found → generate MonsterID if it's the selected
170        // fallback
171        if ($this->getConf('local_default') === 'monsterid') {
172            if ($this->saveMonsterIdAvatar($username, 120)) {
173                $monsterPath = $ns . ':' . $username . '.png';
174                if (file_exists(mediaFN($monsterPath))) {
175                    return ml($monsterPath, ['w' => $size, 'h' => $size], true, '&', false);
176                }
177            }
178        }
179
180        return null;
181    }
182
183    private function saveMonsterIdAvatar(string $username, int $size): bool
184    {
185        global $INFO;
186
187        $currentUser = cleanID($INFO['client'] ?? '');
188        $username    = cleanID($username);
189
190        // It only allows you to save your own avatar.
191        if ($username !== $currentUser) {
192            return false;
193        }
194
195        $ns = $this->resolveTokens($this->getConf('namespace'));
196        $filename = $ns . ':' . $username . '.png';
197        $filepath = mediaFN($filename);
198
199        // If any local avatar of the user already exists, it will not generate
200        // a MonsterID.
201        foreach (self::ALLOWED_FORMATS as $format) {
202            if (file_exists(mediaFN($ns . ':' . $username . $format))) {
203                return true;
204            }
205        }
206
207        // MonsterID URL for the user
208        $seed = md5(dokuwiki\Utf8\PhpString::strtolower($username));
209        $monsterUrl = DOKU_URL . 'lib/plugins/avatar/monsterid.php?seed=' . $seed . '&size=' . $size;
210
211        // Download the image using file_get_contents
212        $imageData = @file_get_contents($monsterUrl);
213        if ($imageData === false) return false;
214
215        // creates the directory if it does not exist
216        io_makeFileDir($filepath);
217
218        // Save the image
219        return file_put_contents($filepath, $imageData) !== false;
220    }
221
222    private function getGravatarUrl(string $mail, int $size): string
223    {
224        $seed = md5(dokuwiki\Utf8\PhpString::strtolower($mail));
225
226        $default = function_exists('imagecreatetruecolor')
227            ? $this->getMonsterIdUrl($seed, $size)
228            : $this->getDefaultImageUrl($size);
229
230        if (!mail_isvalid($mail)) {
231            return $default;
232        }
233
234        $params = [
235            's' => $size,
236            'r' => $this->getConf('rating')
237        ];
238
239        $gravatar_default = $this->getConf('gravatar_default');
240
241        if ($gravatar_default !== 'default') {
242            $params['d'] = $gravatar_default;
243        }
244
245        return self::GRAVATAR_BASE . $seed . '?' . http_build_query($params);
246    }
247
248    private function getMonsterIdUrl(string $seed, int $size): string
249    {
250        $file = 'monsterid.php?seed=' . $seed . '&size=' . $size;
251        return ml(DOKU_URL . 'lib/plugins/avatar/' . $file, 'cache=recache', true, '&', true);
252    }
253
254    private function getDefaultImageUrl(int $size): string
255    {
256        $file = 'images/default_' . $size . '.png';
257        return ml(DOKU_URL . 'lib/plugins/avatar/' . $file, 'cache=recache', true, '&', true);
258    }
259}
260