Skip to content
Open
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
2 changes: 2 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
['name' => 'Admin#circles', 'url' => '/admin/{emulated}/circles', 'verb' => 'GET'],
['name' => 'Admin#circlesAll', 'url' => '/admin/circlesall', 'verb' => 'GET'],
['name' => 'Admin#teamFolders', 'url' => '/admin/teamfolders', 'verb' => 'GET'],
['name' => 'Admin#updateTeamFolderDefaultQuota', 'url' => '/admin/teamfolders/{circleId}/default-quota', 'verb' => 'PUT'],
['name' => 'Admin#create', 'url' => '/admin/{emulated}/circles', 'verb' => 'POST'],
['name' => 'Admin#destroy', 'url' => '/admin/{emulated}/circles/{circleId}', 'verb' => 'DELETE'],
[
Expand Down Expand Up @@ -96,6 +97,7 @@
['name' => 'Admin#editConfig', 'url' => '/admin/{emulated}/circles/{circleId}/config', 'verb' => 'PUT'],
['name' => 'Admin#link', 'url' => '/admin/{emulated}/link/{circleId}/{singleId}', 'verb' => 'GET'],

['name' => 'Settings#getGroups', 'url' => '/settings/groups/', 'verb' => 'GET'],
['name' => 'Settings#getValues', 'url' => '/settings/', 'verb' => 'GET'],
['name' => 'Settings#setValue', 'url' => '/settings/{key}/', 'verb' => 'POST'],
],
Expand Down
5 changes: 4 additions & 1 deletion lib/ConfigLexicon.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ class ConfigLexicon implements ILexicon {
public const FEDERATED_TEAMS_FRONTAL = 'federated_teams_frontal';
public const TEAM_FOLDER_AUTO_CREATE = 'team_folder_auto_create';
public const TEAM_FOLDER_DEFAULT_QUOTA = 'team_folder_default_quota';
public const TEAM_FOLDER_GROUP_QUOTAS = 'team_folder_group_quotas';
public const DEFAULT_QUOTA = 104857600; // 104857600 bytes ~ 100 MB

// OIDC
public const OIDC_ENABLED = 'oidc_enabled';
Expand Down Expand Up @@ -56,7 +58,8 @@ public function getAppConfigs(): array {
new Entry(key: self::FEDERATED_TEAMS_ENABLED, type: ValueType::BOOL, defaultRaw: false, definition: 'disable/enable Federated Teams', lazy: true),
new Entry(key: self::FEDERATED_TEAMS_FRONTAL, type: ValueType::STRING, defaultRaw: '', definition: 'domain name used to auth public request', lazy: true),
new Entry(key: self::TEAM_FOLDER_AUTO_CREATE, type: ValueType::BOOL, defaultRaw: true, definition: 'whether Circles auto-creates team folders and allows UI/API upgrade (not exposed in admin UI)', lazy: true),
new Entry(key: self::TEAM_FOLDER_DEFAULT_QUOTA, type: ValueType::INT, defaultRaw: 0, definition: 'default quota in bytes for auto-created team folders (0 means unlimited)', lazy: true),
new Entry(key: self::TEAM_FOLDER_DEFAULT_QUOTA, type: ValueType::INT, defaultRaw: self::DEFAULT_QUOTA, definition: 'default quota in bytes for auto-created team folders (0 means unlimited)', lazy: true),
new Entry(key: self::TEAM_FOLDER_GROUP_QUOTAS, type: ValueType::ARRAY, defaultRaw: [], definition: 'quota overrides in bytes by Nextcloud group ID (0 means unlimited)', lazy: true),
// OIDC
new Entry(key: self::OIDC_ENABLED, type: ValueType::BOOL, defaultRaw: false, definition: 'disable/enable OIDC integration', lazy: true),
new Entry(key: self::OIDC_ISSUER, type: ValueType::STRING, defaultRaw: '', definition: 'provider issuer URL', lazy: true),
Expand Down
41 changes: 37 additions & 4 deletions lib/Controller/AdminController.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,13 @@
use OCA\Circles\Service\MemberService;
use OCA\Circles\Service\MembershipService;
use OCA\Circles\Service\SearchService;
use OCA\Circles\Service\TeamFolderPolicy;
use OCA\Circles\Tools\Traits\TDeserialize;
use OCA\Circles\Tools\Traits\TNCLogger;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCS\OCSException;
use OCP\AppFramework\OCS\OCSNotFoundException;
use OCP\AppFramework\OCSController;
use OCP\IRequest;
use OCP\IUserSession;
Expand Down Expand Up @@ -72,6 +75,7 @@ public function __construct(
private MembershipService $membershipService,
private SearchService $searchService,
private ITeamManager $teamManager,
private TeamFolderPolicy $teamFolderPolicy,
ConfigService $configService,
) {
parent::__construct($appName, $request);
Expand Down Expand Up @@ -278,9 +282,6 @@ public function teamFolders(): DataResponse {
try {
$this->setLocalFederatedUser($this->userSession->getUser()->getUID());
$provider = $this->teamManager->getTeamFolderProvider();
if ($provider === null) {
return new DataResponse([]);
}

$probe = new CircleProbe();
$probe->filterPersonalCircles()
Expand All @@ -291,11 +292,12 @@ public function teamFolders(): DataResponse {

$teamFolders = [];
foreach ($this->circleService->getAllCircles($probe) as $circle) {
$folder = $provider->getTeamFolder($circle->getSingleId());
$folder = $provider?->getTeamFolder($circle->getSingleId());

$teamFolders[] = [
'teamId' => $circle->getSingleId(),
'teamName' => $circle->getDisplayName(),
'defaultQuota' => $this->teamFolderPolicy->getTeamFolderQuota($circle),
'folder' => $folder?->jsonSerialize(),
];
}
Expand All @@ -307,6 +309,37 @@ public function teamFolders(): DataResponse {
}
}

/**
* Set or remove the quota override associated with a team.
*
* @throws OCSException
*/
public function updateTeamFolderDefaultQuota(string $circleId, ?int $quota): DataResponse {
try {
$user = $this->userSession->getUser();
if ($user === null) {
throw new OCSException('Authentication required', Http::STATUS_UNAUTHORIZED);
}

$this->setLocalFederatedUser($user->getUID());
$circle = $this->circleService->getCircle($circleId);
if ($quota === null) {
$this->teamFolderPolicy->removeTeamFolderQuota($circle);
} else {
$this->teamFolderPolicy->setTeamFolderQuota($circle, $quota);
}

return new DataResponse([
'teamId' => $circleId,
'defaultQuota' => $this->teamFolderPolicy->getTeamFolderQuota($circle),
]);
} catch (\InvalidArgumentException $e) {
throw new OCSException($e->getMessage(), Http::STATUS_BAD_REQUEST);
} catch (\OCA\Circles\Exceptions\CircleNotFoundException) {
throw new OCSNotFoundException('Team not found');
}
}

/**
* @param string $emulated
* @param string $circleId
Expand Down
34 changes: 34 additions & 0 deletions lib/Controller/SettingsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\AppFramework\Services\IAppConfig;
use OCP\IGroupManager;
use OCP\IRequest;

class SettingsController extends OCSController {
public function __construct(
string $appName,
IRequest $request,
private readonly IAppConfig $appConfig,
private readonly IGroupManager $groupManager,
) {
parent::__construct($appName, $request);
}
Expand Down Expand Up @@ -49,6 +51,16 @@ public function setValue(string $key, string $value): DataResponse {
return $this->getValues();
}

if ($key === ConfigLexicon::TEAM_FOLDER_GROUP_QUOTAS) {
$quotas = json_decode($value, true);
if (!is_array($quotas) || !$this->areValidGroupQuotas($quotas)) {
return new DataResponse(['data' => ['message' => 'group quotas must be a mapping of group IDs to non-negative integers']], Http::STATUS_BAD_REQUEST);
}

$this->appConfig->setAppValueArray(ConfigLexicon::TEAM_FOLDER_GROUP_QUOTAS, $quotas);
return $this->getValues();
}

return new DataResponse(['data' => ['message' => 'unsupported key']], Http::STATUS_BAD_REQUEST);
}

Expand All @@ -57,9 +69,31 @@ public function getValues(): DataResponse {
ConfigLexicon::FEDERATED_TEAMS_FRONTAL => $this->getFrontalValue() ?? '',
ConfigLexicon::FEDERATED_TEAMS_ENABLED => $this->appConfig->getAppValueBool(ConfigLexicon::FEDERATED_TEAMS_ENABLED),
ConfigLexicon::TEAM_FOLDER_DEFAULT_QUOTA => $this->appConfig->getAppValueInt(ConfigLexicon::TEAM_FOLDER_DEFAULT_QUOTA, 0),
ConfigLexicon::TEAM_FOLDER_GROUP_QUOTAS => $this->appConfig->getAppValueArray(ConfigLexicon::TEAM_FOLDER_GROUP_QUOTAS, []),
]);
}

public function getGroups(string $search = ''): DataResponse {
return new DataResponse(array_map(
static fn (\OCP\IGroup $group): array => [
'id' => $group->getGID(),
'label' => $group->getDisplayName(),
],
$this->groupManager->search($search, 100),
));
}

/** @param array<mixed> $quotas */
private function areValidGroupQuotas(array $quotas): bool {
foreach ($quotas as $groupId => $quota) {
if (!is_string($groupId) || $groupId === '' || !is_int($quota) || $quota < 0) {
return false;
}
}

return true;
}

private function setFrontalValue(string $url): bool {
[$scheme, $cloudId, $path] = $this->parseFrontalAddress($url);
if (is_null($scheme)) {
Expand Down
2 changes: 1 addition & 1 deletion lib/Controller/TeamFolderController.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ public function upgradeTeamFolder(string $circleId, string $name = ''): DataResp
displayName: trim($name) ?: $circle->getDisplayName(),
link: null,
),
$this->policy->getDefaultQuota(),
$this->policy->getQuotaForCircle($circle),
);

return new DataResponse([
Expand Down
2 changes: 1 addition & 1 deletion lib/Listeners/TeamFolderLifecycleListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ public function handle(Event $event): void {
displayName: $circle->getDisplayName(),
link: null,
),
$this->policy->getDefaultQuota(),
$this->policy->getQuotaForCircle($circle),
);
} catch (\Throwable $e) {
$this->logger->error('Failed to auto-create team folder', [
Expand Down
1 change: 1 addition & 0 deletions lib/Model/Circle.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ class Circle extends ManagedModel implements IEntity, IDeserializable, IQueryRow

// settings array keys
public const SETTING_EXTERNAL_ID = 'external_id';
public const SETTING_TEAM_FOLDER_QUOTA = 'team_folder_quota';

/**
* Note: When editing those values, update lib/Application/Capabilities.php
Expand Down
3 changes: 2 additions & 1 deletion lib/Search/LocalUsers.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ public function search($needle): array {
return $this->searchFromCollaborator($needle);
}

$users = $userManager->search($needle);
$users = $userManager->searchDisplayName($needle);
foreach ($users as $user) {
$result[] = new SearchResult(
$user->getUID(),
Expand All @@ -62,6 +62,7 @@ public function search($needle): array {
* @return array
*/
private function searchFromCollaborator($search): array {
/** @psalm-suppress DeprecatedMethod The Collaborator ISearch API has no replacement yet. */
[$temp, $hasMore] = $this->search->search($search, [IShare::TYPE_USER, IShare::TYPE_EMAIL], false, 50, 0);

$result = array_merge($temp['exact']['users'], $temp['users']);
Expand Down
113 changes: 108 additions & 5 deletions lib/Service/TeamFolderPolicy.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,23 @@
namespace OCA\Circles\Service;

use OCA\Circles\ConfigLexicon;
use OCA\Circles\Db\CircleRequest;
use OCA\Circles\Db\MembershipRequest;
use OCA\Circles\Exceptions\CircleNotFoundException;
use OCA\Circles\Model\Circle;
use OCP\AppFramework\Services\IAppConfig;
use OCP\IGroupManager;
use OCP\IUserManager;

/**
* Policy for team folders owned by teams (circles).
*
* This class owns the *policy* for team-folder creation:
* - the `team_folder_auto_create` app config toggle (occ only, not admin UI),
* - the `team_folder_default_quota` app config value,
* - the default quota and per-team quota settings,
* - the circle-type eligibility rules (personal/hidden/system/backend circles
* are excluded).
*
* Per-creation opt-out from the team wizard/API is applied by the listener
* before this policy runs.
*
* The *orchestration* (creating, unlinking, removing folders) is owned by the
* groupfolders app. The circles app keeps no reference to the groupfolders app.
*
Expand All @@ -36,6 +38,10 @@ class TeamFolderPolicy {

public function __construct(
private IAppConfig $appConfig,
private MembershipRequest $membershipRequest,
private CircleRequest $circleRequest,
private IGroupManager $groupManager,
private IUserManager $userManager,
) {
}

Expand Down Expand Up @@ -82,7 +88,104 @@ public function shouldCreateTeamFolder(Circle $circle): bool {
return $this->isEligibleCircle($circle);
}

/**
* Get the quota applied when no group-specific override matches.
*/
public function getDefaultQuota(): int {
return $this->appConfig->getAppValueInt(ConfigLexicon::TEAM_FOLDER_DEFAULT_QUOTA, 0);
$quota = $this->appConfig->getAppValueInt(ConfigLexicon::TEAM_FOLDER_DEFAULT_QUOTA, ConfigLexicon::DEFAULT_QUOTA);
return $quota >= 0 ? $quota : ConfigLexicon::DEFAULT_QUOTA;
}

/**
* @throws \InvalidArgumentException when the quota is negative.
*/
public function setDefaultQuota(int $quota): void {
if ($quota < 0) {
throw new \InvalidArgumentException('default quota must be a non-negative integer');
}

$this->appConfig->setAppValueInt(ConfigLexicon::TEAM_FOLDER_DEFAULT_QUOTA, $quota);
}

public function getTeamFolderQuota(Circle $circle): ?int {
$quota = $circle->getSettings()[Circle::SETTING_TEAM_FOLDER_QUOTA] ?? null;

return is_int($quota) && $quota >= 0 ? $quota : null;
}

public function setTeamFolderQuota(Circle $circle, int $quota): void {
if ($quota < 0) {
throw new \InvalidArgumentException('team folder quota must be a non-negative integer');
}

$settings = $circle->getSettings();
$settings[Circle::SETTING_TEAM_FOLDER_QUOTA] = $quota;
$this->circleRequest->updateSettings($circle->setSettings($settings));
}

public function removeTeamFolderQuota(Circle $circle): void {
$settings = $circle->getSettings();
if (array_key_exists(Circle::SETTING_TEAM_FOLDER_QUOTA, $settings)) {
unset($settings[Circle::SETTING_TEAM_FOLDER_QUOTA]);
$this->circleRequest->updateSettings($circle->setSettings($settings));
}
}

/** @return array<string, int> */
public function getGroupQuotas(): array {
$quotas = $this->appConfig->getAppValueArray(ConfigLexicon::TEAM_FOLDER_GROUP_QUOTAS, []);
return array_filter($quotas, static fn (mixed $quota): bool => is_int($quota) && $quota >= 0);
}

/**
* Resolve the highest configured team or group quota for the local team owner.
* Unlimited (0) takes precedence over every finite quota.
*/
public function getQuotaForCircle(Circle $circle): int {
$override = $this->getTeamFolderQuota($circle);
$fallback = $this->getDefaultQuota();
$matches = $override === null ? [] : [$override];
$owner = $circle->getOwner();
if (!$owner->isLocal()) {
return $matches === [] ? $fallback : ($matches[0] ?? $fallback);
}

$matches = [...$matches, ...$this->getMatchingGroupQuotas($owner->getUserId())];
foreach ($this->membershipRequest->getMemberships($owner->getSingleId()) as $membership) {
try {
$membershipCircle = $this->circleRequest->getCircle($membership->getCircleId());
} catch (CircleNotFoundException) {
continue;
}

$quota = $this->getTeamFolderQuota($membershipCircle);
if ($quota !== null) {
$matches[] = $quota;
}
}

if ($matches === []) {
return $fallback;
}

if (in_array(0, $matches, true)) {
return 0;
}

return max($matches);
}

/** @return list<int> */
private function getMatchingGroupQuotas(string $userId): array {
$user = $this->userManager->get($userId);
if ($user === null) {
return [];
}

$quotas = $this->getGroupQuotas();
return array_values(array_filter(array_map(
static fn (string $groupId): ?int => $quotas[$groupId] ?? null,
$this->groupManager->getUserGroupIds($user),
), static fn (?int $quota): bool => $quota !== null));
}
}
6 changes: 6 additions & 0 deletions lib/Settings/AdminTeamFolders.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

use OCA\Circles\AppInfo\Application;
use OCA\Circles\ConfigLexicon;
use OCA\Circles\Service\TeamFolderPolicy;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\IAppConfig;
Expand All @@ -26,6 +27,7 @@ public function __construct(
private IAppConfig $appConfig,
private IL10N $l,
private IInitialState $initialState,
private TeamFolderPolicy $teamFolderPolicy,
) {
}

Expand All @@ -36,6 +38,10 @@ public function getForm(): TemplateResponse {
$teamFolderDefaultQuota = $this->appConfig->getValueInt(Application::APP_ID, ConfigLexicon::TEAM_FOLDER_DEFAULT_QUOTA, 0);

$this->initialState->provideInitialState('teamFolderDefaultQuota', $teamFolderDefaultQuota);
$this->initialState->provideInitialState(
'teamFolderProvisioningEnabled',
$this->teamFolderPolicy->isTeamFolderProvisioningEnabled(),
);

Util::addStyle(Application::APP_ID, 'teams-settings-team-folders');
Util::addScript(Application::APP_ID, 'teams-settings-team-folders');
Expand Down
Loading
Loading