diff --git a/src/.env.example b/src/.env.example index 420e4085..fba2c27a 100644 --- a/src/.env.example +++ b/src/.env.example @@ -1,173 +1,174 @@ APP_NAME=Kolab APP_ENV=local APP_KEY= APP_DEBUG=true APP_URL=http://127.0.0.1:8000 #APP_PASSPHRASE= APP_PUBLIC_URL= APP_DOMAIN=kolabnow.com APP_WEBSITE_DOMAIN=kolabnow.com APP_THEME=default APP_TENANT_ID=5 APP_LOCALE=en APP_LOCALES= APP_WITH_ADMIN=1 APP_WITH_RESELLER=1 APP_WITH_SERVICES=1 APP_HEADER_CSP="connect-src 'self'; child-src 'self'; font-src 'self'; form-action 'self' data:; frame-ancestors 'self'; img-src blob: data: 'self' *; media-src 'self'; object-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-eval' 'unsafe-inline'; default-src 'self';" APP_HEADER_XFO=sameorigin SIGNUP_LIMIT_EMAIL=0 SIGNUP_LIMIT_IP=0 ASSET_URL=http://127.0.0.1:8000 WEBMAIL_URL=/apps SUPPORT_URL=/support SUPPORT_EMAIL= LOG_CHANNEL=stack LOG_SLOW_REQUESTS=5 LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug DB_CONNECTION=mysql DB_DATABASE=kolabdev DB_HOST=127.0.0.1 DB_PASSWORD=kolab DB_PORT=3306 DB_USERNAME=kolabdev BROADCAST_DRIVER=redis CACHE_DRIVER=redis QUEUE_CONNECTION=redis SESSION_DRIVER=file SESSION_LIFETIME=120 OPENEXCHANGERATES_API_KEY="from openexchangerates.org" MFA_DSN=mysql://roundcube:Welcome2KolabSystems@127.0.0.1/roundcube MFA_TOTP_DIGITS=6 MFA_TOTP_INTERVAL=30 MFA_TOTP_DIGEST=sha1 IMAP_URI=ssl://127.0.0.1:11993 IMAP_ADMIN_LOGIN=cyrus-admin IMAP_ADMIN_PASSWORD=Welcome2KolabSystems IMAP_VERIFY_HOST=false IMAP_VERIFY_PEER=false LDAP_BASE_DN="dc=mgmt,dc=com" LDAP_DOMAIN_BASE_DN="ou=Domains,dc=mgmt,dc=com" LDAP_HOSTS=127.0.0.1 LDAP_PORT=389 LDAP_SERVICE_BIND_DN="uid=kolab-service,ou=Special Users,dc=mgmt,dc=com" LDAP_SERVICE_BIND_PW="Welcome2KolabSystems" LDAP_USE_SSL=false LDAP_USE_TLS=false # Administrative LDAP_ADMIN_BIND_DN="cn=Directory Manager" LDAP_ADMIN_BIND_PW="Welcome2KolabSystems" LDAP_ADMIN_ROOT_DN="dc=mgmt,dc=com" # Hosted (public registration) LDAP_HOSTED_BIND_DN="uid=hosted-kolab-service,ou=Special Users,dc=mgmt,dc=com" LDAP_HOSTED_BIND_PW="Welcome2KolabSystems" LDAP_HOSTED_ROOT_DN="dc=hosted,dc=com" COTURN_PUBLIC_IP=127.0.0.1 COTURN_STATIC_SECRET="Welcome2KolabSystems" MEET_WEBHOOK_TOKEN=Welcome2KolabSystems MEET_SERVER_TOKEN=Welcome2KolabSystems MEET_SERVER_URLS=https://localhost:12443/meetmedia/api/ MEET_SERVER_VERIFY_TLS=true MEET_WEBRTC_LISTEN_IP= MEET_PUBLIC_DOMAIN=127.0.0.1:12443 MEET_TURN_SERVER='turn:127.0.0.1:3478?transport=tcp' PGP_ENABLED= PGP_BINARY= PGP_AGENT= PGP_GPGCONF= PGP_LENGTH= # Set these to IP addresses you serve WOAT with. # Have the domain owner point _woat. NS RRs refer to ns0{1,2}. WOAT_NS1=ns01.domain.tld WOAT_NS2=ns02.domain.tld REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null REDIS_PORT=6379 OCTANE_HTTP_HOST=127.0.0.1 +SWOOLE_PACKAGE_MAX_LENGTH=10485760 PAYMENT_PROVIDER= MOLLIE_KEY= STRIPE_KEY= STRIPE_PUBLIC_KEY= STRIPE_WEBHOOK_SECRET= MAIL_MAILER=smtp MAIL_HOST=smtp.mailtrap.io MAIL_PORT=2525 MAIL_USERNAME=null MAIL_PASSWORD=null MAIL_ENCRYPTION=null MAIL_FROM_ADDRESS="noreply@example.com" MAIL_FROM_NAME="Example.com" MAIL_REPLYTO_ADDRESS="replyto@example.com" MAIL_REPLYTO_NAME=null DNS_TTL=3600 DNS_SPF="v=spf1 mx -all" DNS_STATIC="%s. MX 10 ext-mx01.mykolab.com." DNS_COPY_FROM=null AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_DEFAULT_REGION=us-east-1 AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false PUSHER_APP_ID= PUSHER_APP_KEY= PUSHER_APP_SECRET= PUSHER_APP_CLUSTER=mt1 MIX_ASSET_PATH='/' MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}" MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" # Generate with ./artisan passport:client --password #PASSPORT_PROXY_OAUTH_CLIENT_ID= #PASSPORT_PROXY_OAUTH_CLIENT_SECRET= # Generate with ./artisan passport:client --password #PASSPORT_COMPANIONAPP_OAUTH_CLIENT_ID= #PASSPORT_COMPANIONAPP_OAUTH_CLIENT_SECRET= PASSPORT_PRIVATE_KEY= PASSPORT_PUBLIC_KEY= PASSWORD_POLICY= COMPANY_NAME= COMPANY_ADDRESS= COMPANY_DETAILS= COMPANY_EMAIL= COMPANY_LOGO= COMPANY_FOOTER= VAT_COUNTRIES=CH,LI VAT_RATE=7.7 KB_ACCOUNT_DELETE= KB_ACCOUNT_SUSPENDED= diff --git a/src/app/Backends/Storage.php b/src/app/Backends/Storage.php new file mode 100644 index 00000000..b314c19a --- /dev/null +++ b/src/app/Backends/Storage.php @@ -0,0 +1,268 @@ +path . '/' . $file->id; + + // TODO: Deleting files might be slow, consider marking as deleted and async job + + $disk->deleteDirectory($path); + + $file->chunks()->delete(); + } + + /** + * File download handler. + * + * @param \App\Fs\Item $file File object + * + * @throws \Exception + */ + public static function fileDownload(Item $file): StreamedResponse + { + $response = new StreamedResponse(); + + $props = $file->getProperties(['name', 'size', 'mimetype']); + + // Prepare the file name for the Content-Disposition header + $extension = pathinfo($props['name'], \PATHINFO_EXTENSION) ?: 'file'; + $fallbackName = str_replace('%', '', Str::ascii($props['name'])) ?: "file.{$extension}"; + $disposition = $response->headers->makeDisposition('attachment', $props['name'], $fallbackName); + + $response->headers->replace([ + 'Content-Length' => $props['size'] ?: 0, + 'Content-Type' => $props['mimetype'], + 'Content-Disposition' => $disposition, + ]); + + $response->setCallback(function () use ($file) { + $file->chunks()->orderBy('sequence')->get()->each(function ($chunk) use ($file) { + $disk = LaravelStorage::disk('files'); + $path = Storage::chunkLocation($chunk->chunk_id, $file); + + $stream = $disk->readStream($path); + + fpassthru($stream); + fclose($stream); + }); + }); + + return $response; + } + + /** + * File upload handler + * + * @param resource $stream File input stream + * @param array $params Request parameters + * @param ?\App\Fs\Item $file The file object + * + * @return array File/Response attributes + * @throws \Exception + */ + public static function fileInput($stream, array $params, Item $file = null): array + { + if (!empty($params['uploadId'])) { + return self::fileInputResumable($stream, $params, $file); + } + + $disk = LaravelStorage::disk('files'); + + $chunkId = \App\Utils::uuidStr(); + + $path = self::chunkLocation($chunkId, $file); + + $disk->writeStream($path, $stream); + + $fileSize = $disk->fileSize($path); + + // Update the file type and size information + $file->setProperties([ + 'size' => $fileSize, + 'mimetype' => self::mimetype($path), + ]); + + // Assign the node to the file, "unlink" any old nodes of this file + $file->chunks()->delete(); + $file->chunks()->create([ + 'chunk_id' => $chunkId, + 'sequence' => 0, + 'size' => $fileSize, + ]); + + return ['id' => $file->id]; + } + + /** + * Resumable file upload handler + * + * @param resource $stream File input stream + * @param array $params Request parameters + * @param ?\App\Fs\Item $file The file object + * + * @return array File/Response attributes + * @throws \Exception + */ + protected static function fileInputResumable($stream, array $params, Item $file = null): array + { + // Initial request, save file metadata, return uploadId + if ($params['uploadId'] == 'resumable') { + if (empty($params['size']) || empty($file)) { + throw new \Exception("Missing parameters of resumable file upload."); + } + + $params['uploadId'] = \App\Utils::uuidStr(); + + $upload = [ + 'fileId' => $file->id, + 'size' => $params['size'], + 'uploaded' => 0, + ]; + + if (!Cache::add('upload:' . $params['uploadId'], $upload, self::UPLOAD_TTL)) { + throw new \Exception("Failed to create cache entry for resumable file upload."); + } + + return [ + 'uploadId' => $params['uploadId'], + 'uploaded' => 0, + 'maxChunkSize' => (\config('octane.swoole.options.package_max_length') ?: 10 * 1024 * 1024) - 8192, + ]; + } + + $upload = Cache::get('upload:' . $params['uploadId']); + + if (empty($upload)) { + throw new \Exception("Cache entry for resumable file upload does not exist."); + } + + $file = Item::find($upload['fileId']); + + if (!$file) { + throw new \Exception("Invalid fileId for resumable file upload."); + } + + $from = $params['from'] ?? 0; + + // Sanity checks on the input parameters + // TODO: Support uploading again a chunk that already has been uploaded? + if ($from < $upload['uploaded'] || $from > $upload['uploaded'] || $from > $upload['size']) { + throw new \Exception("Invalid 'from' parameter for resumable file upload."); + } + + $disk = LaravelStorage::disk('files'); + + $chunkId = \App\Utils::uuidStr(); + + $path = self::chunkLocation($chunkId, $file); + + // Save the file chunk + $disk->writeStream($path, $stream); + + // Detect file type using the first chunk + if ($from == 0) { + $upload['mimetype'] = self::mimetype($path); + $upload['chunks'] = []; + } + + $chunkSize = $disk->fileSize($path); + + // Create the chunk record + $file->chunks()->create([ + 'chunk_id' => $chunkId, + 'sequence' => count($upload['chunks']), + 'size' => $chunkSize, + 'deleted_at' => \now(), // not yet active chunk + ]); + + $upload['chunks'][] = $chunkId; + $upload['uploaded'] += $chunkSize; + + // Update the file metadata after the upload of all chunks is completed + if ($upload['uploaded'] >= $upload['size']) { + // Update file metadata + $file->setProperties([ + 'size' => $upload['uploaded'], + 'mimetype' => $upload['mimetype'] ?: 'application/octet-stream', + ]); + + // Assign uploaded chunks to the file, "unlink" any old chunks of this file + $file->chunks()->delete(); + $file->chunks()->whereIn('chunk_id', $upload['chunks'])->restore(); + + // TODO: Create a "cron" job to remove orphaned nodes from DB and the storage. + // I.e. all with deleted_at set and older than UPLOAD_TTL + + // Delete the upload cache record + Cache::forget('upload:' . $params['uploadId']); + + return ['id' => $file->id]; + } + + // Update the upload metadata + Cache::put('upload:' . $params['uploadId'], $upload, self::UPLOAD_TTL); + + return ['uploadId' => $params['uploadId'], 'uploaded' => $upload['uploaded']]; + } + + /** + * Get the file mime type. + * + * @param string $path File location + * + * @return string File mime type + */ + protected static function mimetype(string $path): string + { + $disk = LaravelStorage::disk('files'); + + // TODO: If file is empty, detect the mimetype based on the extension? + try { + return $disk->mimeType($path); + } catch (\Exception $e) { + // do nothing + } + + // TODO: If it fails detect the mimetype based on extension? + + return 'application/octet-stream'; + } + + /** + * Node location in the storage + * + * @param string $chunkId Chunk identifier + * @param \App\Fs\Item $file File the chunk belongs to + * + * @return string Chunk location + */ + public static function chunkLocation(string $chunkId, Item $file): string + { + return $file->path . '/' . $file->id . '/' . $chunkId; + } +} diff --git a/src/app/Fs/Chunk.php b/src/app/Fs/Chunk.php new file mode 100644 index 00000000..e2e769a6 --- /dev/null +++ b/src/app/Fs/Chunk.php @@ -0,0 +1,37 @@ + The attributes that are mass assignable */ + protected $fillable = ['item_id', 'chunk_id', 'sequence', 'deleted_at', 'size']; + + /** @var string Database table name */ + protected $table = 'fs_chunks'; + + + /** + * The item (file) the chunk belongs to. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function item() + { + return $this->belongsTo(Item::class); + } +} diff --git a/src/app/Fs/Item.php b/src/app/Fs/Item.php new file mode 100644 index 00000000..422f08d5 --- /dev/null +++ b/src/app/Fs/Item.php @@ -0,0 +1,178 @@ + The attributes that are mass assignable */ + protected $fillable = ['user_id', 'type']; + + /** @var array The attributes that should be cast */ + protected $casts = [ + 'created_at' => 'datetime:Y-m-d H:i:s', + 'updated_at' => 'datetime:Y-m-d H:i:s', + ]; + + /** @var string Database table name */ + protected $table = 'fs_items'; + + + /** + * COntent chunks of this item (file). + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function chunks() + { + return $this->hasMany(Chunk::class); + } + + /** + * Getter for the file path (without the filename) in the storage. + * + * @return \Illuminate\Database\Eloquent\Casts\Attribute + */ + protected function path(): Attribute + { + return Attribute::make( + get: function ($value) { + if (empty($this->id)) { + throw new \Exception("Cannot get path for an item without ID"); + } + + $id = substr($this->id, 0, 6); + + return implode('/', str_split($id, 2)); + } + ); + } + + /** + * Any (additional) properties of this item. + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function properties() + { + return $this->hasMany(Property::class); + } + + /** + * Obtain the value for an item property + * + * @param string $key Property name + * @param mixed $default Default value, to be used if not found + * + * @return string|null Property value + */ + public function getProperty(string $key, $default = null) + { + $attr = $this->properties()->where('key', $key)->first(); + + return $attr ? $attr->value : $default; + } + + /** + * Obtain the values for many properties in one go (for better performance). + * + * @param array $keys Property names + * + * @return array Property key=value hash, includes also requested but non-existing properties + */ + public function getProperties(array $keys): array + { + $props = array_fill_keys($keys, null); + + $this->properties()->whereIn('key', $keys)->get() + ->each(function ($prop) use (&$props) { + $props[$prop->key] = $prop->value; + }); + + return $props; + } + + /** + * Remove a property + * + * @param string $key Property name + */ + public function removeProperty(string $key): void + { + $this->setProperty($key, null); + } + + /** + * Create or update a property. + * + * @param string $key Property name + * @param string|null $value The new value for the property + */ + public function setProperty(string $key, $value): void + { + $this->storeProperty($key, $value); + } + + /** + * Create or update multiple properties in one fell swoop. + * + * @param array $data An associative array of key value pairs. + */ + public function setProperties(array $data = []): void + { + foreach ($data as $key => $value) { + $this->storeProperty($key, $value); + } + } + + /** + * Create or update a property. + * + * @param string $key Property name + * @param string|null $value The new value for the property + */ + private function storeProperty(string $key, $value): void + { + if ($value === null || $value === '') { + // Note: We're selecting the record first, so observers can act + if ($prop = $this->properties()->where('key', $key)->first()) { + $prop->delete(); + } + } else { + $this->properties()->updateOrCreate( + ['key' => $key], + ['value' => $value] + ); + } + } + + /** + * The user to which this item belongs. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function user() + { + return $this->belongsTo(User::class, 'user_id', 'id'); + } +} diff --git a/src/app/Fs/Property.php b/src/app/Fs/Property.php new file mode 100644 index 00000000..de175adb --- /dev/null +++ b/src/app/Fs/Property.php @@ -0,0 +1,33 @@ + The attributes that are mass assignable */ + protected $fillable = ['item_id', 'key', 'value']; + + /** @var string Database table name */ + protected $table = 'fs_properties'; + + + /** + * The item to which this property belongs. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function item() + { + return $this->belongsTo(Item::class); + } +} diff --git a/src/app/Handlers/Beta/Base.php b/src/app/Handlers/Beta/Base.php index 32b28e20..7d32c9e0 100644 --- a/src/app/Handlers/Beta/Base.php +++ b/src/app/Handlers/Beta/Base.php @@ -1,70 +1,91 @@ active) { return $object->hasSku('beta'); } else { if ($object->entitlements()->where('sku_id', $sku->id)->first()) { return true; } } return false; } /** * SKU handler metadata. * * @param \App\Sku $sku The SKU object * * @return array */ public static function metadata(\App\Sku $sku): array { $data = parent::metadata($sku); $data['required'] = ['Beta']; return $data; } /** * Prerequisites for the Entitlement to be applied to the object. * * @param \App\Entitlement $entitlement * @param mixed $object * * @return bool */ public static function preReq($entitlement, $object): bool { if (!parent::preReq($entitlement, $object)) { return false; } // TODO: User has to have the "beta" entitlement return true; } + + /** + * The priority that specifies the order of SKUs in UI. + * Higher number means higher on the list. + * + * @return int + */ + public static function priority(): int + { + return 10; + } } diff --git a/src/app/Handlers/Beta/Distlists.php b/src/app/Handlers/Beta/Distlists.php index 014e9b67..5b6cb5a9 100644 --- a/src/app/Handlers/Beta/Distlists.php +++ b/src/app/Handlers/Beta/Distlists.php @@ -1,49 +1,28 @@ wallet()->entitlements() ->where('entitleable_type', \App\Domain::class)->count() > 0; } return false; } - - /** - * The priority that specifies the order of SKUs in UI. - * Higher number means higher on the list. - * - * @return int - */ - public static function priority(): int - { - return 10; - } } diff --git a/src/app/Handlers/Beta/Resources.php b/src/app/Handlers/Beta/Resources.php index 127e3c91..9091bb5d 100644 --- a/src/app/Handlers/Beta/Resources.php +++ b/src/app/Handlers/Beta/Resources.php @@ -1,49 +1,28 @@ wallet()->entitlements() ->where('entitleable_type', \App\Domain::class)->count() > 0; } return false; } - - /** - * The priority that specifies the order of SKUs in UI. - * Higher number means higher on the list. - * - * @return int - */ - public static function priority(): int - { - return 10; - } } diff --git a/src/app/Handlers/Beta/SharedFolders.php b/src/app/Handlers/Beta/SharedFolders.php index 93615186..3801e155 100644 --- a/src/app/Handlers/Beta/SharedFolders.php +++ b/src/app/Handlers/Beta/SharedFolders.php @@ -1,49 +1,28 @@ wallet()->entitlements() ->where('entitleable_type', \App\Domain::class)->count() > 0; } return false; } - - /** - * The priority that specifies the order of SKUs in UI. - * Higher number means higher on the list. - * - * @return int - */ - public static function priority(): int - { - return 10; - } } diff --git a/src/app/Handlers/Files.php b/src/app/Handlers/Files.php new file mode 100644 index 00000000..b0f53269 --- /dev/null +++ b/src/app/Handlers/Files.php @@ -0,0 +1,7 @@ +inputFile($id, null); + + if (is_int($file)) { + return $this->errorResponse($file); + } + + // FIXME: Here we're just deleting the file, but maybe it would be better/faster + // to mark the file (record in db) as deleted and invoke a job to + // delete it asynchronously? + + Storage::fileDelete($file); + + $file->delete(); + + return response()->json([ + 'status' => 'success', + 'message' => \trans('app.file-delete-success'), + ]); + } + + /** + * Fetch content of a file. + * + * @param string $id The download (not file) identifier. + * + * @return \Illuminate\Http\Response|\Symfony\Component\HttpFoundation\StreamedResponse + */ + public function download($id) + { + $fileId = Cache::get('download:' . $id); + + if (!$fileId) { + return response('Not found', 404); + } + + $file = Item::find($fileId); + + if (!$file) { + return response('Not found', 404); + } + + return Storage::fileDownload($file); + } + + /** + * Fetch the permissions for the specific file. + * + * @param string $fileId The file identifier. + * + * @return \Illuminate\Http\JsonResponse + */ + public function getPermissions($fileId) + { + // Only the file owner can do that, for now + $file = $this->inputFile($fileId, null); + + if (is_int($file)) { + return $this->errorResponse($file); + } + + $result = $file->properties()->where('key', 'like', 'share-%')->get()->map( + fn($prop) => self::permissionToClient($prop->key, $prop->value) + ); + + $result = [ + 'list' => $result, + 'count' => count($result), + ]; + + return response()->json($result); + } + + /** + * Add permission for the specific file. + * + * @param string $fileId The file identifier. + * + * @return \Illuminate\Http\JsonResponse + */ + public function createPermission($fileId) + { + // Only the file owner can do that, for now + $file = $this->inputFile($fileId, null); + + if (is_int($file)) { + return $this->errorResponse($file); + } + + // Validate/format input + $v = Validator::make(request()->all(), [ + 'user' => 'email|required', + 'permissions' => 'string|required', + ]); + + $errors = $v->fails() ? $v->errors()->toArray() : []; + + $acl = self::inputAcl(request()->input('permissions')); + + if (empty($errors['permissions']) && empty($acl)) { + $errors['permissions'] = \trans('validation.file-perm-invalid'); + } + + $user = \strtolower(request()->input('user')); + + // Check if it already exists + if (empty($errors['user'])) { + if ($file->properties()->where('key', 'like', 'share-%')->where('value', 'like', "$user:%")->exists()) { + $errors['user'] = \trans('validation.file-perm-exists'); + } + } + + if (!empty($errors)) { + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + + // Create the property (with a unique id) + while ($shareId = 'share-' . \App\Utils::uuidStr()) { + if (!Property::where('key', $shareId)->exists()) { + break; + } + } + + $file->setProperty($shareId, "$user:$acl"); + + $result = self::permissionToClient($shareId, "$user:$acl"); + + return response()->json($result + [ + 'status' => 'success', + 'message' => \trans('app.file-permissions-create-success'), + ]); + } + + /** + * Delete file permission. + * + * @param string $fileId The file identifier. + * @param string $id The file permission identifier. + * + * @return \Illuminate\Http\JsonResponse + */ + public function deletePermission($fileId, $id) + { + // Only the file owner can do that, for now + $file = $this->inputFile($fileId, null); + + if (is_int($file)) { + return $this->errorResponse($file); + } + + $property = $file->properties()->where('key', $id)->first(); + + if (!$property) { + return $this->errorResponse(404); + } + + $property->delete(); + + return response()->json([ + 'status' => 'success', + 'message' => \trans('app.file-permissions-delete-success'), + ]); + } + + /** + * Update file permission. + * + * @param \Illuminate\Http\Request $request The API request. + * @param string $fileId The file identifier. + * @param string $id The file permission identifier. + * + * @return \Illuminate\Http\JsonResponse + */ + public function updatePermission(Request $request, $fileId, $id) + { + // Only the file owner can do that, for now + $file = $this->inputFile($fileId, null); + + if (is_int($file)) { + return $this->errorResponse($file); + } + + $property = $file->properties()->where('key', $id)->first(); + + if (!$property) { + return $this->errorResponse(404); + } + + // Validate/format input + $v = Validator::make($request->all(), [ + 'user' => 'email|required', + 'permissions' => 'string|required', + ]); + + $errors = $v->fails() ? $v->errors()->toArray() : []; + + $acl = self::inputAcl($request->input('permissions')); + + if (empty($errors['permissions']) && empty($acl)) { + $errors['permissions'] = \trans('validation.file-perm-invalid'); + } + + $user = \strtolower($request->input('user')); + + if (empty($errors['user']) && strpos($property->value, "$user:") !== 0) { + if ($file->properties()->where('key', 'like', 'share-%')->where('value', 'like', "$user:%")->exists()) { + $errors['user'] = \trans('validation.file-perm-exists'); + } + } + + if (!empty($errors)) { + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + + $property->value = "$user:$acl"; + $property->save(); + + $result = self::permissionToClient($property->key, $property->value); + + return response()->json($result + [ + 'status' => 'success', + 'message' => \trans('app.file-permissions-update-success'), + ]); + } + + /** + * Listing of files (and folders). + * + * @return \Illuminate\Http\JsonResponse + */ + public function index() + { + $search = trim(request()->input('search')); + $page = intval(request()->input('page')) ?: 1; + $pageSize = 100; + $hasMore = false; + + $user = $this->guard()->user(); + + $result = $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'); + + if (strlen($search)) { + $result->whereLike('fs_properties.value', $search); + } + + $result = $result->orderBy('name') + ->limit($pageSize + 1) + ->offset($pageSize * ($page - 1)) + ->get(); + + if (count($result) > $pageSize) { + $result->pop(); + $hasMore = true; + } + + // Process the result + $result = $result->map( + function ($file) { + $result = $this->objectToClient($file); + $result['name'] = $file->name; // @phpstan-ignore-line + + return $result; + } + ); + + $result = [ + 'list' => $result, + 'count' => count($result), + 'hasMore' => $hasMore, + ]; + + return response()->json($result); + } + + /** + * Fetch the specific file metadata or content. + * + * @param string $id The file identifier. + * + * @return \Illuminate\Http\JsonResponse|\Symfony\Component\HttpFoundation\StreamedResponse + */ + public function show($id) + { + $file = $this->inputFile($id, self::READ); + + if (is_int($file)) { + return $this->errorResponse($file); + } + + $response = $this->objectToClient($file, true); + + if (request()->input('downloadUrl')) { + // Generate a download URL (that does not require authentication) + $downloadId = Utils::uuidStr(); + Cache::add('download:' . $downloadId, $file->id, 60); + $response['downloadUrl'] = Utils::serviceUrl('api/v4/files/downloads/' . $downloadId); + } elseif (request()->input('download')) { + // Return the file content + return Storage::fileDownload($file); + } + + $response['mtime'] = $file->updated_at->format('Y-m-d H:i'); + + // TODO: Handle read-write/full access rights + $isOwner = $this->guard()->user()->id == $file->user_id; + $response['canUpdate'] = $isOwner; + $response['canDelete'] = $isOwner; + $response['isOwner'] = $isOwner; + + return response()->json($response); + } + + /** + * Create a new file. + * + * @param \Illuminate\Http\Request $request The API request. + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function store(Request $request) + { + $user = $this->guard()->user(); + + // Validate file name input + $v = Validator::make($request->all(), ['name' => ['required', new FileName($user)]]); + + if ($v->fails()) { + return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); + } + + $filename = $request->input('name'); + $media = $request->input('media'); + + // FIXME: Normally people just drag and drop/upload files. + // The client side will not know whether the file with the same name + // already exists or not. So, in such a case should we throw + // an error or accept the request as an update? + + $params = []; + + if ($media == 'resumable') { + $params['uploadId'] = 'resumable'; + $params['size'] = $request->input('size'); + $params['from'] = $request->input('from') ?: 0; + } + + // TODO: Delete the existing incomplete file with the same name? + + $file = $user->fsItems()->create(['type' => Item::TYPE_INCOMPLETE | Item::TYPE_FILE]); + $file->setProperty('name', $filename); + + try { + $response = Storage::fileInput($request->getContent(true), $params, $file); + + $response['status'] = 'success'; + + if (!empty($response['id'])) { + $response += $this->objectToClient($file, true); + $response['message'] = \trans('app.file-create-success'); + } + } catch (\Exception $e) { + \Log::error($e); + $file->delete(); + return $this->errorResponse(500); + } + + return response()->json($response); + } + + /** + * Update a file. + * + * @param \Illuminate\Http\Request $request The API request. + * @param string $id File identifier + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function update(Request $request, $id) + { + $file = $this->inputFile($id, self::WRITE); + + if (is_int($file)) { + return $this->errorResponse($file); + } + + $media = $request->input('media') ?: 'metadata'; + + if ($media == 'metadata') { + $filename = $request->input('name'); + + // Validate file name input + if ($filename != $file->getProperty('name')) { + $v = Validator::make($request->all(), ['name' => [new FileName($file->user)]]); + + if ($v->fails()) { + return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); + } + + $file->setProperty('name', $filename); + } + + // $file->save(); + } elseif ($media == 'resumable' || $media == 'content') { + $params = []; + + if ($media == 'resumable') { + $params['uploadId'] = 'resumable'; + $params['size'] = $request->input('size'); + $params['from'] = $request->input('from') ?: 0; + } + + try { + $response = Storage::fileInput($request->getContent(true), $params, $file); + } catch (\Exception $e) { + \Log::error($e); + return $this->errorResponse(500); + } + } else { + $errors = ['media' => \trans('validation.entryinvalid', ['attribute' => 'media'])]; + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + + $response['status'] = 'success'; + + if ($media == 'metadata' || !empty($response['id'])) { + $response += $this->objectToClient($file, true); + $response['message'] = \trans('app.file-update-success'); + } + + return response()->json($response); + } + + /** + * Upload a file content. + * + * @param \Illuminate\Http\Request $request The API request. + * @param string $id Upload (not file) identifier + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function upload(Request $request, $id) + { + $params = [ + 'uploadId' => $id, + 'from' => $request->input('from') ?: 0, + ]; + + try { + $response = Storage::fileInput($request->getContent(true), $params); + + $response['status'] = 'success'; + + if (!empty($response['id'])) { + $response += $this->objectToClient(Item::find($response['id']), true); + $response['message'] = \trans('app.file-upload-success'); + } + } catch (\Exception $e) { + \Log::error($e); + return $this->errorResponse(500); + } + + return response()->json($response); + } + + /** + * Convert Permission to an array for the API response. + * + * @param string $id Permission identifier + * @param string $value Permission record + * + * @return array Permission data + */ + protected static function permissionToClient(string $id, string $value): array + { + list($user, $acl) = explode(':', $value); + + $perms = strpos($acl, self::WRITE) !== false ? 'read-write' : 'read-only'; + + return [ + 'id' => $id, + 'user' => $user, + 'permissions' => $perms, + 'link' => Utils::serviceUrl('file/' . $id), + ]; + } + + /** + * Convert ACL label into internal permissions spec. + * + * @param string $acl Access rights label + * + * @return ?string Permissions ('r' or 'rw') + */ + protected static function inputAcl($acl): ?string + { + // The ACL widget supports 'full', 'read-write', 'read-only', + if ($acl == 'read-write') { + return self::READ . self::WRITE; + } + + if ($acl == 'read-only') { + return self::READ; + } + + return null; + } + + /** + * Get the input file object, check permissions + * + * @param string $fileId File or file permission identifier + * @param ?string $permission Required access rights + * + * @return \App\Fs\Item|int File object or error code + */ + protected function inputFile($fileId, $permission) + { + $user = $this->guard()->user(); + $isShare = str_starts_with($fileId, 'share-'); + + // Access via file permission identifier + if ($isShare) { + $property = Property::where('key', $fileId)->first(); + + if (!$property) { + return 404; + } + + list($acl_user, $acl) = explode(':', $property->value); + + if (!$permission || $acl_user != $user->email || strpos($acl, $permission) === false) { + return 403; + } + + $fileId = $property->item_id; + } + + $file = Item::find($fileId); + + if (!$file) { + return 404; + } + + if (!$isShare && $user->id != $file->user_id) { + return 403; + } + + return $file; + } + + /** + * Prepare a file object for the UI. + * + * @param object $object An object + * @param bool $full Include all object properties + * + * @return array Object information + */ + protected function objectToClient($object, bool $full = false): array + { + $result = ['id' => $object->id]; + + if ($full) { + $props = array_filter($object->getProperties(['name', 'size', 'mimetype'])); + + $props['size'] *= 1; // convert to int + $result += $props; + } + + return $result; + } +} diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php index ed7566af..257eafed 100644 --- a/src/app/Http/Controllers/API/V4/UsersController.php +++ b/src/app/Http/Controllers/API/V4/UsersController.php @@ -1,763 +1,764 @@ guard()->user(); $search = trim(request()->input('search')); $page = intval(request()->input('page')) ?: 1; $pageSize = 20; $hasMore = false; $result = $user->users(); // Search by user email, alias or name if (strlen($search) > 0) { // thanks to cloning we skip some extra queries in $user->users() $allUsers1 = clone $result; $allUsers2 = clone $result; $result->whereLike('email', $search) ->union( $allUsers1->join('user_aliases', 'users.id', '=', 'user_aliases.user_id') ->whereLike('alias', $search) ) ->union( $allUsers2->join('user_settings', 'users.id', '=', 'user_settings.user_id') ->whereLike('value', $search) ->whereIn('key', ['first_name', 'last_name']) ); } $result = $result->orderBy('email') ->limit($pageSize + 1) ->offset($pageSize * ($page - 1)) ->get(); if (count($result) > $pageSize) { $result->pop(); $hasMore = true; } // Process the result $result = $result->map( function ($user) { return $this->objectToClient($user); } ); $result = [ 'list' => $result, 'count' => count($result), 'hasMore' => $hasMore, ]; return response()->json($result); } /** * Display information on the user account specified by $id. * * @param string $id The account to show information for. * * @return \Illuminate\Http\JsonResponse */ public function show($id) { $user = User::find($id); if (!$this->checkTenant($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($user)) { return $this->errorResponse(403); } $response = $this->userResponse($user); $response['skus'] = \App\Entitlement::objectEntitlementsSummary($user); $response['config'] = $user->getConfig(); $response['aliases'] = $user->aliases()->pluck('alias')->all(); $code = $user->verificationcodes()->where('active', true) ->where('expires_at', '>', \Carbon\Carbon::now()) ->first(); if ($code) { $response['passwordLinkCode'] = $code->short_code . '-' . $code->code; } return response()->json($response); } /** * User status (extended) information * * @param \App\User $user User object * * @return array Status information */ public static function statusInfo($user): array { $process = self::processStateInfo( $user, [ 'user-new' => true, 'user-ldap-ready' => $user->isLdapReady(), 'user-imap-ready' => $user->isImapReady(), ] ); // Check if the user is a controller of his wallet $isController = $user->canDelete($user); $hasCustomDomain = $user->wallet()->entitlements() ->where('entitleable_type', Domain::class) ->count() > 0; // Get user's entitlements titles $skus = $user->entitlements()->select('skus.title') ->join('skus', 'skus.id', '=', 'entitlements.sku_id') ->get() ->pluck('title') ->sort() ->unique() ->values() ->all(); $result = [ 'skus' => $skus, // TODO: This will change when we enable all users to create domains 'enableDomains' => $isController && $hasCustomDomain, // TODO: Make 'enableDistlists' working for wallet controllers that aren't account owners 'enableDistlists' => $isController && $hasCustomDomain && in_array('beta-distlists', $skus), + 'enableFiles' => in_array('files', $skus), // TODO: Make 'enableFolders' working for wallet controllers that aren't account owners 'enableFolders' => $isController && $hasCustomDomain && in_array('beta-shared-folders', $skus), // TODO: Make 'enableResources' working for wallet controllers that aren't account owners 'enableResources' => $isController && $hasCustomDomain && in_array('beta-resources', $skus), 'enableSettings' => $isController, 'enableUsers' => $isController, 'enableWallets' => $isController, 'enableCompanionapps' => $isController && in_array('beta', $skus), ]; return array_merge($process, $result); } /** * Create a new user record. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { $current_user = $this->guard()->user(); $owner = $current_user->walletOwner(); if ($owner->id != $current_user->id) { return $this->errorResponse(403); } $this->deleteBeforeCreate = null; if ($error_response = $this->validateUserRequest($request, null, $settings)) { return $error_response; } if (empty($request->package) || !($package = \App\Package::withEnvTenantContext()->find($request->package))) { $errors = ['package' => \trans('validation.packagerequired')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } if ($package->isDomain()) { $errors = ['package' => \trans('validation.packageinvalid')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } DB::beginTransaction(); // @phpstan-ignore-next-line if ($this->deleteBeforeCreate) { $this->deleteBeforeCreate->forceDelete(); } // Create user record $user = User::create([ 'email' => $request->email, 'password' => $request->password, ]); $this->activatePassCode($user); $owner->assignPackage($package, $user); if (!empty($settings)) { $user->setSettings($settings); } if (!empty($request->aliases)) { $user->setAliases($request->aliases); } DB::commit(); return response()->json([ 'status' => 'success', 'message' => \trans('app.user-create-success'), ]); } /** * Update user data. * * @param \Illuminate\Http\Request $request The API request. * @param string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { $user = User::withEnvTenantContext()->find($id); if (empty($user)) { return $this->errorResponse(404); } $current_user = $this->guard()->user(); // TODO: Decide what attributes a user can change on his own profile if (!$current_user->canUpdate($user)) { return $this->errorResponse(403); } if ($error_response = $this->validateUserRequest($request, $user, $settings)) { return $error_response; } // Entitlements, only controller can do that if ($request->skus !== null && !$current_user->canDelete($user)) { return $this->errorResponse(422, "You have no permission to change entitlements"); } DB::beginTransaction(); $this->updateEntitlements($user, $request->skus); if (!empty($settings)) { $user->setSettings($settings); } if (!empty($request->password)) { $user->password = $request->password; $user->save(); } $this->activatePassCode($user); if (isset($request->aliases)) { $user->setAliases($request->aliases); } DB::commit(); $response = [ 'status' => 'success', 'message' => \trans('app.user-update-success'), ]; // For self-update refresh the statusInfo in the UI if ($user->id == $current_user->id) { $response['statusInfo'] = self::statusInfo($user); } return response()->json($response); } /** * Update user entitlements. * * @param \App\User $user The user * @param array $rSkus List of SKU IDs requested for the user in the form [id=>qty] */ protected function updateEntitlements(User $user, $rSkus) { if (!is_array($rSkus)) { return; } // list of skus, [id=>obj] $skus = Sku::withEnvTenantContext()->get()->mapWithKeys( function ($sku) { return [$sku->id => $sku]; } ); // existing entitlement's SKUs $eSkus = []; $user->entitlements()->groupBy('sku_id') ->selectRaw('count(*) as total, sku_id')->each( function ($e) use (&$eSkus) { $eSkus[$e->sku_id] = $e->total; } ); foreach ($skus as $skuID => $sku) { $e = array_key_exists($skuID, $eSkus) ? $eSkus[$skuID] : 0; $r = array_key_exists($skuID, $rSkus) ? $rSkus[$skuID] : 0; if ($sku->handler_class == \App\Handlers\Mailbox::class) { if ($r != 1) { throw new \Exception("Invalid quantity of mailboxes"); } } if ($e > $r) { // remove those entitled more than existing $user->removeSku($sku, ($e - $r)); } elseif ($e < $r) { // add those requested more than entitled $user->assignSku($sku, ($r - $e)); } } } /** * Create a response data array for specified user. * * @param \App\User $user User object * * @return array Response data */ public static function userResponse(User $user): array { $response = array_merge($user->toArray(), self::objectState($user)); // Settings $response['settings'] = []; foreach ($user->settings()->whereIn('key', self::USER_SETTINGS)->get() as $item) { $response['settings'][$item->key] = $item->value; } // Status info $response['statusInfo'] = self::statusInfo($user); // Add more info to the wallet object output $map_func = function ($wallet) use ($user) { $result = $wallet->toArray(); if ($wallet->discount) { $result['discount'] = $wallet->discount->discount; $result['discount_description'] = $wallet->discount->description; } if ($wallet->user_id != $user->id) { $result['user_email'] = $wallet->owner->email; } $provider = \App\Providers\PaymentProvider::factory($wallet); $result['provider'] = $provider->name(); return $result; }; // Information about wallets and accounts for access checks $response['wallets'] = $user->wallets->map($map_func)->toArray(); $response['accounts'] = $user->accounts->map($map_func)->toArray(); $response['wallet'] = $map_func($user->wallet()); return $response; } /** * Prepare user statuses for the UI * * @param \App\User $user User object * * @return array Statuses array */ protected static function objectState($user): array { $state = parent::objectState($user); $state['isAccountDegraded'] = $user->isDegraded(true); return $state; } /** * Validate user input * * @param \Illuminate\Http\Request $request The API request. * @param \App\User|null $user User identifier * @param array $settings User settings (from the request) * * @return \Illuminate\Http\JsonResponse|null The error response on error */ protected function validateUserRequest(Request $request, $user, &$settings = []) { $rules = [ 'external_email' => 'nullable|email', 'phone' => 'string|nullable|max:64|regex:/^[0-9+() -]+$/', 'first_name' => 'string|nullable|max:128', 'last_name' => 'string|nullable|max:128', 'organization' => 'string|nullable|max:512', 'billing_address' => 'string|nullable|max:1024', 'country' => 'string|nullable|alpha|size:2', 'currency' => 'string|nullable|alpha|size:3', 'aliases' => 'array|nullable', ]; $controller = ($user ?: $this->guard()->user())->walletOwner(); // Handle generated password reset code if ($code = $request->input('passwordLinkCode')) { // Accept - input if (strpos($code, '-')) { $code = explode('-', $code)[1]; } $this->passCode = $this->guard()->user()->verificationcodes() ->where('code', $code)->where('active', false)->first(); // Generate a password for a new user with password reset link // FIXME: Should/can we have a user with no password set? if ($this->passCode && empty($user)) { $request->password = $request->password_confirmation = Str::random(16); $ignorePassword = true; } } if (empty($user) || !empty($request->password) || !empty($request->password_confirmation)) { if (empty($ignorePassword)) { $rules['password'] = ['required', 'confirmed', new Password($controller)]; } } $errors = []; // Validate input $v = Validator::make($request->all(), $rules); if ($v->fails()) { $errors = $v->errors()->toArray(); } // For new user validate email address if (empty($user)) { $email = $request->email; if (empty($email)) { $errors['email'] = \trans('validation.required', ['attribute' => 'email']); } elseif ($error = self::validateEmail($email, $controller, $this->deleteBeforeCreate)) { $errors['email'] = $error; } } // Validate aliases input if (isset($request->aliases)) { $aliases = []; $existing_aliases = $user ? $user->aliases()->get()->pluck('alias')->toArray() : []; foreach ($request->aliases as $idx => $alias) { if (is_string($alias) && !empty($alias)) { // Alias cannot be the same as the email address (new user) if (!empty($email) && Str::lower($alias) == Str::lower($email)) { continue; } // validate new aliases if ( !in_array($alias, $existing_aliases) && ($error = self::validateAlias($alias, $controller)) ) { if (!isset($errors['aliases'])) { $errors['aliases'] = []; } $errors['aliases'][$idx] = $error; continue; } $aliases[] = $alias; } } $request->aliases = $aliases; } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } // Update user settings $settings = $request->only(array_keys($rules)); unset($settings['password'], $settings['aliases'], $settings['email']); return null; } /** * Execute (synchronously) specified step in a user setup process. * * @param \App\User $user User object * @param string $step Step identifier (as in self::statusInfo()) * * @return bool|null True if the execution succeeded, False if not, Null when * the job has been sent to the worker (result unknown) */ public static function execProcessStep(User $user, string $step): ?bool { try { if (strpos($step, 'domain-') === 0) { list ($local, $domain) = explode('@', $user->email); $domain = Domain::where('namespace', $domain)->first(); return DomainsController::execProcessStep($domain, $step); } switch ($step) { case 'user-ldap-ready': // User not in LDAP, create it $job = new \App\Jobs\User\CreateJob($user->id); $job->handle(); $user->refresh(); return $user->isLdapReady(); case 'user-imap-ready': // User not in IMAP? Verify again // Do it synchronously if the imap admin credentials are available // otherwise let the worker do the job if (!\config('imap.admin_password')) { \App\Jobs\User\VerifyJob::dispatch($user->id); return null; } $job = new \App\Jobs\User\VerifyJob($user->id); $job->handle(); $user->refresh(); return $user->isImapReady(); } } catch (\Exception $e) { \Log::error($e); } return false; } /** * Email address validation for use as a user mailbox (login). * * @param string $email Email address * @param \App\User $user The account owner * @param null|\App\User|\App\Group $deleted Filled with an instance of a deleted user or group * with the specified email address, if exists * * @return ?string Error message on validation error */ public static function validateEmail(string $email, \App\User $user, &$deleted = null): ?string { $deleted = null; if (strpos($email, '@') === false) { return \trans('validation.entryinvalid', ['attribute' => 'email']); } list($login, $domain) = explode('@', Str::lower($email)); if (strlen($login) === 0 || strlen($domain) === 0) { return \trans('validation.entryinvalid', ['attribute' => 'email']); } // Check if domain exists $domain = Domain::withObjectTenantContext($user)->where('namespace', $domain)->first(); if (empty($domain)) { return \trans('validation.domaininvalid'); } // Validate login part alone $v = Validator::make( ['email' => $login], ['email' => ['required', new UserEmailLocal(!$domain->isPublic())]] ); if ($v->fails()) { return $v->errors()->toArray()['email'][0]; } // Check if it is one of domains available to the user if (!$domain->isPublic() && $user->id != $domain->walletOwner()->id) { return \trans('validation.entryexists', ['attribute' => 'domain']); } // Check if a user/group/resource/shared folder with specified address already exists if ( ($existing = User::emailExists($email, true)) || ($existing = \App\Group::emailExists($email, true)) || ($existing = \App\Resource::emailExists($email, true)) || ($existing = \App\SharedFolder::emailExists($email, true)) ) { // If this is a deleted user/group/resource/folder in the same custom domain // we'll force delete it before creating the target user if (!$domain->isPublic() && $existing->trashed()) { $deleted = $existing; } else { return \trans('validation.entryexists', ['attribute' => 'email']); } } // Check if an alias with specified address already exists. if (User::aliasExists($email) || \App\SharedFolder::aliasExists($email)) { return \trans('validation.entryexists', ['attribute' => 'email']); } return null; } /** * Email address validation for use as an alias. * * @param string $email Email address * @param \App\User $user The account owner * * @return ?string Error message on validation error */ public static function validateAlias(string $email, \App\User $user): ?string { if (strpos($email, '@') === false) { return \trans('validation.entryinvalid', ['attribute' => 'alias']); } list($login, $domain) = explode('@', Str::lower($email)); if (strlen($login) === 0 || strlen($domain) === 0) { return \trans('validation.entryinvalid', ['attribute' => 'alias']); } // Check if domain exists $domain = Domain::withObjectTenantContext($user)->where('namespace', $domain)->first(); if (empty($domain)) { return \trans('validation.domaininvalid'); } // Validate login part alone $v = Validator::make( ['alias' => $login], ['alias' => ['required', new UserEmailLocal(!$domain->isPublic())]] ); if ($v->fails()) { return $v->errors()->toArray()['alias'][0]; } // Check if it is one of domains available to the user if (!$domain->isPublic() && $user->id != $domain->walletOwner()->id) { return \trans('validation.entryexists', ['attribute' => 'domain']); } // Check if a user with specified address already exists if ($existing_user = User::emailExists($email, true)) { // Allow an alias in a custom domain to an address that was a user before if ($domain->isPublic() || !$existing_user->trashed()) { return \trans('validation.entryexists', ['attribute' => 'alias']); } } // Check if a group/resource/shared folder with specified address already exists if ( \App\Group::emailExists($email) || \App\Resource::emailExists($email) || \App\SharedFolder::emailExists($email) ) { return \trans('validation.entryexists', ['attribute' => 'alias']); } // Check if an alias with specified address already exists if (User::aliasExists($email) || \App\SharedFolder::aliasExists($email)) { // Allow assigning the same alias to a user in the same group account, // but only for non-public domains if ($domain->isPublic()) { return \trans('validation.entryexists', ['attribute' => 'alias']); } } return null; } /** * Activate password reset code (if set), and assign it to a user. * * @param \App\User $user The user */ protected function activatePassCode(User $user): void { // Activate the password reset code if ($this->passCode) { $this->passCode->user_id = $user->id; $this->passCode->active = true; $this->passCode->save(); } } } diff --git a/src/app/Http/Middleware/ContentSecurityPolicy.php b/src/app/Http/Middleware/ContentSecurityPolicy.php index f13d7719..e0083e30 100644 --- a/src/app/Http/Middleware/ContentSecurityPolicy.php +++ b/src/app/Http/Middleware/ContentSecurityPolicy.php @@ -1,34 +1,34 @@ 'Content-Security-Policy', 'xfo' => 'X-Frame-Options', ]; $next = $next($request); foreach ($headers as $opt => $header) { if ($value = \config("app.headers.{$opt}")) { - $next->header($header, $value); + $next->headers->set($header, $value); } } return $next; } } diff --git a/src/app/Rules/FileName.php b/src/app/Rules/FileName.php new file mode 100644 index 00000000..2d952d8d --- /dev/null +++ b/src/app/Rules/FileName.php @@ -0,0 +1,83 @@ +owner = $owner; + } + + /** + * Determine if the validation rule passes. + * + * @param string $attribute Attribute name + * @param mixed $name The value to validate + * + * @return bool + */ + public function passes($attribute, $name): bool + { + if (empty($name) || !is_string($name)) { + $this->message = \trans('validation.file-name-invalid'); + return false; + } + + // Check the max length, according to the database column length + if (strlen($name) > 512) { + $this->message = \trans('validation.max.string', ['max' => 512]); + return false; + } + + // Non-allowed characters + if (preg_match('|[\x00-\x1F\/*"\x7F]|', $name)) { + $this->message = \trans('validation.file-name-invalid'); + return false; + } + + // Leading/trailing spaces, or all spaces + if (preg_match('|^\s+$|', $name) || preg_match('|^\s+|', $name) || preg_match('|\s+$|', $name)) { + $this->message = \trans('validation.file-name-invalid'); + return false; + } + + // FIXME: Should we require a dot? + + // Check if the name is unique + $exists = $this->owner->fsItems() + ->join('fs_properties', 'fs_items.id', '=', 'fs_properties.item_id') + ->where('key', 'name') + ->where('value', $name) + ->exists(); + + if ($exists) { + $this->message = \trans('validation.file-name-exists'); + return false; + } + + return true; + } + + /** + * Get the validation error message. + * + * @return string + */ + public function message(): ?string + { + return $this->message; + } +} diff --git a/src/app/User.php b/src/app/User.php index 84009c57..7e07e048 100644 --- a/src/app/User.php +++ b/src/app/User.php @@ -1,709 +1,719 @@ The attributes that are mass assignable */ protected $fillable = [ 'id', 'email', 'password', 'password_ldap', 'status', ]; /** @var array The attributes that should be hidden for arrays */ protected $hidden = [ 'password', 'password_ldap', 'role' ]; /** @var array The attributes that can be null */ protected $nullable = [ 'password', 'password_ldap' ]; /** @var array The attributes that should be cast */ protected $casts = [ 'created_at' => 'datetime:Y-m-d H:i:s', 'deleted_at' => 'datetime:Y-m-d H:i:s', 'updated_at' => 'datetime:Y-m-d H:i:s', ]; /** * Any wallets on which this user is a controller. * * This does not include wallets owned by the user. * * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany */ public function accounts() { return $this->belongsToMany( Wallet::class, // The foreign object definition 'user_accounts', // The table name 'user_id', // The local foreign key 'wallet_id' // The remote foreign key ); } /** * Assign a package to a user. The user should not have any existing entitlements. * * @param \App\Package $package The package to assign. * @param \App\User|null $user Assign the package to another user. * * @return \App\User */ public function assignPackage($package, $user = null) { if (!$user) { $user = $this; } return $user->assignPackageAndWallet($package, $this->wallets()->first()); } /** * Assign a package plan to a user. * * @param \App\Plan $plan The plan to assign * @param \App\Domain $domain Optional domain object * * @return \App\User Self */ public function assignPlan($plan, $domain = null): User { $this->setSetting('plan_id', $plan->id); foreach ($plan->packages as $package) { if ($package->isDomain()) { $domain->assignPackage($package, $this); } else { $this->assignPackage($package); } } return $this; } /** * Check if current user can delete another object. * * @param mixed $object A user|domain|wallet|group object * * @return bool True if he can, False otherwise */ public function canDelete($object): bool { if (!method_exists($object, 'wallet')) { return false; } $wallet = $object->wallet(); // TODO: For now controller can delete/update the account owner, // this may change in future, controllers are not 0-regression feature return $wallet && ($wallet->user_id == $this->id || $this->accounts->contains($wallet)); } /** * Check if current user can read data of another object. * * @param mixed $object A user|domain|wallet|group object * * @return bool True if he can, False otherwise */ public function canRead($object): bool { if ($this->role == 'admin') { return true; } if ($object instanceof User && $this->id == $object->id) { return true; } if ($this->role == 'reseller') { if ($object instanceof User && $object->role == 'admin') { return false; } if ($object instanceof Wallet && !empty($object->owner)) { $object = $object->owner; } return isset($object->tenant_id) && $object->tenant_id == $this->tenant_id; } if ($object instanceof Wallet) { return $object->user_id == $this->id || $object->controllers->contains($this); } if (!method_exists($object, 'wallet')) { return false; } $wallet = $object->wallet(); return $wallet && ($wallet->user_id == $this->id || $this->accounts->contains($wallet)); } /** * Check if current user can update data of another object. * * @param mixed $object A user|domain|wallet|group object * * @return bool True if he can, False otherwise */ public function canUpdate($object): bool { if ($object instanceof User && $this->id == $object->id) { return true; } if ($this->role == 'admin') { return true; } if ($this->role == 'reseller') { if ($object instanceof User && $object->role == 'admin') { return false; } if ($object instanceof Wallet && !empty($object->owner)) { $object = $object->owner; } return isset($object->tenant_id) && $object->tenant_id == $this->tenant_id; } return $this->canDelete($object); } /** * Degrade the user * * @return void */ public function degrade(): void { if ($this->isDegraded()) { return; } $this->status |= User::STATUS_DEGRADED; $this->save(); } /** * List the domains to which this user is entitled. * * @param bool $with_accounts Include domains assigned to wallets * the current user controls but not owns. * @param bool $with_public Include active public domains (for the user tenant). * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function domains($with_accounts = true, $with_public = true) { $domains = $this->entitleables(Domain::class, $with_accounts); if ($with_public) { $domains->orWhere(function ($query) { if (!$this->tenant_id) { $query->where('tenant_id', $this->tenant_id); } else { $query->withEnvTenantContext(); } $query->where('domains.type', '&', Domain::TYPE_PUBLIC) ->where('domains.status', '&', Domain::STATUS_ACTIVE); }); } return $domains; } /** * Return entitleable objects of a specified type controlled by the current user. * * @param string $class Object class * @param bool $with_accounts Include objects assigned to wallets * the current user controls, but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ private function entitleables(string $class, bool $with_accounts = true) { $wallets = $this->wallets()->pluck('id')->all(); if ($with_accounts) { $wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all()); } $object = new $class(); $table = $object->getTable(); return $object->select("{$table}.*") ->whereExists(function ($query) use ($table, $wallets, $class) { $query->select(DB::raw(1)) ->from('entitlements') ->whereColumn('entitleable_id', "{$table}.id") ->whereIn('entitlements.wallet_id', $wallets) ->where('entitlements.entitleable_type', $class); }); } /** * Helper to find user by email address, whether it is * main email address, alias or an external email. * * If there's more than one alias NULL will be returned. * * @param string $email Email address * @param bool $external Search also for an external email * * @return \App\User|null User model object if found */ public static function findByEmail(string $email, bool $external = false): ?User { if (strpos($email, '@') === false) { return null; } $email = \strtolower($email); $user = self::where('email', $email)->first(); if ($user) { return $user; } $aliases = UserAlias::where('alias', $email)->get(); if (count($aliases) == 1) { return $aliases->first()->user; } // TODO: External email return null; } + /** + * Storage items for this user. + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function fsItems() + { + return $this->hasMany(Fs\Item::class); + } + /** * Return groups controlled by the current user. * * @param bool $with_accounts Include groups assigned to wallets * the current user controls but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function groups($with_accounts = true) { return $this->entitleables(Group::class, $with_accounts); } /** * Returns whether this user (or its wallet owner) is degraded. * * @param bool $owner Check also the wallet owner instead just the user himself * * @return bool */ public function isDegraded(bool $owner = false): bool { if ($this->status & self::STATUS_DEGRADED) { return true; } if ($owner && ($wallet = $this->wallet())) { return $wallet->owner && $wallet->owner->isDegraded(); } return false; } /** * A shortcut to get the user name. * * @param bool $fallback Return " User" if there's no name * * @return string Full user name */ public function name(bool $fallback = false): string { $settings = $this->getSettings(['first_name', 'last_name']); $name = trim($settings['first_name'] . ' ' . $settings['last_name']); if (empty($name) && $fallback) { return trim(\trans('app.siteuser', ['site' => Tenant::getConfig($this->tenant_id, 'app.name')])); } return $name; } /** * Old passwords for this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function passwords() { return $this->hasMany(UserPassword::class); } /** * Return resources controlled by the current user. * * @param bool $with_accounts Include resources assigned to wallets * the current user controls but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function resources($with_accounts = true) { return $this->entitleables(Resource::class, $with_accounts); } /** * Return shared folders controlled by the current user. * * @param bool $with_accounts Include folders assigned to wallets * the current user controls but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function sharedFolders($with_accounts = true) { return $this->entitleables(SharedFolder::class, $with_accounts); } public function senderPolicyFrameworkWhitelist($clientName) { $setting = $this->getSetting('spf_whitelist'); if (!$setting) { return false; } $whitelist = json_decode($setting); $matchFound = false; foreach ($whitelist as $entry) { if (substr($entry, 0, 1) == '/') { $match = preg_match($entry, $clientName); if ($match) { $matchFound = true; } continue; } if (substr($entry, 0, 1) == '.') { if (substr($clientName, (-1 * strlen($entry))) == $entry) { $matchFound = true; } continue; } if ($entry == $clientName) { $matchFound = true; continue; } } return $matchFound; } /** * Un-degrade this user. * * @return void */ public function undegrade(): void { if (!$this->isDegraded()) { return; } $this->status ^= User::STATUS_DEGRADED; $this->save(); } /** * Return users controlled by the current user. * * @param bool $with_accounts Include users assigned to wallets * the current user controls but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function users($with_accounts = true) { return $this->entitleables(User::class, $with_accounts); } /** * Verification codes for this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function verificationcodes() { return $this->hasMany(VerificationCode::class, 'user_id', 'id'); } /** * Wallets this user owns. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function wallets() { return $this->hasMany(Wallet::class); } /** * User password mutator * * @param string $password The password in plain text. * * @return void */ public function setPasswordAttribute($password) { if (!empty($password)) { $this->attributes['password'] = Hash::make($password); $this->attributes['password_ldap'] = '{SSHA512}' . base64_encode( pack('H*', hash('sha512', $password)) ); } } /** * User LDAP password mutator * * @param string $password The password in plain text. * * @return void */ public function setPasswordLdapAttribute($password) { $this->setPasswordAttribute($password); } /** * User status mutator * * @throws \Exception */ public function setStatusAttribute($status) { $new_status = 0; $allowed_values = [ self::STATUS_NEW, self::STATUS_ACTIVE, self::STATUS_SUSPENDED, self::STATUS_DELETED, self::STATUS_LDAP_READY, self::STATUS_IMAP_READY, self::STATUS_DEGRADED, ]; foreach ($allowed_values as $value) { if ($status & $value) { $new_status |= $value; $status ^= $value; } } if ($status > 0) { throw new \Exception("Invalid user status: {$status}"); } $this->attributes['status'] = $new_status; } /** * Validate the user credentials * * @param string $username The username. * @param string $password The password in plain text. * @param bool $updatePassword Store the password if currently empty * * @return bool true on success */ public function validateCredentials(string $username, string $password, bool $updatePassword = true): bool { $authenticated = false; if ($this->email === \strtolower($username)) { if (!empty($this->password)) { if (Hash::check($password, $this->password)) { $authenticated = true; } } elseif (!empty($this->password_ldap)) { if (substr($this->password_ldap, 0, 6) == "{SSHA}") { $salt = substr(base64_decode(substr($this->password_ldap, 6)), 20); $hash = '{SSHA}' . base64_encode( sha1($password . $salt, true) . $salt ); if ($hash == $this->password_ldap) { $authenticated = true; } } elseif (substr($this->password_ldap, 0, 9) == "{SSHA512}") { $salt = substr(base64_decode(substr($this->password_ldap, 9)), 64); $hash = '{SSHA512}' . base64_encode( pack('H*', hash('sha512', $password . $salt)) . $salt ); if ($hash == $this->password_ldap) { $authenticated = true; } } } else { \Log::error("Incomplete credentials for {$this->email}"); } } if ($authenticated) { \Log::info("Successful authentication for {$this->email}"); // TODO: update last login time if ($updatePassword && (empty($this->password) || empty($this->password_ldap))) { $this->password = $password; $this->save(); } } else { // TODO: Try actual LDAP? \Log::info("Authentication failed for {$this->email}"); } return $authenticated; } /** * Retrieve and authenticate a user * * @param string $username The username. * @param string $password The password in plain text. * @param string $secondFactor The second factor (secondfactor from current request is used as fallback). * * @return array ['user', 'reason', 'errorMessage'] */ public static function findAndAuthenticate($username, $password, $secondFactor = null): ?array { $user = User::where('email', $username)->first(); if (!$user) { return ['reason' => 'notfound', 'errorMessage' => "User not found."]; } if (!$user->validateCredentials($username, $password)) { return ['reason' => 'credentials', 'errorMessage' => "Invalid password."]; } if (!$secondFactor) { // Check the request if there is a second factor provided // as fallback. $secondFactor = request()->secondfactor; } try { (new \App\Auth\SecondFactor($user))->validate($secondFactor); } catch (\Exception $e) { return ['reason' => 'secondfactor', 'errorMessage' => $e->getMessage()]; } return ['user' => $user]; } /** * Hook for passport * * @throws \Throwable * * @return \App\User User model object if found */ public function findAndValidateForPassport($username, $password): User { $result = self::findAndAuthenticate($username, $password); if (isset($result['reason'])) { if ($result['reason'] == 'secondfactor') { // This results in a json response of {'error': 'secondfactor', 'error_description': '$errorMessage'} throw new OAuthServerException($result['errorMessage'], 6, 'secondfactor', 401); } throw OAuthServerException::invalidCredentials(); } return $result['user']; } } diff --git a/src/app/Wallet.php b/src/app/Wallet.php index 36280ded..274fdb3a 100644 --- a/src/app/Wallet.php +++ b/src/app/Wallet.php @@ -1,491 +1,492 @@ 0, ]; /** @var array The attributes that are mass assignable */ protected $fillable = [ 'currency', 'description' ]; /** @var array The attributes that can be not set */ protected $nullable = [ 'description', ]; /** @var array The types of attributes to which its values will be cast */ protected $casts = [ 'balance' => 'integer', ]; /** * Add a controller to this wallet. * * @param \App\User $user The user to add as a controller to this wallet. * * @return void */ public function addController(User $user) { if (!$this->controllers->contains($user)) { $this->controllers()->save($user); } } /** * Charge entitlements in the wallet * * @param bool $apply Set to false for a dry-run mode * * @return int Charged amount in cents */ public function chargeEntitlements($apply = true): int { // This wallet has been created less than a month ago, this is the trial period if ($this->owner->created_at >= Carbon::now()->subMonthsWithoutOverflow(1)) { // Move all the current entitlement's updated_at timestamps forward to one month after // this wallet was created. $freeMonthEnds = $this->owner->created_at->copy()->addMonthsWithoutOverflow(1); foreach ($this->entitlements()->get()->fresh() as $entitlement) { if ($entitlement->updated_at < $freeMonthEnds) { $entitlement->updated_at = $freeMonthEnds; $entitlement->save(); } } return 0; } $profit = 0; $charges = 0; $discount = $this->getDiscountRate(); $isDegraded = $this->owner->isDegraded(); if ($apply) { DB::beginTransaction(); } // used to parent individual entitlement billings to the wallet debit. $entitlementTransactions = []; foreach ($this->entitlements()->get() as $entitlement) { // This entitlement has been created less than or equal to 14 days ago (this is at // maximum the fourteenth 24-hour period). if ($entitlement->created_at > Carbon::now()->subDays(14)) { continue; } // This entitlement was created, or billed last, less than a month ago. if ($entitlement->updated_at > Carbon::now()->subMonthsWithoutOverflow(1)) { continue; } // updated last more than a month ago -- was it billed? if ($entitlement->updated_at <= Carbon::now()->subMonthsWithoutOverflow(1)) { $diff = $entitlement->updated_at->diffInMonths(Carbon::now()); $cost = (int) ($entitlement->cost * $discount * $diff); $fee = (int) ($entitlement->fee * $diff); if ($isDegraded) { $cost = 0; } $charges += $cost; $profit += $cost - $fee; // if we're in dry-run, you know... if (!$apply) { continue; } $entitlement->updated_at = $entitlement->updated_at->copy() ->addMonthsWithoutOverflow($diff); $entitlement->save(); if ($cost == 0) { continue; } $entitlementTransactions[] = $entitlement->createTransaction( Transaction::ENTITLEMENT_BILLED, $cost ); } } if ($apply) { $this->debit($charges, '', $entitlementTransactions); // Credit/debit the reseller if ($profit != 0 && $this->owner->tenant) { // FIXME: Should we have a simpler way to skip this for non-reseller tenant(s) if ($wallet = $this->owner->tenant->wallet()) { $desc = "Charged user {$this->owner->email}"; $method = $profit > 0 ? 'credit' : 'debit'; $wallet->{$method}(abs($profit), $desc); } } DB::commit(); } return $charges; } /** * Calculate for how long the current balance will last. * * Returns NULL for balance < 0 or discount = 100% or on a fresh account * * @return \Carbon\Carbon|null Date */ public function balanceLastsUntil() { if ($this->balance < 0 || $this->getDiscount() == 100) { return null; } // retrieve any expected charges $expectedCharge = $this->expectedCharges(); // get the costs per day for all entitlements billed against this wallet $costsPerDay = $this->costsPerDay(); if (!$costsPerDay) { return null; } // the number of days this balance, minus the expected charges, would last $daysDelta = floor(($this->balance - $expectedCharge) / $costsPerDay); // calculate from the last entitlement billed $entitlement = $this->entitlements()->orderBy('updated_at', 'desc')->first(); $until = $entitlement->updated_at->copy()->addDays($daysDelta); // Don't return dates from the past if ($until < Carbon::now() && !$until->isToday()) { return null; } return $until; } /** * Controllers of this wallet. * * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany */ public function controllers() { return $this->belongsToMany( User::class, // The foreign object definition 'user_accounts', // The table name 'wallet_id', // The local foreign key 'user_id' // The remote foreign key ); } /** * Retrieve the costs per day of everything charged to this wallet. * * @return float */ public function costsPerDay() { $costs = (float) 0; foreach ($this->entitlements as $entitlement) { $costs += $entitlement->costsPerDay(); } return $costs; } /** * Add an amount of pecunia to this wallet's balance. * * @param int $amount The amount of pecunia to add (in cents). * @param string $description The transaction description * * @return Wallet Self */ public function credit(int $amount, string $description = ''): Wallet { $this->balance += $amount; $this->save(); Transaction::create( [ 'object_id' => $this->id, 'object_type' => Wallet::class, 'type' => Transaction::WALLET_CREDIT, 'amount' => $amount, 'description' => $description ] ); return $this; } /** * Deduct an amount of pecunia from this wallet's balance. * * @param int $amount The amount of pecunia to deduct (in cents). * @param string $description The transaction description * @param array $eTIDs List of transaction IDs for the individual entitlements * that make up this debit record, if any. * @return Wallet Self */ public function debit(int $amount, string $description = '', array $eTIDs = []): Wallet { if ($amount == 0) { return $this; } $this->balance -= $amount; $this->save(); $transaction = Transaction::create( [ 'object_id' => $this->id, 'object_type' => Wallet::class, 'type' => Transaction::WALLET_DEBIT, 'amount' => $amount * -1, 'description' => $description ] ); if (!empty($eTIDs)) { Transaction::whereIn('id', $eTIDs)->update(['transaction_id' => $transaction->id]); } return $this; } /** * The discount assigned to the wallet. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function discount() { return $this->belongsTo(Discount::class, 'discount_id', 'id'); } /** * Entitlements billed to this wallet. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function entitlements() { return $this->hasMany(Entitlement::class); } /** * Calculate the expected charges to this wallet. * * @return int */ public function expectedCharges() { return $this->chargeEntitlements(false); } /** * Return the exact, numeric version of the discount to be applied. * * Ranges from 0 - 100. * * @return int */ public function getDiscount() { return $this->discount ? $this->discount->discount : 0; } /** * The actual discount rate for use in multiplication * * Ranges from 0.00 to 1.00. */ public function getDiscountRate() { return (100 - $this->getDiscount()) / 100; } /** * A helper to display human-readable amount of money using * the wallet currency and specified locale. * * @param int $amount A amount of money (in cents) * @param string $locale A locale for the output * * @return string String representation, e.g. "9.99 CHF" */ public function money(int $amount, $locale = 'de_DE') { $amount = round($amount / 100, 2); $nf = new \NumberFormatter($locale, \NumberFormatter::CURRENCY); $result = $nf->formatCurrency($amount, $this->currency); // Replace non-breaking space return str_replace("\xC2\xA0", " ", $result); } /** * The owner of the wallet -- the wallet is in his/her back pocket. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function owner() { return $this->belongsTo(User::class, 'user_id', 'id'); } /** * Payments on this wallet. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function payments() { return $this->hasMany(Payment::class); } /** * Remove a controller from this wallet. * * @param \App\User $user The user to remove as a controller from this wallet. * * @return void */ public function removeController(User $user) { if ($this->controllers->contains($user)) { $this->controllers()->detach($user); } } /** * Retrieve the transactions against this wallet. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function transactions() { return Transaction::where( [ 'object_id' => $this->id, 'object_type' => Wallet::class ] ); } /** * Force-update entitlements' updated_at, charge if needed. * * @param bool $withCost When enabled the cost will be charged * * @return int Charged amount in cents */ public function updateEntitlements($withCost = true): int { $charges = 0; $discount = $this->getDiscountRate(); $now = Carbon::now(); DB::beginTransaction(); // used to parent individual entitlement billings to the wallet debit. $entitlementTransactions = []; foreach ($this->entitlements()->get() as $entitlement) { $cost = 0; $diffInDays = $entitlement->updated_at->diffInDays($now); // This entitlement has been created less than or equal to 14 days ago (this is at // maximum the fourteenth 24-hour period). if ($entitlement->created_at > Carbon::now()->subDays(14)) { // $cost=0 } elseif ($withCost && $diffInDays > 0) { // The price per day is based on the number of days in the last month // or the current month if the period does not overlap with the previous month // FIXME: This really should be simplified to constant $daysInMonth=30 if ($now->day >= $diffInDays && $now->month == $entitlement->updated_at->month) { $daysInMonth = $now->daysInMonth; } else { $daysInMonth = \App\Utils::daysInLastMonth(); } $pricePerDay = $entitlement->cost / $daysInMonth; $cost = (int) (round($pricePerDay * $discount * $diffInDays, 0)); } if ($diffInDays > 0) { $entitlement->updated_at = $entitlement->updated_at->setDateFrom($now); $entitlement->save(); } if ($cost == 0) { continue; } $charges += $cost; // FIXME: Shouldn't we store also cost=0 transactions (to have the full history)? $entitlementTransactions[] = $entitlement->createTransaction( Transaction::ENTITLEMENT_BILLED, $cost ); } if ($charges > 0) { $this->debit($charges, '', $entitlementTransactions); } DB::commit(); return $charges; } } diff --git a/src/config/filesystems.php b/src/config/filesystems.php index 9bf04e7e..6d5f46e4 100644 --- a/src/config/filesystems.php +++ b/src/config/filesystems.php @@ -1,78 +1,83 @@ env('FILESYSTEM_DISK', 'local'), /* |-------------------------------------------------------------------------- | Filesystem Disks |-------------------------------------------------------------------------- | | Here you may configure as many filesystem "disks" as you wish, and you | may even configure multiple disks of the same driver. Defaults have | been setup for each driver as an example of the required options. | | Supported Drivers: "local", "ftp", "sftp", "s3" | */ 'disks' => [ 'local' => [ 'driver' => 'local', 'root' => storage_path('app'), ], + 'files' => [ + 'driver' => 'local', + 'root' => storage_path('app/files'), + ], + 'pgp' => [ 'driver' => 'local', 'root' => storage_path('app/keys'), ], 'public' => [ 'driver' => 'local', 'root' => storage_path('app/public'), 'url' => env('APP_URL') . '/storage', 'visibility' => 'public', ], 's3' => [ 'driver' => 's3', 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), 'region' => env('AWS_DEFAULT_REGION'), 'bucket' => env('AWS_BUCKET'), 'url' => env('AWS_URL'), 'endpoint' => env('AWS_ENDPOINT'), 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), ], ], /* |-------------------------------------------------------------------------- | Symbolic Links |-------------------------------------------------------------------------- | | Here you may configure the symbolic links that will be created when the | `storage:link` Artisan command is executed. The array keys should be | the locations of the links and the values should be their targets. | */ 'links' => [ public_path('storage') => storage_path('app/public'), ], ]; diff --git a/src/config/octane.php b/src/config/octane.php index b89f15b5..bf118711 100644 --- a/src/config/octane.php +++ b/src/config/octane.php @@ -1,246 +1,246 @@ env('OCTANE_SERVER', 'swoole'), /* |-------------------------------------------------------------------------- | Force HTTPS |-------------------------------------------------------------------------- | | When this configuration value is set to "true", Octane will inform the | framework that all absolute links must be generated using the HTTPS | protocol. Otherwise your links may be generated using plain HTTP. | */ 'https' => env('OCTANE_HTTPS', true), /* |-------------------------------------------------------------------------- | Octane Listeners |-------------------------------------------------------------------------- | | All of the event listeners for Octane's events are defined below. These | listeners are responsible for resetting your application's state for | the next request. You may even add your own listeners to the list. | */ 'listeners' => [ WorkerStarting::class => [ EnsureUploadedFilesAreValid::class, EnsureUploadedFilesCanBeMoved::class, ], RequestReceived::class => [ ...Octane::prepareApplicationForNextOperation(), ...Octane::prepareApplicationForNextRequest(), // ], RequestHandled::class => [ // ], RequestTerminated::class => [ // FlushUploadedFiles::class, ], TaskReceived::class => [ ...Octane::prepareApplicationForNextOperation(), // ], TaskTerminated::class => [ // ], TickReceived::class => [ ...Octane::prepareApplicationForNextOperation(), // ], TickTerminated::class => [ // ], OperationTerminated::class => [ FlushTemporaryContainerInstances::class, // DisconnectFromDatabases::class, CollectGarbage::class, ], WorkerErrorOccurred::class => [ ReportException::class, StopWorkerIfNecessary::class, ], WorkerStopping::class => [ // ], ], /* |-------------------------------------------------------------------------- | Warm / Flush Bindings |-------------------------------------------------------------------------- | | The bindings listed below will either be pre-warmed when a worker boots | or they will be flushed before every new request. Flushing a binding | will force the container to resolve that binding again when asked. | */ 'warm' => [ ...Octane::defaultServicesToWarm(), ], 'flush' => [ ], /* |-------------------------------------------------------------------------- | Octane Cache Table |-------------------------------------------------------------------------- | | While using Swoole, you may leverage the Octane cache, which is powered | by a Swoole table. You may set the maximum number of rows as well as | the number of bytes per row using the configuration options below. | */ 'cache' => [ 'rows' => 1000, 'bytes' => 10000, ], /* |-------------------------------------------------------------------------- | Octane Swoole Tables |-------------------------------------------------------------------------- | | While using Swoole, you may define additional tables as required by the | application. These tables can be used to store data that needs to be | quickly accessed by other workers on the particular Swoole server. | */ 'tables' => [ /* 'example:1000' => [ 'name' => 'string:1000', 'votes' => 'int', ], */ ], /* |-------------------------------------------------------------------------- | File Watching |-------------------------------------------------------------------------- | | The following list of files and directories will be watched when using | the --watch option offered by Octane. If any of the directories and | files are changed, Octane will automatically reload your workers. | */ 'watch' => [ 'app', 'bootstrap', 'config', 'database', 'public/**/*.php', 'resources/**/*.php', 'routes', 'composer.lock', '.env', ], /* |-------------------------------------------------------------------------- | Garbage Collection Threshold |-------------------------------------------------------------------------- | | When executing long-lived PHP scripts such as Octane, memory can build | up before being cleared by PHP. You can force Octane to run garbage | collection if your application consumes this amount of megabytes. | */ 'garbage' => 64, /* |-------------------------------------------------------------------------- | Maximum Execution Time |-------------------------------------------------------------------------- | | The following setting configures the maximum execution time for requests | being handled by Octane. You may set this value to 0 to indicate that | there isn't a specific time limit on Octane request execution time. | */ 'max_execution_time' => 30, /* |-------------------------------------------------------------------------- | Swoole configuration |-------------------------------------------------------------------------- | | See Laravel\Octane\Command\StartSwooleCommand */ 'swoole' => [ 'options' => [ 'log_file' => storage_path('logs/swoole_http.log'), - 'package_max_length' => 10 * 1024 * 1024, + 'package_max_length' => env('SWOOLE_PACKAGE_MAX_LENGTH', 10 * 1024 * 1024), 'enable_coroutine' => false, //FIXME the daemonize option does not work // 'daemonize' => env('OCTANE_DAEMONIZE', true), //FIXME accessing app()->environment in here renders artisan disfunctional. I suppose it's too early. //'log_level' => app()->environment('local') ? SWOOLE_LOG_INFO : SWOOLE_LOG_ERROR, // 'reactor_num' => , // number of available cpus by default 'send_yield' => true, 'socket_buffer_size' => 10 * 1024 * 1024, // 'task_worker_num' => // number of available cpus by default // 'worker_num' => // number of available cpus by default ], ], ]; diff --git a/src/database/migrations/2022_03_02_100000_create_filesystem_tables.php b/src/database/migrations/2022_03_02_100000_create_filesystem_tables.php new file mode 100644 index 00000000..69835965 --- /dev/null +++ b/src/database/migrations/2022_03_02_100000_create_filesystem_tables.php @@ -0,0 +1,89 @@ +string('id', 36)->primary(); + $table->bigInteger('user_id')->index(); + $table->integer('type')->unsigned()->default(0); + + $table->timestamps(); + $table->softDeletes(); + + $table->foreign('user_id')->references('id')->on('users') + ->onUpdate('cascade')->onDelete('cascade'); + } + ); + + Schema::create( + 'fs_properties', + function (Blueprint $table) { + $table->bigIncrements('id'); + $table->string('item_id', 36); + $table->string('key')->index(); + $table->text('value'); + + $table->timestamps(); + + $table->unique(['item_id', 'key']); + + $table->foreign('item_id')->references('id')->on('fs_items') + ->onDelete('cascade')->onUpdate('cascade'); + } + ); + + Schema::create( + 'fs_chunks', + function (Blueprint $table) { + $table->bigIncrements('id'); + $table->string('item_id', 36); + $table->string('chunk_id', 36); + $table->integer('sequence')->default(0); + $table->integer('size')->unsigned()->default(0); + + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['item_id', 'chunk_id']); + // $table->unique(['item_id', 'sequence', 'deleted_at']); + + $table->foreign('item_id')->references('id')->on('fs_items') + ->onUpdate('cascade')->onDelete('cascade'); + } + ); + + if (!\App\Sku::where('title', 'files')->first()) { + \App\Sku::create([ + 'title' => 'files', + 'name' => 'File storage', + 'description' => 'Access to file storage', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Files', + 'active' => true, + ]); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('fs_properties'); + Schema::dropIfExists('fs_chunks'); + Schema::dropIfExists('fs_items'); + } +}; diff --git a/src/database/seeds/local/SkuSeeder.php b/src/database/seeds/local/SkuSeeder.php index dd13300f..cf009f2f 100644 --- a/src/database/seeds/local/SkuSeeder.php +++ b/src/database/seeds/local/SkuSeeder.php @@ -1,364 +1,380 @@ 'mailbox', 'name' => 'User Mailbox', 'description' => 'Just a mailbox', 'cost' => 500, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Mailbox', 'active' => true, ] ); Sku::create( [ 'title' => 'domain', 'name' => 'Hosted Domain', 'description' => 'Somewhere to place a mailbox', 'cost' => 100, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Domain', 'active' => false, ] ); Sku::create( [ 'title' => 'domain-registration', 'name' => 'Domain Registration', 'description' => 'Register a domain with us', 'cost' => 101, 'period' => 'yearly', 'handler_class' => 'App\Handlers\DomainRegistration', 'active' => false, ] ); Sku::create( [ 'title' => 'domain-hosting', 'name' => 'External Domain', 'description' => 'Host a domain that is externally registered', 'cost' => 100, 'units_free' => 1, 'period' => 'monthly', 'handler_class' => 'App\Handlers\DomainHosting', 'active' => true, ] ); Sku::create( [ 'title' => 'domain-relay', 'name' => 'Domain Relay', 'description' => 'A domain you host at home, for which we relay email', 'cost' => 103, 'period' => 'monthly', 'handler_class' => 'App\Handlers\DomainRelay', 'active' => false, ] ); Sku::create( [ 'title' => 'storage', 'name' => 'Storage Quota', 'description' => 'Some wiggle room', 'cost' => 25, 'units_free' => 5, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Storage', 'active' => true, ] ); Sku::create( [ 'title' => 'groupware', 'name' => 'Groupware Features', 'description' => 'Groupware functions like Calendar, Tasks, Notes, etc.', 'cost' => 490, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Groupware', 'active' => true, ] ); Sku::create( [ 'title' => 'resource', 'name' => 'Resource', 'description' => 'Reservation taker', 'cost' => 101, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Resource', 'active' => true, ] ); Sku::create( [ 'title' => 'shared-folder', 'name' => 'Shared Folder', 'description' => 'A shared folder', 'cost' => 89, 'period' => 'monthly', 'handler_class' => 'App\Handlers\SharedFolder', 'active' => true, ] ); Sku::create( [ 'title' => '2fa', 'name' => '2-Factor Authentication', 'description' => 'Two factor authentication for webmail and administration panel', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Auth2F', 'active' => true, ] ); Sku::create( [ 'title' => 'activesync', 'name' => 'Activesync', 'description' => 'Mobile synchronization', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Activesync', 'active' => true, ] ); // Check existence because migration might have added this already $sku = Sku::where(['title' => 'beta', 'tenant_id' => \config('app.tenant_id')])->first(); if (!$sku) { Sku::create( [ 'title' => 'beta', 'name' => 'Private Beta (invitation only)', 'description' => 'Access to the private beta program subscriptions', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Beta', 'active' => false, ] ); } // Check existence because migration might have added this already $sku = Sku::where(['title' => 'meet', 'tenant_id' => \config('app.tenant_id')])->first(); if (!$sku) { Sku::create( [ 'title' => 'meet', 'name' => 'Voice & Video Conferencing (public beta)', 'description' => 'Video conferencing tool', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Meet', 'active' => true, ] ); } // Check existence because migration might have added this already $sku = Sku::where(['title' => 'group', 'tenant_id' => \config('app.tenant_id')])->first(); if (!$sku) { Sku::create( [ 'title' => 'group', 'name' => 'Group', 'description' => 'Distribution list', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Group', 'active' => true, ] ); } // Check existence because migration might have added this already $sku = Sku::where(['title' => 'beta-distlists', 'tenant_id' => \config('app.tenant_id')])->first(); if (!$sku) { Sku::create( [ 'title' => 'beta-distlists', 'name' => 'Distribution lists', 'description' => 'Access to mail distribution lists', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Beta\Distlists', 'active' => true, ] ); } // Check existence because migration might have added this already $sku = Sku::where(['title' => 'beta-resources', 'tenant_id' => \config('app.tenant_id')])->first(); if (!$sku) { Sku::create([ 'title' => 'beta-resources', 'name' => 'Calendaring resources', 'description' => 'Access to calendaring resources', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Beta\Resources', 'active' => true, ]); } // Check existence because migration might have added this already $sku = Sku::where(['title' => 'beta-shared-folders', 'tenant_id' => \config('app.tenant_id')])->first(); if (!$sku) { Sku::create([ 'title' => 'beta-shared-folders', 'name' => 'Shared folders', 'description' => 'Access to shared folders', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Beta\SharedFolders', 'active' => true, ]); } + // Check existence because migration might have added this already + $sku = Sku::where(['title' => 'files', 'tenant_id' => \config('app.tenant_id')])->first(); + + if (!$sku) { + Sku::create([ + 'title' => 'files', + 'name' => 'File storage', + 'description' => 'Access to file storage', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Files', + 'active' => true, + ]); + } + // for tenants that are not the configured tenant id $tenants = \App\Tenant::where('id', '!=', \config('app.tenant_id'))->get(); foreach ($tenants as $tenant) { $sku = Sku::create( [ 'title' => 'mailbox', 'name' => 'User Mailbox', 'description' => 'Just a mailbox', 'cost' => 500, 'fee' => 333, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Mailbox', 'active' => true, ] ); $sku->tenant_id = $tenant->id; $sku->save(); $sku = Sku::create( [ 'title' => 'storage', 'name' => 'Storage Quota', 'description' => 'Some wiggle room', 'cost' => 25, 'fee' => 16, 'units_free' => 5, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Storage', 'active' => true, ] ); $sku->tenant_id = $tenant->id; $sku->save(); $sku = Sku::create( [ 'title' => 'domain-hosting', 'name' => 'External Domain', 'description' => 'Host a domain that is externally registered', 'cost' => 100, 'fee' => 66, 'units_free' => 1, 'period' => 'monthly', 'handler_class' => 'App\Handlers\DomainHosting', 'active' => true, ] ); $sku->tenant_id = $tenant->id; $sku->save(); $sku = Sku::create( [ 'title' => 'groupware', 'name' => 'Groupware Features', 'description' => 'Groupware functions like Calendar, Tasks, Notes, etc.', 'cost' => 490, 'fee' => 327, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Groupware', 'active' => true, ] ); $sku->tenant_id = $tenant->id; $sku->save(); $sku = Sku::create( [ 'title' => '2fa', 'name' => '2-Factor Authentication', 'description' => 'Two factor authentication for webmail and administration panel', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Auth2F', 'active' => true, ] ); $sku->tenant_id = $tenant->id; $sku->save(); $sku = Sku::create( [ 'title' => 'activesync', 'name' => 'Activesync', 'description' => 'Mobile synchronization', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Activesync', 'active' => true, ] ); $sku->tenant_id = $tenant->id; $sku->save(); } } } diff --git a/src/database/seeds/production/SkuSeeder.php b/src/database/seeds/production/SkuSeeder.php index b7f4deee..3a505ca2 100644 --- a/src/database/seeds/production/SkuSeeder.php +++ b/src/database/seeds/production/SkuSeeder.php @@ -1,245 +1,259 @@ 'mailbox', 'name' => 'User Mailbox', 'description' => 'Just a mailbox', 'cost' => 444, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Mailbox', 'active' => true, ] ); Sku::create( [ 'title' => 'domain', 'name' => 'Hosted Domain', 'description' => 'Somewhere to place a mailbox', 'cost' => 100, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Domain', 'active' => false, ] ); Sku::create( [ 'title' => 'domain-registration', 'name' => 'Domain Registration', 'description' => 'Register a domain with us', 'cost' => 101, 'period' => 'yearly', 'handler_class' => 'App\Handlers\DomainRegistration', 'active' => false, ] ); Sku::create( [ 'title' => 'domain-hosting', 'name' => 'External Domain', 'description' => 'Host a domain that is externally registered', 'cost' => 100, 'units_free' => 1, 'period' => 'monthly', 'handler_class' => 'App\Handlers\DomainHosting', 'active' => true, ] ); Sku::create( [ 'title' => 'domain-relay', 'name' => 'Domain Relay', 'description' => 'A domain you host at home, for which we relay email', 'cost' => 103, 'period' => 'monthly', 'handler_class' => 'App\Handlers\DomainRelay', 'active' => false, ] ); Sku::create( [ 'title' => 'storage', 'name' => 'Storage Quota', 'description' => 'Some wiggle room', 'cost' => 50, 'units_free' => 2, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Storage', 'active' => true, ] ); Sku::create( [ 'title' => 'groupware', 'name' => 'Groupware Features', 'description' => 'Groupware functions like Calendar, Tasks, Notes, etc.', 'cost' => 555, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Groupware', 'active' => true, ] ); Sku::create( [ 'title' => 'resource', 'name' => 'Resource', 'description' => 'Reservation taker', 'cost' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Resource', 'active' => true, ] ); Sku::create( [ 'title' => 'shared-folder', 'name' => 'Shared Folder', 'description' => 'A shared folder', 'cost' => 89, 'period' => 'monthly', 'handler_class' => 'App\Handlers\SharedFolder', 'active' => false, ] ); Sku::create( [ 'title' => '2fa', 'name' => '2-Factor Authentication', 'description' => 'Two factor authentication for webmail and administration panel', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Auth2F', 'active' => true, ] ); Sku::create( [ 'title' => 'activesync', 'name' => 'Activesync', 'description' => 'Mobile synchronization', 'cost' => 100, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Activesync', 'active' => true, ] ); // Check existence because migration might have added this already if (!Sku::where('title', 'beta')->first()) { Sku::create( [ 'title' => 'beta', 'name' => 'Private Beta (invitation only)', 'description' => 'Access to the private beta program subscriptions', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Beta', 'active' => false, ] ); } // Check existence because migration might have added this already if (!Sku::where('title', 'meet')->first()) { Sku::create( [ 'title' => 'meet', 'name' => 'Voice & Video Conferencing (public beta)', 'description' => 'Video conferencing tool', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Meet', 'active' => true, ] ); } // Check existence because migration might have added this already if (!Sku::where('title', 'group')->first()) { Sku::create( [ 'title' => 'group', 'name' => 'Group', 'description' => 'Distribution list', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Group', 'active' => true, ] ); } // Check existence because migration might have added this already if (!Sku::where('title', 'beta-distlists')->first()) { Sku::create([ 'title' => 'beta-distlists', 'name' => 'Distribution lists', 'description' => 'Access to mail distribution lists', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Beta\Distlists', 'active' => true, ]); } // Check existence because migration might have added this already if (!Sku::where('title', 'beta-resources')->first()) { Sku::create([ 'title' => 'beta-resources', 'name' => 'Calendaring resources', 'description' => 'Access to calendaring resources', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Beta\Resources', 'active' => true, ]); } // Check existence because migration might have added this already if (!Sku::where('title', 'beta-shared-folders')->first()) { Sku::create([ 'title' => 'beta-shared-folders', 'name' => 'Shared folders', 'description' => 'Access to shared folders', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Beta\SharedFolders', 'active' => true, ]); } + + // Check existence because migration might have added this already + if (!Sku::where('title', 'files')->first()) { + Sku::create([ + 'title' => 'files', + 'name' => 'File storage', + 'description' => 'Access to file storage', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Files', + 'active' => true, + ]); + } } } diff --git a/src/resources/js/app.js b/src/resources/js/app.js index 427913d5..75707537 100644 --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -1,512 +1,515 @@ /** * First we will load all of this project's JavaScript dependencies which * includes Vue and other libraries. It is a great starting point when * building robust, powerful web applications using Vue and Laravel. */ require('./bootstrap') import AppComponent from '../vue/App' import MenuComponent from '../vue/Widgets/Menu' import SupportForm from '../vue/Widgets/SupportForm' import store from './store' import { Tab } from 'bootstrap' import { loadLangAsync, i18n } from './locale' const loader = '
Loading
' let isLoading = 0 // Lock the UI with the 'loading...' element const startLoading = () => { isLoading++ let loading = $('#app > .app-loader').removeClass('fadeOut') if (!loading.length) { $('#app').append($(loader)) } } // Hide "loading" overlay const stopLoading = () => { if (isLoading > 0) { $('#app > .app-loader').addClass('fadeOut') isLoading--; } } let loadingRoute // Note: This has to be before the app is created // Note: You cannot use app inside of the function window.router.beforeEach((to, from, next) => { // check if the route requires authentication and user is not logged in if (to.meta.requiresAuth && !store.state.isLoggedIn) { // remember the original request, to use after login store.state.afterLogin = to; // redirect to login page next({ name: 'login' }) return } if (to.meta.loading) { startLoading() loadingRoute = to.name } next() }) window.router.afterEach((to, from) => { if (to.name && loadingRoute === to.name) { stopLoading() loadingRoute = null } // When changing a page remove old: // - error page // - modal backdrop $('#error-page,.modal-backdrop.show').remove() $('body').css('padding', 0) // remove padding added by unclosed modal // Close the mobile menu if ($('#header-menu .navbar-collapse.show').length) { $('#header-menu .navbar-toggler').click(); } }) const app = new Vue({ components: { AppComponent, MenuComponent, }, i18n, store, router: window.router, data() { return { isUser: !window.isAdmin && !window.isReseller, appName: window.config['app.name'], appUrl: window.config['app.url'], themeDir: '/themes/' + window.config['app.theme'] } }, methods: { // Clear (bootstrap) form validation state clearFormValidation(form) { $(form).find('.is-invalid').removeClass('is-invalid') $(form).find('.invalid-feedback').remove() }, hasPermission(type) { const authInfo = store.state.authInfo const key = 'enable' + type.charAt(0).toUpperCase() + type.slice(1) return !!(authInfo && authInfo.statusInfo[key]) }, hasRoute(name) { return this.$router.resolve({ name: name }).resolved.matched.length > 0 }, hasSKU(name) { const authInfo = store.state.authInfo return authInfo.statusInfo.skus && authInfo.statusInfo.skus.indexOf(name) != -1 }, isController(wallet_id) { if (wallet_id && store.state.authInfo) { let i for (i = 0; i < store.state.authInfo.wallets.length; i++) { if (wallet_id == store.state.authInfo.wallets[i].id) { return true } } for (i = 0; i < store.state.authInfo.accounts.length; i++) { if (wallet_id == store.state.authInfo.accounts[i].id) { return true } } } return false }, // Set user state to "logged in" loginUser(response, dashboard, update) { if (!update) { store.commit('logoutUser') // destroy old state data store.commit('loginUser') } localStorage.setItem('token', response.access_token) localStorage.setItem('refreshToken', response.refresh_token) axios.defaults.headers.common.Authorization = 'Bearer ' + response.access_token if (response.email) { store.state.authInfo = response } if (dashboard !== false) { this.$router.push(store.state.afterLogin || { name: 'dashboard' }) } store.state.afterLogin = null // Refresh the token before it expires let timeout = response.expires_in || 0 // We'll refresh 60 seconds before the token expires if (timeout > 60) { timeout -= 60 } // TODO: We probably should try a few times in case of an error // TODO: We probably should prevent axios from doing any requests // while the token is being refreshed this.refreshTimeout = setTimeout(() => { - axios.post('/api/auth/refresh', {'refresh_token': response.refresh_token}).then(response => { + axios.post('api/auth/refresh', { refresh_token: response.refresh_token }).then(response => { this.loginUser(response.data, false, true) }) }, timeout * 1000) }, // Set user state to "not logged in" logoutUser(redirect) { store.commit('logoutUser') localStorage.setItem('token', '') localStorage.setItem('refreshToken', '') delete axios.defaults.headers.common.Authorization if (redirect !== false) { this.$router.push({ name: 'login' }) } clearTimeout(this.refreshTimeout) }, logo(mode) { let src = this.appUrl + this.themeDir + '/images/logo_' + (mode || 'header') + '.png' return `${this.appName}` }, // Display "loading" overlay inside of the specified element addLoader(elem, small = true, style = null) { if (style) { $(elem).css(style) } else { $(elem).css('position', 'relative') } $(elem).append(small ? $(loader).addClass('small') : $(loader)) }, // Create an object copy with specified properties only pick(obj, properties) { let result = {} properties.forEach(prop => { if (prop in obj) { result[prop] = obj[prop] } }) return result }, // Remove loader element added in addLoader() removeLoader(elem) { $(elem).find('.app-loader').remove() }, startLoading, stopLoading, isLoading() { return isLoading > 0 }, tab(e) { e.preventDefault() new Tab(e.target).show() }, errorPage(code, msg, hint) { // Until https://github.com/vuejs/vue-router/issues/977 is implemented // we can't really use router to display error page as it has two side // effects: it changes the URL and adds the error page to browser history. // For now we'll be replacing current view with error page "manually". if (!msg) msg = this.$te('error.' + code) ? this.$t('error.' + code) : this.$t('error.unknown') if (!hint) hint = '' const error_page = '
' + `
${code}
${msg}
${hint}
` + '
' $('#error-page').remove() $('#app').append(error_page) app.updateBodyClass('error') }, errorHandler(error) { this.stopLoading() const status = error.response ? error.response.status : 500 const message = error.response ? error.response.statusText : '' if (status == 401) { // Remember requested route to come back to it after log in if (this.$route.meta.requiresAuth) { store.state.afterLogin = this.$route this.logoutUser() } else { this.logoutUser(false) } } else { this.errorPage(status, message) } }, - downloadFile(url) { + downloadFile(url, filename) { // TODO: This might not be a best way for big files as the content // will be stored (temporarily) in browser memory // TODO: This method does not show the download progress in the browser // but it could be implemented in the UI, axios has 'progress' property axios.get(url, { responseType: 'blob' }) .then(response => { const link = document.createElement('a') - const contentDisposition = response.headers['content-disposition'] - let filename = 'unknown' - if (contentDisposition) { - const match = contentDisposition.match(/filename="(.+)"/); - if (match.length === 2) { - filename = match[1]; + if (!filename) { + const contentDisposition = response.headers['content-disposition'] + filename = 'unknown' + + if (contentDisposition) { + const match = contentDisposition.match(/filename="?(.+)"?/); + if (match && match.length === 2) { + filename = match[1]; + } } } link.href = window.URL.createObjectURL(response.data) link.download = filename link.click() }) }, price(price, currency) { // TODO: Set locale argument according to the currently used locale return ((price || 0) / 100).toLocaleString('de-DE', { style: 'currency', currency: currency || 'CHF' }) }, priceLabel(cost, discount, currency) { let index = '' if (discount) { cost = Math.floor(cost * ((100 - discount) / 100)) index = '\u00B9' } return this.price(cost, currency) + '/' + this.$t('wallet.month') + index }, clickRecord(event) { if (!/^(a|button|svg|path)$/i.test(event.target.nodeName)) { $(event.target).closest('tr').find('a').trigger('click') } }, isDegraded() { return store.state.authInfo && store.state.authInfo.isAccountDegraded }, pageName(path) { let page = this.$route.path // check if it is a "menu page", find the page name // otherwise we'll use the real path as page name window.config.menu.every(item => { if (item.location == page && item.page) { page = item.page return false } }) page = page.replace(/^\//, '') return page ? page : '404' }, supportDialog(container) { let dialog = $('#support-dialog')[0] if (!dialog) { // FIXME: Find a nicer way of doing this SupportForm.i18n = i18n let form = new Vue(SupportForm) form.$mount($('
').appendTo(container)[0]) form.$root = this form.$toast = this.$toast dialog = form.$el } dialog.__vue__.showDialog() }, statusClass(obj) { if (obj.isDeleted) { return 'text-muted' } if (obj.isDegraded || obj.isAccountDegraded || obj.isSuspended) { return 'text-warning' } if (obj.isImapReady === false || obj.isLdapReady === false || obj.isVerified === false || obj.isConfirmed === false) { return 'text-danger' } return 'text-success' }, statusText(obj) { if (obj.isDeleted) { return this.$t('status.deleted') } if (obj.isDegraded || obj.isAccountDegraded) { return this.$t('status.degraded') } if (obj.isSuspended) { return this.$t('status.suspended') } if (obj.isImapReady === false || obj.isLdapReady === false || obj.isVerified === false || obj.isConfirmed === false) { return this.$t('status.notready') } return this.$t('status.active') }, // Append some wallet properties to the object userWalletProps(object) { let wallet = store.state.authInfo.accounts[0] if (!wallet) { wallet = store.state.authInfo.wallets[0] } if (wallet) { object.currency = wallet.currency if (wallet.discount) { object.discount = wallet.discount object.discount_description = wallet.discount_description } } }, updateBodyClass(name) { // Add 'class' attribute to the body, different for each page // so, we can apply page-specific styles document.body.className = 'page-' + (name || this.pageName()).replace(/\/.*$/, '') } } }) // Fetch the locale file and the start the app loadLangAsync().then(() => app.$mount('#app')) // Add a axios request interceptor axios.interceptors.request.use( config => { // This is the only way I found to change configuration options // on a running application. We need this for browser testing. config.headers['X-Test-Payment-Provider'] = window.config.paymentProvider return config }, error => { // Do something with request error return Promise.reject(error) } ) // Add a axios response interceptor for general/validation error handler axios.interceptors.response.use( response => { if (response.config.onFinish) { response.config.onFinish() } return response }, error => { // Do not display the error in a toast message, pass the error as-is if (axios.isCancel(error) || error.config.ignoreErrors) { return Promise.reject(error) } if (error.config.onFinish) { error.config.onFinish() } let error_msg const status = error.response ? error.response.status : 200 const data = error.response ? error.response.data : {} if (status == 422 && data.errors) { error_msg = app.$t('error.form') const modal = $('div.modal.show') $(modal.length ? modal : 'form').each((i, form) => { form = $(form) $.each(data.errors, (idx, msg) => { const input_name = (form.data('validation-prefix') || form.find('form').first().data('validation-prefix') || '') + idx let input = form.find('#' + input_name) if (!input.length) { input = form.find('[name="' + input_name + '"]'); } if (input.length) { // Create an error message // API responses can use a string, array or object let msg_text = '' if (typeof(msg) !== 'string') { $.each(msg, (index, str) => { msg_text += str + ' ' }) } else { msg_text = msg } let feedback = $('
').text(msg_text) if (input.is('.list-input')) { // List input widget let controls = input.children(':not(:first-child)') if (!controls.length && typeof msg == 'string') { // this is an empty list (the main input only) // and the error message is not an array input.find('.main-input').addClass('is-invalid') } else { controls.each((index, element) => { if (msg[index]) { $(element).find('input').addClass('is-invalid') } }) } input.addClass('is-invalid').next('.invalid-feedback').remove() input.after(feedback) } else { // a special case, e.g. the invitation policy widget if (input.is('select') && input.parent().is('.input-group-select.selected')) { input = input.next() } // Standard form element input.addClass('is-invalid') input.parent().find('.invalid-feedback').remove() input.parent().append(feedback) } } }) form.find('.is-invalid:not(.list-input)').first().focus() }) } else if (data.status == 'error') { error_msg = data.message } else { error_msg = error.request ? error.request.statusText : error.message } app.$toast.error(error_msg || app.$t('error.server')) // Pass the error as-is return Promise.reject(error) } ) diff --git a/src/resources/js/files.js b/src/resources/js/files.js new file mode 100644 index 00000000..96f58bde --- /dev/null +++ b/src/resources/js/files.js @@ -0,0 +1,191 @@ + +function FileAPI(params = {}) +{ + // Initial max size of a file chunk in an upload request + // Note: The value may change to the value provided by the server on the first upload. + // Note: That chunk size here is only body, Swoole's package_max_length is body + headers, + // so don't forget to subtract some margin (e.g. 8KB) + // FIXME: From my preliminary tests it looks like on the PHP side you need + // about 3-4 times as much memory as the request size when using Swoole + // (only 1 time without Swoole). And I didn't find a way to lower the memory usage, + // it looks like it happens before we even start to process the request in FilesController. + let maxChunkSize = 5 * 1024 * 1024 - 1024 * 8 + + const area = $(params.dropArea) + + // Add hidden input to the drop area, so we can handle upload by click + const input = $('') + .attr({type: 'file', multiple: true, style: 'visibility: hidden'}) + .on('change', event => { fileDropHandler(event) }) + .appendTo(area) + .get(0) + + // Register events on the upload area element + area.on('click', () => input.click()) + .on('drop', event => fileDropHandler(event)) + .on('dragenter dragleave drop', event => fileDragHandler(event)) + .on('dragover', event => event.preventDefault()) // prevents file from being opened on drop) + + // Handle dragging on the whole page, so we can style the area in a different way + $(document.documentElement).off('.fileapi') + .on('dragenter.fileapi dragleave.fileapi', event => area.toggleClass('dragactive')) + + // Handle dragging file(s) - style the upload area element + const fileDragHandler = (event) => { + if (event.type == 'drop') { + area.removeClass('dragover dragactive') + } else { + area[event.type == 'dragenter' ? 'addClass' : 'removeClass']('dragover') + } + } + + // Handler for both a ondrop event and file input onchange event + const fileDropHandler = (event) => { + let files = event.target.files || event.dataTransfer.files + + if (!files || !files.length) { + return + } + + // Prevent default behavior (prevent file from being opened on drop) + event.preventDefault(); + + // TODO: Check file size limit, limit number of files to upload at once? + + // For every file... + for (const file of files) { + const progress = { + id: Date.now(), + name: file.name, + total: file.size, + completed: 0 + } + + file.uploaded = 0 + + // Upload request configuration + const config = { + onUploadProgress: progressEvent => { + progress.completed = Math.round(((file.uploaded + progressEvent.loaded) * 100) / file.size) + + // Don't trigger the event when 100% of the file has been sent + // We need to wait until the request response is available, then + // we'll trigger it (see below where the axios request is created) + if (progress.completed < 100) { + params.eventHandler('upload-progress', progress) + } + }, + headers: { + 'Content-Type': file.type + }, + ignoreErrors: true, // skip the Kolab4 interceptor + params: { name: file.name }, + maxBodyLength: -1, // no limit + timeout: 0, // no limit + 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) + const uploadFn = (start = 0, uploadId) => { + let url = 'api/v4/files' + let body = '' + + if (file.size <= maxChunkSize) { + // The file is small, we'll upload it using a single request + // Note that even in this case the auth token might expire while + // the file is uploading, but the risk is quite small. + body = file + start += maxChunkSize + } else if (!uploadId) { + // The file is big, first send a request for the upload location + // The upload location does not require authentication, which means + // there should be no problem with expired auth token, etc. + config.params.media = 'resumable' + config.params.size = file.size + } else { + // Upload a chunk of the file to the upload location + url = 'api/v4/files/uploads/' + uploadId + body = file.slice(start, start + maxChunkSize, file.type) + + config.params = { from: start } + config.headers.Authorization = '' + start += maxChunkSize + } + + axios.post(url, body, config) + .then(response => { + if (response.data.maxChunkSize) { + maxChunkSize = response.data.maxChunkSize + } + + if (start < file.size) { + file.uploaded = start + uploadFn(start, uploadId || response.data.uploadId) + } else { + progress.completed = 100 + params.eventHandler('upload-progress', progress) + } + }) + .catch(error => { + console.log(error) + + // TODO: Depending on the error consider retrying the request + // if it was one of many chunks of a bigger file? + + progress.error = error + progress.completed = 100 + params.eventHandler('upload-progress', progress) + }) + } + + // Start uploading + uploadFn() + } + } + + /** + * Download a file. Starts downloading using a hidden link trick. + */ + this.fileDownload = (id) => { + axios.get('api/v4/files/' + id + '?downloadUrl=1') + .then(response => { + // Create a dummy link element and click it + if (response.data.downloadUrl) { + $('').attr('href', response.data.downloadUrl).get(0).click() + } + }) + } + + /** + * Rename a file. + */ + this.fileRename = (id, name) => { + axios.put('api/v4/files/' + id, { name }) + .then(response => { + + }) + } + + /** + * Convert file size as a number of bytes to a human-readable format + */ + this.sizeText = (bytes) => { + if (bytes >= 1073741824) + return parseFloat(bytes/1073741824).toFixed(2) + ' GB'; + if (bytes >= 1048576) + return parseFloat(bytes/1048576).toFixed(2) + ' MB'; + if (bytes >= 1024) + return parseInt(bytes/1024) + ' kB'; + + return parseInt(bytes || 0) + ' B'; + } +} + +export default FileAPI diff --git a/src/resources/js/user/routes.js b/src/resources/js/user/routes.js index cf8613e0..c3294544 100644 --- a/src/resources/js/user/routes.js +++ b/src/resources/js/user/routes.js @@ -1,172 +1,186 @@ import LoginComponent from '../../vue/Login' import LogoutComponent from '../../vue/Logout' import PageComponent from '../../vue/Page' import PasswordResetComponent from '../../vue/PasswordReset' import SignupComponent from '../../vue/Signup' // Here's a list of lazy-loaded components // Note: you can pack multiple components into the same chunk, webpackChunkName // is also used to get a sensible file name instead of numbers const CompanionAppComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/CompanionApp') const DashboardComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Dashboard') const DistlistInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Distlist/Info') const DistlistListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Distlist/List') const DomainInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Domain/Info') const DomainListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Domain/List') +const FileInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/File/Info') +const FileListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/File/List') const MeetComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Rooms') const ResourceInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Resource/Info') const ResourceListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Resource/List') const SettingsComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Settings') const SharedFolderInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/SharedFolder/Info') const SharedFolderListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/SharedFolder/List') const UserInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/User/Info') const UserListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/User/List') const UserProfileComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/User/Profile') const UserProfileDeleteComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/User/ProfileDelete') const WalletComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Wallet') const RoomComponent = () => import(/* webpackChunkName: "../user/meet" */ '../../vue/Meet/Room.vue') const routes = [ { path: '/dashboard', name: 'dashboard', component: DashboardComponent, meta: { requiresAuth: true } }, { path: '/distlist/:list', name: 'distlist', component: DistlistInfoComponent, meta: { requiresAuth: true, perm: 'distlists' } }, { path: '/distlists', name: 'distlists', component: DistlistListComponent, meta: { requiresAuth: true, perm: 'distlists' } }, { path: '/companion', name: 'companion', component: CompanionAppComponent, meta: { requiresAuth: true, perm: 'companionapps' } }, { path: '/domain/:domain', name: 'domain', component: DomainInfoComponent, meta: { requiresAuth: true, perm: 'domains' } }, { path: '/domains', name: 'domains', component: DomainListComponent, meta: { requiresAuth: true, perm: 'domains' } }, + { + path: '/file/:file', + name: 'file', + component: FileInfoComponent, + meta: { requiresAuth: true /*, perm: 'files' */ } + }, + { + path: '/files', + name: 'files', + component: FileListComponent, + meta: { requiresAuth: true, perm: 'files' } + }, { path: '/login', name: 'login', component: LoginComponent }, { path: '/logout', name: 'logout', component: LogoutComponent }, { path: '/password-reset/:code?', name: 'password-reset', component: PasswordResetComponent }, { path: '/profile', name: 'profile', component: UserProfileComponent, meta: { requiresAuth: true } }, { path: '/profile/delete', name: 'profile-delete', component: UserProfileDeleteComponent, meta: { requiresAuth: true } }, { path: '/resource/:resource', name: 'resource', component: ResourceInfoComponent, meta: { requiresAuth: true, perm: 'resources' } }, { path: '/resources', name: 'resources', component: ResourceListComponent, meta: { requiresAuth: true, perm: 'resources' } }, { component: RoomComponent, name: 'room', path: '/meet/:room', meta: { loading: true } }, { path: '/rooms', name: 'rooms', component: MeetComponent, meta: { requiresAuth: true } }, { path: '/settings', name: 'settings', component: SettingsComponent, meta: { requiresAuth: true, perm: 'settings' } }, { path: '/shared-folder/:folder', name: 'shared-folder', component: SharedFolderInfoComponent, meta: { requiresAuth: true, perm: 'folders' } }, { path: '/shared-folders', name: 'shared-folders', component: SharedFolderListComponent, meta: { requiresAuth: true, perm: 'folders' } }, { path: '/signup/invite/:param', name: 'signup-invite', component: SignupComponent }, { path: '/signup/:param?', alias: '/signup/voucher/:param', name: 'signup', component: SignupComponent }, { path: '/user/:user', name: 'user', component: UserInfoComponent, meta: { requiresAuth: true, perm: 'users' } }, { path: '/users', name: 'users', component: UserListComponent, meta: { requiresAuth: true, perm: 'users' } }, { path: '/wallet', name: 'wallet', component: WalletComponent, meta: { requiresAuth: true, perm: 'wallets' } }, { name: '404', path: '*', component: PageComponent } ] export default routes diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php index 512daf2c..c3c8e650 100644 --- a/src/resources/lang/en/app.php +++ b/src/resources/lang/en/app.php @@ -1,128 +1,135 @@ 'Created', 'chart-deleted' => 'Deleted', 'chart-average' => 'average', 'chart-allusers' => 'All Users - last year', 'chart-discounts' => 'Discounts', 'chart-vouchers' => 'Vouchers', 'chart-income' => 'Income in :currency - last 8 weeks', 'chart-users' => 'Users - last 8 weeks', 'companion-deleteall-success' => 'All companion apps have been removed.', 'mandate-delete-success' => 'The auto-payment has been removed.', 'mandate-update-success' => 'The auto-payment has been updated.', 'planbutton' => 'Choose :plan', 'process-async' => 'Setup process has been pushed. Please wait.', 'process-user-new' => 'Registering a user...', 'process-user-ldap-ready' => 'Creating a user...', 'process-user-imap-ready' => 'Creating a mailbox...', 'process-domain-new' => 'Registering a custom domain...', 'process-domain-ldap-ready' => 'Creating a custom domain...', 'process-domain-verified' => 'Verifying a custom domain...', 'process-domain-confirmed' => 'Verifying an ownership of a custom domain...', 'process-success' => 'Setup process finished successfully.', 'process-error-distlist-ldap-ready' => 'Failed to create a distribution list.', 'process-error-domain-ldap-ready' => 'Failed to create a domain.', 'process-error-domain-verified' => 'Failed to verify a domain.', 'process-error-domain-confirmed' => 'Failed to verify an ownership of a domain.', 'process-error-resource-imap-ready' => 'Failed to verify that a shared folder exists.', 'process-error-resource-ldap-ready' => 'Failed to create a resource.', 'process-error-shared-folder-imap-ready' => 'Failed to verify that a shared folder exists.', 'process-error-shared-folder-ldap-ready' => 'Failed to create a shared folder.', 'process-error-user-ldap-ready' => 'Failed to create a user.', 'process-error-user-imap-ready' => 'Failed to verify that a mailbox exists.', 'process-distlist-new' => 'Registering a distribution list...', 'process-distlist-ldap-ready' => 'Creating a distribution list...', 'process-resource-new' => 'Registering a resource...', 'process-resource-imap-ready' => 'Creating a shared folder...', 'process-resource-ldap-ready' => 'Creating a resource...', 'process-shared-folder-new' => 'Registering a shared folder...', 'process-shared-folder-imap-ready' => 'Creating a shared folder...', 'process-shared-folder-ldap-ready' => 'Creating a shared folder...', 'distlist-update-success' => 'Distribution list updated successfully.', 'distlist-create-success' => 'Distribution list created successfully.', 'distlist-delete-success' => 'Distribution list deleted successfully.', 'distlist-suspend-success' => 'Distribution list suspended successfully.', 'distlist-unsuspend-success' => 'Distribution list unsuspended successfully.', 'distlist-setconfig-success' => 'Distribution list settings updated successfully.', 'domain-create-success' => 'Domain created successfully.', 'domain-delete-success' => 'Domain deleted successfully.', 'domain-notempty-error' => 'Unable to delete a domain with assigned users or other objects.', 'domain-verify-success' => 'Domain verified successfully.', 'domain-verify-error' => 'Domain ownership verification failed.', 'domain-suspend-success' => 'Domain suspended successfully.', 'domain-unsuspend-success' => 'Domain unsuspended successfully.', 'domain-setconfig-success' => 'Domain settings updated successfully.', + 'file-create-success' => 'File created successfully.', + 'file-delete-success' => 'File deleted successfully.', + 'file-update-success' => 'File updated successfully.', + 'file-permissions-create-success' => 'File permissions created successfully.', + 'file-permissions-update-success' => 'File permissions updated successfully.', + 'file-permissions-delete-success' => 'File permissions deleted successfully.', + 'resource-update-success' => 'Resource updated successfully.', 'resource-create-success' => 'Resource created successfully.', 'resource-delete-success' => 'Resource deleted successfully.', 'resource-setconfig-success' => 'Resource settings updated successfully.', 'shared-folder-update-success' => 'Shared folder updated successfully.', 'shared-folder-create-success' => 'Shared folder created successfully.', 'shared-folder-delete-success' => 'Shared folder deleted successfully.', 'shared-folder-setconfig-success' => 'Shared folder settings updated successfully.', 'user-update-success' => 'User data updated successfully.', 'user-create-success' => 'User created successfully.', 'user-delete-success' => 'User deleted successfully.', 'user-suspend-success' => 'User suspended successfully.', 'user-unsuspend-success' => 'User unsuspended successfully.', 'user-reset-2fa-success' => '2-Factor authentication reset successfully.', 'user-setconfig-success' => 'User settings updated successfully.', 'user-set-sku-success' => 'The subscription added successfully.', 'user-set-sku-already-exists' => 'The subscription already exists.', 'search-foundxdomains' => ':x domains have been found.', 'search-foundxdistlists' => ':x distribution lists have been found.', 'search-foundxresources' => ':x resources have been found.', 'search-foundxsharedfolders' => ':x shared folders have been found.', 'search-foundxusers' => ':x user accounts have been found.', 'signup-invitations-created' => 'The invitation has been created.|:count invitations has been created.', 'signup-invitations-csv-empty' => 'Failed to find any valid email addresses in the uploaded file.', 'signup-invitations-csv-invalid-email' => 'Found an invalid email address (:email) on line :line.', 'signup-invitation-delete-success' => 'Invitation deleted successfully.', 'signup-invitation-resend-success' => 'Invitation added to the sending queue successfully.', 'support-request-success' => 'Support request submitted successfully.', 'support-request-error' => 'Failed to submit the support request.', 'siteuser' => ':site User', 'wallet-award-success' => 'The bonus has been added to the wallet successfully.', 'wallet-penalty-success' => 'The penalty has been added to the wallet successfully.', 'wallet-update-success' => 'User wallet updated successfully.', 'password-reset-code-delete-success' => 'Password reset code deleted successfully.', 'password-rule-min' => 'Minimum password length: :param characters', 'password-rule-max' => 'Maximum password length: :param characters', 'password-rule-lower' => 'Password contains a lower-case character', 'password-rule-upper' => 'Password contains an upper-case character', 'password-rule-digit' => 'Password contains a digit', 'password-rule-special' => 'Password contains a special character', 'password-rule-last' => 'Password cannot be the same as the last :param passwords', 'wallet-notice-date' => 'With your current subscriptions your account balance will last until about :date (:days).', 'wallet-notice-nocredit' => 'You are out of credit, top up your balance now.', 'wallet-notice-today' => 'You will run out of credit today, top up your balance now.', 'wallet-notice-trial' => 'You are in your free trial period.', 'wallet-notice-trial-end' => 'Your free trial is about to end, top up to continue.', ]; diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php index 7b67cb12..1a42de6f 100644 --- a/src/resources/lang/en/ui.php +++ b/src/resources/lang/en/ui.php @@ -1,510 +1,527 @@ [ 'faq' => "FAQ", ], 'btn' => [ 'add' => "Add", 'accept' => "Accept", 'back' => "Back", 'cancel' => "Cancel", 'close' => "Close", 'continue' => "Continue", 'copy' => "Copy", 'delete' => "Delete", 'deny' => "Deny", 'download' => "Download", 'edit' => "Edit", 'file' => "Choose file...", 'moreinfo' => "More information", 'refresh' => "Refresh", 'reset' => "Reset", 'resend' => "Resend", 'save' => "Save", 'search' => "Search", + 'share' => "Share", 'signup' => "Sign Up", 'submit' => "Submit", 'suspend' => "Suspend", 'unsuspend' => "Unsuspend", 'verify' => "Verify", ], 'companion' => [ 'title' => "Companion App", 'name' => "Name", 'description' => "Use the Companion App on your mobile phone for advanced two factor authentication.", 'pair-new' => "Pair new device", 'paired' => "Paired devices", 'pairing-instructions' => "Pair a new device using the following QR-Code:", 'deviceid' => "Device ID", 'nodevices' => "There are currently no devices", 'delete' => "Remove devices", 'remove-devices' => "Remove Devices", 'remove-devices-text' => "Do you really want to remove all devices permanently?" . " Please note that this action cannot be undone, and you can only remove all devices together." . " You may pair devices you would like to keep individually again.", ], 'dashboard' => [ 'beta' => "beta", 'distlists' => "Distribution lists", 'chat' => "Video chat", 'companion' => "Companion app", 'domains' => "Domains", + 'files' => "Files", 'invitations' => "Invitations", 'profile' => "Your profile", 'resources' => "Resources", 'settings' => "Settings", 'shared-folders' => "Shared folders", 'users' => "User accounts", 'wallet' => "Wallet", 'webmail' => "Webmail", 'stats' => "Stats", ], 'distlist' => [ 'list-title' => "Distribution list | Distribution lists", 'create' => "Create list", 'delete' => "Delete list", 'email' => "Email", 'list-empty' => "There are no distribution lists in this account.", 'name' => "Name", 'new' => "New distribution list", 'recipients' => "Recipients", 'sender-policy' => "Sender Access List", 'sender-policy-text' => "With this list you can specify who can send mail to the distribution list." . " You can put a complete email address (jane@kolab.org), domain (kolab.org) or suffix (.org) that the sender email address is compared to." . " If the list is empty, mail from anyone is allowed.", ], 'domain' => [ 'delete' => "Delete domain", 'delete-domain' => "Delete {domain}", 'delete-text' => "Do you really want to delete this domain permanently?" . " This is only possible if there are no users, aliases or other objects in this domain." . " Please note that this action cannot be undone.", 'dns-verify' => "Domain DNS verification sample:", 'dns-config' => "Domain DNS configuration sample:", 'namespace' => "Namespace", 'spf-whitelist' => "SPF Whitelist", 'spf-whitelist-text' => "The Sender Policy Framework allows a sender domain to disclose, through DNS, " . "which systems are allowed to send emails with an envelope sender address within said domain.", 'spf-whitelist-ex' => "Here you can specify a list of allowed servers, for example: .ess.barracuda.com.", 'verify' => "Domain verification", 'verify-intro' => "In order to confirm that you're the actual holder of the domain, we need to run a verification process before finally activating it for email delivery.", 'verify-dns' => "The domain must have one of the following entries in DNS:", 'verify-dns-txt' => "TXT entry with value:", 'verify-dns-cname' => "or CNAME entry:", 'verify-outro' => "When this is done press the button below to start the verification.", 'verify-sample' => "Here's a sample zone file for your domain:", 'config' => "Domain configuration", 'config-intro' => "In order to let {app} receive email traffic for your domain you need to adjust the DNS settings, more precisely the MX entries, accordingly.", 'config-sample' => "Edit your domain's zone file and replace existing MX entries with the following values:", 'config-hint' => "If you don't know how to set DNS entries for your domain, please contact the registration service where you registered the domain or your web hosting provider.", 'create' => "Create domain", 'new' => "New domain", ], 'error' => [ '400' => "Bad request", '401' => "Unauthorized", '403' => "Access denied", '404' => "Not found", '405' => "Method not allowed", '500' => "Internal server error", 'unknown' => "Unknown Error", 'server' => "Server Error", 'form' => "Form validation error", ], + 'file' => [ + 'create' => "Create file", + 'delete' => "Delete file", + 'list-empty' => "There are no files in this account.", + 'mimetype' => "Mimetype", + 'mtime' => "Modified", + 'new' => "New file", + 'search' => "File name", + 'sharing' => "Sharing", + 'sharing-links-text' => "You can share the file with other users by giving them read-only access " + . "to the file via a unique link.", + ], + 'form' => [ 'acl' => "Access rights", 'acl-full' => "All", 'acl-read-only' => "Read-only", 'acl-read-write' => "Read-write", 'amount' => "Amount", 'anyone' => "Anyone", 'code' => "Confirmation Code", 'config' => "Configuration", 'date' => "Date", 'description' => "Description", 'details' => "Details", 'disabled' => "disabled", 'domain' => "Domain", 'email' => "Email Address", 'emails' => "Email Addresses", 'enabled' => "enabled", 'firstname' => "First Name", 'general' => "General", 'lastname' => "Last Name", 'name' => "Name", 'none' => "none", 'or' => "or", 'password' => "Password", 'password-confirm' => "Confirm Password", 'phone' => "Phone", 'settings' => "Settings", 'shared-folder' => "Shared Folder", + 'size' => "Size", 'status' => "Status", 'surname' => "Surname", 'type' => "Type", 'user' => "User", 'primary-email' => "Primary Email", 'id' => "ID", 'created' => "Created", 'deleted' => "Deleted", ], 'invitation' => [ 'create' => "Create invite(s)", 'create-title' => "Invite for a signup", 'create-email' => "Enter an email address of the person you want to invite.", 'create-csv' => "To send multiple invitations at once, provide a CSV (comma separated) file, or alternatively a plain-text file, containing one email address per line.", 'empty-list' => "There are no invitations in the database.", 'title' => "Signup invitations", 'search' => "Email address or domain", 'send' => "Send invite(s)", 'status-completed' => "User signed up", 'status-failed' => "Sending failed", 'status-sent' => "Sent", 'status-new' => "Not sent yet", ], 'lang' => [ 'en' => "English", 'de' => "German", 'fr' => "French", 'it' => "Italian", ], 'login' => [ '2fa' => "Second factor code", '2fa_desc' => "Second factor code is optional for users with no 2-Factor Authentication setup.", 'forgot_password' => "Forgot password?", 'header' => "Please sign in", 'sign_in' => "Sign in", 'webmail' => "Webmail" ], 'meet' => [ 'title' => "Voice & Video Conferencing", 'welcome' => "Welcome to our beta program for Voice & Video Conferencing.", 'url' => "You have a room of your own at the URL below. This room is only open when you yourself are in attendance. Use this URL to invite people to join you.", 'notice' => "This is a work in progress and more features will be added over time. Current features include:", 'sharing' => "Screen Sharing", 'sharing-text' => "Share your screen for presentations or show-and-tell.", 'security' => "Room Security", 'security-text' => "Increase the room security by setting a password that attendees will need to know" . " before they can enter, or lock the door so attendees will have to knock, and a moderator can accept or deny those requests.", 'qa-title' => "Raise Hand (Q&A)", 'qa-text' => "Silent audience members can raise their hand to facilitate a Question & Answer session with the panel members.", 'moderation' => "Moderator Delegation", 'moderation-text' => "Delegate moderator authority for the session, so that a speaker is not needlessly" . " interrupted with attendees knocking and other moderator duties.", 'eject' => "Eject Attendees", 'eject-text' => "Eject attendees from the session in order to force them to reconnect, or address policy" . " violations. Click the user icon for effective dismissal.", 'silent' => "Silent Audience Members", 'silent-text' => "For a webinar-style session, configure the room to force all new attendees to be silent audience members.", 'interpreters' => "Language Specific Audio Channels", 'interpreters-text' => "Designate a participant to interpret the original audio to a target language, for sessions" . " with multi-lingual attendees. The interpreter is expected to be able to relay the original audio, and override it.", 'beta-notice' => "Keep in mind that this is still in beta and might come with some issues." . " Should you encounter any on your way, let us know by contacting support.", // Room options dialog 'options' => "Room options", 'password' => "Password", 'password-none' => "none", 'password-clear' => "Clear password", 'password-set' => "Set password", 'password-text' => "You can add a password to your meeting. Participants will have to provide the password before they are allowed to join the meeting.", 'lock' => "Locked room", 'lock-text' => "When the room is locked participants have to be approved by a moderator before they could join the meeting.", 'nomedia' => "Subscribers only", 'nomedia-text' => "Forces all participants to join as subscribers (with camera and microphone turned off)." . " Moderators will be able to promote them to publishers throughout the session.", // Room menu 'partcnt' => "Number of participants", 'menu-audio-mute' => "Mute audio", 'menu-audio-unmute' => "Unmute audio", 'menu-video-mute' => "Mute video", 'menu-video-unmute' => "Unmute video", 'menu-screen' => "Share screen", 'menu-hand-lower' => "Lower hand", 'menu-hand-raise' => "Raise hand", 'menu-channel' => "Interpreted language channel", 'menu-chat' => "Chat", 'menu-fullscreen' => "Full screen", 'menu-fullscreen-exit' => "Exit full screen", 'menu-leave' => "Leave session", // Room setup screen 'setup-title' => "Set up your session", 'mic' => "Microphone", 'cam' => "Camera", 'nick' => "Nickname", 'nick-placeholder' => "Your name", 'join' => "JOIN", 'joinnow' => "JOIN NOW", 'imaowner' => "I'm the owner", // Room 'qa' => "Q & A", 'leave-title' => "Room closed", 'leave-body' => "The session has been closed by the room owner.", 'media-title' => "Media setup", 'join-request' => "Join request", 'join-requested' => "{user} requested to join.", // Status messages 'status-init' => "Checking the room...", 'status-323' => "The room is closed. Please, wait for the owner to start the session.", 'status-324' => "The room is closed. It will be open for others after you join.", 'status-325' => "The room is ready. Please, provide a valid password.", 'status-326' => "The room is locked. Please, enter your name and try again.", 'status-327' => "Waiting for permission to join the room.", 'status-404' => "The room does not exist.", 'status-429' => "Too many requests. Please, wait.", 'status-500' => "Failed to connect to the room. Server error.", // Other menus 'media-setup' => "Media setup", 'perm' => "Permissions", 'perm-av' => "Audio & Video publishing", 'perm-mod' => "Moderation", 'lang-int' => "Language interpreter", 'menu-options' => "Options", ], 'menu' => [ 'cockpit' => "Cockpit", 'login' => "Login", 'logout' => "Logout", 'signup' => "Signup", 'toggle' => "Toggle navigation", ], 'msg' => [ 'initializing' => "Initializing...", 'loading' => "Loading...", 'loading-failed' => "Failed to load data.", 'notfound' => "Resource not found.", 'info' => "Information", 'error' => "Error", + 'uploading' => "Uploading...", 'warning' => "Warning", 'success' => "Success", ], 'nav' => [ 'more' => "Load more", 'step' => "Step {i}/{n}", ], 'password' => [ 'link-invalid' => "The password reset code is expired or invalid.", 'reset' => "Password Reset", 'reset-step1' => "Enter your email address to reset your password.", 'reset-step1-hint' => "You may need to check your spam folder or unblock {email}.", 'reset-step2' => "We sent out a confirmation code to your external email address." . " Enter the code we sent you, or click the link in the message.", ], 'resource' => [ 'create' => "Create resource", 'delete' => "Delete resource", 'invitation-policy' => "Invitation policy", 'invitation-policy-text' => "Event invitations for a resource are normally accepted automatically" . " if there is no conflicting event on the requested time slot. Invitation policy allows" . " for rejecting such requests or to require a manual acceptance from a specified user.", 'ipolicy-manual' => "Manual (tentative)", 'ipolicy-accept' => "Accept", 'ipolicy-reject' => "Reject", 'list-title' => "Resource | Resources", 'list-empty' => "There are no resources in this account.", 'new' => "New resource", ], 'shf' => [ 'aliases-none' => "This shared folder has no email aliases.", 'create' => "Create folder", 'delete' => "Delete folder", 'acl-text' => "Defines user permissions to access the shared folder.", 'list-title' => "Shared folder | Shared folders", 'list-empty' => "There are no shared folders in this account.", 'new' => "New shared folder", 'type-mail' => "Mail", 'type-event' => "Calendar", 'type-contact' => "Address Book", 'type-task' => "Tasks", 'type-note' => "Notes", 'type-file' => "Files", ], 'signup' => [ 'email' => "Existing Email Address", 'login' => "Login", 'title' => "Sign Up", 'step1' => "Sign up to start your free month.", 'step2' => "We sent out a confirmation code to your email address. Enter the code we sent you, or click the link in the message.", 'step3' => "Create your Kolab identity (you can choose additional addresses later).", 'voucher' => "Voucher Code", ], 'status' => [ 'prepare-account' => "We are preparing your account.", 'prepare-domain' => "We are preparing the domain.", 'prepare-distlist' => "We are preparing the distribution list.", 'prepare-resource' => "We are preparing the resource.", 'prepare-shared-folder' => "We are preparing the shared folder.", 'prepare-user' => "We are preparing the user account.", 'prepare-hint' => "Some features may be missing or readonly at the moment.", 'prepare-refresh' => "The process never ends? Press the \"Refresh\" button, please.", 'ready-account' => "Your account is almost ready.", 'ready-domain' => "The domain is almost ready.", 'ready-distlist' => "The distribution list is almost ready.", 'ready-resource' => "The resource is almost ready.", 'ready-shared-folder' => "The shared-folder is almost ready.", 'ready-user' => "The user account is almost ready.", 'verify' => "Verify your domain to finish the setup process.", 'verify-domain' => "Verify domain", 'degraded' => "Degraded", 'deleted' => "Deleted", 'suspended' => "Suspended", 'notready' => "Not Ready", 'active' => "Active", ], 'support' => [ 'title' => "Contact Support", 'id' => "Customer number or email address you have with us", 'id-pl' => "e.g. 12345678 or john@kolab.org", 'id-hint' => "Leave blank if you are not a customer yet", 'name' => "Name", 'name-pl' => "how we should call you in our reply", 'email' => "Working email address", 'email-pl' => "make sure we can reach you at this address", 'summary' => "Issue Summary", 'summary-pl' => "one sentence that summarizes your issue", 'expl' => "Issue Explanation", ], 'user' => [ '2fa-hint1' => "This will remove 2-Factor Authentication entitlement as well as the user-configured factors.", '2fa-hint2' => "Please, make sure to confirm the user identity properly.", 'add-beta' => "Enable beta program", 'address' => "Address", 'aliases' => "Aliases", 'aliases-email' => "Email Aliases", 'aliases-none' => "This user has no email aliases.", 'add-bonus' => "Add bonus", 'add-bonus-title' => "Add a bonus to the wallet", 'add-penalty' => "Add penalty", 'add-penalty-title' => "Add a penalty to the wallet", 'auto-payment' => "Auto-payment", 'auto-payment-text' => "Fill up by {amount} when under {balance} using {method}", 'country' => "Country", 'create' => "Create user", 'custno' => "Customer No.", 'degraded-warning' => "The account is degraded. Some features have been disabled.", 'degraded-hint' => "Please, make a payment.", 'delete' => "Delete user", 'delete-account' => "Delete this account?", 'delete-email' => "Delete {email}", 'delete-text' => "Do you really want to delete this user permanently?" . " This will delete all account data and withdraw the permission to access the email account." . " Please note that this action cannot be undone.", 'discount' => "Discount", 'discount-hint' => "applied discount", 'discount-title' => "Account discount", 'distlists' => "Distribution lists", 'domains' => "Domains", 'domains-none' => "There are no domains in this account.", 'ext-email' => "External Email", 'finances' => "Finances", 'greylisting' => "Greylisting", 'greylisting-text' => "Greylisting is a method of defending users against spam. Any incoming mail from an unrecognized sender " . "is temporarily rejected. The originating server should try again after a delay. " . "This time the email will be accepted. Spammers usually do not reattempt mail delivery.", 'list-title' => "User accounts", 'managed-by' => "Managed by", 'new' => "New user account", 'org' => "Organization", 'package' => "Package", 'pass-input' => "Enter password", 'pass-link' => "Set via link", 'pass-link-label' => "Link:", 'pass-link-hint' => "Press Submit to activate the link", 'passwordpolicy' => "Password Policy", 'price' => "Price", 'profile-title' => "Your profile", 'profile-delete' => "Delete account", 'profile-delete-title' => "Delete this account?", 'profile-delete-text1' => "This will delete the account as well as all domains, users and aliases associated with this account.", 'profile-delete-warning' => "This operation is irreversible", 'profile-delete-text2' => "As you will not be able to recover anything after this point, please make sure that you have migrated all data before proceeding.", 'profile-delete-support' => "As we always strive to improve, we would like to ask for 2 minutes of your time. " . "The best tool for improvement is feedback from users, and we would like to ask " . "for a few words about your reasons for leaving our service. Please send your feedback to {email}.", 'profile-delete-contact' => "Also feel free to contact {app} Support with any questions or concerns that you may have in this context.", 'reset-2fa' => "Reset 2-Factor Auth", 'reset-2fa-title' => "2-Factor Authentication Reset", 'resources' => "Resources", 'title' => "User account", 'search' => "User email address or name", 'search-pl' => "User ID, email or domain", 'skureq' => "{sku} requires {list}.", 'subscription' => "Subscription", 'subscriptions' => "Subscriptions", 'subscriptions-none' => "This user has no subscriptions.", 'users' => "Users", 'users-none' => "There are no users in this account.", ], 'wallet' => [ 'add-credit' => "Add credit", 'auto-payment-cancel' => "Cancel auto-payment", 'auto-payment-change' => "Change auto-payment", 'auto-payment-failed' => "The setup of automatic payments failed. Restart the process to enable automatic top-ups.", 'auto-payment-hint' => "Here is how it works: Every time your account runs low, we will charge your preferred payment method for an amount you choose." . " You can cancel or change the auto-payment option at any time.", 'auto-payment-setup' => "Set up auto-payment", 'auto-payment-disabled' => "The configured auto-payment has been disabled. Top up your wallet or raise the auto-payment amount.", 'auto-payment-info' => "Auto-payment is set to fill up your account by {amount} every time your account balance gets under {balance}.", 'auto-payment-inprogress' => "The setup of the automatic payment is still in progress.", 'auto-payment-next' => "Next, you will be redirected to the checkout page, where you can provide your credit card details.", 'auto-payment-disabled-next' => "The auto-payment is disabled. Immediately after you submit new settings we'll enable it and attempt to top up your wallet.", 'auto-payment-update' => "Update auto-payment", 'banktransfer-hint' => "Please note that a bank transfer can take several days to complete.", 'currency-conv' => "Here is how it works: You specify the amount by which you want to top up your wallet in {wc}." . " We will then convert this to {pc}, and on the next page you will be provided with the bank-details to transfer the amount in {pc}.", 'fill-up' => "Fill up by", 'history' => "History", 'month' => "month", 'noperm' => "Only account owners can access a wallet.", 'payment-amount-hint' => "Choose the amount by which you want to top up your wallet.", 'payment-method' => "Method of payment: {method}", 'payment-warning' => "You will be charged for {price}.", 'pending-payments' => "Pending Payments", 'pending-payments-warning' => "You have payments that are still in progress. See the \"Pending Payments\" tab below.", 'pending-payments-none' => "There are no pending payments for this account.", 'receipts' => "Receipts", 'receipts-hint' => "Here you can download receipts (in PDF format) for payments in specified period. Select the period and press the Download button.", 'receipts-none' => "There are no receipts for payments in this account. Please, note that you can download receipts after the month ends.", 'title' => "Account balance", 'top-up' => "Top up your wallet", 'transactions' => "Transactions", 'transactions-none' => "There are no transactions for this account.", 'when-below' => "when account balance is below", ], ]; diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php index 99d82857..84b39d31 100644 --- a/src/resources/lang/en/validation.php +++ b/src/resources/lang/en/validation.php @@ -1,195 +1,200 @@ 'The :attribute must be accepted.', 'accepted_if' => 'The :attribute must be accepted when :other is :value.', 'active_url' => 'The :attribute is not a valid URL.', 'after' => 'The :attribute must be a date after :date.', 'after_or_equal' => 'The :attribute must be a date after or equal to :date.', 'alpha' => 'The :attribute must only contain letters.', 'alpha_dash' => 'The :attribute must only contain letters, numbers, dashes and underscores.', 'alpha_num' => 'The :attribute must only contain letters and numbers.', 'array' => 'The :attribute must be an array.', 'before' => 'The :attribute must be a date before :date.', 'before_or_equal' => 'The :attribute must be a date before or equal to :date.', 'between' => [ 'numeric' => 'The :attribute must be between :min and :max.', 'file' => 'The :attribute must be between :min and :max kilobytes.', 'string' => 'The :attribute must be between :min and :max characters.', 'array' => 'The :attribute must have between :min and :max items.', ], 'boolean' => 'The :attribute field must be true or false.', 'confirmed' => 'The :attribute confirmation does not match.', 'current_password' => 'The password is incorrect.', 'date' => 'The :attribute is not a valid date.', 'date_equals' => 'The :attribute must be a date equal to :date.', 'date_format' => 'The :attribute does not match the format :format.', 'declined' => 'The :attribute must be declined.', 'declined_if' => 'The :attribute must be declined when :other is :value.', 'different' => 'The :attribute and :other must be different.', 'digits' => 'The :attribute must be :digits digits.', 'digits_between' => 'The :attribute must be between :min and :max digits.', 'dimensions' => 'The :attribute has invalid image dimensions.', 'distinct' => 'The :attribute field has a duplicate value.', 'email' => 'The :attribute must be a valid email address.', 'ends_with' => 'The :attribute must end with one of the following: :values', 'enum' => 'The selected :attribute is invalid.', 'exists' => 'The selected :attribute is invalid.', 'file' => 'The :attribute must be a file.', 'filled' => 'The :attribute field must have a value.', 'gt' => [ 'numeric' => 'The :attribute must be greater than :value.', 'file' => 'The :attribute must be greater than :value kilobytes.', 'string' => 'The :attribute must be greater than :value characters.', 'array' => 'The :attribute must have more than :value items.', ], 'gte' => [ 'numeric' => 'The :attribute must be greater than or equal :value.', 'file' => 'The :attribute must be greater than or equal :value kilobytes.', 'string' => 'The :attribute must be greater than or equal :value characters.', 'array' => 'The :attribute must have :value items or more.', ], 'image' => 'The :attribute must be an image.', 'in' => 'The selected :attribute is invalid.', 'in_array' => 'The :attribute field does not exist in :other.', 'integer' => 'The :attribute must be an integer.', 'ip' => 'The :attribute must be a valid IP address.', 'ipv4' => 'The :attribute must be a valid IPv4 address.', 'ipv6' => 'The :attribute must be a valid IPv6 address.', 'json' => 'The :attribute must be a valid JSON string.', 'lt' => [ 'numeric' => 'The :attribute must be less than :value.', 'file' => 'The :attribute must be less than :value kilobytes.', 'string' => 'The :attribute must be less than :value characters.', 'array' => 'The :attribute must have less than :value items.', ], 'lte' => [ 'numeric' => 'The :attribute must be less than or equal :value.', 'file' => 'The :attribute must be less than or equal :value kilobytes.', 'string' => 'The :attribute must be less than or equal :value characters.', 'array' => 'The :attribute must not have more than :value items.', ], 'max' => [ 'numeric' => 'The :attribute may not be greater than :max.', 'file' => 'The :attribute may not be greater than :max kilobytes.', 'string' => 'The :attribute may not be greater than :max characters.', 'array' => 'The :attribute may not have more than :max items.', ], 'mac_address' => 'The :attribute must be a valid MAC address.', 'mimes' => 'The :attribute must be a file of type: :values.', 'mimetypes' => 'The :attribute must be a file of type: :values.', 'min' => [ 'numeric' => 'The :attribute must be at least :min.', 'file' => 'The :attribute must be at least :min kilobytes.', 'string' => 'The :attribute must be at least :min characters.', 'array' => 'The :attribute must have at least :min items.', ], 'multiple_of' => 'The :attribute must be a multiple of :value.', 'not_in' => 'The selected :attribute is invalid.', 'not_regex' => 'The :attribute format is invalid.', 'numeric' => 'The :attribute must be a number.', 'present' => 'The :attribute field must be present.', 'prohibited' => 'The :attribute field is prohibited.', 'prohibited_if' => 'The :attribute field is prohibited when :other is :value.', 'prohibited_unless' => 'The :attribute field is prohibited unless :other is in :values.', 'prohibits' => 'The :attribute field prohibits :other from being present.', 'regex' => 'The :attribute format is invalid.', 'required' => 'The :attribute field is required.', 'required_array_keys' => 'The :attribute field must contain entries for: :values.', 'required_if' => 'The :attribute field is required when :other is :value.', 'required_unless' => 'The :attribute field is required unless :other is in :values.', 'required_with' => 'The :attribute field is required when :values is present.', 'required_with_all' => 'The :attribute field is required when :values are present.', 'required_without' => 'The :attribute field is required when :values is not present.', 'required_without_all' => 'The :attribute field is required when none of :values are present.', 'same' => 'The :attribute and :other must match.', 'size' => [ 'numeric' => 'The :attribute must be :size.', 'file' => 'The :attribute must be :size kilobytes.', 'string' => 'The :attribute must be :size characters.', 'array' => 'The :attribute must contain :size items.', ], 'starts_with' => 'The :attribute must start with one of the following: :values', 'string' => 'The :attribute must be a string.', 'timezone' => 'The :attribute must be a valid timezone.', 'unique' => 'The :attribute has already been taken.', 'uploaded' => 'The :attribute failed to upload.', 'url' => 'The :attribute must be a valid URL.', 'uuid' => 'The :attribute must be a valid UUID.', '2fareq' => 'Second factor code is required.', '2fainvalid' => 'Second factor code is invalid.', 'emailinvalid' => 'The specified email address is invalid.', 'domaininvalid' => 'The specified domain is invalid.', 'domainnotavailable' => 'The specified domain is not available.', 'logininvalid' => 'The specified login is invalid.', 'loginexists' => 'The specified login is not available.', 'domainexists' => 'The specified domain is not available.', 'noemailorphone' => 'The specified text is neither a valid email address nor a phone number.', 'packageinvalid' => 'Invalid package selected.', 'packagerequired' => 'Package is required.', 'usernotexists' => 'Unable to find user.', 'voucherinvalid' => 'The voucher code is invalid or expired.', 'noextemail' => 'This user has no external email address.', 'entryinvalid' => 'The specified :attribute is invalid.', 'entryexists' => 'The specified :attribute is not available.', 'minamount' => 'Minimum amount for a single payment is :amount.', 'minamountdebt' => 'The specified amount does not cover the balance on the account.', 'notalocaluser' => 'The specified email address does not exist.', 'memberislist' => 'A recipient cannot be the same as the list address.', 'listmembersrequired' => 'At least one recipient is required.', 'spf-entry-invalid' => 'The entry format is invalid. Expected a domain name starting with a dot.', 'sp-entry-invalid' => 'The entry format is invalid. Expected an email, domain, or part of it.', 'acl-entry-invalid' => 'The entry format is invalid. Expected an email address.', + 'file-perm-exists' => 'File permission already exists.', + 'file-perm-invalid' => 'The file permission is invalid.', + 'file-name-exists' => 'The file name already exists.', + 'file-name-invalid' => 'The file name is invalid.', + 'file-name-toolong' => 'The file name is too long.', 'ipolicy-invalid' => 'The specified invitation policy is invalid.', 'invalid-config-parameter' => 'The requested configuration parameter is not supported.', 'nameexists' => 'The specified name is not available.', 'nameinvalid' => 'The specified name is invalid.', 'password-policy-error' => 'Specified password does not comply with the policy.', 'invalid-password-policy' => 'Specified password policy is invalid.', 'password-policy-min-len-error' => 'Minimum password length cannot be less than :min.', 'password-policy-max-len-error' => 'Maximum password length cannot be more than :max.', 'password-policy-last-error' => 'The minimum value for last N passwords is :last.', /* |-------------------------------------------------------------------------- | Custom Validation Language Lines |-------------------------------------------------------------------------- | | Here you may specify custom validation messages for attributes using the | convention "attribute.rule" to name the lines. This makes it quick to | specify a specific custom language line for a given attribute rule. | */ 'custom' => [ 'attribute-name' => [ 'rule-name' => 'custom-message', ], ], /* |-------------------------------------------------------------------------- | Custom Validation Attributes |-------------------------------------------------------------------------- | | The following language lines are used to swap our attribute placeholder | with something more reader friendly such as "E-Mail Address" instead | of "email". This simply helps us make our message more expressive. | */ 'attributes' => [], ]; diff --git a/src/resources/themes/app.scss b/src/resources/themes/app.scss index 17605297..88dfaa71 100644 --- a/src/resources/themes/app.scss +++ b/src/resources/themes/app.scss @@ -1,462 +1,496 @@ html, body, body > .outer-container { height: 100%; } #app { display: flex; flex-direction: column; min-height: 100%; overflow: hidden; & > nav { flex-shrink: 0; z-index: 12; } & > div.container { flex-grow: 1; margin-top: 2rem; margin-bottom: 2rem; } & > .filler { flex-grow: 1; } & > div.container + .filler { display: none; } } .error-page { position: absolute; top: 0; height: 100%; width: 100%; align-content: center; align-items: center; display: flex; flex-wrap: wrap; justify-content: center; color: #636b6f; z-index: 10; background: white; .code { text-align: right; border-right: 2px solid; font-size: 26px; padding: 0 15px; } .message { font-size: 18px; padding: 0 15px; } .hint { margin-top: 3em; text-align: center; width: 100%; } } .app-loader { background-color: $body-bg; height: 100%; width: 100%; position: absolute; top: 0; left: 0; display: flex; align-items: center; justify-content: center; z-index: 8; .spinner-border { width: 120px; height: 120px; border-width: 15px; color: #b2aa99; } &.small .spinner-border { width: 25px; height: 25px; border-width: 3px; } &.fadeOut { visibility: hidden; opacity: 0; transition: visibility 300ms linear, opacity 300ms linear; } } pre { margin: 1rem 0; padding: 1rem; background-color: $menu-bg-color; } .card-title { font-size: 1.2rem; font-weight: bold; } tfoot.table-fake-body { background-color: #f8f8f8; color: grey; text-align: center; td { vertical-align: middle; height: 8em; border: 0; } tbody:not(:empty) + & { display: none; } } table { th { white-space: nowrap; } td.email, td.price, td.datetime, td.selection { width: 1%; white-space: nowrap; } td.buttons, th.price, - td.price { + td.price, + th.size, + td.size { width: 1%; text-align: right; white-space: nowrap; } &.form-list { margin: 0; td { border: 0; &:first-child { padding-left: 0; } &:last-child { padding-right: 0; } } button { line-height: 1; } } .btn-action { line-height: 1; padding: 0; } td { & > svg + a, & > svg + span { margin-left: .4em; } } + + &.files { + table-layout: fixed; + + td { + white-space: nowrap; + } + + td.name { + overflow: hidden; + text-overflow: ellipsis; + } +/* + td.size, + th.size { + width: 80px; + } + + td.mtime, + th.mtime { + width: 140px; + + @include media-breakpoint-down(sm) { + display: none; + } + } +*/ + td.buttons, + th.buttons { + width: 50px; + } + } } .list-details { min-height: 1em; & > ul { margin: 0; padding-left: 1.2em; } } .plan-selector { .plan-header { display: flex; } .plan-ico { margin:auto; font-size: 3.8rem; color: #f1a539; border: 3px solid #f1a539; width: 6rem; height: 6rem; border-radius: 50%; } } .status-message { display: flex; align-items: center; justify-content: center; .app-loader { width: auto; position: initial; .spinner-border { color: $body-color; } } svg { font-size: 1.5em; } :first-child { margin-right: 0.4em; } } .form-separator { position: relative; margin: 1em 0; display: flex; justify-content: center; hr { border-color: #999; margin: 0; position: absolute; top: 0.75em; width: 100%; } span { background: #fff; padding: 0 1em; z-index: 1; } } #status-box { background-color: lighten($green, 35); .progress { background-color: #fff; height: 10px; } .progress-label { font-size: 0.9em; } .progress-bar { background-color: $green; } &.process-failed { background-color: lighten($orange, 30); .progress-bar { background-color: $red; } } } @keyframes blinker { 50% { opacity: 0; } } .blinker { animation: blinker 750ms step-start infinite; } #dashboard-nav { display: flex; flex-wrap: wrap; justify-content: center; & > a { padding: 1rem; text-align: center; white-space: nowrap; margin: 0.25rem; text-decoration: none; width: 150px; &.disabled { pointer-events: none; opacity: 0.6; } // Some icons are too big, scale them down &.link-domains, &.link-resources, &.link-settings, &.link-wallet, &.link-invitations { svg { transform: scale(0.9); } } .badge { position: absolute; top: 0.5rem; right: 0.5rem; } } svg { width: 6rem; height: 6rem; margin: auto; } } #payment-method-selection { display: flex; flex-wrap: wrap; justify-content: center; & > a { padding: 1rem; text-align: center; white-space: nowrap; margin: 0.25rem; text-decoration: none; width: 150px; } svg { width: 6rem; height: 6rem; margin: auto; } } #logon-form { flex-basis: auto; // Bootstrap issue? See logon page with width < 992 } #logon-form-footer { a:not(:first-child) { margin-left: 2em; } } // Various improvements for mobile @include media-breakpoint-down(sm) { .card, .card-footer { border: 0; } .card-body { padding: 0.5rem 0; } .nav-tabs { flex-wrap: nowrap; .nav-link { white-space: nowrap; padding: 0.5rem 0.75rem; } } #app > div.container { margin-bottom: 1rem; margin-top: 1rem; max-width: 100%; } #header-menu-navbar { padding: 0; } #dashboard-nav > a { width: 135px; } .table-sm:not(.form-list) { tbody td { padding: 0.75rem 0.5rem; svg { vertical-align: -0.175em; } & > svg { font-size: 125%; margin-right: 0.25rem; } } } .table.transactions { thead { display: none; } tbody { tr { position: relative; display: flex; flex-wrap: wrap; } td { width: auto; border: 0; padding: 0.5rem; &.datetime { width: 50%; padding-left: 0; } &.description { order: 3; width: 100%; border-bottom: 1px solid $border-color; color: $secondary; padding: 0 1.5em 0.5rem 0; margin-top: -0.25em; } &.selection { position: absolute; right: 0; border: 0; top: 1.7em; padding-right: 0; } &.price { width: 50%; padding-right: 0; } &.email { display: none; } } } } } @include media-breakpoint-down(sm) { .tab-pane > .card-body { padding: 0.5rem; } } diff --git a/src/resources/themes/forms.scss b/src/resources/themes/forms.scss index b13edc66..e1da1cf2 100644 --- a/src/resources/themes/forms.scss +++ b/src/resources/themes/forms.scss @@ -1,171 +1,200 @@ .list-input { & > div { &:not(:last-child) { margin-bottom: -1px; input, a.btn { border-bottom-right-radius: 0; border-bottom-left-radius: 0; } } &:not(:first-child) { input, a.btn { border-top-right-radius: 0; border-top-left-radius: 0; } } } input.is-invalid { z-index: 2; } .btn svg { vertical-align: middle; } } .acl-input { select.acl, select.mod-user { max-width: fit-content; } } .password-input { ul { svg { width: 0.75em !important; } span { padding: 0 0.05em; } } } .range-input { display: flex; label { margin-right: 0.5em; min-width: 4em; text-align: right; line-height: 1.7; } } .input-group-activable { &.active { :not(.activable) { display: none; } } &:not(.active) { .activable { display: none; } } // Label is always visible .label { color: $body-color; display: initial !important; } .input-group-text { border-color: transparent; background: transparent; padding-left: 0; &:not(.label) { flex: 1; } } } // An input group with a select and input, where input is displayed // only for some select values .input-group-select { &:not(.selected) { input { display: none; } select { border-bottom-right-radius: .25rem !important; border-top-right-radius: .25rem !important; } } input { border-bottom-right-radius: .25rem !important; border-top-right-radius: .25rem !important; } } .form-control-plaintext .btn-sm { margin-top: -0.25rem; } .buttons { & > button + button { margin-left: .5em; } } // Various improvements for mobile @include media-breakpoint-down(sm) { .row.mb-3 { margin-bottom: 0.5rem !important; } .nav-tabs { .nav-link { white-space: nowrap; padding: 0.5rem 0.75rem; } } .tab-content { margin-top: 0.5rem; } .col-form-label { color: #666; font-size: 95%; } .row.plaintext .col-form-label { padding-bottom: 0; } form.read-only.short label { width: 35%; & + * { width: 65%; } } .row.checkbox { position: relative; & > div { padding-top: 0 !important; input { position: absolute; top: 0.5rem; right: 1rem; } } label { padding-right: 2.5rem; } } } + +.file-drop-area { + display: inline; + background: $menu-bg-color; + color: grey; + font-size: 0.9rem; + font-weight: normal; + line-height: 2; + border: 1px solid #eee; + border-radius: 0.5em; + padding: 0.5em; + cursor: pointer; + position: relative; + + input { + position: absolute; + height: 10px; + } + + &.dragactive { + border: 1px dashed #aaa; + } + + &.dragover { + background-color: rgba($main-color, 0.25); + border: 1px dashed $main-color; + color: $main-color; + } +} diff --git a/src/resources/themes/toast.scss b/src/resources/themes/toast.scss index c537a4be..ccfe4ca3 100644 --- a/src/resources/themes/toast.scss +++ b/src/resources/themes/toast.scss @@ -1,53 +1,67 @@ .toast-container { position: fixed; bottom: 0; right: 0; margin: 0.5rem; width: 320px; max-height: calc(100% - 1rem); overflow-y: auto; scrollbar-width: thin; scrollbar-color: rgba(52, 58, 64, 0.95) transparent; z-index: 1065; // above Bootstrap's modal backdrop and dialogs @media (max-width: 375px) { left: 0; width: auto; } } .toast { background-color: rgba(52, 58, 64, 0.95); &:not(:last-child) { margin-bottom: 0.3rem; } @media (max-width: 375px) { max-width: 100%; } } .toast-header { background-color: #343a40; border-color: #555; color: #fff; strong { flex: 1; } svg { font-size: 1.2em; margin-right: 0.5rem; } .btn-close { font-size: 0.8em; cursor: pointer; } } .toast-body { color: #fff; } + +.toast-progress { + margin: 0.5em; + margin-top: 0; + height: 3px; + background: #222; + border-radius: 1.5px; + overflow: hidden; +} + +.toast-progress-bar { + height: 100%; + background: $main-color; +} diff --git a/src/resources/vue/Dashboard.vue b/src/resources/vue/Dashboard.vue index 860d3770..744e2cd1 100644 --- a/src/resources/vue/Dashboard.vue +++ b/src/resources/vue/Dashboard.vue @@ -1,84 +1,88 @@ diff --git a/src/resources/vue/File/Info.vue b/src/resources/vue/File/Info.vue new file mode 100644 index 00000000..09e7e0aa --- /dev/null +++ b/src/resources/vue/File/Info.vue @@ -0,0 +1,163 @@ + + + diff --git a/src/resources/vue/File/List.vue b/src/resources/vue/File/List.vue new file mode 100644 index 00000000..0805bd2c --- /dev/null +++ b/src/resources/vue/File/List.vue @@ -0,0 +1,133 @@ + + + diff --git a/src/resources/vue/Widgets/AclInput.vue b/src/resources/vue/Widgets/AclInput.vue index 6e03a965..d3c94c9e 100644 --- a/src/resources/vue/Widgets/AclInput.vue +++ b/src/resources/vue/Widgets/AclInput.vue @@ -1,118 +1,119 @@ diff --git a/src/resources/vue/Widgets/ListTools.vue b/src/resources/vue/Widgets/ListTools.vue index f4f7f15c..590d5595 100644 --- a/src/resources/vue/Widgets/ListTools.vue +++ b/src/resources/vue/Widgets/ListTools.vue @@ -1,116 +1,116 @@ diff --git a/src/resources/vue/Widgets/Toast.vue b/src/resources/vue/Widgets/Toast.vue index 326f316e..a26734f4 100644 --- a/src/resources/vue/Widgets/Toast.vue +++ b/src/resources/vue/Widgets/Toast.vue @@ -1,116 +1,118 @@ diff --git a/src/resources/vue/Widgets/ToastMessage.vue b/src/resources/vue/Widgets/ToastMessage.vue index 6d25d120..d7ca30a4 100644 --- a/src/resources/vue/Widgets/ToastMessage.vue +++ b/src/resources/vue/Widgets/ToastMessage.vue @@ -1,61 +1,70 @@ diff --git a/src/routes/api.php b/src/routes/api.php index 85e30942..e93c84e1 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -1,250 +1,261 @@ 'api', 'prefix' => 'auth' ], function () { Route::post('login', [API\AuthController::class, 'login']); Route::group( ['middleware' => 'auth:api'], function () { Route::get('info', [API\AuthController::class, 'info']); Route::post('info', [API\AuthController::class, 'info']); Route::post('logout', [API\AuthController::class, 'logout']); Route::post('refresh', [API\AuthController::class, 'refresh']); } ); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'middleware' => 'api', 'prefix' => 'auth' ], function () { Route::post('password-policy/check', [API\PasswordPolicyController::class, 'check']); Route::post('password-reset/init', [API\PasswordResetController::class, 'init']); Route::post('password-reset/verify', [API\PasswordResetController::class, 'verify']); Route::post('password-reset', [API\PasswordResetController::class, 'reset']); Route::post('signup/init', [API\SignupController::class, 'init']); Route::get('signup/invitations/{id}', [API\SignupController::class, 'invitation']); Route::get('signup/plans', [API\SignupController::class, 'plans']); Route::post('signup/verify', [API\SignupController::class, 'verify']); Route::post('signup', [API\SignupController::class, 'signup']); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'middleware' => 'auth:api', 'prefix' => 'v4' ], function () { Route::post('companion/register', [API\V4\CompanionAppsController::class, 'register']); Route::post('auth-attempts/{id}/confirm', [API\V4\AuthAttemptsController::class, 'confirm']); Route::post('auth-attempts/{id}/deny', [API\V4\AuthAttemptsController::class, 'deny']); Route::get('auth-attempts/{id}/details', [API\V4\AuthAttemptsController::class, 'details']); Route::get('auth-attempts', [API\V4\AuthAttemptsController::class, 'index']); Route::get('companion/pairing', [API\V4\CompanionAppsController::class, 'pairing']); Route::apiResource('companion', API\V4\CompanionAppsController::class); Route::post('companion/register', [API\V4\CompanionAppsController::class, 'register']); Route::post('companion/revoke', [API\V4\CompanionAppsController::class, 'revokeAll']); Route::apiResource('domains', API\V4\DomainsController::class); Route::get('domains/{id}/confirm', [API\V4\DomainsController::class, 'confirm']); Route::get('domains/{id}/skus', [API\V4\SkusController::class, 'domainSkus']); Route::get('domains/{id}/status', [API\V4\DomainsController::class, 'status']); Route::post('domains/{id}/config', [API\V4\DomainsController::class, 'setConfig']); + Route::apiResource('files', API\V4\FilesController::class); + Route::get('files/{fileId}/permissions', [API\V4\FilesController::class, 'getPermissions']); + Route::post('files/{fileId}/permissions', [API\V4\FilesController::class, 'createPermission']); + Route::put('files/{fileId}/permissions/{id}', [API\V4\FilesController::class, 'updatePermission']); + Route::delete('files/{fileId}/permissions/{id}', [API\V4\FilesController::class, 'deletePermission']); + Route::post('files/uploads/{id}', [API\V4\FilesController::class, 'upload']) + ->withoutMiddleware(['auth:api']) + ->middleware(['api']); + Route::get('files/downloads/{id}', [API\V4\FilesController::class, 'download']) + ->withoutMiddleware(['auth:api']); + Route::apiResource('groups', API\V4\GroupsController::class); Route::get('groups/{id}/status', [API\V4\GroupsController::class, 'status']); Route::post('groups/{id}/config', [API\V4\GroupsController::class, 'setConfig']); Route::apiResource('packages', API\V4\PackagesController::class); Route::get('meet/rooms', [API\V4\MeetController::class, 'index']); Route::post('meet/rooms/{id}/config', [API\V4\MeetController::class, 'setRoomConfig']); Route::post('meet/rooms/{id}', [API\V4\MeetController::class, 'joinRoom']) ->withoutMiddleware(['auth:api']); Route::apiResource('resources', API\V4\ResourcesController::class); Route::get('resources/{id}/status', [API\V4\ResourcesController::class, 'status']); Route::post('resources/{id}/config', [API\V4\ResourcesController::class, 'setConfig']); Route::apiResource('shared-folders', API\V4\SharedFoldersController::class); Route::get('shared-folders/{id}/status', [API\V4\SharedFoldersController::class, 'status']); Route::post('shared-folders/{id}/config', [API\V4\SharedFoldersController::class, 'setConfig']); Route::apiResource('skus', API\V4\SkusController::class); Route::apiResource('users', API\V4\UsersController::class); Route::post('users/{id}/config', [API\V4\UsersController::class, 'setConfig']); Route::get('users/{id}/skus', [API\V4\SkusController::class, 'userSkus']); Route::get('users/{id}/status', [API\V4\UsersController::class, 'status']); Route::apiResource('wallets', API\V4\WalletsController::class); Route::get('wallets/{id}/transactions', [API\V4\WalletsController::class, 'transactions']); Route::get('wallets/{id}/receipts', [API\V4\WalletsController::class, 'receipts']); Route::get('wallets/{id}/receipts/{receipt}', [API\V4\WalletsController::class, 'receiptDownload']); Route::get('password-policy', [API\PasswordPolicyController::class, 'index']); Route::post('password-reset/code', [API\PasswordResetController::class, 'codeCreate']); Route::delete('password-reset/code/{id}', [API\PasswordResetController::class, 'codeDelete']); Route::post('payments', [API\V4\PaymentsController::class, 'store']); //Route::delete('payments', [API\V4\PaymentsController::class, 'cancel']); Route::get('payments/mandate', [API\V4\PaymentsController::class, 'mandate']); Route::post('payments/mandate', [API\V4\PaymentsController::class, 'mandateCreate']); Route::put('payments/mandate', [API\V4\PaymentsController::class, 'mandateUpdate']); Route::delete('payments/mandate', [API\V4\PaymentsController::class, 'mandateDelete']); Route::get('payments/methods', [API\V4\PaymentsController::class, 'paymentMethods']); Route::get('payments/pending', [API\V4\PaymentsController::class, 'payments']); Route::get('payments/has-pending', [API\V4\PaymentsController::class, 'hasPayments']); Route::post('support/request', [API\V4\SupportController::class, 'request']) ->withoutMiddleware(['auth:api']) ->middleware(['api']); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'prefix' => 'webhooks' ], function () { Route::post('payment/{provider}', [API\V4\PaymentsController::class, 'webhook']); Route::post('meet', [API\V4\MeetController::class, 'webhook']); } ); if (\config('app.with_services')) { Route::group( [ 'domain' => 'services.' . \config('app.website_domain'), 'prefix' => 'webhooks' ], function () { Route::get('nginx', [API\V4\NGINXController::class, 'authenticate']); Route::get('nginx-httpauth', [API\V4\NGINXController::class, 'httpauth']); Route::post('policy/greylist', [API\V4\PolicyController::class, 'greylist']); Route::post('policy/ratelimit', [API\V4\PolicyController::class, 'ratelimit']); Route::post('policy/spf', [API\V4\PolicyController::class, 'senderPolicyFramework']); } ); } if (\config('app.with_admin')) { Route::group( [ 'domain' => 'admin.' . \config('app.website_domain'), 'middleware' => ['auth:api', 'admin'], 'prefix' => 'v4', ], function () { Route::apiResource('domains', API\V4\Admin\DomainsController::class); Route::get('domains/{id}/skus', [API\V4\Admin\SkusController::class, 'domainSkus']); Route::post('domains/{id}/suspend', [API\V4\Admin\DomainsController::class, 'suspend']); Route::post('domains/{id}/unsuspend', [API\V4\Admin\DomainsController::class, 'unsuspend']); Route::apiResource('groups', API\V4\Admin\GroupsController::class); Route::post('groups/{id}/suspend', [API\V4\Admin\GroupsController::class, 'suspend']); Route::post('groups/{id}/unsuspend', [API\V4\Admin\GroupsController::class, 'unsuspend']); Route::apiResource('resources', API\V4\Admin\ResourcesController::class); Route::apiResource('shared-folders', API\V4\Admin\SharedFoldersController::class); Route::apiResource('skus', API\V4\Admin\SkusController::class); Route::apiResource('users', API\V4\Admin\UsersController::class); Route::get('users/{id}/discounts', [API\V4\Reseller\DiscountsController::class, 'userDiscounts']); Route::post('users/{id}/reset2FA', [API\V4\Admin\UsersController::class, 'reset2FA']); Route::get('users/{id}/skus', [API\V4\Admin\SkusController::class, 'userSkus']); Route::post('users/{id}/skus/{sku}', [API\V4\Admin\UsersController::class, 'setSku']); Route::post('users/{id}/suspend', [API\V4\Admin\UsersController::class, 'suspend']); Route::post('users/{id}/unsuspend', [API\V4\Admin\UsersController::class, 'unsuspend']); Route::apiResource('wallets', API\V4\Admin\WalletsController::class); Route::post('wallets/{id}/one-off', [API\V4\Admin\WalletsController::class, 'oneOff']); Route::get('wallets/{id}/transactions', [API\V4\Admin\WalletsController::class, 'transactions']); Route::get('stats/chart/{chart}', [API\V4\Admin\StatsController::class, 'chart']); } ); } if (\config('app.with_reseller')) { Route::group( [ 'domain' => 'reseller.' . \config('app.website_domain'), 'middleware' => ['auth:api', 'reseller'], 'prefix' => 'v4', ], function () { Route::apiResource('domains', API\V4\Reseller\DomainsController::class); Route::get('domains/{id}/skus', [API\V4\Reseller\SkusController::class, 'domainSkus']); Route::post('domains/{id}/suspend', [API\V4\Reseller\DomainsController::class, 'suspend']); Route::post('domains/{id}/unsuspend', [API\V4\Reseller\DomainsController::class, 'unsuspend']); Route::apiResource('groups', API\V4\Reseller\GroupsController::class); Route::post('groups/{id}/suspend', [API\V4\Reseller\GroupsController::class, 'suspend']); Route::post('groups/{id}/unsuspend', [API\V4\Reseller\GroupsController::class, 'unsuspend']); Route::apiResource('invitations', API\V4\Reseller\InvitationsController::class); Route::post('invitations/{id}/resend', [API\V4\Reseller\InvitationsController::class, 'resend']); Route::post('payments', [API\V4\Reseller\PaymentsController::class, 'store']); Route::get('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandate']); Route::post('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandateCreate']); Route::put('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandateUpdate']); Route::delete('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandateDelete']); Route::get('payments/methods', [API\V4\Reseller\PaymentsController::class, 'paymentMethods']); Route::get('payments/pending', [API\V4\Reseller\PaymentsController::class, 'payments']); Route::get('payments/has-pending', [API\V4\Reseller\PaymentsController::class, 'hasPayments']); Route::apiResource('resources', API\V4\Reseller\ResourcesController::class); Route::apiResource('shared-folders', API\V4\Reseller\SharedFoldersController::class); Route::apiResource('skus', API\V4\Reseller\SkusController::class); Route::apiResource('users', API\V4\Reseller\UsersController::class); Route::get('users/{id}/discounts', [API\V4\Reseller\DiscountsController::class, 'userDiscounts']); Route::post('users/{id}/reset2FA', [API\V4\Reseller\UsersController::class, 'reset2FA']); Route::get('users/{id}/skus', [API\V4\Reseller\SkusController::class, 'userSkus']); Route::post('users/{id}/skus/{sku}', [API\V4\Admin\UsersController::class, 'setSku']); Route::post('users/{id}/suspend', [API\V4\Reseller\UsersController::class, 'suspend']); Route::post('users/{id}/unsuspend', [API\V4\Reseller\UsersController::class, 'unsuspend']); Route::apiResource('wallets', API\V4\Reseller\WalletsController::class); Route::post('wallets/{id}/one-off', [API\V4\Reseller\WalletsController::class, 'oneOff']); Route::get('wallets/{id}/receipts', [API\V4\Reseller\WalletsController::class, 'receipts']); Route::get('wallets/{id}/receipts/{receipt}', [API\V4\Reseller\WalletsController::class, 'receiptDownload']); Route::get('wallets/{id}/transactions', [API\V4\Reseller\WalletsController::class, 'transactions']); Route::get('stats/chart/{chart}', [API\V4\Reseller\StatsController::class, 'chart']); } ); } diff --git a/src/tests/Feature/Controller/FilesTest.php b/src/tests/Feature/Controller/FilesTest.php new file mode 100644 index 00000000..37daebb4 --- /dev/null +++ b/src/tests/Feature/Controller/FilesTest.php @@ -0,0 +1,732 @@ +delete(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + Item::query()->delete(); + + $disk = LaravelStorage::disk('files'); + foreach ($disk->listContents('') as $dir) { + $disk->deleteDirectory($dir->path()); + } + + parent::tearDown(); + } + + /** + * Test deleting files (DELETE /api/v4/files/) + */ + public function testDelete(): void + { + $john = $this->getTestUser('john@kolab.org'); + $jack = $this->getTestUser('jack@kolab.org'); + $file = $this->getTestFile($john, 'teśt.txt', 'Teśt content'); + + // Unauth access + $response = $this->delete("api/v4/files/{$file->id}"); + $response->assertStatus(401); + + // Unauth access + $response = $this->actingAs($jack)->delete("api/v4/files/{$file->id}"); + $response->assertStatus(403); + + // Non-existing file + $response = $this->actingAs($john)->delete("api/v4/files/123"); + $response->assertStatus(404); + + // File owner access + $response = $this->actingAs($john)->delete("api/v4/files/{$file->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertSame("File deleted successfully.", $json['message']); + + // Test that the file has been removed from the filesystem? + $disk = LaravelStorage::disk('files'); + $this->assertFalse($disk->directoryExists($file->path . '/' . $file->id)); + $this->assertSame(null, Item::find($file->id)); + + // Test deletion of a chunked file + $file = $this->getTestFile($john, 'test.txt', ['T1', 'T2']); + + $response = $this->actingAs($john)->delete("api/v4/files/{$file->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertSame("File deleted successfully.", $json['message']); + + // Test that the file has been removed from the filesystem? + $disk = LaravelStorage::disk('files'); + $this->assertFalse($disk->directoryExists($file->path . '/' . $file->id)); + $this->assertSame(null, Item::find($file->id)); + + // TODO: Test acting as another user with permissions + } + + /** + * Test file downloads (GET /api/v4/files/downloads/) + */ + public function testDownload(): void + { + $john = $this->getTestUser('john@kolab.org'); + $jack = $this->getTestUser('jack@kolab.org'); + $file = $this->getTestFile($john, 'teśt.txt', 'Teśt content'); + + // Unauth access + $response = $this->get("api/v4/files/{$file->id}?downloadUrl=1"); + $response->assertStatus(401); + + $response = $this->actingAs($jack)->get("api/v4/files/{$file->id}?downloadUrl=1"); + $response->assertStatus(403); + + // Non-existing file + $response = $this->actingAs($john)->get("api/v4/files/123456?downloadUrl=1"); + $response->assertStatus(404); + + // Get downloadLink for the file + $response = $this->actingAs($john)->get("api/v4/files/{$file->id}?downloadUrl=1"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame($file->id, $json['id']); + $link = $json['downloadUrl']; + + // Fetch the file content + $response = $this->get(substr($link, strpos($link, '/api/') + 1)); + $response->assertStatus(200) + ->assertHeader('Content-Disposition', "attachment; filename=test.txt; filename*=utf-8''te%C5%9Bt.txt") + ->assertHeader('Content-Length', $file->getProperty('size')) + ->assertHeader('Content-Type', $file->getProperty('mimetype') . '; charset=UTF-8'); + + $this->assertSame('Teśt content', $response->streamedContent()); + + // Test acting as another user with read permission + $permission = $this->getTestFilePermission($file, $jack, 'r'); + $response = $this->actingAs($jack)->get("api/v4/files/{$permission->key}?downloadUrl=1"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame($file->id, $json['id']); + $link = $json['downloadUrl']; + + // Fetch the file content + $response = $this->get(substr($link, strpos($link, '/api/') + 1)); + $response->assertStatus(200) + ->assertHeader('Content-Disposition', "attachment; filename=test.txt; filename*=utf-8''te%C5%9Bt.txt") + ->assertHeader('Content-Length', $file->getProperty('size')) + ->assertHeader('Content-Type', $file->getProperty('mimetype') . '; charset=UTF-8'); + + $this->assertSame('Teśt content', $response->streamedContent()); + + // Test downloading a multi-chunk file + $file = $this->getTestFile($john, 'test2.txt', ['T1', 'T2']); + $response = $this->actingAs($john)->get("api/v4/files/{$file->id}?downloadUrl=1"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame($file->id, $json['id']); + $link = $json['downloadUrl']; + + // Fetch the file content + $response = $this->get(substr($link, strpos($link, '/api/') + 1)); + $response->assertStatus(200) + ->assertHeader('Content-Disposition', "attachment; filename=test2.txt") + ->assertHeader('Content-Length', $file->getProperty('size')) + ->assertHeader('Content-Type', $file->getProperty('mimetype')); + + $this->assertSame('T1T2', $response->streamedContent()); + } + + /** + * Test fetching/creating/updaing/deleting file permissions (GET|POST|PUT /api/v4/files//permissions) + */ + public function testPermissions(): void + { + $john = $this->getTestUser('john@kolab.org'); + $jack = $this->getTestUser('jack@kolab.org'); + $file = $this->getTestFile($john, 'test1.txt', []); + + // Unauth access not allowed + $response = $this->get("api/v4/files/{$file->id}/permissions"); + $response->assertStatus(401); + $response = $this->post("api/v4/files/{$file->id}/permissions", []); + $response->assertStatus(401); + + // Non-existing file + $response = $this->actingAs($john)->get("api/v4/files/1234/permissions"); + $response->assertStatus(404); + $response = $this->actingAs($john)->post("api/v4/files/1234/permissions", []); + $response->assertStatus(404); + + // No permissions to the file + $response = $this->actingAs($jack)->get("api/v4/files/{$file->id}/permissions"); + $response->assertStatus(403); + $response = $this->actingAs($jack)->post("api/v4/files/{$file->id}/permissions", []); + $response->assertStatus(403); + + // Expect an empty list of permissions + $response = $this->actingAs($john)->get("api/v4/files/{$file->id}/permissions"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(2, $json); + $this->assertSame([], $json['list']); + $this->assertSame(0, $json['count']); + + // Empty input + $response = $this->actingAs($john)->post("api/v4/files/{$file->id}/permissions", []); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(2, $json['errors']); + $this->assertSame(["The user field is required."], $json['errors']['user']); + $this->assertSame(["The permissions field is required."], $json['errors']['permissions']); + + // Test more input validation + $post = ['user' => 'user', 'permissions' => 'read']; + $response = $this->actingAs($john)->post("api/v4/files/{$file->id}/permissions", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(2, $json['errors']); + $this->assertSame(["The user must be a valid email address."], $json['errors']['user']); + $this->assertSame("The file permission is invalid.", $json['errors']['permissions']); + + // Let's add some permission + $post = ['user' => 'jack@kolab.org', 'permissions' => 'read-only']; + $response = $this->actingAs($john)->post("api/v4/files/{$file->id}/permissions", $post); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertSame("File permissions created successfully.", $json['message']); + + $permission = $file->properties()->where('key', 'like', 'share-%')->orderBy('value')->first(); + + $this->assertSame("{$jack->email}:r", $permission->value); + $this->assertSame($permission->key, $json['id']); + $this->assertSame($jack->email, $json['user']); + $this->assertSame('read-only', $json['permissions']); + $this->assertSame(\App\Utils::serviceUrl('file/' . $permission->key), $json['link']); + + // Error handling on use of the same user + $post = ['user' => 'jack@kolab.org', 'permissions' => 'read-only']; + $response = $this->actingAs($john)->post("api/v4/files/{$file->id}/permissions", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertSame("File permission already exists.", $json['errors']['user']); + + // Test update + $response = $this->actingAs($john)->put("api/v4/files/{$file->id}/permissions/1234", $post); + $response->assertStatus(404); + + $post = ['user' => 'jack@kolab.org', 'permissions' => 'read-write']; + $response = $this->actingAs($john)->put("api/v4/files/{$file->id}/permissions/{$permission->key}", $post); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertSame("File permissions updated successfully.", $json['message']); + + $permission->refresh(); + + $this->assertSame("{$jack->email}:rw", $permission->value); + $this->assertSame($permission->key, $json['id']); + $this->assertSame($jack->email, $json['user']); + $this->assertSame('read-write', $json['permissions']); + $this->assertSame(\App\Utils::serviceUrl('file/' . $permission->key), $json['link']); + + // Input validation on update + $post = ['user' => 'jack@kolab.org', 'permissions' => 'read']; + $response = $this->actingAs($john)->put("api/v4/files/{$file->id}/permissions/{$permission->key}", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertSame("The file permission is invalid.", $json['errors']['permissions']); + + // Test GET with existing permissions + $response = $this->actingAs($john)->get("api/v4/files/{$file->id}/permissions"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(2, $json); + $this->assertCount(1, $json['list']); + $this->assertSame(1, $json['count']); + + $this->assertSame($permission->key, $json['list'][0]['id']); + $this->assertSame($jack->email, $json['list'][0]['user']); + $this->assertSame('read-write', $json['list'][0]['permissions']); + $this->assertSame(\App\Utils::serviceUrl('file/' . $permission->key), $json['list'][0]['link']); + + // Delete permission + $response = $this->actingAs($john)->delete("api/v4/files/{$file->id}/permissions/1234"); + $response->assertStatus(404); + + $response = $this->actingAs($john)->delete("api/v4/files/{$file->id}/permissions/{$permission->key}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertSame("File permissions deleted successfully.", $json['message']); + + $this->assertCount(0, $file->properties()->where('key', 'like', 'share-%')->get()); + } + + /** + * Test fetching files/folders list (GET /api/v4/files) + */ + public function testIndex(): void + { + // Unauth access not allowed + $response = $this->get("api/v4/files"); + $response->assertStatus(401); + + $user = $this->getTestUser('john@kolab.org'); + + // Expect an empty list + $response = $this->actingAs($user)->get("api/v4/files"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(3, $json); + $this->assertSame([], $json['list']); + $this->assertSame(0, $json['count']); + $this->assertSame(false, $json['hasMore']); + + // Create some files and test again + $file1 = $this->getTestFile($user, 'test1.txt', [], ['mimetype' => 'text/plain', 'size' => 12345]); + $file2 = $this->getTestFile($user, 'test2.gif', [], ['mimetype' => 'image/gif', 'size' => 10000]); + + $response = $this->actingAs($user)->get("api/v4/files"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(3, $json); + $this->assertSame(2, $json['count']); + $this->assertSame(false, $json['hasMore']); + $this->assertCount(2, $json['list']); + $this->assertSame('test1.txt', $json['list'][0]['name']); + $this->assertSame($file1->id, $json['list'][0]['id']); + $this->assertSame('test2.gif', $json['list'][1]['name']); + $this->assertSame($file2->id, $json['list'][1]['id']); + + // Searching + $response = $this->actingAs($user)->get("api/v4/files?search=t2"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(3, $json); + $this->assertSame(1, $json['count']); + $this->assertSame(false, $json['hasMore']); + $this->assertCount(1, $json['list']); + $this->assertSame('test2.gif', $json['list'][0]['name']); + $this->assertSame($file2->id, $json['list'][0]['id']); + + // TODO: Test paging + } + + /** + * Test fetching file metadata (GET /api/v4/files/) + */ + public function testShow(): void + { + // Unauth access not allowed + $response = $this->get("api/v4/files/1234"); + $response->assertStatus(401); + + $john = $this->getTestUser('john@kolab.org'); + $jack = $this->getTestUser('jack@kolab.org'); + $file = $this->getTestFile($john, 'teśt.txt', 'Teśt content'); + + // Non-existing file + $response = $this->actingAs($jack)->get("api/v4/files/1234"); + $response->assertStatus(404); + + // Unauthorized access + $response = $this->actingAs($jack)->get("api/v4/files/{$file->id}"); + $response->assertStatus(403); + + // Get file metadata + $response = $this->actingAs($john)->get("api/v4/files/{$file->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame($file->id, $json['id']); + $this->assertSame($file->getProperty('mimetype'), $json['mimetype']); + $this->assertSame((int) $file->getProperty('size'), $json['size']); + $this->assertSame($file->getProperty('name'), $json['name']); + $this->assertSame(true, $json['isOwner']); + $this->assertSame(true, $json['canUpdate']); + $this->assertSame(true, $json['canDelete']); + + // Get file content + $response = $this->actingAs($john)->get("api/v4/files/{$file->id}?download=1"); + $response->assertStatus(200) + ->assertHeader('Content-Disposition', "attachment; filename=test.txt; filename*=utf-8''te%C5%9Bt.txt") + ->assertHeader('Content-Length', $file->getProperty('size')) + ->assertHeader('Content-Type', $file->getProperty('mimetype') . '; charset=UTF-8'); + + $this->assertSame('Teśt content', $response->streamedContent()); + + // Test acting as a user with file permissions + $permission = $this->getTestFilePermission($file, $jack, 'r'); + $response = $this->actingAs($jack)->get("api/v4/files/{$permission->key}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame($file->id, $json['id']); + $this->assertSame(false, $json['isOwner']); + $this->assertSame(false, $json['canUpdate']); + $this->assertSame(false, $json['canDelete']); + } + + /** + * Test creating files (POST /api/v4/files) + */ + public function testStore(): void + { + // Unauth access not allowed + $response = $this->post("api/v4/files"); + $response->assertStatus(401); + + $john = $this->getTestUser('john@kolab.org'); + + // Test input validation + $response = $this->sendRawBody($john, 'POST', "api/v4/files", [], ''); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertSame(["The name field is required."], $json['errors']['name']); + + $response = $this->sendRawBody($john, 'POST', "api/v4/files?name=*.txt", [], ''); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertSame(["The file name is invalid."], $json['errors']['name']); + + // Create a file - the simple method + $body = "test content"; + $headers = []; + $response = $this->sendRawBody($john, 'POST', "api/v4/files?name=test.txt", $headers, $body); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertSame("File created successfully.", $json['message']); + $this->assertSame('text/plain', $json['mimetype']); + $this->assertSame(strlen($body), $json['size']); + $this->assertSame('test.txt', $json['name']); + + $file = Item::find($json['id']); + + $this->assertSame(Item::TYPE_FILE, $file->type); + $this->assertSame($json['mimetype'], $file->getProperty('mimetype')); + $this->assertSame($json['size'], (int) $file->getProperty('size')); + $this->assertSame($json['name'], $file->getProperty('name')); + $this->assertSame($body, $this->getTestFileContent($file)); + } + + /** + * Test creating files - resumable (POST /api/v4/files) + */ + public function testStoreResumable(): void + { + $john = $this->getTestUser('john@kolab.org'); + + $response = $this->actingAs($john)->post("api/v4/files?name=test2.txt&media=resumable&size=400"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(0, $json['uploaded']); + $this->assertMatchesRegularExpression( + '/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/', + $json['uploadId'] + ); + + $uploadId = $json['uploadId']; + $size = 0; + $fileContent = ''; + + for ($x = 0; $x <= 2; $x++) { + $body = str_repeat("$x", 100); + $response = $this->sendRawBody(null, 'POST', "api/v4/files/uploads/{$uploadId}?from={$size}", [], $body); + $response->assertStatus(200); + + $json = $response->json(); + $size += 100; + $fileContent .= $body; + + $this->assertSame($size, $json['uploaded']); + $this->assertSame($uploadId, $json['uploadId']); + } + + $body = str_repeat("$x", 100); + $response = $this->sendRawBody(null, 'POST', "api/v4/files/uploads/{$uploadId}?from={$size}", [], $body); + $response->assertStatus(200); + + $json = $response->json(); + $size += 100; + $fileContent .= $body; + + $this->assertSame('success', $json['status']); + // $this->assertSame("", $json['message']); + $this->assertSame('text/plain', $json['mimetype']); + $this->assertSame($size, $json['size']); + $this->assertSame('test2.txt', $json['name']); + + $file = Item::find($json['id']); + + $this->assertSame(Item::TYPE_FILE, $file->type); + $this->assertSame($json['mimetype'], $file->getProperty('mimetype')); + $this->assertSame($json['size'], (int) $file->getProperty('size')); + $this->assertSame($json['name'], $file->getProperty('name')); + $this->assertSame($fileContent, $this->getTestFileContent($file)); + } + + /** + * Test updating files (PUT /api/v4/files/) + */ + public function testUpdate(): void + { + // Unauth access not allowed + $response = $this->put("api/v4/files/1234"); + $response->assertStatus(401); + + $john = $this->getTestUser('john@kolab.org'); + $jack = $this->getTestUser('jack@kolab.org'); + $file = $this->getTestFile($john, 'teśt.txt', 'Teśt content'); + + // Non-existing file + $response = $this->actingAs($john)->put("api/v4/files/1234", []); + $response->assertStatus(404); + + // Unauthorized access + $response = $this->actingAs($jack)->put("api/v4/files/{$file->id}", []); + $response->assertStatus(403); + + // Test name validation + $post = ['name' => 'test/test.txt']; + $response = $this->actingAs($john)->put("api/v4/files/{$file->id}", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertSame(["The file name is invalid."], $json['errors']['name']); + + $post = ['name' => 'new name.txt', 'media' => 'test']; + $response = $this->actingAs($john)->put("api/v4/files/{$file->id}", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertSame("The specified media is invalid.", $json['errors']['media']); + + // Rename a file + $post = ['name' => 'new namś.txt']; + $response = $this->actingAs($john)->put("api/v4/files/{$file->id}", $post); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertSame("File updated successfully.", $json['message']); + + $file->refresh(); + + $this->assertSame($post['name'], $file->getProperty('name')); + + // Update file content + $body = "Test1\nTest2"; + $response = $this->sendRawBody($john, 'PUT', "api/v4/files/{$file->id}?media=content", [], $body); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertSame("File updated successfully.", $json['message']); + + $file->refresh(); + + $this->assertSame($body, $this->getTestFileContent($file)); + $this->assertSame('text/plain', $file->getProperty('mimetype')); + $this->assertSame(strlen($body), (int) $file->getProperty('size')); + + // TODO: Test acting as another user with file permissions + // TODO: Test media=resumable + } + + /** + * Create a test file. + * + * @param \App\User $user File owner + * @param string $name File name + * @param string|array $content File content + * @param array $props Extra file properties + * + * @return \App\Fs\Item + */ + protected function getTestFile(User $user, string $name, $content = [], $props = []): Item + { + $disk = LaravelStorage::disk('files'); + + $file = $user->fsItems()->create(['type' => Item::TYPE_FILE]); + $size = 0; + $mimetype = ''; + + if (is_array($content) && empty($content)) { + // do nothing, we don't need the body here + } else { + foreach ((array) $content as $idx => $chunk) { + $chunkId = \App\Utils::uuidStr(); + $path = Storage::chunkLocation($chunkId, $file); + + $disk->write($path, $chunk); + + if (!$size) { + $mimetype = $disk->mimeType($path); + } + + $size += strlen($chunk); + + $file->chunks()->create([ + 'chunk_id' => $chunkId, + 'sequence' => $idx, + 'size' => strlen($chunk), + ]); + } + } + + $properties = [ + 'name' => $name, + 'size' => $size, + 'mimetype' => $mimetype ?: 'application/octet-stream', + ]; + + $file->setProperties($props + $properties); + + return $file; + } + + /** + * Get contents of a test file. + * + * @param \App\Fs\Item $file File record + * + * @return string + */ + protected function getTestFileContent(Item $file): string + { + $content = ''; + + $file->chunks()->orderBy('sequence')->get()->each(function ($chunk) use ($file, &$content) { + $disk = LaravelStorage::disk('files'); + $path = Storage::chunkLocation($chunk->chunk_id, $file); + + $content .= $disk->read($path); + }); + + return $content; + } + + /** + * Create a test file permission. + * + * @param \App\Fs\Item $file The file + * @param \App\User $user File owner + * @param string $permission File permission + * + * @return \App\Fs\Property File permission property + */ + protected function getTestFilePermission(Item $file, User $user, string $permission): Property + { + $shareId = 'share-' . \App\Utils::uuidStr(); + + return $file->properties()->create([ + 'key' => $shareId, + 'value' => "{$user->email}:{$permission}", + ]); + } + + /** + * Invoke a HTTP request with a custom raw body + * + * @param ?\App\User $user Authenticated user + * @param string $method Request method (POST, PUT) + * @param string $uri Request URL + * @param array $headers Request headers + * @param string $content Raw body content + * + * @return \Illuminate\Testing\TestResponse HTTP Response object + */ + protected function sendRawBody(?User $user, string $method, string $uri, array $headers, string $content) + { + $headers['Content-Length'] = strlen($content); + + $server = $this->transformHeadersToServerVars($headers); + $cookies = $this->prepareCookiesForRequest(); + + if ($user) { + return $this->actingAs($user)->call($method, $uri, [], $cookies, [], $server, $content); + } else { + // TODO: Make sure this does not use "acting user" set earlier + return $this->call($method, $uri, [], $cookies, [], $server, $content); + } + } +}