diff --git a/app/controllers/api/messaging.php b/app/controllers/api/messaging.php index 50128cbb00..e700e58b4e 100644 --- a/app/controllers/api/messaging.php +++ b/app/controllers/api/messaging.php @@ -1,5 +1,6 @@ add(new \DateInterval('P15D'))->format('U'); + } else { + $expiry = (new \DateTime())->add(new \DateInterval('P15D'))->format('U'); + } + + $encoder = new JWT(App::getEnv('_APP_OPENSSL_KEY_V1')); + + $jwt = $encoder->encode([ + 'iat' => \time(), + 'exp' => $expiry, + 'bucketId' => $bucket->getId(), + 'fileId' => $file->getId(), + 'projectId' => $project->getId(), + ]); + $image = [ 'bucketId' => $bucket->getId(), 'fileId' => $file->getId(), - 'url' => "{$protocol}://{$host}/v1/storage/buckets/{$bucket->getId()}/files/{$file->getId()}/view?project={$project->getId()}", + 'url' => "{$protocol}://{$host}/v1/storage/buckets/{$bucket->getId()}/files/{$file->getId()}/push?project={$project->getId()}&jwt={$jwt}", ]; } @@ -3774,10 +3792,27 @@ App::patch('/v1/messaging/messages/push/:messageId') throw new Exception(Exception::STORAGE_FILE_NOT_PUBLIC); } + $scheduleTime = $currentScheduledAt ?? $scheduledAt; + if (!\is_null($scheduleTime)) { + $expiry = (new \DateTime($scheduleTime))->add(new \DateInterval('P15D'))->format('U'); + } else { + $expiry = (new \DateTime())->add(new \DateInterval('P15D'))->format('U'); + } + + $encoder = new JWT(App::getEnv('_APP_OPENSSL_KEY_V1')); + + $jwt = $encoder->encode([ + 'iat' => \time(), + 'exp' => $expiry, + 'bucketId' => $bucket->getId(), + 'fileId' => $file->getId(), + 'projectId' => $project->getId(), + ]); + $pushData['image'] = [ 'bucketId' => $bucket->getId(), 'fileId' => $file->getId(), - 'url' => "{$protocol}://{$host}/v1/storage/buckets/{$bucket->getId()}/files/{$file->getId()}/view?project={$project->getId()}" + 'url' => "{$protocol}://{$host}/v1/storage/buckets/{$bucket->getId()}/files/{$file->getId()}/push?project={$project->getId()}&jwt={$jwt}" ]; } diff --git a/app/controllers/api/storage.php b/app/controllers/api/storage.php index 0574f9c57b..38cc9ad1e4 100644 --- a/app/controllers/api/storage.php +++ b/app/controllers/api/storage.php @@ -1,5 +1,7 @@ inject('dbForProject') ->inject('mode') ->inject('deviceForFiles') - ->action(function (string $bucketId, string $fileId, Response $response, Request $request, Database $dbForProject, string $mode, Device $deviceForFiles) { + ->action(function (string $bucketId, string $fileId, string $jwt, Response $response, Request $request, Database $dbForProject, string $mode, Device $deviceForFiles) { $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); $isAPIKey = Auth::isAppUser(Authorization::getRoles()); @@ -1306,6 +1308,172 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/view') } }); +App::get('/v1/storage/buckets/:bucketId/files/:fileId/push') + ->desc('Get file for push notification') + ->groups(['api', 'storage']) + ->label('scope', 'public') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', '*/*') + ->label('sdk.methodType', 'location') + ->param('bucketId', '', new UID(), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).') + ->param('fileId', '', new UID(), 'File ID.') + ->param('jwt', '', new Text(2048, 0), 'JSON Web Token to validate', true) + ->inject('response') + ->inject('request') + ->inject('dbForProject') + ->inject('mode') + ->inject('project') + ->inject('deviceForFiles') + ->action(function (string $bucketId, string $fileId, string $jwt, Response $response, Request $request, Database $dbForProject, Document $project, string $mode, Device $deviceForFiles) { + $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); + + $decoder = new JWT(App::getEnv('_APP_OPENSSL_KEY_V1')); + + try { + $decoded = $decoder->decode($jwt); + } catch (JWTException) { + throw new Exception(Exception::USER_UNAUTHORIZED); + } + + if ( + $decoded['projectId'] !== $project->getId() || + $decoded['bucketId'] !== $bucketId || + $decoded['fileId'] !== $fileId || + $decoded['exp'] < \time() + ) { + throw new Exception(Exception::USER_UNAUTHORIZED); + } + + $isAPIKey = Auth::isAppUser(Authorization::getRoles()); + $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + + if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { + throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); + } + + $fileSecurity = $bucket->getAttribute('fileSecurity', false); + $validator = new Authorization(Database::PERMISSION_READ); + $valid = $validator->isValid($bucket->getRead()); + if (!$fileSecurity && !$valid) { + throw new Exception(Exception::USER_UNAUTHORIZED); + } + + if ($fileSecurity && !$valid) { + $file = $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId); + } else { + $file = Authorization::skip(fn () => $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId)); + } + + if ($file->isEmpty()) { + throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); + } + + $mimes = Config::getParam('storage-mimes'); + + $path = $file->getAttribute('path', ''); + + if (!$deviceForFiles->exists($path)) { + throw new Exception(Exception::STORAGE_FILE_NOT_FOUND, 'File not found in ' . $path); + } + + $contentType = 'text/plain'; + + if (\in_array($file->getAttribute('mimeType'), $mimes)) { + $contentType = $file->getAttribute('mimeType'); + } + + $response + ->setContentType($contentType) + ->addHeader('Content-Security-Policy', 'script-src none;') + ->addHeader('X-Content-Type-Options', 'nosniff') + ->addHeader('Content-Disposition', 'inline; filename="' . $file->getAttribute('name', '') . '"') + ->addHeader('Expires', \date('D, d M Y H:i:s', \time() + (60 * 60 * 24 * 45)) . ' GMT') // 45 days cache + ->addHeader('X-Peak', \memory_get_peak_usage()) + ; + + $size = $file->getAttribute('sizeOriginal', 0); + + $rangeHeader = $request->getHeader('range'); + if (!empty($rangeHeader)) { + $start = $request->getRangeStart(); + $end = $request->getRangeEnd(); + $unit = $request->getRangeUnit(); + + if ($end === null) { + $end = min(($start + 2000000 - 1), ($size - 1)); + } + + if ($unit != 'bytes' || $start >= $end || $end >= $size) { + throw new Exception(Exception::STORAGE_INVALID_RANGE); + } + + $response + ->addHeader('Accept-Ranges', 'bytes') + ->addHeader('Content-Range', "bytes $start-$end/$size") + ->addHeader('Content-Length', $end - $start + 1) + ->setStatusCode(Response::STATUS_CODE_PARTIALCONTENT); + } + + $source = ''; + if (!empty($file->getAttribute('openSSLCipher'))) { // Decrypt + $source = $deviceForFiles->read($path); + $source = OpenSSL::decrypt( + $source, + $file->getAttribute('openSSLCipher'), + App::getEnv('_APP_OPENSSL_KEY_V' . $file->getAttribute('openSSLVersion')), + 0, + \hex2bin($file->getAttribute('openSSLIV')), + \hex2bin($file->getAttribute('openSSLTag')) + ); + } + + switch ($file->getAttribute('algorithm', Compression::NONE)) { + case Compression::ZSTD: + if (empty($source)) { + $source = $deviceForFiles->read($path); + } + $compressor = new Zstd(); + $source = $compressor->decompress($source); + break; + case Compression::GZIP: + if (empty($source)) { + $source = $deviceForFiles->read($path); + } + $compressor = new GZIP(); + $source = $compressor->decompress($source); + break; + } + + if (!empty($source)) { + if (!empty($rangeHeader)) { + $response->send(substr($source, $start, ($end - $start + 1))); + } + $response->send($source); + return; + } + + if (!empty($rangeHeader)) { + $response->send($deviceForFiles->read($path, $start, ($end - $start + 1))); + return; + } + + $size = $deviceForFiles->getFileSize($path); + if ($size > APP_STORAGE_READ_BUFFER) { + for ($i = 0; $i < ceil($size / MAX_OUTPUT_CHUNK_SIZE); $i++) { + $response->chunk( + $deviceForFiles->read( + $path, + ($i * MAX_OUTPUT_CHUNK_SIZE), + min(MAX_OUTPUT_CHUNK_SIZE, $size - ($i * MAX_OUTPUT_CHUNK_SIZE)) + ), + (($i + 1) * MAX_OUTPUT_CHUNK_SIZE) >= $size + ); + } + } else { + $response->send($deviceForFiles->read($path)); + } + }); + App::put('/v1/storage/buckets/:bucketId/files/:fileId') ->alias('/v1/storage/files/:fileId', ['bucketId' => 'default']) ->desc('Update file')