Page MenuHomePhorge

D5766.1774818325.diff
No OneTemporary

Authored By
Unknown
Size
91 KB
Referenced Files
None
Subscribers
None

D5766.1774818325.diff

diff --git a/config.demo/src/database/seeds/PackageSeeder.php b/config.demo/src/database/seeds/PackageSeeder.php
--- a/config.demo/src/database/seeds/PackageSeeder.php
+++ b/config.demo/src/database/seeds/PackageSeeder.php
@@ -19,6 +19,7 @@
$skuGroupware = Sku::where(['title' => 'groupware', 'tenant_id' => \config('app.tenant_id')])->first();
$skuMailbox = Sku::where(['title' => 'mailbox', 'tenant_id' => \config('app.tenant_id')])->first();
$skuStorage = Sku::where(['title' => 'storage', 'tenant_id' => \config('app.tenant_id')])->first();
+ $skuFsQuota = Sku::where(['title' => 'fs-quota', 'tenant_id' => \config('app.tenant_id')])->first();
$package = Package::create(
[
@@ -32,18 +33,16 @@
$skus = [
$skuMailbox,
$skuGroupware,
+ $skuFsQuota,
$skuStorage
];
$package->skus()->saveMany($skus);
- // This package contains 2 units of the storage SKU, which just so happens to also
+ // This package contains 5 units of the storage SKU, which just so happens to also
// be the number of SKU free units.
- $package->skus()->updateExistingPivot(
- $skuStorage,
- ['qty' => 5],
- false
- );
+ $package->skus()->updateExistingPivot($skuStorage, ['qty' => 5], false);
+ $package->skus()->updateExistingPivot($skuFsQuota, ['qty' => 5], false);
$package = Package::create(
[
@@ -56,16 +55,14 @@
$skus = [
$skuMailbox,
+ $skuFsQuota,
$skuStorage
];
$package->skus()->saveMany($skus);
- $package->skus()->updateExistingPivot(
- $skuStorage,
- ['qty' => 5],
- false
- );
+ $package->skus()->updateExistingPivot($skuStorage, ['qty' => 5], false);
+ $package->skus()->updateExistingPivot($skuFsQuota, ['qty' => 5], false);
$package = Package::create(
[
@@ -113,11 +110,7 @@
// This package contains 2 units of the storage SKU, which just so happens to also
// be the number of SKU free units.
- $package->skus()->updateExistingPivot(
- $skuStorage,
- ['qty' => 5],
- false
- );
+ $package->skus()->updateExistingPivot($skuStorage,['qty' => 5], false);
$package = Package::create(
[
@@ -138,11 +131,7 @@
$package->skus()->saveMany($skus);
- $package->skus()->updateExistingPivot(
- $skuStorage,
- ['qty' => 5],
- false
- );
+ $package->skus()->updateExistingPivot($skuStorage, ['qty' => 5], false);
$package = Package::create(
[
diff --git a/config.demo/src/database/seeds/SkuSeeder.php b/config.demo/src/database/seeds/SkuSeeder.php
--- a/config.demo/src/database/seeds/SkuSeeder.php
+++ b/config.demo/src/database/seeds/SkuSeeder.php
@@ -72,6 +72,16 @@
'handler_class' => 'App\Handlers\Storage',
'active' => true,
],
+ [
+ 'title' => 'fs-quota',
+ 'name' => 'File Storage Quota',
+ 'description' => 'Some wiggle room for files',
+ 'cost' => 25,
+ 'units_free' => 5,
+ 'period' => 'monthly',
+ 'handler_class' => 'App\Handlers\FsQuota',
+ 'active' => true,
+ ],
[
'title' => 'groupware',
'name' => 'Groupware Features',
diff --git a/src/app/Console/Commands/Fs/QuotaMigrateCommand.php b/src/app/Console/Commands/Fs/QuotaMigrateCommand.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/Fs/QuotaMigrateCommand.php
@@ -0,0 +1,103 @@
+<?php
+
+namespace App\Console\Commands\Fs;
+
+use App\Console\Command;
+use App\Entitlement;
+use App\Fs\Quota;
+use App\Handlers\FsQuota;
+use App\Package;
+use App\Sku;
+use App\User;
+
+class QuotaMigrateCommand extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'fs:quota-migrate';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = 'Introduce files quota SKU';
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ if (Sku::where('handler_class', FsQuota::class)->exists()) {
+ return;
+ }
+
+ $def = [
+ 'title' => 'fs-quota',
+ 'name' => 'File Storage Quota',
+ 'description' => 'Some wiggle room for files',
+ 'cost' => 25,
+ 'units_free' => 5,
+ 'fee' => 0,
+ 'period' => 'monthly',
+ 'handler_class' => FsQuota::class,
+ 'active' => true,
+ ];
+
+ foreach (\App\Tenant::pluck('id')->all() as $tenant_id) {
+ // Rename the existing quota SKU from "Storage Quota" to "Mail Storage Quota"
+ Sku::where('title', 'storage')->where('tenant_id', $tenant_id)
+ ->update(['name' => '{"en":"Mail Storage Quota"}']);
+
+ // Create files quota SKU
+ $sku = new Sku($def);
+ $sku->tenant_id = $tenant_id;
+ $sku->save();
+
+ // Add the SKU into packages
+ Package::whereIn('title', ['kolab', 'lite'])->where('tenant_id', $tenant_id)
+ ->each(function ($package) use ($sku) {
+ $package->skus()->save($sku);
+ $package->skus()->updateExistingPivot($sku, ['qty' => 5], false);
+ });
+
+ // Add 5 entitlements to every user (that already has any active entitlement)
+ $users = Entitlement::select('wallet_id', 'entitleable_id')->distinct()
+ ->join('users', 'users.id', '=', 'entitlements.entitleable_id')
+ ->where('entitleable_type', User::class)
+ ->where('tenant_id', $tenant_id)
+ ->pluck('wallet_id', 'entitleable_id');
+
+ $bar = $this->createProgressBar($users->count(), "Creating entitlements");
+
+ foreach ($users as $user_id => $wallet_id) {
+ $bar->advance();
+
+ $i = 5;
+ while ($i-- > 0) {
+ Entitlement::create([
+ 'wallet_id' => $wallet_id,
+ 'sku_id' => $sku->id,
+ 'entitleable_id' => $user_id,
+ 'entitleable_type' => User::class,
+ 'cost' => 0,
+ 'fee' => 0,
+ ]);
+ }
+ }
+
+ $bar->finish();
+
+ $this->info("DONE");
+
+ // Note: There might be users that already have more than 5GB (?)
+ // For them we could add additional entitlements, but then we should make it free
+ // until it's a beta feature. For now just do nothing.
+ }
+ }
+}
diff --git a/src/app/Console/Commands/Fs/QuotaUsageCommand.php b/src/app/Console/Commands/Fs/QuotaUsageCommand.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/Fs/QuotaUsageCommand.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace App\Console\Commands\Fs;
+
+use App\Console\Command;
+use App\Fs\Quota;
+
+class QuotaUsageCommand extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'fs:quota-usage';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = 'Display quota usage of the file storage for all users';
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ foreach (Quota::stats() as $email => $quota) {
+ $usage = str_replace(' ', '', Quota::bytes($quota['usage']));
+ $limit = (int) ($quota['limit'] / 1024 / 1024 / 1024) . 'GB';
+ $this->info("{$email} {$usage} of {$limit}");
+ }
+ }
+}
diff --git a/src/app/Fs/Item.php b/src/app/Fs/Item.php
--- a/src/app/Fs/Item.php
+++ b/src/app/Fs/Item.php
@@ -5,8 +5,10 @@
use App\Backends\Storage;
use App\Traits\BelongsToUserTrait;
use App\Traits\UuidStrKeyTrait;
+use App\User;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
@@ -315,6 +317,16 @@
return $this->belongsToMany(self::class, 'fs_relations', 'related_id', 'item_id');
}
+ /**
+ * The user the item belongs to.
+ *
+ * @return BelongsTo<User, $this>
+ */
+ public function user()
+ {
+ return $this->belongsTo(User::class);
+ }
+
/**
* Item type mutator
*
diff --git a/src/app/Fs/Quota.php b/src/app/Fs/Quota.php
new file mode 100644
--- /dev/null
+++ b/src/app/Fs/Quota.php
@@ -0,0 +1,106 @@
+<?php
+
+namespace App\Fs;
+
+use App\Entitlement;
+use App\Handlers\FsQuota;
+use App\User;
+use Illuminate\Database\Query\JoinClause;
+use Illuminate\Support\Collection;
+
+/**
+ * A utility for storage quota.
+ */
+class Quota
+{
+ /**
+ * Returns size of an available space for a user (in bytes).
+ */
+ public static function available(User $user): int
+ {
+ $free = self::limit($user) - self::usage($user);
+
+ return max(0, $free);
+ }
+
+ /**
+ * Converts quota size bytes into human readable string with an appropriate size unit
+ */
+ public static function bytes(int $bytes): string
+ {
+ if ($bytes >= 1073741824) {
+ return sprintf('%.01f GB', $bytes / 1073741824);
+ }
+
+ if ($bytes >= 1048576) {
+ $ret = sprintf('%.01f MB', $bytes / 1048576);
+ return $ret == '1024.0 MB' ? '1.0 GB' : $ret;
+ }
+
+ if ($bytes >= 1024) {
+ $ret = sprintf('%.01f KB', $bytes / 1024);
+ return $ret == '1024.0 KB' ? '1.0 MB' : $ret;
+ }
+
+ return "{$bytes} B";
+ }
+
+ /**
+ * Returns current quota limit for a user (in bytes).
+ */
+ public static function limit(User $user): int
+ {
+ $count = $user->entitlements()
+ ->join('skus', 'skus.id', '=', 'sku_id')
+ ->where('handler_class', FsQuota::class)
+ ->count();
+
+ return $count * 1024 * 1024 * 1024;
+ }
+
+ /**
+ * Returns current quota usage for a user (in bytes).
+ */
+ public static function usage(User $user): int
+ {
+ return (int) $user->fsItems()
+ ->join('fs_chunks', 'fs_items.id', '=', 'fs_chunks.item_id')
+ ->whereNot('type', '&', Item::TYPE_INCOMPLETE)
+ ->whereNull('fs_chunks.deleted_at')
+ ->sum('fs_chunks.size');
+ }
+
+ /**
+ * Returns current quota usage for all users.
+ *
+ * @return Collection<string, array{usage: int, limit: int}>
+ */
+ public static function stats(): Collection
+ {
+ $limits = Entitlement::selectRaw('entitleable_id as user_id, count(*) as cnt')
+ ->join('skus', 'skus.id', '=', 'sku_id')
+ ->where('handler_class', FsQuota::class)
+ ->groupBy('entitleable_id');
+
+ return User::query()
+ ->join('fs_items', 'fs_items.user_id', '=', 'users.id')
+ ->join('fs_chunks', 'fs_items.id', '=', 'fs_chunks.item_id')
+ ->joinSub($limits, 'limits', static function (JoinClause $join) {
+ $join->on('users.id', '=', 'limits.user_id');
+ })
+ ->whereNot('fs_items.type', '&', Item::TYPE_INCOMPLETE)
+ ->whereNull('fs_chunks.deleted_at')
+ ->selectRaw('users.email, sum(fs_chunks.size) as `usage`, sum(limits.cnt) as `quota`')
+ ->groupBy('users.email')
+ ->orderByDesc('usage')
+ ->get()
+ ->mapWithKeys(function ($item, int $key) {
+ return [
+ $item->email => [
+ 'usage' => (int) $item->usage, // @phpstan-ignore-line
+ 'limit' => (int) $item->quota * 1024 * 1024 * 1024, // @phpstan-ignore-line
+ ],
+ ];
+ });
+ }
+}
diff --git a/src/app/Handlers/Base.php b/src/app/Handlers/Base.php
--- a/src/app/Handlers/Base.php
+++ b/src/app/Handlers/Base.php
@@ -53,6 +53,8 @@
public static function metadata(Sku $sku): array
{
return [
+ // Inactive handlers will not appear on the subscriptions list
+ 'active' => true,
// entitleable type
'type' => \lcfirst(\class_basename(static::entitleableClass())),
// handler
diff --git a/src/app/Handlers/Storage.php b/src/app/Handlers/FsQuota.php
copy from src/app/Handlers/Storage.php
copy to src/app/Handlers/FsQuota.php
--- a/src/app/Handlers/Storage.php
+++ b/src/app/Handlers/FsQuota.php
@@ -2,12 +2,10 @@
namespace App\Handlers;
-use App\Entitlement;
-use App\Jobs\User\UpdateJob;
use App\Sku;
use App\User;
-class Storage extends Base
+class FsQuota extends Base
{
public const MAX_ITEMS = 100;
public const ITEM_UNIT = 'GB';
@@ -20,24 +18,6 @@
return User::class;
}
- /**
- * Handle entitlement creation event.
- */
- public static function entitlementCreated(Entitlement $entitlement): void
- {
- // Update the user IMAP mailbox quota
- UpdateJob::dispatch($entitlement->entitleable_id);
- }
-
- /**
- * Handle entitlement deletion event.
- */
- public static function entitlementDeleted(Entitlement $entitlement): void
- {
- // Update the user IMAP mailbox quota
- UpdateJob::dispatch($entitlement->entitleable_id);
- }
-
/**
* SKU handler metadata.
*/
@@ -45,8 +25,10 @@
{
$data = parent::metadata($sku);
+ $data['required'] = ['Beta'];
+ $data['active'] = \config('app.with_files');
$data['readonly'] = true; // only the checkbox will be disabled, not range
- $data['enabled'] = true;
+ $data['enabled'] = true; // default checkbox state
$data['range'] = [
'min' => $sku->units_free,
'max' => self::MAX_ITEMS,
@@ -62,6 +44,6 @@
*/
public static function priority(): int
{
- return 90;
+ return 0;
}
}
diff --git a/src/app/Handlers/Storage.php b/src/app/Handlers/Storage.php
--- a/src/app/Handlers/Storage.php
+++ b/src/app/Handlers/Storage.php
@@ -62,6 +62,6 @@
*/
public static function priority(): int
{
- return 90;
+ return 5;
}
}
diff --git a/src/app/Http/Controllers/API/V4/FsController.php b/src/app/Http/Controllers/API/V4/FsController.php
--- a/src/app/Http/Controllers/API/V4/FsController.php
+++ b/src/app/Http/Controllers/API/V4/FsController.php
@@ -4,11 +4,13 @@
use App\Fs\Item;
use App\Fs\Property;
+use App\Fs\Quota;
use App\Http\Controllers\RelationController;
use App\Http\Resources\FsItemInfoResource;
use App\Http\Resources\FsItemResource;
use App\Rules\FileName;
use App\Support\Facades\Storage;
+use App\User;
use App\Utils;
use Dedoc\Scramble\Attributes\QueryParameter;
use Illuminate\Http\JsonResponse;
@@ -407,6 +409,10 @@
}
}
+ if ($this->isOverQuota($this->guard()->user())) {
+ return $this->errorResponse(507, self::trans('validation.overquota'));
+ }
+
DB::beginTransaction();
$file = $this->deduplicateOrCreate($request, Item::TYPE_INCOMPLETE | Item::TYPE_FILE);
@@ -551,6 +557,8 @@
* @param string $id Upload (not file) identifier
*
* @return JsonResponse The response
+ *
+ * @unauthenticated
*/
public function upload(Request $request, $id)
{
@@ -576,6 +584,19 @@
return response()->json($response);
}
+ /**
+ * Check if user is over-quota.
+ */
+ protected function isOverQuota(User $user): bool
+ {
+ // On a file upload we don't know what is the input stream size. We should
+ // not trust Content-Length header (?), and fstat() does not return the size.
+ // It's much easier to check if user is over-quota early than check after
+ // every chunk has been saved into the disk (and if exceeded revert everything).
+ // This means we're relaxed about users being able to exceed the quota slightly.
+ return Quota::available($user) === 0;
+ }
+
/**
* Create a new collection.
*
diff --git a/src/app/Http/Controllers/API/V4/SkusController.php b/src/app/Http/Controllers/API/V4/SkusController.php
--- a/src/app/Http/Controllers/API/V4/SkusController.php
+++ b/src/app/Http/Controllers/API/V4/SkusController.php
@@ -2,6 +2,7 @@
namespace App\Http\Controllers\API\V4;
+use App\Handlers\FsQuota;
use App\Handlers\Mailbox;
use App\Http\Controllers\ResourceController;
use App\Http\Resources\SkuResource;
@@ -26,10 +27,10 @@
$list = Sku::withSubjectTenantContext()->where('active', true)->orderBy('title')
->get()
->transform(function ($sku, $type) {
- return $this->skuElement($sku, $type);
+ return $this->skuElement($sku, !empty($type));
})
- ->filter(static function ($sku) use ($type) {
- return $sku && (!$type || $sku->metadata['type'] === $type);
+ ->filter(static function ($resource) use ($type) {
+ return $resource && (!$type || $resource->metadata['type'] === $type);
})
->sortByDesc('prio')
->values();
@@ -59,11 +60,13 @@
&& $object::class == $sku->handler_class::entitleableClass()
&& $sku->handler_class::isAvailable($sku, $object);
})
- ->transform(function ($sku) {
+ ->transform(static function ($sku) {
return self::skuElement($sku);
})
- ->filter(static function ($sku) use ($user) {
- return !empty($sku) && (empty($sku->controllerOnly) || $user->wallet()->isController($user));
+ ->filter(static function ($resource) use ($user) {
+ return !empty($resource) && (empty($resource->controllerOnly) || $user->wallet()->isController($user))
+ // For now we hide FsQuota SKU for users without the beta entitlement
+ && ($resource->resource->handler_class != FsQuota::class || $user->hasSku('beta'));
})
->sortByDesc('prio')
->values();
@@ -136,10 +139,10 @@
* Convert SKU information to metadata used by UI to
* display the form control
*
- * @param Sku $sku SKU object
- * @param string $type Type filter
+ * @param Sku $sku SKU object
+ * @param bool $ext Use extended resource
*/
- protected static function skuElement($sku, $type = null): ?SkuResource
+ protected static function skuElement($sku, $ext = false): ?SkuResource
{
if (!class_exists($sku->handler_class)) {
\Log::warning("Missing handler {$sku->handler_class}");
@@ -148,13 +151,18 @@
$resource = new SkuResource($sku);
+ // ignore inactive handlers
+ if (empty($resource->metadata['active'])) {
+ return null;
+ }
+
// ignore incomplete handlers
if (empty($resource->metadata['type'])) {
\Log::warning("Incomplete handler {$sku->handler_class}");
return null;
}
- $resource->type = $type;
+ $resource->ext = $ext;
return $resource;
}
diff --git a/src/app/Http/Controllers/DAVController.php b/src/app/Http/Controllers/DAVController.php
--- a/src/app/Http/Controllers/DAVController.php
+++ b/src/app/Http/Controllers/DAVController.php
@@ -44,6 +44,8 @@
$auth_backend = new DAV\Auth();
$locks_backend = new DAV\Locks();
+ $auth_backend->setRealm(\config('app.name') . '/DAV');
+
// Initialize the Sabre DAV Server
$server = new Server(new DAV\Collection(''), $sapi);
$server->setBaseUri('/' . $root . '/user/' . $email);
diff --git a/src/app/Http/DAV/Auth.php b/src/app/Http/DAV/Auth.php
--- a/src/app/Http/DAV/Auth.php
+++ b/src/app/Http/DAV/Auth.php
@@ -10,25 +10,12 @@
*/
class Auth extends AbstractBasic
{
- // Make the current user available to all classes
- public static $user;
-
- /**
- * Authentication Realm.
- *
- * The realm is often displayed by browser clients when showing the
- * authentication dialog.
- *
- * @var string
- */
- protected $realm = 'Kolab/DAV';
-
/**
* This is the prefix that will be used to generate principal urls.
*
* @var string
*/
- protected $principalPrefix = 'dav/principals/';
+ protected $principalPrefix = 'dav/principals/user/';
/**
* Validates a username and password
@@ -44,13 +31,12 @@
// Note: For now authenticating user must match the path user
if (str_contains($username, '@') && $username === $this->getPathUser()) {
- $auth = User::findAndAuthenticate($username, $password);
-
- if (!empty($auth['user'])) {
- self::$user = $auth['user'];
-
- // Cyrus DAV principal location
- $this->principalPrefix = 'dav/principals/user/' . $username;
+ $res = User::findAndAuthenticate($username, $password);
+ if (isset($res['user'])) {
+ // This might be a bit hacky, but allows as to assign the authenticated
+ // user with the request. Other option would be to use a custom Laravel
+ // auth guard instead of Sabre's auth plugin.
+ \request()->setUserResolver(fn ($guard) => $res['user']);
return true;
}
}
@@ -69,4 +55,12 @@
return rawurldecode(explode('/', $path)[0]);
}
+
+ /**
+ * Get authenticated user for the current request
+ */
+ public static function user(): ?User
+ {
+ return \request()->user();
+ }
}
diff --git a/src/app/Http/DAV/Collection.php b/src/app/Http/DAV/Collection.php
--- a/src/app/Http/DAV/Collection.php
+++ b/src/app/Http/DAV/Collection.php
@@ -4,6 +4,8 @@
use App\Backends\Storage;
use App\Fs\Item;
+use App\Fs\Quota;
+use Illuminate\Support\Facades\Context;
use Illuminate\Support\Facades\DB;
use Sabre\DAV\Exception;
use Sabre\DAV\ICollection;
@@ -13,13 +15,14 @@
use Sabre\DAV\INode;
use Sabre\DAV\INodeByPath;
use Sabre\DAV\IProperties;
+use Sabre\DAV\IQuota;
use Sabre\DAV\MkCol;
use Sabre\DAV\Xml\Property\ResourceType;
/**
* Sabre DAV Collection interface implemetation
*/
-class Collection extends Node implements ICollection, ICopyTarget, IExtendedCollection, IMoveTarget, INodeByPath, IProperties
+class Collection extends Node implements ICollection, ICopyTarget, IExtendedCollection, IMoveTarget, INodeByPath, IProperties, IQuota
{
/**
* Checks if a child-node exists.
@@ -70,6 +73,8 @@
\Log::debug("[DAV] COPY-INTO: {$sourcePath} > {$path}, Depth:{$depth}");
+ $this->quotaCheck();
+
$item = $sourceNode->fsItem(); // @phpstan-ignore-line
$item->copy($this->data, $targetName, $depth);
@@ -160,9 +165,11 @@
throw new Exception\Forbidden('Hidden files are not accepted');
}
+ $this->quotaCheck();
+
DB::beginTransaction();
- $file = Auth::$user->fsItems()->create(['type' => Item::TYPE_FILE]);
+ $file = Auth::user()->fsItems()->create(['type' => Item::TYPE_FILE]);
$file->setProperty('name', $name);
if ($parent = $this->data) {
@@ -199,7 +206,7 @@
DB::beginTransaction();
- $collection = Auth::$user->fsItems()->create(['type' => Item::TYPE_COLLECTION]);
+ $collection = Auth::user()->fsItems()->create(['type' => Item::TYPE_COLLECTION]);
$collection->setProperty('name', $name);
if ($parent = $this->data) {
@@ -252,7 +259,7 @@
{
\Log::debug('[DAV] GET-CHILDREN: ' . $this->path);
- $query = Auth::$user->fsItems()
+ $query = Auth::user()->fsItems()
->select('fs_items.*')
->whereNot('type', '&', Item::TYPE_INCOMPLETE);
@@ -366,6 +373,33 @@
return $result;
}
+ /**
+ * Returns the quota information.
+ *
+ * This method MUST return an array with 2 values, the first being the total used space,
+ * the second the available space (in bytes)
+ */
+ public function getQuotaInfo()
+ {
+ \Log::debug("[DAV] GET-QUOTA-INFO: {$this->path}");
+
+ // This method will be called for every collection in a PROPFIND response
+ // when quota-used-bytes or quota-available-bytes was requested.
+ // So, we have to optimize this to be done only once per request.
+ $quota = Context::getHidden('quota');
+
+ if (empty($quota)) {
+ $user = Auth::user();
+ $limit = Quota::limit($user);
+ $used = Quota::usage($user);
+ $quota = [$used, max(0, $limit - $used)];
+
+ Context::addHidden('quota', $quota);
+ }
+
+ return $quota;
+ }
+
/**
* Moves a node into this collection.
*
diff --git a/src/app/Http/DAV/File.php b/src/app/Http/DAV/File.php
--- a/src/app/Http/DAV/File.php
+++ b/src/app/Http/DAV/File.php
@@ -209,8 +209,6 @@
{
\Log::debug('[DAV] PUT: ' . $this->path);
- // TODO: fileInput() method creates a non-chunked file, we need another way.
-
$result = Storage::fileInput($data, [], $this->data);
// Refresh the internal state for getETag()
diff --git a/src/app/Http/DAV/Node.php b/src/app/Http/DAV/Node.php
--- a/src/app/Http/DAV/Node.php
+++ b/src/app/Http/DAV/Node.php
@@ -3,6 +3,7 @@
namespace App\Http\DAV;
use App\Fs\Item;
+use App\Fs\Quota;
use Illuminate\Support\Facades\Context;
use Sabre\DAV\Exception;
use Sabre\DAV\INode;
@@ -30,7 +31,7 @@
*/
public function __construct($path, $parent = null, $data = null)
{
- $root = trim(\config('services.dav.webdav_root'), '/') . '/user/' . Auth::$user?->email;
+ $root = trim(\config('services.dav.webdav_root'), '/') . '/user/' . Auth::user()?->email;
if ($path === $root) {
$path = '';
@@ -196,7 +197,7 @@
$item = self::getCachedItem($item_path);
if ($item === null) {
- $query = Auth::$user->fsItems()->select('fs_items.*', 'fs_properties.value as name')
+ $query = Auth::user()->fsItems()->select('fs_items.*', 'fs_properties.value as name')
->join('fs_properties', 'fs_items.id', '=', 'fs_properties.item_id')
->whereNot('type', '&', Item::TYPE_INCOMPLETE)
->where('key', 'name')
@@ -272,6 +273,18 @@
Context::addHidden('fs:' . $path, $item);
}
+ /**
+ * Check if there's any space left
+ *
+ * @throws Exception\InsufficientStorage
+ */
+ protected static function quotaCheck(): void
+ {
+ if (!Quota::available(Auth::user())) {
+ throw new Exception\InsufficientStorage();
+ }
+ }
+
/**
* Convert an array into XML property understood by the Sabre XML writer
*/
diff --git a/src/app/Http/Resources/SkuResource.php b/src/app/Http/Resources/SkuResource.php
--- a/src/app/Http/Resources/SkuResource.php
+++ b/src/app/Http/Resources/SkuResource.php
@@ -14,7 +14,7 @@
class SkuResource extends ApiResource
{
public bool $controllerOnly = false;
- public ?string $type = null;
+ public bool $ext = false;
public int $prio = 0;
public array $metadata = [];
@@ -75,7 +75,7 @@
'range' => $this->when(isset($this->metadata['range']), $this->metadata['range'] ?? []),
// Cost for a new object of the specified type
- 'nextCost' => $this->when(!empty($this->type), fn () => $this->nextCost()),
+ 'nextCost' => $this->when($this->ext, fn () => $this->nextCost()),
];
}
diff --git a/src/resources/js/files.js b/src/resources/js/files.js
--- a/src/resources/js/files.js
+++ b/src/resources/js/files.js
@@ -35,7 +35,7 @@
}
}
- // Handler for both a ondrop event and file input onchange event
+ // Handler for both an ondrop event and file input onchange event
const fileDropHandler = (event) => {
let files = event.target.files || event.dataTransfer.files
@@ -46,6 +46,8 @@
// Prevent default behavior (prevent file from being opened on drop)
event.preventDefault();
+ let count = 0;
+
// TODO: Check file size limit, limit number of files to upload at once?
// For every file...
@@ -54,9 +56,11 @@
id: Date.now(),
name: file.name,
total: file.size,
- completed: 0
+ completed: 0,
+ done: false
}
+ count++
file.uploaded = 0
// Upload request configuration
@@ -81,11 +85,6 @@
transformRequest: [] // disable body transformation
}
- // FIXME: It might be better to handle multiple-files upload as a one
- // "progress source", i.e. we might want to refresh the files list once
- // all files finished uploading, not multiple times in the middle
- // of the upload process.
-
params.eventHandler('upload-progress', progress)
// A "recursive" function to upload the file in chunks (if needed)
@@ -131,18 +130,22 @@
file.uploaded = start
uploadFn(start, uploadId || response.data.uploadId)
} else {
+ count--
progress.completed = 100
+ progress.done = count == 0
params.eventHandler('upload-progress', progress)
}
})
.catch(error => {
- console.log(error)
+ // console.log(error)
// TODO: Depending on the error consider retrying the request
// if it was one of many chunks of a bigger file?
+ count--
progress.error = error
progress.completed = 100
+ progress.done = count == 0
params.eventHandler('upload-progress', progress)
})
}
diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php
--- a/src/resources/lang/en/validation.php
+++ b/src/resources/lang/en/validation.php
@@ -180,6 +180,7 @@
'referralcodeinvalid' => 'The referral program code is invalid.',
'signuptokeninvalid' => 'The signup token is invalid.',
'verificationcodeinvalid' => 'The verification code is invalid or expired.',
+ 'overquota' => 'The storage quota has been exceeded.',
/*
|--------------------------------------------------------------------------
diff --git a/src/resources/vue/File/List.vue b/src/resources/vue/File/List.vue
--- a/src/resources/vue/File/List.vue
+++ b/src/resources/vue/File/List.vue
@@ -94,6 +94,9 @@
text: 'lines',
}
+ let listLoading = false
+ let listParams = null
+
export default {
components: {
ModalDialog
@@ -209,7 +212,25 @@
params['parent'] = this.collectionId
}
- this.listSearch('files', 'api/v4/fs', params)
+ if (listLoading) {
+ listParams = params
+ return
+ }
+
+ listLoading = true
+ listParams = null
+
+ const request = this.listSearch('files', 'api/v4/fs', params)
+
+ request.finally(() => {
+ listLoading = false
+
+ if (listParams) {
+ this.$nextTick().then(() => {
+ this.loadFiles(listParams)
+ })
+ }
+ })
},
searchFiles(search) {
this.loadFiles({ reset: true, search })
@@ -231,8 +252,14 @@
this.uploads[params.id].delete() // close the toast message
delete this.uploads[params.id]
- // TODO: Reloading the list is probably not the best solution
- this.loadFiles({ reset: true })
+ // Display over-quota error
+ if (params.error && params.error.status == 507) {
+ this.$toast.error(params.error.response.data.message)
+ }
+
+ if (params.done) {
+ this.loadFiles({ reset: true })
+ }
} else {
// update progress bar
this.uploads[params.id].updateProgress(params.completed)
diff --git a/src/resources/vue/Widgets/ListTools.vue b/src/resources/vue/Widgets/ListTools.vue
--- a/src/resources/vue/Widgets/ListTools.vue
+++ b/src/resources/vue/Widgets/ListTools.vue
@@ -3,7 +3,6 @@
</template>
<script>
-
export const ListSearch = {
props: {
onSearch: { type: Function, default: () => {} },
@@ -145,7 +144,7 @@
this.currentParent = null
}
- axios.get(url, { params: get, loader })
+ return axios.get(url, { params: get, loader })
.then(response => {
// Note: In Vue we can't just use .concat()
for (let i in response.data.list) {
diff --git a/src/resources/vue/Widgets/SubscriptionSelect.vue b/src/resources/vue/Widgets/SubscriptionSelect.vue
--- a/src/resources/vue/Widgets/SubscriptionSelect.vue
+++ b/src/resources/vue/Widgets/SubscriptionSelect.vue
@@ -180,7 +180,7 @@
// Uncheck all dependent SKUs, e.g. when unchecking Groupware we also uncheck Activesync
// TODO: Should we display an alert instead?
this.skus.forEach(item => {
- if (item.required && item.required.indexOf(sku.handler) > -1) {
+ if (!item.readonly && item.required && item.required.indexOf(sku.handler) > -1) {
$('#s' + item.id).find('input[type=checkbox]').prop('checked', false)
}
})
diff --git a/src/tests/Browser/Admin/UserTest.php b/src/tests/Browser/Admin/UserTest.php
--- a/src/tests/Browser/Admin/UserTest.php
+++ b/src/tests/Browser/Admin/UserTest.php
@@ -157,10 +157,10 @@
$browser->assertElementsCount('table tbody tr', 3)
->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox')
->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '5,00 CHF')
- ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 5 GB')
- ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF')
- ->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features')
- ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,90 CHF')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Groupware Features')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '4,90 CHF')
+ ->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Storage Quota 5 GB')
+ ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '0,00 CHF')
->assertMissing('table tfoot')
->assertMissing('#reset2fa');
});
@@ -323,10 +323,10 @@
$browser->assertElementsCount('table tbody tr', 3)
->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox')
->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '4,50 CHF/month¹')
- ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 5 GB')
- ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹')
- ->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features')
- ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,41 CHF/month¹')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Groupware Features')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '4,41 CHF/month¹')
+ ->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Storage Quota 5 GB')
+ ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '0,00 CHF/month¹')
->assertMissing('table tfoot')
->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher');
});
@@ -484,16 +484,16 @@
$browser->assertElementsCount('table tbody tr', 6)
->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox')
->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '4,50 CHF/month¹')
- ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 6 GB')
- ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '45,00 CHF/month¹')
- ->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features')
- ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,41 CHF/month¹')
- ->assertSeeIn('table tbody tr:nth-child(4) td:first-child', 'Activesync')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Groupware Features')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '4,41 CHF/month¹')
+ ->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Activesync')
+ ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '0,00 CHF/month¹')
+ ->assertSeeIn('table tbody tr:nth-child(4) td:first-child', '2-Factor Authentication')
->assertSeeIn('table tbody tr:nth-child(4) td:last-child', '0,00 CHF/month¹')
- ->assertSeeIn('table tbody tr:nth-child(5) td:first-child', '2-Factor Authentication')
- ->assertSeeIn('table tbody tr:nth-child(5) td:last-child', '0,00 CHF/month¹')
- ->assertSeeIn('table tbody tr:nth-child(6) td:first-child', 'Private Beta (invitation only)')
- ->assertSeeIn('table tbody tr:nth-child(6) td:last-child', '45,09 CHF/month¹')
+ ->assertSeeIn('table tbody tr:nth-child(5) td:first-child', 'Private Beta (invitation only)')
+ ->assertSeeIn('table tbody tr:nth-child(5) td:last-child', '45,09 CHF/month¹')
+ ->assertSeeIn('table tbody tr:nth-child(6) td:first-child', 'Storage Quota 6 GB')
+ ->assertSeeIn('table tbody tr:nth-child(6) td:last-child', '45,00 CHF/month¹')
->assertMissing('table tfoot')
->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher')
->assertSeeIn('#reset2fa', 'Reset 2-Factor Auth')
diff --git a/src/tests/Browser/Reseller/UserTest.php b/src/tests/Browser/Reseller/UserTest.php
--- a/src/tests/Browser/Reseller/UserTest.php
+++ b/src/tests/Browser/Reseller/UserTest.php
@@ -138,10 +138,10 @@
$browser->assertElementsCount('table tbody tr', 3)
->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox')
->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '5,00 CHF/month')
- ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 5 GB')
- ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month')
- ->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features')
- ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,90 CHF/month')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Groupware Features')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '4,90 CHF/month')
+ ->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Storage Quota 5 GB')
+ ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '0,00 CHF/month')
->assertMissing('table tfoot')
->assertMissing('#reset2fa');
});
@@ -272,10 +272,10 @@
$browser->assertElementsCount('table tbody tr', 3)
->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox')
->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '4,50 CHF/month¹')
- ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 5 GB')
- ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹')
- ->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features')
- ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,41 CHF/month¹')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Groupware Features')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '4,41 CHF/month¹')
+ ->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Storage Quota 5 GB')
+ ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '0,00 CHF/month¹')
->assertMissing('table tfoot')
->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher');
});
@@ -397,13 +397,13 @@
$browser->assertElementsCount('table tbody tr', 5)
->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox')
->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '4,50 CHF/month¹')
- ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 5 GB')
- ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹')
- ->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features')
- ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,41 CHF/month¹')
- ->assertSeeIn('table tbody tr:nth-child(4) td:first-child', 'Activesync')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Groupware Features')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '4,41 CHF/month¹')
+ ->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Activesync')
+ ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '0,00 CHF/month¹')
+ ->assertSeeIn('table tbody tr:nth-child(4) td:first-child', '2-Factor Authentication')
->assertSeeIn('table tbody tr:nth-child(4) td:last-child', '0,00 CHF/month¹')
- ->assertSeeIn('table tbody tr:nth-child(5) td:first-child', '2-Factor Authentication')
+ ->assertSeeIn('table tbody tr:nth-child(5) td:first-child', 'Storage Quota 5 GB')
->assertSeeIn('table tbody tr:nth-child(5) td:last-child', '0,00 CHF/month¹')
->assertMissing('table tfoot')
->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher')
diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php
--- a/src/tests/Browser/UsersTest.php
+++ b/src/tests/Browser/UsersTest.php
@@ -212,47 +212,48 @@
'tbody tr:nth-child(1) td.buttons button',
'Just a mailbox'
)
- // Storage SKU
- ->assertSeeIn('tbody tr:nth-child(2) td.name', 'Storage Quota')
- ->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month')
+ // Groupware SKU
+ ->assertSeeIn('tbody tr:nth-child(2) td.name', 'Groupware Features')
+ ->assertSeeIn('tbody tr:nth-child(2) td.price', '4,90 CHF/month')
->assertChecked('tbody tr:nth-child(2) td.selection input')
- ->assertDisabled('tbody tr:nth-child(2) td.selection input')
+ ->assertEnabled('tbody tr:nth-child(2) td.selection input')
->assertTip(
'tbody tr:nth-child(2) td.buttons button',
- 'Some wiggle room'
+ 'Groupware functions like Calendar, Tasks, Notes, etc.'
)
- ->with(new QuotaInput('tbody tr:nth-child(2) .range-input'), static function ($browser) {
- $browser->assertQuotaValue(5)->setQuotaValue(6);
- })
- ->assertSeeIn('tr:nth-child(2) td.price', '0,25 CHF/month')
- // Groupware SKU
- ->assertSeeIn('tbody tr:nth-child(3) td.name', 'Groupware Features')
- ->assertSeeIn('tbody tr:nth-child(3) td.price', '4,90 CHF/month')
- ->assertChecked('tbody tr:nth-child(3) td.selection input')
+ // ActiveSync SKU
+ ->assertSeeIn('tbody tr:nth-child(3) td.name', 'Activesync')
+ ->assertSeeIn('tbody tr:nth-child(3) td.price', '0,00 CHF/month')
+ ->assertNotChecked('tbody tr:nth-child(3) td.selection input')
->assertEnabled('tbody tr:nth-child(3) td.selection input')
->assertTip(
'tbody tr:nth-child(3) td.buttons button',
- 'Groupware functions like Calendar, Tasks, Notes, etc.'
+ 'Mobile synchronization'
)
- // ActiveSync SKU
- ->assertSeeIn('tbody tr:nth-child(4) td.name', 'Activesync')
+ // 2FA SKU
+ ->assertSeeIn('tbody tr:nth-child(4) td.name', '2-Factor Authentication')
->assertSeeIn('tbody tr:nth-child(4) td.price', '0,00 CHF/month')
->assertNotChecked('tbody tr:nth-child(4) td.selection input')
->assertEnabled('tbody tr:nth-child(4) td.selection input')
->assertTip(
'tbody tr:nth-child(4) td.buttons button',
- 'Mobile synchronization'
+ 'Two factor authentication for webmail and administration panel'
)
- // 2FA SKU
- ->assertSeeIn('tbody tr:nth-child(5) td.name', '2-Factor Authentication')
- ->assertSeeIn('tbody tr:nth-child(5) td.price', '0,00 CHF/month')
- ->assertNotChecked('tbody tr:nth-child(5) td.selection input')
- ->assertEnabled('tbody tr:nth-child(5) td.selection input')
+ // Storage SKU
+ ->assertSeeIn('tbody tr:nth-child(5) td.name', 'Storage Quota')
+ ->assertSeeIn('tr:nth-child(5) td.price', '0,00 CHF/month')
+ ->assertChecked('tbody tr:nth-child(5) td.selection input')
+ ->assertDisabled('tbody tr:nth-child(5) td.selection input')
->assertTip(
'tbody tr:nth-child(5) td.buttons button',
- 'Two factor authentication for webmail and administration panel'
+ 'Some wiggle room'
)
- ->click('tbody tr:nth-child(4) td.selection input');
+ ->with(new QuotaInput('tbody tr:nth-child(5) .range-input'), static function ($browser) {
+ $browser->assertQuotaValue(5)->setQuotaValue(6);
+ })
+ ->assertSeeIn('tr:nth-child(5) td.price', '0,25 CHF/month')
+ // enable Activesync
+ ->click('tbody tr:nth-child(3) td.selection input');
})
->assertMissing('@skus table + .hint')
->click('button[type=submit]')
@@ -262,8 +263,12 @@
->click('@table tr:nth-child(3) a')
->on(new UserInfo());
- $expected = ['activesync', 'groupware', 'mailbox',
- 'storage', 'storage', 'storage', 'storage', 'storage', 'storage'];
+ $expected = [
+ 'activesync',
+ 'fs-quota', 'fs-quota', 'fs-quota', 'fs-quota', 'fs-quota',
+ 'groupware', 'mailbox',
+ 'storage', 'storage', 'storage', 'storage', 'storage', 'storage',
+ ];
$this->assertEntitlements($john->fresh(), $expected);
// Test subscriptions interaction
@@ -612,7 +617,11 @@
$alias = UserAlias::where('user_id', $julia->id)->where('alias', 'julia.roberts2@kolab.org')->first();
$this->assertTrue(!empty($alias));
- $this->assertEntitlements($julia, ['mailbox', 'storage', 'storage', 'storage', 'storage', 'storage']);
+ $this->assertEntitlements($julia, [
+ 'fs-quota', 'fs-quota', 'fs-quota', 'fs-quota', 'fs-quota',
+ 'mailbox',
+ 'storage', 'storage', 'storage', 'storage', 'storage',
+ ]);
$this->assertSame('Julia', $julia->getSetting('first_name'));
$this->assertSame('Roberts', $julia->getSetting('last_name'));
$this->assertSame('Test Org', $julia->getSetting('organization'));
@@ -757,23 +766,23 @@
->on(new UserInfo())
->with('@general', static function (Browser $browser) {
$browser->whenAvailable('@skus', static function (Browser $browser) {
- $quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input');
+ $quota_input = new QuotaInput('tbody tr:nth-child(5) .range-input');
$browser->waitFor('tbody tr')
->assertElementsCount('tbody tr', 5)
// Mailbox SKU
->assertSeeIn('tbody tr:nth-child(1) td.price', '4,50 CHF/month¹')
// Storage SKU
- ->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month¹')
+ ->assertSeeIn('tr:nth-child(5) td.price', '0,00 CHF/month¹')
->with($quota_input, static function (Browser $browser) {
$browser->setQuotaValue(100);
})
- ->assertSeeIn('tr:nth-child(2) td.price', '21,37 CHF/month¹')
+ ->assertSeeIn('tr:nth-child(5) td.price', '21,37 CHF/month¹')
// Groupware SKU
- ->assertSeeIn('tbody tr:nth-child(3) td.price', '4,41 CHF/month¹')
+ ->assertSeeIn('tbody tr:nth-child(2) td.price', '4,41 CHF/month¹')
// ActiveSync SKU
- ->assertSeeIn('tbody tr:nth-child(4) td.price', '0,00 CHF/month¹')
+ ->assertSeeIn('tbody tr:nth-child(3) td.price', '0,00 CHF/month¹')
// 2FA SKU
- ->assertSeeIn('tbody tr:nth-child(5) td.price', '0,00 CHF/month¹');
+ ->assertSeeIn('tbody tr:nth-child(4) td.price', '0,00 CHF/month¹');
})
->assertSeeIn('@skus table + .hint', '¹ applied discount: 10% - Test voucher');
});
@@ -820,20 +829,20 @@
->on(new UserInfo())
->with('@general', static function (Browser $browser) {
$browser->whenAvailable('@skus', static function (Browser $browser) {
- $quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input');
+ $quota_input = new QuotaInput('tbody tr:nth-child(6) .range-input');
$browser->waitFor('tbody tr')
// Beta SKU
- ->assertSeeIn('tbody tr:nth-child(6) td.price', '45,09 CHF/month¹')
+ ->assertSeeIn('tbody tr:nth-child(5) td.price', '45,09 CHF/month¹')
// Storage SKU
- ->assertSeeIn('tr:nth-child(2) td.price', '45,00 CHF/month¹')
+ ->assertSeeIn('tr:nth-child(6) td.price', '45,00 CHF/month¹')
->with($quota_input, static function (Browser $browser) {
$browser->setQuotaValue(7);
})
- ->assertSeeIn('tr:nth-child(2) td.price', '45,22 CHF/month¹')
+ ->assertSeeIn('tr:nth-child(6) td.price', '45,22 CHF/month¹')
->with($quota_input, static function (Browser $browser) {
$browser->setQuotaValue(5);
})
- ->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month¹');
+ ->assertSeeIn('tr:nth-child(6) td.price', '0,00 CHF/month¹');
})
->assertSeeIn('@skus table + .hint', '¹ applied discount: 10% - Test voucher');
});
@@ -924,17 +933,17 @@
->on(new UserInfo())
->with('@general', static function (Browser $browser) {
$browser->whenAvailable('@skus', static function (Browser $browser) {
- $quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input');
+ $quota_input = new QuotaInput('tbody tr:nth-child(5) .range-input');
$browser->waitFor('tbody tr')
->assertElementsCount('tbody tr', 5)
// Mailbox SKU
->assertSeeIn('tbody tr:nth-child(1) td.price', '5,00 €/month')
// Storage SKU
- ->assertSeeIn('tr:nth-child(2) td.price', '0,00 €/month')
+ ->assertSeeIn('tr:nth-child(5) td.price', '0,00 €/month')
->with($quota_input, static function (Browser $browser) {
$browser->setQuotaValue(100);
})
- ->assertSeeIn('tr:nth-child(2) td.price', '23,75 €/month');
+ ->assertSeeIn('tr:nth-child(5) td.price', '23,75 €/month');
});
});
});
diff --git a/src/tests/Feature/Console/Data/Import/LdifTest.php b/src/tests/Feature/Console/Data/Import/LdifTest.php
--- a/src/tests/Feature/Console/Data/Import/LdifTest.php
+++ b/src/tests/Feature/Console/Data/Import/LdifTest.php
@@ -85,6 +85,7 @@
$wallet = $owner->wallets->first();
$this->assertEntitlements($owner, [
+ 'fs-quota', 'fs-quota', 'fs-quota', 'fs-quota', 'fs-quota',
'groupware',
'mailbox',
'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage',
@@ -107,6 +108,7 @@
$this->assertSame(['alias2@kolab3.com'], $aliases);
$this->assertEntitlements($users['user@kolab3.com'], [
+ 'fs-quota', 'fs-quota', 'fs-quota', 'fs-quota', 'fs-quota',
'groupware',
'mailbox',
'storage', 'storage', 'storage', 'storage', 'storage',
diff --git a/src/tests/Feature/Console/Fs/QuotaUsageTest.php b/src/tests/Feature/Console/Fs/QuotaUsageTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Console/Fs/QuotaUsageTest.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace Tests\Feature\Console\Fs;
+
+use Tests\TestCaseFs;
+
+class QuotaUsageTest extends TestCaseFs
+{
+ /**
+ * Test the command
+ */
+ public function testHandle(): void
+ {
+ $user = $this->getTestUser('john@kolab.org');
+
+ // Expect empty result
+ $code = \Artisan::call('fs:quota-usage');
+ $output = trim(\Artisan::output());
+ $this->assertSame(0, $code);
+ $this->assertSame('', $output);
+
+ $file = $this->getTestFile($user, 'test1.txt', '123');
+
+ $code = \Artisan::call('fs:quota-usage');
+ $output = trim(\Artisan::output());
+ $this->assertSame(0, $code);
+ $this->assertStringContainsString('john@kolab.org 3B of 5GB', $output);
+ $this->assertStringNotContainsString('ned@kolab.org', $output);
+ }
+}
diff --git a/src/tests/Feature/Console/User/ForceDeleteTest.php b/src/tests/Feature/Console/User/ForceDeleteTest.php
--- a/src/tests/Feature/Console/User/ForceDeleteTest.php
+++ b/src/tests/Feature/Console/User/ForceDeleteTest.php
@@ -50,9 +50,6 @@
$user->assignPackage($package_kolab);
$domain->assignPackage($package_domain, $user);
$wallet = $user->wallets()->first();
- $entitlements = $wallet->entitlements->pluck('id')->all();
-
- $this->assertCount(8, $entitlements);
// Non-deleted user
$this->artisan('user:force-delete user@force-delete.com')
diff --git a/src/tests/Feature/Console/User/RestoreTest.php b/src/tests/Feature/Console/User/RestoreTest.php
--- a/src/tests/Feature/Console/User/RestoreTest.php
+++ b/src/tests/Feature/Console/User/RestoreTest.php
@@ -49,9 +49,6 @@
$user->assignPackage($package_kolab);
$domain->assignPackage($package_domain, $user);
$wallet = $user->wallets()->first();
- $entitlements = $wallet->entitlements->pluck('id')->all();
-
- $this->assertCount(8, $entitlements);
// Non-deleted user
$code = \Artisan::call("user:restore {$user->email}");
diff --git a/src/tests/Feature/Controller/Admin/SkusTest.php b/src/tests/Feature/Controller/Admin/SkusTest.php
--- a/src/tests/Feature/Controller/Admin/SkusTest.php
+++ b/src/tests/Feature/Controller/Admin/SkusTest.php
@@ -74,7 +74,7 @@
$json = $response->json();
- $this->assertCount(12, $json['list']);
+ $this->assertCount(13, $json['list']);
$this->assertSame(100, $json['list'][0]['prio']);
$this->assertSame($sku->id, $json['list'][0]['id']);
$this->assertSame($sku->title, $json['list'][0]['title']);
@@ -109,7 +109,7 @@
$json = $response->json();
- $this->assertCount(5, $json['list']);
+ $this->assertCount(6, $json['list']);
// Note: Details are tested where we test API\V4\SkusController
}
}
diff --git a/src/tests/Feature/Controller/DAVTest.php b/src/tests/Feature/Controller/DAVTest.php
--- a/src/tests/Feature/Controller/DAVTest.php
+++ b/src/tests/Feature/Controller/DAVTest.php
@@ -133,6 +133,11 @@
$response = $this->davRequest('COPY', "{$root}/folderB", '', $john, ['Destination' => "{$host}/{$root}/folderB/folderX", 'Depth' => 'infinity']);
$response->assertStatus(409);
+
+ // Test over-quota condition
+ $this->turnUserOverQuota($john);
+ $response = $this->davRequest('COPY', "{$root}/folderA", '', $john, ['Destination' => "{$host}/{$root}/folderD", 'Depth' => '0']);
+ $response->assertStatus(507);
}
/**
@@ -681,7 +686,6 @@
$this->assertSame("/{$root}/", $responses[0]->getElementsByTagName('href')->item(0)->textContent);
$this->assertCount(1, $responses[0]->getElementsByTagName('resourcetype')->item(0)->childNodes);
$this->assertSame('collection', $responses[0]->getElementsByTagName('resourcetype')->item(0)->firstChild->localName);
- $this->assertCount(1, $responses[0]->getElementsByTagName('prop')->item(0)->childNodes);
$this->assertStringContainsString('200 OK', $responses[0]->getElementsByTagName('status')->item(0)->textContent);
// the subfolder folder
@@ -784,6 +788,49 @@
$this->assertSame('notebook', $responses[0]->getElementsByTagName('resourcetype')->item(0)->childNodes->item(1)->localName);
}
+ /**
+ * Test PROPFIND requests with quota properties
+ */
+ public function testPropfindQuota(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $root = trim(\config('services.dav.webdav_root'), '/') . '/user/john@kolab.org';
+
+ [$folders, $files] = $this->initTestStorage($john);
+
+ // Test requesting quota properties on the root
+ $xml = '<d:propfind xmlns:d="DAV:"><d:prop><d:quota-used-bytes/><d:quota-available-bytes/></d:prop></d:propfind>';
+ $response = $this->davRequest('PROPFIND', $root, $xml, $john, ['Depth' => 0]);
+ $response->assertStatus(207);
+
+ $doc = $this->responseXML($response);
+
+ $this->assertSame('multistatus', $doc->documentElement->localName);
+ $this->assertCount(1, $responses = $doc->documentElement->getElementsByTagName('response'));
+ $this->assertSame('64', $responses[0]->getElementsByTagName('quota-used-bytes')->item(0)->textContent);
+ $this->assertSame(
+ (string) (5 * 1024 * 1024 * 1024 - 64),
+ $responses[0]->getElementsByTagName('quota-available-bytes')->item(0)->textContent
+ );
+
+ // Test with '<allprop>'
+ // It should not include quota properties according to the RFC, but Sabre does otherwise
+ // See https://github.com/sabre-io/dav/issues/1616
+ /*
+ $xml = '<d:propfind xmlns:d="DAV:"><d:allprop/></d:propfind>';
+ $response = $this->davRequest('PROPFIND', $root, $xml, $john, ['Depth' => 0]);
+ $response->assertStatus(207);
+
+ $doc = $this->responseXML($response);
+
+ $this->assertSame('multistatus', $doc->documentElement->localName);
+ $this->assertCount(1, $responses = $doc->documentElement->getElementsByTagName('response'));
+ $this->assertCount(0, $responses[0]->getElementsByTagName('quota-used-bytes'));
+ $this->assertCount(0, $responses[0]->getElementsByTagName('quota-available-bytes'));
+ */
+ $this->markTestIncomplete();
+ }
+
/**
* Test basic PUT requests
*/
@@ -853,6 +900,11 @@
$this->assertSame('4', $files[0]->getProperty('size'));
$this->assertSame('text/plain', $files[0]->getProperty('mimetype'));
$this->assertSame('Test', $this->getTestFileContent($files[0]));
+
+ // Test over-quota condition
+ $this->turnUserOverQuota($john);
+ $response = $this->davRequest('PUT', "{$root}/folder1/test1234.txt", 'Test', $john, ['Content-Type' => 'text/plain']);
+ $response->assertNoContent(507);
}
/**
diff --git a/src/tests/Feature/Controller/FsTest.php b/src/tests/Feature/Controller/FsTest.php
--- a/src/tests/Feature/Controller/FsTest.php
+++ b/src/tests/Feature/Controller/FsTest.php
@@ -538,6 +538,16 @@
$this->assertSame($parent->id, $file->parents()->first()->id);
// TODO: Test X-Kolab-Parents
+
+ // Test over-quota condition (John has 5GB quota)
+ $this->turnUserOverQuota($john);
+ $response = $this->sendRawBody($john, 'POST', "api/v4/fs?name=test1.txt", $headers, $body);
+ $response->assertStatus(507);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame("The storage quota has been exceeded.", $json['message']);
}
/**
diff --git a/src/tests/Feature/Controller/Reseller/SkusTest.php b/src/tests/Feature/Controller/Reseller/SkusTest.php
--- a/src/tests/Feature/Controller/Reseller/SkusTest.php
+++ b/src/tests/Feature/Controller/Reseller/SkusTest.php
@@ -91,8 +91,7 @@
$json = $response->json();
- $this->assertCount(12, $json['list']);
-
+ $this->assertCount(13, $json['list']);
$this->assertSame(100, $json['list'][0]['prio']);
$this->assertSame($sku->id, $json['list'][0]['id']);
$this->assertSame($sku->title, $json['list'][0]['title']);
@@ -159,7 +158,7 @@
$json = $response->json();
- $this->assertCount(5, $json['list']);
+ $this->assertCount(6, $json['list']);
// Note: Details are tested where we test API\V4\SkusController
}
}
diff --git a/src/tests/Feature/Controller/SkusTest.php b/src/tests/Feature/Controller/SkusTest.php
--- a/src/tests/Feature/Controller/SkusTest.php
+++ b/src/tests/Feature/Controller/SkusTest.php
@@ -59,8 +59,7 @@
$json = $response->json();
- $this->assertCount(12, $json['list']);
-
+ $this->assertCount(13, $json['list']);
$this->assertSame(100, $json['list'][0]['prio']);
$this->assertSame($sku->id, $json['list'][0]['id']);
$this->assertSame($sku->title, $json['list'][0]['title']);
@@ -95,6 +94,16 @@
$this->assertCount(1, $json['list']);
$this->assertSame('domain-hosting', $json['list'][0]['title']);
$this->assertSame(0, $json['list'][0]['nextCost']); // first domain costs 0
+
+ // Test de-activating handlers (fs-quota requires app.with_files)
+ \config(['app.with_files' => false]);
+ $response = $this->actingAs($john)->get("api/v4/skus");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(12, $json['list']);
+ $this->assertFalse(in_array('fs-quota', array_column($json['list'], 'title')));
}
/**
diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php
--- a/src/tests/Feature/Controller/UsersTest.php
+++ b/src/tests/Feature/Controller/UsersTest.php
@@ -442,21 +442,22 @@
$json = $response->json();
$storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first();
+ $fsquota_sku = Sku::withEnvTenantContext()->where('title', 'fs-quota')->first();
$groupware_sku = Sku::withEnvTenantContext()->where('title', 'groupware')->first();
$mailbox_sku = Sku::withEnvTenantContext()->where('title', 'mailbox')->first();
$secondfactor_sku = Sku::withEnvTenantContext()->where('title', '2fa')->first();
- $this->assertCount(5, $json['skus']);
-
+ $this->assertCount(6, $json['skus']);
$this->assertSame(5, $json['skus'][$storage_sku->id]['count']);
$this->assertSame([0, 0, 0, 0, 0], $json['skus'][$storage_sku->id]['costs']);
+ $this->assertSame(5, $json['skus'][$fsquota_sku->id]['count']);
+ $this->assertSame([0, 0, 0, 0, 0], $json['skus'][$fsquota_sku->id]['costs']);
$this->assertSame(1, $json['skus'][$groupware_sku->id]['count']);
$this->assertSame([490], $json['skus'][$groupware_sku->id]['costs']);
$this->assertSame(1, $json['skus'][$mailbox_sku->id]['count']);
$this->assertSame([500], $json['skus'][$mailbox_sku->id]['costs']);
$this->assertSame(1, $json['skus'][$secondfactor_sku->id]['count']);
$this->assertSame([0], $json['skus'][$secondfactor_sku->id]['costs']);
-
$this->assertSame([], $json['aliases']);
}
@@ -499,20 +500,7 @@
'readonly' => true,
]);
- $this->assertSkuElement('storage', $json['list'][1], [
- 'prio' => 90,
- 'type' => 'user',
- 'handler' => 'Storage',
- 'enabled' => true,
- 'readonly' => true,
- 'range' => [
- 'min' => 5,
- 'max' => 100,
- 'unit' => 'GB',
- ],
- ]);
-
- $this->assertSkuElement('groupware', $json['list'][2], [
+ $this->assertSkuElement('groupware', $json['list'][1], [
'prio' => 80,
'type' => 'user',
'handler' => 'Groupware',
@@ -520,7 +508,7 @@
'readonly' => false,
]);
- $this->assertSkuElement('activesync', $json['list'][3], [
+ $this->assertSkuElement('activesync', $json['list'][2], [
'prio' => 70,
'type' => 'user',
'handler' => 'Activesync',
@@ -529,7 +517,7 @@
'required' => ['Groupware'],
]);
- $this->assertSkuElement('2fa', $json['list'][4], [
+ $this->assertSkuElement('2fa', $json['list'][3], [
'prio' => 60,
'type' => 'user',
'handler' => 'Auth2F',
@@ -538,6 +526,19 @@
'forbidden' => ['Activesync'],
]);
+ $this->assertSkuElement('storage', $json['list'][4], [
+ 'prio' => 5,
+ 'type' => 'user',
+ 'handler' => 'Storage',
+ 'enabled' => true,
+ 'readonly' => true,
+ 'range' => [
+ 'min' => 5,
+ 'max' => 100,
+ 'unit' => 'GB',
+ ],
+ ]);
+
// Test inclusion of beta SKUs
$sku = Sku::withEnvTenantContext()->where('title', 'beta')->first();
$user->assignSku($sku);
@@ -546,15 +547,41 @@
$json = $response->json();
- $this->assertCount(6, $json['list']);
+ $this->assertCount(7, $json['list']);
- $this->assertSkuElement('beta', $json['list'][5], [
+ $this->assertSkuElement('beta', $json['list'][4], [
'prio' => 10,
'type' => 'user',
'handler' => 'Beta',
'enabled' => false,
'readonly' => false,
]);
+
+ $this->assertSkuElement('storage', $json['list'][5], [
+ 'prio' => 5,
+ 'type' => 'user',
+ 'handler' => 'Storage',
+ 'enabled' => true,
+ 'readonly' => true,
+ 'range' => [
+ 'min' => 5,
+ 'max' => 100,
+ 'unit' => 'GB',
+ ],
+ ]);
+
+ $this->assertSkuElement('fs-quota', $json['list'][6], [
+ 'prio' => 0,
+ 'type' => 'user',
+ 'handler' => 'FsQuota',
+ 'enabled' => true,
+ 'readonly' => true,
+ 'range' => [
+ 'min' => 5,
+ 'max' => 100,
+ 'unit' => 'GB',
+ ],
+ ]);
}
/**
@@ -1007,8 +1034,11 @@
$this->assertSame('deleted@kolab.org', $aliases[0]->alias);
$this->assertSame('useralias1@kolab.org', $aliases[1]->alias);
// Assert the new user entitlements
- $this->assertEntitlements($user, ['groupware', 'mailbox',
- 'storage', 'storage', 'storage', 'storage', 'storage']);
+ $this->assertEntitlements($user, [
+ 'fs-quota', 'fs-quota', 'fs-quota', 'fs-quota', 'fs-quota',
+ 'groupware', 'mailbox',
+ 'storage', 'storage', 'storage', 'storage', 'storage',
+ ]);
// Assert the wallet to which the new user should be assigned to
$wallet = $user->wallet();
$this->assertSame($john->wallets->first()->id, $wallet->id);
@@ -1033,8 +1063,11 @@
$this->assertSame('Doe2', $user->getSetting('last_name'));
$this->assertSame('TestOrg', $user->getSetting('organization'));
$this->assertCount(0, $user->aliases()->get());
- $this->assertEntitlements($user, ['groupware', 'mailbox',
- 'storage', 'storage', 'storage', 'storage', 'storage']);
+ $this->assertEntitlements($user, [
+ 'fs-quota', 'fs-quota', 'fs-quota', 'fs-quota', 'fs-quota',
+ 'groupware', 'mailbox',
+ 'storage', 'storage', 'storage', 'storage', 'storage',
+ ]);
// Test password reset link "mode"
$code = new VerificationCode(['mode' => VerificationCode::MODE_PASSWORD, 'active' => false]);
@@ -1337,7 +1370,11 @@
$this->assertEntitlements(
$user,
- ['groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage']
+ [
+ 'fs-quota', 'fs-quota', 'fs-quota', 'fs-quota', 'fs-quota',
+ 'groupware', 'mailbox',
+ 'storage', 'storage', 'storage', 'storage', 'storage', 'storage',
+ ]
);
$this->assertSame([0, 0, 0, 0, 0, 25], $storage_cost);
@@ -1394,15 +1431,10 @@
$jane,
[
'activesync',
+ 'fs-quota', 'fs-quota', 'fs-quota', 'fs-quota', 'fs-quota',
'groupware',
'mailbox',
- 'storage',
- 'storage',
- 'storage',
- 'storage',
- 'storage',
- 'storage',
- 'storage',
+ 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage',
]
);
@@ -1422,17 +1454,10 @@
$this->assertEntitlements(
$jane,
[
+ 'fs-quota', 'fs-quota', 'fs-quota', 'fs-quota', 'fs-quota',
'groupware',
'mailbox',
- 'storage',
- 'storage',
- 'storage',
- 'storage',
- 'storage',
- 'storage',
- 'storage',
- 'storage',
- 'storage',
+ 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage',
]
);
@@ -1452,17 +1477,10 @@
$this->assertEntitlements(
$jane,
[
+ 'fs-quota', 'fs-quota', 'fs-quota', 'fs-quota', 'fs-quota',
'groupware',
'mailbox',
- 'storage',
- 'storage',
- 'storage',
- 'storage',
- 'storage',
- 'storage',
- 'storage',
- 'storage',
- 'storage',
+ 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage',
]
);
@@ -1482,17 +1500,10 @@
$this->assertEntitlements(
$jane,
[
+ 'fs-quota', 'fs-quota', 'fs-quota', 'fs-quota', 'fs-quota',
'groupware',
'mailbox',
- 'storage',
- 'storage',
- 'storage',
- 'storage',
- 'storage',
- 'storage',
- 'storage',
- 'storage',
- 'storage',
+ 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage',
]
);
@@ -1512,13 +1523,10 @@
$this->assertEntitlements(
$jane,
[
+ 'fs-quota', 'fs-quota', 'fs-quota', 'fs-quota', 'fs-quota',
'groupware',
'mailbox',
- 'storage',
- 'storage',
- 'storage',
- 'storage',
- 'storage',
+ 'storage', 'storage', 'storage', 'storage', 'storage',
]
);
}
diff --git a/src/tests/Feature/EntitlementTest.php b/src/tests/Feature/EntitlementTest.php
--- a/src/tests/Feature/EntitlementTest.php
+++ b/src/tests/Feature/EntitlementTest.php
@@ -149,10 +149,10 @@
$wallet = $owner->wallets->first();
- $this->assertCount(7, $owner->entitlements()->get());
+ $this->assertCount(12, $owner->entitlements()->get());
$this->assertCount(1, $skuDomain->entitlements()->where('wallet_id', $wallet->id)->get());
$this->assertCount(2, $skuMailbox->entitlements()->where('wallet_id', $wallet->id)->get());
- $this->assertCount(15, $wallet->entitlements);
+ $this->assertCount(12 + 12 + 1, $wallet->entitlements);
$this->backdateEntitlements(
$owner->entitlements,
diff --git a/src/tests/Feature/Fs/QuotaTest.php b/src/tests/Feature/Fs/QuotaTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Fs/QuotaTest.php
@@ -0,0 +1,91 @@
+<?php
+
+namespace Tests\Feature\Controller;
+
+use App\Fs\Item;
+use App\Fs\Quota;
+use App\Handlers\FsQuota;
+use App\Sku;
+use Tests\TestCaseFs;
+
+class QuotaTest extends TestCaseFs
+{
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestUser('jane@kolabnow.com');
+ }
+
+ protected function tearDown(): void
+ {
+ $this->deleteTestUser('jane@kolabnow.com');
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test Quota::available()
+ */
+ public function testAvailable(): void
+ {
+ $user = $this->getTestUser('jane@kolabnow.com');
+
+ // No quota entitlements, no files
+ $this->assertSame(0, Quota::available($user));
+
+ // No quota entitlements, existing file
+ $file = $this->getTestFile($user, 'test1.txt', '123456789');
+ $this->assertSame(0, Quota::available($user));
+
+ // Add quota entitlement
+ $sku = Sku::withObjectTenantContext($user)->where('handler_class', FsQuota::class)->first();
+ $user->assignSku($sku);
+ $this->assertSame(1024 * 1024 * 1024 - 9, Quota::available($user));
+ }
+
+ /**
+ * Test Quota::limit()
+ */
+ public function testLimit(): void
+ {
+ $user = $this->getTestUser('jane@kolabnow.com');
+
+ // No quota entitlements
+ $this->assertSame(0, Quota::limit($user));
+
+ // With 2 quota entitlements
+ $sku = Sku::withObjectTenantContext($user)->where('handler_class', FsQuota::class)->first();
+ $user->assignSku($sku, 2);
+ $this->assertSame(2 * 1024 * 1024 * 1024, Quota::limit($user));
+ }
+
+ /**
+ * Test Quota::usage()
+ */
+ public function testUsage(): void
+ {
+ $user = $this->getTestUser('john@kolab.org');
+
+ $this->assertSame(0, Quota::usage($user));
+
+ $file1 = $this->getTestFile($user, 'test1.txt', '1');
+ $file2 = $this->getTestFile($user, 'test2.txt', '20');
+ $file3 = $this->getTestFile($user, 'test3.txt', '3000');
+ $file3->type |= Item::TYPE_INCOMPLETE;
+ $file3->save();
+ $file4 = $this->getTestFile($user, 'test4.txt', '40000');
+ $file4->delete();
+
+ $folder = $this->getTestCollection($user, 'folder1');
+ $folder->children()->attach([$file1, $file3]);
+
+ $this->assertSame(3, Quota::usage($user));
+
+ // Test if it handles numbers over 4GB
+ $file1->chunks()->first()->update(['size' => 4294967295]);
+
+ $this->assertSame(8, \PHP_INT_SIZE); // make sure we're on 64bit
+ $this->assertSame(4294967295 + 2, Quota::usage($user));
+ }
+}
diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php
--- a/src/tests/Feature/UserTest.php
+++ b/src/tests/Feature/UserTest.php
@@ -134,7 +134,7 @@
$user->assignPlan($plan);
$this->assertSame((string) $plan->id, $user->getSetting('plan_id'));
- $this->assertSame(7, $user->entitlements()->count()); // 5 storage + 1 mailbox + 1 groupware
+ $this->assertSame(12, $user->entitlements()->count()); // 5 storage + 1 mailbox + 1 groupware + 5 fs-quota
$user = $this->getTestUser('useraccountb@' . $domain->namespace);
@@ -142,7 +142,7 @@
$user->assignPlan($plan, $domain);
$this->assertSame((string) $plan->id, $user->getSetting('plan_id'));
- $this->assertSame(7, $user->entitlements()->count()); // 5 storage + 1 mailbox + 1 groupware
+ $this->assertSame(12, $user->entitlements()->count()); // 5 storage + 1 mailbox + 1 groupware + 5 fs-quota
$this->assertSame(1, $domain->entitlements()->count());
// Individual plan (domain is not allowed)
@@ -768,10 +768,10 @@
$wallet = $userA->wallets->first();
- $this->assertSame(7, $entitlementsA->count());
- $this->assertSame(7, $entitlementsB->count());
- $this->assertSame(7, $entitlementsA->whereDate('updated_at', $yesterday->toDateString())->count());
- $this->assertSame(7, $entitlementsB->whereDate('updated_at', $yesterday->toDateString())->count());
+ $this->assertSame(12, $entitlementsA->count());
+ $this->assertSame(12, $entitlementsB->count());
+ $this->assertSame(12, $entitlementsA->whereDate('updated_at', $yesterday->toDateString())->count());
+ $this->assertSame(12, $entitlementsB->whereDate('updated_at', $yesterday->toDateString())->count());
$this->assertSame(0, $wallet->balance);
$this->fakeQueueReset();
@@ -789,8 +789,8 @@
$balance = $wallet->fresh()->balance;
$this->assertTrue($balance < 0);
- $this->assertSame(7, $entitlementsA->whereDate('updated_at', Carbon::now()->toDateString())->count());
- $this->assertSame(7, $entitlementsB->whereDate('updated_at', Carbon::now()->toDateString())->count());
+ $this->assertSame(12, $entitlementsA->whereDate('updated_at', Carbon::now()->toDateString())->count());
+ $this->assertSame(12, $entitlementsB->whereDate('updated_at', Carbon::now()->toDateString())->count());
// Expect one update job for every user
// @phpstan-ignore-next-line
@@ -821,8 +821,8 @@
// Expect no balance change, degraded account entitlements are free
$this->assertSame($balance, $wallet->fresh()->balance);
- $this->assertSame(7, $entitlementsA->whereDate('updated_at', Carbon::now()->toDateString())->count());
- $this->assertSame(7, $entitlementsB->whereDate('updated_at', Carbon::now()->toDateString())->count());
+ $this->assertSame(12, $entitlementsA->whereDate('updated_at', Carbon::now()->toDateString())->count());
+ $this->assertSame(12, $entitlementsB->whereDate('updated_at', Carbon::now()->toDateString())->count());
// Expect one update job for every user
// @phpstan-ignore-next-line
@@ -846,7 +846,7 @@
$id = $user->id;
- $this->assertCount(7, $user->entitlements()->get());
+ $this->assertCount(12, $user->entitlements()->get());
Queue::fake();
@@ -903,9 +903,10 @@
$entitlementsResource = Entitlement::where('entitleable_id', $resource->id);
$entitlementsFolder = Entitlement::where('entitleable_id', $folder->id);
- $this->assertSame(7, $entitlementsA->count());
- $this->assertSame(7, $entitlementsB->count());
- $this->assertSame(7, $entitlementsC->count());
+ $all_count = 12;
+ $this->assertSame($all_count, $entitlementsA->count());
+ $this->assertSame($all_count, $entitlementsB->count());
+ $this->assertSame($all_count, $entitlementsC->count());
$this->assertSame(1, $entitlementsDomain->count());
$this->assertSame(1, $entitlementsGroup->count());
$this->assertSame(1, $entitlementsResource->count());
@@ -927,9 +928,9 @@
$this->assertSame(0, $entitlementsGroup->count());
$this->assertSame(0, $entitlementsResource->count());
$this->assertSame(0, $entitlementsFolder->count());
- $this->assertSame(7, $entitlementsA->withTrashed()->count());
- $this->assertSame(7, $entitlementsB->withTrashed()->count());
- $this->assertSame(7, $entitlementsC->withTrashed()->count());
+ $this->assertSame($all_count, $entitlementsA->withTrashed()->count());
+ $this->assertSame($all_count, $entitlementsB->withTrashed()->count());
+ $this->assertSame($all_count, $entitlementsC->withTrashed()->count());
$this->assertSame(1, $entitlementsDomain->withTrashed()->count());
$this->assertSame(1, $entitlementsGroup->withTrashed()->count());
$this->assertSame(1, $entitlementsResource->withTrashed()->count());
@@ -1351,7 +1352,7 @@
$this->assertFalse($domainA->fresh()->trashed());
// Assert entitlements
- $this->assertSame(7, $entitlementsA->count()); // mailbox + groupware + 5 x storage
+ $this->assertSame(12, $entitlementsA->count()); // mailbox + groupware + 5 x storage + 5 x fs-quota
$this->assertTrue($ent1->fresh()->trashed());
$entitlementsA->get()->each(function ($ent) {
$this->assertTrue($ent->updated_at->greaterThan(Carbon::now()->subSeconds(5)));
diff --git a/src/tests/Feature/WalletTest.php b/src/tests/Feature/WalletTest.php
--- a/src/tests/Feature/WalletTest.php
+++ b/src/tests/Feature/WalletTest.php
@@ -346,8 +346,7 @@
// Test second month
$this->backdateEntitlements($wallet->entitlements, Carbon::now()->subMonthsWithoutOverflow(2));
- $this->assertCount(7, $wallet->entitlements);
-
+ $this->assertCount(12, $wallet->entitlements);
$this->assertSame(1980, $wallet->expectedCharges());
$entitlement = Entitlement::create([
@@ -501,7 +500,7 @@
$this->assertSame(0, $wallet->balance);
$this->assertSame(0, $reseller_wallet->balance);
$this->assertSame(0, $wallet->transactions()->count());
- $this->assertSame(12, $user->entitlements()->where('updated_at', $backdate)->count());
+ $this->assertSame(17, $user->entitlements()->where('updated_at', $backdate)->count());
// ------------------------------------
// Test normal charging of entitlements
@@ -546,7 +545,7 @@
// Assert all entitlements' updated_at timestamp
$date = $backdate->addMonthsWithoutOverflow(1);
- $this->assertCount(12, $wallet->entitlements()->where('updated_at', $date)->get());
+ $this->assertCount(17, $wallet->entitlements()->where('updated_at', $date)->get());
// Assert per-entitlement transactions
$entitlement_transactions = Transaction::where('transaction_id', $transactions[0]->id)
@@ -676,7 +675,7 @@
// Assert all entitlements' updated_at timestamp
$date = $backdate->addMonthsWithoutOverflow(1);
- $this->assertSame(9, $wallet->entitlements()->where('updated_at', $date)->count());
+ $this->assertSame(14, $wallet->entitlements()->where('updated_at', $date)->count());
// There should be only one transaction at this point (for the reseller wallet)
$this->assertSame(1, Transaction::count());
}
@@ -742,7 +741,7 @@
// Assert all entitlements' updated_at timestamp
$date = $backdate->addMonthsWithoutOverflow(1);
- $this->assertCount(12, $wallet->entitlements()->where('updated_at', $date)->get());
+ $this->assertCount(17, $wallet->entitlements()->where('updated_at', $date)->get());
// Run again, expect no changes
$charge = $wallet->chargeEntitlements();
@@ -751,7 +750,7 @@
$this->assertSame(0, $charge);
$this->assertSame($balance, $wallet->balance);
$this->assertCount(1, $wallet->transactions()->get());
- $this->assertCount(12, $wallet->entitlements()->where('updated_at', $date)->get());
+ $this->assertCount(17, $wallet->entitlements()->where('updated_at', $date)->get());
// -----------------------------------
// Test charging deleted entitlements
@@ -944,7 +943,7 @@
// Assert all entitlements' updated_at timestamp
$date = $now->copy()->setTimeFrom($backdate);
- $this->assertCount(7, $wallet->entitlements()->where('updated_at', $date)->get());
+ $this->assertCount(12, $wallet->entitlements()->where('updated_at', $date)->get());
$reseller_transactions = $reseller_wallet->transactions()->get();
$this->assertCount(1, $reseller_transactions);
@@ -992,7 +991,7 @@
// Assert all entitlements' updated_at timestamp
$date = $now->copy()->setTimeFrom($backdate);
- $this->assertCount(7, $wallet->entitlements()->where('updated_at', $date)->get());
+ $this->assertCount(12, $wallet->entitlements()->where('updated_at', $date)->get());
// Assert per-entitlement transactions
$groupware_entitlement = $user->entitlements->where('sku_id', '===', $groupware->id)->first();
diff --git a/src/tests/TestCaseFs.php b/src/tests/TestCaseFs.php
--- a/src/tests/TestCaseFs.php
+++ b/src/tests/TestCaseFs.php
@@ -161,4 +161,31 @@
// TODO: Make sure this does not use "acting user" set earlier
return $this->call($method, $uri, [], $cookies, [], $server, $content);
}
+
+ /**
+ * Fake user files size over-quota
+ */
+ protected static function turnUserOverQuota($user): void
+ {
+ // Assume user has 5GB quota for now
+ // Note: Max chunk size is 2GB per the DB column type
+ $user->fsItems()->where('type', '&', Item::TYPE_FILE)->first()
+ ->chunks()->createMany([
+ [
+ 'chunk_id' => Utils::uuidStr(),
+ 'sequence' => 2,
+ 'size' => 2 * 1024 * 1024 * 1024,
+ ],
+ [
+ 'chunk_id' => Utils::uuidStr(),
+ 'sequence' => 3,
+ 'size' => 2 * 1024 * 1024 * 1024,
+ ],
+ [
+ 'chunk_id' => Utils::uuidStr(),
+ 'sequence' => 4,
+ 'size' => 1 * 1024 * 1024 * 1024,
+ ],
+ ]);
+ }
}
diff --git a/src/tests/Unit/Fs/QuotaTest.php b/src/tests/Unit/Fs/QuotaTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Unit/Fs/QuotaTest.php
@@ -0,0 +1,25 @@
+<?php
+
+namespace Tests\Unit\Fs;
+
+use App\Fs\Quota;
+use Tests\TestCase;
+
+class QuotaTest extends TestCase
+{
+ /**
+ * Test Quota::bytes()
+ */
+ public function testBytes()
+ {
+ $this->assertSame('0 B', Quota::bytes(0));
+ $this->assertSame('101 B', Quota::bytes(101));
+ $this->assertSame('1.0 KB', Quota::bytes(1024));
+ $this->assertSame('1.5 KB', Quota::bytes(1500));
+ $this->assertSame('1.0 MB', Quota::bytes(1024 * 1024 - 1));
+ $this->assertSame('1.0 MB', Quota::bytes(1024 * 1024));
+ $this->assertSame('1.0 GB', Quota::bytes(1024 * 1024 * 1024 - 1));
+ $this->assertSame('1.0 GB', Quota::bytes(1024 * 1024 * 1024));
+ $this->assertSame('1.1 GB', Quota::bytes(1024 * 1024 * 1024 + 1024 * 1024 * 100));
+ }
+}

File Metadata

Mime Type
text/plain
Expires
Sun, Mar 29, 9:05 PM (3 d, 1 h ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18776313
Default Alt Text
D5766.1774818325.diff (91 KB)

Event Timeline