Skip to content

Commit 0f0afeb

Browse files
committed
feat: convert regular accounts to guests
- add "Convert to guest account" for regular, never-logged-in database accounts (admin … menu action + OCS endpoint), keeping the login name and password - add ConversionService for the transactional users -> guests_users move; extract GuestManager::setGuestQuota so converted accounts get the default guest quota - allow a free-form guest login name via `occ guests:add --uid` instead of deriving the user ID from the email address - loosen the guests backend user-id guard to accept any non-empty id Closes #1153 Assisted-by: ClaudeCode:claude-opus-4-8 Signed-off-by: ernolf <raphael.gradenwitz@googlemail.com>
1 parent fecc2be commit 0f0afeb

12 files changed

Lines changed: 541 additions & 18 deletions

File tree

appinfo/routes.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@
5050
'url' => '/api/v1/transfer',
5151
'verb' => 'POST',
5252
],
53+
[
54+
'name' => 'users#convert',
55+
'url' => '/api/v1/convert',
56+
'verb' => 'POST',
57+
],
5358
[
5459
'name' => 'API#languages',
5560
'url' => '/api/v1/languages',

lib/Command/AddCommand.php

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ protected function configure(): void {
4848
InputArgument::REQUIRED,
4949
'Email address'
5050
)
51+
->addOption(
52+
'uid',
53+
null,
54+
InputOption::VALUE_REQUIRED,
55+
'Login name (user ID) for the guest. If omitted, the email address is used (hashed when the privacy setting is enabled).'
56+
)
5157
->addOption(
5258
'generate-password',
5359
null,
@@ -86,11 +92,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int
8692
}
8793

8894
$email = $input->getArgument('email');
89-
if ($this->config->useHashedEmailAsUserID()) {
90-
$email = strtolower($email);
91-
$uid = hash('sha256', $email);
92-
} else {
93-
$uid = $email;
95+
96+
$uid = $input->getOption('uid');
97+
if ($uid === null || $uid === '') {
98+
if ($this->config->useHashedEmailAsUserID()) {
99+
$email = strtolower($email);
100+
$uid = hash('sha256', $email);
101+
} else {
102+
$uid = $email;
103+
}
94104
}
95105

96106
// same behavior like in the UsersController

lib/Controller/UsersController.php

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use OCA\Guests\Db\Transfer;
1515
use OCA\Guests\Db\TransferMapper;
1616
use OCA\Guests\GuestManager;
17+
use OCA\Guests\Service\ConversionService;
1718
use OCA\Guests\Service\InviteService;
1819
use OCA\Guests\TransferService;
1920
use OCP\AppFramework\Db\DoesNotExistException;
@@ -29,6 +30,7 @@
2930
use OCP\IUserManager;
3031
use OCP\IUserSession;
3132
use OCP\Mail\IMailer;
33+
use Psr\Log\LoggerInterface;
3234

3335
class UsersController extends OCSController {
3436
public function __construct(
@@ -45,6 +47,8 @@ public function __construct(
4547
private readonly TransferService $transferService,
4648
private readonly TransferMapper $transferMapper,
4749
private readonly InviteService $inviteService,
50+
private readonly ConversionService $conversionService,
51+
private readonly LoggerInterface $logger,
4852
) {
4953
parent::__construct($appName, $request);
5054
}
@@ -245,4 +249,53 @@ public function transfer(string $guestUserId, string $targetUserId): DataRespons
245249

246250
return new DataResponse([], Http::STATUS_CREATED);
247251
}
252+
253+
/**
254+
* Convert a regular, never-logged-in account into a guest account
255+
*/
256+
public function convert(string $userId): DataResponse {
257+
$author = $this->userSession->getUser();
258+
if (!($author instanceof IUser)) {
259+
return new DataResponse([
260+
'message' => $this->l10n->t('Failed to authorize')
261+
], Http::STATUS_UNAUTHORIZED);
262+
}
263+
264+
$user = $this->userManager->get($userId);
265+
if (!($user instanceof IUser)) {
266+
return new DataResponse([
267+
'message' => $this->l10n->t('Account not found')
268+
], Http::STATUS_NOT_FOUND);
269+
}
270+
271+
if ($this->guestManager->isGuest($user)) {
272+
return new DataResponse([
273+
'message' => $this->l10n->t('Account is already a guest')
274+
], Http::STATUS_CONFLICT);
275+
}
276+
277+
if ($user->getBackendClassName() !== 'Database') {
278+
return new DataResponse([
279+
'message' => $this->l10n->t('Only regular accounts can be converted to guests')
280+
], Http::STATUS_CONFLICT);
281+
}
282+
283+
if ($user->getLastLogin() !== 0) {
284+
return new DataResponse([
285+
'message' => $this->l10n->t('Only accounts that have never logged in can be converted')
286+
], Http::STATUS_CONFLICT);
287+
}
288+
289+
try {
290+
$this->conversionService->convertToGuest($user, $author);
291+
$this->guestManager->setGuestQuota($user);
292+
} catch (\Throwable $e) {
293+
$this->logger->error('Failed to convert account "' . $userId . '" to a guest', ['exception' => $e]);
294+
return new DataResponse([
295+
'message' => $this->l10n->t('An error occurred while converting the account')
296+
], Http::STATUS_INTERNAL_SERVER_ERROR);
297+
}
298+
299+
return new DataResponse([]);
300+
}
248301
}

lib/GuestManager.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,11 +111,18 @@ public function createGuest(?IUser $createdBy, string $userId, string $email, st
111111
);
112112
}
113113

114-
$user->setQuota($this->appConfig->getAppValueString(ConfigLexicon::GUEST_DISK_QUOTA));
114+
$this->setGuestQuota($user);
115115

116116
return $user;
117117
}
118118

119+
/**
120+
* Apply the configured default guest quota to an account.
121+
*/
122+
public function setGuestQuota(IUser $user): void {
123+
$user->setQuota($this->appConfig->getAppValueString(ConfigLexicon::GUEST_DISK_QUOTA));
124+
}
125+
119126
/**
120127
* @return list<string>
121128
*/

lib/Service/ConversionService.php

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Guests\Service;
11+
12+
use OCA\Guests\AppInfo\Application;
13+
use OCA\Guests\ConfigLexicon;
14+
use OCP\Config\IUserConfig;
15+
use OCP\IDBConnection;
16+
use OCP\IUser;
17+
18+
/**
19+
* Converts a regular account into a guest account by moving it from the core
20+
* "Database" user backend into the guests backend.
21+
*
22+
* The user ID is preserved, so all existing account data, home storage and
23+
* mounts stay valid, and the password hash is carried over unchanged. The
24+
* caller is responsible for checking eligibility (database backend, never
25+
* logged in, not already a guest).
26+
*/
27+
class ConversionService {
28+
public function __construct(
29+
private readonly IDBConnection $connection,
30+
private readonly IUserConfig $userConfig,
31+
) {
32+
}
33+
34+
public function convertToGuest(IUser $user, IUser $createdBy): void {
35+
$uid = $user->getUID();
36+
$uidLower = mb_strtolower($uid);
37+
$displayName = $user->getDisplayName();
38+
$email = $user->getSystemEMailAddress() ?? '';
39+
40+
// Carry over the existing password hash from the database backend.
41+
$query = $this->connection->getQueryBuilder();
42+
$query->select('password')
43+
->from('users')
44+
->where($query->expr()->eq('uid_lower', $query->createNamedParameter($uidLower)));
45+
$result = $query->executeQuery();
46+
$passwordHash = $result->fetchOne();
47+
$result->closeCursor();
48+
if ($passwordHash === false) {
49+
throw new \RuntimeException('No password hash found for "' . $uid . '"');
50+
}
51+
52+
// Move the account between backends in a single transaction. The user ID
53+
// is unchanged, so all data keyed by it (account, home storage, mounts,
54+
// shares) stays valid.
55+
$this->connection->beginTransaction();
56+
try {
57+
$insert = $this->connection->getQueryBuilder();
58+
$insert->insert('guests_users')
59+
->values([
60+
'uid' => $insert->createNamedParameter($uid),
61+
'uid_lower' => $insert->createNamedParameter($uidLower),
62+
'displayname' => $insert->createNamedParameter($displayName),
63+
'password' => $insert->createNamedParameter($passwordHash),
64+
'email' => $insert->createNamedParameter($email),
65+
]);
66+
$insert->executeStatement();
67+
68+
$delete = $this->connection->getQueryBuilder();
69+
$delete->delete('users')
70+
->where($delete->expr()->eq('uid_lower', $delete->createNamedParameter($uidLower)));
71+
$delete->executeStatement();
72+
73+
$this->connection->commit();
74+
} catch (\Throwable $e) {
75+
$this->connection->rollBack();
76+
throw $e;
77+
}
78+
79+
$this->userConfig->setValueString($uid, Application::APP_ID, ConfigLexicon::USER_CREATED_BY, $createdBy->getUID());
80+
}
81+
}

lib/UserBackend.php

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -330,8 +330,7 @@ private function loadUser($uid): bool {
330330
return false;
331331
}
332332

333-
// guests $uid could be NULL or ''
334-
// or is not an email anyway
333+
// Skip empty IDs; any non-empty ID is resolved against the guests_users table.
335334
if (!$this->potentialGuestUserId($uid)) {
336335
$this->cache[$uid] = false;
337336
return false;
@@ -473,14 +472,10 @@ public function getRealUID(string $uid): string {
473472
}
474473

475474
/**
476-
* Guest app user ids are:
477-
* - either email addresses so they need to contain an @
478-
* - lowercase sha256 hashes of email addresses, 64 characters of a-f and 0-9
479-
*
480-
* @param string $userId
481-
* @return bool
475+
* Guard against empty IDs only. Any non-empty ID may belong to a guest and is
476+
* resolved against the guests_users table.
482477
*/
483478
protected function potentialGuestUserId(string $userId): bool {
484-
return str_contains($userId, '@') || preg_match('/^[a-f0-9]{64}$/', $userId);
479+
return $userId !== '';
485480
}
486481
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
<!--
2+
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
- SPDX-License-Identifier: AGPL-3.0-or-later
4+
-->
5+
<template>
6+
<NcDialog
7+
:name="t('guests', 'Convert to guest account')"
8+
:outTransition="true"
9+
size="small"
10+
@closing="cancel">
11+
<NcNoteCard type="warning">
12+
{{ t('guests', 'The account "{userId}" will be converted into a guest account. It keeps its login name and password but becomes a limited guest account, restricted to the apps allowed for guests.', { userId: String(user.id) }) }}
13+
</NcNoteCard>
14+
<p class="convert-dialog__hint">
15+
{{ t('guests', 'This is only possible for accounts that have never logged in. It cannot be undone automatically.') }}
16+
</p>
17+
18+
<template #actions>
19+
<NcButton
20+
variant="tertiary"
21+
:disabled="loading"
22+
@click="cancel">
23+
{{ t('guests', 'Cancel') }}
24+
</NcButton>
25+
26+
<NcButton
27+
variant="error"
28+
:disabled="loading"
29+
@click="submit">
30+
<template v-if="loading" #icon>
31+
<NcLoadingIcon :name="t('guests', 'Converting account…')" />
32+
</template>
33+
{{ t('guests', 'Convert to guest') }}
34+
</NcButton>
35+
</template>
36+
</NcDialog>
37+
</template>
38+
39+
<script lang="ts">
40+
import type { PropType } from 'vue'
41+
import type { User } from '../types.ts'
42+
43+
import axios from '@nextcloud/axios'
44+
import { showError } from '@nextcloud/dialogs'
45+
import { translate as t } from '@nextcloud/l10n'
46+
import { generateOcsUrl } from '@nextcloud/router'
47+
import { defineComponent } from 'vue'
48+
import NcButton from '@nextcloud/vue/components/NcButton'
49+
import NcDialog from '@nextcloud/vue/components/NcDialog'
50+
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
51+
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
52+
import { logger } from '../services/logger.ts'
53+
54+
export default defineComponent({
55+
name: 'ConvertToGuestDialog',
56+
57+
components: {
58+
NcButton,
59+
NcDialog,
60+
NcLoadingIcon,
61+
NcNoteCard,
62+
},
63+
64+
props: {
65+
user: {
66+
type: Object as PropType<User>,
67+
default: () => ({}),
68+
},
69+
},
70+
71+
emits: ['close'],
72+
73+
data() {
74+
return {
75+
loading: false,
76+
}
77+
},
78+
79+
methods: {
80+
t,
81+
82+
async submit(): Promise<void> {
83+
this.loading = true
84+
const userId = String(this.user.id)
85+
try {
86+
await axios.post(generateOcsUrl('/apps/guests/api/v1/convert'), { userId })
87+
this.$emit('close', userId)
88+
} catch (error: unknown) {
89+
const message = (error as { response?: { data?: { ocs?: { data?: { message?: string } } } } })
90+
?.response?.data?.ocs?.data?.message
91+
logger.error(message ?? 'Failed to convert account to guest', { error })
92+
showError(message ?? t('guests', 'An error occurred while converting the account'))
93+
this.$emit('close', null)
94+
}
95+
this.loading = false
96+
},
97+
98+
cancel(): void {
99+
this.$emit('close', null)
100+
},
101+
},
102+
})
103+
</script>
104+
105+
<style lang="scss" scoped>
106+
.convert-dialog__hint {
107+
margin-top: 8px;
108+
}
109+
</style>

0 commit comments

Comments
 (0)