3 * @copyright Copyright (C) 2010-2023, the Friendica project
5 * @license GNU AGPL version 3 or any later version
7 * This program is free software: you can redistribute it and/or modify
8 * it under the terms of the GNU Affero General Public License as
9 * published by the Free Software Foundation, either version 3 of the
10 * License, or (at your option) any later version.
12 * This program is distributed in the hope that it will be useful,
13 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 * GNU Affero General Public License for more details.
17 * You should have received a copy of the GNU Affero General Public License
18 * along with this program. If not, see <https://www.gnu.org/licenses/>.
22 namespace Friendica\Module\User;
25 use Friendica\Core\Config\Capability\IManageConfigValues;
26 use Friendica\Core\L10n;
27 use Friendica\Core\PConfig\Capability\IManagePersonalConfigValues;
28 use Friendica\Core\Protocol;
29 use Friendica\Core\Renderer;
30 use Friendica\Core\System;
31 use Friendica\Core\Worker;
32 use Friendica\Database\Database;
33 use Friendica\Database\DBA;
34 use Friendica\Database\DBStructure;
35 use Friendica\Model\Photo;
36 use Friendica\Model\Profile;
37 use Friendica\Module\Response;
38 use Friendica\Navigation\SystemMessages;
39 use Friendica\Network\HTTPException;
40 use Friendica\Object\Image;
41 use Friendica\Protocol\Delivery;
42 use Friendica\Security\PermissionSet\Repository\PermissionSet;
43 use Friendica\Util\Profiler;
44 use Friendica\Util\Strings;
45 use Psr\Log\LoggerInterface;
47 class Import extends \Friendica\BaseModule
49 const IMPORT_DEBUG = false;
54 /** @var IManageConfigValues */
57 /** @var IManagePersonalConfigValues */
60 /** @var SystemMessages */
61 private $systemMessages;
66 /** @var PermissionSet */
67 private $permissionSet;
69 public function __construct(PermissionSet $permissionSet, IManagePersonalConfigValues $pconfig, Database $database, SystemMessages $systemMessages, IManageConfigValues $config, App $app, L10n $l10n, App\BaseURL $baseUrl, App\Arguments $args, LoggerInterface $logger, Profiler $profiler, Response $response, array $server, array $parameters = [])
71 parent::__construct($l10n, $baseUrl, $args, $logger, $profiler, $response, $server, $parameters);
74 $this->config = $config;
75 $this->pconfig = $pconfig;
76 $this->systemMessages = $systemMessages;
77 $this->database = $database;
78 $this->permissionSet = $permissionSet;
81 protected function post(array $request = [])
83 if ($this->config->get('config', 'register_policy') != \Friendica\Module\Register::OPEN && !$this->app->isSiteAdmin()) {
84 throw new HttpException\ForbiddenException($this->t('Permission denied.'));
87 $max_dailies = intval($this->config->get('system', 'max_daily_registrations'));
89 $total = $this->database->count('user', ["`register_date` > UTC_TIMESTAMP - INTERVAL 1 DAY"]);
90 if ($total >= $max_dailies) {
91 throw new HttpException\ForbiddenException($this->t('Permission denied.'));
95 if (!empty($_FILES['accountfile'])) {
96 $this->importAccount($_FILES['accountfile']);
100 protected function content(array $request = []): string
102 if (($this->config->get('config', 'register_policy') != \Friendica\Module\Register::OPEN) && !$this->app->isSiteAdmin()) {
103 $this->systemMessages->addNotice($this->t('User imports on closed servers can only be done by an administrator.'));
106 $max_dailies = intval($this->config->get('system', 'max_daily_registrations'));
108 $total = $this->database->count('user', ["`register_date` > UTC_TIMESTAMP - INTERVAL 1 DAY"]);
109 if ($total >= $max_dailies) {
110 $this->logger->notice('max daily registrations exceeded.');
111 $this->systemMessages->addNotice($this->t('This site has exceeded the number of allowed daily account registrations. Please try again tomorrow.'));
115 $tpl = Renderer::getMarkupTemplate('user/import.tpl');
116 return Renderer::replaceMacros($tpl, [
117 '$regbutt' => $this->t('Import'),
119 'title' => $this->t('Move account'),
120 'intro' => $this->t('You can import an account from another Friendica server.'),
121 'instruct' => $this->t('You need to export your account from the old server and upload it here. We will recreate your old account here with all your contacts. We will try also to inform your friends that you moved here.'),
122 'warn' => $this->t("This feature is experimental. We can't import contacts from the OStatus network (GNU Social/Statusnet) or from Diaspora"),
123 'field' => ['accountfile', $this->t('Account file'), '<input id="id_accountfile" name="accountfile" type="file">', $this->t('To export your account, go to "Settings->Export your personal data" and select "Export account"')],
128 private function lastInsertId(): int
130 if (self::IMPORT_DEBUG) {
134 return $this->database->lastInsertId();
138 * Remove columns from array $arr that aren't in table $table
140 * @param string $table Table name
141 * @param array &$arr Column=>Value array from json (by ref)
144 private function checkCols(string $table, array &$arr)
146 $tableColumns = DBStructure::getColumns($table);
150 // get a plain array of column names
151 foreach ($tableColumns as $tcol) {
152 $tcols[] = $tcol['Field'];
153 $ttype[$tcol['Field']] = $tcol['Type'];
156 // remove inexistent columns
157 foreach ($arr as $icol => $ival) {
158 if (!in_array($icol, $tcols)) {
163 if ($ttype[$icol] === 'datetime') {
164 $arr[$icol] = $ival ?? DBA::NULL_DATETIME;
170 * Import data into table $table
172 * @param string $table Table name
173 * @param array $arr Column=>Value array from json
177 private function dbImportAssoc(string $table, array $arr): bool
179 if (isset($arr['id'])) {
183 $this->checkCols($table, $arr);
185 if (self::IMPORT_DEBUG) {
189 return $this->database->insert($table, $arr);
193 * Import account file exported from mod/uexport
195 * @param array $file array from $_FILES
197 * @throws HTTPException\FoundException
198 * @throws HTTPException\InternalServerErrorException
199 * @throws HTTPException\MovedPermanentlyException
200 * @throws HTTPException\TemporaryRedirectException
201 * @throws \ImagickException
203 private function importAccount(array $file)
205 $this->logger->notice('Start user import from ' . $file['tmp_name']);
209 2. replace old baseurl with new baseurl
210 3. import data (look at user id and contacts id)
211 4. archive non-dfrn contacts
212 5. send message to dfrn contacts
215 $account = json_decode(file_get_contents($file['tmp_name']), true);
216 if ($account === null) {
217 $this->systemMessages->addNotice($this->t('Error decoding account file'));
221 if (empty($account['version'])) {
222 $this->systemMessages->addNotice($this->t('Error! No version data in file! This is not a Friendica account file?'));
226 // check for username
227 // check if username matches deleted account
228 if ($this->database->exists('user', ['nickname' => $account['user']['nickname']])
229 || $this->database->exists('userd', ['username' => $account['user']['nickname']])) {
230 $this->systemMessages->addNotice($this->t("User '%s' already exists on this server!", $account['user']['nickname']));
234 // Backward compatibility
235 $account['circle'] = $account['circle'] ?? $account['group'];
236 $account['circle_member'] = $account['circle_member'] ?? $account['group_member'];
238 $oldBaseUrl = $account['baseurl'];
239 $newBaseUrl = (string)$this->baseUrl;
241 $oldAddr = str_replace('http://', '@', Strings::normaliseLink($oldBaseUrl));
242 $newAddr = str_replace('http://', '@', Strings::normaliseLink($newBaseUrl));
244 if (!empty($account['profile']['addr'])) {
245 $oldHandle = $account['profile']['addr'];
247 $oldHandle = $account['user']['nickname'] . $oldAddr;
250 // Creating a new guid to avoid problems with Diaspora
251 $account['user']['guid'] = System::createUUID();
253 $oldUid = $account['user']['uid'];
255 unset($account['user']['uid']);
256 unset($account['user']['account_expired']);
257 unset($account['user']['account_expires_on']);
258 unset($account['user']['expire_notification_sent']);
260 array_walk($account['user'], function (&$user) use ($oldBaseUrl, $oldAddr, $newBaseUrl, $newAddr) {
261 $user = str_replace([$oldBaseUrl, $oldAddr], [$newBaseUrl, $newAddr], $user);
265 if ($this->dbImportAssoc('user', $account['user']) === false) {
266 $this->logger->warning('Error inserting user', ['user' => $account['user'], 'error' => $this->database->errorMessage()]);
267 $this->systemMessages->addNotice($this->t('User creation error'));
271 $newUid = $this->lastInsertId();
273 $this->pconfig->set($newUid, 'system', 'previous_addr', $oldHandle);
277 array_walk($account['contact'], function (&$contact) use (&$errorCount, $oldUid, $oldBaseUrl, $oldAddr, $newUid, $newBaseUrl, $newAddr) {
278 if ($contact['uid'] == $oldUid && $contact['self'] == '1') {
279 array_walk($contact, function (&$field) use ($oldUid, $oldBaseUrl, $oldAddr, $newUid, $newBaseUrl, $newAddr) {
280 $field = str_replace([$oldBaseUrl, $oldAddr], [$newBaseUrl, $newAddr], $field);
281 foreach (['profile', 'avatar', 'micro'] as $key) {
282 $field = str_replace($oldBaseUrl . '/photo/' . $key . '/' . $oldUid . '.jpg', $newBaseUrl . '/photo/' . $key . '/' . $newUid . '.jpg', $field);
287 if ($contact['uid'] == $oldUid && $contact['self'] == '0') {
288 // set contacts 'avatar-date' to NULL_DATE to let worker update the URLs
289 $contact['avatar-date'] = DBA::NULL_DATETIME;
291 switch ($contact['network']) {
293 case Protocol::DIASPORA:
294 // send relocate message (below)
301 // archive other contacts
302 $contact['archive'] = '1';
306 $contact['uid'] = $newUid;
307 if ($this->dbImportAssoc('contact', $contact) === false) {
308 $this->logger->warning('Error inserting contact', ['nick' => $contact['nick'], 'network' => $contact['network'], 'error' => $this->database->errorMessage()]);
311 $contact['newid'] = $this->lastInsertId();
315 if ($errorCount > 0) {
316 $this->systemMessages->addNotice($this->tt('%d contact not imported', '%d contacts not imported', $errorCount));
319 array_walk($account['circle'], function (&$circle) use ($newUid) {
320 $circle['uid'] = $newUid;
321 if ($this->dbImportAssoc('group', $circle) === false) {
322 $this->logger->warning('Error inserting circle', ['name' => $circle['name'], 'error' => $this->database->errorMessage()]);
324 $circle['newid'] = $this->lastInsertId();
328 foreach ($account['circle_member'] as $circle_member) {
330 foreach ($account['circle'] as $circle) {
331 if ($circle['id'] == $circle_member['gid'] && isset($circle['newid'])) {
332 $circle_member['gid'] = $circle['newid'];
338 foreach ($account['contact'] as $contact) {
339 if ($contact['id'] == $circle_member['contact-id'] && isset($contact['newid'])) {
340 $circle_member['contact-id'] = $contact['newid'];
346 if ($import == 2 && $this->dbImportAssoc('group_member', $circle_member) === false) {
347 $this->logger->warning('Error inserting circle member', ['gid' => $circle_member['id'], 'error' => $this->database->errorMessage()]);
351 foreach ($account['profile'] as $profile) {
352 unset($profile['id']);
353 $profile['uid'] = $newUid;
355 array_walk($profile, function (&$field) use ($oldUid, $oldBaseUrl, $oldAddr, $newUid, $newBaseUrl, $newAddr) {
356 $field = str_replace([$oldBaseUrl, $oldAddr], [$newBaseUrl, $newAddr], $field);
357 foreach (['profile', 'avatar'] as $key) {
358 $field = str_replace($oldBaseUrl . '/photo/' . $key . '/' . $oldUid . '.jpg', $newBaseUrl . '/photo/' . $key . '/' . $newUid . '.jpg', $field);
362 if (count($account['profile']) === 1 || $profile['is-default']) {
363 if ($this->dbImportAssoc('profile', $profile) === false) {
364 $this->logger->warning('Error inserting profile', ['error' => $this->database->errorMessage()]);
365 $this->systemMessages->addNotice($this->t('User profile creation error'));
366 $this->database->delete('user', ['uid' => $newUid]);
367 $this->database->delete('profile_field', ['uid' => $newUid]);
371 $profile['id'] = $this->database->lastInsertId();
374 Profile::migrate($profile);
377 $permissionSet = $this->permissionSet->selectDefaultForUser($newUid);
379 foreach ($account['profile_fields'] ?? [] as $profile_field) {
380 $profile_field['uid'] = $newUid;
382 ///@TODO Replace with permissionset import
383 $profile_field['psid'] = $profile_field['psid'] ? $permissionSet->id : PermissionSet::PUBLIC;
385 if ($this->dbImportAssoc('profile_field', $profile_field) === false) {
386 $this->logger->info('Error inserting profile field', ['profile_id' => $profile_field['id'], 'error' => $this->database->errorMessage()]);
390 foreach ($account['photo'] as $photo) {
391 $photo['uid'] = $newUid;
392 $photo['data'] = hex2bin($photo['data']);
395 new Image($photo['data'], $photo['type']),
396 $photo['uid'], $photo['contact-id'], //0
397 $photo['resource-id'], $photo['filename'], $photo['album'], $photo['scale'], $photo['profile'], //1
398 $photo['allow_cid'], $photo['allow_gid'], $photo['deny_cid'], $photo['deny_gid']
402 $this->logger->warning('Error inserting photo', ['resource-id' => $photo['resource-id'], 'scale' => $photo['scale'], 'error' => $this->database->errorMessage()]);
406 foreach ($account['pconfig'] as $pconfig) {
407 $pconfig['uid'] = $newUid;
408 if ($this->dbImportAssoc('pconfig', $pconfig) === false) {
409 $this->logger->warning('Error inserting pconfig', ['pconfig_id' => $pconfig['id'], 'error' => $this->database->errorMessage()]);
413 // send relocate messages
414 Worker::add(Worker::PRIORITY_HIGH, 'Notifier', Delivery::RELOCATION, $newUid);
416 $this->systemMessages->addInfo($this->t('Done. You can now login with your username and password'));
417 $this->baseUrl->redirect('login');