Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117371289
D5766.1774818325.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
91 KB
Referenced Files
None
Subscribers
None
D5766.1774818325.diff
View Options
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
Details
Attached
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)
Attached To
Mode
D5766: Files Quota
Attached
Detach File
Event Timeline